☰
STM32编码器闭环步进电机控制:脉冲当量与丢步补偿
2026/10/26 13:42:59 网站建设 项目流程

简介:面向嵌入式电机控制初学者与课程设计、毕业设计开发者,这份资料以STM32F103ZET6为核心,演示定时器3输出PWM/互补PWM驱动步进电机、定时器4输入捕获读取增量编码器A/B相,并通过USART中断将位置与速度反馈上传电脑的完整链路。内容涉及步距角、脉冲频率与方向控制、输入捕获边沿检测、串口波特率与数据格式配置,可作为运动控制系统的入门框架,也便于在此基础上扩展加减速、闭环校正与上位机显示。压缩包共206个文件,约4.96MB,主体为37个c与38个h源文件,配合Keil工程文件、启动与分散加载配置,以及o、d、crf等编译中间文件、hex/axf可执行产物和map/lst映射清单,能同时看到代码组织与构建结果。当前已有7350人学习,适合按工程结构梳理外设初始化、中断服务与串口收发思路,快速搭建可调试的电机控制样例。

1. 从「空载能转」到「带载停准」:这套方案要解决什么

很多人的第一步是把步进电机转起来:定时器给脉冲,驱动器拨细分,电机转了,事情就算完。真装到设备上就翻车——空载怎么跑都准,挂上负载跑五千个脉冲,回来看实际位置差了 0.3 mm;把速度调高一档,误差又是另一个值。步进电机天生是开环执行器,脉冲发出去不等于轴到位,丢步、过冲、堵转它都不会自己报错。这套 STM32 编码器加步进电机的方案,要做的就是把这个黑匣子撬开:定时器负责出脉冲和加减速曲线,编码器定时器负责回读真实位置,主控在指令位置和实际位置之间做比对与补偿。适合正在做机电类毕设课设、需要一套既能演示又能答辩的完整闭环方案的人。

2. 系统总体设计与参数核算:三层结构与脉冲当量怎么定

2.1 三层结构:控制层、驱动层、反馈层的职责边界

这套方案在软件上切成三层,切法的唯一标准是「换哪一部分时,别的层不用动」。

控制层是决策层,只认物理单位和业务逻辑:目标位置是几毫米、速度是几转每分、走完后要不要校验、超差是补偿还是报警。它不知道定时器的 ARR 寄存器长什么样,也不关心编码器是 1000 线还是 2500 线,它拿到的只有结构体里的 target_pos 和 actual_pos。

驱动层是执行层,只认脉冲:往哪个方向发、发多少个、频率多高、加减速怎么排。它把控制层下发的毫米目标换算成脉冲数,然后按加减速表把脉冲一个一个吐出去,发完置一个 done 标志。驱动层完全不知道电机后面接的是丝杆还是转台,所以换机械结构时这一层零改动。

反馈层是感知层,只做一件事:把编码器的计数值换算成编码器位置并挂到全局状态里。它不做判断,不做补偿,谁要用谁去读。这样做的直接好处是调试时能单独把某一层拿出来看——脉冲发得对不对看驱动层日志,位置读得准不准看反馈层原始计数,中间的换算错误很容易被隔离出来。

层与层之间我用一个共享的状态结构体加两个标志位通信,不用回调、不用消息队列,裸机上这套足够,也不容易出竞态。状态结构体里放目标位置、当前位置、当前速度档位、运行状态机枚举,标志位只放「脉冲发完」和「编码器更新」,主循环判标志位干活,中断里只置位。

分层之后还有一个隐性的收益:答辩时老师问「你这套东西换个电机还能不能用」,答案是改驱动层的一张参数表就行,而不是从头讲一遍代码,这种回答方式本身就说明设计是想过的。

2.2 关键参数核算:细分、脉冲当量、编码器分辨率

参数算错是这类项目最常见的返工源头,而且错得很隐蔽——脉冲当量算错一倍,位置永远差两倍,你以为是自己补偿写错了,其实是除数写错了。所以这一节把几个数从头推一遍。

