
简介直线插补与圆弧插补是运动控制中的基础算法在数控机床、3D打印、机器人等领域广泛使用。这份资源以C语言实现二维直线/圆弧插补并针对STM32微控制器做了移植适配面向嵌入式开发、自动化设备及步进电机控制方向的工程师和学生提供可直接参考的算法思路与代码示例。压缩包共7个文件以jpg图片、txt说明文档和html页面为主图片可用于直观对照运行效果与调试界面txt与html中包含具体代码示例和实现细节。资源包仅246KB方便快速下载查阅。目前已有1432人学习下载。通过学习这份资料读者可以掌握直线插补的步数/方向计算、圆弧插补的三角函数与几何建模方法了解PWM控制步进电机、中断服务程序更新位置等STM32实用技术并能为后续误差补偿、实时性优化及通信接口扩展打下基础适合作为入门到进阶的参考。 不知道你有没有遇到过这种情况两台步进电机各自单独跑速度、位置都挺准但一旦让它们同时动作去走一条斜线或者画个圆弧轨迹就歪得没法看。我之前做一台桌面级的小型雕刻机时就卡在这个问题上。后来把直线插补和圆弧插补算法在STM32上跑通才真正理解“两轴联动”这件事的本质。这篇文章就把我从算法原理到C语言实现再到STM32移植的完整过程整理出来给同样在搞步进电机控制的朋友一个参考。插补Interpolation说白了就是用一系列离散的脉冲走出一条连续的轨迹。步进电机是一个脉冲走一步一步的角度由驱动器的细分决定所以“轨迹精度”和“脉冲分配的均匀性”直接挂钩。下面我会从直线插补讲起再讲圆弧插补最后给出一套能在STM32上直接跑的框架。1. 从单轴运动到两轴联动插补算法的工程价值1.1 为什么单轴运动不够用先看一个具体场景。雕刻机要在木板上刻一个矩形四条边分别沿X方向或Y方向走这种运动单轴就能完成X走完停Y再走完全没有问题。但如果要刻一个倾斜45度的槽或者一个半径为50毫米的圆弧系统就必须让X轴和Y轴同时运动而且每一毫秒里两轴的速度比例都要保持恒定。大多数入门者会尝试一个很直接的办法给两个轴分别发固定频率的脉冲X轴发100个脉冲Y轴同时发100个脉冲。问题来了——两个轴完成100个脉冲的时间不一定相同。步进电机的起动频率、负载、驱动器细分都会影响实际脉冲响应而且主控芯片同时翻转两路IO时中断和延时会产生微小的时间差累计下来轨迹就会偏。真正意义上的插补算法就是在一个统一的时基下决定“这一步让哪个轴走、哪个轴不走”。1.2 插补的本质把连续轨迹离散成脉冲序列插补算法的核心是“决策”而不是“驱动”。它负责回答一个问题在当前这一拍X轴走一个脉冲Y轴走一个脉冲还是只走其中一个一拍的时长通常由定时器中断固定比如1毫秒。在每个中断周期内算法根据当前位置与理想轨迹的偏差决定各轴的输出状态。这个思路与数控机床的插补器是一致的只是嵌入式实现时资源更有限算法必须简单、快速、确定。C语言描述起来非常自然只需要整型运算和状态量不需要浮点库这对Cortex-M0/M3级别的STM32很友好。另外要说明的是插补算法本身与驱动器类型无关步进、伺服都能用区别只在脉冲接口的电气形式。2. 直线插补用整数累加避免浮点误差2.1 Bresenham思想的切入点直线插补的常见实现有三种数字积分法DDA、逐点比较法、Bresenham法。DDA实现简单但有浮点误差累加速度控制要额外处理逐点比较法适合圆弧直线用Bresenham的思路最舒服它的核心是“用整数误差累加器替代斜率计算”。拿一条从(0,0)到(100,30)的直线来说斜率是0.3。X轴每走一步Y轴理论上应该走0.3步但实际只能走0或1步。Bresenham的做法是维护一个误差项err每走一步X就把斜率累加进去当err大于等于1时Y才走一步然后err减1。整个过程不涉及浮点也不会累积误差。选择这个方案还有个工程原因常见的步进驱动器都支持细分比如16细分电机每转需要3200个脉冲。在这种高脉冲频率下中断服务程序里能执行的指令数是有限的整数比较远比浮点运算靠谱。2.2 可复用C代码结构体与状态机这段代码我一直在用拉出来改了引脚配置就能用。它采用结构体保存状态step接口每调用一次表示定时器中断来了一拍。typedef struct { int32_t x; // 当前坐标 int32_t y; int32_t dx; // 目标增量 int32_t dy; int32_t err; // 误差累加器 int8_t dir_x; // 方向 int8_t dir_y; uint8_t finished; // 完成标志 } LineInterp; void LineInterp_Init(LineInterp *li, int32_t x0, int32_t y0, int32_t x1, int32_t y1) { li-x x0; li-y y0; li-dx abs(x1 - x0); li-dy abs(y1 - y0); li-dir_x (x1 x0) ? 1 : -1; li-dir_y (y1 y0) ? 1 : -1; li-err 0; li-finished 0; } void LineInterp_Step(LineInterp *li, uint8_t *step_x, uint8_t *step_y) { *step_x 0; *step_y 0; if (li-finished) return; // 这里以X做基准轴dy/dx通过err累加判断 li-err li-dy; if (li-err li-dx) { li-err - li-dx; *step_y 1; li-y li-dir_y; } *step_x 1; li-x li-dir_x; if (li-x (li-x - li-dir_x) li-dir_x * li-dx) { // 判断是否走到终点 // 另一种更稳妥的判断方式统计剩余步数 } }关于终点判断我建议不要在插补函数里直接比较“当前坐标等于目标坐标”因为当dx和dy不互质时最后几步可能提前触发。我实际采用的是“剩余步数递减”的办法typedef struct { // ... 前面成员不变 int32_t total_steps; } LineInterp; void LineInterp_Init(LineInterp *li, int32_t x0, int32_t y0, int32_t x1, int32_t y1) { // ... li-total_steps (li-dx li-dy) ? li-dx : li-dy; } void LineInterp_Step(LineInterp *li, uint8_t *step_x, uint8_t *step_y) { *step_x 0; *step_y 0; if (li-total_steps 0) { li-finished 1; return; } li-err li-dy; if (li-err li-dx) { li-err - li-dx; *step_y 1; li-y li-dir_y; } *step_x 1; li-x li-dir_x; li-total_steps--; }用max(dx, dy)作为总步数就能保证每一步至少有一个轴动作不会出现“误差累积但还没到”的死循环。2.3 边界条件斜率大于1和方向处理上面代码以X为基准轴隐含前提是dx dy。如果dx dy比如直线接近垂直X走一小段Y需要大步进基准轴应该换成Y。最简单的办法入口处交换一下坐标轴逻辑。我在工程里用的方法是把dx/dy比较后如果dy dx则在一个“轴交换标志”里记一笔后续步进映射时把X/Y的结果互换。typedef struct { // ... uint8_t swap_axis; // 1表示以Y为基准轴 } LineInterp; void LineInterp_Init(LineInterp *li, int32_t x0, int32_t y0, int32_t x1, int32_t y1) { // ... li-swap_axis (li-dy li-dx) ? 1 : 0; if (li-swap_axis) { int32_t tmp li-dx; li-dx li-dy; li-dy tmp; } li-total_steps (li-dx li-dy) ? li-dx : li-dy; } void LineInterp_Step(LineInterp *li, uint8_t *step_x, uint8_t *step_y) { uint8_t sx 0, sy 0; if (li-total_steps 0) { li-finished 1; return; } li-err li-dy; if (li-err li-dx) { li-err - li-dx; sy 1; li-y li-dir_y; } sx 1; li-x li-dir_x; li-total_steps--; if (li-swap_axis) { *step_x sy; *step_y sx; } else { *step_x sx; *step_y sy; } }这里还要注意方向为负的情况。Bresenham的误差累加只对距离增量做运算方向由dir_x/dir_y在最后输出脉冲时映射到方向引脚这个逻辑是独立的不容易出错。3. 圆弧插补逐点比较法的偏差递推3.1 圆弧轨迹的难点圆弧的难点在于两轴的瞬时速度比不是常数而是随位置连续变化的。以圆心在(0,0)、半径R的圆为例逆时针走90度圆弧X轴的速度从0逐渐增大Y轴的速度从最大逐渐减小。如果只是简单地对两轴发同等频率的脉冲走出来的轨迹一定是斜线或者抛物线。圆弧插补的经典实现是逐点比较法。它的思想特别朴素每走一步计算当前位置相对理想圆弧的偏差F x² y² - R²。如果F 0说明点在圆外或圆上下一步朝圆内方向走如果F 0说明点在圆内下一步朝圆外方向走。判断只需比较偏差F的符号不需要计算平方根非常适配MCU。3.2 逐点比较法四步走一个完整的逐点比较插补周期有四个步骤偏差判别、坐标进给、偏差计算、终点判断。以第一象限逆圆弧为例从起点P(x0, y0)出发圆心在原点半径R偏差F x² y² - R²若F 0向-X方向走一步此时x x - 1新的偏差 F (x-1)² y² - R² F - 2x 1若F 0向Y方向走一步此时y y 1新的偏差 F x² (y1)² - R² F 2y 1看到没有新的偏差可以从旧偏差加一个简单的整数增量得到不用每次算平方。这个递推是逐点比较法效率高的关键。方向上的处理则是把整个圆拆成四个象限根据当前点所在象限决定进给方向。我在工程里用一个象限变量quadrant来记录每次坐标更新后检查是否跨过坐标轴。第一象限逆圆的C代码结构typedef struct { int32_t x; // 当前位置 int32_t y; int32_t cx; // 圆心 int32_t cy; int32_t r; // 半径的平方 int32_t f; // 偏差值 uint8_t quadrant; // 1-4 uint8_t dir; // 1逆时针0顺时针 int32_t total_steps; uint8_t finished; } ArcInterp; void ArcInterp_Init(ArcInterp *ai, int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t cx, int32_t cy, uint8_t dir) { ai-x x0; ai-y y0; ai-cx cx; ai-cy cy; ai-dx x0 - cx; ai-dy y0 - cy; ai-r ...; // 实际用半径平方 // 偏差初值可以直接用x0² y0² - R²但由于起点在圆上初值为0 ai-f 0; // 根据起点确定象限 // 总步数可以先通过起点到终点的角度差估算也可以用走步计数 ai-total_steps ...; ai-finished 0; } // 第一象限逆圆的单步迭代 void ArcInterp_Step(ArcInterp *ai, uint8_t *step_x, uint8_t *step_y) { *step_x 0; *step_y 0; if (ai-finished) return; if (ai-f 0) { // 向-X方向走 *step_x 1; ai-x--; ai-f - 2 * (ai-x) 1; // 根据递推公式调整 } else { // 向Y方向走 *step_y 1; ai-y; ai-f 2 * (ai-y) 1; } // 剩余步数递减判断完成 }3.3 算法实现与象限扩展上面的代码只针对第一象限逆圆。实际工程中圆弧可能从任意象限开始也可能跨象限。我给一个通用判断表它是我调试时总结的方向判断用“半象限逻辑”比每次算三角函数快得多象限逆时针F0进给逆时针F0进给顺时针F0进给顺时针F0进给一象限(x0,y0)-XY-YX二象限(x0,y0)-Y-XX-Y三象限(x0,y0)X-YY-X四象限(x0,y0)YX-XY实现时先根据当前位置判断象限再根据偏差符号查表取得本拍应该进给的轴和方向。跨象限时只要当前位置更新后重新计算象限算法会自动切换不需要额外逻辑。终点判断这块我踩过坑。圆弧插补的剩余步数不像直线那么好算因为x和y变化频率不固定。最稳妥的方式是记录一个“期望终点”每走一步都检查当前位置是否跨过终点可以用角度方式也可以直接用相对终点的符号变化判断。我实际使用的是步数计数器起点到终点的最短步数在初始化时估算为两倍的弧长步数走完后强制结束误差在一个步距内可接受。4. STM32移植定时器中断与脉冲输出的配合4.1 把算法模块与硬件层解耦算法代码是纯C、纯整数运算这意味着任何C编译器都能编译。移植到STM32时我强烈建议把插补模块和硬件驱动分开插补模块只负责计算“这一步X走不走、Y走不走、方向是正还是负”硬件驱动负责把结果变成引脚上的脉冲。我在工程里的文件结构是这样的interp.h / interp.c直线插补和圆弧插补的结构体与核心函数motor_ctrl.c针对STM32的脉冲输出、方向输出封装timer_interrupt.c定时器中断服务程序每1毫秒调用一次插补Step函数并根据返回值翻转脉冲引脚解耦的好处是你可以在电脑上先用纯C跑算法、打印坐标点验证轨迹确认无误后再接硬件排除了算法和硬件的交叉干扰。4.2 定时器参数配置和中断频率选择STM32定时器中断频率直接决定了插补的“分辨率”。我用的是TIM2配置为1毫秒中断一次也就是1kHz。这个频率对中低速雕刻足够配合16细分、1.8度步进角电机每转需要3200步最高速度约每秒1.25转即120rpm左右对很多桌面设备完全够用。如果想提高速度可以提高到10kHz即0.1毫秒中断一次但要注意中断服务程序里任务不能太重。我实测在STM32F103 72MHz下一次完整的插补Step计算大约2-5微秒加上脉冲翻转和状态保存10kHz时中断占用约2%-5%的CPU可以接受。下面是标准外设库的TIM2配置void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStruct.TIM_Prescaler 72 - 1; // 72MHz / 72 1MHz TIM_InitStruct.TIM_CounterMode TIM_CounterMode_Up; TIM_InitStruct.TIM_Period 1000 - 1; // 1MHz / 1000 1kHz TIM_InitStruct.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_InitStruct); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel TIM2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 2; nvic.NVIC_IRQChannelSubPriority 1; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); TIM_Cmd(TIM2, ENABLE); }中断里做的事情要精简不要做任何延时、打印等耗时操作。一个完整的实现大概是void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { uint8_t sx, sy; LineInterp_Step(line, sx, sy); Motor_OutputPulse(sx, sy); // 根据sx/sy翻转对应脉冲引脚 TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }脉冲输出最简单的方法是“电平翻转法”每个中断周期里如果该轴需要走一步就把脉冲引脚拉高等下一个周期再拉低。这样在1kHz中断下输出脉冲频率最多500Hz需要两个周期产生一个完整高低电平对应转速约150rpm16细分。如果还需要更高速度就得用PWM配合比较寄存器或者用DMA输出脉冲串。4.3 移植后的整机调试步骤把代码烧进去后别急着跑复杂图形按这个顺序来单轴点动用手头最简单的例程驱动X/Y分别正反转确认方向引脚和脉冲引脚接线正确。单轴定长运动发1000个脉冲用尺子量走多少毫米和理论值核对这步用来验证细分数与丝杆导程的换算。两轴同时运动但注释掉插补逻辑直接发同样数量脉冲观察轨迹是否是一条近似直线如果偏差大先查机械和驱动器电流别急着怀疑算法。跑直线插补先从一个简单斜率开始比如(0,0)到(100,50)打印每步输出的step_x、step_y确认比例2:1大致正确。跑圆弧插补从四分之一圆弧开始观察走出来的边缘是否平滑、有无明显台阶。我在第三步时发现过“电机丢步导致直线变成曲线”的情况查了很久最后发现是驱动器的电流设置太低高速时扭矩不足跟算法毫无关系。所以调插补前先确保单轴在最大工作速度下不掉步。5. 实测表现速度、精度与失步的那些事5.1 精度实测我在一台导程5mm的丝杆平台上做直线插补测试X方向走200mmY方向走50mm16细分1kHz中断。实际走完后用百分表打点终点误差约0.06mm全程轨迹中点偏差约0.03mm。这个精度对雕刻机、点胶机来说够用。圆弧插补测试画半径50mm的圆走完后用同心圆规对比轮廓偏差约0.05mm。因为逐点比较法每一步的步距就是电机最小步距只要不高频丢步理论轨迹误差不超过一个步距。换句话说插补算法的精度上不去瓶颈往往是细分不够、机械间隙、丢步而不是算法本身的取舍。5.2 高速失步和加速度很多人在插补代码跑通后第一反应是把中断频率调高让电机转快点。结果发现速度一上去电机就开始“咔咔”响、丢步甚至堵转。我踩过一次后把原因理清了步进电机的力矩随转速升高而下降启动瞬间如果直接给目标频率启动扭矩不足就会失步。这个和插补算法无关但实际运行时表现为轨迹变形。解决思路是加减速控制。在每段轨迹开始前以一定斜率提高中断频率或脉冲频率让电机从低速平稳加速到目标速度接近终点前再减速。如果只在插补模块里改不改电机驱动策略吃枣药丸。我后来加了一个简单的梯形加减速模块速度曲线记录在一个环形缓冲里中断频率本身不变而是通过“某些插补周期不输出脉冲”来实现等效变速。这样代码改动最小效果立竿见影。5.3 空跑测试的波形观察如果你有逻辑分析仪把脉冲引脚和方向引脚抓下来看会很直观。直线插补时X轴的脉冲是均匀的Y轴的脉冲是“走走停停”的但整体数量比严格等于目标比例。圆弧插补时两个轴脉冲的频率都在变化呈现正弦关系。如果看到某个轴在一段时间内完全没有脉冲但位置明显还没到就需要检查终点判断或象限判断逻辑。没有逻辑分析仪也可以用示波器看但逻辑分析仪能同时看多路信号排查多轴联动问题方便很多。注意探头要接在驱动器的PULSE/DIR输入侧而不是电机绕组上否则看到的是高压斩波波形容易误判。6. 工程化扩展从两轴到更多轴6.1 多轴插补的时间片问题两轴跑通后往往会有“第三轴要不要也插补”的想法。比如做三维雕刻或者在XY平台上加一个Z轴做抬刀动作。多轴插补的难点不是算法本身而是时间片分配每1毫秒中断里要同时计算3条轴的走步决策计算量会线性增加。对于Cortex-M3内核跑3轴逐点比较法依然很轻松但要注意中断服务程序整体执行时间不要超过中断周期的30%留出余量给主循环做通信和显示。我的做法是插补模块内部维护一个“轴实例数组”每个轴有自己的累加器中断服务程序里统一遍历数组。这样代码量小扩展也容易。6.2 和其它功能模块的协作在实际项目里插补模块通常还要和G代码解析器、坐标变换、限位开关处理一起工作。一个常见的坑是限位开关触发时插补模块还在中断里不断发脉冲导致机械撞击。我后来在插补模块里加了一个“急停标志”外部中断触发时原子地置位插补Step函数每次进来先查这个标志置位则立即停止所有轴输出并复位状态。这个功能不复杂但极其实用也建议你加上。再有一个经验如果插补的轨迹很多每次都在中断里实时算也是一种方式但如果轨迹固定重复可以预先算出一段轨迹的步进表存到RAM或Flash里运行时就只查表不计算能省下不少CPU资源。STM32的Flash足够大存几万步的轨迹完全没问题这样插补模块就把“运行时计算”变成了“离线预计算”执行效率和确定性都提高了。最后给你一个方向上的建议先把直线插补调得非常稳定再上圆弧。直线是基础圆弧只是比直线多了一个偏差递推。我见过不少朋友一上来就想做完整的圆插补结果被象限切换、方向判断绕晕回头把直线做扎实后圆弧顺手就通了。而且直线插补里关于“基准轴”“误差累加”“剩余步数”的经验到圆弧里全都是通用的。本文还有配套的精品资源点击获取