简介:面向STM32嵌入式开发者与运动控制初学者的双轴联动实战工程包,聚焦定时器PWM输出与直线插补的协同实现,可用于自动化设备、机器人等场景下的两轴同步轨迹控制学习与二次开发。压缩包共660个文件,约7.14MB,以368个C源文件与143个头文件构成核心代码主体,另含71个汇编启动文件、48个分散加载icf配置、多种IDE工程文件(uvprojx、ewp等)及HEX、AXF可执行产物,兼顾Keil、IAR等环境下的编译与烧录验证,并附有说明文档与原理图资料。已有252人学习下载。资源完整呈现双轴同步的工程组织方式:两路定时器参数配平以保证速度一致,输出比较值实时更新调节占空比,配合自定义插补算法将目标位置拆分为细密步进,并预留编码器闭环反馈与驱动器电流限制、死区设置等工程考量,便于读者对照调试、迁移到自有平台并排查同步误差问题。
1. 两轴联动的老毛病:一条斜线跑着跑着就成了台阶
YSF4_HAL_MOTOR-219 这个例程干的事情很具体:在 STM32 上用 HAL 库,让 X、Y 两个步进轴在同一个定时器节拍下同时运动,走出一条直线插补的斜线。很多人第一次做双轴联动,是给每个轴配一个定时器,各自算好频率再一起启动,低速看着没问题,一提速斜线就跑成台阶,拐角处还听得见电机咯噔一下。它把两轴的脉冲发生器合并进同一个中断,用整数运算决定每一拍谁出脉冲,这才是「同时」两个字的真实含义。适合手上已经有 STM32 开发环境、想让两轴机构走斜线、做简单绘图或点胶轨迹的开发者。
2. 工程结构与跑通路径:从时钟树到第一次上电自检
拿到一个 rar 压缩包,很多人第一反应是双击打开找 main.c,然后直接编译烧录。这次我建议先把顺序反过来:先弄清哪几个文件在干活,再把时基调准,最后才接电机。顺序错了,你会在一个连脉冲有没有发出来都不确定的工程里查丢步,纯属给自己找罪受。
2.1 目录里每个文件到底管什么
例程是标准 CubeMX 生成后手工改的骨架,MDK 或 CubeIDE 工程都能打开。解压之后常见做法是分三块:CubeMX 自动生成的初始化代码、手写的电机抽象层、手写的插补器。真正需要你读的其实只有后半部分,前面那一坨 MX_xxx_Init 基本不用动。
| 路径 | 职责 | 改动频率 |
|---|---|---|
| Core/Src/main.c | 时钟初始化、外设初始化调用、主循环命令处理 | 低 |
| Core/Src/tim.c | 插补时基定时器的参数配置 | 中,调频率必改 |
| Core/Src/gpio.c | 脉冲、方向、使能、限位引脚 | 低 |
| Core/Inc/motor.h | 轴结构体、运动状态枚举、对外接口声明 | 中 |
| Core/Src/motor.c | 脉冲输出、方向设置、轴状态维护 | 中 |
| Core/Inc/interpolation.h | 插补上下文结构体 | 高,二次开发主要动这里 |
| Core/Src/interpolation.c | 直线插补算法本体 | 高 |
| Core/Src/motion.c | 运动段管理、加减速规划、状态机 | 高 |
分层的意图很明确:interpolation.c 只负责算「这一拍 X 走不走、Y 走不走」,完全不知道 GPIO 长什么样;motor.c 只负责把「走一步」翻译成寄存器操作,完全不知道当前在走直线还是圆弧。这条边界守住了,后面加圆弧插补才不用推倒重来。我在别的项目里见过把 GPIO 操作直接写进插补循环的版本,改一个引脚编号要在算法文件里翻十几处,那种代码接手一次就够了。
还有一层容易被忽略:坐标单位。命令层收进来的是毫米,插补层跑的是脉冲,中间必须有一次换算,而且换算只能在进入插补之前做一次。如果在插补循环里做浮点乘法,中断时间会随坐标值抖动,高速时直接翻车。
2.2 时钟、定时器、GPIO 的初始化骨架
整个例程的心跳是插补定时器。以 72MHz 主频的 F103 为例,希望中断频率 20kHz,也就是每 50 微秒走一拍插补。这个频率决定了双轴联动的最高脉冲频率——一拍最多发一个脉冲,所以单轴上限就是 20kHz,折算到 1.25μm 脉冲当量大约是 25mm/s。
/* tim.c —— 插补时基:72MHz -> 1MHz 计数 -> 20kHz 中断 */ void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; /* 72MHz / 72 = 1MHz,1 个计数 = 1us */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 50 - 1; /* 1MHz / 50 = 20kHz 中断 */ htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; /* 改 ARR 下一周期生效 */ if (HAL_TIM_Base_Init(&htim3) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig); sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig); }Prescaler 和 Period 这两个参数是所有节奏的源头,值得单独说清楚。Prescaler 决定计数精度,设成 71 就是每微秒计一个数,这样后面算加减速的周期值可以直接用「微秒」当单位,不用来回换算。Period 决定基础中断频率,20kHz 是个折中值:再高,中断服务函数那点开销会占掉可观的 CPU;再低,单轴脉冲频率上不去,机构跑不快。AutoReloadPreload 必须打开,否则你在中断里改 ARR 的那一下可能正好撞上计数器归零,输出一个莫名其妙的超短或超长周期,电机就会「咳」一声。
F4 系列主频 168MHz 的话,Prescaler 改成 168-1,Period 不用动,中断频率还是 20kHz,这个换算关系建议直接写在注释里,半年后回来改参数能省不少事。
GPIO 这块没什么花活,脉冲、方向、使能各占一个引脚,方向脚在发脉冲之前就要稳定。真正要注意的是驱动器的共阳共阴接法,例程默认是共阴(脉冲高有效),如果你的驱动器是共阳,脉冲极性要整个反过来,否则一上电电机就锁死不动,你还以为是定时器没跑起来。
2.3 编译烧录与第一轮空跑验证
不接驱动器,先空跑。这一步能挡掉后面一半的玄学问题。
先编译,确认没有 warning 级别的隐式类型转换——插补里全是 int32_t 的乘加,一次隐式转换就可能把误差项截断。烧录进去之后,不要急着看电机,先用示波器或几十块钱的逻辑分析仪夹住 X、Y 两路脉冲脚,同时跑一个「走一条 45° 斜线」的测试。合格的波形是:两路频率一致、相位对齐、每路脉冲宽度稳定在几微秒。
手里没有仪器也有土办法,在中断里加个计数,每秒翻转一次 LED:
/* main.c —— 不接电机时的自检:确认中断真的在跑,频率对不对 */ volatile uint32_t g_isr_cnt = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM3) return; g_isr_cnt++; if ((g_isr_cnt % 20000U) == 0U) { /* 20kHz 下正好 1 秒 */ HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }LED 闪得太快或太慢,说明 Period 算错了;完全不闪,先查 HAL_TIM_Base_Start_IT 有没有被调用、NVIC 里 TIM3 中断有没有使能。这两条是新手最常踩的——CubeMX 里勾了定时器但忘了勾 NVIC,代码看着对,中断永远进不去。
自检通过之后再把驱动器接上,先跑单轴、低速、短距离,确认方向和脉冲当量对得上;两个轴分别验证完,再跑斜线。跳过单轴验证直接上双轴,出问题时你分不清是插补算错了还是某个轴接线反了。
3. 直线插补核心:Bresenham 把斜线摊成两轴脉冲序列
3.1 逐点比较法和数字积分法,例程为什么用 Bresenham
直线插补的老牌算法有三类。逐点比较法每次只让一个轴走一步,另一个轴等着,实现最简单,但合成速度不均匀,走 45° 和走 0° 的速度能差出 1.4 倍,机构上表现为「斜着走比直着走慢」,精密一点的场合不能接受。数字积分法(DDA)用两个累加器,速度均匀,代价是要处理溢出和余数,位宽一变大就不好写成整数形式。
Bresenham 正好卡在中间:全整数运算、没有除法、每一拍最多两个轴各走一步,且误差被严格限制在半个脉冲以内。它天然适配「固定时基 + 每拍决策」的结构——定时器每 50 微秒问一次「这一拍谁走」,算法回答「X 走」「Y 走」或者「都走」。总拍数正好等于 max(|dx|, |dy|),也就是说这条斜线的耗时由较长的那根轴决定,这正是双轴联动该有的语义。
这里有个概念要拧清楚:插补不是「把线画出来」,是「把一条直线要求的两个轴位移比例,摊到等时间间隔的脉冲上」。比例对了,机构自己会把斜线走出来。
3.2 插补状态机逐行拆解
插补上下文只保存一段直线的信息,段与段之间由上层重新 Line_Begin。这种「一段一上下文」的写法比全局变量好调试,出问题可以直接把结构体打印出来看。
/* interpolation.h —— 一段直线的插补上下文 */ typedef struct { int32_t dx, dy; /* 终点相对起点的脉冲增量,带符号 */ int32_t ax, ay; /* 上面两个的绝对值,算法内部用 */ int32_t err; /* Bresenham 误差项,有符号 */ int32_t step_total; /* 本段总拍数 = max(ax, ay) */ int32_t step_done; /* 已走拍数,用于加减速判断 */ } LineCtx_t; void Line_Begin(LineCtx_t *c, int32_t dx, int32_t dy); /* 走一拍。返回 1 表示本段未结束,0 表示走完。 * out_x / out_y 为 1 表示该轴这一拍要发一个脉冲 */ uint8_t Line_Next(LineCtx_t *c, uint8_t *out_x, uint8_t *out_y);实现部分:
/* interpolation.c */ void Line_Begin(LineCtx_t *c, int32_t dx, int32_t dy) { c->dx = dx; c->dy = dy; c->ax = (dx >= 0) ? dx : -dx; c->ay = (dy >= 0) ? dy : -dy; c->err = c->ax - c->ay; /* 初始误差 */ c->step_total = (c->ax > c->ay) ? c->ax : c->ay; /* 长轴决定拍数 */ c->step_done = 0; } uint8_t Line_Next(LineCtx_t *c, uint8_t *out_x, uint8_t *out_y) { if (c->step_done >= c->step_total) { /* 本段已走完 */ *out_x = 0; *out_y = 0; return 0; } int32_t e2 = c->err << 1; /* 乘 2 避免比较时出现小数,纯整数运算 */ *out_x = 0; *out_y = 0; if (e2 > -c->ay) { /* 误差偏向 X,这一拍 X 要动 */ c->err -= c->ay; *out_x = 1; } if (e2 < c->ax) { /* 误差偏向 Y,这一拍 Y 要动 */ c->err += c->ax; *out_y = 1; } c->step_done++; return 1; }逻辑上就三件事。第一步判断有没有走完,走完直接返回 0,上层据此切段或停机。第二步把误差项左移一位,这是 Bresenham 的经典技巧,用 2*err 和 ±a 比较,避免引入 0.5 这样的浮点常量。第三步两个 if 是并列的,不是 if-else,因为 45° 的时候两个轴要同时出脉冲;如果用 else,斜线会退化成阶梯。
几个参数的含义值得掰开:ax、ay 是脉冲数不是毫米,进来之前已经换算过;step_total 取最大值,保证了「长轴每拍都动,短轴按比例动」;step_done 不只是计数,加减速规划要拿它和 step_total 比较来决定当前该加速还是减速,所以它必须和插补在同一个中断里更新,中间不能有别的代码插队。
c->err << 1这里有个隐患:如果 ax、ay 特别大,比如一段 10 万脉冲的长线,err 的量级也在 10 万,左移一位没问题;但如果有人把坐标单位搞错,传进来百万级的脉冲数,int32_t 左移就可能溢出。稳妥做法是加一句范围断言,或者在换算阶段就把单段长度限制住。
3.3 脉冲当量与坐标换算的实参怎么填
算法跑通不难,难的是参数填对。脉冲当量算错,走 100mm 变 87mm,你会在机械精度上找半天原因。这套换算我一般让学生先手算一遍再写进代码:
| 参数 | 示例取值 | 说明 |
|---|---|---|
| 步距角 | 1.8° | 整步 200 步/转 |
| 驱动器细分 | 16 | 拨码或软件设定 |
| 每转脉冲数 | 3200 | 200 × 16 |
| 丝杠导程 | 4 mm | 电机转一圈走的距离 |
| 脉冲当量 | 1.25 μm | 4mm / 3200 |
| 每毫米脉冲 | 800 | 3200 / 4 |
/* config.h —— 换算常量集中在一处,换机构只改这里 */ #define STEPS_PER_REV 3200.0f /* 200 步/转 × 16 细分 */ #define LEAD_MM 4.0f /* 丝杠导程,同步带机构就填带轮周长 */ #define STEPS_PER_MM (STEPS_PER_REV / LEAD_MM) /* 800 脉冲/mm */ /* 命令层调用:毫米 -> 脉冲,四舍五入,浮点只出现在这一层 */ static inline int32_t mm_to_steps(float mm) { return (int32_t)(mm * STEPS_PER_MM + (mm >= 0 ? 0.5f : -0.5f)); }浮点只在这一层出现,进到 Line_Begin 之后全是整数,这个约束建议写进注释并且别破例。四舍五入那半格不能省,直接截断的话每段都少走不到一个脉冲,走一百段就偏出去几十微米,长轨迹上肉眼可见。
两个轴如果机构不一样——比如 X 用丝杠、Y 用同步带——就把 STEPS_PER_MM_X 和 STEPS_PER_MM_Y 分开定义,各自换算完再调 Line_Begin。插补算法本身对两个轴的单位是不敏感的,它只认脉冲数。
4. 脉冲执行层:定时器中断、梯形加减速与急停回路
4.1 中断服务函数里到底该写几行
中断服务函数是整个例程里唯一对时间敏感的地方。20kHz 意味着每 50 微秒进一次,函数执行时间必须压在 10 微秒以内,否则主循环基本没得跑。所以这里面只做三件事:问插补器这一拍谁走、发脉冲、更新下一拍的周期。
/* motor.h —— 直接操作 BSRR,比 HAL_GPIO_WritePin 快一个数量级 */ #define X_PULSE_H() (GPIOA->BSRR = GPIO_PIN_0) #define X_PULSE_L() (GPIOA->BSRR = (uint32_t)GPIO_PIN_0 << 16) #define Y_PULSE_H() (GPIOA->BSRR = GPIO_PIN_1) #define Y_PULSE_L() (GPIOA->BSRR = (uint32_t)GPIO_PIN_1 << 16) /* motor.c —— 20kHz 插补中断 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { uint8_t px, py; if (htim->Instance != TIM3) return; if (g_motion_state != MOT_RUN) return; if (!Line_Next(&g_line, &px, &py)) { /* 本段走完 */ Motion_SegmentDone(); return; } if (px) X_PULSE_H(); if (py) Y_PULSE_H(); __NOP(); __NOP(); __NOP(); __NOP(); /* 脉冲宽度,按驱动器最小宽度补 */ __NOP(); __NOP(); __NOP(); __NOP(); if (px) X_PULSE_L(); if (py) Y_PULSE_L(); Motion_UpdateSpeed(); /* 改 ARR,下一周期生效 */ }用 BSRR 而不是 HAL_GPIO_WritePin,不是洁癖。HAL 那个函数里面有一堆参数检查和结构体解引用,调用一次几十个时钟周期,两个轴各两次就是几百周期,20kHz 下这部分开销能吃掉可观的主循环时间,更麻烦的是执行时间不恒定,脉冲宽度会抖。BSRR 是单条写寄存器指令,置位和复位都靠它,写高 16 位就是清零,原子操作不会被中断打断。
脉冲宽度那几行 __NOP 是占位,实际取值要看驱动器手册,常见要求是不小于 2.5 微秒。72MHz 下一条 NOP 大约 14 纳秒,八个远远不够,真要精确还是得用示波器量着补,或者干脆换个思路:把脉冲输出交给定时器的 PWM 通道,中断里只改比较值,脉冲宽度由硬件保证。例程用的是软件翻转,胜在简单直观,频率上了 50kHz 就得改成硬件方案。
还有一个细节:Motion_UpdateSpeed()放在发完脉冲之后。如果放在前面,改完 ARR 立刻生效,这一拍的脉冲宽度会被压缩,电机听着就是「咔」的一声杂音。
4.2 梯形加减速的周期表怎么算
步进电机有个启动频率,直接给它 20kHz 会堵转丢步,所以每段运动都要有加速、匀速、减速三段。梯形规划是最常用的折中:加速段频率线性上升,匀速段保持,减速段线性下降。
严格来说频率线性变化对应的是 S 形之外的近似,实际工程里够用。真正需要盯的是加速步数,它由目标频率和加速度决定:
/* motion.c —— 速度规划:返回下一拍的定时器周期(1MHz 时基,单位 us) */ typedef struct { uint32_t f_start; /* 启动频率 Hz,一般 500~1000 */ uint32_t f_run; /* 匀速频率 Hz,由进给速度换算 */ uint32_t n_acc; /* 加速段步数 */ uint32_t n_dec; /* 减速段步数 */ } Profile_t; static uint32_t Speed_NextPeriod(uint32_t done, uint32_t total) { uint32_t f; if (done < g_prof.n_acc) { /* 加速段 */ f = g_prof.f_start + (g_prof.f_run - g_prof.f_start) * done / g_prof.n_acc; } else if (done + g_prof.n_dec >= total) { /* 减速段 */ f = g_prof.f_start + (g_prof.f_run - g_prof.f_start) * (total - done) / g_prof.n_dec; } else { /* 匀速段 */ f = g_prof.f_run; } if (f < 100U) f = 100U; /* 兜底,防止除零和超长周期 */ return 1000000UL / f; /* 1MHz 计数 -> 周期值 */ }n_acc 的算法是f_run² / (2a)除以脉冲当量,a 是机构能承受的加速度。这个值不要拍脑袋,先在低速下试,逐步往上加,直到听见电机开始「尖叫」再退回来三成。短段运动(总步数小于 n_acc + n_dec)要特殊处理,比较常见的做法是把加速段和减速段按比例压缩,保证形状还是个梯形而不是三角形,否则短段根本来不及减速,停的时候会甩一下。
返回值直接写进__HAL_TIM_SET_AUTORELOAD(&htim3, period)。因为开了预装载,新值在下一个更新事件才生效,不会打断当前周期。
4.3 限位、回零与软急停的状态流转
运动控制里最不能省的是停机路径。例程用一个枚举把状态管起来,MOT_IDLE、MOT_RUN、MOT_STOP、MOT_FAULT 四个够用,MOT_FAULT 是单向的,只能靠显式复位退出。
限位开关接外部中断,回调里第一件事就是关定时器:
/* stm32f1xx_it.c —— 限位优先于一切 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == LIMIT_X_Pin || GPIO_Pin == LIMIT_Y_Pin) { __HAL_TIM_DISABLE_IT(&htim3, TIM_IT_UPDATE); /* 立刻停脉冲 */ g_motion_state = MOT_FAULT; g_fault_code = (GPIO_Pin == LIMIT_X_Pin) ? FAULT_LIMIT_X : FAULT_LIMIT_Y; } }关键在于「关中断源」而不是「置个标志位让主循环去停」。插补在中断里跑,主循环的响应延迟是不确定的,等它反应过来,机构已经撞上去几十个脉冲了。血泪经验是:限位、急停这类保护,必须在和插补同一优先级甚至更高的中断里直接掐断脉冲源。
回零逻辑放在主循环,用状态机写:快速找开关 → 碰到后后退一段 → 低速二次逼近 → 记录零点。二次逼近是必须的,机械开关的重复精度撑不起一次定位。
5. 避坑与排查:双轴联动现场翻车的五个记录
5.1 斜线跑成阶梯,45° 尤其明显
现象:低速时看不太出来,速度一提,斜线在机构上明显一段一段的,用笔画出来能看到台阶。
原因:两个轴各用一个定时器,中断优先级不同或者其中一个被高优先级中断打断过几次,两路脉冲的相位就错开了。哪怕只错几个脉冲,累积到段末就是一条折线。
解决:改成单一时基。所有插补决策必须在同一个定时器中断里做完,两个轴共用同一个 step_done 计数。这条例程的结构本身就是答案,别自作聪明拆成两个定时器「减轻中断负担」。
5.2 提速后只有一轴丢步
现象:低速两个轴都正常,频率上到 15kHz 以上,X 轴明显少走,Y 轴反而准。
原因:X 轴的脉冲引脚操作路径更长,比如用了 HAL_GPIO_WritePin,或者方向脚在这个轴上是软件延时切换的。中断执行时间超过了 50 微秒周期,中断嵌套自己,脉冲被吞。
解决:先用示波器量中断服务函数里翻转一个测试引脚的时间。超过周期的 30% 就必须优化:换 BSRR 直写、把浮点运算全部赶出中断、把 HAL 调用换成寄存器操作。优化完再量一次,我一般要求压在 8 微秒以内。
5.3 段末坐标越走越偏,走十段偏一点
现象:单段走完看着挺准,连续走十几段之后,实际位置和理论位置差出零点几毫米,误差只增不减。
原因:毫米转脉冲时直接截断取整,每段少走不到一个脉冲,误差单向累积。另一个可能是插补结束条件写成了>=之外的形式,短轴在最后几拍被提前判结束。
解决:换算阶段四舍五入;段与段之间用绝对坐标而不是增量坐标,每段开始前重新算一次 dx、dy,误差就不会滚雪球。有条件的话在段末加一次位置校验,偏差超过阈值直接报错停机,别让它悄悄跑偏。
5.4 改完 ARR 定时器直接不中断了
现象:加了加减速,第一段能跑,第二段开始定时器像死了一样,电机停在原地。
原因:频率算出来周期值超过了 16 位 ARR 的范围,或者1000000UL / f里 f 取了 0,返回值被截断成 0,定时器一直重装载。还有一种是自己写了htim3.Init.Period却没生效,因为运行期改 ARR 要用__HAL_TIM_SET_AUTORELOAD。
解决:加下限保护(例程里那句if (f < 100U) f = 100U;),改 ARR 统一走宏;初始化时确认 AutoReloadPreload 打开了。调这类问题,逻辑分析仪夹住脉冲脚看「有没有波形」,比盯着代码猜快十倍。
5.5 撞了限位电机还在冲
现象:手动触发限位开关,电机继续走,直到机构顶死。
原因:限位判断写在主循环里,插补在中断里跑,主循环被别的事情占住,判断延迟了几十毫秒。或者限位引脚的中断优先级低于定时器中断,根本抢不进来。
解决:限位走 EXTI,中断优先级设到和定时器同级或更高,回调里直接关定时器的更新中断。别用「置标志位」这种软处理,在运动控制里,保护路径越短越好。
6. 二次开发:把两轴插补器拆成可复用的运动队列
跑了几个自己的轨迹之后,多半会想加第三轴或者加圆弧。我的习惯是先把「一段运动」抽象成结构体,再用一个环形队列把它们串起来,主循环只管往队列里塞,中断只管从队列里取,两边不打架:
/* motion_queue.h —— 运动段队列,主循环写、插补中断读 */ typedef enum { SEG_LINE, SEG_ARC, SEG_DWELL } SegType_t; typedef struct { SegType_t type; int32_t target[3]; /* 绝对坐标,单位:脉冲 */ uint32_t feed_hz; /* 该段进给频率 */ } MotionSeg_t; int MQ_Push(const MotionSeg_t *seg); /* 主循环调用,满了返回 -1 */ int MQ_Pop(MotionSeg_t *seg); /* 插补中断里调用,空返回 -1 */队列长度取 8 到 16 就够,太大反而增加内存占用和调试难度。关键是入队和出队要做成无锁的单生产者单消费者,靠读写指针的原子性保证,别在中断里加互斥锁——那玩意儿一上,实时性就没了。
改造顺序我建议这样:先把直线段跑进队列,验证队列本身没问题;再加 SEG_DWELL 做延时,顺便验证状态机的空闲态;最后才动圆弧。圆弧插补的坑主要在象限判断和圆心偏移的符号,用逐点比较法能少走弯路,别一上来就写 DDA。
验证方法很朴素但有效:画一个 100mm 见方的方框再画一个内切圆,跑完量对角线尺寸,误差在 ±0.05mm 以内就算过关。这个方法我从某实验室的老设备上学的,比看波形直观。
最后说个自己的教训。早些年我改这类例程,总想着先加功能再说,结果三轴联动做完发现两轴的斜线还不如原来准,回头一层层扒才发现是队列里存了增量坐标,段与段之间误差累积。从那以后我每次动插补器,都强制走一遍「单段 → 连续十段 → 往返一万次回原点」这三步验证,一步都不跳。希望帮到你。
本文还有配套的精品资源,点击获取