步进电机的步距角一般是 1.8°,整步一圈 200 步。驱动器上拨细分,常用 16 细分,那么电机转一圈需要的脉冲数是 200 × 16 = 3200 个。这里要注意:细分是驱动器做的,不是 STM32 做的,STM32 只管发脉冲,它并不知道细分设了多少,这个数字必须写进固件的参数表,改拨码就要改固件,两者对不上就是系统性偏差。

再看机械侧。假设电机直连一根导程 5 mm 的丝杆,电机转一圈,滑台走 5 mm,那么一个脉冲对应的位移就是 5 / 3200 = 0.0015625 mm,也就是 1.5625 μm,这个数叫脉冲当量,是所有位置换算的基准,通常写成宏定义放在头文件最上面。

编码器侧单独算。增量式编码器 1000 线,A/B 两相正交,STM32 的编码器接口做四倍频计数,一圈就是 1000 × 4 = 4000 个计数,一个计数对应 5 / 4000 = 0.00125 mm,也就是 1.25 μm。

把两个数放一起看就有意思了:编码器的一个计数,比一个脉冲当量还小一点点,大约是 1.25 个脉冲当量。这意味着反馈的分辨率高于执行的分辨率,闭环是有意义的,但补偿的最小粒度是一个脉冲,也就是 1.5625 μm,做不到比这更细。如果反过来,编码器分辨率低于脉冲当量,那闭环就只能做「有没有丢步」的粗判断,谈不上精确定位,这是选型阶段就要想清楚的。

参数取值说明
步距角1.8°整步 200 步/转
驱动器细分16拨码设定,必须与固件一致
每转脉冲数3200200 × 16
丝杆导程5 mm电机与丝杆直连,传动比 1:1
脉冲当量1.5625 μm5 mm / 3200
编码器线数1000 线A/B/Z 三相信号
编码器每转计数4000四倍频
编码器当量1.25 μm5 mm / 4000
最高转速300 rpm对应脉冲频率 16000 Hz
加减速度20000 脉冲/s²从 500 Hz 加速到 16 kHz 约 0.78 s

最高转速那一行值得单独说。300 rpm 换算成脉冲频率是 300 / 60 × 3200 = 16000 Hz,这个频率对 STM32 的定时器来说很轻松,但对驱动器的光耦输入有要求,多数细分驱动器标称能到 200 kHz,实际留一半余量比较稳。频率上限不是由单片机决定的,而是由驱动器和电机转矩共同决定的——转速一高,电机转矩下降,负载稍大就跟不上,脉冲发得再准也没用。

2.3 引脚与定时器资源分配

资源分配要一次规划到位,因为定时器的功能是互斥的:一个定时器做了编码器接口,就不能再拿来做 PWM,从模式配置会把通道吃掉。规划的原则是「功能唯一、通道不抢、调试留口」。

脉冲输出用高级定时器 TIM1 的通道 1,理由是它有互补输出和刹车输入,将来要加硬件急停可以直接用刹车引脚,不用改电路。方向 DIR 和使能 ENA 用普通 GPIO,因为它们的翻转频率是毫秒级甚至秒级,占用定时器是浪费。编码器 A/B 接 TIM3 的 CH1 和 CH2,这是编码器模式的固定通道组合,不能随便换。编码器 Z 信号接一个带外部中断的 GPIO,只在回零时用一次,不需要定时器资源。限位开关同理,走外部中断。

信号引脚/外设模式备注
PUL+TIM1_CH1PWM 输出定时器时钟预分频到 1 MHz
DIR+GPIO 推挽输出普通输出换向前提前建立
ENA+GPIO 推挽输出普通输出低有效,上电即使能
编码器 ATIM3_CH1编码器模式内部上拉,外部再加滤波
编码器 BTIM3_CH2编码器模式与 A 相同步
编码器 ZGPIO 外部中断上升沿触发回零用,做去抖
限位开关GPIO 外部中断下降沿触发硬件上拉,串联电阻
调试串口USART1115200 8N1打印位置和误差日志

这里有个容易被忽略的细节:TIM1 的时钟来源是 APB2,通常 72 MHz,如果直接用它算 ARR,低速时 ARR 会溢出 16 位。比如目标频率 500 Hz,ARR = 72 000 000 / 500 - 1 = 143999,远超 65535,寄存器装不下。解决办法是把预分频 PSC 设成 71,把定时器计数时钟降到 1 MHz,此时 ARR = 1 000 000 / 500 - 1 = 1999,16 位装得下,最高频 16 kHz 时 ARR = 61,也装得下。

