资讯详情

STM32定时器+直线插补双轴联动运动控制方案

📅 2026/10/26 3:39:22 | 华诺云谱 👁 阅读
STM32定时器+直线插补双轴联动运动控制方案
简介面向STM32运动控制学习者与自动化、机器人方向开发者的双轴联动直线插补工程包围绕定时器PWM输出实现两轴同步运动涉及预分频与计数器配置、占空比调节、梯形或S曲线加减速及位置步进分解适合在具备HAL库基础后进阶理解实时控制与轨迹规划。压缩包共660个文件、约7.14MB以368个C源文件和143个头文件为主体辅以启动汇编、链接脚本、批处理及hex、axf等构建产物并包含多种IDE工程配置便于直接编译、对照与移植。已有252人学习下载。读者可从中获得一套可运行的双轴插补参考实现结合定时器同步、插补算法与编码器闭环思路快速搭建实验平台并排查运动不同步、加减速冲击等问题。1. 两轴联动的第一道坎单轴跑得再准拼起来也会走歪两根轴分开调的时候都挺听话点动、回零、单轴定位一个不差可一旦让它们同时起步去画一条斜线轨迹走到中途开始偏末端两轴的到达时间差出十几毫秒直线硬生生变成一道弧。这是两轴同时运动项目里最典型的第一道坎——难点从来不在单个电机能不能转而在于两路脉冲在时间轴上怎么对齐每个时刻两轴各该分到多少步。这套基于 STM32 定时器与直线插补的双轴运动方案拆的就是这件事定时器做统一时基插补算法把一条直线拆成两轴的步数分配HAL 层把外设配置固化下来让双轴在同一条时间线上各自走完自己的路径。适合正在做运动控制课设、毕设或者要给小型雕刻机、点胶机、写字机搭一套联动底盘的人拿去当骨架改。2. 总体架构三层分工把「同时」这件事拆开2.1 主时基为什么必须交给定时器主循环轮询是新手最容易踩的坑。用while(1)里读标志位、算插补、翻 IO 的写法单轴低速跑看着没问题一旦把速度提到 5 kHz 以上串口打印、按键扫描、LCD 刷新随便哪个函数多花几微秒脉冲周期就抖一下。步进电机对周期抖动极其敏感表现出来就是啸叫、丢步、走位偏移排查半天找不到原因因为逻辑本身没错错在时基。改成定时器更新中断做节拍源之后节拍由硬件产生只要中断服务函数写得足够短周期抖动可以压到几十纳秒量级。这里的关键取舍是主循环只负责「决定去哪」定时器中断只负责「这一拍怎么走」。所有带浮点、带打印、带分支判断的路径规划逻辑一律放在主循环里算完把结果压缩成「每拍走不走、走几步」这种极简形式交给中断。我一般会把插补节拍频率定在 10 kHz 到 20 kHz 之间。低于 10 kHz高细分下电机低速段会有明显颗粒感高于 20 kHz中断里那点固定开销开始吃 CPU尤其在 72 MHz 主频的片子上20 kHz 意味着每 50 微秒就要完整执行一遍中断进出、插补判断、GPIO 翻转留给主循环的时间已经很紧。2.2 三层职责划分指令层、插补层、脉冲输出层整套方案按依赖方向切成三层层与层之间只用结构体传递数据不互相调用业务函数层级职责运行位置典型输出指令层解析目标坐标、限速、加减速参数维护运动状态机主循环一条待执行的直线段插补层把直线段拆成每拍的步进分配管理速度曲线主循环算 中断取用每拍step_x/step_y脉冲输出层按节拍翻转方向脚和脉冲脚更新绝对位置定时器中断两路 STEP 脉冲这样分层的好处是调试时可以逐层替换。比如怀疑是插补算错就把脉冲输出层换成「只累加位置、不真正出脉冲」的空跑模式让程序跑完一整条线段后打印终点坐标跟目标坐标一比就知道是算法问题还是硬件问题。这种空跑开关建议一开始就留好后面验收环节会反复用到。层与层之间的耦合点只有一个Axis_t数组和一份运动命令结构体任何一层都不直接读全局变量。2.3 核心数据结构与工程文件划分单轴的状态用一个结构体描述把外设句柄、方向引脚、位置计数、限速参数全收进去避免后面出现一堆posX、posY之类的平行变量/* axis.h —— 单轴描述双轴就是两个这样的实例 */ typedef struct { TIM_HandleTypeDef *htim; /* 该轴脉冲定时器PWM 或时基 */ uint32_t channel; /* 定时器通道 */ GPIO_TypeDef *dir_port; /* 方向信号端口 */ uint16_t dir_pin; /* 方向信号引脚 */ int32_t pos; /* 当前绝对位置单位脉冲 */ int32_t target; /* 目标绝对位置单位脉冲 */ uint32_t max_freq; /* 该轴允许的最高脉冲频率 Hz */ uint8_t dir_level; /* 正方向对应的电平用于适配不同驱动器 */ } Axis_t; /* 运动状态机主循环据此决定还能不能接收新指令 */ typedef enum { MOTION_IDLE 0, MOTION_RUNNING, MOTION_DECEL, MOTION_FAULT } MotionState_t;pos用int32_t而不是uint16_t是因为很多人在行程稍长的机架上跑几十万脉冲后溢出位置突然跳变表现为「走到一半电机反向猛冲」这类事故在调试阶段很吓人。按 3200 脉冲/圈算int32_t能覆盖上百万圈足够用。工程文件建议这样切motor_cfg.h引脚定义、细分、丝杠导程、软限位全部是宏改机型只改这一个文件axis.c/h单轴初始化、方向设置、位置读写interp.c/h直线插补器纯算法不碰硬件可以拿到 PC 上单独验证planner.c/h速度规划输出当前节拍应该用的定时器周期app_motion.c状态机、命令接收、错误处理把interp.c做成不依赖 HAL 的纯 C是这套结构里最值钱的一条经验。它意味着你可以写个几十行的小测试程序在电脑上枚举几千条不同斜率的线段检查插补器输出的脉冲数是否和理论值完全一致一次把算法层面的 bug 清干净不用每次都烧板子。3. 直线插补实现脉冲怎么在两轴之间分3.1 DDA 与 Bresenham 的误差和代价对比两轴走直线的本质是在总步数N个节拍里X 轴要出dx个脉冲Y 轴要出dy个脉冲且任意时刻两轴走过的步数比例尽量贴近dx:dy。两种经典做法各有取舍。数字积分法DDA每拍给每轴累加一个增量dx/N、dy/N累计超过 1 就出一步。它的优点是合速度恒定不管斜率多少两轴合成的进给速度都一样缺点是要么用浮点中断里开销大要么用定点小数要选好放大倍数否则低速时累积误差明显。Bresenham用整数加减和一个误差项决定这一拍哪轴出步。它没有除法、没有浮点、误差严格不超过半步是单片机上的首选。但有个容易被忽略的性质它保证的是「每拍最多走一步」长轴每一拍都走短轴按比例走。也就是说如果节拍频率固定长轴的脉冲频率就是节拍频率而合速度会随斜率变化45 度时合速度比单轴时高出约 41%。这个性质带来一个实际问题画正方形时四条边的合速度一样因为长轴相同但画一条接近 45 度的长线和一条接近水平的短线手感完全不同。对雕刻、点胶这类对进给速度一致性有要求的场景得在速度规划层补偿后面 3.3 会说怎么补。结论用 Bresenham 定步数分配用规划层定节拍频率。算法负责「比例对」规划负责「快慢对」两件事分开管代码也好写。3.2 插补器的纯 C 实现下面这份实现不依赖任何 HAL可以直接拿到 PC 上跑单元测试/* interp.c —— 纯整数 Bresenham 直线插补器 */ typedef struct { int32_t dx, dy; /* 两轴总步数已取绝对值 */ int32_t sx, sy; /* 两轴方向1 或 -1 */ int32_t err; /* 累积误差项 */ int32_t total; /* 已走节拍数 */ int32_t total_steps; /* 总节拍数 max(dx, dy) */ } LineInterp_t; /* 用起点终点初始化坐标单位是脉冲 */ void LineInterp_Init(LineInterp_t *li, int32_t x0, int32_t y0, int32_t x1, int32_t y1) { int32_t dx x1 - x0; int32_t dy y1 - y0; li-sx (dx 0) ? 1 : -1; li-sy (dy 0) ? 1 : -1; li-dx (dx 0) ? dx : -dx; li-dy (dy 0) ? dy : -dy; li-err li-dx - li-dy; /* 经典 Bresenham 初值 */ li-total 0; li-total_steps (li-dx li-dy) ? li-dx : li-dy; } /* 每个节拍调用一次返回 1 表示还有步要走0 表示整段结束 */ uint8_t LineInterp_Next(LineInterp_t *li, uint8_t *step_x, uint8_t *step_y) { *step_x 0; *step_y 0; if (li-total li-total_steps) { return 0; /* 线段走完 */ } int32_t e2 li-err 1; /* 左移代替乘 2省一个乘法 */ if (e2 -li-dy) { /* X 轴该出一步 */ li-err - li-dy; *step_x 1; } if (e2 li-dx) { /* Y 轴该出一步 */ li-err li-dx; *step_y 1; } li-total; /* 节拍计数前进 */ return 1; }这段代码有三个要点值得说清楚。第一total_steps取两轴步数的最大值意味着长轴每拍都出步短轴按误差项决定出不出。因此节拍频率就等于长轴的脉冲频率这一点直接决定了后面定时器周期的计算方式。第二e2 -dy和e2 dx两个判断是并列的不是二选一所以当斜率为 1 时两轴会在同一拍同时出步这就是「两轴同时运动」在 45 度方向上的天然体现。若写成if/else45 度会退化成交替出步合速度直接掉一半画出来的斜线明显比其它角度慢。第三整个函数没有除法、没有浮点最坏路径也就几十个周期。放在 20 kHz 的中断里完全没有压力。这是选 Bresenham 而不是 DDA 的直接理由。配套的位置更新放在脉冲输出层做插补器本身只回答「走不走」不改pos职责清晰。这样同一份插补代码也能被圆弧扩展复用。3.3 速度规划接进插补的时机定好每拍走多少接下来定「一拍多长」。最朴素的做法是全段用同一个定时器周期即恒速运行。低速小线段勉强能用一旦线段变长、速度变高起步瞬间就是一次阶跃——电机直接堵转或者丢步这是新手最常见的翻车点。合理做法是梯形速度曲线起步频率f_start、目标频率f_max、加速度a单位脉冲/秒²分加速段、匀速段、减速段三部分。实现上不必每拍都重算按固定拍数分批更新就行/* planner.c —— 每 16 拍更新一次定时器周期近似匀加速 */ #define SPEED_UPDATE_TICKS 16 static void Planner_Update(const LineInterp_t *li, uint32_t *freq) { uint32_t remain li-total_steps - li-total; /* 剩余步数少于减速段所需步数时进入减速 */ if (remain g_planner.decel_steps) { *freq - g_planner.freq_step; /* 逐步降频 */ if (*freq g_planner.f_start) { *freq g_planner.f_start; } } else if (*freq g_planner.f_max) { *freq g_planner.freq_step; /* 逐步升频 */ if (*freq g_planner.f_max) { *freq g_planner.f_max; } } /* 匀速段什么都不做保持 f_max */ }freq_step由加速度换算而来每SPEED_UPDATE_TICKS拍经过的时间是16/f秒在这么长的时间里速度提升a × 16/f约等于freq_step。工程上更省事的做法是先算好一张加速表运行时查表递增避免每拍做乘除。表长取 256 或 512 项用f_max/f_start的比例做对数分布加速段的手感会明显更顺。回到 3.1 提到的合速度问题如果要求不管什么斜率合速度都恒定就在算出f_max之后乘一个系数dx / sqrt(dx² dy²)长轴为 X 时把长轴速度按斜率缩一缩。这个开方在主循环里算一次就够不进中断。是否要做取决于场景——雕刻直线纹理时感知明显写字机上基本看不出来按需加。最后提醒一点f_start不要设得太低。常见的步进驱动器在 200 Hz 以下会有明显共振区起步频率压在共振带里每次启动都「嗡」一声并且丢几步。我一般把起始频率定在 600 Hz 到 1 kHz 之间配 16 细分基本能平稳起步。4. 定时器与脉冲输出让两路脉冲走在同一条时间线上4.1 定时器周期的换算与限幅节拍频率定了之后要落到定时器的 ARR 上。公式很简单f_pulse f_tim / ((PSC 1) × (ARR 1))其中f_tim是定时器挂载总线的时钟具体数值取决于芯片型号和时钟树配置常见的是 72 MHz、84 MHz 或 170 MHz 量级配置前务必在时钟树文件里确认不要凭印象写。PSC 一般固定不动取一个能让 ARR 落在合理区间的值运行时只改 ARR。/* axis.c —— 运行时只改 ARRPSC 保持不变 */ static void Axis_SetPulseFreq(TIM_HandleTypeDef *htim, uint32_t f_pulse) { const uint32_t tim_clk 84000000U; /* 按实际定时器时钟修改 */ uint32_t psc htim-Instance-PSC; /* 已配置好读出来即可 */ if (f_pulse 100U) { f_pulse 100U; /* 下限保护防止除零和超长周期 */ } uint32_t arr tim_clk / ((psc 1U) * f_pulse); if (arr 2U) { arr 2U; /* 上限保护防止频率超规格 */ } __HAL_TIM_SET_AUTORELOAD(htim, arr - 1U); htim-Instance-EGR TIM_EGR_UG; /* 立即载入新周期避免等一个旧周期 */ }arr - 1是因为计数器从 0 计到 ARR 共 ARR1 个数。EGR写UG位的作用是产生一次更新事件把预装载寄存器里的新值立刻搬到影子寄存器否则要等当前周期跑完才生效在加减速时会落后一两拍表现为速度曲线末端不够平滑。arr的下限保护尤其重要。当f_pulse算出来接近定时器时钟的一半时arr会小到 2 甚至 1此时计数器几乎没有分辨率实际输出频率和设定值偏差很大还可能触发定时器自身的更新事件异常。真正的高频段应该改 PSC 而不是硬压 ARR但双轴联动时节拍是共享的改 PSC 会影响另一轴所以通常的做法是把 PSC 一次性配到「最高目标频率时 ARR 不小于 20」的位置之后只动 ARR。4.2 双轴同时运动的四种输出方案「两轴同时」在硬件上有好几种实现层次差别很大选错了后面全是麻烦方案做法同步性适用场景双定时器独立两轴各用一个定时器出 PWM软件先后启动差启动有微秒级偏差两轴各自独立定位不需要插补单定时器双通道一个定时器的 CH1/CH2 输出共用 ARR好同频严格同步只适合两轴速度相同的场合如固定 45 度单定时器中断 GPIO一个定时器做节拍中断里翻 IO好两轴在同一中断里出步通用插补本方案采用定时器中断 PWM 门控中断决定走不走PWM 硬件出脉冲最好脉宽稳定高脉冲频率场合实现复杂通用直线插补必须让两轴在同一拍里按需独立出步前两种做不到第四种实现成本高。所以主流做法是第三种一个定时器负责节拍中断里同时处理两轴。这样做的好处是节拍天然对齐两轴的每一步都发生在同一个时间基准上不存在「X 轴已经走了三步Y 轴才走两步」的相位漂移。代价是脉冲宽度不受硬件控制只能靠中断里的代码执行时间来定。这就引出下一节。4.3 中断服务函数与脉冲宽度控制中断里的活必须干得又少又稳。核心流程就三步取插补结果、输出脉冲、更新位置。/* app_motion.c —— 定时器更新中断20 kHz 节拍 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! MOTION_TIM) { return; /* 只处理节拍定时器 */ } uint8_t sx 0, sy 0; if (!LineInterp_Next(g_line, sx, sy)) { Axis_StopAll(); /* 整段走完关脉冲 */ g_motion.state MOTION_IDLE; return; } /* 两轴同时置高保证脉冲前沿对齐 */ if (sx) HAL_GPIO_WritePin(STEP_X_PORT, STEP_X_PIN, GPIO_PIN_SET); if (sy) HAL_GPIO_WritePin(STEP_Y_PORT, STEP_Y_PIN, GPIO_PIN_SET); DWT_DelayUs(3); /* 定宽 3us驱动器识别用 */ /* 统一拉低脉宽一致 */ HAL_GPIO_WritePin(STEP_X_PORT, STEP_X_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(STEP_Y_PORT, STEP_Y_PIN, GPIO_PIN_RESET); if (sx) g_axis[0].pos g_line.sx; if (sy) g_axis[1].pos g_line.sy; if (g_tick_cnt SPEED_UPDATE_TICKS) { /* 到点更新速度 */ g_tick_cnt 0; Planner_Update(g_line, g_freq); Axis_SetPulseFreq(MOTION_TIM_H, g_freq); } }几个设计点逐个说明。先置高再拉低中间不要插别的操作。两轴同时置高、同时拉低的写法保证了脉冲前沿严格对齐这对「两轴同时到达」的判定很关键。如果写成「X 置高、延时、X 拉低、Y 置高、延时、Y 拉低」两轴脉冲前沿差出一个脉宽虽然只有几微秒但在高脉冲数下会累积成明显的末端不同步。脉宽不能用HAL_Delay。那个函数基于 SysTick 毫秒计数最小分辨率 1 ms在中断里调用直接卡死整个节拍。用 DWT 周期计数器做微秒级延时或者干脆写一个已知周期的空循环__NOP()序列后者要关中断防止被更高优先级打断。3 微秒这个值来自常见步进驱动器的要求多数型号要求脉宽不小于 2.5 微秒留点余量。脉宽决定了节拍频率的上限。20 kHz 时一个周期 50 微秒3 微秒脉宽占 6%可以接受。想跑到 50 kHz周期只有 20 微秒脉宽占比升到 15%中断里再干点别的就会挤爆。这时候就得换成 4.2 表格里的第四种方案让硬件 PWM 去出脉冲。位置更新放在脉冲之后。顺序反了不会出错但调试时如果在中途打断点位置会比实际脉冲多走一步查起来很费劲。还有一个容易忽略的细节LineInterp_Next返回 0 的那一拍脉冲已经不该出了但定时器还在跑。所以Axis_StopAll()里要么关掉定时器中断要么把 ARR 设成一个极大值让它空转前者更干净。注意关中断要在中断函数内部做不能直接HAL_TIM_Base_Stop_IT那会在中断上下文里操作寄存器虽然多数情况下能用但更稳妥的方式是置一个标志位让主循环去关。5. 常见问题排查双轴联动最容易翻车的五处5.1 轨迹类异常走歪、末端不齐现象一斜线走到末端一轴先停另一轴还在补步收尾处出现一个小折角。原因通常不在插补算法而在两轴的总步数没有对齐。Bresenham 保证的是「在total_steps拍内两轴分别出够dx和dy步」但如果初始化时用的是「起点到终点的增量」而位置结构体里存的是「上一段的终点」中间有一次手动回零或软限位修正两边就对不上了。表现出来就是末端多走或少走几个脉冲。解决方式是在每段运动开始前做一次一致性检查把pos与target的差值算出来和LineInterp_Init里用的dx/dy比对不一致就报MOTION_FAULT并拒绝执行。这个检查只有几行代码但能挡住后续所有因为位置漂移导致的神奇现象。现象二正方形四个角是圆的或者闭合时回不到原点。这是加减速段没算准的典型表现。减速距离decel_steps如果按f_max算但实际运行中因为限幅只跑到了 0.8 倍的f_max减速段就会提前结束最后几拍以匀速冲过终点拐角处来不及减速机械上表现为过冲。反过来减速距离算长了会在到达终点前就降到最低频最后一段慢得像爬。稳妥的做法是减速判断不要用「固定步数」改成「按当前频率实时算剩余所需步数」v²/(2a)换算成脉冲数每拍更新一次。多一次除法但拐角精度立刻正常。5.2 脉冲与节拍类异常丢步、抖动、堵转现象三低速正常一提速就丢步回零位置每次都差一点。先别怀疑算法先量脉宽。用示波器抓 STEP 引脚如果脉宽只有几百纳秒驱动器根本来不及响应尤其是光耦隔离输入的驱动器上升沿本身就慢。原因多半是中断里置高之后紧接着就拉低中间没有确定的延时。按 4.3 的写法补上 3 微秒定宽问题基本消失。如果脉宽正常还丢步就看加速度。步进电机的矩频特性决定了高速段扭矩衰减很快加速度设得比电机能力大每次启动都丢几步且丢的步数随机回零位置自然每次不同。把freq_step减半再试能稳定跑完就说明是加速度的问题不是代码的问题。现象四电机静止时能听到规律的「嗒嗒」声手摸有轻微抖动。这是节拍中断里在空转时仍然翻转 IO 导致的。有些实现在LineInterp_Next返回 0 之后没有真正停止定时器中断还在以 20 kHz 触发虽然不置高脉冲脚但方向脚或使能脚被反复写或者中断本身干扰了驱动器。解决很简单整段结束后关掉定时器中断等下一段开始时再开。还有一种可能是细分设置和节拍频率不匹配。细分 32、节拍 20 kHz 时电机每转需要 6400 个脉冲理论上限转速只有 3 转/秒左右再高就跟不上。这种「跑不快」不是 bug是参数没配平把细分降到 16 或者提高节拍频率即可。现象五两轴单独跑都正常一联动就随机性死机或复位。优先查中断优先级。如果节拍定时器中断和串口接收、SysTick 的优先级配置不当嵌套过深会耗尽栈空间。20 kHz 的中断里再被更高优先级打断几次栈很快就会见底表现为随机复位。把节拍定时器设成最高优先级数值最小其它外设中断一律降级并且确保中断里不调用任何可能阻塞的 HAL 函数。我吃过这个亏一开始以为是电源干扰查了两天发现是栈溢出。6. 验收方法怎么证明两轴真的「同时」到了方案跑通不等于能交付。答辩或者验收时评委最爱问的一句话是「你怎么知道两轴是同时到的」光说「我看画出来是直的」压不住场。下面这套验收流程我在几个项目上都走过可以直接照搬。第一层逻辑空跑。把脉冲输出关掉只让插补器跑跑完打印两轴的累计步数和理论值比对。枚举 200 条不同斜率的线段含水平、垂直、45 度、接近 0 度和接近 90 度的边界情况要求误差严格为 0。这一层能把算法问题全部挡在硬件之外。第二层示波器测同步性。抓两轴 STEP 信号的最后一拍测量两个下降沿的时间差。判据是差值不超过一个节拍周期。20 kHz 节拍下就是 50 微秒以内。这个数字是可以写进报告的硬指标。第三层几何闭合测试。装好机械跑一个 100 mm × 100 mm 的正方形连续 20 圈回到起点后用千分表测偏差。判据是单圈闭合误差小于 0.05 mm20 圈后不累积。累积说明有系统性丢步不累积但有随机偏差说明是加减速过冲。验收项方法判据插补正确性逻辑空跑 200 条线段步数误差为 0两轴同步性示波器测末端脉冲沿差值小于 1 个节拍周期轨迹精度正方形闭合 20 圈单圈误差 0.05 mm无累积速度一致性同一段长分别用不同斜率跑合速度偏差 5%长时间稳定性连续运行 2 小时无丢步、无复位、温升正常答辩时的表达技巧。别一上来讲代码先讲那张对比表为什么双定时器独立启动不行为什么单定时器双通道只适合 45 度把「同步性」这个抽象指标落到「脉冲前沿时间差」这个可测量的量上。评委听到可测量的判据追问的欲望会明显下降。然后拿出示波器截图和闭合测试的数据两页 PPT 就够。从那以后我每次做联动项目都强制走一遍「空跑 → 示波器 → 闭合」这三步再急的活也不跳。跳过空跑直接上机械后面排查问题时花的每一分钟都是在给前面省下的十分钟还债。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