【STM32循迹小车】PID算法原理与循迹调参实战:从“疯狂左转“到“完美跑圈“(附完整代码)
本文可解决什么问题:如果你正在调 STM32 循迹小车,遇到"疯狂左转""直线画龙""直角弯冲线""丢线冲出赛道"等问题,本文用 20 小时实测记录,帮你快速定位并解决这些调参痛点,附完整可运行的 PD + 丢线旋转代码。
最终成果:S=40 速度下,7 个 360° 急弯的赛道只有 1 个极小弯(直径不到车身的 1/5)会出错,其余全程稳定跑圈。
一、写在前面
做这辆循迹小车之前,我只学过 PWM 和串口(HAL库),PID 对我来说就是"三个系数+一个玄学公式"。这篇文章不是教科书式的 PID 推导,而是我调参 20 个小时踩出来的真实记录——包括一度让车"疯狂左转"的 bug、让我想摔车的直线蛇形,以及最后怎么一步步让车从"画龙"变成"完美跑圈"。
如果你也在调循迹车,希望这篇能帮你少走我走过的弯路。
我的最终成果:S=40 速度下,7 个 360° 急弯的赛道只有 1 个极小弯(直径不到车身的 1/5)会出错,其余全程稳定跑圈。
二、循迹控制的基本框架
循迹小车 = 传感器("看"线在哪里)→ 控制器(算该往哪修)→ 电机(执行转向)。拆开就三件事:
5路红外传感器 → 加权误差 e → PD控制器 → 差速(左轮+Δ, 右轮-Δ) → 车转向追线
核心变量:误差 e
- e = 0:车正压在线中间 ✅
- e < 0:线在左边(偏左)
- e > 0:线在右边(偏右)
误差算得准不准,决定控制好不好。我的 5 路传感器从左到右加权 {-2, -1, 0, 1, 2},哪个传感器压线就把它的权重加进误差:
/* 读取5路循迹传感器,返回加权误差: 偏左为负,偏右为正,居中为0 */
static int8_t read_sensors(void)
{
GPIO_TypeDef* ports[5] = {IR_L_GPIO_Port, IR_LM_GPIO_Port, IR_C_GPIO_Port, IR_RM_GPIO_Port, IR_R_GPIO_Port};
const uint16_t pins[5] = {IR_L_Pin, IR_LM_Pin, IR_C_Pin, IR_RM_Pin, IR_R_Pin};
const int8_t weights[5] = {-2, -1, 0, 1, 2}; /* 左->右加权 */
int8_t error = 0;
uint8_t active = 0;
for (uint8_t i = 0; i < 5; i++)
{
if (HAL_GPIO_ReadPin(ports[i], pins[i]) == LINE_ACTIVE_LEVEL)
{
error += weights[i];
active++;
}
}
/* 丢线处理:见后面"丢线旋转找线"章节 */
if (active == 0)
{
lost_cycles++;
if (lost_cycles <= LOST_SPIN_DELAY) {
line_lost = 0;
error = last_error * 2; /* 短暂丢线: 沿上次方向加强修正 */
} else {
line_lost = 1; /* 持续丢线: 交给原地旋转找线 */
error = 0;
}
} else {
last_error = error;
lost_cycles = 0;
line_lost = 0;
}
return error;
}
【配图占位:整车照片】(此处插入整车照片,展示 5 路红外传感器布局与电机安装位置)
踩坑 1:传感器极性(LINE_ACTIVE_LEVEL)。我的模块是"白板触发"型:白板亮灯/DO输出低,黑线熄灯/DO输出高。所以黑线=高电平=检测到,要设
LINE_ACTIVE_LEVEL = GPIO_PIN_SET。不同模块极性可能相反,一定要实测——对着黑线白纸各看一次串口 IR 值,反了就改这一个宏。我一开始用反了,车把白板当线,满场乱跑。
IR 状态表(黑线=1,白板=0,从左到右 5 位):
【配图占位:IR状态表截图】(此处插入串口打印的 IR 状态表截图,便于对照验证传感器极性)
| 状态 | IR |
|---|---|
| 悬空/全黑 | 11111 |
| 偏左 | 01000 / 01100 |
| 居中 | 00100 |
| 偏右 | 00010 / 00110 |
| 大左偏 | 11100 / 10100 |
| 大右偏 | 00101 / 00111 |
三、P 控制:让车"转向线"
最简单的控制器:修正量 = KP × 误差。
int16_t corr = kp_x10 * e / 10; /* 误差 -4..4 → 修正量 */
change1(base_speed + corr); /* 电机1 = 左轮 */
change2(base_speed - corr); /* 电机2 = 右轮 */
线偏右(e>0)→ 左轮加速、右轮减速 → 车右转追线 ✅
踩坑 2:转向方向反了(疯狂左转)。我一开始写的是
change1(base - corr),物理上电机1驱动左轮,线偏右时左轮减速右轮加速 = 车左转 = 越转越偏 → 疯狂左转。这个 bug 我调了很久才发现,因为"代码注释写的是电机1=左轮",但接线实际是电机1=左轮……方向对不对,别信注释,架空车轮 T 命令实测(发T 1 40看哪个轮转、往哪转)。
KP 实测记录(SP=40,其他参数不变):
| KP | 现象 |
|---|---|
| 10 | 直线蛇形严重(画龙) |
| 8 | 直线可接受 + 弯道跟得上(甜点) |
| 4 | 直线稳,但弯道转不过来(冲线) |
【配图占位:蛇形与完美对比】(此处插入直线蛇形与调参后走直的轨迹对比图)
结论:KP 太小 → 跟不上弯;KP 太大 → 直线过冲蛇形。先调 P 到"直线勉强不画龙 + 弯道勉强能过",再上 D 项。
四、D 项:直线蛇形的解药
蛇形的本质是修正过冲:车偏一点 → P 猛修 → 修过头 → 偏另一边 → 再猛修……无限循环。P 控制看不到"车正在以多快的速度偏",所以刹不住。
D 项(微分)= 按误差变化速度给阻尼:误差正在增大(车在偏离)→ 阻尼对抗;误差正在减小(车在回正)→ 阻尼让它别冲过头。
int16_t d_term = (int16_t)(e - last_e); /* 误差变化率 */
int16_t corr = kp_x10 * e / 10 + kd_x10 * d_term / 10; /* P项 + D项 */
last_e = e;
KD 实测:KD=30(配合 KP=8、20ms 控制周期),直线从"画龙"直接变"走直"——这是整个调参过程里最爽的一次质变。
踩坑 3:控制周期不是越快越好。我把控制周期从 20ms 改到 10ms 想"响应更快",结果车一步一甩头。原因:传感器只有 5 档离散值(-4..+4),10ms 采样时误差跳变更频繁,D 项把离散跳变当"剧烈变化"放大 → 疯狂抖动。回退 20ms 后完美。数字传感器 + D 项,控制周期别太短。
五、丢线处理:比 PID 更重要的救场
直角弯、锐角弯时,线会瞬间从传感器视野里消失(00000)。P 控制这时候是瞎的,处理不好就是冲出赛道。
我的方案是两段式丢线处理:
- 短暂丢线(前 2 个控制周期):沿上次误差方向加强修正
error = last_error * 2——应对传感器瞬间没压上 - 持续丢线:原地旋转找线——朝上次偏差的方向原地打转(一前一后),直到重新看到线
/* 持续丢线: 原地旋转找线(先朝上次偏差方向转) */
if (line_lost)
{
if (lost_cycles > LOST_MAX_CYCLES) {
change1(0); change2(0); /* 找线超时(1.5s): 停车防跑飞 */
} else if (last_error >= 0) {
change1(LOST_SPIN_SPEED); /* 线之前在右 → 右转找线 */
change2(-LOST_SPIN_SPEED);
} else {
change1(-LOST_SPIN_SPEED); /* 线之前在左 → 左转找线 */
change2(LOST_SPIN_SPEED);
}
return;
}
效果:直角弯从"冲出去回不来"变成"原地转一下,找到线继续跑"。我的赛道 7 个 360° 弯,靠它只错 1 个极小弯。
六、I 项:我帮你踩过的坑
教科书说 PID 三项俱全,但循迹车急弯多,我实测 I 项弊大于利:
- I 项治"恒定偏角"(车一直偏同一侧拉不回来),适合长直线
- 我试 KI=0.2:误差在弯道里持续偏一侧 → 积分疯涨 → 丢线时积分没复位 → 车原地转圈
- 结论:急弯赛道别上 I 项。除非你加"抗积分饱和 + 丢线时积分清零",否则纯 PD 更稳
七、完整 line_follow 代码(PD + 丢线旋转)
/* 循迹差速控制: 每20ms调用一次 */
static void line_follow(int16_t base_speed)
{
int8_t e = read_sensors();
/* 持续丢线: 原地旋转找线 */
if (line_lost)
{
if (lost_cycles > LOST_MAX_CYCLES) {
change1(0); change2(0);
} else if (last_error >= 0) {
change1(LOST_SPIN_SPEED);
change2(-LOST_SPIN_SPEED);
} else {
change1(-LOST_SPIN_SPEED);
change2(LOST_SPIN_SPEED);
}
return;
}
int16_t d_term = (int16_t)(e - last_e);
int16_t corr = kp_x10 * e / 10 + kd_x10 * d_term / 10; /* PD */
last_e = e;
if (corr > 99) corr = 99;
if (corr < -99) corr = -99;
int16_t m1 = base_speed + corr; /* 电机1 = 左轮 */
int16_t m2 = base_speed - corr; /* 电机2 = 右轮 */
if (m1 > 99) m1 = 99; if (m1 < -99) m1 = -99;
if (m2 > 99) m2 = 99; if (m2 < -99) m2 = -99;
change1(m1);
change2(m2);
}
参数默认值(写死在固件里):KP=8, KD=30, SP=40——断电重启直接是最优配置,比赛现场不用调。
八、调参方法论总结(我的整定顺序)
- 先验证传感器:串口看 IR,黑=1 白=0,5 路一致,左右不反(这一步错了后面全白调)
- 先验证转向方向:架空车轮,单电机测试,确认"线偏右→车右转"
- 先 P 后 D:P 调到"直线勉强不画龙 + 弯道勉强能过",再上 D 项压蛇形
- D 项从中间值开始:我 KD=30(配合 KP=8);蛇形残留→加,弯道迟钝→减
- I 项急弯赛道直接跳过
- 控制周期 20ms 别乱改
- 最后调速度:S 从 40 起步,稳了再加;冲到 S=50 直角弯开始冲线,就定格在 40
真实数据:S=40 时 1.9m 直线 3.56s(约 0.53 m/s),全程赛道稳定跑圈。
九、附录:其他真实踩坑(每个都能省你几小时)
- 串口只发不收:USART1 的 RX 引脚(PA10)被配成了推挽输出(AF_PP),输入信号被顶住。改成输入模式就好了。CubeMX 重新生成代码会把这个修复冲掉,注意
- 霍尔测速恒为 0:EXTI 回调里用
==判断GPIO_PIN_11|GPIO_PIN_12的合并掩码,永远不相等。改成按位与&,且中断入口拆成单脚调用 - 电机时转时不转:电机输出线虚焊,扯线测试才暴露。焊完一定要做拉力测试
- 电机响但轮子不动:锂电池没电了(电压不足)。调试/比赛前先确认电池满电
- PCB 打样:封装尺寸务必对照实物核对(我的驱动板就因封装问题只能外接);电源线加粗(VM≥40mil);信号线远离晶振和电机线