代价是频率分辨率变粗:ARR 只能取整数,16 kHz 附近相邻两档是 1 000 000 / 62 = 16129 Hz 和 1 000 000 / 61 = 16393 Hz,跳一档就是 1.6% 的频率突变。低速段这个问题不明显,高速段就能听出电机声音发毛。所以参数表里我把最高频率压在 16 kHz 而不是贴着上限跑,就是为了留出这个分辨率余量。

3. 步进电机驱动实现:定时器 PWM 与加减速曲线怎么落地

3.1 PWM 出脉冲还是中断翻转 IO:两种方案的取舍

发脉冲有两条路,选错了后面全是坑,所以先把账算清楚。

第一条路是定时器 PWM 模式,硬件自动翻转引脚,CPU 只在需要改频率时动一次 ARR。优点是高频下 CPU 几乎零负担,16 kHz 甚至 100 kHz 都无压力,波形干净,占空比固定 50%。缺点有两个:一是改 ARR 的瞬间,当前周期会被拉长或缩短,严格来说相位不连续,不过对步进电机这种惯性负载影响很小;二是它不自动数脉冲,PWM 模式只管发不管数,脉冲个数得自己另外记。

第二条路是定时器更新中断里手动翻转 IO,每个脉冲进一次中断。优点是天然知道发了几个脉冲,计数和输出严格同步;缺点是中断频率等于脉冲频率,16 kHz 就是每秒一万六千次中断,主循环基本被切碎,再往上走就更吃力,超过 50 kHz 基本不用考虑。

我选的是第一种,PWM 出波形,脉冲计数放在定时器的更新中断里累加——反正改频率本来就要进更新中断,顺手加一,成本几乎为零。这样 CPU 占用在 16 kHz 下不到 5%,主循环还有余量去跑编码器读取和串口打印。

/* TIM1 CH1 输出脉冲,定时器计数时钟预分频到 1 MHz */ void pulse_init(void) { /* PSC = 71,72MHz / 72 = 1MHz,ARR 单位即 1 微秒 */ TIM1->PSC = 71; TIM1->ARR = 1999; /* 默认 500Hz 起始频率 */ TIM1->CCR1 = 1000; /* 占空比 50% */ TIM1->CCMR1 |= (6 << 4); /* PWM 模式 1 */ TIM1->CCER |= TIM_CCER_CC1E; /* 使能通道 1 输出 */ TIM1->DIER |= TIM_DIER_UIE; /* 使能更新中断,用于计脉冲 */ TIM1->CR1 |= TIM_CR1_ARPE; /* ARR 预装载,改频率不撕裂波形 */ TIM1->CR1 |= TIM_CR1_CEN; } /* 按目标频率设置脉冲频率,f 单位 Hz,范围 500 ~ 20000 */ void pulse_set_freq(uint32_t f) { uint32_t arr; if (f < 500) f = 500; if (f > 20000) f = 20000; arr = 1000000UL / f - 1; /* 1MHz 计数时钟 */ TIM1->ARR = arr; TIM1->CCR1 = (arr + 1) / 2; /* 保持 50% 占空比 */ } /* 更新中断里累加已发脉冲数 */ void TIM1_UP_IRQHandler(void) { if (TIM1->SR & TIM_SR_UIF) { TIM1->SR = ~TIM_SR_UIF; if (motor.state == MOTOR_RUNNING) { motor.pulse_sent++; /* 一个周期 = 一个脉冲 */ if (motor.pulse_sent >= motor.pulse_target) { motor.state = MOTOR_DONE; } } } }

三段代码对应三个动作:初始化、改频率、数脉冲。pulse_set_freq里的边界裁剪是必须的,因为加减速表在端点附近可能算出超范围的值,直接写进 ARR 会导致频率突变甚至输出停止。ARPE位的作用是让新的 ARR 在下一个更新事件才生效,避免当前周期被截断一半,这个位不加,高速段会看到明显的脉冲宽度不均。占空比固定 50% 是有讲究的:驱动器光耦对脉宽有最小要求,太窄的脉冲会被吃掉,50% 是最稳的选择,不需要为了省电去调。

