
简介这份资源围绕STM32F103ZET6的步进电机与编码器联合控制展开面向已经具备单片机基础、希望练习定时器PWM输出、输入捕获和串口通信的嵌入式学习者。内容以可编译的Keil工程形式组织包含定时器3驱动步进电机、定时器4读取增量式编码器、USART回传位置与速度数据等模块可用于运动控制入门实验和课程设计参考。压缩包共206个文件约4.96MB以c源文件、h头文件、o目标文件、d依赖文件及crf编译记录为主同时附带uvprojx工程、sct分散加载、hex与map等构建产物便于直接打开、编译和对照分析。已有7350人学习下载。读者可据此理解PWM调速、编码器测速、中断服务与串口上报的配合方式并在此基础上扩展自己的电机控制方案。1. 空载很准、带载就偏STM32 驱动编码器给步进电机补上的那点闭环有个做小型贴标设备的开发者找我一起调试设备空载试机时定位准确换上料带后每张标签的停位都往前偏一点点连续跑两百多张累计偏出去将近 4 毫米。拆开排查后发现脉冲一个没少发电机就是没转到该到的位置。这是典型的开环步进电机问题控制器只知道自己发了多少脉冲根本不知道电机实际走到了哪。后来我把方案改成 STM32 驱动编码器做实时位置反馈再用反馈回头修正步进电机的脉冲节奏问题才彻底稳定下来。这套结构不复杂但细节很零碎编码器四倍频怎么算、16 位计数器回绕怎么处理、脉冲频率改快了电机失步怎么办。下面按我实际调试的顺序从选型一路讲到二次开发。2. 方案与资源分配编码器、驱动器、定时器怎么分工动手写代码之前先把三件事定下来用什么样的编码器、驱动器接口怎么接、STM32 的定时器资源怎么切。这三件事一旦定错方向后面无论是改控制算法还是调整加减速都会很别扭。我见过不少工程为了省一个定时器硬把脉冲输出和编码器解码塞到同一个外设上最后只能靠软件轮询补脉冲CPU 占用直接涨了一倍。2.1 编码器选型增量式 ABZ 够用别一上来就上绝对值这套方案的目标是「知道自己有没有走偏」不是「断电后还记得自己在哪」。所以增量式 ABZ 编码器是最合适的选择A、B 两相正交输出STM32 的定时器编码器接口直接硬件解码CPU 完全不参与计数Z 相每圈给一个索引脉冲用来做零点校准和累计误差的验证。选型时有几个参数要看清楚参数常见取值对本方案的影响每圈线数400 / 500 / 1000 线决定最小可分辨角度1000 线四倍频后一圈 4000 个计数输出形式集电极开路 / 推挽 / 差分长线、强干扰场合用差分A/A-短距离推挽就够供电5V / 3.3V5V 输出必须确认引脚是否耐 5V否则要加电平转换最高响应频率100kHz / 200kHz转速乘以线数不能超过这个值超了直接丢计数算一笔账就很清楚1000 线编码器、驱动器细分 1600 脉冲每圈四倍频后编码器一圈 4000 个计数也就是说电机每走一个脉冲编码器理论上走 2.5 个计数。这个 2.5 就是整个闭环里的换算系数所有位置比较都要经过它。如果编码器线数太低比如 200 线一个脉冲只对应 0.5 个计数反馈分辨率还不如指令分辨率闭环就变成了“看起来很忙但没什么用”。Z 相要不要接我的建议是接。它不参与实时控制只在两种场合用上电找零、长时间连续运行后做一次累计误差复位。接法很简单Z 相进一个 EXTI 外部中断中断里把编码器计数器清零并记一个绝对圈数。不接也能跑但一旦累计误差攒起来你连“到底偏了多少”都说不清。2.2 步进驱动器接口STEP/DIR/EN 三根线细分要算清楚驱动器这边用最通用的 STEP/DIR/EN 三线接口。STEP 每来一个上升沿电机走一个细分步DIR 决定方向EN 是使能低有效居多具体看驱动器手册。这里容易出问题的地方是细分设置很多人为了“更平滑”直接把细分拉到 3200 甚至 6400结果发现高转速下扭矩掉得厉害还容易丢步。细分本质上是把一整步切成若干微步切得越细单步的力矩越小同时控制器需要发出的脉冲频率越高。以一个 1.8° 两相步进电机为例整步 200 步每圈细分 800一圈 800 个脉冲1kHz 脉冲频率对应 1.25 转每秒细分 1600一圈 1600 个脉冲1kHz 对应 0.625 转每秒细分 3200一圈 3200 个脉冲1kHz 对应 0.3125 转每秒STM32 的定时器在 1MHz 计数频率下输出几十 kHz 毫无压力但驱动器光耦输入端的上限往往只有 100kHz200kHz超过这个值脉冲直接被吃掉而且不报错。所以我一般把细分定在 8001600先把脉冲频率留出余量平滑度靠加减速曲线去补而不是靠堆细分。还有两个硬件细节STEP 信号一定要用推挽输出不要用开漏如果脉冲线超过 30 厘米串一个 22100 欧的电阻并加 RC 低通能明显减少误触发。EN 引脚不要图省事直接接地常使能上电瞬间电机是锁死的手动调整机构时会很别扭接一个 GPIO 控制更灵活。2.3 定时器资源分配一个出脉冲、一个收反馈、一个管节拍资源分配我一般按下面这张表来STM32F103/F407 系列都能套外设用途关键配置TIM1STEP 脉冲输出PWM 模式 1占空比固定 50%改 ARR 调频率TIM2编码器接口编码器模式 TI12四倍频16 位满量程TIM3控制节拍1kHz 更新中断跑位置环和速度采样TIM4脉冲计数可选从模式外部计数统计实际发出多少脉冲EXTIZ 相索引下降沿触发做零点校准TIM1 是高级定时器带死区和刹车输出能力最稳TIM2/TIM5 是 32 位定时器的型号当然更好但 16 位也完全够用回绕问题在软件里一个强制类型转换就能解决。TIM3 的 1kHz 节拍是整个闭环的心脏位置环周期就是它不要再往里面塞别的耗时逻辑否则控制周期抖动会直接体现在电机的声音上。有一件事值得单独说不要用软件延时去发脉冲。用HAL_Delay或者空循环翻 GPIO 的做法在低速下看着能用一旦频率上到几 kHz中断一来节奏就乱了电机听着像哮喘。硬件 PWM 输出脉冲是这套方案的基础后面所有频率调整都是在改 ARR 寄存器。3. 工程代码结构与初始化从时钟树到第一个脉冲代码组织的原则很简单谁负责产生动作、谁负责采集反馈、谁负责决策分成三层中间用结构体传数据别到处extern全局变量。我见过一个工程把编码器计数、PID 参数、脉冲频率全写成全局变量改一个轴要复制一遍代码加第二个轴的时候直接崩了。3.1 目录结构与分层project/ ├── Core/ │ ├── Src/ │ │ ├── main.c # 只做初始化和主循环调度 │ │ ├── bsp_step.c/.h # 脉冲输出频率设置、启停、发定量脉冲 │ │ ├── bsp_encoder.c/.h # 编码器初始化、增量读取、清零 │ │ ├── ctrl_axis.c/.h # 轴对象位置环、加减速、状态机 │ │ └── app_cmd.c/.h # 指令解析串口收到的运动命令 │ └── Inc/ ├── Drivers/ # HAL 库 └── stm32f1xx_it.c # 中断里只置标志不做业务分层的边界是bsp_*只认寄存器和外设不知道什么叫“目标位置”ctrl_axis只认物理量counts、counts/s不直接碰寄存器中断服务函数只置标志位业务逻辑全部丢到主循环或者 TIM3 的回调里跑。这条规矩坚持下来后面加第二、第三个轴的时候几乎是复制粘贴。3.2 时钟、GPIO 与脉冲定时器的初始化先把脉冲通道打通这是唯一一个能用示波器直接看到的东西先让它出波形后面的调试会轻松很多。以 72MHz 主频为例/* bsp_step.c —— STEP 脉冲输出TIM1_CH1 映射到 PA8 */ static TIM_HandleTypeDef htim1; void bsp_step_init(void) { __HAL_RCC_TIM1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_8; gpio.Mode GPIO_MODE_AF_PP; /* 必须推挽复用开漏带不动光耦 */ gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio); htim1.Instance TIM1; htim1.Init.Prescaler 72 - 1; /* 72MHz / 72 1MHz 计数频率 */ htim1.Init.CounterMode TIM_COUNTERMODE_UP; htim1.Init.Period 1000 - 1; /* 1MHz / 1000 1kHz 起始频率 */ htim1.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter 0; htim1.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; /* 改 ARR 立即生效且不撕脉冲 */ HAL_TIM_PWM_Init(htim1); TIM_OC_InitTypeDef oc {0}; oc.OCMode TIM_OCMODE_PWM1; oc.Pulse 500; /* 50% 占空比驱动器只认边沿 */ oc.OCPolarity TIM_OCPOLARITY_HIGH; oc.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim1, oc, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); __HAL_TIM_SET_COUNTER(htim1, 0); }几个参数要理解透预分频 71 把 72MHz 降到 1MHz这样 ARR 的数值就直接等于“多少微秒一个周期”ARR 设 999 得到 1kHz换算关系是f 1MHz / (ARR 1)。占空比必须给 50%因为很多驱动器对高电平宽度有最小要求常见 2.5μs频率拉高以后 50% 是最保险的。AutoReloadPreload一定要开否则在计数器跑到一半时改 ARR可能产生一个畸形脉冲驱动器会当成一步走掉表现就是“偶尔多走一步”。/* 运行时改频率边界必须卡死 */ void bsp_step_set_freq(uint32_t hz) { if (hz 200) hz 200; /* 低于 200Hz 容易踩到电机低频共振点 */ if (hz 40000) hz 40000; /* 留余量驱动器光耦上限通常 100kHz 以内 */ uint32_t arr (1000000UL / hz) - 1; __HAL_TIM_SET_AUTORELOAD(htim1, arr); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, (arr 1) / 2); }这段代码里的两个边界值不是随手写的。下限 200Hz 是因为两相步进电机在 100200Hz 附近有个明显的共振区长期在那个频率跑机架会跟着嗡上限 40kHz 是给驱动器光耦和线路容差留的余量不同驱动器手册上标的值差别很大宁可保守。占空比每次跟着 ARR 重算是因为改 ARR 不会自动改比较值忘了改就会出现频率升高、占空比掉到 10% 以下然后丢步。3.3 编码器接口配置与四倍频计数编码器这边用定时器的硬件编码器模式A/B 两相接 TIM2 的 CH1/CH2。核心配置就几行但滤波器参数是重点/* bsp_encoder.c —— 正交编码器四倍频 */ static TIM_HandleTypeDef htim2; void bsp_encoder_init(void) { __HAL_RCC_TIM2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_0 | GPIO_PIN_1; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_PULLUP; /* 集电极开路输出必须上拉 */ HAL_GPIO_Init(GPIOA, gpio); TIM_Encoder_InitTypeDef enc {0}; enc.EncoderMode TIM_ENCODERMODE_TI12; /* A/B 双相都计数 四倍频 */ enc.IC1Polarity TIM_ICPOLARITY_RISING; enc.IC2Polarity TIM_ICPOLARITY_RISING; enc.IC1Selection TIM_ICSELECTION_DIRECTTI; enc.IC2Selection TIM_ICSELECTION_DIRECTTI; enc.IC1Prescaler TIM_ICPSC_DIV1; enc.IC2Prescaler TIM_ICPSC_DIV1; enc.IC1Filter 6; /* 关键滤掉 100ns 级毛刺值越大滤波越强 */ enc.IC2Filter 6; htim2.Instance TIM2; htim2.Init.Prescaler 0; htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; /* 16 位满量程回绕交给软件处理 */ HAL_TIM_Encoder_Init(htim2, enc); HAL_TIM_Encoder_Start(htim2, TIM_CHANNEL_ALL); __HAL_TIM_SET_COUNTER(htim2, 0); }TIM_ENCODERMODE_TI12表示 A、B 两相的上升沿和下降沿都触发计数一圈 1000 线的编码器就得到 4000 个计数这就是四倍频。滤波器值 6 对应的采样窗口大概在几百纳秒量级具体看芯片手册现场如果电机线、编码器线捆在一起走这个值给到 810 会更稳代价是最高可识别频率下降所以高速场合要重新算一遍。读取用下面这个技巧比一堆 if 判断回绕干净得多/* 返回相对上次调用的增量正数正转负数反转 */ int16_t bsp_encoder_read_delta(void) { static uint16_t last 0; uint16_t now (uint16_t)__HAL_TIM_GET_COUNTER(htim2); int16_t d (int16_t)(now - last); /* 无符号相减再强转有符号自动处理回绕 */ last now; return d; }原理是补码的减法0x0005 - 0xFFFE在 16 位无符号里得到 7强转有符号就是 7正好是跨过 0xFFFF 回绕后的真实增量。负数方向同理。这个写法要求调用间隔内增量不超过 ±327671kHz 采样下意味着转速不能高到离谱正常工况完全够。别忘了last是静态变量多轴的话要放到轴对象里不能用函数内静态。4. 核心链路拆解脉冲发生、反馈采样与位置闭环到这里硬件通道都通了接下来是把它们串成一条闭环定时器发脉冲 → 电机转 → 编码器计数 → 位置环算误差 → 调整脉冲频率。这条链路的每一环单独看都很简单难的是时序和单位换算。4.1 定量脉冲与频率在线修改开环模式下“走 1000 个脉冲”很好实现闭环模式下就不能这么干了因为脉冲是持续输出的随时可能被叫停。我的做法是脉冲定时器一直开着用使能位控制启停位置到了就关掉输出通道而不是频繁启停定时器。频繁HAL_TIM_PWM_Stop/Start会带来几微秒的不确定延迟累积起来就是定位误差。/* 用一个 GPIO 控制 DIR一个使能标志控制脉冲开关 */ void bsp_step_run(int8_t dir, uint32_t hz) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, dir 0 ? GPIO_PIN_SET : GPIO_PIN_RESET); /* 方向建立时间驱动器一般要求 5us 以上留 20us 余量 */ for (volatile uint32_t i 0; i 200; i) { __NOP(); } bsp_step_set_freq(hz); __HAL_TIM_SET_COUNTER(htim1, 0); HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); } void bsp_step_stop(void) { HAL_TIM_PWM_Stop(htim1, TIM_CHANNEL_1); /* 停止后把输出拉低避免 STEP 长期高电平让驱动器误判 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET); }方向切换前那段空循环是血泪经验。DIR 翻转和第一个 STEP 边沿如果挨得太近驱动器内部的方向锁存还没完成会把这一步走到反方向去现象是“偶尔反冲一下”特别难查。20 微秒的建立时间对所有常见驱动器都够用代价可以忽略。还有一点HAL_TIM_PWM_Stop之后输出引脚会回到复位状态有些型号会变成浮空STEP 线上出现毛刺就可能导致驱动器多走一步。所以停完手动拉低一次这个动作在调试时看着多余量产时能省掉很多莫名其妙的“偶发多走一步”。4.2 编码器读数、方向与 16 位溢出处理位置反馈的关键不是读计数而是把计数换算成物理位置并且保证长时间运行不丢圈。我用一个 32 位有符号变量做累计typedef struct { int32_t pos_counts; /* 累计位置编码器计数 */ int32_t zero_offset; /* 零点偏移Z 相校准后写入 */ float ratio; /* 编码器 counts / 驱动器 pulse */ } enc_state_t; void enc_update(enc_state_t *e) { e-pos_counts bsp_encoder_read_delta(); } int32_t enc_get_position(const enc_state_t *e) { return e-pos_counts - e-zero_offset; }pos_counts用 32 位而不是 16 位是因为 4000 计数一圈的话16 位只有 ±8 圈的行程来回运动几趟就溢出了。32 位能撑到几十万圈实际项目里不可能跑满。ratio就是前面算的 2.5它的作用是把编码器位置换算成“等效脉冲数”跟指令位置直接比较/* 指令走了 target_pulse 个脉冲理论上编码器应该在这个位置 */ int32_t expect_counts target_pulse * e-ratio; int32_t error_counts expect_counts - enc_get_position(e);Z 相校准的写法是找零时让电机以低速往一个方向走等 Z 相中断把当前pos_counts写进zero_offset。这样每次上电的位置基准都一致不会因为上次断电时停在哪而产生偏移。注意 Z 相中断里只置标志实际清零放到主循环做中断里改共享变量容易出竞态。4.3 位置环 PID 与死区补偿位置环的输出不是电压而是脉冲频率的修正量。误差大就跑快一点误差小就慢下来接近目标时频率降到最低避免冲过头。增量式 PID 在这里比位置式好用因为它天然抗积分饱和typedef struct { float kp, ki, kd; float err_last, err_prev; float out_max; /* 频率修正上限Hz */ float dead_zone; /* 死区counts */ } pos_pid_t; float pos_pid_calc(pos_pid_t *p, float target, float actual) { float err target - actual; /* 死区内不修正否则电机会在目标点附近来回抖 */ if (fabsf(err) p-dead_zone) { p-err_last err; return 0.0f; } float d_out p-kp * (err - p-err_last) p-ki * err p-kd * (err - 2.0f * p-err_last p-err_prev); p-err_prev p-err_last; p-err_last err; if (d_out p-out_max) d_out p-out_max; if (d_out -p-out_max) d_out -p-out_max; return d_out; }参数整定的顺序我一般这么走先把ki、kd置零kp从 0.1 开始往上加加到电机在目标点附近开始小幅震荡然后回退一半接着加ki消除静差但积分项一定要限幅否则长时间堵转会攒出一个巨大的修正量一松开车就飞出去kd最后加它对编码器噪声极其敏感滤波器没做好之前加kd基本是自找麻烦。死区这个参数容易被忽略。编码器有量化误差机械有间隙误差永远不可能精确到 0没有死区的话 PID 会一直输出微小修正电机就在目标点附近持续微震声音像蚊子叫。死区取 35 个编码器计数对应机械上零点零几度肉眼看不出来但电机立刻就安静了。死区太大又会影响定位精度这个值要拿实际重复定位精度去标。4.4 梯形加减速用剩余距离反推允许速度频率阶跃是丢步的头号原因。从 0 直接跳到 20kHz电机转子跟不上旋转磁场直接原地啸叫不动。加减速曲线里梯形最简单也最够用核心思路是每一步都根据剩余距离反推“现在最快能跑多快”typedef struct { float v_max; /* counts/s机构允许的最高速度 */ float acc; /* counts/s^2加速度 */ float v_cur; /* 当前速度 */ } ramp_t; /* dt 是调用周期单位秒remain 是剩余 counts */ float ramp_next(ramp_t *r, int32_t remain, float dt) { float s fabsf((float)remain); /* v sqrt(2*a*s)保证在到达目标前能把速度降到 0 */ float v_allow sqrtf(2.0f * r-acc * s); float v_set (v_allow r-v_max) ? v_allow : r-v_max; if (r-v_cur v_set) { r-v_cur r-acc * dt; if (r-v_cur v_set) r-v_cur v_set; } else { r-v_cur - r-acc * dt; if (r-v_cur v_set) r-v_cur v_set; } if (r-v_cur 20.0f s 0) r-v_cur 20.0f; /* 保底速度别停在半路 */ return r-v_cur; }sqrtf每 1ms 调一次在 72MHz 的 M3 上大概几十微秒能接受如果跑到 10kHz 控制频率就该换成定点开方或者查表。速度单位是编码器 counts/s转成脉冲频率时除以ratio就行两个单位体系别混着用混一次就要查半天。保底速度那行看着不起眼实际很关键。按 sqrt 曲线算最后几个 counts 的允许速度会趋近于 0如果不设下限电机会在离目标还有一点点的地方“爬”很久表现是定位时间忽长忽短。给个 20 counts/s 的底速最后一点点直接跨过去靠死区吃掉。5. 常见问题排查丢步、抖动、计数跳变这几类现场故障这套结构调通以后真正花时间的都在现场排查。下面五个是我踩过或者帮别人定位过的按“现象 → 原因 → 解决”记下来遇到类似症状可以直接对号。5.1 现象空载准、带载越跑越偏原因位置环没开或者开了但只做了“报警”没做“修正”。很多人把编码器接上以后只用来判断“有没有丢步”超阈值就停机实际位置从来不参与控制负载一上来每步都差一点误差单调累积。解决把误差真正喂回频率修正同时确认ratio算对了。验证方法是手动让电机走 1000 个脉冲读编码器位置变化应该是 2500 个计数1000 线四倍频、细分 1600 的情况。对不上就是系数错了PID 调得再好也没用。5.2 现象低速爬行时电机一顿一顿原因两个可能。一是脉冲频率低于电机共振区又没做细分补偿二是位置环的控制周期太长1kHz 的节拍配上很低的脉冲频率每个周期只发出去几个脉冲节奏本身就是断续的。解决先把细分从 800 提到 1600 试试通常立刻改善再把低速段的脉冲频率下限抬到 200Hz 以上靠“快走死区”代替“慢爬”。如果机构必须低速那就把控制周期提到 5kHz 或 10kHz让每个周期发出的脉冲数均匀一些。5.3 现象编码器计数偶发跳变几千原因干扰。编码器线跟电机线捆在一根线槽里STEP 边沿通过容性耦合窜进 A/B 相硬件解码器当成真脉冲计了。也可能是上拉电阻没加集电极开路输出在长线上边沿太缓。解决先加大滤波器IC1Filter从 6 提到 10验证是不是干扰确认是的话物理层解决——编码器线换屏蔽双绞、屏蔽层单端接地、跟动力线分开走。软件上再加一层合理性校验单次采样增量超过物理极限就丢弃并计数累计几次就报错停机。这层校验比事后补偿有用得多。5.4 现象升频过程中电机突然停住或啸叫原因加速度给太大转子跟不上。或者改 ARR 的时机不对AutoReloadPreload没开改的那一刻产生畸变脉冲驱动器误判。解决把acc减半试啸叫消失就是加速度问题检查AutoReloadPreload是否为 ENABLE再确认改频率的操作是不是在中断里做的——如果在 TIM3 中断里改 TIM1 的 ARR注意两者的优先级关系TIM1 的更新中断优先级要低于 TIM3否则会打断正在进行的分频写入。5.5 现象位置环一开就震荡越调越糟原因符号搞反了或者反馈延迟太大。误差是“目标减实际”输出是“频率修正”如果 DIR 极性和编码器计数方向不一致负反馈就变成了正反馈。解决先做单方向测试——手动给一个正向小位移看编码器计数是增还是减再看 PID 输出是正是负两边对上再闭环。延迟方面1kHz 控制周期对应 1ms 延迟配 4000 counts/圈、1600 脉冲/圈的参数系统增益不能给太高kp超过 2 基本就开始振。注意排查顺序永远是“先开环打表、再单方向验证、最后闭环”跳过前两步直接调 PID等于在黑匣子里瞎猜。6. 二次开发从单轴闭环到多轴联动与参数在线整定单轴跑通以后下一步基本都是加轴。这时候如果代码还停留在全局变量阶段改动量会成倍增长。我的做法是把轴抽象成对象所有状态打包进结构体硬件资源用指针挂进去typedef struct axis_s { TIM_HandleTypeDef *ht_step; /* 脉冲定时器 */ TIM_HandleTypeDef *ht_enc; /* 编码器定时器 */ uint32_t ch; /* 脉冲通道 */ float ratio; /* counts / pulse */ enc_state_t enc; pos_pid_t pid; ramp_t ramp; int32_t target; /* 目标位置counts */ uint8_t busy; } axis_t; void axis_task(axis_t *ax, float dt) { enc_update(ax-enc); int32_t pos enc_get_position(ax-enc); int32_t rem ax-target - pos; if (labs(rem) 5 !ax-busy) { bsp_step_stop(); return; } float v ramp_next(ax-ramp, rem, dt); /* counts/s */ float corr pos_pid_calc(ax-pid, ax-target, pos); /* 频率修正 */ float hz (v corr) / ax-ratio; /* 换成脉冲频率 */ bsp_step_run(rem 0 ? 1 : -1, (uint32_t)fabsf(hz)); }这样加第二个轴就是多一个axis_t实例、多一组定时器业务代码一行不用改。多轴联动要同步启动的话别一个个调axis_task先用一个统一的“预装载”把各轴的target和ramp初始化好再用一个全局标志位在同一毫秒内全部放开起步时间差控制在控制周期以内插补出来的轨迹才不会有拐点。参数在线整定值得单独做一套串口协议我一般定成M轴号 目标counts vmax acc\r\n收到就更新结构体字段回一个OK 当前位置。调试 PID 的时候不用反复烧程序改一个数看一次响应效率差好几倍。协议解析放主循环别塞中断。验证方法我固定走三步第一步开环跑 1000 个脉冲用编码器确认实际走了 2500 个计数误差在 ±2 以内算过第二步闭环定点同一目标来回走 20 次记录每次的停止位置极差就是重复定位精度第三步连续往复 500 次看累计误差曲线是不是平的如果单调上升说明还有系统性偏差没补掉通常是反向间隙或者 Z 相校准没做。从那以后我每次接步进电机的活不管客户说得多简单都强制先跑一遍“开环打表 编码器读数核对”这两步宁可多花半小时也不愿在客户现场对着一个说不清偏了多少的机构猜。这套流程希望帮到你。本文还有配套的精品资源点击获取