STM32步进电机两轴联动:直线插补与圆弧插补算法实现
2026/9/16 18:56:08 网站建设 项目流程

简介:直线插补与圆弧插补是运动控制中的基础算法,在数控机床、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² + (y+1)² - 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 算法实现与象限扩展

上面的代码只针对第一象限逆圆。实际工程中圆弧可能从任意象限开始,也可能跨象限。我给一个通用判断表,它是我调试时总结的,方向判断用“半象限逻辑”比每次算三角函数快得多:

象限逆时针F>=0进给逆时针F<0进给顺时针F>=0进给顺时针F<0进给
一象限(x>0,y>0)-X+Y-Y+X
二象限(x<0,y>0)-Y-X+X-Y
三象限(x<0,y<0)+X-Y+Y-X
四象限(x>0,y<0)+Y+X-X+Y

实现时先根据当前位置判断象限,再根据偏差符号查表取得本拍应该进给的轴和方向。跨象限时,只要当前位置更新后重新计算象限,算法会自动切换,不需要额外逻辑。

终点判断这块我踩过坑。圆弧插补的剩余步数不像直线那么好算,因为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(需要两个周期产生一个完整高低电平),对应转速约150rpm(16细分)。如果还需要更高速度,就得用PWM配合比较寄存器,或者用DMA输出脉冲串。

4.3 移植后的整机调试步骤

把代码烧进去后别急着跑复杂图形,按这个顺序来:

  1. 单轴点动:用手头最简单的例程驱动X/Y分别正反转,确认方向引脚和脉冲引脚接线正确。
  2. 单轴定长运动:发1000个脉冲,用尺子量走多少毫米,和理论值核对,这步用来验证细分数与丝杆导程的换算。
  3. 两轴同时运动但注释掉插补逻辑,直接发同样数量脉冲,观察轨迹是否是一条近似直线,如果偏差大,先查机械和驱动器电流,别急着怀疑算法。
  4. 跑直线插补:先从一个简单斜率开始,比如(0,0)到(100,50),打印每步输出的step_x、step_y,确认比例2:1大致正确。
  5. 跑圆弧插补:从四分之一圆弧开始,观察走出来的边缘是否平滑、有无明显台阶。

我在第三步时发现过“电机丢步导致直线变成曲线”的情况,查了很久最后发现是驱动器的电流设置太低,高速时扭矩不足,跟算法毫无关系。所以调插补前先确保单轴在最大工作速度下不掉步。

5. 实测表现:速度、精度与失步的那些事

5.1 精度实测

我在一台导程5mm的丝杆平台上做直线插补测试:X方向走200mm,Y方向走50mm,16细分,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足够大,存几万步的轨迹完全没问题,这样插补模块就把“运行时计算”变成了“离线预计算”,执行效率和确定性都提高了。

最后给你一个方向上的建议:先把直线插补调得非常稳定,再上圆弧。直线是基础,圆弧只是比直线多了一个偏差递推。我见过不少朋友一上来就想做完整的圆插补,结果被象限切换、方向判断绕晕,回头把直线做扎实后,圆弧顺手就通了。而且,直线插补里关于“基准轴”“误差累加”“剩余步数”的经验,到圆弧里全都是通用的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询