3.2 梯形加减速的离散实现:先算脉冲数,再查表

步进电机不能瞬间从 0 跳到 16 kHz,那样十次有九次直接堵转失步。必须有一段加速、一段匀速、一段减速,这就是梯形曲线。参数只有三个:起始频率 f0、目标频率 fmax、加速度 a,单位是 Hz/s,物理意义是「每秒把脉冲频率提高多少赫兹」。

先算加速段需要多少个脉冲。用运动学公式,加速段脉冲数 n1 = (fmax² - f0²) / (2a)。代入 f0 = 500、fmax = 16000、a = 20000,得到 (256000000 - 250000) / 40000 ≈ 6394 个脉冲。换算成位移是 6394 × 1.5625 μm ≈ 10 mm,加速时间 (16000 - 500) / 20000 ≈ 0.78 s。这个组合意味着走 10 mm 就能加到满速,短行程基本全程在加减速,长行程才有匀速段。

减速段对称,脉冲数也是 6394。所以总脉冲数小于 2 × 6394 = 12788 时没有匀速段,曲线退化成三角形,这种情况下要按总脉冲数反算能达到的峰值频率,否则会在还没加速完的时候就开始减速。程序里用一个判断处理:如果总脉冲数小于加速段加减速段之和,就把 fmax 降到 sqrt(a × N + f0²) 再重算,保证曲线闭合。

实现方式我推荐查表法而不是每次中断现算。原因很实际:中断里做浮点开方运算,时间不可控,而且一旦算错了很难查。查表的做法是在启动前把每个脉冲对应的 ARR 值算好存进数组,中断里只做一次数组索引和寄存器赋值,确定性强,还能在调试器里直接看表对不对。

#define ACC_PULSES 6394 #define MAX_FREQ 16000 #define START_FREQ 500 #define ACC_RATE 20000.0f static uint16_t arr_table[ACC_PULSES]; /* 加速段 ARR 表 */ static uint32_t total_pulses; /* 本次运动总脉冲数 */ /* 运动开始前生成加减速表 */ void motion_plan(uint32_t pulses) { total_pulses = pulses; for (uint32_t i = 0; i < ACC_PULSES; i++) { /* 第 i 个脉冲时的瞬时频率:f = sqrt(f0^2 + 2*a*i) */ float f = sqrtf(START_FREQ * START_FREQ + 2.0f * ACC_RATE * i); if (f > MAX_FREQ) f = MAX_FREQ; arr_table[i] = (uint16_t)(1000000UL / (uint32_t)f - 1); } motor.pulse_sent = 0; motor.pulse_target = pulses; motor.state = MOTOR_ACCEL; pulse_set_freq(START_FREQ); } /* 更新中断里根据所处阶段取频率 */ void motion_tick(void) { uint32_t n = motor.pulse_sent; if (motor.state == MOTOR_ACCEL) { if (n < ACC_PULSES) { TIM1->ARR = arr_table[n]; /* 查表加速 */ TIM1->CCR1 = (TIM1->ARR + 1) / 2; } else { motor.state = MOTOR_RUN; } } else if (motor.state == MOTOR_RUN) { pulse_set_freq(MAX_FREQ); /* 匀速段恒定频率 */ if (motor.pulse_target - n <= ACC_PULSES) { motor.state = MOTOR_DECEL; } } else if (motor.state == MOTOR_DECEL) { uint32_t left = motor.pulse_target - n; /* 剩余脉冲即减速索引 */ if (left < ACC_PULSES) { TIM1->ARR = arr_table[left]; TIM1->CCR1 = (TIM1->ARR + 1) / 2; } } }

这里的关键设计是减速段直接复用加速段的表,但索引取「剩余脉冲数」,也就是倒着查。这样一张表管两段,省一半 RAM,逻辑也更简单:剩余脉冲越少,查到的索引越小,频率越低,自然就慢下来了。

