脉冲线性加减速曲线的DDA实现
从一个简单的问题说起
假设我要让电机从1000Hz加速到2000Hz,分3步完成。频率总变化量是1000Hz,除以3步,得到每步变化333Hz。
1 | |
最终频率停在了1999Hz,而不是目标2000Hz。差了1Hz。
这1Hz的误差,就是整数除法截断带来的。在嵌入式MCU上,我们不能用浮点数来计算频率——定时器要求频率是整数,浮点运算又慢,而且浮点截断误差会随着脉冲数累积。那怎么办?
答案是:用DDA算法,把这1Hz的"余数"均匀地分散到每一步中去。
remainderStep:那个让人困惑的变量
DDA算法的核心在于三个变量:
1 | |
integerStep很好理解,就是每步"必定"增加的频率。但remainderStep呢?它到底是什么?为什么要存在?
我花了很长时间才真正理解这个问题。最初我以为它只是一个简单的补丁——余数嘛,最后加一下就好了。但实际的实现远比这精妙:
1 | |
每一步都累加remainderStep,当累加器"满"了(超过steps),就额外补偿1Hz,然后累加器减去steps。
这看起来很反直觉——steps是步数,remainderStep是频率余数,它们的单位不同,怎么能直接比较和相减?
相位累加器的视角
后来我意识到,不能用"频率"的视角来看这段代码,而要用"相位"的视角。
DDA本质上是一个相位累加器。想象一个时钟的表盘,表盘上有steps个刻度。每走一步,指针前进remainderStep个刻度。当指针转满一整圈(超过steps个刻度),就触发一次1Hz的频率补偿。
用数学语言来说:
- 每步的物理相位增量 =
remainderStep / steps(一个无量纲的分数) - 累加器每步加的是这个分数的"分子"
remainderStep - 满溢阈值是这个分数的"分母"
steps - 每次溢出,意味着相位走满了"1圈"
这样理解之后,remainderAcc -= steps就不再是"减掉一个不同单位的量",而是标准的取模操作——把累加器拉回到 [0, steps-1] 的有效范围内。
一个具体的例子
假设 freqDiff = 2000,steps = 3:
1 | |
执行过程:
1 | |
总补偿次数 = 2次,恰好等于remainderStep。而且补偿点在时间轴上均匀分布——第2、3个脉冲各补偿一次,而不是集中在最后一个脉冲。
这就是DDA的精妙之处:它用整数运算,实现了浮点级别的精确频率线性插值,而且保证误差不超过1Hz。
为什么每步加的是remainderStep而不是1?
这是另一个让我困惑了很久的问题。如果每步只加1:
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颗,均匀分配。完美。
0Hz起步的特殊处理
代码中有一个看似不起眼的细节:
1 | |
当起始频率为0Hz时,如果integerStep为0(意味着频率变化量小于步数),第一个脉冲的频率仍然是0Hz——而0Hz意味着"没有脉冲",电机不会动。这个初始化把累加器预设为一个"快要溢出"的值,让第一个脉冲立即推进到正频率,避免了死锁。
这种边界处理,不看代码很难想到,看了代码才能理解设计者的用心。
步数收敛:从时间到脉冲的映射
DDA解决了"如何均匀分配频率变化"的问题,但还有一个前置问题:给定加减速时间,应该用多少步来完成?
PlsrProfileGetTransitionSteps函数用了一个迭代收敛的方法:
- 用连续模型估算初始步数:
expectedPulses = (startFreq + endFreq) * rampTimeMs / 2000 - 用DDA模拟计算这组步数下的实际耗时
- 按时间比例修正步数:
adjustedSteps = steps * targetTime / actualTime - 重复2-3步,直到步数收敛(最多迭代4次)
这个方法的巧妙之处在于:它不需要解析求解DDA的精确步数(那几乎不可能),而是用"模拟+反馈"的方式逐步逼近目标时间。4次迭代对于工程精度来说已经足够。
三角形曲线:当资源不够时
如果总脉冲数不足以完成完整的"加速-匀速-减速"梯形曲线,算法会自动降级为三角形曲线——没有匀速段,只有加速和减速。
PlsrProfileFindTriangleFreq用二分搜索找到三角形曲线的峰值频率:在可用脉冲数的约束下,找到一个最高的频率,使得加速段和减速段的总步数恰好等于可用步数。
这里有一个细节:二分搜索的上下界不是随便选的。如果是加速场景(峰值频率高于起止频率),下界取起止频率中的较大者,上界取目标频率;如果是减速场景,反过来。这保证了搜索空间的合理性。
最后的状态机
整个曲线发生器的状态机很简单:ACCEL → CRUISE → DECEL → COMPLETE。每个状态推进一步,更新当前频率,然后检查是否应该切换到下一个状态。
PlsrProfileAdvance函数是对外的接口,每次调用输出一个频率值。调用者只需要按这个频率设置定时器,就能产生平滑的加减速脉冲序列。
写在最后
这段代码不长,但每一个函数、每一个变量都有它存在的理由。DDA算法用最朴素的整数运算,实现了精确的频率插值;迭代收敛方法用"模拟+反馈"绕过了不可能的解析求解;三角形曲线检测用二分搜索在约束空间里找到了最优解。
最让我感慨的是,这些算法都不是什么新发明——DDA是上世纪60年代Bresenham画线算法的推广,迭代收敛是数值分析的基本套路,二分搜索更是教科书级的经典。但把它们组合在一起,解决一个具体的工程问题,就需要对每一个细节都有深入的理解。

