简介面向STM32运动控制学习者、嵌入式工程师及课程设计需求的双轴直线插补工程包围绕定时器PWM输出、双轴同步和插补算法展开可用于理解两轴同时运动的轨迹规划与闭环调试。包内共660个文件、约7.14MB以368个C源文件、143个头文件和71个汇编启动文件为主并含48个ICF链接脚本及Keil、IAR工程配置便于编译移植C与头文件承载HAL外设驱动和插补逻辑汇编与链接脚本保障启动及内存布局。已有252人学习该例程在HAL库运动控制入门中具备参考价值。工程把定时器配置、PWM占空比调节、插补步进分解与编码器反馈组织为可运行框架读者可据此梳理双轴同步参数、加减速曲线及驱动器设置并对照HAL接口完成速度与位置联调。目录保留完整外设初始化与算法模块适合作为双轴运动控制项目的起手模板和排错参照。1. 两个电机各转各的很简单难的是它们同时到位第一次把 XY 平台点起来的时候单轴测试全都正常写个斜线走位立刻露馅轨迹是台阶状的X 走一段停一下等 Y或者干脆一头先到、另一头再补落点差出去几十个脉冲。问题不在电机也不在驱动器而在两路脉冲从一开始就没共享同一个时基。这个以 YSF4_HAL_MOTOR 命名的工程干的就是把“双轴同时运动”这件事单独拆出来做干净在 STM32 上用一颗定时器当统一节拍源配一套纯整数运算的直线插补让两个步进轴在同一拍里出脉冲、同一拍里收尾。技术栈很朴素——HAL 库、定时器更新中断、Bresenham 插补、脉冲方向型步进驱动没有 RTOS也没有运动控制芯片。适合谁看已经能驱动单个步进电机、想往 XY 联动上走的人用 CubeMX 建了工程但卡在两轴同步的人做小型雕刻、点胶、写字机、实验台定位这类对成本敏感、对精度要求中等的场景。工程规模不大正因为它小才适合拿来当双轴插补的第一份可跑代码。2. 工程骨架与技术选型一颗定时器做节拍而不是两路独立 PWM2.1 解压后的目录结构与各文件职责解压出来是一个标准的 CubeMX 生成工程用 Keil MDK 直接打开就能编译不需要额外装包。第一次看这种工程容易被 Drivers 目录劝退其实真正要读的只有 Core 下面那几个文件。路径职责是否需要改Core/Src/main.c时钟、外设初始化主循环里下发运动指令少量Core/Src/interp.c直线插补核心纯整数运算不依赖 HAL经常改Core/Src/motor.c运动层限位检查、方向脚、加减速表、启停经常改Core/Src/stm32f4xx_it.c中断向量最终会调到 HAL 的回调一般不动Core/Inc/motor.h运动状态机与对外接口经常改Drivers/STM32F4xx_HAL_DriverHAL 库源码不动*.iocCubeMX 工程文件改引脚和定时器从这里进按需看工程先看.ioc引脚和定时器参数全在里面比翻MX_TIM2_Init()快得多。我一般会先确认三件事节拍定时器选的是哪一颗、它的时钟源是多少、STEP 和 DIR 分别落在哪几个引脚上。这三件事定了剩下的都是软件逻辑。2.2 两路独立 PWM 与统一节拍为什么选后者最直觉的做法是给 X 轴配一路定时器 PWM给 Y 轴配另一路各自算频率按比例跑。这条路能出斜线但有个绕不过去的坑两颗定时器的计数器是各自独立的启动时刻天然差一个中断响应周期停止时刻又差一次比较匹配。低速时这点偏差看不出来速度一上去或者中途加减速两轴的脉冲数就不对等落点开始漂。更麻烦的是变速。直线插补要求两轴的进给速度严格成比例任何一轴改频率另一轴必须同步改。两颗定时器就是两次独立的寄存器写操作中间夹着中断延迟比例在过渡的那几拍上是失真的。统一节拍的做法反过来只留一颗定时器产生“拍”每一拍由软件决定这一拍里哪个轴出脉冲、哪个轴不动。两个轴的脉冲边沿来自同一次中断物理上就是对齐的一拍只走一步走完max(|dx|, |dy|)拍两轴必然在同一拍结束。代价是脉冲频率上限受中断执行时间约束这个后面会具体算。提示这个取舍的本质是拿 CPU 时间换同步性。脉冲频率需求在 100 kHz 以内的场合统一节拍完全够用真要做到几兆赫兹的脉冲输出才需要上运动控制芯片或者带插补外设的 MCU。2.3 时钟链路ARR 是怎么算出来的插补的每一拍都要改一次自动重装载值所以必须先搞清楚定时器的计数时钟。以常见的 F4 系列配置为例HCLK 跑 168 MHzAPB1 预分频 4 得到 42 MHz但定时器挂在 APB1 上且预分频不为 1 时时钟会翻倍实际计数时钟是 84 MHz。有了这个基数拍周期和 ARR 的换算就是一行/* 84MHz 计数时钟下把每秒多少拍换算成 ARR 值 */ #define TIM_CLK_HZ 84000000u #define F2ARR(f_hz) ((uint32_t)(TIM_CLK_HZ / (f_hz)) - 1u) /* 例目标 20kHz 拍频 - ARR 84000000/20000 - 1 4199 */ARR是 16 位还是 32 位要看具体定时器TIM2 和 TIM5 是 32 位TIM3、TIM4 是 16 位。如果最低速要求很低比如 10 Hz16 位定时器在 84 MHz 下最大分频也只能到 0.78 Hz 左右勉强够但为了省事这个工程把节拍定时器放在 32 位的那几颗上低速段不用额外加预分频。-1不能省。计数器从 0 数到 ARR 是 ARR1 个计数少减这个 1实际频率会差千分之一左右短距离看不出来走上十万步就能差出好几个脉冲。2.4 一次运动指令经过的四层从上层调用往下看整个链路分四层每层职责很清楚改代码的时候知道该动哪一层。第一层是接口层motion_move_to(x, y, feed)拿到目标绝对步数和进给拍频先做软限位判断目标越界直接返回错误码不动定时器。第二层是规划层算出增量dx/dy、总拍数、主导轴生成加速段的 ARR 表。第三层是节拍层定时器更新中断里按插补结果出脉冲、取下一拍的 ARR。第四层是硬件层两个 GPIO 的置位复位加上方向脚的电平。/* motor.c —— 上层唯一入口返回值区分错误类型 */ int motion_move_to(int32_t x1, int32_t y1, uint32_t f_max) { motion_t *m g_motion; int32_t dx, dy; if (m-state MOT_RUN) return -1; /* 上一段还没走完 */ if (x1 0 || x1 X_MAX_STEP) return -2; /* 软限位 */ if (y1 0 || y1 Y_MAX_STEP) return -2; dx x1 - m-ip.pos_x; dy y1 - m-ip.pos_y; set_dir(dx, dy); /* DIR 必须先建立再开节拍见 4.3 */ interp_begin(m-ip, x1, y1); if (m-ip.steps 0) return 0; /* 原地不动 */ m-tick 0; m-state MOT_RUN; profile_build(m, F2ARR(F_START), F2ARR(f_max), PROFILE_MAX); __HAL_TIM_SET_AUTORELOAD(MOT_TIM_H, m-profile[0] - 1u); __HAL_TIM_SET_COUNTER(MOT_TIM_H, 0); HAL_TIM_Base_Start_IT(MOT_TIM_H); /* 到这里才开始出脉冲 */ return 0; }这段代码里有两个参数值得单独说。F_START是起步拍频不能设太高步进电机静止起步时转矩最小直接上高速必丢步我一般从 800 Hz 到 1.5 kHz 之间试。f_max由调用方给是这一段运动的匀速段拍频也就是主导轴的脉冲频率从动轴的实际脉冲频率是它按比例分出来的不需要单独指定——这正是统一节拍省心的地方。profile_build放在启动前一次性算完中断里只查表不做浮点、不做开方。这是硬性约束中断里每多一个除法最高脉冲频率就往下掉一截。表只存加速段减速段靠镜像索引读取内存占用减半长行程也不会把 RAM 吃掉。3. 直线插补核心Bresenham 怎么把一条斜线拆成脉冲3.1 为什么用整数误差累加而不是浮点 DDA浮点 DDA 的思路很直白每拍给 X、Y 各累加一个增量累加值过 1 就出一步。写起来快但在中断里跑浮点有两个问题。一是 F4 的 FPU 在中断上下文里要保存额外的寄存器现场进出中断的开销凭空多出几十个周期二是浮点累加的舍入误差会随拍数累积走十万步以后两轴的比例会慢慢偏掉。整数误差累加没有这两个毛病。全部是加法和移位判据只有一个符号位误差项本身是有界的跑多久都不会漂。这也是插补算法在单片机上落地时的常规选择。这版实现采用逐点比较的经典形式每一拍最多给两个轴各发一个脉冲总拍数等于max(|dx|, |dy|)。主导轴每拍都走从动轴按比例插进去最后一拍结束时两轴同时到位——这正好就是“同时运动”要的效果。3.2 插补状态结构体与字段含义状态全部塞进一个结构体中断里只碰这个结构体不做任何动态分配。/* interp.h —— 插补状态纯整数可重入到单段运动 */ typedef struct { int32_t pos_x, pos_y; /* 当前绝对步数运动过程中实时更新 */ int32_t dx, dy; /* 本段增量绝对值 */ int32_t err; /* 误差项初值 dx - dy */ int8_t sx, sy; /* 方向1 或 -1 */ uint32_t steps; /* 总拍数 max(dx, dy) */ uint32_t done; /* 已输出拍数 */ uint8_t major; /* 主导轴0X1Y */ } interp_t; void interp_begin(interp_t *ip, int32_t x1, int32_t y1); uint8_t interp_next(interp_t *ip); /* bit0X 走一步bit1Y 走一步 */ uint8_t interp_busy(const interp_t *ip);pos_x/pos_y放在插补结构里而不是放在运动层是有意为之。位置在interp_next里随着脉冲一起累加任何时刻中断被叫停读出来的都是真实位置不会出现“脉冲发了但位置没记上”的中间态。中途急停、限位触发、上位机查询都靠这两个字段。major字段只做记录用方便调试时判断这一段是 X 主导还是 Y 主导实际插补逻辑不依赖它。真正决定谁走谁不走的是err。3.3 两个核心函数初始化和单拍推进/* interp.c */ void interp_begin(interp_t *ip, int32_t x1, int32_t y1) { int32_t dx x1 - ip-pos_x; int32_t dy y1 - ip-pos_y; ip-sx (dx 0) ? 1 : -1; ip-sy (dy 0) ? 1 : -1; ip-dx (dx 0) ? dx : -dx; ip-dy (dy 0) ? dy : -dy; ip-steps (ip-dx ip-dy) ? ip-dx : ip-dy; /* 拍数取长者 */ ip-major (ip-dx ip-dy) ? 0 : 1; ip-err ip-dx - ip-dy; ip-done 0; } uint8_t interp_next(interp_t *ip) { uint8_t act 0; int32_t e2; if (ip-done ip-steps) return 0; /* 已到位不再出脉冲 */ e2 ip-err 1; /* 乘 2 用移位避免乘法 */ if (e2 -ip-dy) { ip-err - ip-dy; act | 0x01; } /* X 出一步 */ if (e2 ip-dx) { ip-err ip-dx; act | 0x02; } /* Y 出一步 */ if (act 0x01) ip-pos_x ip-sx; if (act 0x02) ip-pos_y ip-sy; ip-done; return act; }interp_begin做的是坐标变换把“从当前位置到目标位置”翻译成增量、方向、拍数和初始误差。err dx - dy这个初值决定了第一拍谁先动dx dy时err为正第一拍 X 走符合“主导轴先动”的直觉。方向用sx/sy单独存位置累加时直接加上去不用每拍判断正负。interp_next是中断里唯一会调用的插补函数执行时间决定了脉冲频率的上限。里面只有一次移位、两次加法、两次比较和两次分支在 168 MHz 的 F4 上实测三四十个周期加上后面的 GPIO 操作整段中断控制在 1 微秒出头。这个数字很重要后面讲最高频率限制时会用到。返回值用位掩码而不是枚举是为了让中断里的判断变成一次位测试act 0x01直接决定要不要动 X 的 GPIO不用 switch。3.4 走一遍实例从 (0,0) 到 (3,1)拿一个最小的例子验证一下dx3, dy1初始err 2steps 3。拍err进函数时e2判据结果本拍动作走完后位置124X 走Y 不走X 单步(1, 0)212X 走Y 也走XY 同时(2, 1)336X 走Y 不走X 单步(3, 1)三拍走完两轴同时到达。注意第 2 拍两个轴在同一个中断里各发一个脉冲物理上就是两个 STEP 引脚的上升沿在同一时刻出现这就是统一节拍带来的同步性。这个例子里 Y 轴只走了 1 步用了 1 拍X 轴走了 3 步用了 3 拍。比例 3:1 体现在 X 每拍都走、Y 只在第 2 拍插进来。行程越斜从动脉冲分布得越均匀这也是 Bresenham 相比“先走完主轴再补从轴”最大的优势——后者的轨迹是折线前者才是接近直线的阶梯。3.5 单步与对角步两种流派怎么选上面这个实现允许同一拍里两个轴同时出脉冲业内一般叫“对角步”。另一种做法是每拍只允许一个轴动两个轴的脉冲严格错开叫“单步”。单步的好处是对驱动器的电流冲击小两个电机不会同时启动电源瞬态更平稳坏处是同样一段斜线总拍数变成dx dy比对角步的max(dx,dy)多出近一倍最高进给速度直接砍半。小型平台上电源余量本来就紧如果两个电机同时出脉冲会把电压拉下来那就老老实实改单步代码上只需要把interp_next里的两个if改成if/else。对角步适合电源留足余量、对速度有要求的场合。要改单步注意一个细节改成if/else之后从动轴的误差项更新逻辑会偏需要在条件不满足时把误差项补回去否则比例会失真。这个坑我在第 5 章单独列出来。3.6 位置维护与回零插补之外必须有的一套账插补只管“这一段怎么走”不管“我在哪”。真正让机器能反复定位的是一套绝对位置账。工程里的做法是上电后pos_x/pos_y归零但此时机械位置是未知的必须先回零。回零不走插补走的是单轴点动加限位开关慢速朝负方向走撞到限位后反向退一段再慢速逼近取第二次触发的边沿作为零点。零点确立之后pos_x/pos_y才真正和机械位置对应上。回零之后的所有运动都走绝对坐标上层给的是目标点不是增量。这样即使中途某段因为限位被截断重新下发同一目标点也会自动补上剩下的距离不需要人算差多少。这也是为什么interp_begin里要用x1 - ip-pos_x现算增量而不是让调用方直接给增量。注意软限位必须同时检查起点和终点。只检查终点的话起点已经在界外时反向往回走会被误判成越界机器卡死在边界上。4. 定时器与脉冲输出中断里翻 GPIO 的具体写法4.1 定时器初始化的关键几项节拍定时器用 HAL 的标准初始化但有几个参数不能随便填。/* motor.c —— 节拍定时器只开更新中断不开任何输出通道 */ static void motor_tim_init(void) { MOT_TIM_H-Instance MOT_TIM; MOT_TIM_H-Init.Prescaler 0; /* 84MHz 直通分频留给 ARR */ MOT_TIM_H-Init.CounterMode TIM_COUNTERMODE_UP; MOT_TIM_H-Init.Period F2ARR(1000) ; /* 上电先给个 1kHz 的初值 */ MOT_TIM_H-Init.ClockDivision TIM_CLOCKDIVISION_DIV1; MOT_TIM_H-Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(MOT_TIM_H); HAL_NVIC_SetPriority(MOT_TIM_IRQn, 1, 0); /* 抢占优先级要高 */ HAL_NVIC_EnableIRQ(MOT_TIM_IRQn); }Prescaler 0是有意的。分频交给 ARR 去做精度更高而且改速度的时候只改一个寄存器不用同时动两个少一处出错的地方。AutoReloadPreload必须开。开了之后中断里写进 ARR 的新值会在下一个更新事件才生效不会在当前计数周期中途把计数器打乱。关掉这个选项高速段会出现偶发的长脉冲或短脉冲示波器上能明显看到拍周期抖动电机听起来就是“咔”一下。NVIC 抢占优先级给到比较高的档位。插补中断被别的中断拖住一个周期那一拍就晚了晚的那一拍就是丢的一步。串口、USB 这些外设的中断优先级都应该排在它后面。4.2 中断服务函数里的三件事HAL 把中断统一收口到HAL_TIM_PeriodElapsedCallback实际写业务就在这个回调里。/* motor.c —— 每一拍的全部工作出脉冲、取下一拍周期、判断收尾 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { motion_t *m g_motion; uint8_t act; uint32_t arr; if (htim-Instance ! MOT_TIM) return; /* 别的定时器不管 */ if (m-state ! MOT_RUN) { /* 状态不对就关门 */ __HAL_TIM_DISABLE_IT(htim, TIM_IT_UPDATE); return; } act interp_next(m-ip); /* 本拍谁走 */ if (act 0x01) STEP_X_PORT-BSRR STEP_X_PIN; /* 直接写 BSRR比 HAL 快 */ if (act 0x02) STEP_Y_PORT-BSRR STEP_Y_PIN; delay_us(PULSE_WIDTH_US); /* 脉宽见下 */ if (act 0x01) STEP_X_PORT-BSRR (uint32_t)STEP_X_PIN 16; if (act 0x02) STEP_Y_PORT-BSRR (uint32_t)STEP_Y_PIN 16; m-tick; arr profile_arr_at(m, m-tick); /* 查加减速表 */ __HAL_TIM_SET_AUTORELOAD(htim, arr - 1u); __HAL_TIM_SET_COUNTER(htim, 0); /* 清计数避免多等一轮 */ if (m-ip.done m-ip.steps) { /* 这一拍就是最后一拍 */ HAL_TIM_Base_Stop_IT(htim); m-state MOT_DONE; } }直接用BSRR而不是HAL_GPIO_WritePin是因为 HAL 那层函数有参数检查、有句柄解引用一次调用几十个周期在中断里调用两次就吃掉上百周期。BSRR是单寄存器写置位和复位各一次写操作原子性也好——写高 16 位复位不会被别的中断打断成“先置位再复位”的中间态。__HAL_TIM_SET_COUNTER(htim, 0)这一句容易漏。改了 ARR 之后如果当前计数值已经超过新的 ARR计数器要数到溢出才能触发下一次更新实际周期会变成“当前值到 65535 再绕回”的一大段表现为偶尔顿一下。清零计数器就把这个不确定性抹掉了。PULSE_WIDTH_US这个参数按驱动器手册给常见要求是最小 2.5 微秒。实际给 3 到 5 微秒留余量。给太长会挤占拍周期高速时拍周期本身可能只有 5 微秒脉宽再长就直接占满了。4.3 微秒级延时与方向脚的建立时间中断里不能用HAL_Delay它依赖 SysTick 且是毫秒级。用内核的周期计数器做忙等精度足够。/* 内核周期计数器做微秒延时需先使能 DWT */ static inline void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000u); while ((DWT-CYCCNT - start) ticks) { } } /* 上电时调用一次 */ void dwt_enable(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; }SystemCoreClock是当前主频除以一百万得到每微秒的周期数。这里用无符号减法计数器溢出后回绕也能正确算出差值不用管 32 位计数翻转的问题。方向脚的处理单独拎出来/* 方向只在段开始时设置一次且必须早于第一个脉冲 */ static void set_dir(int32_t dx, int32_t dy) { if (dx ! 0) HAL_GPIO_WritePin(DIR_X_PORT, DIR_X_PIN, (dx 0) ? GPIO_PIN_SET : GPIO_PIN_RESET); if (dy ! 0) HAL_GPIO_WritePin(DIR_Y_PORT, DIR_Y_PIN, (dy 0) ? GPIO_PIN_SET : GPIO_PIN_RESET); delay_us(DIR_SETUP_US); /* 常见驱动器要求 5us 以上 */ }dx ! 0这个判断不能省。某一轴这一段根本不动时不要去动它的方向脚否则会在静止轴上产生一次无意义的电平翻转某些驱动器会把这个当成一次换向内部电流环要重新建立下一段启动时就容易丢第一步。DIR_SETUP_US的取值看驱动器手册常见是 5 微秒。这个时间是从方向电平稳定到第一个 STEP 上升沿之间的最小间隔给不够的后果是第一步方向错表现为“每次换向都少走一步”累积起来就是明显的反向间隙。4.4 加减速表为什么不在中断里算速度匀速跑直线是最简单的情况但真实设备不可能一直匀速——从静止直接上 20 kHz步进电机只会原地啸叫不转。必须有一段加速。问题在于速度规划里带平方根匀加速下速度与位移的平方关系中断里算平方根是不现实的。所以这个工程把加速段的每一拍周期提前算好做成表中断里只做一次数组索引。/* 生成加速段周期表c_n c_{n-1} - 2*c_{n-1}/(4n1)匀加速下的周期递推 */ static void profile_build(motion_t *m, uint32_t c0, uint32_t c_min, uint32_t n_max) { uint32_t c c0; uint32_t n; m-accel_n 0; for (n 1; n n_max; n) { m-profile[m-accel_n] (uint16_t)c; /* 第 n 拍的 p a hrefhttps://download.csdn.net/download/qq_27715203/15659479 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
阅读完成 · 觉得有帮助?