【问题标题】:Endless sine generation in CC语言中的无穷正弦生成
【发布时间】:2021-12-12 04:39:30
【问题描述】:

我正在开展一个项目,该项目将计算正弦波作为控制回路的输入。

正弦波的频率为 280 Hz,控制回路每 30 µs 运行一次,所有内容都用 C 语言编写,用于Arm Cortex-M7

目前我们只是在做:

double time;
void control_loop() {
    time += 30e-6;
    double sine = sin(2 * M_PI * 280 * time);
    ...
}

出现两个问题/疑问:

  1. 长时间运行,time变大。突然间,正弦函数的计算时间急剧增加(见图)。为什么是这样?这些功能通常是如何实现的?有没有办法避免这种情况(没有明显的精度损失),因为速度对我们来说是一个重要因素?我们正在使用 math.h(Arm GCC)中的 sin。
  2. 我一般如何处理时间?长时间运行,变量time必然会达到双精度的极限。即使使用计数器time = counter++ * 30e-6; 也只能改善这一点,但并不能解决它。由于我肯定不是第一个想长期生成正弦波的人,所以一定有一些想法/论文/...关于如何快速准确地实现这一点。

【问题讨论】:

标签: c performance time precision trigonometry


【解决方案1】:

引人入胜的线程。 Walter Mitty's answer 中提到的 Minsky 算法让我想起了在 Electronics & Wireless World 上发表并保留的画圆的方法。 (信用:https://www.electronicsworld.co.uk/magazines/)。我把它贴在这里是为了感兴趣。

但是,对于我自己的类似项目(用于音频合成),我使用查找表,其中包含足够多的点,线性插值足够准确(做数学!)

【讨论】:

  • 这是一种计算y = sqrt(1 - x^2)(即x均匀采样)而不是y = sin(t)(即角度均匀采样)的算法。
【解决方案2】:

我想直接解决您代码中的嵌入式编程问题 - @0___________'s answer 是在微控制器上执行此操作的正确方法,我不会重蹈覆辙。

  • 表示时间的变量从不是浮点数。如果您的增量不是 2 的幂,则错误总是会累积。即使是这样,最终您的增量将小于最小增量并且计时器将停止。始终使用整数表示时间。您可以选择一个足够大的整数来忽略翻转 - 表示毫秒的无符号 32 位整数需要 50 天才能翻转,而无符号 64 位整数需要超过 5 亿年。
  • 在您不关心信号相位的情况下生成任何周期性信号不需要时间变量。相反,您可以保留一个在周期结束时重置为 0 的内部计数器。 (当您将 DMA 与查找表一起使用时,这正是您所做的 - 计数器是 DMA 控制器的下一次读取指针。)
  • 每当您在微控制器中使用超越函数(例如正弦)时,您的第一个想法应该是“我可以为此使用查找表吗?”您无法在 4 GHz+ 多核处理器上使用现代操作系统来优化您的负载。您经常处理一个单线程,该线程将等待您的 200 MHz 微控制器使 FPU 脱离待机状态并执行近似算法。超越函数的成本很高。 LUTs 也有一定的成本,但如果您经常使用该功能,那么您很有可能会更喜欢 LUT 的权衡取舍。

