从一个简单的问题说起

假设我要让电机从1000Hz加速到2000Hz,分3步完成。频率总变化量是1000Hz,除以3步,得到每步变化333Hz。

1
2
3
脉冲1:1000 → 1333
脉冲2:1333 → 1666
脉冲3:1666 → 1999

最终频率停在了1999Hz,而不是目标2000Hz。差了1Hz。

这1Hz的误差,就是整数除法截断带来的。在嵌入式MCU上,我们不能用浮点数来计算频率——定时器要求频率是整数,浮点运算又慢,而且浮点截断误差会随着脉冲数累积。那怎么办?

答案是:用DDA算法,把这1Hz的"余数"均匀地分散到每一步中去。

remainderStep:那个让人困惑的变量

DDA算法的核心在于三个变量:

1
2
3
integerStep   = freqDiff / steps;    // 每步固定的频率变化量
remainderStep = freqDiff % steps; // 无法整除的余数
remainderAcc = 0; // 余数累加器

integerStep很好理解,就是每步"必定"增加的频率。但remainderStep呢?它到底是什么?为什么要存在?

我花了很长时间才真正理解这个问题。最初我以为它只是一个简单的补丁——余数嘛,最后加一下就好了。但实际的实现远比这精妙:

1
2
3
4
5
6
remainderAcc += remainderStep;
if (remainderAcc >= steps)
{
currentFreq++;
remainderAcc -= steps;
}

每一步都累加remainderStep,当累加器"满"了(超过steps),就额外补偿1Hz,然后累加器减去steps

这看起来很反直觉——steps是步数,remainderStep是频率余数,它们的单位不同,怎么能直接比较和相减?

相位累加器的视角

后来我意识到,不能用"频率"的视角来看这段代码,而要用"相位"的视角。

DDA本质上是一个相位累加器。想象一个时钟的表盘,表盘上有steps个刻度。每走一步,指针前进remainderStep个刻度。当指针转满一整圈(超过steps个刻度),就触发一次1Hz的频率补偿。

用数学语言来说:

  • 每步的物理相位增量 = remainderStep / steps(一个无量纲的分数)
  • 累加器每步加的是这个分数的"分子" remainderStep
  • 满溢阈值是这个分数的"分母" steps
  • 每次溢出,意味着相位走满了"1圈"

这样理解之后,remainderAcc -= steps就不再是"减掉一个不同单位的量",而是标准的取模操作——把累加器拉回到 [0, steps-1] 的有效范围内。

一个具体的例子

假设 freqDiff = 2000steps = 3

1
2
integerStep   = 2000 / 3 = 666
remainderStep = 2000 % 3 = 2

执行过程:

1
2
3
脉冲1:基础+666,Acc=0→2,不溢出,频率=1666
脉冲2:基础+666,Acc=2→4,溢出,Acc=1,补偿+1,频率=2333
脉冲3:基础+666,Acc=1→3,溢出,Acc=0,补偿+1,频率=3000

总补偿次数 = 2次,恰好等于remainderStep。而且补偿点在时间轴上均匀分布——第2、3个脉冲各补偿一次,而不是集中在最后一个脉冲。

这就是DDA的精妙之处:它用整数运算,实现了浮点级别的精确频率线性插值,而且保证误差不超过1Hz。

为什么每步加的是remainderStep而不是1?

这是另一个让我困惑了很久的问题。如果每步只加1:

1
2
3
脉冲1:Acc=1,不溢出
脉冲2:Acc=2,不溢出
脉冲3:Acc=3,溢出,补偿1次

总补偿只有1次,但需要补偿2次。频率偏差1Hz。

原因在于:remainderStep表示的是"在steps步内需要额外补偿的次数"。如果每步只加1,走满一圈只能触发1次补偿;但我们需要触发remainderStep次补偿。所以每步必须加remainderStep,才能保证在steps步内恰好补偿remainderStep次。

用分糖果的比喻:有3个小朋友(steps=3),要分2颗糖(remainderStep=2)。每个小朋友来的时候计数器+2,当计数器≥3时,这个小朋友多拿1颗糖,然后计数器-3。

1
2
3
小朋友1:Acc=2,不拿
小朋友2:Acc=4,拿糖,Acc=1
小朋友3:Acc=3,拿糖,Acc=0

第2、3个小朋友各拿1颗,均匀分配。完美。

0Hz起步的特殊处理

代码中有一个看似不起眼的细节:

1
2
3
4
5
if ((startFreq == 0u) && (endFreq != 0u)
&& (integerStep == 0u) && (remainderStep != 0u))
{
remainderAcc = steps - remainderStep;
}

当起始频率为0Hz时,如果integerStep为0(意味着频率变化量小于步数),第一个脉冲的频率仍然是0Hz——而0Hz意味着"没有脉冲",电机不会动。这个初始化把累加器预设为一个"快要溢出"的值,让第一个脉冲立即推进到正频率,避免了死锁。

这种边界处理,不看代码很难想到,看了代码才能理解设计者的用心。

步数收敛:从时间到脉冲的映射

DDA解决了"如何均匀分配频率变化"的问题,但还有一个前置问题:给定加减速时间,应该用多少步来完成?

PlsrProfileGetTransitionSteps函数用了一个迭代收敛的方法:

  1. 用连续模型估算初始步数:expectedPulses = (startFreq + endFreq) * rampTimeMs / 2000
  2. 用DDA模拟计算这组步数下的实际耗时
  3. 按时间比例修正步数:adjustedSteps = steps * targetTime / actualTime
  4. 重复2-3步,直到步数收敛(最多迭代4次)

这个方法的巧妙之处在于:它不需要解析求解DDA的精确步数(那几乎不可能),而是用"模拟+反馈"的方式逐步逼近目标时间。4次迭代对于工程精度来说已经足够。

三角形曲线:当资源不够时

如果总脉冲数不足以完成完整的"加速-匀速-减速"梯形曲线,算法会自动降级为三角形曲线——没有匀速段,只有加速和减速。

PlsrProfileFindTriangleFreq用二分搜索找到三角形曲线的峰值频率:在可用脉冲数的约束下,找到一个最高的频率,使得加速段和减速段的总步数恰好等于可用步数。

这里有一个细节:二分搜索的上下界不是随便选的。如果是加速场景(峰值频率高于起止频率),下界取起止频率中的较大者,上界取目标频率;如果是减速场景,反过来。这保证了搜索空间的合理性。

最后的状态机

整个曲线发生器的状态机很简单:ACCEL → CRUISE → DECEL → COMPLETE。每个状态推进一步,更新当前频率,然后检查是否应该切换到下一个状态。

PlsrProfileAdvance函数是对外的接口,每次调用输出一个频率值。调用者只需要按这个频率设置定时器,就能产生平滑的加减速脉冲序列。

写在最后

这段代码不长,但每一个函数、每一个变量都有它存在的理由。DDA算法用最朴素的整数运算,实现了精确的频率插值;迭代收敛方法用"模拟+反馈"绕过了不可能的解析求解;三角形曲线检测用二分搜索在约束空间里找到了最优解。

最让我感慨的是,这些算法都不是什么新发明——DDA是上世纪60年代Bresenham画线算法的推广,迭代收敛是数值分析的基本套路,二分搜索更是教科书级的经典。但把它们组合在一起,解决一个具体的工程问题,就需要对每一个细节都有深入的理解。