STM32步进电机S型加减速算法详解与工程实现
两年前我在一个小型运动平台上调试步进电机,当时用的还是梯形加减速,速度曲线写起来简单,但电机一到中速段就开始“嗡嗡”叫,带着联轴器一起抖,严重的时候直接丢步,加工出来的零件全是残次品。后来我把加减速算法从梯形换成S型,启动和停止的冲击明显小了一截,丢步问题也基本消失。这篇文章就围绕STM32上的S型加减速,把原理、参数设计、源码实现以及调试中的坑一次性讲清楚,适合正在做步进电机控制、想优化启停体验的嵌入式开发者参考。
1. S型加减速解决的是哪个层面的问题
1.1 梯形加减速的两个硬伤
先说说为什么梯形的方案不够用。梯形加减速的数学模型非常简单:速度随时间线性上升,加速度恒定。写成公式就是 v = v0 + a·t,加速阶段速度直线上升,减速阶段直线下降。这个模型在低速场合完全够用,代码写起来也很顺手,但它在真实机械系统里有两处硬伤。
硬伤一是加速度在瞬间发生阶跃。启动瞬间速度开始增长,加速度从0直接跳到设定值a;到了匀速段,加速度又突然从a变成0;减速起始和结束,同样有两个加速度突变。这种突变产生的“冲击”在物理上对应无穷大的加加速度,也就是jerk。机械系统最怕这种东西,联轴器间隙被反复撞击,导轨产生弹性形变,整个平台的固有频率被激励起来,表现出来就是整机抖动和低频噪声。
硬伤二是步进电机本身的开环特性。步进电机没有编码器反馈,一旦丢步,控制器无从感知。电机的输出力矩会随着转速升高而下降,也就是矩频特性曲线,如果加速段初期的加速度拉得太猛,转矩需求超过电机当前转速下能提供的最大力矩,就会失步。梯形加速的启动阶段加速度最大,恰好踩在这个软肋上。
所以问题并不是梯形加减速“数学上错”,而是它把机械冲击和力矩需求集中在几个时间点上,对开环步进系统非常不友好。
1.2 为什么步进电机开环控制必须“温柔”启动
步进电机开环控制想要跑得稳,核心逻辑就是“让速度变化尽可能平滑”。你可以想象自己开一台手动挡车,如果起步时离合器瞬间全部松开、油门一脚踩到底,乘客肯定会顿挫;老司机的做法是先缓加油、等转速起来后再逐渐加深油门,整个加速过程是渐变而非突变。
S型加减速做的事情本质上就是模拟老司机开车的这种“温柔”。它的速度曲线不是直线,而是一条先缓后急、再缓的S形曲线。加速过程中,加速度从0开始逐渐增大,达到峰值后再逐渐回落,直到匀速段变成0。减速时完全对称。这样一来,jerk是有限值,机械系统受到的冲击被分散到整个加速区间。
对步进电机来说,这样做还有一个额外好处:启动初期的加速度为零,电机不会在起步瞬间被要求输出很大力矩,而是随着速度上升,在力矩能力相对充足的区间逐步释放加速度。对带负载的平台、丝杆、同步带结构,这种启动方式的成功率明显更高。
2. S曲线的数学原理与参数设计
2.1 低频振动的根源:加加速度突变
要让加减速变平滑,就要引入一个比加速度更底层的物理量:加加速度,也就是 jerk,定义为加速度对时间的导数。梯形加减速的速度曲线是直线,加速度曲线是矩形脉冲,jerk在两个直角边处是无穷大的冲激信号。这个无穷大的冲击力,就是共振和振动的根源。
S型加减速的思路很直白:让加速度不是阶跃变化,而是连续地上升和下降。这样jerk就从“无穷大冲激”变成“有限值曲线”。工程上并不要求jerk完全连续,只要把加速度变化率限制在可接受的范围内,机械冲击就已经大幅下降。
经典的7段S曲线把加速度过程拆成加加速、匀加速、减加速、匀速、加减速、匀减速、减减速七个阶段,每段的jerk是常数。这种实现精度高,但参数计算和状态切换都比较繁琐,在步进电机这种本身不是超高精度的场合,多数时候用不到那么复杂。
2.2 用余弦函数构造S型速度曲线
工程上我更推荐用余弦函数去构造S型速度曲线,公式简洁、边界条件自然,效果也足够平滑。加速段的速度公式写出来是这个样子:
v(t) = v0 + (vmax - v0) × (1 - cos(π·t / T_acc)) / 2
这里v0是起始速度,vmax是目标最高速度,T_acc是加速段时间。当t=0时,1 - cos(0) = 0,速度等于v0;当t=T_acc时,cos(π) = -1,1 - (-1) = 2,整体等于(vmax - v0),速度正好到达vmax。
这个形式妙在边界处的加速度自然为零。对时间求导:
a(t) = (vmax - v0) × π / (2·T_acc) × sin(π·t / T_acc)
可以看到sin值在t=0和t=T_acc时都等于0,意味着启动和到达最大速度这两个瞬间加速度都是0,没有突变。加速度的峰值在T_acc的一半处出现,大小为:
Amax = (vmax - v0) × π / (2·T_acc)
再求一次导就能得到jerk:
j(t) = (vmax - v0) × π² / (2·T_acc²) × cos(π·t / T_acc)
jerk的峰值在起点和终点出现,也是有限值。这就是S型曲线的本质特性:速度连续,加速度连续,且首尾加速度为零。
这里有一个容易误解的点我必须单独说一下:如果T_acc和梯形加减速的加速时间一样,S型曲线的加速度峰值反而比梯形还要高大约57%。因为梯形加速度均值是(vmax - v0)/T_acc,而S型峰值约等于1.57倍的平均值。S型曲线的优势在于“没有突变”,而不是“降低峰值”。所以设计参数时一定要以允许的最大加速度Amax为约束,而不是套用梯形方案的时间。
2.3 步进脉冲间隔表怎么生成
步进电机每一步对应一个固定的角度增量,比如1.8度步距角配合8细分后,每一步是0.225度。速度控制最终转换成脉冲频率控制,而S型规划给出的是速度-时间曲线,需要把连续曲线离散成“每一步脉冲的周期”。
连续速度曲线下,每走一步所需的时间是变化的,最直接的做法是“时间戳法”。原理是:让仿真时间以很小的步长推进,每推进一个微小时间片,就累加一次位置增量 v(t)·dt,当累计位移正好跨过某一步的整数倍时,记录下当前时刻t_k。这一步的实际周期就是 t_{k+1} - t_k。
我在PC上用Python生成间隔表,再把结果导出为C数组,整个流程大概是这样的:
import math TIMER_CLK = 72_000_000 # STM32定时器时钟 V0 = 400 # 起始速度 step/s VMAX = 5000 # 目标速度 step/s T_ACC = 0.15 # 加速时间 s ACC_STEPS = 800 # 加速段最大步数 DT = 0.00001 # 仿真步长 10us times = [] pos = 0.0 k = 1 t = 0.0 while k <= ACC_STEPS and t <= T_ACC: ratio = t / T_ACC v = V0 + (VMAX - V0) * (1.0 - math.cos(math.pi * ratio)) / 2.0 pos_new = pos + v * DT if pos_new >= k: # 线性插值求出更精确的跨越时刻 t_cross = t - DT + (k - pos) / (pos_new - pos + 1e-12) * DT times.append(t_cross) k += 1 pos = pos_new t += DT periods = [] for i in range(len(times) - 1): dt_k = times[i + 1] - times[i] arr = int(round(dt_k * TIMER_CLK)) - 1 if arr < 2: arr = 2 periods.append(arr) print("const uint32_t speed_table[] = {") for i, val in enumerate(periods): print(f" {val},") print("};") print(f"// accel_steps = {len(periods)}")生成的periods数组里,每个元素就是对应那个脉冲周期在72MHz计数频率下需要计数的ARR值。之所以要减1,是因为定时器的计数器从0数到ARR一共是ARR+1个时钟周期。上电后MCU里不需要再做浮点计算,直接把这张表放进FLASH就能用。
3. STM32端的脉冲产生方案与状态机
3.1 三种脉冲产生方案,为什么我推荐PWM
在STM32上给步进驱动器输出脉冲,主流有三种做法。
第一种是定时器中断里翻转GPIO。代码简单,但每产生一个方波周期要进两次中断,频率稍高时CPU负载明显,而且如果中断被其他任务打断,脉冲间隔会变得不均匀。
第二种是定时器PWM硬件输出。让定时器工作在PWM模式,输出引脚自动产生方波,占空比固定为50%。速度变化只需要在比较中断里更新ARR和CCR值,硬件自动按新频率输出,CPU负担小,脉冲均匀度高。这也是我推荐的方式。
第三种是用DMA搬运一串预先设置好的ARR列表,几乎不占CPU,但每个运动过程都要准备一整张脉冲表,长距离运动时内存开销很大,一般用在有大量重复轨迹的场合。对大多数步进应用来说,PWM方案是性价比最高的。
我用的是TIM3的通道1做PWM输出,同时使能比较中断。每来一个完整的PWM周期,系统进一次中断,在中断里根据速度表计算下一步的ARR和CCR,然后写进定时器寄存器。
3.2 运动状态机与步数规划
一个完整的S型加减速运动包含加速、匀速、减速三个阶段,为了把逻辑组织清楚,我建议用一个简单的状态机,不要把所有逻辑都堆在中断里。状态枚举如下:
typedef enum { STEPPER_IDLE = 0, STEPPER_ACCEL, STEPPER_CRUISE, STEPPER_DECEL, STEPPER_FINISH } StepperState;运动开始时先做一次规划:根据总步数total_steps、加速段步数accel_steps、减速段步数decel_steps,算出有没有匀速段。如果总步数足够,中间就有一块匀速段;如果路程很短,可能加速还没到最高速度就要开始减速,这时匀速段步数为0。
控制逻辑很简单:脉冲计数器step_done记录已经发出的完整脉冲个数,每个阶段用不同的速度表来源。加速阶段从头往尾查加速表,匀速阶段用恒定的巡航ARR,减速阶段把加速表反过来查。这样一张加速表既承担了加速,又通过反序复用来承担减速,代码量最少。
启动运动前,设置好方向引脚DIR,把第一个脉冲的ARR和CCR写入定时器,然后启动PWM输出。
3.3 中断回调里的源码实现
规划函数大概长这样:
void stepper_start(uint32_t total_steps, uint8_t dir) { if (total_steps == 0) return; stepper.total_steps = total_steps; stepper.step_done = 0; stepper.state = STEPPER_ACCEL; HAL_GPIO_WritePin(DIR_PORT, DIR_PIN, dir ? GPIO_PIN_SET : GPIO_PIN_RESET); if (accel_steps + decel_steps <= total_steps) { stepper.cruise_steps = total_steps - accel_steps - decel_steps; } else { stepper.cruise_steps = 0; } __HAL_TIM_SET_AUTORELOAD(&htim3, speed_table[0]); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, speed_table[0] >> 1); HAL_TIM_PWM_Start_IT(&htim3, TIM_CHANNEL_1); }重点是中断回调,这是整个S型加减速的核心:
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM3) return; stepper.step_done++; if (stepper.step_done >= stepper.total_steps) { HAL_TIM_PWM_Stop_IT(&htim3, TIM_CHANNEL_1); stepper.state = STEPPER_FINISH; return; } uint32_t next = stepper.step_done; uint32_t arr = 0; if (next < accel_steps) { stepper.state = STEPPER_ACCEL; arr = speed_table[next]; } else if (next < accel_steps + stepper.cruise_steps) { stepper.state = STEPPER_CRUISE; arr = cruise_arr; } else if (next < stepper.total_steps) { stepper.state = STEPPER_DECEL; uint32_t dec_i = next - accel_steps - stepper.cruise_steps; arr = speed_table[accel_steps - 1 - dec_i]; } else { return; } __HAL_TIM_SET_AUTORELOAD(&htim3, arr); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, arr >> 1); }这里step_done的含义是“已经完成的完整脉冲数”。每次进入回调,说明硬件刚刚又输出完一个完整的PWM周期,所以先自增。然后以自增后的值作为“下一个待输出脉冲的索引”,去速度表里查对应的ARR。第一次进中断时step_done变成1,下一脉冲用的是speed_table[1],而speed_table[0]在启动时就已经写进定时器了,两个逻辑完全对得上。
减速段的反查要特别留意。假设加速表一共有800个元素,匀速段结束之后,next等于accel_steps + cruise_steps,那么dec_i等于0,取的是speed_table[799]。next每加1,arr就往加速表的头方向走一步,正好形成对称的减速S曲线。
4. 工程落地:短行程处理与参数调优
4.1 短行程下到不了最大速度怎么办
最容易被忽略的情况是“短行程”。比如总步数只有300,而加速段规划需要500步,减速段又要500步,显然来不及加速到最高速度就已经要刹车了。如果在代码里硬套长行程逻辑,速度曲线会失真,甚至出现还没加速就突然减速的怪异行为。
处理思路是动态收缩速度顶点。运动总步数N固定后,先判断一下梯形近似的临界条件:如果按规划的最高速度vmax来计算,加速距离加上减速距离已经超过N,那就说明根本到不了vmax,需要重新算一个“实际能达到的最高速度”。
工程上不用做太精确的推导,做一个比例收缩就能用。把总步数N拆成两部分,加速段和减速段各占一部分,然后根据N重新生成一张“短行程专用速度表”。在PC端生成表的时候就判断一下:如果加速表长度加减速表长度大于总步数,就减小vmax,重新生成。
还有一种更省事的处理方式:对短行程直接用对称S型,也就是加速占用一半步数,减速占用另一半,整个运动就是一张“从小到大再从大到小”的速度表。这种情况下没有匀速段,电机刚加速到位就开始减速,体验上依然是平滑的。代价是高速度来不及充分体现,但对短距离定位来说这是必然的物理限制。
4.2 减速点和恒速段怎么算
有了加速表之后,减速点不用单独算一个“位置”,纯粹靠步数索引就够了。我在规划函数里把total_steps、accel_steps、cruise_steps存进结构体,中断回调里通过比较next与各阶段步数边界,天然就知道当前处于加速、匀速还是减速。
有个细节需要注意:减速段的反查表是speed_table[accel_steps - 1 - dec_i],其中dec_i从0递增到decel_steps-1。那么减速段使用的最后一个值应该是dec_i等于decel_steps-1时,对应的speed_table[accel_steps - decel_steps]。如果accel_steps大于decel_steps,数组访问没问题;如果accel_steps小于decel_steps,那说明这张表设计有问题,减速段没法完全覆盖。我一般强制要求减速段步数和加速段步数相等,都是accel_steps,这样反查一定安全。
匀速段的cruise_arr也不是随便取。它对应的速度是vmax,所以arr值可以直接从速度表最后一个元素近似得到。我在PC端生成表时就额外输出一个cruise_arr:
#define CRUISE_ARR ((uint32_t)(TIMER_CLK / VMAX) - 1)如果加速表最后一个arr和这个值差得比较多,说明加速段结束时的速度收敛得还不够,可以把T_ACC调大一点,让速度曲线更平滑地靠近vmax。
4.3 调参顺序与经验参数表
我调试时不会一上来就改运动曲线,而是先按下面这个顺序把基础参数定下来。
第一步确定起始速度v0。v0不能设成0,因为速度为0意味着脉冲周期无穷大,第一步永远等不完。实际工程中v0一般取300到800 step/s,保证电机在启动频率内能可靠建立力矩。起步太慢会拖慢整个节拍,起步太快又容易在第一步就丢步,所以要结合负载试。
第二步确定最高速度vmax,也就是目标脉冲频率。对大多数42步进电机配细分驱动器,5000到10000 step/s是一个常见区间,超过这个区间必须检查电机发热和力矩。
第三步根据允许的最大加速度Amax反推T_acc。根据公式 Amax = (vmax - v0) × π / (2·T_acc) 可以得到:
T_acc = (vmax - v0) × π / (2·Amax)
如果对Amax没有概念,可以先取一个经验值,比如5000到20000 step/s²,再根据抖动和丢步情况增减。下面这张表给出一组可以参考的初始参数:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| v0 | 300 ~ 800 step/s | 启动频率,需低于电机不失步频率 |
| vmax | 3000 ~ 10000 step/s | 高速段,受力矩和驱动器限制 |
| T_acc | 0.1 ~ 0.3 s | 加速时间,影响加速度峰值 |
| Amax | 5000 ~ 20000 step/s² | 峰值加速度,决定T_acc |
| 细分 | 8 ~ 32 | 细分越高单步越平滑,但最高频率受限 |
调参的时候有个很实用的建议:先用一个大点的T_acc,比如0.3秒,确保启动不丢步,然后逐步减小T_acc,每次减小20%左右,同时听电机的声音。一旦开始出现“嘶嘶”或“咔咔”的异常声,就退回到上一次的参数,再加10%余量作为最终值。
5. 调试实录与避坑指南
5.1 电机明明在转,声音却很难听
这个问题我遇到过很多次,第一反应往往是加减速曲线不对,但检查完发现S型参数本身没问题。后来用示波器抓了STEP引脚的波形才意识到,真正的问题是脉冲频率在某些点发生了跳变。
跳变的来源有两个。第一个是速度表的量化误差,如果相邻两项ARR的差值不是平滑递减,而是忽大忽小,电机听起来就会有沙哑感。解决办法是在PC端生成表时增加仿真步数,比如把DT从10us缩小到2us,让每步周期变化更平滑;另一个办法是在速度表中做一下滑动平均滤波,但注意不要过度滤波以免破坏首尾的加速度零边界。
第二个来源是巡航段和加速表最后一个值衔接不上。如果cruise_arr与speed_table[accel_steps-1]差得太多,速度会有一个小台阶,听感上像“咯噔”一下。让加速表最后一个值尽量接近匀速ARR,或者直接让巡航段从加速表过渡,而不是立刻换到常量。
5.2 突然丢步,先从启动频率查
S型加减速加持下还是丢步,我排查顺序永远是先看v0,再看Amax,最后看vmax。
v0太高是最常见的元凶。有人觉得S型曲线很平滑,就把起始速度调到2000 step/s,结果起步瞬间力矩根本不够,第一步就丢了。起步频率一定要按实际负载测,空载可以800,带了一根丝杆和滑台之后可能要降到300甚至更低。
Amax太高是第二个原因。S型只是让加速度平滑上升,峰值依然存在。如果峰值加速度超过了电机在对应速度下的力矩能力,到了加速中段一样丢步。用手捏住滑台感受一下启动力矩,如果觉得推背感太强,大概率就是Amax偏大。
最后才是vmax。步进电机高频力矩下降很快,vmax太高时电机转矩不足,即使加减速曲线再好也没用。确认办法:把vmax逐步降下来,如果降到某个值以后丢步现象消失,说明是高速力矩不够。
5.3 中断里别做多余的事
PWM回调里的代码要尽量精简,原则是除了更新ARR、CCR和简单计数之外,不要再做任何耗时操作。我有一次为了调试方便,直接在中断里调用HAL_GPIO_TogglePin去翻转LED,结果输出脉冲的周期被拉长了差不多一倍,速度曲线完全失真。
更危险的操作是中断里调用printf、做浮点除法、或者执行HAL_Delay。这类操作会让中断服务时间超过脉冲周期,导致脉冲丢失,而且很难查。调试信息通过串口发送时,应该先写到一个环形缓冲区,在主循环里统一发送。
如果发现中断处理时间真的紧张,可以进一步优化:把速度表从FLASH拷贝到RAM,减少FLASH读取等待;ARR和CCR的更新直接操作寄存器,不经过HAL封装。实测下来,72MHz主频下处理一次回调只用几十个时钟周期就够了。
5.4 用示波器验证S曲线是否生效
代码写完了,怎么确认S曲线真的一步步在按预期执行?我推荐用示波器抓STEP引脚,并把时基调到能看多个脉冲的位置,观察脉冲之间的间隔。
加速阶段,示波器上应该看到脉冲越来越密,也就是频率越来越高。减速阶段则反过来,脉冲越来越稀疏。如果只看波形觉得不明显,可以把示波器切换到频率测量模式,或者用逻辑分析仪记录每个脉冲的时间戳,导到电脑里画成速度曲线。这个办法最能直接反映加减速的真实形态,比耳朵听可靠得多。
还有一个点是,在视察脉冲间隔的同时,结合DIR引脚的逻辑判断运动方向,防止减速段反查表的索引方向搞反。方向反了的表现是:加速段正常,但到了后程频率不降反升,和预期正好相反。
我在调试阶段习惯把v0设低、T_acc设大,让S曲线特性在示波器上看得非常明显,确认基础逻辑无误后再逐步把参数逼近实际工况。这样一次调通的概率最高,也少走很多弯路。