表长 6394 项,每项 2 字节,占用大约 12.5 KB 的 Flash 或者 RAM,F103 这类芯片完全放得下。如果脉冲当量更小、加速段更长,表会变得很大,那时候可以每隔 8 个或 16 个脉冲存一项,中间用线性插值,精度损失可以接受,这也是我在资源紧张的板子上常用的折中。

3.3 方向、使能与限位信号的时序处理

脉冲本身只是一半,另外三个信号如果时序不对,照样翻车。

DIR 换向必须在脉冲停止之后、下一段脉冲开始之前完成,并且要有建立时间。多数驱动器要求方向信号在脉冲上升沿之前至少稳定 5 μs,实际我一般留 100 μs 以上,因为机械上还有反向间隙,快这一点没意义。程序里的做法是:换向时不立刻启动定时器,而是先写 DIR,延时一小段,再置状态位启动。

ENA 的上电顺序也要管。正确的顺序是先给 ENA 使能,等驱动器内部电荷泵稳定,通常 100 ms 到 200 ms,再开始发脉冲。一上电就发脉冲,驱动器可能还没就绪,前几十个脉冲直接丢失,表现就是「第一次运动总是差一点,后面就正常了」,这种偶发问题最难查。

限位信号的优先级最高,走硬件外部中断,中断里做三件事:立刻关定时器输出、清空剩余脉冲、把状态机置成故障。注意不能只置标志位等主循环处理,主循环可能正在跑一段耗时逻辑,等它回来已经撞上了。关输出的写法是清 TIM1 的 CCER 使能位,而不是只停计数器,因为停计数器后引脚电平会停在当前状态,可能一直是高电平,驱动器会认为你在持续发脉冲。

/* 限位中断:立即切断脉冲输出 */ void EXTI_LIMIT_IRQHandler(void) { if (EXTI->PR & LIMIT_PIN) { EXTI->PR = LIMIT_PIN; TIM1->CCER &= ~TIM_CCER_CC1E; /* 关脉冲输出,引脚回默认电平 */ TIM1->CR1 &= ~TIM_CR1_CEN; /* 停计数器 */ motor.pulse_target = motor.pulse_sent; /* 剩余脉冲清零 */ motor.state = MOTOR_FAULT; /* 进故障态,主循环报警 */ } }

顺带提一句回零逻辑。用 Z 信号回零比用限位开关回零精度高得多,因为限位开关的重复精度通常在 0.05 mm 量级,而 Z 信号是编码器每转一次的固定位置,重复精度取决于编码器本身。做法是先朝着限位方向慢速走,碰到限位后反向退一点,再慢速找 Z 信号的第一个上升沿,找到就清零编码器计数。这个过程速度要压到 100 rpm 以下,快了容易冲过 Z 点。

4. 编码器采集与闭环校正:位置回读与丢步判定怎么做

4.1 定时器编码器模式的配置与 16 位溢出处理

STM32 的定时器有个专门的编码器接口模式,把 CH1 和 CH2 配成输入捕获,硬件自动根据 A/B 两相的相位关系加减计数,CPU 一个周期都不用管,这是它比自己写外部中断计数强的地方——中断方式在高速下会丢计数,硬件计数不会。

配置上有几个点要注意。一是滤波器,编码器信号经过长线传输难免有毛刺,定时器的输入滤波器(ICF 位)可以设成采样 8 次取一致,能滤掉大部分窄脉冲干扰。二是极性,如果发现计数方向跟电机转向相反,不用改接线,把其中一相的极性位翻转就行。三是计数范围,TIM3 是 16 位,0 到 65535,超过就回绕,一次长行程走下来溢出好几次很正常。

16 位溢出必须做软件扩展成 32 位,否则位置会在 65 mm 附近突然跳变,这个 bug 我第一次做的时候被坑了整整两天,因为空载短距离测试根本复现不了。

/* 读编码器计数值,扩展到 32 位,处理 16 位回绕 */ int32_t encoder_read(void) { static uint16_t last_cnt = 0; static int32_t overflow = 0; uint16_t now = TIM3->CNT; int16_t diff = (int16_t)(now - last_cnt); /* 有符号差值自动处理回绕 */ /* 差值超过半圈认为是真实位移,累加到 32 位 */ overflow += diff; last_cnt = now; return overflow; /* 单位:编码器计数,4000 计数 = 1 转 */ }