【讨论】:

    【解决方案3】:

    有一种替代方法可以计算一系列正弦(和余弦)值,这些值增加了一些非常小的角度。它本质上是计算圆的 X 和 Y 坐标,然后将 Y 值除以某个常数得到正弦,再将 X 值除以相同的常数得到余弦。

    如果您满足于生成“非常圆的椭圆”,则可以使用以下技巧,该技巧归因于 1960 年代的Marvin Minsky。它比计算正弦和余弦要快得多,尽管它在序列中引入了一个非常小的错误。这是 Hakmem 文档第 149 项的摘录。概述了明斯基圆算法。


    第 149 项(明斯基):循环算法 这是在点绘图显示器上绘制几乎圆形的优雅方法:

    NEW X = OLD X - epsilon * OLD Y
    NEW Y = OLD Y + epsilon * NEW(!) X
    

    这会生成一个以原点为中心的非常圆的椭圆,其大小由初始点决定。 epsilon 决定了循环点的角速度,对偏心率有轻微的影响。如果 epsilon 是 2 的幂,那么我们甚至不需要乘法,更不用说平方根、正弦和余弦了! “圆”将非常稳定,因为点很快就会变成周期性的。

    当我试图在显示黑客中保存一个寄存器时,我错误地发明了圆形算法! Ben Gurley 只用了大约六到七条指令就完成了一个惊人的显示技巧,这真是一个奇迹。但它基本上是面向线的。我突然想到有曲线会很令人兴奋,我试图用最少的指令来获得曲线显示技巧。


    这里是 hakmem 的链接:http://inwap.com/pdp10/hbaker/hakmem/hacks.html

    【讨论】:

    • 这基本上是@YakovGalka 的答案,但是为了降低准确性,需要保存一个寄存器和一些指令。 ARM 处理器有很多寄存器,所以我认为不值得这样做,尤其是在每个全波只有大约 119 个样本的情况下。不过,这是一个很棒的技巧!
    • 也可以将其解释为 x''=-x 的 Leapfrog-Verlet 方法。
    • 是的,这是相同的算法。除了它有一个错误。除了这个错误原来是一个功能。这很整洁!
    【解决方案4】:

    我认为可以使用模数,因为sin() 是周期性的。

    那么你就不用担心这些问题了。

    double time = 0;
    long unsigned int timesteps = 0;
    double sine;
    void controll_loop()
    {
      timesteps++;
      time += 30e-6;
      if( time > 1 )
      {
        time -= 1;
      }
      sine = sin( 2 * M_PI * 280 * time );
      ...
    }
    

    【讨论】:

    • 您不能在浮点操作数上使用%。您可以改为调用fmod 库函数。但是,很可能sin() 已经在需要时在内部执行此操作,因此它只需要处理角度在[-pi,pi] 中的情况。 (它可能会检查角度是否已经在该范围内并在这种情况下跳过 fmod,这可以解释为什么这种情况更快。)因此使用 fmod 是多余的,不太可能提高性能。
    • 但是如果性能下降意味着sin() 不能处理大数字或者?
    • sin 可以处理大数字;它通过在内部执行fmod 来处理它们。如果你先调用fmod,它会加快sin,但在调用fmod 时所花费的额外时间至少会和你节省的一样多。
    • 我编辑了我的答案以向您展示我的意思。 double time 值不会变大。所以它不会变得不精确。
    • 函数fmod 相当昂贵。随着 time 缓慢增长,您可以将其替换为 if (time> 1) time -=1;
    【解决方案5】:

    不要将正弦计算为时间的函数,而是维护一个正弦/余弦对并通过复数乘法将其推进。这不需要任何三角函数或查找表;只有四次乘法和偶尔的重新归一化:

    static const double a = 2 * M_PI * 280 * 30e-6;
    static const double dx = cos(a);
    static const double dy = sin(a);
    double x = 1, y = 0; // complex x + iy
    int counter = 0;
    
    void control_loop() {
        double xx = dx*x - dy*y;
        double yy = dx*y + dy*x;
        x = xx, y = yy;
    
        // renormalize once in a while, based on
        // https://www.gamedev.net/forums/topic.asp?topic_id=278849
        if((counter++ & 0xff) == 0) {
            double d = 1 - (x*x + y*y - 1)/2;
            x *= d, y *= d;
        }
    
        double sine = y; // this is your sine
    }
    

    如果需要,可以通过重新计算 dxdy 来调整频率。

    此外,这里的所有操作都可以很容易地在定点完成。


    理性

    由于@user3386109 points out below (+1)280 * 30e-6 = 21 / 2500 是一个有理数,因此正弦应该在 2500 个样本后循环正好。我们可以通过每 2500 次迭代(或 5000 或 10000 等)重置我们的生成器 (x=1,y=0) 来将此方法与他们的方法结合使用。这将消除重整化的需要,并消除任何长期相位误差。

    (从技术上讲,任何浮点数都是二元有理数。但是280 * 30e-6 没有二进制的精确表示。但是,通过按照建议重置生成器,我们将得到预期的精确周期性正弦。)


    说明

    有些人要求在 cmets 中解释为什么这样做。最简单的解释是使用angle sum trigonometric identities

    xx = cos((n+1)*a) = cos(n*a)*cos(a) - sin(n*a)*sin(a) = x*dx - y*dy
    yy = sin((n+1)*a) = sin(n*a)*cos(a) + cos(n*a)*sin(a) = y*dx + x*dy
    

    正确性通过归纳得出。

    根据Euler's formula,如果我们将这些正弦/余弦对视为复数,这本质上就是De Moivre's formula

    一种更有洞察力的方法可能是从几何角度来看它。 exp(ia) 的复数乘法相当于a 弧度的旋转。因此,通过反复乘以dx + idy = exp(ia),我们沿着单位圆递增地旋转我们的起点1 + 0i。再次根据欧拉公式,y 坐标是当前相位的正弦。

    标准化

    虽然阶段随着每次迭代继续前进,但由于舍入误差,x + iy 的幅度(又名范数)会偏离1。然而,我们有兴趣生成幅度正弦1,因此我们需要对x + iy 进行归一化以补偿数值漂移。当然,直接的方法是将其除以自己的规范:

    double d = 1/sqrt(x*x + y*y);
    x *= d, y *= d;
    

    这需要计算平方根的倒数。即使我们每 X 次迭代只标准化一次,避免它仍然很酷。幸运的是|x + iy| 已经接近1,因此我们只需要稍微修正一下就可以了。围绕1(一阶泰勒近似)扩展d 的表达式,我们得到代码中的公式:

    d = 1 - (x*x + y*y - 1)/2
    

    TODO:要完全理解这个近似值的有效性,需要证明它可以比累积误差更快地补偿舍入误差,从而确定需要应用它的频率。

    【讨论】:

    • 作为一个迂腐的注释,由于舍入误差,这很可能不会完全给出 OP 想要的频率,尽管它会非常接近。 (再说一遍,OP 的代码和大多数其他简单的解决方案都不会。)对于大多数目的,差异可能并不重要,或者会被其他相位误差源所淹没,例如时钟漂移。但是,如果 OP 想要从同一个源生成两个正弦波,其中一个的频率是另一个的有理倍数并且绝对没有长期相位漂移,那么他们可能需要一些额外的代码来确保这一点。
    • @RobAudenaerde:除了对复数和泰勒级数的基本了解外,别无其他。如果你能计算出sin——一定要去使用它。这种方法具有教育价值,因为它演示了如何避免三角函数(和平方根)。作为未来读者的练习,我建议消除 dx/dy 初始化中的 cos/sin,并解释为什么可以这样做。
    • @IlmariKaronen 该方法适用于稍有变化的情况,即使用最小公倍频计算 dx 和 dy,然后使用该算法将每个频率发生器向前步进(比如 n第一个生成器的步骤和第二个每次迭代的 m 个步骤)。而且,根据我的估计(使用 Mathematica),这个答案中的代码每秒只损失大约 2x10^-13 弧度,并且随着时间的推移,相位误差比普通的 sin 实现要小得多。
    • @RobAudenaerde:虽然我们通常对 Stack Overflow 上这类棘手的数学问题不屑一顾,但这是因为通常问题是从桌面或服务器机器的开发角度来看的,说过早优化之类的东西是根源万恶之源。嵌入式设备资源高度受限,此类方法在这些环境中很常见。
    • to fully understand the validity of this approximation one needs to prove that it compensates for round-off errors faster than they accumulate 随着时间的推移绘制错误,我发现使用双精度浮点数时,错误以每十亿次迭代 3.5E-10 的速率累积。如果我使用答案中描述的规范漂移修复,规范是稳定的。我什至可以通过仅每 2^24 次迭代应用一次来放松修复,这将错误减少到最大 6E-12,每次以锯齿模式将错误降至 0。
    【解决方案6】:

    我对现有的答案感到相当震惊。你发现的第一个问题很容易解决,当你解决第一个问题时,下一个问题就神奇地消失了。

    您需要对数学有基本的了解才能了解它是如何工作的。回想一下,sin(x+2pi) 在数学上只是sin(x)。当您的 sin(float) 实现切换到另一种算法时,您看到的时间会大幅增加,而您确实希望避免这种情况。

    请记住,float 只有 6 个有效数字。 100000.0f*M_PI+x 将这 6 位数字用于 100000.0f*M_PI,因此 x 没有任何剩余。

    因此,最简单的解决方案是自己跟踪x。在t=0,您将x 初始化为0.0f。每 30 个我们,您增加 x+= M_PI * 280 * 30e-06;。时间没有出现在这个公式中!最后,如果x>2*M_PI,则递减x-=2*M_PI;(因为sin(x)==sin(x-2*pi)

    您现在有一个x,它很好地保持在06.2834 的范围内,其中sin 速度很快,6 位精度都很有用。

    【讨论】:

    • 你在浪费符号位。您可以在x > M_PI 时减少 x,使其范围从-M_PIM_PI。让您获得额外的精确度。
    • 您错过了x 计算中的错误会以这种方式累积的事实。因此,经过足够长的时间后,您将不会以 x 的“正确”值计算 sin。 (当然,OP 也不是!)
    • @Kapil:你所说的“积累”到底是什么意思?您是否有理由相信舍入误差平均偏离零?这与其他答案有何不同?当然,30e-06 本身存在数值舍入误差,但物理时钟硬件本身可能更不精确。典型的晶体有 1E-5 误差(第 5 位),差一个数量级。
    • @MSalters 如果 n_1-n_2 可被 2500 整除,x=n*280*30e-6 的 sin(2pi x) 的值应与 n=n_1 和 n=n_2 的值相同。我不认为你的计算有这个属性。
    • 问题是,一些答案让您“震惊”,避免错误累积,同时比您建议的要快得多。
    【解决方案7】:

    使用查找表。您在与 Eugene Sh. 的讨论中的评论:

    与正弦频率的小偏差(如 280.1Hz)是可以的。

    在这种情况下,控制间隔为 30 µs,如果您有一个包含 119 个样本的表格,您会一遍又一遍地重复,您将得到一个 280.112 Hz 的正弦波。由于您有一个 12 位 DAC,如果您将其直接输出到 DAC,则只需要 119 * 2 = 238 个字节来存储它。如果您将其用作 cmets 中提到的进一步计算的输入,您可以根据需要将其存储为 floatdouble。在具有嵌入式静态 RAM 的 MCU 上,从内存加载最多只需要几个周期。

    【讨论】:

    • DMA,DMA - 无缓存。 DMA 忽略缓存
    • @0___________ 当然可以,但我的回答是假设正弦波将由定期调用的 control_loop() 函数生成,在这种情况下缓存可能很重要。
    • 这只是在uC编程方面缺乏OP经验,并试图暴力破解解决方案。这个想法没有意义
    • @0___________ 是的,但不是每个阅读此问题的人如果对解决类似问题感兴趣,都可能使用相同的 MCU。此外,如果您需要所有计时器和/或 DMA 通道用于其他用途,则计时器 + DMA 方法可能不起作用。因此,蛮力方式可能对某些人有用。
    • 为什么没有 21 个周期的查找表?采样频率不是很合适吗?
    【解决方案8】:

    其他人的基于模的概念的变体怎么样:

    int t = 0;
    int divisor = 1000000;
    void control_loop() {
        t += 30 * 280;
        if (t > divisor) t -= divisor;
        double sine = sin(2 * M_PI * t / (double)divisor));
        ...
    }
    

    它以整数计算模数,然后不会导致舍入误差。

    【讨论】:

      【解决方案9】:

      如何生成可爱的正弦。

      DAC 是 12 位的,因此您只有 4096 个级别。每个周期发送超过 4096 个样本是没有意义的。在现实生活中,生成高质量波形所需的样本要少得多。

      1. 使用查找表创建 C 文件(使用您的 PC)。将输出重定向到文件 (https://helpdeskgeek.com/how-to/redirect-output-from-command-line-to-text-file/)。
      #define STEP   ((2*M_PI) / 4096.0)
      
      int main(void)
      {
          double alpha = 0;
      
          printf("#include <stdint.h>\nconst uint16_t sine[4096] = {\n");
          for(int x = 0; x < 4096 / 16; x++)
          {
              for(int y = 0; y < 16; y++)
              {
                  printf("%d, ", (int)(4095 * (sin(alpha) + 1.0) / 2.0));
                  alpha += STEP;
              }
              printf("\n");
          }
          printf("};\n");
      }
      

      https://godbolt.org/z/e899d98oW

      1. 配置定时器每秒触发溢出4096*280=1146880次。设置定时器以生成 DAC 触发事件。对于 180MHz 定时器时钟,它并不精确,频率为 279.906449045Hz。如果您需要更高的精度,请更改样本数以匹配您的定时器频率或/和更改定时器时钟频率(H7 定时器可以运行到 480MHz)

      2. 将 DAC 配置为使用 DMA,并在触发事件时将值从步骤 1 中创建的查找表传输到 DAC。

      3. 使用示波器享受美丽的正弦波。请注意,您的微控制器内核根本不会被加载。您将拥有它用于其他任务。如果要更改周期,只需重新配置计时器。您可以根据需要每秒执行多次。要重新配置定时器,请使用定时器 DMA 突发模式 - 这将在更新事件时自动重新加载 PSC 和 ARR 寄存器,而不会干扰生成的波形。

      我知道这是高级 STM32 编程,需要寄存器级编程。我用它在我们的设备中生成复杂的波形。

      这是正确的做法。没有控制循环,没有计算,没有核心负载。

      【讨论】:

      • “每个周期发送超过 4096 个样本是没有意义的。” 如果您定期发送这些样本,情况就不完全正确了。如果您增加采样率,您仍然可以从输出中获得一些额外的质量,以便从一个级别到下一个级别之间的转换更接近它们的理想时间点。我不会将采样率完全与 DAC 分辨率联系起来,而是根据需要增加或减少它,以便使生成的频率更接近 280 Hz。也就是说,使用计时器 + DMA 的绝佳答案。
      • @G.Sliepen 不会有太大帮助。需要一个平滑波形的电容器和电阻器来形成一个 LP 滤波器。根据我的经验(设计了很多设备)4096 个样本足以完成这项任务)。顺便说一句,我确实在我的答案中写了它。更改样本数或定时器时钟频率:)
      • 感谢您的详细回答。如果我只想在 DAC 上输出正弦波,那将是完美的选择。但不幸的是,在我们的例子中,正弦只是我们系统的参考输入。参考输入和当前位置测量然后进入状态观察器,然后是一个控制器,它会输出 DAC 值(这应该使我们的受控对象跟随参考输入)。
      • @energetic 取决于您与控制器的对话方式,DMA 可能仍然有效。例如让 I2C 外设以与 DAC 外设相同的方式通过 DMA 访问 LUT。也就是说,如果你找不到一种方法来实现它,你总是可以用一个带有内部计数器的小函数替换 DMA,每次调用它时都会逐步遍历 LUT。更多的 CPU 时间,更少的开发时间,这就是权衡。
      • 您可以通过仅存储四分之一的查找表来获得,并根据您所在的象限,在 X 或 Y 方向上进行一点减法。这基本上就是 Commodore 64 中的浮点三角函数的作用
      【解决方案10】:

      如果您有几千字节的可用内存,则可以使用查找表完全消除此问题。

      采样周期为 30 µs,2500 个样本的总持续时间为 75 ms。这正好等于 280 Hz 下 21 个周期的持续时间。

      我还没有测试或编译以下代码,但它至少应该演示该方法:

      double sin2500() {
        static double *table = NULL;
        static int n = 2499;
        if (!table) {
          table = malloc(2500 * sizeof(double));
          for (int i=0; i<2500; i++) table[i] = sin(2 * M_PI * 280 * i * 30e-06);
        }
        n = (n+1) % 2500;
        return table[n];
      }
      

      【讨论】:

      • 嵌入式系统上没有 malloc。大多数嵌入式系统的编程标准都禁止动态分配。
      • 好的。然后直接在代码中嵌入表格。
      • 只需static double table[2500]; static int initme = 1; if (initme) { initme = 0; for (...) table[i] =...;} 初始化表并消除malloc
      【解决方案11】:

      函数可以改写为

      double n;
      void control_loop() {
          n += 1;
          double sine = sin(2 * M_PI * 280 * 30e-6 * n);
          ...
      }
      

      这与问题中的代码完全相同,但问题完全相同。但现在可以简化:

      280 * 30e-6 = 280 * 30 / 1000000 = 21 / 2500 = 8.4e-3
      

      这意味着当n 达到 2500 时,您已经输出了正好 21 个周期的正弦波。这意味着您可以将 n 设置回 0。 结果代码是:

      int n;
      void control_loop() {
          n += 1;
          if (n == 2500)
              n = 0;
          double sine = sin(2 * M_PI * 8.4e-3 * n);
          ...
      }
      

      只要您的代码可以运行 21 个周期而不会出现问题,它就会永远运行而不会出现问题。

      【讨论】:

        【解决方案12】:

        正如在某些 cmets 中所指出的,time 值会随着时间不断增长。这带来了两个问题:

        1. sin 函数可能必须在内部执行模数才能将内部值置于支持的范围内。
        2. 由于增加了更高的数字,时间分辨率会随着值的增加而变得越来越差。

        进行以下更改应该会提高性能:

        double time;
        void control_loop() {
            time += 30.0e-6;
            if((1.0/280.0) < time)
            {
                time -= 1.0/280.0;
            }
            
            double sine = sin(2 * M_PI * 280 * time);
            ...
        }
        

        请注意,一旦进行此更改,您将不再有时间变量。

        【讨论】:

        • 感谢您的意见。这确实提高了性能,但只是将问题推迟到以后。
        • @energetic 它不会推迟问题,因为time 不会无限制地增长。 time 将始终介于 [0, 1.0/280.0 + 30.0e-6) 之间,但它实际上变成了 phase 变量而不是 time 变量
        • 是的,这很清楚。我的意思是,随着时间的推移,每次减法都会产生一个小的量化误差。
        • @energetic 您是否在函数 control_loop() 之外累积结果,例如在 PC 上的模拟中。如果是这种情况,问题可能是程序需要越来越多的 RAM 来存储/显示结果。我过去在使用 LabView 或 Simulink 的时候就遇到过这个问题。
        猜你喜欢
        • 2018-10-26
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2012-07-11
        • 2021-12-16
        • 2016-02-21
        相关资源
        最近更新 更多