简介:面向嵌入式初学者与电机控制实践者的STM32F103ZET6工程包,围绕步进电机精确定位、调速及增量式编码器反馈读取展开。压缩包共206个文件、约4.96MB,以38个.h头文件、37个.c源文件及.o、.d、.crf等编译中间文件为主,并含uvprojx、uvoptx、sct、hex、axf等Keil工程与烧录文件,便于直接打开编译和对照学习。已有7350人学习下载。工程使用定时器3输出PWM或互补PWM驱动步进电机,定时器4以输入捕获方式读取编码器A/B相脉冲,再经USART和中断把编码器值发送到电脑,形成控制与反馈链路。读者可从中梳理定时器配置、PWM调速、编码器测速与串口通信的组织方法,并据此扩展运动控制功能。
1. 从一台总是丢步的滑台说起:为什么要在步进电机后面加编码器
滑台走 100 mm,回零再走 100 mm,第三次停在 99.7 mm——这种一点点累积起来的位置漂移,是开环步进最典型的翻车现场。用 STM32 驱动编码器加步进电机,本质上就是给步进电机装一只眼睛:MCU 照常输出脉冲和方向信号,编码器把实际位移读回来,两边一比对,丢了就补,堵了就报。
这套组合适合做定位滑台、XY 平台、机械臂关节、自动送料机构的课程设计和毕业设计,也适合手上有一台老开环设备、想低成本升级成闭环的场合。整篇按能落地交付的顺序走:先定资源分配,再拆编码器反馈和脉冲输出两条链路,最后把闭环调参和验收标准摊开讲。
2. 总体架构:四个定时器各司其职的硬件资源分配
嵌入式项目里最常见的返工,是把定时器随便一分,写到一半发现编码器接口和 PWM 抢了同一个外设。所以架构这章不先画框图,先把资源分配表定死,再谈代码分层。STM32 的定时器是硬资源,一旦引脚焊上去就改不动了,前期花二十分钟把表排清楚,比后期飞线省事得多。
2.1 从需求到信号流:一条闭环里到底有几段
把整套系统拆开,信号只走一条主链:上位机下发目标位置 → 控制器换算成脉冲数 → 定时器输出脉冲和方向 → 驱动器细分放大 → 步进电机转动 → 丝杠或减速机构带动负载 → 编码器采集实际转角 → MCU 读回计数 → 与目标比对后修正输出。这条链上有两个独立的物理量在跑,一个是「发了多少脉冲」,一个是「走了多少位移」,闭环的全部价值就在于让后者追上后者。
这里要先定一个概念:这套系统通常做成半闭环,不是全闭环。编码器装在电机尾轴或丝杠输入端,测的是电机转了多少,不是工作台最终停在哪里。丝杠反向间隙、联轴器弹性变形、皮带打滑这些误差它测不到。想测到工作台,就得加光栅尺,成本立刻上一个台阶。对于毕设和课设,半闭环完全够用,而且能把「失步」这个步进电机最主要的问题兜住——电机被负载卡住空转、脉冲发了但没转,编码器立刻能发现。
信号流定下来之后,控制周期也就定了。位置环跑 1 kHz,也就是 1 ms 一次,这个节拍对步进电机足够。步进电机的机械响应时间常数通常在几十毫秒量级,1 ms 采样不会漏掉有效动态;同时 1 kHz 对 72 MHz 的 Cortex-M3 来说负担很轻,主循环还能腾出时间做串口解析和状态上报。
2.2 定时器与引脚分配表:谁发脉冲、谁收反馈、谁管节拍
以常见的 STM32F103C8T6 为例,72 MHz 主频,4 个定时器,资源刚好够一套单轴闭环。下面这张表是我一般会先写进设计文档的分配方案:
| 资源 | 用途 | 关键配置 | 备注 |
|---|---|---|---|
| TIM1_CH1 | 步进脉冲输出 | PWM 模式 1,占空比 50%,频率 500 Hz~20 kHz | 高级定时器,APB2 上 72 MHz |
| GPIOB Pin5 | 方向信号 DIR | 推挽输出,高正低反 | 换向前后留 5 μs 建立时间 |
| GPIOB Pin6 | 使能信号 ENA | 推挽输出,低有效 | 待机时关使能省电、降发热 |
| TIM3_CH1/CH2 | 编码器 A/B 相输入 | 编码器接口模式 TI12,四倍频 | 输入滤波 IC1Filter=6 |
| TIM2 | 1 ms 控制时基 | 预分频 71,ARR 999,更新中断 | 中断优先级设最高 |
| USART1 | 上位机通信 | 115200 8N1,中断接收 | 帧头 + 命令 + 数据 + 校验 |
| GPIOA Pin0/1 | 左右限位开关 | 上拉输入,外部中断 | 硬件保护,优先级高于软件 |
这张表里有两个容易忽略的点。第一,TIM1 是高级定时器,它挂在 APB2 上,时钟就是 72 MHz;TIM2、TIM3、TIM4 挂在 APB1 上,APB1 分频后是 36 MHz,但定时器时钟会被自动倍频回 72 MHz,算 PWM 频率的时候别按 36 MHz 算,否则频率差一倍。第二,编码器接口模式只支持带 CH1/CH2 的定时器,TIM2、TIM3、TIM4 都能用,TIM1 也能,但 TIM1 通常留给 PWM 更划算。
引脚上还要注意电平匹配。脉冲加方向型驱动器的输入一般是光耦,导通电流 8~15 mA,STM32 的 GPIO 拉电流能力只有 8 mA 左右,直连偶尔能转,但边沿会变缓、高速时丢脉冲。稳妥的做法是加一级 74HC245 或者高速光耦做缓冲,成本几毛钱,能省掉后面一堆玄学问题。
2.3 软件分层与 1 kHz 控制节拍
软件分三层。底层是 BSP,只管寄存器和外设,bsp_encoder.c负责读计数、bsp_step.c负责设频率和方向、bsp_timer.c负责时基。中间层是控制层,放脉冲当量换算、加减速规划、位置 PID。上层是应用层,解析串口命令、跑状态机、上报数据。分层的目的不是好看,是为了让后面调参时能单独替换某一层而不动其他代码。
控制节拍统一挂在 TIM2 的 1 ms 更新中断里。中断里只做三件事:读编码器累计位置、跑一次状态机、算出新的脉冲频率写进寄存器。所有耗时操作——串口发数据、浮点打印、Flash 写入——一律踢到主循环,中断里绝不留printf。这是血泪经验,一个printf放在 1 kHz 中断里,串口一阻塞,整个控制节拍就乱了。
/* 1 kHz 控制节拍:只做读、算、写三件事 */ void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE); /* 1. 读反馈:累计位置(单位:编码器计数) */ axis.pos_now = Encoder_GetPos(); /* 2. 跑状态机与位置环 */ Axis_Task_1ms(); /* 3. 写输出:新的脉冲频率由 Axis_Task 内部写入 TIM1 */ } }这段代码的注释比代码本身重要。Encoder_GetPos()返回的是 32 位累计值,不是定时器里的 16 位原始计数,累计逻辑在 3.3 节展开。Axis_Task_1ms()是整个系统的核心,它按当前状态决定是继续发脉冲、减速停车还是报警。中断里没有一句浮点打印,串口上报放在主循环,这样即使上位机卡住,控制节拍依然稳定。
2.4 上位机指令帧与状态机
状态机是让代码从「能跑」变成「能交付」的分水岭。评审老师或者甲方问「如果运行中按了急停会怎样」,有状态机的项目能直接答,没有的只能现场演示碰运气。我一般定五个状态:
typedef enum { AX_IDLE = 0, /* 空闲,使能关闭 */ AX_HOMING, /* 回零中,找限位或 Z 相 */ AX_MOVING, /* 定位中,位置环工作 */ AX_HOLD, /* 到位保持,使能开着锁住位置 */ AX_FAULT /* 故障,脉冲已停,等待复位 */ } ax_state_t;状态迁移的规则要写死:AX_IDLE收到定位命令进AX_MOVING;AX_MOVING中误差进入容差带且速度降下来进AX_HOLD;任何状态下限位触发或失步超限直接跳AX_FAULT,且AX_FAULT只能由上位机发复位命令退出。急停处理放在限位的外部中断里,优先级高于 TIM2,进中断第一件事就是把 TIM1 的脉冲输出关掉,不等下一个控制周期。
串口协议不用复杂,够用就行:帧头0xAA+ 命令字 + 4 字节目标位置(小端)+ 1 字节校验和。命令字区分「定位」「回零」「停止」「读状态」。上位机每 20 ms 发一次读状态,MCU 回目标位置、实际位置、当前频率、状态字,共 13 字节。这套协议在答辩时用串口助手就能演示,不需要额外写上位机软件,省下来的时间可以拿去调参数。
3. 编码器反馈链路:正交解码、四倍频与速度估计
编码器这条链路是整套方案里最容易出问题的地方,因为它一头连着机械、一头连着电。很多人调不通不是代码写错了,是波形本身就脏。这一章按信号进入 MCU 的顺序走:先看电气前端,再看定时器怎么配,然后解决 16 位计数器回绕这个必踩的坑,最后落到速度怎么估。
3.1 接线与电气前端:先保证波形干净再谈算法
增量式正交编码器输出 A、B 两路方波,相位差 90 度,另外可能带一路 Z 相每转一个脉冲。接线只有五根:VCC、GND、A、B、Z。看起来简单,坑全在细节里。
供电电压先确认。编码器常见 5 V 供电,输出也是 5 V 推挽或集电极开路。STM32 的引脚耐 5 V 的是标着 FT 的那些,PA8~PA15、PB2~PB15 之类,但即便是 FT 引脚,长期在 5 V 下工作也不踏实。稳妥做法是加电平转换,或者选 3.3 V 供电的编码器型号。集电极开路输出的还必须加上拉电阻,一般 4.7 kΩ 到 10 kΩ,不加的话波形上升沿是圆的,高速时直接读不到。
线缆和滤波同样重要。编码器线不要和电机动力线绑在一起走,电机 PWM 的 dv/dt 很大,耦合过来就是一堆毛刺。用屏蔽双绞线,屏蔽层单端接地。软件侧在定时器输入上加滤波,IC1Filter设成 6 左右,对应采样几次一致才认,能滤掉几微秒的窄脉冲,又不至于把正常边沿吃掉。
提示:调试编码器第一步永远是把 A、B 两相接示波器或者逻辑分析仪,手动慢速转动电机,看两路是不是干净的 90 度方波。波形不对,后面所有代码都是白写。
3.2 定时器编码器模式配置与四倍频
STM32 的定时器有个专门的编码器接口模式,硬件自动根据 A、B 两相的边沿做加减计数,CPU 完全不参与。这是它比软件外部中断解码强的地方——软件解码在 20 kHz 脉冲下中断频率能到 80 kHz,CPU 直接被打满。
配置的关键是TIM_EncoderMode选TIM_ENCODERMODE_TI12,表示两路都参与计数,也就是四倍频。四倍频的意思是:编码器标称 1000 线,指的是每转 A 相 1000 个脉冲,A、B 两相各上下沿都计数,一圈就是 4000 个计数。分辨率翻四倍,对低速定位很有价值。
/* TIM3 编码器接口模式初始化:四倍频,带输入滤波 */ void Encoder_Init(void) { TIM_Encoder_InitTypeDef sConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; /* 编码器不能分频,分频会丢边沿 */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 0xFFFF; /* 16 位满量程,靠软件扩展位宽 */ htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode = TIM_ENCODERMODE_TI12; /* TI1 与 TI2 都计数 → 四倍频 */ sConfig.IC1Polarity = TIM_ICPOLARITY_RISING; sConfig.IC1Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler = TIM_ICPSC_DIV1; sConfig.IC1Filter = 6; /* 滤波系数,抗电机干扰 */ sConfig.IC2Polarity = TIM_ICPOLARITY_RISING; sConfig.IC2Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC2Prescaler = TIM_ICPSC_DIV1; sConfig.IC2Filter = 6; HAL_TIM_Encoder_Init(&htim3, &sConfig); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); }Prescaler必须为 0,这是新手最容易设错的一个参数。有人想着降频抗干扰,把预分频设成 2,结果每两个边沿才计一次数,四倍频直接变两倍频,分辨率对不上,位置换算全错。抗干扰要靠输入滤波IC1Filter,不能靠预分频。
Period设成 0xFFFF 而不是某个小值,是为了让计数器尽量少回绕。但 16 位终究会满,4000 计数一圈的话,16 圈就溢出了,连续运行必然遇到,所以回绕处理必须写。
3.3 16 位计数器的回绕处理与累计位置
回绕处理有个非常简洁的写法:把两次读数的差值强制转成int16_t。补码运算天然处理了正向溢出和反向溢出两种情况,不需要写任何if判断。
/* 读累计位置:利用 int16_t 补码特性自然处理 16 位回绕 */ int32_t Encoder_GetPos(void) { static uint16_t last_cnt = 0; static int32_t total = 0; uint16_t now = TIM3->CNT; /* 直接读寄存器,比 HAL 快 */ int16_t diff = (int16_t)(now - last_cnt); total += diff; last_cnt = now; return total; }now - last_cnt是无符号减法,结果按 16 位截断;强制转int16_t后,如果实际是正向跨过 0xFFFF 回到 0,差值会变成一个小负数,但因为补码表示,total += diff的净效果依然正确。举个例子:last_cnt = 65530,now = 5,无符号差是 11,转成int16_t还是 11,累计加 11,正确。反向跨零时差值是 -11,同样正确。这个技巧的前提是两次读取之间电机转动不超过 32767 个计数,按 4000 计数一圈算就是 8 圈,1 ms 内不可能转到,所以安全。
这个函数必须在固定的 1 ms 节拍里调用,不能在主循环里随手调。原因很直接:两次调用之间的时间间隔如果不定,差值对应的物理位移就不定,累计值虽然还是对的,但速度计算会乱。所有对Encoder_GetPos()的调用都收敛到 TIM2 中断里做一次,其他地方用缓存值。
回零逻辑也在这里落地。Z 相每转一个脉冲,配合限位开关做二次精定位:先快速撞限位,退回 5 mm,再慢速找 Z 相上升沿,找到就把total清零作为机械原点。只靠限位的话,每次回零误差能有好几十个计数,重复性很差,答辩演示时一眼就能看出来。
3.4 速度估计:M 法、T 法与滑动平均
速度用在两个地方:一是加减速规划要知道当前实际速度,二是失步判定要看速度是否异常掉下来。两种经典方法各有适用区间。
M 法是在固定时间窗内数脉冲个数,v = Δcount / Δt。实现简单,1 ms 读一次差值就是,高速时精度高。缺点是低速时 1 ms 内可能只有一两个计数,量化误差极大,速度读数一跳一跳。
T 法是测两个脉冲之间的时间间隔,v = 1 / (计数差 × 时基)。低速时一个脉冲周期很长,时间测量精度高;高速时脉冲间隔太短,反而测不准。
工程上的做法是分段:速度高于某个阈值用 M 法,低于阈值切 T 法,或者干脆只用 M 法加滑动平均。对步进电机这种应用,最高转速一般不超过 600 rpm,1 ms 采样下 4000 计数一圈对应 40 计数/ms,量化误差 2.5%,加个 8 点滑动平均就够用了,代码也简单。
/* M 法测速 + 8 点滑动平均,单位:计数/毫秒 */ int16_t Encoder_GetSpeed(void) { static int16_t buf[8] = {0}; static uint8_t idx = 0; static int32_t last = 0; int32_t now = Encoder_GetPos(); int16_t raw = (int16_t)(now - last); /* 1 ms 内的计数增量 */ last = now; buf[idx] = raw; idx = (idx + 1) & 0x07; int32_t sum = 0; for (uint8_t i = 0; i < 8; i++) sum += buf[i]; return (int16_t)(sum / 8); /* 除以 8 用移位更快 */ }滑动平均会引入约 4 ms 的相位滞后,对位置环影响不大,但如果要做高动态响应就得换成带预测的滤波。这个滞后在调 PID 时会有体现:微分项如果用这个速度做,容易超前补偿过头,一般微分直接对位置误差做,不用速度值。
4. 步进驱动与位置闭环:从脉冲当量到PID补偿
前面铺完了感知,这一章讲执行。步进电机的控制说到底就两个量:发多快(频率)和发多少(脉冲数)。闭环要做的事,是在这两个量上叠加一层根据实际位置做的修正。这一章先把换算关系钉死,再讲频率怎么调,最后落到两种闭环写法的取舍。
4.1 脉冲当量换算:细分、导程与减速比
脉冲当量是整套系统的地基,算错了后面全错,而且错得很隐蔽——位置永远差一个固定比例,看起来像「控制精度不够」,实际是换算系数写错了。
换算链条是这样的:步进电机步距角 1.8 度,一圈 200 个整步;驱动器设 16 细分,一圈需要 200 × 16 = 3200 个脉冲;丝杠导程 5 mm,电机直连,那么走 1 mm 需要 3200 / 5 = 640 个脉冲。编码器侧:1000 线四倍频后 4000 计数一圈,走 1 mm 对应 4000 / 5 = 800 个计数。
/* 机械参数集中定义,改结构只改这里 */ #define STEP_ANGLE_DEG 1.8f #define STEP_FULL_PER_REV (360.0f / STEP_ANGLE_DEG) /* 200 */ #define DRIVER_MICROSTEP 16 /* 驱动器细分数 */ #define SCREW_LEAD_MM 5.0f /* 丝杠导程 mm */ #define GEAR_RATIO 1.0f /* 减速比,直连为 1 */ #define ENCODER_LINE 1000.0f /* 编码器线数 */ #define ENCODER_MULT 4.0f /* 四倍频 */ /* 1 mm 对应多少个脉冲 */ #define PULSE_PER_MM (STEP_FULL_PER_REV * DRIVER_MICROSTEP * GEAR_RATIO / SCREW_LEAD_MM) /* 1 mm 对应多少个编码器计数 */ #define COUNT_PER_MM (ENCODER_LINE * ENCODER_MULT * GEAR_RATIO / SCREW_LEAD_MM)按上面的参数,PULSE_PER_MM = 640,COUNT_PER_MM = 800。注意这两个数不相等,而且编码器分辨率比脉冲当量高,这是好事——高出来的那部分正是能检测到细分内误差的空间。如果反过来编码器分辨率低于脉冲当量,闭环就失去了意义,误差小于一个编码器计数时根本看不见。
一个容易忽略的细节:细分设多少合适。细分越高,运行越平滑、噪声越小,但同样速度下脉冲频率也越高。16 细分、5 mm 导程、要求 100 mm/s,脉冲频率就是 64 kHz,已经接近部分驱动器的上限,也吃满了定时器的调节余量。所以细分不是越高越好,一般 8 到 32 之间选,按最高速度反推脉冲频率不超过 100 kHz 来定。
4.2 PWM频率调节与加减速规划
脉冲输出用定时器的 PWM 模式,占空比固定 50%,改频率就是改 ARR。公式是f = 72MHz / ((PSC+1) × (ARR+1)),PSC 固定为 0,ARR 在 1 ms 控制周期里更新。
/* 设置脉冲频率,freq 单位 Hz,有效范围 200 ~ 100000 */ void Step_SetFreq(uint32_t freq) { if (freq < 200) freq = 200; /* 低于 200 Hz 电机容易共振 */ if (freq > 100000) freq = 100000; /* 上限受驱动器与定时器共同限制 */ uint32_t arr = (72000000UL / freq) - 1; __HAL_TIM_SET_AUTORELOAD(&htim1, arr); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, arr / 2); /* 50% 占空比 */ } /* 设置方向,换向前后留建立时间 */ void Step_SetDir(uint8_t dir) { if (dir != step.dir) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_RESET); /* 先停脉冲 */ for (volatile int i = 0; i < 200; i++); /* 约 5 μs */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, dir ? GPIO_PIN_SET : GPIO_PIN_RESET); step.dir = dir; } }Step_SetFreq里的两个限幅是实打实的经验值。下限 200 Hz 是因为步进电机在 100~200 Hz 附近有低速共振区,长期停在这个频率会听到明显的嗡嗡声甚至原地抖动;上限则取决于驱动器的光耦响应速度,多数脉冲加方向型驱动器要求脉冲宽度不小于 2.5 μs,换算下来就是 200 kHz 的理论上限,但留一半余量比较稳。
换向时的停机延时不能省。驱动器内部的方向信号要经过光耦,有几百纳秒到几微秒的延迟,如果方向刚翻转脉冲就到,会丢第一步或者走反一步。这种错一步的问题在单次定位里看不出来,往复运行几百次之后就变成明显的累积误差,排查起来非常费劲。
加减速规划解决的是「起步就丢步」这个最普遍的问题。步进电机的输出转矩随转速升高而下降,直接从 0 跳到 10 kHz,电机根本跟不上,只会原地嗡嗡响。工程上常用梯形加减速,实现简单、参数直观。
/* 梯形加减速:每 1 ms 调用一次,加速度单位 Hz/ms */ void Step_Ramp(void) { if (step.cur_freq < step.target_freq) { step.cur_freq += step.accel; if (step.cur_freq > step.target_freq) step.cur_freq = step.target_freq; } else if (step.cur_freq > step.target_freq) { step.cur_freq -= step.decel; if (step.cur_freq < step.target_freq) step.cur_freq = step.target_freq; } Step_SetFreq(step.cur_freq); }accel的取值靠实测:从 500 Hz 起,每次加 200 Hz/ms,观察电机是否丢步;丢步就往下调。空载和带载差别很大,所以参数一定要在最终装配状态下标定,实验室空转调好的参数搬上台架大概率不够用。要求更高时可以换 S 形曲线,加速度本身也做平滑过渡,冲击更小,代价是代码多一层状态判断,交付周期紧的话梯形足够。
注意:ARR 在运行中修改,如果恰好在计数器快计满时改小,当前周期会被截断,输出一个极窄的脉冲。稳妥做法是开启 ARR 预装载,让新值在下一个更新事件生效。
4.3 位置闭环的两种写法:调频跟踪与剩余脉冲补偿
闭环这块有两种主流写法,选错了会很别扭。
第一种是「调频跟踪」:位置 PID 的输出直接作为脉冲频率指令,误差大就跑得快,误差小就跑得慢,误差进容差带就停。这种写法代码最少,看起来最像教科书里的闭环,但用在步进电机上有个天然缺陷——步进电机的脉冲是离散的,频率降得很低时,每个脉冲走的位移反而变大了(相对误差),容易在目标点附近来回蹭,形成极限环。
第二种是「剩余脉冲补偿」:始终以编码器实际位置为基准,实时计算还差多少个脉冲才能到目标,然后按剩余量规划速度。发了多少脉冲不重要,重要的是还差多少。这种写法天然具备自愈能力,中途被人为推了一下、或者短暂丢了几步,下一个周期会自动把差额补回来。
/* 剩余脉冲补偿式位置闭环,1 ms 调用一次 */ void Axis_Task_1ms(void) { int32_t pos_now = axis.pos_now; /* 编码器累计计数 */ int32_t err_cnt = axis.target_cnt - pos_now; /* 剩余量,单位:编码器计数 */ /* 换算成剩余脉冲数 */ int32_t err_pulse = (int32_t)((float)err_cnt * PULSE_PER_MM / COUNT_PER_MM); /* 进入容差带:停机保持 */ if (labs(err_pulse) <= POS_TOL_PULSE) { step.target_freq = 0; axis.state = AX_HOLD; return; } /* 按剩余量规划速度:剩余多则快,剩余少则慢,形成自然减速 */ uint32_t freq = (uint32_t)(labs(err_pulse) * POS_KP); if (freq > axis.max_freq) freq = axis.max_freq; if (freq < FREQ_MIN) freq = FREQ_MIN; Step_SetDir(err_pulse > 0 ? 1 : 0); step.target_freq = freq; /* 交给 Step_Ramp 做斜坡限制 */ }这段代码里POS_KP是唯一的调节参数,物理含义是「每剩余一个脉冲,给多少赫兹的频率」,量纲是 Hz/脉冲。它的取值要满足两个约束:太小则响应慢、到位磨蹭;太大则接近目标时速度仍然很高,冲过头再往回拉,形成震荡。我一般的起调值是让最大剩余量对应到max_freq,然后往下砍一半留余量,实测再微调。
真正的位置 PID 用在需要严格轨迹跟踪的场合,比如多轴插补。单轴点位运动用上面这个比例控制加斜坡限制就够了,加积分反而容易在到位后积分饱和,退不出来。
4.4 失步判定与故障恢复
闭环最大的价值不是「走得更准」,是「知道什么时候不准了」。开环系统丢步了没人知道,闭环系统丢了能报出来,这是质变。
判定逻辑很朴素:连续一段时间内,剩余脉冲数一直不下降,说明脉冲发了但电机没动。设一个阈值,比如 200 ms 内误差没有减少 50 个脉冲当量,就判定失步。
/* 失步检测:误差长时间不收敛则报警 */ void Step_LostCheck(int32_t err_pulse) { static uint16_t stuck_ms = 0; if (labs(err_pulse) > LOST_ERR_THRESHOLD) { if (++stuck_ms > LOST_TIME_MS) { /* 超过 200 ms 未收敛 */ step.target_freq = 0; /* 立刻停脉冲 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); /* 关使能 */ axis.state = AX_FAULT; /* 进故障态等复位 */ axis.fault_code = ERR_LOST_STEP; stuck_ms = 0; } } else { stuck_ms = 0; /* 收敛了就清零 */ } }阈值和时间要一起调。阈值太小,正常加速过程中的瞬时误差也会触发误报;太大,真卡住了半天不报。经验值是阈值取 3 到 5 倍的容差带,时间取 200 ms,这个组合能滤掉启动瞬间的误差,又能在一秒内发现堵转。
故障后的恢复策略分两种。轻微失步(比如被人碰了一下)可以自动补偿,因为剩余脉冲算法本身就带自愈能力,不用特殊处理。严重失步(堵转、撞限位)必须停机,因为此时机械位置已经不可信,继续走可能撞坏东西。区分方法就是看误差是否在持续扩大。
5. 常见问题排查:五个最容易翻车的现场
这一章按现象归类,每条给出原因和解决路径。调这套系统时遇到的问题,九成落在这五类里,把这张表对着走一遍,能省掉大量盲猜时间。
5.1 只响不转,或者原地小幅抖动
现象:上电后电机发出嗡嗡声,轴不转,用手轻推能感觉到它在某个位置来回颤。
原因通常有三个。最常见是脉冲频率起始值设太高,直接给了 5 kHz,电机转矩跟不上,只能在原地建立不起旋转磁场。第二个是驱动器细分数和代码里的DRIVER_MICROSTEP对不上,实际走的距离和算的差好几倍,位置环拼命补偿反而把电机拖住。第三个是使能信号极性搞反了,代码以为开了实际是关的,或者反过来。
解决顺序:先把闭环关掉,用固定 500 Hz、固定脉冲数开环跑一次,确认电机能连续转动。这一步是分水岭,能转说明硬件和基本配置没问题,问题在闭环逻辑;不能转就先查接线和驱动器拨码。开环能转之后,把DRIVER_MICROSTEP改成实际拨码值,重新算一遍PULSE_PER_MM,再开闭环。
5.2 编码器计数只增不减,或者来回跳
现象:手动正反转电机,串口打印的累计计数一直往一个方向涨,或者数字乱跳。
只增不减,八成是 A、B 两相有一根线没接好或者接触不良,定时器只收到一路信号,编码器模式退化成单相计数,方向信息丢失。用示波器看两路波形,正常应该是两路都有、相位差 90 度。只有一路有波形,查那根线。
计数乱跳,是干扰问题。编码器线和电机线捆在一起走、屏蔽层没接、或者输入滤波设得太松。先把IC1Filter从 0 调到 6 试试,同时把两根线分开布线,屏蔽层单端接地。如果还跳,检查电源——电机驱动和 MCU 共用一路电源且没做隔离时,电机换向产生的浪涌会串到编码器供电上,加个磁珠或者单独供电能解决。
还有一种隐蔽情况:编码器和电机不同轴,联轴器有偏心,转起来编码器轴在晃,输出波形占空比周期性变化,边界处就会多计或少计。这种只能从机械上解决,换个弹性联轴器。
5.3 定位到位了,但总有固定的比例偏差
现象:目标是 100 mm,每次都停在 98.5 mm 左右,重复性很好,误差稳定。
重复性好说明系统是线性的、可控的,问题一定在换算系数上。按这个顺序查:先确认驱动器拨码的细分数和代码里的宏是不是一致,这是最高频的错误,很多人拨码看的是开关位置而不是说明书上的组合;再确认丝杠导程,标称 5 mm 的丝杠有 4 mm、10 mm 的规格,实物量一下最快;最后确认有没有减速比,同步带轮齿数比容易被漏掉。
另一个可能是脉冲丢失但没被检测到。如果每次都是少一点点,而且少的量随速度变化(跑得越快少得越多),那就是高速段丢脉冲,不是系数问题。这时候降低最高频率或者降低加速度再试,如果误差消失,说明是驱动器响应跟不上,需要在Step_SetFreq的上限上做限制。
还有一种情况值得单独提:脉冲宽度不够。驱动器光耦要求脉宽 ≥ 2.5 μs,如果高频时 ARR 太小,占空比 50% 下的高电平时间可能不足,驱动器识别不到。用示波器量一下最高频时的脉冲宽度,不够就把上限频率降下来。
5.4 位置环啸叫,停在目标点附近来回蹭
现象:到位后电机不停地在目标点前后小幅摆动,发出规律的咔哒声,串口看位置在 ±3 个计数之间来回。
这是典型的参数过大导致的极限环。剩余脉冲补偿算法里,POS_KP太大会让系统在接近目标时仍然给出较高的频率指令,冲过去之后再反向拉回来,形成等幅震荡。把POS_KP减半,同时把容差带POS_TOL_PULSE从 1 个脉冲放宽到 3 到 5 个脉冲,观察是否收敛。
容差带不能设成 0,这是个认知误区。步进电机加细分之后,最小位移就是脉冲当量,编码器分辨率再高也改变不了「电机只能整步或整细分移动」这个事实。要求误差小于一个脉冲当量在物理上做不到,只会让系统永远在补偿。容差带设成 2 到 3 个脉冲当量,是符合机械实际的。
如果放宽参数后依然震荡,检查是不是机械上有间隙。反向间隙大的丝杠,正转到位和反转到位的位置天然不一样,闭环会一直试图消除这个它消除不了的误差。这种情况要么换消隙丝杠,要么在软件里做单向逼近——永远从同一个方向接近目标点。
5.5 跑几分钟就卡死、复位或者进 HardFault
现象:短时间测试正常,连续运行几分钟后程序跑飞,串口无响应。
先查中断里的耗时操作。1 kHz 中断里如果有printf、浮点除法、或者HAL_Delay,累计起来必然出问题。用 GPIO 翻转加示波器量中断执行时间,正常应该控制在 20 μs 以内,超过 100 μs 就要优化。printf一律挪到主循环,浮点运算尽量预计算成常量。
再查栈溢出。位置环里如果定义了大的局部数组,或者串口中断和主循环同时操作同一个缓冲区且没做保护,都可能踩内存。把启动文件里的栈大小从默认的 0x400 加到 0x800 试一下,如果现象消失,就是栈的问题。
还有一个隐蔽原因是串口接收溢出。上位机发得快、MCU 处理慢,ORE标志置位后没清除,后续接收全部失效,表现就是「跑着跑着就不响应了」。在串口中断里显式清ORE标志,并且用环形缓冲区解耦收发,能根治这类问题。
6. 验收与答辩:用一张误差表和一段波形证明闭环真的闭上了
到了交付这一步,「能跑」和「能证明它跑得对」是两回事。评审或者甲方不会只看演示,他们要的是可复现的数据。我的做法是提前准备三样东西:一张误差测试表、一段示波器波形截图、一份失步注入的演示脚本。
误差表分四项测,每项测十次取统计值。
| 验收项 | 测试方法 | 合格判据 |
|---|---|---|
| 单向定位精度 | 目标 100 mm,同方向走 10 次,记录实际位置 | 最大偏差 ≤ ±0.05 mm |
| 双向重复定位 | 正反交替各 10 次回到同一点 | 极差 ≤ 0.1 mm |
| 阶跃响应 | 0 → 50 mm,串口记录位置曲线 | 超调 ≤ 5%,稳定时间 ≤ 500 ms |
| 失步检测 | 运行中人为堵转 1 s | 200 ms 内进故障态并停机 |
| 连续运行 | 往复运行 1 小时 | 无累积漂移,无复位 |
阶跃响应的数据获取有个小技巧:让 MCU 在每次 1 ms 控制周期里把目标位置和实际位置打包,攒够 500 组一次性发出来,上位机用串口助手存成 CSV,直接能画曲线。不要在中断里逐点打印,那样测出来的曲线是被串口拖慢的假数据。
波形截图要拍两个位置:一个是 TIM1 的脉冲输出,看占空比和频率是否随加减速变化;另一个是编码器 A、B 两相,看相位关系和边沿质量。答辩时把这两张图和误差表放一起,「发了多少」和「实际走了多少」的对应关系一目了然,比讲十分钟原理管用。
失步注入这个环节最能体现闭环的价值,一定要演示。做法很简单:运行中用手按住滑台不让它动,正常情况下 200 ms 内系统应该停下、关使能、串口报故障码。然后松开,发复位命令,系统重新回零、恢复定位。这一套动作做完,闭环的意义不需要任何解释。
答辩前还有个必做的检查:把所有可调参数列成一张表贴在代码注释里,包括POS_KP、accel、POS_TOL_PULSE、LOST_ERR_THRESHOLD的当前值和调整依据。被问到「这个参数怎么定的」时,能说出「实测从 X 调到 Y,因为在 Z 速度下出现了 A 现象」,这个问题就过了。
从那以后我每次做带反馈的项目,都会在写第一行控制代码之前先把验收表填好——不是走形式,是逼自己想清楚「什么叫做完了」。很多时候表填到一半就会发现某个指标根本没法测,那就是架构上缺了数据回传的通路,早发现比晚发现省一周。希望帮到你。
本文还有配套的精品资源,点击获取