这段的核心是那个强制类型转换。(int16_t)(now - last_cnt)把无符号减法结果解释成有符号数,如果计数从 65530 涨到 3,普通减法得到 -65527,转成 int16_t 后变成 9,正好是实际增量。这个技巧在所有涉及环形计数器的场景都通用,值得记下来。

要注意这个方法有前提:两次读取之间的位移不能超过半圈(2000 计数)。主循环读得够勤就没问题,如果某段代码可能阻塞几百毫秒,最好把它放到一个周期性的定时器任务里读,别放在主循环随手读。

4.2 从计数值到物理位置的换算与零点建立

编码器计数本身没有物理意义,必须和脉冲位置对齐。

换算分两步。先算编码器位置:enc_mm = encoder_read() × 0.00125,单位毫米。再算指令位置:cmd_mm = pulse_sent × 0.0015625。两个数一减就是误差。

零点建立有两种。绝对参考方案是每次上电都执行一遍回零流程,找到 Z 信号后把 encoder 计数清零,同时把 pulse_sent 也清零,两个坐标系原点重合。相对参考方案是上电时把当前位置当作零点,不做回零动作,靠掉电前的记忆恢复——这个方案要求有 EEPROM 或者后备电池,而且一旦中途有人手动转动了电机,坐标系就废了。

我一般用回零方案,虽然上电慢几秒,但状态是确定的,答辩演示也不会出现「上次跑完没回零」的尴尬。

零点对齐之后还有一个残余问题:编码器计数和脉冲计数是两个独立的计数器,它们的累加起点不可能完全同时,中间差几个计数很正常。所以第一次运动之前要做一个标定:走一段固定距离,读回编码器增量,和理论值比,如果差得在阈值内就把这个固定偏差记下来当零点偏置,后面所有误差计算都减掉它。这一步不做,后面所有的误差曲线都会整体平移一个固定值,看起来像系统性偏差,其实是零点没对齐。

4.3 丢步判定与补偿策略:能补的补,不能补的报

闭环校正这件事,最怕的思路是「实时调频率」。步进电机不是伺服,它没有连续的转矩响应,你实时改脉冲频率只会让它更容易失步。正确的做法是位置级补偿:一段运动走完,停稳,比对,然后决定补几个脉冲或者报警。

判定的时机很重要。脉冲发完不代表轴到位,机械还有余振,编码器读数在停下来的头几十毫秒内会小幅跳动。所以要等 100 ms 的稳定延时再读编码器,读三次取中值,这样能过滤掉机械振荡带来的假误差。

误差算出来之后分三档处理。误差在 ±3 个编码器计数以内(约 ±3.75 μm),判定为合格,不动作,这是机械间隙和弹性变形的正常范围。误差在 3 到 200 个计数之间,判定为丢步,直接补发对应数量的脉冲,补发时用低速,避免补的过程再丢。误差超过 200 个计数,判定为严重故障,停机报警,因为这说明可能是堵转或者驱动器保护了,盲目补脉冲只会让情况更糟。

补偿还要加次数上限,同一次定位最多补两轮。不加这个限制,遇到机械卡死的情况,程序会陷入「补脉冲、读误差、再补、再错」的死循环,电机原地抖动,电源都可能被拉垮。

#define ERR_TOLERANCE 3 /* ±3 计数内认为合格 */ #define ERR_MAX_COMP 200 /* 超过则判定严重故障 */ #define COMP_MAX_TIMES 2 /* 最多补偿两轮 */ /* 定位完成后调用:比对指令位置与编码器位置 */ uint8_t position_check(void) { int32_t cmd_cnt = (int32_t)((int64_t)motor.pulse_sent * 4000 / 3200); int32_t err = cmd_cnt - encoder_read(); /* 正数表示走得不够 */ if (err >= -ERR_TOLERANCE && err <= ERR_TOLERANCE) { return RESULT_OK; /* 合格 */ } if (err > ERR_MAX_COMP || err < -ERR_MAX_COMP) { return RESULT_FAULT; /* 严重偏差,报警 */ } if (motor.comp_times >= COMP_MAX_TIMES) { return RESULT_FAULT; /* 补太多次仍不准,放弃 */ } /* 小偏差:按误差补发脉冲,低速补偿 */ motor.comp_times++; motor.comp_dir = (err > 0) ? DIR_FORWARD : DIR_BACKWARD; motion_plan((uint32_t)(err > 0 ? err : -err) * 3200 / 4000); return RESULT_COMPENSATING; }

