基于STM32定时器的双轴同步直线插补实现
简介:YSF4_HAL_MOTOR-219 双轴同时运动工程包面向STM32运动控制学习者与嵌入式开发者,围绕定时器PWM输出、双轴同步和直线插补展开,可用于机器人、自动化设备等场景的入门学习与方案参考。压缩包共660个文件、约7.14MB,主体为368个C源文件、143个头文件、71个汇编文件及48个ICF链接脚本,另含工程配置、HEX、PDF/DOCX说明和少量调试记录,较完整地保留了源码、编译产物与目录结构。已有252人学习。读者可从中梳理HAL库下定时器配置、PWM调速、双轴同步及直线插补算法的代码组织,结合工程文件理解编译链接、调试流程和参数整定思路。工程中还涉及梯形与S曲线加减速、编码器反馈、电机驱动设置及同步误差控制等考虑,便于快速定位关键代码与验证思路,适合作为课程设计、项目原型或排错对照的参考资料。
1. 从“两个电机各自转”到“走一条直线”:双轴联动的需求边界
很多人第一次搭 XY 平台,两个轴分开调都能跑,单轴点动、回零、调速一切正常,一旦要求两轴同时启动、同时到达,画出来的线就变成斜一段、折一段,甚至末端一轴先停、另一轴还在爬。问题通常不在驱动器,也不在电机力矩,而在于“谁来定两轴每一步的节奏”这件事没想清楚。这套 STM32 定时器驱动双轴同时运动的方案,核心就是把直线插补和脉冲发生拆开:一个节拍定时器统管两轴,每次中断算一次“这一拍谁走、走几步”,两轴的脉冲在同一个时间基准上发出。它适合正在做 XY 绘图仪、点胶平台、写字机、数控小台子这类毕设或课程设计的人,也适合已经写过单轴步进代码、想补上联动这一块的工程师。下面从方案结构一路讲到怎么验收。
2. 总体方案设计:一个节拍定时器统管两轴脉冲
先把最容易踩错的地方摆出来:不少人给 X 轴配一个定时器、Y 轴配一个定时器,各自算频率、各自跑中断。单轴看没问题,两轴一起动就出问题——两个定时器的相位关系是随机的,起步瞬间谁先谁后完全看运气,频率一改更是各跑各的,直线自然走不直。这套方案的选择是反过来的:只用一个定时器做节拍源,两轴的走步决策都在同一个中断里完成。
2.1 职责划分:插补层、规划层、执行层各管一段
整个工程按三层切,层与层之间只传结构体,不互相摸对方的寄存器。
| 层次 | 负责的事 | 不负责的事 | 主要文件 |
|---|---|---|---|
| 指令层 | 解析 G01 X… Y… F… 这类运动指令,压入指令队列 | 不关心步数怎么算 | motion_cmd.c |
| 规划层 | 由起点终点算两轴步数、方向、总步数、加减速参数 | 不碰定时器和 GPIO | interpolate.c |
| 执行层 | 定时器中断里做脉冲分配、翻转 GPIO、更新 ARR | 不知道直线还是圆弧 | motor_hw.c |
这么切的好处很实际:调插补算法时不用连板子,把Line_ISR()拿出来在 PC 上跑一遍,把每拍的坐标打印出来,画在纸上就知道直不直;调硬件时也不用改算法,只改 GPIO 宏定义。带过完整项目的人一般都会这么切,因为一旦算法和硬件糊在一起,改一个引脚都要重新编译验证整条链路,血泪经验。
规划层输出的结构体大致是这几个字段:steps(总步数,取两轴步数的较大值)、dx/dy(两轴步数绝对值)、sx/sy(方向,±1)、v_target(目标合成速度,mm/s)、acc(加速度,mm/s²)。执行层只认这个结构体,不认识“直线”这个概念,将来要加圆弧插补,只需要在规划层多写一个把圆弧离散成小线段的上层,执行层一行不用改。
2.2 定时器与引脚资源分配
以常见的 STM32F4 为例,主频 72MHz 到 168MHz 都行,这套配置按 72MHz 写,换芯片只需要重算 PSC。定时器分配如下:
| 资源 | 用途 | 关键配置 |
|---|---|---|
| TIM3 | 节拍定时器,产生插补中断 | PSC=71,计数频率 1MHz,ARR 动态改写 |
| GPIO PA0 | X 轴脉冲 | 推挽输出,默认低电平 |
| GPIO PA1 | Y 轴脉冲 | 推挽输出,默认低电平 |
| GPIO PA2 / PA3 | X / Y 方向 | 推挽输出,走步前先置方向 |
| GPIO PA4 | 驱动器使能 | 上电拉高,急停拉低 |
选 TIM3 是因为它是通用定时器、16 位、带更新中断,够用且不占高级定时器的互补输出资源。PSC 取 71 把 72MHz 分成 1MHz,也就是计数器每 1 微秒加一,这样 ARR 的值直接对应“多少微秒一拍”,改速度就是改一个整数,心里有数。步进频率 5kHz 时 ARR=199,10kHz 时 ARR=99,20kHz 时 ARR=49,都在 16 位范围内,不需要动 PSC。
有一个取舍要说清楚:这套方案在中断里直接把 GPIO 拉高、短延时、拉低,脉冲宽度靠几个__NOP()撑到 2~3 微秒,能满足大多数细分驱动器的要求。代价是步频上限受中断耗时限制,实测在 72MHz 下跑到 15kHz 左右还算稳,再往上就要换思路——把脉冲发生交给定时器的 PWM 通道或单脉冲模式,中断只做插补决策和通道使能。毕设和课设的行程、速度要求一般在 10kHz 以内,直接 GPIO 翻转是最省事也最好讲清楚的写法。
2.3 实时性预算:中断里到底能干多少活
有人担心“中断里算插补会不会太慢”。算一笔账:一次中断要做的事是读结构体、比较两个整数、决定走哪轴、翻转 GPIO、算一次 ARR、写寄存器,编译出来大概 80~150 条指令,在 72MHz 上 2~4 微秒。10kHz 步频时中断周期 100 微秒,CPU 占用 2%~4%,主循环还能跑串口、按键、屏幕刷新。真正会翻车的是在中断里塞了浮点开方、printf、或者 HAL 的阻塞延时,这几样随便沾一个,占用立刻翻几倍,高步频时就丢拍。
所以规划层的浮点运算要放在启动阶段一次算完,中断里只做整数比较和一次简单的速度迭代,具体见第 3 章的代码。这条边界划清楚,整套方案的稳定性就有底了。
3. 直线插补实现:误差累积分配与梯形加减速的配合
直线插补要回答两个问题:这一拍两轴各走几步;这一拍隔多久发。前一个问题是几何问题,后一个问题是速度问题,两者必须分开算,否则改速度会牵动走步比例,直线就走歪了。
3.1 误差累积:为什么主轴每拍必走一步
两轴步距相同的前提下,直线插补最常用的做法是以步数多的那根轴作为主轴,主轴每一拍固定走一步,从轴按累积误差决定这一拍走不走。这样总拍数就等于主轴步数,两轴必然同时启动、同时收尾,末端不会出现一轴先停的台阶。
/* 直线插补上下文,规划层算好,执行层只读 */ typedef struct { int32_t dx, dy; /* 两轴步数绝对值 */ int32_t sx, sy; /* 方向:+1 或 -1 */ int32_t steps; /* 总拍数 = max(dx, dy) */ int32_t err; /* 从轴误差累积器 */ int32_t cur; /* 已走拍数 */ uint8_t done; /* 1 = 本段结束 */ } LineCtx_t; /* 每次节拍中断调用一次,产生一拍脉冲 */ void Line_ISR(LineCtx_t *L) { if (L->done) return; if (L->dx >= L->dy) { /* X 为主轴:每拍必走 X,累积够了再补一步 Y */ L->err += L->dy; Step_X(L->sx); if (L->err >= L->dx) { L->err -= L->dx; Step_Y(L->sy); } } else { /* Y 为主轴,逻辑对称 */ L->err += L->dx; Step_Y(L->sy); if (L->err >= L->dy) { L->err -= L->dy; Step_X(L->sx); } } if (++L->cur >= L->steps) L->done = 1; }err的含义是“从轴欠了多少步”。主轴每走一步,给err加上从轴步数;一旦err超过主轴步数,说明累积误差够一步了,从轴补一步并把err减回去。整个过程只有整数加减和比较,没有除法、没有浮点,中断里跑起来很轻。
判断主轴用>=而不是>,是为了在 45 度这种两轴步数相等的极限情况下,让 X 稳定地当主轴,避免每次编译优化不同导致主轴选择抖动。Step_X()/Step_Y()里做的事是:按方向置好方向脚、拉高脉冲脚、短延时、拉低。方向脚只在方向变化时改,不要每拍都写,写多了在某些驱动器上会被当成噪声。
这套写法的直线度误差不超过半个脉冲当量,也就是一个步距以内,肉眼和卡尺都看不出来。有人喜欢用 DDA 的浮点累加版本,效果一样,但中断里跑浮点要做现场保护,F4 的 FPU 在中断里用要额外小心,不如整数省心。
3.2 速度换算:把 mm/s 变成定时器 ARR
速度这一层要处理两个换算。第一个是合成速度换成主轴步频。设脉冲当量step_mm(毫米每步),比如丝杆导程 4mm、1.8 度电机 16 细分,step_mm = 4 / (200 × 16) = 0.00125,即 800 步每毫米。合成长度用步数表示是hypot(dx, dy),主轴步数是steps,那么主轴步频:
/* 启动前算一次,结果存进上下文 */ float len_steps = sqrtf((float)dx * dx + (float)dy * dy); float f_main = (v_target / step_mm) * ((float)steps / len_steps);steps / len_steps这一项就是主轴在合成方向上的投影比例,45 度时是 0.707,沿 X 轴走时是 1。少了这一项,斜着走的速度会比直着走快,画方框时四个角的速度不一致,一眼就能看出来。
第二个换算是步频换成 ARR。计数频率 1MHz 时:
/* f_step 单位 Hz,返回写进 ARR 的值 */ static uint32_t FreqToARR(float f_step) { float arr = 1000000.0f / f_step - 1.0f; if (arr < 20.0f) arr = 20.0f; /* 上限约 47kHz,保护中断耗时 */ if (arr > 60000.0f) arr = 60000.0f; /* 下限约 16Hz,防止停转 */ return (uint32_t)arr; }上限 20 对应约 47kHz,实际跑不到那么高,留这个值只是防止参数填错时 ARR 变成 0 导致定时器行为异常。下限 60000 是给极慢的手动点动留的余量,低于这个频率电机听着就是一格一格地响,不如直接停。写完 ARR 之后记得调一次__HAL_TIM_SET_AUTORELOAD(),下一个周期才生效,别去改计数器。
3.3 梯形加减速与减速点预判
恒速启动在轻载下能用,负载稍大就丢步,所以加减速要有。梯形曲线比 S 型简单,参数只有加速度acc(mm/s²)和目标速度v_target,够应付绝大多数课设场景。
加速段每拍更新一次速度,用迭代而不是开方:
/* 每拍调用,dt 是上一拍的周期,单位秒 */ static void Speed_Update(void) { float dt = 1.0f / cur_freq; switch (state) { case ST_ACC: v += ACC * dt; /* 线性加速 */ if (v >= v_target) { v = v_target; state = ST_RUN; } break; case ST_RUN: /* 剩余步数进入减速区,提前切减速 */ if (steps_left <= dec_steps) state = ST_DEC; break; case ST_DEC: v -= ACC * dt; if (v < V_MIN) v = V_MIN; /* 留一点末速度,别减到 0 */ break; } cur_freq = (v / step_mm) * proj_ratio; /* 合成速度 -> 主轴步频 */ __HAL_TIM_SET_AUTORELOAD(&htim3, FreqToARR(cur_freq)); }dec_steps是减速段需要多少拍,启动时一次算好:dec_steps = v_target² / (2 × acc) / step_mm × proj_ratio。这里单位要统一到“拍”,算错了会怎样?算大了提前减速,走一半就开始爬;算小了到终点还在全速,直接过冲撞限位。我的习惯是把dec_steps打出来跟总步数比一下,超过总步数的一半就说明加速度设小了或者速度设高了,这段直线根本加速不起来,得先降速。
V_MIN不要设成 0,最后几拍速度太低会让 ARR 顶到下限,最后一拍的时间被拉长,末端看起来像“顿一下”。给个 5~10mm/s 的末速度,走完直接停脉冲,反而干净。
4. HAL 落地:定时器中断、脉冲输出与状态机的接线方式
算法层写完之后,落到 HAL 上的工作量其实不大,关键是把初始化的顺序和中断里的边界处理好。
4.1 定时器初始化:PSC 定分辨率,ARR 定速度
/* TIM3:1MHz 计数,ARR 由速度动态改写,先给个安全值 */ static void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef clk = {0}; TIM_MasterConfigTypeDef mst = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 71; /* 72MHz / 72 = 1MHz */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 999; /* 初始 1kHz,启动时会被覆盖 */ htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim3); clk.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &clk); mst.MasterOutputTrigger = TIM_TRGO_RESET; mst.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &mst); }AutoReloadPreload必须打开。不打开的话,中断里写 ARR 有可能正好撞上计数器溢出,新值要等下一轮才生效,表现出来就是速度曲线有一拍是乱的,高速时听着电机“咔”一下。这个坑很隐蔽,示波器上才能看见某一拍脉宽明显不对。
启动和停止都封成函数,别在主循环里直接操作寄存器:
void Motion_Start(LineCtx_t *L) { if (L->steps == 0) return; cur_freq = V_START / step_mm * proj_ratio; /* 起始速度 */ __HAL_TIM_SET_AUTORELOAD(&htim3, FreqToARR(cur_freq)); __HAL_TIM_SET_COUNTER(&htim3, 0); state = ST_ACC; HAL_TIM_Base_Start_IT(&htim3); } void Motion_Stop(void) { HAL_TIM_Base_Stop_IT(&htim3); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); /* 关使能 */ state = ST_IDLE; }Motion_Stop()里先停中断再关使能,顺序反了的话,关使能的瞬间可能还有一拍脉冲在路上,电机发出一声异响。急停按钮直接调这个函数,不要在里面加延时或打印。
4.2 中断回调:把插补结果落到 GPIO
HAL 的更新中断最终走到HAL_TIM_PeriodElapsedCallback(),这里只做一件事——调插补,别的都别塞。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM3) return; if (line.done) { /* 本段走完,立刻停,不再产生脉冲 */ Motion_Stop(); cmd_queue_pop_and_start(); /* 取下一段,没有就空转 */ return; } Line_ISR(&line); /* 走一拍:决策 + 发脉冲 */ Speed_Update(); /* 更新下一拍的 ARR */ }注意line.done的判断放在最前面。放在后面的话,最后一步走完还会多产生一拍脉冲,行程累积误差,走一百条线段就偏出去一步。这种“多一拍”的问题在短线段多的图形上特别明显,画个圆能看出来接缝处有毛刺。
Step_X()内部大致是这样:
static inline void Step_X(int8_t dir) { if (dir != last_dir_x) { /* 方向变了才写 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, dir > 0 ? GPIO_PIN_SET : GPIO_PIN_RESET); last_dir_x = dir; } HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); for (volatile int i = 0; i < 20; i++) __NOP(); /* 约 2~3us 脉宽 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); }那个for循环的圈数要按主频实测。72MHz 下 20 圈大约 2.5 微秒,168MHz 下要加到 45 圈左右。脉宽不够,驱动器认不到;脉宽太长,高步频时占空比过大,下一拍还没拉低就来了。用示波器量一下最稳,别拍脑袋。
4.3 指令队列与运动状态机
上层只往队列里塞目标点,中断负责消费,两边用一个头尾指针加__disable_irq()保护临界区,队列入队出队各三五条指令,比上 RTOS 轻得多。
状态机的四个状态ST_IDLE / ST_ACC / ST_RUN / ST_DEC只在中断里改,主循环只读,用于界面显示。这样避免了主循环写状态、中断也写状态导致的竞争。要做暂停功能,加一个ST_PAUSE,中断里看到这个状态就直接 return 不发脉冲,但定时器还在跑,恢复时不用重配 ARR,比反复启停定时器平滑。
5. 常见问题排查:脉冲丢步、直线走偏与启动抖动的四类根因
这一章列的都是实际调试时最常撞上的,按“现象 → 原因 → 解决”写,对着查能省不少时间。
5.1 脉冲层:走不动、丢步、频率上不去
现象一:低速正常,速度一提就丢步,电机嗡嗡响但不转。原因多半是中断耗时超过了节拍周期。中断里如果有浮点开方、printf、HAL 的阻塞延时,单次耗时可能到十几微秒,节拍 100 微秒时看着还能跑,节拍降到 50 微秒就顶不住了,脉冲被吞掉。解决是把浮点运算全部挪到启动阶段,中断里只留整数比较;用示波器量一下中断服务函数的执行时间,留出至少 50% 余量。另一个可能原因是驱动器要求的最小脉宽没满足,for循环圈数按主频重新标定。
现象二:两轴分别测都正常,一起动就有一轴明显滞后。原因是两轴的Step_X()/Step_Y()里各有一段忙等脉宽延时,先调的那轴延时会挤占后调那轴的时间,一拍的周期被拉长,频率越高偏差越大。解决是把两轴的脉冲同时拉高、延时一次、再同时拉低,让延时只付一次;或者干脆改用定时器 PWM 通道输出脉冲,中断只负责使能,脉宽由硬件保证。我一般会把两轴的置位写在紧挨着的两行,中间只隔几条指令,同步误差能压到几十纳秒。
现象三:跑几分钟后突然停住,复位又能跑。先查 ARR 是不是被算成了 0 或极小值。速度参数填错、proj_ratio算成 0、除零得到无穷大,都会让FreqToARR()返回一个非法值,定时器行为异常。加一句上下限钳位就能挡住。另一种情况是方向脚每拍都写,某些驱动器把频繁的方向翻转当成干扰信号,内部保护性停机,改成方向变化时才写即可。
5.2 插补与机械层:走偏、过冲、起步抖动
现象四:理论上该走直线的两点,实际走出来中间鼓一点或者末端拐一下。九成是主轴选择或误差累积写错。检查err的比较阈值用的是dx还是dy,主轴分支里的累积量必须是“从轴步数”,一旦写反,走出来是阶梯状的折线。还有一种情况是steps用了dx + dy,这样总拍数偏多,末端会多走一段,直线末端出现一个小勾。正确做法是取max(dx, dy)。调试时把每拍的(x, y)打印出来,在纸上点一遍,一眼就能看出问题。
现象五:起步瞬间声音发闷、负载大时丢步,或者到终点“哐”一下撞上去。丢步是起始速度设太高,直接从 0 跳到目标速度,惯量大的平台扛不住。把起始速度压到目标速度的 10%~20%,加速段就有了缓冲。撞终点是dec_steps算小了,减速点预判偏晚,全速冲到末端才想起来减速。算完之后跟总步数比一比,减速段超过总步数一半就说明这段直线的规划本身不合理,要么降速要么降加速度。还有一种隐蔽情况:短线段很多时,每段都重新从起始速度开始加速,速度永远上不去,整体走起来一顿一顿,这种要在规划层做前瞻,把连续共线的短段合并,属于进阶话题,先把单段跑稳再考虑。
6. 验收与答辩:用三个测试把“同时运动”讲清楚
方案做完了,怎么证明它真的“同时”?答辩现场最怕说不清。我的做法是准备三个能当场复现的测试,每个都有量化指标。
| 验收项 | 测试方法 | 合格标准 |
|---|---|---|
| 直线度 | 画一条 100mm 的 45 度线,量起点到终点的连线与理论直线的最大偏差 | 偏差 ≤ 2 个脉冲当量(约 0.0025mm) |
| 同步性 | 用示波器双通道同时抓两轴脉冲,看首拍和末拍的相对位置 | 首拍时间差 < 5us,末拍时间差 < 一拍周期 |
| 速度一致性 | 分别走 X 向、Y 向、45 度三条 100mm 线,记录耗时 | 三条线耗时偏差 < 3% |
| 加减速 | 从 0 加速到 50mm/s 再停,观察有无丢步 | 连续 20 次往返,位置误差不累积 |
直线度这一项,量不到 2 个脉冲当量也没关系,只要说明用的是整数误差累积、理论误差上限就是一步,比含糊地说“挺直的”强得多。同步性用示波器抓是最有说服力的,把两轴的首个脉冲和最后一个脉冲截图放进答辩 PPT,比讲十分钟算法管用。
答辩时容易被问到的两个问题,提前准备好答案:一是“为什么不用两个定时器”,答两轴相位不可控、改速度时容易不同步;二是“为什么中断里发脉冲而不是 PWM”,答频率要求不高、逻辑直观、便于观察每一步的决策过程,同时说明高频场景的替代方案。这两个问题答上来,评委基本就认可你想清楚了。
还有一个我自己的习惯:出图之前一定会先跑一遍“单段长直线 + 单段短直线 + 连续折线”这组最小用例,用 A4 纸垫在笔下面画出来,用眼睛先看一遍。纸上都歪了,示波器上数据再漂亮也没用。从那以后我每次改插补代码,都强制走一遍这组用例再看后面的指标。希望这套结构能帮到你,少在同步问题上绕弯路。
本文还有配套的精品资源,点击获取