STM32编码器测距与位置速度双环PID停准实战
简介:面向嵌入式电机控制学习者与STM32入门开发者,这份工程示例围绕“控制行驶指定距离”展开,用编码器反馈配合PID闭环调节电机速度与位置,帮助理解从目标距离、位置偏差到PWM输出的完整控制链路,适合具备C语言与HAL库基础、希望动手搭建闭环控制的人参考。压缩包共236个文件,以121个.h头文件和107个.c源文件为主体,包含STM32F4 HAL驱动、定时器与编码器接口以及PID控制代码,另有Keil工程配置、启动文件、hex烧录文件和bat清理脚本,整体约1.65MB。已有1023人学习下载。读者可据此梳理编码器脉冲计数、位置偏差计算、Kp/Ki/Kd参数调试与PWM占空比输出的实现思路,也能借助现成工程验证阶跃响应、排查超调与静差问题,快速形成可复用的距离闭环控制方案。
1. 先看一个停不准的实际问题
地面是瓷砖,小车跑直线,代码里写HAL_Delay(2000); Motor_Stop();,量出来第一次 1050mm,第二次 930mm,第三次直接冲出白线。换块新电池又变了,加个配重又变了。开环控制只靠延时,本质上依赖电压、地面摩擦和轮子磨损,一旦这些条件变化就失效。
所以这个项目的目标很明确:用 stm32 控制行驶指定距离,给一个目标值(比如 1000mm),小车自己走完并在 ±10mm 内停住。做法是编码器测距 + pid 闭环,外面套一层位置环,里面套一层速度环。适合已经把点灯、串口、PWM 跑通,想第一次把闭环控制真正落地的人;也适合做竞赛小车、分拣底盘、巡检小车这类需要“走一段停一下”的场景。
2. 工程骨架:定时器怎么分、目录怎么切
2.1 硬件清单与定时器资源分配
这套东西对主频不敏感,对定时器数量敏感。我用的配置是 STM32F103C8T6 最小系统板,72MHz,四个通用定时器刚好够分。电机是带霍尔编码器的直流减速电机,减速比 1:30,编码器 11 线;驱动用 TB6612FNG,双路 PWM 加两个方向脚。
| 部件 | 规格 | 作用 |
|---|---|---|
| 主控 | STM32F103C8T6,72MHz | 跑控制循环、串口通信 |
| 电机驱动 | TB6612FNG | 双路 PWM 调速 + 正反转 |
| 电机 | 直流减速电机 1:30,编码器 11 线 | 执行 + 反馈 |
| 编码器 | AB 两相霍尔 | 正交解码计脉冲 |
| 轮子 | 直径 65mm 橡胶轮 | 距离换算基准 |
| 电源 | 两节 18650 串联 7.4V | 动力 + 逻辑供电 |
定时器分配是这套方案的关键,分配不当后面全是麻烦:
| 定时器 | 工作模式 | 用途 |
|---|---|---|
| TIM1 | PWM 输出,CH1 / CH4 | 左右轮调速,20kHz |
| TIM2 | 编码器模式 TI12 | 左轮计数 |
| TIM3 | 编码器模式 TI12 | 右轮计数 |
| TIM4 | 基本定时,10ms 中断 | 控制节拍 |
为什么 PWM 选 20kHz:人耳听不见,电机不会发出啸叫,同时 72MHz / 20kHz = 3600 个计数分辨率,调速够细。为什么控制节拍选 10ms:再长,减速段来不及收;再短,低速时一个周期只数到几个脉冲,速度值抖动得厉害,微分项直接炸。10ms 是这套减速比下一个比较稳的折中。
2.2 工程目录与模块职责边界
CubeMX 生成的工程只有Core和Drivers,业务代码全塞main.c里,两周后自己都看不懂。我的习惯是在工程根目录下再挂一个App,按“硬件抽象 → 控制算法 → 任务调度”三层切开:
Project/ ├── Core/ # CubeMX 生成,只放初始化和中断入口 │ ├── Src/main.c │ ├── Src/stm32f1xx_it.c │ └── Src/stm32f1xx_hal_msp.c ├── Drivers/ # HAL 库与 CMSIS,不动 └── App/ ├── Inc/ │ ├── encoder.h # 编码器读数、里程累计 │ ├── motor.h # PWM 与方向控制 │ ├── pid.h # 通用 PID 结构体 │ └── chassis.h # 底盘:位置环、状态机 └── Src/ ├── encoder.c ├── motor.c ├── pid.c └── chassis.c边界划清楚以后有个直接好处:encoder.c里不出现任何 PWM 相关代码,motor.c里不出现脉冲换算,chassis.c只调用这两层的接口。以后换电机、换轮径、换减速比,改的永远是encoder.h里的几个宏,控制逻辑一行不动。
2.3 上电初始化顺序与一个容易出问题的地方
初始化顺序有讲究,顺序错了上电瞬间小车会自己窜一下。常见做法是先确保电机输出为 0,再配置 PWM,最后才启动编码器和节拍中断:
void App_Init(void) { /* 1. 先把方向脚和比较寄存器清零,保证上电不窜车 */ Motor_StopAll(); /* 2. 编码器定时器:只做计数,不开任何中断 */ HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); /* 3. PWM 输出,此时占空比已经是 0 */ HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_4); /* 4. 最后开 10ms 控制节拍 */ HAL_TIM_Base_Start_IT(&htim4); /* 5. 清掉上电时寄存器里的随机计数 */ Encoder_Reset(); }Motor_StopAll()必须在HAL_TIM_PWM_Start之前调用。因为 PWM 启动的一瞬间,比较寄存器CCR里可能还是复位后的随机值,如果占空比正好不为 0,轮子会猛地转一下。这个现象很短,一两百毫秒,但每次上电都来一下,编码器累计里程就被污染了,第一次跑距离必然不准。
Encoder_Reset()放在最后,是因为启动编码器定时器时会有一个计数建立的瞬间,先启动再清零,才能保证后续读到的第一个增量是从 0 开始的。
第 5 步之后才开始接受目标距离指令,中间不要插HAL_Delay。以前为了“等一下电机稳定”加过 500ms 延时,结果这 500ms 里 TIM4 中断已经在跑了,控制循环读到的距离是 0、速度是 0,位置环以为车没动,一路把目标速度顶到上限,等延时结束时 PWM 已经是满占空比,车直接冲出去。这个坑记了很久。
3. 编码器测距:把轮子转过的圈数变成毫米
3.1 正交解码:把 A/B 相变成有符号计数
编码器输出 A、B 两路方波,相位差 90 度。STM32 的定时器有硬件编码器接口,配置好以后 A、B 相的每个边沿都会让计数器自动加一或减一,全程不占 CPU。关键是把EncoderMode设成TIM_ENCODERMODE_TI12,也就是 A、B 双相都参与计数,这就是常说的四倍频。
static void Encoder_TIM_Init(TIM_HandleTypeDef *htim, TIM_TypeDef *inst) { TIM_Encoder_InitTypeDef sConfig = {0}; htim->Instance = inst; htim->Init.Prescaler = 0; /* 不分频,每个边沿都要数 */ htim->Init.CounterMode = TIM_COUNTERMODE_UP; htim->Init.Period = 65535; /* 16 位满量程,别改小 */ htim->Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim->Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode = TIM_ENCODERMODE_TI12; /* 四倍频 */ 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(htim, &sConfig); }Prescaler必须是 0。设成其他值会漏掉边沿,四倍频变成两倍频甚至一倍频,换算出来的每毫米脉冲数全错,而且错得很隐蔽——跑短距离看不出来,跑两三米就明显偏短。
IC1Filter和IC2Filter建议设 6 到 10。这个滤波不是软件数字滤波,是定时器输入端的采样窗口,能滤掉电机换向时电刷打火耦合进来的尖峰。设成 0 时,偶尔会看到某个采样周期脉冲数突然多出一大截,位置环一看“走了这么多”,立刻减速,车就一顿一顿的。
Period保持 65535。有人为了“防止溢出”把它改成 1000,然后软件里手动处理重装载,纯属给自己找麻烦。16 位免费给你 65536 的计数范围,10ms 采样周期下根本用不完,没必要动。
3.2 从脉冲到毫米:一圈到底多少脉冲
换算链路是:编码器线数 × 倍频 × 减速比 = 输出轴一圈的计数。11 线 × 4 倍频 = 44 计数/电机轴圈,再乘减速比 30,等于 1320 计数/输出轴圈。轮径 65mm,周长 204.2mm,除下来每计数约 0.1547mm。
/* encoder.h:所有换算系数集中在这里,换底盘只改这一段 */ #define ENCODER_PPR 11.0f /* 编码器线数 */ #define ENCODER_MULT 4.0f /* 四倍频 */ #define GEAR_RATIO 30.0f /* 减速比 */ #define WHEEL_DIAMETER_MM 65.0f /* 轮径,实测值 */ /* 每脉冲对应的毫米数,约 0.1547 mm/pulse */ #define MM_PER_PULSE ((3.14159265f * WHEEL_DIAMETER_MM) / \ (ENCODER_PPR * ENCODER_MULT * GEAR_RATIO))轮径一定要实测。买的 65mm 橡胶轮,卡尺量出来经常是 64.2 到 65.8 之间,加上轮胎受压变形,有效轮径比标称值小。用标称值算,跑 1 米能差出十几毫米,正好卡在容差边界上。
读计数时要处理有符号增量,这是最容易写错的一行:
static int16_t s_last[2]; /* 上次读到的 16 位计数值 */ static int32_t s_total[2]; /* 累计脉冲,带符号 */ int32_t Encoder_ReadDelta(uint8_t id) { TIM_TypeDef *tim = (id == 0) ? TIM2 : TIM3; /* 计数器是 16 位无符号的,强转成 int16_t 才能拿到正确增量 */ int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(tim); int16_t delta = (int16_t)(now - s_last[id]); s_last[id] = now; s_total[id] += delta; /* 累计值用 32 位,不会溢出 */ return delta; }now - s_last[id]这一步,如果两个变量都是int16_t,减法结果在 C 里会自动提升成int,然后强转回int16_t,跨过 65535→0 的回绕点时结果仍然正确。前提是两次采样之间增量不超过 32767。10ms 内就算电机跑到 1000rpm,输出轴也是 1320 × 1000 / 60 × 0.01 ≈ 220 个计数,离上限远得很,安全。
如果偷懒直接读__HAL_TIM_GET_COUNTER当里程用,反转时数值会从 0 跳到 65535,位置环一看“已经走了 6 万多毫米”,立刻判定到达目标并停车。这个 bug 只在倒车时出现,正着跑一切正常,排查起来相当费时间。
3.3 固定周期采样与速度量纲统一
控制循环放在 TIM4 的 10ms 中断里,采样、换算、累计一次做完。速度单位用 mm/s,和距离单位保持一致,后面调 PID 参数时不用在脑子里做单位换算。
#define CTRL_PERIOD_S 0.01f /* chassis.c:每 10ms 由 TIM4 更新中断调用 */ void Chassis_Update(void) { int32_t dl = Encoder_ReadDelta(0); int32_t dr = Encoder_ReadDelta(1); g_chassis.dist_l_mm += dl * MM_PER_PULSE; g_chassis.dist_r_mm += dr * MM_PER_PULSE; /* 车体中心里程取两轮平均 */ g_chassis.dist_mm = (g_chassis.dist_l_mm + g_chassis.dist_r_mm) * 0.5f; /* 速度统一用 mm/s */ g_chassis.v_l = dl * MM_PER_PULSE / CTRL_PERIOD_S; g_chassis.v_r = dr * MM_PER_PULSE / CTRL_PERIOD_S; g_chassis.v = (g_chassis.v_l + g_chassis.v_r) * 0.5f; }dist_mm取两轮平均而不是取某一轮,是为了让直线判定更稳。如果左右轮因为打滑或者电机特性差异走得不一样,取平均相当于把误差摊开,最终停的位置是车体中心位置,比单轮更接近实际期望。
低速时速度值会抖,因为 10ms 内可能只数到 3 个脉冲,量化误差就是 33%。常见做法是对速度做一阶低通:
/* alpha 越小越平滑,但延迟越大,0.3 左右是个可用的起点 */ g_chassis.v_filt = 0.7f * g_chassis.v_filt + 0.3f * g_chassis.v; /* 直接输出给速度环用滤波后的值 */ actual = g_chassis.v_filt;注意低通会引入相位滞后,速度环的 Kp 不能给太大,否则平滑是平滑了,超调也跟着来了。这个取舍在最后一章还会提到。
4. PID 闭环:位置环套速度环的两级串联
4.1 串级结构:位置环在外面,速度环在里面
单环控制在这里不好用,原因是两件事要同时管:走多远,和走多快。只用位置环直接输出 PWM,起步会冲、末端会撞停,参数怎么调都是“要么慢得离谱,要么冲过头”。所以拆成两级:
外环是位置环,输入是剩余距离,输出是目标速度;内环是速度环,输入是目标速度和实际速度,输出是 PWM 占空比。外环用 P 就够了,加 I 容易积分饱和,加 D 会因为距离信号本身是单调增的而没什么意义。
/* 外层:位置环,纯 P,输出目标速度 mm/s */ static float Position_Loop(float remain_mm) { float v_cmd = KP_POS * remain_mm; /* 减速段限制:v = sqrt(2 * a * s),临近终点自动收油 */ float v_limit = sqrtf(2.0f * DECEL_ACC * remain_mm); if (v_cmd > v_limit) v_cmd = v_limit; if (v_cmd > V_MAX) v_cmd = V_MAX; if (v_cmd < 0.0f) v_cmd = 0.0f; return v_cmd; }KP_POS的物理含义是“每剩 1mm,希望速度是多少 mm/s”。取 3.0 表示剩 100mm 时目标速度 300mm/s。这个值不能太大,太大等于起步就顶到V_MAX,位置环退化成开关控制。
DECEL_ACC是减速度,单位 mm/s²。sqrtf(2*a*s)是匀减速运动的经典关系,保证在任何剩余距离下,以当前速度都能在剩余距离内减到 0。这一行是防冲过头的核心,比调 PID 参数有用得多。
4.2 增量式速度环的实现
速度环用增量式 PID,好处是不需要处理积分项的历史累积,输出直接是“这次比上次多给多少 PWM”,切换模式或者急停时不会因为积分残留而继续输出。
typedef struct { float kp, ki, kd; float err[3]; /* err[0] 本次, err[1] 上次, err[2] 上上次 */ float out; /* 当前输出,增量累加得到 */ float out_max; float out_min; } IncPID; float PID_IncUpdate(IncPID *p, float target, float actual) { p->err[2] = p->err[1]; p->err[1] = p->err[0]; p->err[0] = target - actual; /* 增量式:Δu = Kp*Δe + Ki*e + Kd*(e - 2e1 + e2) */ float du = p->kp * (p->err[0] - p->err[1]) + p->ki * p->err[0] + p->kd * (p->err[0] - 2.0f * p->err[1] + p->err[2]); p->out += du; /* 输出限幅,同时兼做抗积分饱和 */ if (p->out > p->out_max) p->out = p->out_max; if (p->out < p->out_min) p->out = p->out_min; return p->out; }三个参数的直观理解:kp管“反应有多快”,ki管“稳态误差消不消得掉”,kd管“变化趋势预判”。增量式里ki乘的是当前误差而不是误差累积,所以ki的量纲要和kp拉开一两个数量级。
out的限幅在这里同时承担了抗饱和的职责。因为输出是累加出来的,一旦顶到上限就不再往上加,误差继续存在也不会让内部状态越积越大。
死区补偿单独拿出来做,不要混进 PID 里:
static int16_t PWM_WithDeadzone(float u) { const float dead = 380.0f; /* 实测:低于这个值电机不转 */ if (u > 0.5f) return (int16_t)(u + dead); if (u < -0.5f) return (int16_t)(u - dead); return 0; }dead这个值必须实测。把占空比从 0 慢慢往上加,记下轮子刚开始转的那个数,就是死区。TB6612 配这套电机,380 左右是个典型值。不做补偿的话,低速段 PID 输出在死区里来回晃,车要么不动,要么突然一窜,调参调到怀疑人生。
4.3 状态机与停止判据
整个行驶过程用一个状态机管,比一堆 if 嵌套清楚得多:
typedef enum { CHS_IDLE = 0, CHS_RUN, CHS_DONE, CHS_FAULT } ChassisState; void Chassis_Start(float target_mm) { g_chassis.dist_l_mm = 0.0f; g_chassis.dist_r_mm = 0.0f; g_chassis.dist_mm = 0.0f; g_chassis.v_filt = 0.0f; g_chassis.target_mm = target_mm; g_chassis.state = CHS_RUN; Encoder_Reset(); PID_Reset(&g_pid_l); PID_Reset(&g_pid_r); }控制节拍里做判断:
void Chassis_Control(void) /* 10ms 调用一次 */ { if (g_chassis.state != CHS_RUN) return; float remain = g_chassis.target_mm - g_chassis.dist_mm; /* 停止条件:距离进入容差,且速度已经降下来 */ if (remain <= STOP_TOL_MM && fabsf(g_chassis.v) < 20.0f) { Motor_StopAll(); g_chassis.state = CHS_DONE; return; } float v_cmd = Position_Loop(remain); /* 左右轮分别闭环,抑制跑偏 */ float pwmL = PID_IncUpdate(&g_pid_l, v_cmd, g_chassis.v_l); float pwmR = PID_IncUpdate(&g_pid_r, v_cmd, g_chassis.v_r); Motor_SetPWM(PWM_WithDeadzone(pwmL), PWM_WithDeadzone(pwmR)); }停止条件里的fabsf(g_chassis.v) < 20.0f不能省。只判断距离的话,车还有惯性往前滑,等滑进容差时速度还很大,下一次采样就冲出去了。加上速度条件,相当于要求“位置到了并且基本停住”才判定完成。
STOP_TOL_MM取值和减速曲线有关。DECEL_ACC给 800mm/s²、V_MAX给 600mm/s 的情况下,容差给 10mm 左右能稳定停住;给 3mm 会因为惯性反复微调,车在原地蠕动。
4.4 参数整定顺序与抗饱和
调参顺序是固定的,跳步会白费时间:
先只开内环。把KP_POS设成一个常数,直接给固定目标速度(比如 300mm/s),单独调kp / ki / kd,让实际速度能在 0.3 秒内跟到目标,且不振荡。这一步没调好,外环怎么调都是错的。
然后加外环。KP_POS从 1.0 开始往上加,观察停车位置。小了表现为末段爬行、慢慢蹭过去;大了表现为临近终点还在加速,靠减速曲线硬刹。
最后调DECEL_ACC。这个值主要看机械极限,给太小刹不住,给太大会在减速点突然掉速,车头点一下。可以从 500 开始往上试,直到末端既不点头也不冲过。
抗饱和有两条要同时做:一是输出限幅(上面代码里的out_max / out_min),二是在启动瞬间把err[0..2]全部清零。后者容易被忘,如果上一次任务的误差还留在结构体里,第一次计算出的du会带一个巨大的跳变,车会窜一下。
提示:
PID_Reset一定要在Chassis_Start里调用,不要在初始化里调一次就完事。连续执行两条指令时,第二条的起步冲击就是这么来的。
5. 避坑与排查:停准最后 20 毫米的那些事
5.1 测距链路:脉冲数为什么总会偏
现象:跑 1000mm,实际停在 940mm 左右,重复十次都是偏短,方向一致。
原因:MM_PER_PULSE用的是标称轮径和标称减速比。橡胶轮受压后有效直径比标称值小,减速比也有公差,两个误差叠加起来就是系统性的比例偏差。
解决:做一次实标。让车跑 3000mm,记录实际脉冲总数P,用卡尺量出真实距离D,重算MM_PER_PULSE = D / P。把这个值写死进encoder.h,不要再用理论公式。跑得越远标得越准,3000mm 是这套轮径下一个合适的标定距离。
现象:里程偶尔跳一个大数,车突然判定到达。
原因:输入滤波设成TIM_ICPSC_DIV1但IC1Filter给 0,电机换向时电刷打火耦合到编码器线上,定时器把毛刺当成了真实边沿。
解决:IC1Filter/IC2Filter设 6 到 10。同时编码器线用双绞或者加个小电容到地,硬件上先滤一道。
现象:倒车时距离不减反增,或者车原地打转不停。
原因:直接读__HAL_TIM_GET_COUNTER当累计值,没有强转int16_t做差分,反向计数从 0 回绕到 65535。
解决:按 3.2 的写法,用int16_t差分配合int32_t累加。这个改动只有几行,但漏掉就是持续出问题。
现象:跑 2000mm 以内都很准,跑 5000mm 以上误差越来越大。
原因:不是浮点精度问题,是轮子打滑。加速段和急减速段打滑最严重,跑得越远累积越多。
解决:把DECEL_ACC调小,让加减速更平缓;地面换成防滑材质;如果对精度要求高,就分段走——走 2000mm 停一下再走下一段,用停顿让打滑误差归零。
5.2 控制链路:为什么总是冲过头
现象:车到终点前不减速,直接撞停或者冲过去。
原因:减速曲线没生效,或者V_MAX设得太高,导致sqrtf(2*a*s)这一项在整个行程里都没成为限制条件。
解决:把DECEL_ACC降下来,让减速曲线在剩余 200mm 左右就开始起作用。可以在调试时把v_cmd通过串口打出来,看它在哪一点开始下降。
现象:低速段车身抖、嗡嗡响,传感器都跟着共振。
原因:PID 输出正好落在电机死区边缘,一会儿给 370 不转,一会儿给 400 猛转。或者kd太大,把速度采样的量化噪声放大了。
解决:先做死区补偿(PWM_WithDeadzone),再看kd。速度信号本身抖的情况下,kd给 0 往往比给一个小值更稳。
现象:满电时停得准,跑十几分钟后距离越来越短。
原因:电池电压下降,同样占空比对应的实际转速变低,速度环的增益等效变小了。
解决:速度环的积分项本身能吸收一部分,如果ki给得太小,就把ki加大。另一种做法是加电池电压前馈,把 PWM 输出乘一个7.4 / V_bat的修正系数,从根上补偿。
现象:在中断里加了printf调试,车的行为完全变了。
原因:串口打印是阻塞的,几个字节就是毫秒级,10ms 的控制周期被拉长到十几甚至几十毫秒,采样周期不再恒定,所有基于CTRL_PERIOD_S算出来的速度全错。
解决:中断里只置标志、写环形缓冲区,打印放到主循环里做。调试信息用定点数或者直接发原始整数,主循环里再格式化。
现象:直线走 2 米,横向偏出 100mm 以上。
原因:两个电机的内阻、减速箱摩擦不一致,同样的 PWM 出力不同。代码里如果只对两轮平均速度闭环,偏差会一直存在。
解决:左右轮分别闭环,各自跟同一个v_cmd。如果还偏,说明是机械层面的问题(轮子不圆、轴不平行),软件补偿不了。要彻底解决得加陀螺仪做航向环。
6. 二次开发:把这套东西挪到自己的底盘上
6.1 换底盘要重标定的三个参数
代码里的控制逻辑是可以复用的,但有三类参数换底盘必须重来,不然一定不准:
| 参数 | 位置 | 标定方法 |
|---|---|---|
MM_PER_PULSE | encoder.h | 跑 3000mm,实测脉冲数反算 |
死区dead | motor.c | 占空比从 0 缓升,记录起转点 |
DECEL_ACC | chassis.c | 从 500 起试,末端不点头不冲过为准 |
V_MAX和KP_POS也要跟着调,但这两个有经验值可参考:V_MAX取电机空载转速的 60% 左右,KP_POS取 2.0 到 4.0 之间。
6.2 把固定距离改成指令驱动
写死目标距离只能自己玩,改成串口下发才能接上位机或者接入其他系统。协议不用复杂,够用就行:
/* 帧格式:0xAA 0x55 | len | cmd | dist_l | dist_h | crc */ typedef struct { uint8_t head[2]; uint8_t len; uint8_t cmd; /* 0x01 = 走指定距离,0x02 = 急停 */ uint16_t dist_mm; /* 0 ~ 65535 */ uint8_t crc; } MoveCmd; /* 串口中断只做搬运,绝不在这里跑控制 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { RingBuf_Push(g_rx_byte); HAL_UART_Receive_IT(huart, &g_rx_byte, 1); } /* 主循环里解析,解析完只置一个请求,由控制节拍取走 */ void App_Poll(void) { MoveCmd cmd; if (Proto_Parse(&cmd) && cmd.cmd == 0x01) { g_req_dist_mm = cmd.dist_mm; g_req_pending = 1; } }这个分层不是形式主义。串口中断随时可能来,如果直接在回调里调Chassis_Start,很可能正好打断Chassis_Control的中间状态——距离刚清零、速度还没清,下一拍算出来的remain是对的但v_filt是旧的,车会顿一下。改成“中断只入队、主循环解析、节拍里取走”,这个竞争就消失了。
如果要做连续多段路径,在CHS_DONE状态里直接取队列里的下一条目标,不要重新走一遍初始化,编码器也不用复位,用相对增量继续累加就行。
挺长一段时间里,我改完任何跟编码器或者定时器相关的配置,都会先做一件事:把车架起来,轮子悬空,手动正转反转各十圈,串口打出来的累计脉冲必须正好回到 0。只要这个不归零,后面所有的距离控制和 PID 调参都是白费功夫。这套流程帮我省下了很多次在现场拆车的下午。希望帮到你。
本文还有配套的精品资源,点击获取