代码里那个pulse_sent * 4000 / 3200是脉冲数换算成编码器计数,乘除顺序要注意:先乘后除,中间结果用 int64_t 防止长行程溢出。如果写成先除后乘,整数除法会把余数丢掉,走几千个脉冲之后累积误差就很可观了,这也是个典型的坑。

补偿方向的处理也要小心。err 是正数说明实际走得比指令少,需要往正方向补;是负数就往反方向补。但反方向补的时候,机械反向间隙会吃掉一部分补偿量,所以反向补偿的判定阈值我一般设得比正向宽一档,避免来回找不准。

5. 避坑与常见问题排查:五个最容易翻车的点

5.1 机械与电气侧的两个坑

现象:空载怎么测都准,装上负载之后位置总是差一点,而且正反方向走同一个目标点,停下来的位置不一样,差值固定。

原因:这是典型的反向间隙。联轴器和丝杆之间、丝杆和螺母之间都有间隙,换向的时候要先「空走」一段间隙才开始真正传动。这不是程序问题,是机械问题,光靠改代码治不了根。

解决:先测出间隙量。做法是朝一个方向走 10 mm 记下编码器读数,再朝反方向走 10 mm 记下读数,两次的差就是间隙。这个值如果小于 0.05 mm,用软件补偿,换向前先把间隙量补进去;如果大于 0.1 mm,说明是联轴器松了或者丝杆磨损,该紧的紧、该换的换,软件补偿只是掩盖问题。同时把验收阈值放宽到间隙量以上,否则每次测试都不合格,但其实是机械的正常特性。

现象:电机不转的时候编码器读数也在小幅跳动,位置数值在 3 到 5 个计数之间来回晃。

原因:编码器信号受干扰。步进电机的驱动线是大电流高频开关,和编码器信号线走在一起,很容易耦合进来;另外增量编码器是集电极开路或者差分输出,如果接成单端并且没加上拉,信号边沿会变得很缓,定时器的输入滤波器识别不稳。

解决:编码器线必须用双绞屏蔽线,屏蔽层单端接地;信号线远离电机动力线,至少隔开 10 cm,实在走不开就垂直交叉,不要平行走;编码器电源单独用一路 LDO,不要和电机驱动共用;A/B 相各加一个 1 kΩ 上拉和 100 pF 对地电容做 RC 滤波,硬件先滤一遍,再让定时器的数字滤波器处理剩下的。处理完之后静止时的抖动应该降到 0 到 1 个计数。

5.2 固件与时序侧的三个坑

现象:低速运行正常,速度一提高电机声音就发毛,偶尔失步,把速度降回来又正常。

原因:频率分辨率不够。前面算过,1 MHz 计数时钟下,16 kHz 附近相邻两档 ARR 差 1.6%,改频率的时候是一个台阶一个台阶跳的,加上 ARR 改在更新事件前的那个瞬间,周期长度会有一个短促的突变,电机听起来就是「咔」一下。

解决:一是把最高频率降下来,别贴着分辨率极限跑;二是在高速段改用更细的时钟,比如 PSC 设成 17,计数时钟 4 MHz,ARR 精度提高四倍,代价是低速段 ARR 变大但仍在 16 位范围内;三是加速表在高速段做插值,不要每脉冲都改 ARR,改成每 4 个脉冲改一次,减少跳变次数。我一般用第二种,把时钟和量程匹配好,比事后打补丁干净。

现象:短行程测试全对,走超过 65 mm 之后位置数值突然跳到 0 附近,然后误差判定全部失败。

原因:编码器 16 位计数器回绕,没有做 32 位扩展。65 mm 对应 65536 个编码器计数左右,正好是 16 位的量程。

