惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

爱范儿
爱范儿
Y
Y Combinator Blog
博客园 - Franky
D
Docker
B
Blog RSS Feed
M
MIT News - Artificial intelligence
雷峰网
雷峰网
博客园 - 司徒正美
人人都是产品经理
人人都是产品经理
宝玉的分享
宝玉的分享
S
SegmentFault 最新的问题
GbyAI
GbyAI
Recent Announcements
Recent Announcements
Martin Fowler
Martin Fowler
H
Hackread – Cybersecurity News, Data Breaches, AI and More
MyScale Blog
MyScale Blog
B
Blog
H
Help Net Security
Microsoft Security Blog
Microsoft Security Blog
WordPress大学
WordPress大学
Vercel News
Vercel News
The Cloudflare Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Google DeepMind News
Google DeepMind News

洛屿的小站

STM32F407 IAR环境下uC/OS-III移植完整指南 STM32 UCOS3+HAL库集成中的FPU HardFault问题 一种简单的卡尔曼滤波器设计 Cadence - 应律而动,落指成音 Zephyr - 聆风之息,为你而奏 不改一行插件代码,实现消息优先级与阻断 洛玖定时任务系统 在 butterfly 主题中添加首页点集动画(基于p2line项目) 你好,2026 stm32f4xx-ads1256驱动 stm32f4xx-ad9854并行驱动 主动式网站状态监测实现及其应用 右键菜单加入用Trae打开文件和文件夹 三角洲行动ID映射表 洛玖SDK说明 为网页文章开头添加原文连接 Hexo-Butterfly主题在主页添加GitHub贡献日历 Proteus中555定时器仿真问题 装饰器 洛玖开发日记 STS3032舵机获取力矩输出 kotlin网页前后端那些事 mspm0g3507-ad9850 奇怪的bug Paddle模型转PaddleLite 人工智能考核 构建一个yolov3网络 yolo和paddle模型常见输出参数 PaddleDetection 随便写的一些东西
脉冲线性加减速曲线的DDA实现
洛屿 · 2026-08-10 · via 洛屿的小站

从一个简单的问题说起

假设我要让电机从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画线算法的推广,迭代收敛是数值分析的基本套路,二分搜索更是教科书级的经典。但把它们组合在一起,解决一个具体的工程问题,就需要对每一个细节都有深入的理解。