十,完整踩坑清单(按阶段)
一、画板子(PCB 设计)
| # | 坑 | 一句话解决 |
|---|---|---|
| 1 | 封装尺寸没核对实物 | 驱动板画小了放不进去 → 只能外接。打样前拿卡尺量实物 |
| 2 | 核心板正反面/脚位方向没标 | 逻辑脚位反了 → 只能焊在板底。原理图标注脚位方向 |
| 3 | 横向排针易断路 | 横向受力方向差,纵向排针稳。选型时优先纵向 |
| 4 | 排针针数"待确认"就下单 | 排母长度 = 针数×2.54,先数实物针数 |
| 5 | 5V 信号接了非 FT 脚(PA0~PA7/PB0/PB1) | 会烧单片机,只能接 FT 脚 |
| 6 | TB6612 的 STBY 悬空 | 悬空=输出关闭电机不转,必须拉 3.3V |
| 7 | TB6612 的 VCC 接 5V | 逻辑阈值 3.5V > STM32 的 3.3V 会偶发不转,接 3.3V |
| 8 | 电源线太细 | VM/5V/GND ≥40mil,VM 越粗越好 |
| 9 | 晶振离信号线/电机线太近 | 干扰导致复位/乱码,晶振紧贴 PD0/PD1 |
| 10 | 3.3V 总线宽度 | 30mil(紧张 20mil),瓶颈在排针接触电阻(3/4 针并联) |
| 11 | 接线表左右写反(文档笔误) | 我们画板手册 P2-2 就写反过一次,拉线前对照检查 |
| 12 | 布局不分区 | 电源/驱动/传感器混放 → 干扰,按功能分区+接插件放板边 |
二、焊接
| # | 坑 | 一句话解决 |
|---|---|---|
| 13 | 虚焊(最大坑) | 左轮电机线虚焊 → 时转时不转+疯狂左转,焊完必做拉力测试 |
| 14 | 不上锡 | 电机接口表面氧化/有漆,刮一下/用助焊剂/铜线绑着焊 |
| 15 | 焊点不保护 | 用热缩管或热熔胶包焊点,防短路防拉扯 |
| 16 | 模块引脚顺序想当然 | 霍尔模块是 D0/GND/VCC/A0(VCC 不在第一脚),看丝印/说明 |
| 17 | 焊完不测 | 万用表通断 + 上电量电压再装 |
三、接线
| # | 坑 | 一句话解决 |
|---|---|---|
| 18 | USB-TTL 收发接反 | 串口没数据的第一嫌疑,RXD←PA9、TXD→PA10 |
| 19 | 不共地 | 电机 VM 接了不转 = 电源模块 GND 没和 MCU 连,一根共地线 |
| 20 | 电机 VM 接 ST-Link 5V | 电流不够会烧 ST-Link,VM 走电源模块 VADJ |
| 21 | 霍尔磁铁距离不均 | 触发时间短=漏脉冲=转速读数虚低,距离<5mm 且两轮一致 |
| 22 | 磁铁极性不对 | 3144 单极触发,翻面才触发,装错面=没信号 |
| 23 | 串口延长线串联 | 接头多=易断+端口号变,接头处胶带固定/换成品长线 |
| 24 | 电机输出线绑不牢 | 跑动甩松,焊+绑+胶带+拉力测试四件套 |
四、电源
| # | 坑 | 一句话解决 |
|---|---|---|
| 25 | 锂电池没电="电机响但轮子不动" | 电压不足,比赛/调试前先量 VM 或充满电 |
| 26 | VADJ 电压和电机额定不匹配 | N20 上 7.4V 会过热,按电机额定调(N20=6V) |
| 27 | 全车断电重启参数丢失 | 参数回默认,把最优参数写进固件默认值 |
五、软件/调车(最多)
| # | 坑 | 一句话解决 |
|---|---|---|
| 28 | 串口只发不收 | PA10 被 CubeMX 配成 AF_PP 推挽输出顶住信号 → 改输入模式(重生成会冲掉!) |
| 29 | HAL_UART_Receive_IT 死锁 | ORE 溢出后 HAL 关中断不恢复 → 寄存器级自己清标志继续收 |
| 30 | 电机方向反 | 方向电平反了 → 实测后对调 DIR 电平 |
| 31 | 霍尔 RPM 恒 0 | EXTI 回调 == 判断合并掩码永不相等 → 改按位与 & |
| 32 | RPM1 恒等于 RPM2 | 中断入口合并传掩码 → 拆成单脚调用 HAL_GPIO_EXTI_IRQHandler |
| 33 | 转向逻辑反(疯狂左转) | m1/m2 加减反 → 线偏右却左转,架空 T 命令实测方向 |
| 34 | 传感器极性反(LINE_ACTIVE_LEVEL) | 模块"白板触发"型 → 黑线=高电平 → GPIO_PIN_SET |
| 35 | P=10 直线蛇形 | 修正过冲 → 降 KP(8 甜点) |
| 36 | P=4 弯道转不动 | KP 太小 → 回升 |
| 37 | 蛇形压不住 | 上 D 项(KD=30),直线从画龙变走直 |
| 38 | I 项实验失败 | 急弯积分错乱+丢线不复位 → 转圈,急弯赛道别上 I 项 |
| 39 | 控制周期 10ms 反而蛇形 | 离散传感器跳变被 D 项放大 → 回退 20ms |
| 40 | 丢线跑飞 | last_error*2 猜方向不靠谱 → 丢线原地旋转找线 |
| 41 | 丢线旋转停不下来 | 加超时(1.5s)自动停车 |
| 42 | 断电重启参数丢 | 默认值写成最优(KP=8/KD=30/SP=40) |
| 43 | S=50 直角弯冲线 | 速度上限,定格 S=40(除非加出弯降速) |
| 44 | 左右电机 RPM 差 44% | 电机个体差,P 控制能修正,可加单边补偿 |
| 45 | 磁铁偏心惯性力 | 高速振动,换轻磁铁/对称布置 |
| 46 | S=20 低速弯道停 | PWM 死区堵转,N20 扭矩大没这问题 |
| 47 | 悬空 IR:11111 误判"坏了" | 无反射=像黑线,正常现象 |
| 48 | 桌面深色/杂物误判 | 传感器把桌面当线 → 白板环境测试/调电位器 |
| 49 | 串口端口号变 | 重插 USB-TTL 后 COM 号会变 → 重新枚举 |
| 50 | main.c 编码错位 | 编辑工具把 MX_GPIO_Init 拆行 → 改完必编译 |
十一、总结与互动
做这辆车的最大感受:PID 不是数学题,是"看现象-改参数-看现象"的工程循环。真正让我车变稳的,不是理解了公式,而是搞懂了"蛇形是过冲、丢线要旋转、方向要先验证"这些工程直觉。
如果你也在调循迹车,卡住了欢迎评论区交流。完整工程(CubeMX 工程 + 全套文档)整理好后会开源,关注不迷路。
下一篇预告:【STM32循迹小车】从洞洞板到PCB打样全流程与翻车点
原创文章,欢迎转载,注明出处。
更多推荐


所有评论(0)