解决:所有读编码器的地方统一走encoder_read()这个封装函数,禁止直接读 TIM3->CNT,这样溢出处理只在一个地方做,不会漏。另外注意这个封装必须保证调用间隔内的位移小于半圈,主循环里如果有阻塞超过 200 ms 的操作,把这个读取放到定时器任务里跑。

现象:上电第一次运动总差一点,后面就正常了,复位之后又重复。

原因:使能时序。驱动器上电到内部电荷泵稳定需要时间,这段时间里发出的脉冲响应不正常,而且光耦本身也有上升时间。

解决:初始化函数里加上电顺序控制,先拉低 ENA 使能,延时 200 ms,再配置定时器,最后才允许接收运动指令。延时不要用 for 循环空跑,用 SysTick 或者 HAL_Delay,否则优化等级一改延时就变了,这是很隐蔽的坑。

现象:一开电机,串口就乱码,严重的时候单片机直接复位。

原因:地线干扰和电源跌落。电机驱动的大电流回路和控制回路共地,开关瞬间在地线上产生压差,串口电平被判错;同时电机启停时电源被拉低,单片机欠压复位。

解决:电机电源和控制电源分开,各自一路,只在地线上单点连接;驱动器信号全部走光耦隔离,PUL、DIR、ENA 三路都要隔离,只隔一路没用;控制板电源入口加 470 μF 电解电容和 0.1 μF 瓷片电容,储能和滤高频各司其职;串口线用地线包起来。这套改完之后如果还复位,就要检查是不是驱动器细分设置过高导致电流过大,电源功率不够了。

6. 验收与答辩:怎么证明这套闭环真的不丢步

做到这一步,代码能跑、电机能转、编码器能读数,但这三件事加起来不等于方案成立。验收要回答的只有一个问题:它凭什么说自己准。我一般会准备三组测试,每组都有可复现的操作步骤和判据。

测试项操作方法判据记录内容
单向定位精度从零点出发,依次走到 20/40/60 mm,每点停 100 ms 读编码器误差 ≤ ±0.02 mm每点五次的误差值
重复定位精度同一目标点往返十次,每次回零再出发极差 ≤ 0.01 mm十次的实测值
极限速度测试从 100 rpm 逐级提到 300 rpm,每级跑满行程全程无丢步报警失步临界转速

第一组测的是「走得准不准」,第二组测的是「稳不稳」,第三组测的是「有没有余量」。三组数据摆出来,方案的边界就清楚了,答辩时老师问「你这套能到多少精度」,答案不是一个模糊的「挺准」,而是 ±0.02 mm 的定位和 0.01 mm 的重复定位,速度余量到 300 rpm。有了这个数,后面所有讨论都有落脚点。

测试的时候有个细节必须做:串口日志全程打印。每一行打时间戳、指令脉冲数、编码器计数、换算位置、误差值,出问题的时候回看日志,能直接看出是哪一段开始偏的。我见过太多人测试只记最终结果,误差大了完全不知道是从第几个脉冲开始丢的,只能重新跑一遍盲猜。

答辩演示有个技巧可以加分:正常走一遍流程之后,故意用手挡一下联轴器制造一次丢步,让系统当场检测出来并报警。这个动作说明的不只是「我的代码能跑」,而是「我的系统知道自己什么时候不可信」。开环方案做不出这个效果,这就是闭环的价值所在。演示前记得把补偿次数上限调成 1,否则系统会自己补回去,看不到报警。

还有一个常被忽略的答辩点:把脉冲当量的推导过程写在 PPT 上。3200 脉冲每转、5 mm 导程、1.5625 μm 每脉冲,这三个数的关系是整个方案的地基,讲清楚这个,后面所有的误差分析和补偿逻辑就顺理成章;讲不清楚,看起来就像在调参数碰运气。

从那以后我每次做这类机电项目,都会在写第一行驱动代码之前,先把参数表和误差判据写在纸上贴到显示器边上,走完一次全行程再开始写闭环逻辑。顺序反了,后面补的每一行代码都是在给前面的错误打补丁。希望帮到你。

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

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

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

立即咨询