做stm32步进电机加减速控制这件事,我前后折腾了不少时间。最开始我写stm32f103的步进电机控制代码时,用的还是最朴素的梯形加减速,速度直线升、直线降,实测下来电机启动那一下咔哒一声,停止的时候又会往前冲一小段,甚至带着负载直接把丝杠顶过位。后来我把调速曲线换成S型加减速,情况立刻不一样了——电机起步安静了,停止也不再有那种“撞墙感”。这篇文章就把我在stm32f103上移植和调优S型加减速程序的过程完整拆给大家看,包括数学模型、源码分析、参数整定和实际踩坑,适合正在做步进电机位置控制、又不想用现成库函数糊弄过去的朋友。
1. S型加减速为什么比梯形曲线好用:两端冲击的由来
1.1 加速度突变才是丢步和异响的根源
咱们先别急着贴代码,先把“为什么需要S型加减速”这件事说透。梯形加减速大家应该都见过:速度从0开始线性升到目标速度,保持匀速,最后线性降到0。速度曲线是梯形,对应加速度曲线就是两个矩形——启动瞬间加速度直接从0跳到一个正值,停止瞬间再跳回0。
问题就出在这个“跳”上。步进电机是靠电磁转矩拉动转子走的,转矩和电流、相位差有关,而转子本身有转动惯量。当加速度瞬间突变时,机械结构受到的是阶跃式的冲击力,反映到声音上是“咔哒”一声,反映到运动上就是转子跟不上磁场变化。
如果负载惯量稍微大一点,丢步基本是必然的——转子在加速阶段被电机的旋转磁场“甩开”了,等磁场转完一整步,转子还停在半路。丢了这一步之后,后续所有位置都不对了,这就是开环步进控制里最头疼的问题。
梯形曲线虽然简单,但它的物理本质决定了它不照顾机械系统的感受。很多新手一开始会觉得“反正加减速有了就行”,实际去推较重的负载,或者把速度调快一点,马上就露馅。
1.2 S型曲线的本质:限制加加速度
S型加减速的思路,是把加速度曲线从“矩形”改成“梯形”甚至“三角形”,让加速度从0逐渐升到最大值,再逐渐回落到0。
在运动控制里有一个专门的物理量描述这个“加速度的变化率”,叫做加加速度,英文一般是jerk。jerk = da/dt,也就是加速度随时间的变化率。梯形加减速的jerk是无穷大,因为加速度在一瞬间就从0跳到a_max;S型加减速则把jerk限制在一个有限值,让加速度平滑变化。
打个比方:梯形加减速像是你开手动挡车,起步时直接一脚离合全松开,车猛地窜出去;S型加减速则像是慢慢抬离合、再逐步踩油门,车身平稳提速,乘客不会前仰后合。
对应到速度曲线上,梯形加减速的速度斜率是固定的,所以速度是一条直线;S型加减速的速度斜率(也就是加速度)在变化,所以速度曲线是一条先凹后凸的“S”形,因此叫S型曲线。
1.3 七段式还是简化式:步数不多时怎么取舍
完整的S型加减速,学术上叫七段式:
- 加加速段:加速度从0升到a_max
- 匀加速段:加速度保持a_max
- 减加速段:加速度从a_max降到0
- 匀速段:速度为v_max
- 加减速段:加速度从0降到-a_max
- 匀减速段:加速度保持-a_max
- 减减速段:加速度从-a_max回升到0
七段式最平滑,但实现起来复杂,每段都要计算持续时间和步数。实际工程里,如果运动距离很短,根本到不了v_max,匀加速段和匀速段都可能不存在,加速度曲线变成三角形,速度曲线只有一个“尖头”。
我在stm32f103上做的做法是:保留“加加速—匀加速—减加速”这个加速段的骨架,减速段对称处理,中间根据总步数判断是否需要匀速段。如果总步数太少,就降低峰值速度,让加速过程直接衔接到减速过程。
这样做的好处是代码结构清楚,每个段都是一个状态,中断服务函数里只需要做加减乘除,不需要解方程。后面第3章的源码分析,就是这个简化的实现。
2. stm32f103上S曲线的落地方案:从速度模型到定时器脉冲
2.1 步进脉冲的产生原理:为什么用定时器ARR控制频率
stm32f103控制步进电机,最常见的方式是用定时器输出脉冲给驱动器(比如A4988或者TB6600)。一个脉冲对应驱动器让电机走一步,方向由另外一根引脚控制。
定时器产生脉冲的机制很简单:把某个通用定时器配置为输出比较模式或PWM模式,当计数器计数到设定的ARR值时,发生更新事件,这时候输出引脚翻转一次。所以脉冲的频率就是:
f_pulse = TIM_CLK / (ARR + 1)
其中TIM_CLK是定时器经过预分频后的计数时钟。
从这个公式能看出来,改变ARR就能改变脉冲频率,也就是改变步进电机的转速。S型加减速做得再漂亮,最终都要落到“每一步更新一次ARR”这件事上。所以控制的核心逻辑就是:规划好每一步的速度,算出对应的ARR,然后在定时器中断里把新的ARR写进寄存器。
2.2 简化S型规划的数学模型:把加速过程拆成三段
我用的简化模型把加速度过程拆成三段,每段对应一个阶段状态:
- 第1段(加加速段):加速度按jerk从0线性增长到a_max。此时速度是时间的二次函数,位移是三次函数,这也是“三次S曲线”这个叫法的来源。
- 第2段(匀加速段):加速度保持a_max不变,速度线性增长。
- 第3段(减加速段):加速度从a_max线性下降回0。此时速度曲线开始变平缓,为进入匀速段做准备。
对应减速阶段则是完全对称的三段。
在离散的脉冲计数模型里,每产生一个脉冲,就对应一步。我们可以用当前步骤数作为“时间轴”,在每个中断里更新三个关键量:
加加速度阶段,每次中断: a = a + jerk v = v + a
匀加速阶段,每次中断: a = a_max v = v + a_max
减加速阶段,每次中断: a = a - jerk v = v + a
这里要注意量纲:v是“每秒脉冲数”,a是“每秒每秒脉冲数”,jerk是“每秒每秒每秒脉冲数”。由于我们每次更新是在一个脉冲周期之后,而脉冲周期本身就是1/v秒,所以严格说这不是连续积分,而是一种离散递推。好在脉冲频率远高于控制周期,工程上这个近似完全够用。
2.3 ARR值换算与速度上下限
每一次中断里拿到新的v之后,需要换算成ARR:
period_us = 1000000 / v arr = (uint32_t)(period_us * tick_mhz) - 1
tick_mhz是预分频之后定时器每微秒的计数次数。比如TIM_CLK = 1MHz,那么ARR就等于period_us减1。
这里有个容易忽略的边界:STM32F103通用定时器的ARR寄存器是16位的,最大值65535。也就是说,周期不能超过65535个计数。如果TIM_CLK = 1MHz,那么最低脉冲频率约为15Hz(实际15.26Hz)。如果你的S曲线起始速度设得太低,算出来的ARR超过65535,定时器就会溢出变成满频率,直接飞车。
反过来,ARR也不能太短,至少得大于0。实际中还要考虑中断开销:每个脉冲都触发一次中断,如果脉冲频率太高,中断函数执行时间可能超过脉冲周期,MCU就卡死了。我一般会把最高速度控制在10k~20k pulse/s左右,具体看你中断函数里干了多少活。如果用PWM+DMA,可以跑更高,但这里是纯中断方案。
2.4 中断里的浮点开销:一个实用的定点优化思路
stm32f103是M3内核,不带硬件浮点单元,软件浮点计算虽然能用,但在中断里每来一个脉冲都做浮点乘除,会比较浪费CPU时间。我实际做的时候,把速度规划用的变量全部转成定点,用“小数放大”的方式表示。
具体思路是:速度和加速度都乘以一个放大系数,比如1000或者4096,全部用int32类型做加减。需要进行ARR换算时,再一次性转换为浮点或直接做整数除法。
很多现成的S型加减速代码库都用了这个思路,因为中断频率高,省下几次浮点运算,CPU占用率能明显降下来。第3章示例代码里,我为了便于理解,先用float写清楚逻辑,实际移植时再按这个思路改成定点即可。
3. 源码拆解:S型加减速的核心函数与中断处理路径
3.1 运动参数结构体与状态初值
先把核心数据结构放出来,这是整个S型加减速程序的地基:
typedef struct { uint32_t total_steps; // 总步数 uint32_t current_step; // 当前已走步数 uint32_t cruise_steps; // 匀速段步数 float v; // 当前速度,单位:pulse/s float v_max; // 目标最大速度 float v_start; // 起始速度 float a; // 当前加速度,pulse/s^2 float a_max; // 最大加速度 float jerk; // 加加速度,pulse/s^3 uint8_t phase; // 当前阶段 uint8_t dir; // 方向 } RampState; static RampState ramp;初始化函数负责把所有字段清零,并把方向引脚设为正确的电平。这里一个建议是:方向切换不要和脉冲产生同时进行。驱动器在脉冲边沿时锁存方向引脚,如果刚好换向和脉冲冲突,可能产生一个方向错误的反向脉冲,导致一步错位。所以我的做法是,启动运动之前先设好方向,再延时几微秒,然后启动定时器。
3.2 加减速规划函数:如何决定每个阶段的切换点
规划函数的作用,是根据总步数算出匀加速段和匀速段各有多少步,确定每个阶段的切换边界。
先判断能否加速到v_max。在不考虑S型弯曲的情况下,加速段需要的步数大约是:
steps_acc = v_max * v_max / (2 * a_max)
如果总步数小于 2 * steps_acc,说明根本没有足够距离跑满速度,就需要降低峰值速度。简化处理可以设一个较低的target_v_max:
target_v_max = sqrt(a_max * total_steps)
这个公式是粗略估算,因为S型曲线的加速距离和梯形曲线不完全一样,但用来做一个初步限幅已经够了。工程上我还会留10%的余量,避免减速段末尾速度高于启动频率。
规划函数的核心代码逻辑如下:
void ramp_plan(uint32_t steps) { ramp.total_steps = steps; ramp.current_step = 0; ramp.v = v_start; ramp.a = 0; float v_target = ramp.v_max; float s_acc = v_target * v_target / (2 * ramp.a_max); if (steps < 2 * s_acc) { v_target = sqrtf(ramp.a_max * steps); } ramp.v_max_actual = v_target; // 计算加加速阶段时长相关量,这里简化为: // jerk 阶段需要 v_jerk = a_max * a_max / jerk // 剩余部分给匀加速段 ... }实际写的时候,需要根据jerk把加加速阶段消耗的“速度增量”算出来,再从总加速度需求中扣除,剩下的速度增量由匀加速段承担。如果速度增量全部由加加速阶段完成时就已经到顶了,那就没有匀加速段,此时jerk要适当调大,或者直接降低峰值速度,二选一。
3.3 定时器中断服务函数:每一个脉冲的诞生过程
定时器每产生一个脉冲,就进入一次更新中断。中断函数里做的第一件事是递推速度,第二件事是根据当前阶段判断是否要切换状态,第三件事是把新速度换算成ARR并写入寄存器。
void TIM_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (ramp.current_step >= ramp.total_steps) { TIM_Cmd(TIM3, DISABLE); return; } ramp.current_step++; motion_update_speed(&ramp); // 换算ARR uint32_t arr = calc_arr_from_speed(ramp.v); if (arr > 65535) arr = 65535; if (arr < 10) arr = 10; TIM_SetAutoreload(TIM3, arr); // 全部走完 if (ramp.current_step == ramp.total_steps) { TIM_Cmd(TIM3, DISABLE); motion_done_flag = 1; } } }motion_update_speed函数是整个S型算法的心脏,它要根据当前phase更新v和a:
void motion_update_speed(RampState *r) { switch (r->phase) { case PHASE_ACC_JERK: // 加加速 r->a += r->jerk; r->v += r->a; if (r->a >= r->a_max) { r->a = r->a_max; r->phase = PHASE_ACC_LINE; // 切换到匀加速 } break; case PHASE_ACC_LINE: // 匀加速 r->v += r->a; if (r->v >= r->v_acc_end) { r->phase = PHASE_ACC_DEJERK; // 切换到减加速 } break; case PHASE_ACC_DEJERK: // 减加速 r->a -= r->jerk; r->v += r->a; if (r->a <= 0) { r->a = 0; r->phase = PHASE_CRUISE; // 进入匀速 } break; case PHASE_CRUISE: r->v = r->v_max_actual; if (r->current_step >= r->decel_start_step) { r->phase = PHASE_DEC_JERK; } break; case PHASE_DEC_JERK: // 加减速 r->a -= r->jerk; r->v += r->a; if (r->a <= -r->a_max) { r->a = -r->a_max; r->phase = PHASE_DEC_LINE; } break; case PHASE_DEC_LINE: // 匀减速 r->v += r->a; if (r->v <= r->v_dec_end) { r->phase = PHASE_DEC_DEJERK; } break; case PHASE_DEC_DEJERK: // 减减速 r->a += r->jerk; r->v += r->a; if (r->a >= 0) { r->a = 0; r->phase = PHASE_IDLE; } break; default: break; } }这里每个阶段切换的触发条件,我写成由速度阈值(v_acc_end、v_dec_end)和步数边界(decel_start_step)共同控制。在完整工程里,decel_start_step = total_steps - steps需要减速的距离,这个值在规划函数里要提前算出来。
3.4 停止、方向切换与急停处理
很多人做到这里就停了,实际上停止逻辑同样影响设备寿命和定位精度。
正常停止:S型曲线自然走完,定时器关闭,current_step等于total_steps。这时候不要立刻松开使能,尤其带负载时,电机可能被外力推动导致位置偏移。我习惯在运动完成后,把使能引脚保持一段时间,确认位置稳定再释放。
方向切换:绝对不能在一个方向的运动结束后,立刻反向启动。因为机械结构有间隙,反向时齿轮或丝杠的间隙会产生空行程,这个空行程之前步进电机是感知不到的。好一点的驱动器可能没有太大影响,但如果有丝杠或减速器,建议换向之前等30~50ms,让机构完全停稳,再切换方向引脚。
急停:如果遇到需要紧急停止的场景(比如碰撞检测触发),我建议不要直接关闭定时器脉冲,而是先把S曲线置为快速减速状态,将加速度设置为一个较大值,让速度快速降到v_start,然后再关定时器。如果直接关脉冲,电机因为惯性会继续滑行一段距离,而且可能滑行时驱动器仍有电流使能,两者叠加容易丢步甚至堵转。
4. 参数整定与实测避坑记录
4.1 三个关键参数怎么选:给一组可以直接起步的数值
S型加减速里要调的参数主要是三个:v_max、a_max、jerk。很多人看到这些参数就发懵,其实可以按下面的思路来。
假设你的系统里,定时器预分频后计数时钟是1MHz(72MHz主频分72),步进驱动器细分设的是8细分,丝杠导程5mm,那么200步的电机配合8细分,一圈是1600个脉冲。
最大速度v_max:取决于你希望走多快。比如希望丝杠滑块速度最高30mm/s,一圈5mm,也就是每秒6转,1600脉冲/转,v_max = 6 * 1600 = 9600 pulse/s。这个数值对应ARR约104,中断频率9.6kHz,stm32f103在中断里做定点运算扛得住,但用float计算需要密切观察CPU占用率。
加速度a_max:一般取v_max的2~4倍每秒。也就是从0加速到v_max需要0.25~0.5秒。按上面的v_max=9600,a_max可以取20000 pulse/s²,对应达到最大速度需要约0.48秒,加速距离约2300步。这个数值对大多数42步进电机来说是安全的起步参考。
jerk:加速度a_max的变化率。一般取a_max的5~10倍每秒。a_max=20000,则jerk可以取100000~200000 pulse/s³。jerk越大,S型曲线越接近梯形,冲击越大;jerk越小,曲线越“圆润”,但会显著拉长加减速时间,导致同样的距离需要更长的过渡段。
我实测中比较顺手的组合是:
- v_max = 6000 pulse/s
- a_max = 12000 pulse/s²
- jerk = 60000 pulse/s³
这套参数在40电机配TB6600、24V供电的情况下,空载跑得很稳,带一个小平台负载也没什么异响。如果你用的是57步进电机或者明显大负载,要把a_max和jerk往下调,宁可慢一点,也不要让电机发出那种尖锐的“啸叫”。
4.2 丢步排查思路:从启动频率到减速收尾
丢步这个问题,最容易出现在两个地方:启动阶段的前几十个脉冲,以及减速阶段收尾时。
启动阶段丢步,多半是起始速度设置太高。很多S型曲线模型默认v_start=0,但实际步进电机在最开始的第一个脉冲,如果频率太高,转子还没有建立起足够的速度,就会跟不上。处理方法是给v_start一个非零的“爬行速度”,比如300~500 pulse/s,这个速度必须低于电机的启动频率上限,然后再从v_start开始S型加速。
减速收尾丢步,则是减速结束时速度仍然高于电机的启动频率。S型减速到最后,速度应该降到接近v_start,然后突然跳到0。如果减速段设计得不合理,最后几个脉冲间隔太近,电机转子冲过头,就会丢步。检查方法很简单:把定点计的收尾速度和v_start对比,如果高于500 pulse/s,说明减速段步数不够,需要提前进入减速。
还有一个容易被忽略的坑:负载的摩擦方向。如果你的机构有垂直方向的负载或者较大的摩擦力,正反向运动的摩擦阻力不一样,同样的S型曲线参数,一个方向可能刚好,另一个方向就会丢步。我处理过的一个实际项目里,垂直丝杠在上升段容易丢步,因为重力让加速负担加大。解决办法是重载方向把a_max调低15%左右,或者干脆用更保守的参数同时满足两个方向的需求。
4.3 查表方案与实时计算的取舍
介绍一个替代实现:查表法。这不算高级技巧,但在某些场景特别实用。
思路是:运动开始之前,一次性计算整条S型曲线的ARR值序列,存到一个数组里。定时器中断里不再做速度递推和阶段判断,只是从数组里取下一个ARR值写进寄存器。这样做的好处在中断占用时间极短,CPU几乎可以腾出来干别的事(比如处理UI、通信)。
缺点也很明显:数组长度等于总步数。对于步数几十万、上百万的运动,RAM根本放不下。我通常的做法是混合方案:如果总步数小于某个阈值(比如2000步),直接用查表法;超过这个阈值,就实时计算。实测中,实时计算在stm32f103上的开销,只要把函数优化成整数运算,一个脉冲中断大概几微秒,完全能接受。
查表法的另一个用途是离线仿真:先把曲线放到电脑或上位机上算出来,看速度曲线长什么样,确认加减速时间、最大加速度是否合理,再烧进MCU。我经常先用这种离线方式验证参数,再移植到单片机上。
4.4 波形观察与调试的小技巧
调加减速曲线时,光听电机声音是不够的,建议用逻辑分析仪(或者示波器)观察脉冲信号的间隔变化。
方法很简单:在定时器输出引脚上加一个探头,观察脉冲间的间隔。S型曲线加速阶段,脉冲间隔应该逐渐缩短,而且缩短的速率应该是先快后慢(因为加速度从jerk增长到a_max,再降回0)。如果看到脉冲间隔先突变然后突然变化停滞,说明阶段切换的阈值设置有误。停止阶段同理,脉冲间隔应该逐渐拉长,最后几个脉冲间隔均匀,然后停止。
如果手头没有逻辑分析仪,也可以在中断里留一个调试计数,每产生一个脉冲就翻转另一个GPIO,然后把GPIO输出到示波器。这招我用过很多次,非常管用。
另外一个很实用的技巧:把每一段的切换步数和速度值通过串口打印出来,在运动开始前规划好,运动结束后打印实际值,两边对比。如果规划的切换点和实际的不一致,说明中断里递推的速度收敛不对,要回头检查离散递推公式的步进量。
最后再分享一个经验
整套代码跑稳定之后,我自己的习惯是保留一个“运动完成”标志,并且在主循环里等这个标志,而不是在中断里直接做下一段运动的规划。原因是中断里处理太多业务逻辑,一旦某个分支卡顿,脉冲周期就开始抖动,电机就会发出那种周期性“哒哒哒”的噪音。
还有一点:如果项目里用到了编码器或者限位开关,建议给S型加减速加一个“当前位置检查”,在加速阶段和减速阶段各检查一次,防止机械卡死时电机还在傻傻地发脉冲。开环步进控制本身没有位置反馈,多一道检查就多一道保险。想把这个S型加减速代码做得更完善的朋友,可以先从上述参数和代码框架入手,跑通基础运动之后,再往里面加限位联动、实时变速和闭环补偿,一步步来就行。