STM32多轴步进电机协同控制:统一时基与自定义动作表
1. 多轴协同到底难在哪:先把复杂度拆开看
接到“多路步进电机协同 + 动作可自定义”这个需求时,很多人的第一反应是:单轴我都能跑,多来几个轴不就是复制几份代码?真上手做一轮就会发现,单轴跑通只是入门,多轴协同才是真正开始处理工程问题。单轴控制里你只需要关心“脉冲发得准不准、方向对不对”,多轴之后要关心的是“几个轴之间的时间关系对不对”。这两个问题差着一整个量级。
我做过一个三轴的小型定位平台改造,控制器换成了 STM32,要求是三个轴能单独点动、能按预设动作表连续执行、还要能在不重新烧录固件的前提下改动作。第一版固件我把每个轴的定时器都配好了,单独测试时三个轴转得都很平稳,结果一按“联动执行”,X 轴跑到一半 Y 轴还在加速,Z 轴干脆丢了几百个脉冲,整个动作轨迹完全对不上。那次之后我才彻底明白:多轴协同的本质问题不是“每个轴会不会转”,而是“所有轴是不是挂在同一个时间基准上”。这个认知转变,决定了后面所有的架构选择。
这篇文章面向的是已经能用 STM32 点动一个步进电机、但被多轴同步和动作配置卡住的人。我会把从硬件选型、脉冲引擎、加减速参数计算到动作表数据结构、队列调度、故障排查的完整链路讲一遍,中间穿插我自己算过的参数和踩过的坑。看完你应该能直接照着搭出一套能跑、能改动作、出了问题知道去哪儿查的方案。
1.1 单轴能跑不等于多轴能协同
先说清楚“协同”到底包含哪几层含义,不然后面的设计会没有着力点。我把它拆成三层:时间同步、运动学协调、状态一致。
时间同步是地基。两个轴如果各用各的定时器、各自开各自的中断,即使参数完全一样,中断响应的微小抖动也会让它们在长时间运行后逐渐错开。跑几十步看不出来,跑几万步之后位置差肉眼可见。更麻烦的是这种偏差不可预测,不能用“补偿一个固定值”解决。
运动学协调说的是轨迹层面的关系。比如要求 X 和 Y 按 45 度斜线走,两轴的速度配比必须严格对应位移配比,否则走出来的是一条折线而不是直线。再比如要求三轴同时启动、同时到达,那么行程短的轴就必须降速,不能各自按最高速跑完。
状态一致则是最容易被忽略的一层。急停触发时三个轴是不是都在同一时刻停止脉冲输出?某个轴撞到限位之后其他轴是继续走还是立即联动停止?动作队列执行到一半被打断,恢复时从哪一条继续?这些问题在单轴项目里根本不会出现,多轴之后每一个都是必须回答的。
我在第一版固件里就栽在状态一致上:急停按钮按下后,X 轴中断里清了使能,但 Y 轴因为中断优先级更高,还在把当前这条动作走完,结果工件直接被拉变形。后来我把急停做成了一个全局状态位加统一的中断安全处理流程,所有轴在同一个节拍边界上一起减速停止,这类问题才彻底消失。
提示:多轴项目开工前,先把“同步”“协调”“一致”这三层各自的具体要求写成条目,逐条对应到设计方案上。漏掉任何一层,后期都是返工。
1.2 三条技术路线与它们的适用边界
把需求拆清楚之后,实现路径其实就那么几条,各有明确的适用场景,选错方向后面怎么优化都是事倍功半。
| 方案 | 实现方式 | 同步能力 | 资源占用 | 适用场景 |
|---|---|---|---|---|
| 独立定时器方案 | 每个轴一个定时器,各自输出脉冲 | 弱,需要额外做对齐 | 定时器数量随轴数线性增长 | 轴数少、只要求“同时启动”不要求轨迹 |
| 主从插补方案 | 一个主轴定时器,从轴按比例跟随 | 中,依赖主轴的节拍 | 定时器占用少 | 直线插补、简单轮廓 |
| 统一时基累加器 | 单个高频定时器,所有轴在同一中断里算 | 强,节拍级严格同步 | CPU 有一定中断负载 | 多轴联动、动作可配置、需要轨迹精度 |
第一条路适合那种“三个轴各自走到位就行”的场合,比如简单的上下料机构。它的优点是代码最直观,缺点是轴一多定时器就不够用,而且中断之间天然存在先后,同步性没法保证。
第二条路在数控领域很常见,主轴上一步,从轴按固定比例跟一步。它的局限在于比例是固定的,遇到需要变速或者中途改变方向的复杂动作就不太好处理。
我最后选的是第三条:用一个高频定时器做全局节拍,所有轴的脉冲生成、加减速、插补计算全在这一个中断里完成。这样做的直接好处是,任意时刻所有轴的状态都在同一行代码里被更新,同步性由架构本身保证,而不是靠事后对齐。代价是中断里要干的活变多了,需要仔细核算 CPU 占用。实测下来,六轴、40kHz 节拍、主频 168MHz 的情况下中断占用大概在 4% 到 5% 之间,完全在可接受范围,具体算法后面详细讲。
2. 硬件层:选型、供电与信号完整性
软件架构再漂亮,硬件上埋的雷一样会把项目炸掉。多轴系统里最典型的两个坑是供电不足导致丢步和信号串扰导致误触发,这两个问题在单轴系统里往往不明显,轴数一多就集中爆发。硬件这部分我建议多花时间,因为改板子的成本远高于改代码。
2.1 主控资源盘点与驱动器选型
先说主控。选型的时候不要只看“能不能跑”,要按最坏情况算资源:轴数 N、每轴至少 2 个 GPIO(脉冲 + 方向)、每轴至少 1 个输入(限位或原点)、再加上急停、通信、调试口。六轴就是 12 路输出加 6 到 12 路输入,普通的小容量型号引脚会紧张。我建议直接按轴数上限的两倍预留引脚,因为实际项目里“再加一个轴”的需求出现频率高得惊人。
定时器资源要单独算。统一时基方案下你只需要一个高级定时器或通用定时器做节拍源,但方向信号、使能信号、限位输入这些如果也想用硬件资源,就得提前规划好哪些复用、哪些直连。我一般会把节拍定时器单独占用,不和其他外设复用,避免后期改配置时踩到冲突。
驱动器选型看三个参数:电流上限、细分档位、支持的最高脉冲频率。电流上限要覆盖电机额定电流并留 20% 到 30% 余量;细分档位决定了你的最小分辨率;最高脉冲频率必须大于你算出来的峰值步频,否则高速段会直接丢脉冲。这一条特别容易忽略,很多驱动器的规格书上写着支持 200kHz,但实际在高温或者供电电压偏低时会掉到几十 kHz,所以留余量是必须的。
我一般的做法是:先按最坏情况算出峰值步频(后面有具体算法),再乘 1.5 倍作为选型门槛。这样即使工况恶化也不会踩线。
2.2 电流设定与细分选择的实际计算
这两个参数新手最容易凭感觉设,设错了要么扭矩不够丢步,要么电机发烫。其实都有明确的算法。
电流设定:常见的恒流型驱动模块通过一个参考电压来设定相电流,关系大致是Itrip = Vref / (8 × Rs),其中 Rs 是模块上的电流采样电阻。举个实际例子,如果采样电阻是 0.1Ω,你希望相电流是 1.2A,那么Vref = 1.2 × 8 × 0.1 = 0.96V。设好之后一定要实测:让电机在最低速连续转十分钟,摸一下外壳温度,超过 60 度就得往下调。我吃过一次亏,按标称电流设满,夏天连续跑了两小时,电机烫到外壳变形,扭矩反而因为磁钢退磁下降了。
细分选择:细分决定了每个脉冲对应多少机械位移。以常见的 1.8 度步距角电机为例,整步一圈是 200 步,16 细分就是 3200 脉冲一圈。如果你的传动是 5mm 导程的丝杠,那么每毫米脉冲数 = 3200 / 5 = 640。这个数字后面算加减速、算插补全都要用,一定要先算出来贴在代码注释里。
细分是不是越高越好?不是。细分越高,同样的物理速度需要的脉冲频率就越高,对定时器和驱动器的压力越大;同时高细分下的保持扭矩其实不如低细分,某些场合反而更容易受外力干扰。我的经验是:定位精度要求不高的场合 8 到 16 细分足够,需要平滑低速的场合上到 32,再高就要认真评估脉冲频率是否撑得住。
| 参数 | 计算方式 | 举例 |
|---|---|---|
| 每圈脉冲数 | 200 × 细分 | 16 细分 = 3200 |
| 每毫米脉冲数 | 每圈脉冲数 / 丝杠导程 | 3200 / 5 = 640 |
| 目标步频 | 每毫米脉冲数 × 目标速度(mm/s) | 640 × 50 = 32000 |
| 相电流 | Vref / (8 × Rs) | 0.96 / (8 × 0.1) = 1.2A |
把这张表填满,后面所有参数计算都有依据了,不用再靠试。
2.3 接线、地与限位回零
接线这部分看起来是体力活,其实是多轴系统里故障率最高的环节。
地线处理:脉冲信号地必须和功率地单点连接,不要形成环路。我习惯的做法是所有驱动器的信号地先汇到一块小铜皮,再用一根线连到主控地,最后单点接到电源负极。多轴系统里如果每个驱动器都各自接地,几个轴同时换向时产生的地电位差会直接把脉冲信号抬成误触发。
脉冲信号线:尽量用双绞或者带屏蔽的线,屏蔽层单端接地。长距离走线(超过 30cm)的时候,加一个 100Ω 左右的串联电阻做阻抗匹配,能显著减少过冲和振铃。
限位与原点:我强烈建议每个轴都装限位,哪怕你觉得“行程很短不会撞”。限位开关用常闭串联的方式接更安全,因为断线时表现为触发状态,而不是失效状态。输入端加上拉电阻和 RC 滤波(比如 10kΩ + 100nF),滤掉机械抖动和干扰毛刺。
回零流程我固定写成三段:快速找原点信号 → 后退一小段 → 低速二次找原点。第一段用较高速度节省时间,第二段保证脱离信号边缘,第三段低速确保重复精度。实测下来这套流程的回零重复精度比单段高速找零好一个数量级。
注意:限位和急停的输入一定要用中断或者高速轮询处理,不要放在动作队列里去查。等队列里某条动作执行完再检查限位,黄花菜都凉了。
3. 脉冲生成引擎:一个时基管所有轴
这是整个项目的核心,也是决定多轴能不能真正同步的地方。前面说了我选统一时基方案,具体怎么实现、精度怎么保证、参数怎么算,这一节全部讲透。
3.1 三种脉冲生成方式的取舍
STM32 上生成步进脉冲,常见三条路:硬件 PWM、定时器比较中断、高频时基加相位累加器。
硬件 PWM最省 CPU,占空比 50%、频率可调,看起来很美。问题在于调速的时候你得不断改 ARR 和 CCR,而改这两个寄存器在运行中容易产生一个异常宽的脉冲或者漏掉一个脉冲。单轴低速的时候这个瑕疵不明显,多轴高速换向的时候就是实打实的丢步。另外每个轴要占一个定时器通道,六轴就要六个通道,还得保证这些通道能同步启动,配置起来很别扭。
定时器比较中断是每次中断改一次比较值,输出一个脉冲。精度不错,但中断频率等于步频,高速时 CPU 负担很重,而且没法在多轴之间做节拍对齐。
高频时基加相位累加器,也就是我最终用的方案:一个定时器以固定高频(比如 40kHz)产生节拍中断,每次中断对每个轴做一次“累加器 += 速度值,溢出就输出一步”的运算。它的核心优势是所有轴天然共享同一时基,同步性由架构保证;而且加减速只需要改速度值,脉冲输出逻辑完全不变,非常简单。
代价有两个。第一个是最高步频受限于时基频率,因为一个节拍最多输出一个脉冲,时基 40kHz 就意味着最高步频 40k。这个必须在选型阶段就算清楚:如果你需要 32000 步/秒的峰值速度,时基至少要 40kHz 才留得出余量。第二个是脉冲抖动,因为脉冲只能在节拍边界输出,实际脉冲周期是时基周期的整数倍,抖动最大可达一个节拍。40kHz 时基的抖动是 25 微秒,对绝大多数定位应用完全无感,但如果你要做特别精密的低速平滑运动,需要评估一下这个抖动是否可接受。
3.2 相位累加器方案的实现与精度分析
原理很简单,用一个 32 位整数做累加器,速度用定点数表示“每个节拍应该走多少步”。累加器超过阈值就发一个脉冲并把阈值减掉。
#define AXIS_NUM 6 #define Q16 16 typedef struct { int32_t acc; // 相位累加器 int32_t vel_q16; // 当前速度,Q16 定点,单位:步/节拍 int32_t vel_target; // 目标速度 int32_t accel_step; // 每节拍的加速度增量 int32_t steps_left; // 剩余步数 uint8_t dir; uint8_t busy; } Axis_t; static Axis_t axis[AXIS_NUM]; void TIM_TICK_IRQHandler(void) // 40kHz 节拍 { if (TIM_GetITStatus(TIMx, TIM_IT_Update) == RESET) return; TIM_ClearITPendingBit(TIMx, TIM_IT_Update); for (uint8_t i = 0; i < AXIS_NUM; i++) { Axis_t *a = &axis[i]; if (!a->busy) continue; // 1. 加减速:速度向目标靠拢 if (a->vel_q16 < a->vel_target) { a->vel_q16 += a->accel_step; if (a->vel_q16 > a->vel_target) a->vel_q16 = a->vel_target; } else if (a->vel_q16 > a->vel_target) { a->vel_q16 -= a->accel_step; if (a->vel_q16 < a->vel_target) a->vel_q16 = a->vel_target; } // 2. 相位累加,溢出即发一步 a->acc += a->vel_q16; if (a->acc >= (1 << Q16)) { a->acc -= (1 << Q16); STEP_PULSE(i, a->dir); // 拉高再拉低,或用短延时保证脉宽 if (a->steps_left > 0) a->steps_left--; } // 3. 剩余步数减速规划(到达前自动降速) if (a->steps_left > 0 && a->steps_left <= a->decel_threshold) { a->vel_target = 0; // 触发减速 } if (a->steps_left == 0 && a->vel_q16 == 0) { a->busy = 0; } } }这段代码有几个细节值得说。
速度用 Q16 定点表示。40kHz 节拍下,最高步频 40000 步/秒,折合每节拍 1 步,所以vel_q16的最大值就是1 << 16。但实际我们很少跑到极限,比如 32000 步/秒对应每节拍 0.8 步,vel_q16 = 0.8 × 65536 ≈ 52429。定点数的好处是能表达非常小的速度,比如节拍之间只走 0.001 步也能累加出来,低速平滑性很好。
脉宽问题。相位累加器的脉冲是在中断里用 GPIO 拉高再拉低,宽度只有几个微秒,很多驱动器要求至少 2.5 微秒的脉宽。如果主频高、拉低太快,驱动器可能识别不到。解决办法要么在两次翻转中间插入几个空指令延时,要么把脉冲输出改成由定时器通道在下一个节拍自动拉低。我一般用前者的简化版本,在拉高后插入一个基于__NOP()的短延时循环。
中断负载核算。40kHz 中断,每个节拍处理六个轴,每轴大概 20 到 30 条指令,加上进出中断的固定开销约 60 个周期,总计约60 + 6 × 25 = 210个周期。168MHz 主频下约 1.25 微秒,占节拍周期(25 微秒)的 5%。这个余量是安全的,但如果再往中断里塞串口解析、状态机之类的活,就会开始挤占。我的原则是中断里只做时间敏感的事,其他全部扔到主循环。
3.3 加减速曲线的参数怎么算
加速参数不能拍脑袋填,填大了会丢步,填小了动作慢得让人着急。这里给一套能直接用的算法。
第一步,确定加速度上限。加速度的物理意义是“电机能多快地把速度提上去而不失步”。这个值和负载惯量、电机扭矩、驱动电流都有关,理论算起来很麻烦。工程上的做法是先给一个保守值,实测逐步往上加:从a = 10000 步/秒²开始,跑一条长距离动作,观察电机有没有异响或者到位偏差,没有就加到 15000、20000,直到出现异常再退回来一档。我那个三轴平台上最终稳定用的是a = 20000 步/秒²。
第二步,验算加速距离。这一步是很多人漏掉的。给定加速度 a 和最高速度 v,加速阶段需要的步数是S = v² / (2a)。
拿具体数字算:v = 32000,a = 20000,那么S = 32000² / (2 × 20000) = 1024000000 / 40000 = 25600 步。按每毫米 640 脉冲算,就是40mm。也就是说如果你的行程只有 30mm,根本达不到 32000 步/秒这个速度,电机还没加到目标速度就得开始减速了。
第三步,行程不足时反算实际峰值速度。三角速度曲线下,峰值速度是v_peak = √(a × S)。行程 30mm 等于 19200 步,代入得v_peak = √(20000 × 19200) = √384000000 ≈ 19596 步/秒。这个数就是你在这段行程上能跑到的真实最高速度,加减速规划要按它来,而不是按你设定的 32000。
第四步,确定减速起始阈值。decel_threshold = v² / (2a),也就是加速需要的步数,对称的。运行时把“剩余步数”和这个阈值比较,小于阈值就把目标速度置零,电机会按同样的加速度曲线平滑减速到停。
| 参数 | 公式 | 计算示例 |
|---|---|---|
| 加速距离 | v² / (2a) | 32000² / (2×20000) = 25600 步 |
| 三角曲线峰值速度 | √(a × S) | √(20000 × 19200) ≈ 19596 步/秒 |
| 减速阈值 | v² / (2a),取加速距离 | 同上 |
| 单步节拍数 | 时基频率 / 步频 | 40000 / 32000 = 1.25 拍/步 |
提示:加速度实测的时候不要只看“能不能动”,要看“到位后回到起点的累计误差”。失步不一定表现成明显的卡顿,很多时候只是每次少走几步,跑几十个循环之后才暴露出来。
4. 多轴联动:从“一起动”到“同时到”
轴与轴之间会互相牵制了,才是真正的联动。这一节讲插补和同步的工程实现。
4.1 直线插补与Bresenham 的工程化用法
要求两轴走一条斜线时,本质上是要让两轴的位移比在每一步都尽量贴近目标比。经典做法是 Bresenham:找出需要步数最多的那个轴作为主轴,每走一步,从轴累加一次误差,误差超过阈值就从轴也走一步。
放到我们的节拍框架里,做法是这样的:先根据各轴的目标位移算出主轴,主轴走完所需节拍数就等于动作的总节拍数,然后从轴按主轴步数 / 从轴步数的比例分配速度。
typedef struct { int32_t delta[AXIS_NUM]; // 各轴目标位移(带符号) int32_t remain[AXIS_NUM]; // 各轴剩余步数 int32_t major_steps; // 主轴总步数 uint8_t major_axis; // 主轴编号 uint8_t active; } InterpBlock_t; void interp_start(InterpBlock_t *blk, int32_t *delta, float v_peak) { int32_t max_abs = 0; for (int i = 0; i < AXIS_NUM; i++) { blk->delta[i] = delta[i]; blk->remain[i] = abs(delta[i]); if (abs(delta[i]) > max_abs) { max_abs = abs(delta[i]); blk->major_axis = i; } } blk->major_steps = max_abs; // 主轴速度 = v_peak,从轴按比例降速 for (int i = 0; i < AXIS_NUM; i++) { if (blk->remain[i] == 0) continue; float ratio = (float)blk->remain[i] / (float)blk->major_steps; axis[i].vel_target = (int32_t)(v_peak * ratio * 65536.0f); axis[i].dir = (blk->delta[i] > 0) ? 1 : 0; axis[i].steps_left = blk->remain[i]; axis[i].busy = 1; } blk->active = 1; }关键点在于所有轴的速度是按同一个比例因子缩放出来的,主轴走满速,从轴按位移比降速。这样各轴会在同一时刻到达目标点,走出来的就是直线。如果用“各轴都跑自己的最高速”,行程短的轴会先到,轨迹是折线。
有一个坑我踩过:abs(delta[i])在位移为负数时,如果直接强转成无符号会溢出。上面代码里用的是有符号绝对值,没问题,但如果位移量超过int32_t的一半就要小心。实际项目里我一般把位移限制在 ±100 万步以内,够用且有余量。
4.2 圆弧与同步启动的边界条件
圆弧插补是直线插补的推广,把一段圆弧拆成很多小线段,每段用一次直线插补。拆多少段?我的经验是按弦高误差算:如果允许的最大轨迹偏差是 0.02mm,圆弧半径 R,每段对应的圆心角 θ 满足R(1 - cos(θ/2)) ≤ 0.02。R = 20mm 时反算出来 θ 大约 5 度,一圈拆 72 段。段数多了会带来频繁的重新规划开销,所以我一般在每段结束时才重新计算下一段的速度,中间不打断。
同步启动有个必须处理的边界:所有轴都到达目标、速度归零之后,才能触发下一段动作。我第一版是只要有一个轴busy == 0就认为动作完成,结果多轴联动时经常出现主轴停了、从轴还在收尾的情况,下一段动作提前启动,位置全乱。正确的做法是遍历所有参与本次动作的轴,全部busy == 0且vel_q16 == 0才算完成,然后才推进队列。
还有一个细节是方向切换时的停顿。如果上一段动作结束时某轴还有残余速度,下一段要求反向,直接在高速下反转方向会造成驱动器过流保护甚至机械冲击。我的处理是:方向变化时强制插入一个“速度归零”的中间状态,先减速到零,再改方向,再加速。这个中间状态虽然会多花几十毫秒,但换来的是可靠性,完全值得。
5. 自定义动作:把动作变成数据而不是代码
“灵活自定义动作”这个需求,说白了就是别每次改动作都重新烧固件。实现思路是把动作抽象成一张表,固件只负责解释执行这张表,表的内容通过串口、按键或者存储介质来更新。
5.1 动作指令的数据结构
先定义单条动作。我的设计里,一条动作包含:目标轴、目标位置(绝对或相对)、速度、加速度、动作结束后的停留时间、标志位。
typedef struct { uint8_t axis_mask; // 位掩码,支持多轴同时动作 uint8_t mode; // 0=绝对定位 1=相对定位 2=回零 3=延时 4=输出IO int32_t target[AXIS_NUM]; // 各轴目标位置,单位:脉冲 uint16_t speed; // 峰值步频,单位:百步/秒 uint16_t accel; // 加速度,单位:百步/秒² uint16_t dwell_ms; // 到位后停留,单位:毫秒 uint8_t flags; // bit0=急停后是否跳过 bit1=是否等待IO } MotionItem_t;用位掩码表示参与轴,比给每个轴单独写一条动作更紧凑,也能天然表达“多轴同时动”。mode用枚举区分动作类型,延时和 IO 输出也做成动作,这样整条流程(走位、等一会儿、开关一个气阀、再走位)可以全部写在表里,固件不用改。
位置用脉冲数而不是毫米,是为了避免浮点运算和单位换算带来的累积误差。上层配置的时候用毫米输入,换算成脉冲之后存进去,运行时不再做除法。
5.2 环形队列与状态机调度
动作表在内存里用环形队列管理,主循环负责往队列里塞,节拍中断和状态机负责取。
#define QUEUE_SIZE 64 static MotionItem_t queue[QUEUE_SIZE]; static volatile uint8_t head = 0, tail = 0; typedef enum { ST_IDLE = 0, ST_MOVING, ST_DWELL, ST_ESTOP } SysState_t; static SysState_t sys_state = ST_IDLE; static uint32_t dwell_start_tick = 0; void motion_task(void) // 主循环调用,1ms 周期 { switch (sys_state) { case ST_IDLE: if (head != tail) { MotionItem_t *it = &queue[tail]; if (it->mode == 3) { // 延时动作 dwell_start_tick = sys_tick; sys_state = ST_DWELL; } else if (it->mode == 4) { // IO 动作 io_apply(it); tail = (tail + 1) % QUEUE_SIZE; } else { int32_t delta[AXIS_NUM]; for (int i = 0; i < AXIS_NUM; i++) { if (it->axis_mask & (1 << i)) { delta[i] = (it->mode == 0) ? (it->target[i] - axis[i].pos) : it->target[i]; } else delta[i] = 0; } interp_start(&blk, delta, it->speed * 100); sys_state = ST_MOVING; } } break; case ST_MOVING: if (interp_done(&blk)) { if (queue[tail].dwell_ms) { dwell_start_tick = sys_tick; sys_state = ST_DWELL; } else { tail = (tail + 1) % QUEUE_SIZE; sys_state = ST_IDLE; } } break; case ST_DWELL: if ((sys_tick - dwell_start_tick) >= queue[tail].dwell_ms) { tail = (tail + 1) % QUEUE_SIZE; sys_state = ST_IDLE; } break; case ST_ESTOP: // 等待所有轴速度归零后清标志 if (all_axes_stopped()) { sys_state = ST_IDLE; } break; } }这个状态机的价值在于:任何时刻系统的状态是明确且唯一的。你去看sys_state就知道它在干什么,排查问题的时候一眼能定位。我见过不少项目用一堆标志位互相配合,最后没人说得清哪个组合是什么状态,一出问题只能靠猜。
环形队列要注意头尾指针的并发访问。节拍中断里只读axis[],不碰队列;队列的读写都在主循环里完成,所以不存在竞争。如果非要从中断里塞数据,记得加临界区保护,或者改成单生产者单消费者模式用原子操作。
5.3 三种配置入口的实现
动作表存在哪、怎么改,决定了这套东西好不好用。我一般同时做三个入口。
串口命令:最简单的文本协议,一行一条命令。比如MOVE X1000 Y500 V800 A200 D50表示 X 轴到 1000、Y 轴到 500、速度 800、加速度 200、停留 50ms。解析器按空格切分,逐字段赋值,简单粗暴但足够用。调试阶段全靠它。
按键与显示:面板上放几个按键,配合一个小的显示模块,能做单步点动、参数查看、动作表选择。这个不是必需品,但现场调试的时候很有用,尤其是没法接电脑的场合。
掉电存储:动作表存在片内 Flash 或者外挂的存储芯片里,上电自动加载。写 Flash 要注意擦除块大小和寿命,我一般把动作表放在单独的页上,用“整页擦除 + 重写”的方式更新,避免频繁擦写。
注意:串口命令解析一定要做长度限制和非法字符过滤。我遇到过上位机误发了一串乱码,解析器没做校验直接把目标位置写成了接近
int32_t上限的值,轴直接撞到限位上。后来加了范围校验,超出软限位直接拒绝执行并回一条错误信息。
5.4 一个完整的动作表实例
假设有个三轴的机构,动作流程是:回零 → 到取料位 → 等 200ms → 夹爪闭合 → 抬升 → 到放料位 → 等 300ms → 松开 → 回原点。
对应的动作表长这样:
| 序号 | 模式 | 参与轴 | 目标位置 | 速度 | 加速度 | 停留 | 说明 |
|---|---|---|---|---|---|---|---|
| 1 | 回零 | X,Y,Z | - | 500 | 200 | 0 | 三轴依次回零 |
| 2 | 绝对 | X,Y | 1000, 2000 | 800 | 200 | 0 | 到取料位 |
| 3 | 延时 | - | - | - | - | 200 | 等待稳定 |
| 4 | IO | - | - | - | - | 0 | 夹爪闭合 |
| 5 | 相对 | Z | +3000 | 400 | 150 | 0 | 抬升 |
| 6 | 绝对 | X,Y | 8000, 2000 | 1200 | 250 | 0 | 到放料位 |
| 7 | 延时 | - | - | - | - | 300 | 等待稳定 |
| 8 | IO | - | - | - | - | 0 | 夹爪松开 |
| 9 | 绝对 | X,Y,Z | 0, 0, 0 | 1000 | 200 | 0 | 回原点 |
这张表存成二进制结构体数组也就几百字节,存在 Flash 里绰绰有余。改成文本格式存的话更直观,上电时解析一遍,代价是几百毫秒启动时间和一点 RAM。我倾向文本格式,因为改起来方便,现场让人直接改文件就行,不用带编译环境。
6. 调试与排障实录
写了这么多,最后还是要落到“出问题怎么查”。多轴系统的故障有一半以上表现得很像,但原因完全不同,需要有系统性的排查顺序。
6.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 单轴正常多轴丢步 | 电源功率不足 | 示波器看母线电压是否塌陷 | 加大电源功率,加储能电容 |
| 高速段丢步低速正常 | 峰值步频超过驱动器上限 | 逐级提高速度找临界点 | 降低最高速或提高驱动器规格 |
| 到位后位置误差累积 | 加减速太猛导致瞬时失步 | 加速度减半复测 | 加速度调到不失步的最大值 |
| 多轴长时间运行后错位 | 各轴时基不统一 | 检查是否共用同一节拍中断 | 统一到单一时基 |
| 限位误触发 | 信号线受脉冲串扰 | 屏蔽线、加滤波、分开走线 | 单端接地 + RC 滤波 |
| 急停后电机还在走 | 急停没做统一处理 | 查急停标志是否在所有轴生效 | 全局状态位 + 节拍边界统一停机 |
| 动作表执行到一半卡住 | 状态机死锁 | 打印 sys_state 和队列头尾指针 | 补上完成判定的边界条件 |
| 电机发烫但扭矩不足 | 电流设置过高导致磁饱和 | 测相电流,摸温度 | 按额定值的 80% 重设 |
这张表我基本是照着现场遇到的顺序排的,越往上越常见。实际排查的时候按“电源 → 信号 → 参数 → 逻辑”的顺序来,能省掉大量来回折腾。
6.2 几个踩过的坑
坑一:以为丢步是电机问题,其实是电源。三轴同时加速的时候,母线电压从 24V 掉到 19V,驱动器直接保护性降低输出,表现就是高速段丢步。后来并了一个 4700μF 的电解电容在驱动器电源入口,问题消失。这个电容的作用不只是储能,更重要的是吸收几个轴同时换向时产生的电流尖峰。经验值:每轴配 1000μF 以上,越靠近驱动器越好。
坑二:串口命令解析没有做流控。连续快速发几十条动作,主循环来不及处理,队列满了之后新的命令直接覆盖旧命令,动作顺序全乱。加了“队列满则返回忙状态,上位机等应答再发”的握手机制之后才稳定。这个坑的教训是:只要涉及外部输入,一定要考虑对端发得比你处理得快的情况。
坑三:软限位的判断时机不对。最初我把软限位判断放在动作下发时,结果一条相对运动动作在当前速度为负的情况下叠加,算出来的目标位置合法但过程中间会越界。后来改成在插补过程中实时判断,一旦某轴累计位置超出软限位,立即触发减速停止,才彻底解决。
坑四:中断里调用了浮点运算。一开始为了省事,速度比例直接用了float除法,结果在中断里触发浮点运算的开销远超预期,节拍偶尔抖动到几十微秒,高细分下能看出来。把所有比例计算提前到动作开始时用定点数算好,中断里只做整数加减,抖动立刻消失。
坑五:动作表版本没做校验。有次更新了结构体定义,新增了一个字段,但存储区里的旧数据没清,解析出来的参数全是乱的,电机乱走。后来在动作表头部加了一个版本号和长度校验,版本不匹配就恢复默认表并提示。这个习惯后来救了我好几次。
7. 关于这套方案还能怎么扩展
这套框架跑通之后,往上加东西其实挺顺的。比如想做多段连续的轨迹,把直线插补的段拆得更细,在每段之间做前瞻预处理,提前算好下一段的起始速度,就能做到拐角不停顿。我的做法是在队列里往前多看两段,如果下一段的方向变化不大,就不把速度降到零,而是降到拐角允许的通过速度。
再比如想接上位机做图形化配置,串口协议里加一条“上传整张动作表”的批量命令,上位机把表打包成二进制帧发下来,固件校验 CRC 之后整体替换。这样配置体验会比一条一条发命令好很多。
还有就是想在动作表里加上条件判断。比如“如果某个输入为高就跳到某一条”,那动作结构体里加一个跳转字段,状态机里加一个比较分支就行。这个已经开始往简易 PLC 的方向走了,看具体需求决定要不要做。
我自己用下来最深的体会是:多轴协同这件事,难点从来不在单个轴的算法,而在于把所有轴的时间关系牢牢绑在一根时间轴上。只要这个约束守住了,剩下的加减速、插补、动作配置都是在这个基础上的自然延伸,改起来也不会互相影响。反过来,如果一开始就允许各轴各跑各的,后面无论怎么优化都是在补漏洞,补不完的。所以如果现在让我重新搭一遍,我还是会先花半天时间把那个 40kHz 的节拍中断和相位累加器写扎实,然后再往上堆功能。