☰
STM32五路红外循迹小车:PWM差速与PID调参实战
2026/10/30 3:08:00 网站建设 项目流程

简介:面向STM32入门学习与智能小车竞赛方向的循迹控制完整工程源码,围绕红外对管循迹、PWM差速转向与OLED实时数据显示三条主线组织,适合已掌握C语言和单片机基础、希望跑通「信号采集—主控决策—电机执行」闭环的开发者参考复现。工程以STM32F103系列为核心,红外光电传感器识别黑线并回传主控,L298N驱动左右电机,偏离线路时通过调节两路PWM占空比差速转弯:偏左则降右轮、偏右则降左轮;OLED屏同步显示实时速度与累计路程,另配红外按键可手动控制前进与左右转向,主控替代传统分立元件方案,稳定性更好。压缩包共294个文件、约7.18MB,以121个.h头文件和74个.c源文件为主体,涵盖HAL库I2C、TIM、UART、SPI、ADC等外设驱动与应用层逻辑,另含uvprojx、uvoptx工程配置、hex可执行文件以及map、lst编译中间产物,可直接打开编译并对照分析。目前已有745人学习下载,适用于课程设计、毕业设计与循迹赛题的功能验证及二次开发。

1. 一辆只会画圈的小车:STM32红外循迹到底在控制什么

第一次给小车通电,它没沿着黑线走,而是原地画了个圆,然后一头撞到桌腿上——这大概是每个做循迹小车的人都经历过的开场。这套基于 STM32 单片机的红外循迹小车源代码,解决的就是从“接上线”到“稳稳跑完全程”之间那一大段没人写进教材的活:五路红外传感器怎么读出黑线位置,STM32 的定时器怎么输出两路 PWM,左右轮的差速怎么算,跑偏了该调哪个参数。代码是裸机工程,不依赖 RTOS,外设初始化、采样、控制环、电机输出全在主循环里,读起来没有跳转迷宫。适合两类人:刚学完 51、想拿 STM32 做第一个完整控制项目的新手,以及课程设计、竞赛入门阶段需要一个能改能扩的底盘骨架的开发者。它不是一个跑分项目,是一台真车。

2. 硬件链路与工程骨架:五路红外到两路 PWM 的完整通路

先看信号是怎么流动的,再谈代码怎么组织。这条链路是:五路红外探头把地面的黑白差异变成五根 GPIO 电平,STM32 在每个控制周期把这五根线拼成一个状态字,状态字查表得到横向偏差,偏差进控制器算出转向量,转向量叠加到基础速度上,拆成左右两路 PWM 占空比,最后经电机驱动芯片落到两个直流减速电机上。整条链路里唯一有“智能”的地方就是中间那个偏差到转向的映射,其余全是确定性代码。

2.1 硬件拓扑:每一根线都要知道它为什么在那儿

常见的一套配置如下,型号可以替换,接口关系不要乱:

模块典型器件与 STM32 的连接作用
主控STM32F103C8T6 最小系统板—采样、运算、输出 PWM
循迹传感器TCRT5000 五路模块PB0~PB4,GPIO 输入输出黑白电平
电机驱动L298N 或 TB6612PA6/PA7 接 PWM,PB5~PB8 接方向功率放大与正反转
执行机构TT 减速电机 ×2接驱动输出差速转向
供电7.4V 锂电池组驱动 VM,板载 5V 供 MCU动力与逻辑电源

传感器的电平极性必须先确认:TCRT5000 是红外发射加接收,白底反射强、接收管导通,多数模块此时输出低电平;黑线吸光、接收管截止,输出高电平。但市面上模块带比较器,电位器位置不同输出可能反相。判断方法很简单,把车头抬起来,用白纸和黑色胶带分别在探头下晃一下,看串口打印的原始字节是不是按预期翻转。这一条没确认就往下写逻辑,后面所有调试都是在错误的地基上做。

电机驱动的选型上,L298N 便宜、接线直观,但内部达林顿管压降大,7.4V 输入实际到电机可能只剩 5.5V 左右,而且发热明显;TB6612 是 MOSFET 方案,压降小、效率高、体积小,价格差不了几块钱。如果小车总重不大、跑的是室内赛道,L298N 够用;如果要求速度或者续航,直接上 TB6612,省下来的调试时间远超差价。这是我踩过的一次坑:同一套代码在 L298N 上跑得好好的,换电池电压一降,PWM 占空比没变但实际转速掉了一截,PID 参数全部失效,最后发现是驱动压降吃掉了裕量。

供电还有一个必须注意的点:共地。MCU 的 GND、驱动的 GND、电池负极必须连在一起。只把信号线接过去不共地,PWM 电平参考漂移,表现是电机时转时停、转向随机。另外电机是大电流感性负载,启停瞬间会在电源上打出尖峰,轻则传感器误判,重则 MCU 复位。常见做法是在电机两端并 0.1μF 瓷片电容抑制火花,电源入口加一个大电容做储能,电机走线和传感器走线分开布,不要捆成一束。

2.2 工程目录:裸机工程也要有分层

这套代码是 HAL 库工程,目录结构大致是这样,不管你用 CubeMX 生成还是手写,分层思路值得照搬:

TrackCar/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── track.h // 循迹采样与状态编码 │ │ ├── motor.h // 电机方向与 PWM 输出 │ │ ├── pid.h // 控制器 │ │ └── config.h // 阈值、权重、限幅等可调参数 │ └── Src/ │ ├── main.c // 初始化 + 主循环调度 │ ├── track.c │ ├── motor.c │ ├── pid.c │ └── stm32f1xx_it.c // 只保留必要中断 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ └── MDK-ARM/ 或 cmake/

关键是把“能调的参数”全部集中到 config.h,包括五路权重、基础速度、PID 三个系数、最大转向量、丢线判定阈值。调试阶段改参数只动这一个文件,不用在几个 .c 里翻。main.c 里只做三件事:外设初始化、主循环按固定周期调用 track 采样和 pid 计算、异常时停车。不要在中断里塞控制逻辑,也不要让主循环跑飞没有周期约束。

主循环的节奏控制有两种写法。简单的是用一个 HAL_Delay(5) 做 200Hz 左右的软定时,缺点是 HAL_Delay 期间 CPU 空转,扩展功能后周期会漂。稳一点的是用 SysTick 或者一个基本定时器产生 1ms 中断,置标志位,主循环轮询标志位执行控制,这样周期由硬件保证,加串口打印、加超声波测距都不会把控制节奏带偏。这套源码用的是后者,扩展性更好。

2.3 时钟与外设初始化:定时器和 GPIO 一次配明白

PWM 输出用 TIM3 的 CH1 和 CH2,分别对应 PA6、PA7。TT 马达的机械特性决定了 PWM 频率不能太低,低了会有明显抖动和啸叫,太高则驱动芯片开关损耗上升。10kHz 到 20kHz 是比较舒服的区间,这里取 10kHz:

/* 72MHz 主频,PSC=71 得到 1MHz 计数时钟,ARR=999 得到 10kHz PWM */ static void Motor_PWM_Init(void) { GPIO_InitTypeDef gpio = {0}; TIM_HandleTypeDef htim3 = {0}; TIM_OC_InitTypeDef oc = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_TIM3_CLK_ENABLE(); /* PA6=CH1(左轮), PA7=CH2(右轮),复用推挽 */ gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; htim3.Init.Period = 1000 - 1; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 0; /* 上电占空比为 0,车不动 */ oc.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2); }

这段代码有三个参数值得说清楚。Prescaler 设成 71,是因为系统时钟 72MHz 分频后要得到 1MHz 的计数频率,方便心算;Period 设成 999,计数 1000 次溢出一次,正好 10kHz;Pulse 是比较值,范围 0 到 999,写 300 就是 30% 占空比。上电时 Pulse 给 0,是为了避免初始化过程中电机突然转动造成意外,这个细节在调试台上能救命。

GPIO 采样口的初始化更简单,五路输入加内部上拉:

/* PB0~PB4 五路红外输入,上拉,模块开漏输出时也能读到确定电平 */ static void Track_GPIO_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &gpio); }

方向控制脚单独配成推挽输出,初始化时全部拉低,保证电机不会因为上电默认电平而误动作。TB6612 有两路方向脚加一路 STBY 使能,STBY 记得拉高,不拉高芯片一直处于待机,PWM 给多少都不转——这个现象我第一次遇到时查了半小时,以为是定时器没配好。

2.4 采样函数:把五根线拼成一个字节

采样本身没有难度,难点在“什么时候采”和“采完怎么表示”。直接在控制周期里读五次 GPIO 就够了,不需要中断触发:

/* 返回 5 位状态,bit4 为最左侧探头,bit0 为最右侧 */ uint8_t Track_ReadRaw(void) { uint8_t s = 0; s |= (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) ? 1 : 0) << 0; s |= (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) ? 1 : 0) << 1; s |= (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2) ? 1 : 0) << 2; s |= (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) ? 1 : 0) << 3; s |= (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4) ? 1 : 0) << 4; return s; }

这里有个约定必须统一:代码假定“检测到黑线输出 1”。如果手里的模块正好相反,不要改五处读取逻辑,只在函数返回前取反一次,注释写清楚。状态值 0b00100 表示只有中间探头压线,是最理想的状态;0b11000 表示左边两路同时压线,说明车身已经偏右,需要往左打。全 0 和全 1 是两个特殊状态,前者是整车都在白底上(丢线),后者可能是遇到了横向黑线、十字路口,或者传感器离地太高全部误触发。

采样周期和控制周期保持一致,200Hz 足够。有人喜欢用外部中断在电平跳变时触发采样,理论上响应更快,实际上红外模块输出有抖动,边沿触发会引入大量误中断,反而把主循环拖慢。轮询加固定周期是这套代码的选择,稳定优先。

3. 循迹算法拆解:从 5 位传感器状态到左右轮差速的每一步

硬件把物理世界压缩成 5 个 bit,算法要做的是把这 5 个 bit 还原成一个带方向的偏差量,再变成一个能让车回到线中央的动作。这中间有三层转换:状态到误差、误差到转向量、转向量到两路 PWM。每一层都可以做得很简单,也可以做得很讲究,区别在车跑直道时会不会左右摇摆、过弯时会不会冲出去。

3.1 状态到误差:查表和加权是两种思路

最直接的做法是查表。5 位一共 32 种组合,实际能出现的有效状态不到 10 种,把每种状态对应一个固定的转向动作写进 switch:

/* 查表法:每种传感器状态直接给出转向量,简单但粒度粗 */ int16_t Track_ErrorByTable(uint8_t s) { switch (s) { case 0x04: return 0; /* 00100 正中 */ case 0x06: return -1; /* 00110 略偏右,往左修 */ case 0x02: return -2; /* 00010 偏右 */ case 0x03: return -3; /* 00011 大偏右 */ case 0x01: return -4; /* 00001 最右,急修 */ case 0x0C: return 1; /* 01100 略偏左 */ case 0x08: return 2; /* 01000 偏左 */ case 0x18: return 3; /* 11000 大偏左 */ case 0x10: return 4; /* 10000 最左,急修 */ case 0x00: return 0; /* 全白,交给丢线逻辑 */ default: return 0; /* 全黑或异常,交给特殊逻辑 */ } }

这种写法上手快,新手第一天就能让车动起来。缺点是分辨率被钉死在 9 档,而且档位之间的跳变是离散的,车会在两个状态之间来回切换,表现为舵机式的“一顿一顿”。更麻烦的是扩展性:想加第六路传感器,整个 switch 得重写。

加权平均法把这个问题一般化了。给每个探头分配一个位置权重,把当前压线的探头权重加起来求平均,得到一个连续的偏差量:

/* 权重法:权重按探头物理位置线性分布,输出连续误差 */ static const int8_t weight[5] = {-2, -1, 0, 1, 2}; float Track_ErrorByWeight(uint8_t s, uint8_t *active) { int16_t sum = 0; uint8_t cnt = 0; for (int i = 0; i < 5; i++) { if (s & (1 << i)) { sum += weight[i]; cnt++; } } *active = cnt; if (cnt == 0) return 0.0f; /* 丢线,由上层处理 */ return (float)sum / cnt; /* 多路同时压线时取平均 */ }

权重的含义是:最左侧探头代表车头相对黑线偏右 2 个单位,最右侧代表偏左 2 个单位,中间为 0。当只有中间探头压线,误差 0,直行;当最左和第二左同时压线,误差 (-2 + -1)/2 = -1.5,车会得到一个中等偏左的修正量。相比查表,输出的误差是连续的,控制器可以做出平滑响应,而且加探头只需要改 weight 数组长度。

权重法的边界情况要单独处理。cnt 为 0 表示五路都没压线,此时不能返回 0,因为返回 0 会让车以为自己在正中央继续直行,实际上已经飞出赛道。cnt 为 5 表示五路全压线,可能是宽黑线或者十字路口,此时取平均仍然是 0,行为上等同于直行通过,这个语义在大多数赛道上是合理的。真正的判断逻辑留给上层。

3.2 误差到转向量:位置式 PD 比 PID 更适合循迹

循迹是一个跟随问题,目标值恒为 0,实际值是当前偏差,控制器输出的就是转向修正量。理论上 PID 三个项都能用,实际上积分项在循迹里基本是负担:车只要还在线上,误差就在 0 附近来回,积分项会缓慢累积,等到长直道结束进弯时,累积的积分量突然释放,车会猛打一下方向冲出赛道。这是典型的积分饱和,我在早期版本里被它坑过一次,直道跑得笔直,一进弯就飞。

所以常见做法是 PD,把 I 置零,或者加一个很小的积分系数并做限幅。位置式 PD 的实现:

typedef struct { float kp; /* 比例系数 */ float kd; /* 微分系数 */ float last_err; /* 上一次误差,用于求微分 */ float out_max; /* 输出限幅,防止转向量过大 */ } PD_t; /* 输入当前误差,输出转向量,正值表示右转,负值表示左转 */ float PD_Calc(PD_t *p, float err) { float d = err - p->last_err; /* 误差变化率 */ float out = p->kp * err + p->kd * d; p->last_err = err; if (out > p->out_max) out = p->out_max; if (out < -p->out_max) out = -p->out_max; return out; }

kp 决定“偏多少就修多少”。kp 太小,车压线了还在慢悠悠地回正,弯道直接冲出去;kp 太大,车会在中心线两侧高频摆动,像喝醉一样。kd 决定“偏差变化得快就提前收手”,它抑制的是过冲。调参顺序一定是先给 kd 为 0,把 kp 从 0 慢慢加,加到车能过弯但直道开始轻微摆动,再把 kd 一点点加上去压住摆动。这个顺序反了会很难受,因为两个参数同时变,你分不清是哪个在起作用。

out_max 的取值要看基础速度。如果基础速度对应 PWM 500,转向量限到 500,理论上可以让一侧轮子完全停转甚至反转,原地转向能力最强,但也最容易在直道上因为一点噪声就猛打方向。一般取基础速度的 60% 到 80% 作为初始值,跑起来再调。

有一个细节值得单独说:微分项对噪声极其敏感。五路数字量本身是离散的,误差会在 -2、-1.5、-1 之间跳,d 值就会跳。如果发现加了 kd 之后车反而更抖,不是 kd 没用,是微分被噪声污染了。解决办法是对误差做一阶低通滤波,或者对 d 做限幅,不让单次跳变产生过大的微分冲击:

/* 带限幅的微分项,防止离散误差跳变打出尖峰 */ float d = err - p->last_err; if (d > 1.0f) d = 1.0f; if (d < -1.0f) d = -1.0f;

3.3 转向量到 PWM:差速合成与方向控制

拿到转向量之后,合成左右轮的目标速度:

/* base 为基础速度,turn 为 PD 输出,正值右转 */ void Motor_Differential(int16_t base, float turn) { int16_t left = base - (int16_t)turn; int16_t right = base + (int16_t)turn; /* 限幅到 PWM 有效区间,负值代表反转 */ if (left > 999) left = 999; if (left < -999) left = -999; if (right > 999) right = 999; if (right < -999) right = -999; Motor_Set(L, left); Motor_Set(R, right); }

右转时左轮加速、右轮减速,这个符号约定要和 PD 输出的符号一致,否则车会越修越偏,直接冲出赛道。检查方法:把车放在线上,手动把车头往左推(车相对线偏右),看代码算出的 turn 是不是正值、右轮是不是减速。这一步在台架上用串口打印就能验证,不用下地跑。

单侧电机的输出函数要处理正负号到方向脚加占空比的映射:

/* 输入 -999~999,正值正转,负值反转,0 为滑行停止 */ static void Motor_Set(MotorId id, int16_t speed) { uint16_t duty = (speed >= 0) ? (uint16_t)speed : (uint16_t)(-speed); if (id == L) { HAL_GPIO_WritePin(GPIOB, PIN_DIR_L1, speed >= 0 ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, PIN_DIR_L2, speed >= 0 ? GPIO_PIN_RESET : GPIO_PIN_SET); } else { HAL_GPIO_WritePin(GPIOB, PIN_DIR_R1, speed >= 0 ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, PIN_DIR_R2, speed >= 0 ? GPIO_PIN_RESET : GPIO_PIN_SET); } __HAL_TIM_SET_COMPARE(&htim3, (id == L) ? TIM_CHANNEL_1 : TIM_CHANNEL_2, duty); }

方向脚的两根线不能同时为高,那是刹车状态,电机两端短接,电流很大,长时间会烧驱动。代码里用互斥写法保证任何时候只有一根为高。另外 __HAL_TIM_SET_COMPARE 是直接写比较寄存器,不阻塞,适合在控制循环里高频调用。

3.4 丢线与特殊路况:状态机兜住异常

正常循迹只覆盖了“至少有一路压线”的情况,实际赛道还有丢线、十字、急弯。用一个简单的状态机把它们分开处理:

typedef enum { ST_NORMAL, ST_LOST, ST_CROSS } TrackState; TrackState g_state = ST_NORMAL; float g_last_valid_err = 0.0f; void Track_Update(void) { uint8_t s = Track_ReadRaw(); uint8_t cnt = 0; float err = Track_ErrorByWeight(s, &cnt); switch (g_state) { case ST_NORMAL: if (cnt == 0) { g_state = ST_LOST; /* 进入丢线 */ } else if (cnt >= 4) { g_state = ST_CROSS; /* 十字或宽线 */ } else { g_last_valid_err = err; /* 记住最后一次有效偏差 */ } break; case ST_LOST: if (cnt > 0) { g_state = ST_NORMAL; /* 找回线 */ } else { /* 按最后一次偏差的方向原地找线,速度减半 */ err = (g_last_valid_err >= 0) ? 4.0f : -4.0f; } break; case ST_CROSS: if (cnt < 4) { g_state = ST_NORMAL; } else { err = 0.0f; /* 直行通过 */ } break; } float turn = PD_Calc(&g_pd, err); Motor_Differential(g_base_speed, turn); }

丢线处理的核心是“不要停,也不要瞎转”。停车在赛道上等于弃权,随机转向可能转到反方向越走越远。按最后一次有效偏差的方向继续修,相当于赌车是从这一侧飞出去的,成功率最高。十字处理的核心是“直行通过”,因为十字路口五路全压线,加权平均本来就是 0,PD 输出 0,车会自然直行,状态机的作用只是防止在十字上误判为丢线。

状态切换要有防抖。cnt 在边界上会抖,比如 0 和 1 之间反复跳,不加防抖会频繁进出 LOST 状态。简单做法是连续 N 个周期都满足条件才切换,N 取 3 到 5 就够。

4. 上车调试实录:烧录、标定、整定的先后顺序不能乱

代码编译通过只是开始,真正花时间的是让车在赛道上跑稳。这个过程有严格的先后顺序:先确认能烧进去、能打印,再确认传感器读得对,然后确认电机转得对,最后才调 PID。顺序错了,你会在错误的前提上反复调参,越调越乱。

4.1 编译与烧录:两条路都能走

用 Keil MDK 的话,打开 MDK-ARM 目录下的工程文件,确认器件型号选的是 STM32F103C8,输出选项里勾上 Create HEX File,编译后点下载。烧录器用 ST-Link,接线是 SWDIO、SWCLK、GND、3.3V 四根,注意 3.3V 是参考电平,不要用它给整板供电,电机一启动电流拉满会把烧录器拖挂。

用 CubeIDE 或 CMake 的话,直接命令行编译和烧录更省事:

# 生成构建目录并编译 cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j # 用 ST-Link 烧录,确认 openocd 已安装对应 target 配置 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/TrackCar.elf verify reset exit"

烧录成功的前提是芯片没被读保护。如果 openocd 报错说连不上或者 flash 写不进去,先检查是不是之前勾了读保护选项,需要整片擦除一次。另外 BOOT0 跳线要在 0 位置,跳到 1 会进系统存储器,程序不会跑。

上电第一件事是看串口有没有输出。在 main 里加一段打印,把传感器原始状态和当前误差按行输出,波特率 115200:

/* 调试打印:每 50ms 输出一次,方便手推车观察状态变化 */ if (HAL_GetTick() - last_print > 50) { last_print = HAL_GetTick(); uint8_t s = Track_ReadRaw(); printf("raw=0x%02X state=%d base=%d\r\n", s, g_state, g_base_speed); }

printf 重定向到串口需要实现 _write 或者用 HAL_UART_Transmit 包一层。注意 printf 本身比较慢,在控制周期里直接调用可能拖慢循环,所以用 50ms 的独立节拍打印,和控制周期解耦。调试阶段打开,跑正式赛道时关掉,或者改成只打印异常状态。

4.2 传感器标定:阈值不是猜的,是量出来的

数字输出的红外模块没有阈值可调,只有模块上的电位器。标定的目标是:白底时输出稳定为 0,黑线时输出稳定为 1,中间过渡区尽量窄。做法是把车架在赛道上,让探头正对白底和黑线分别停住,看串口打印的 raw 值,然后微调每个模块的电位器,直到五路在两种地面下的读数一致。

如果模块是模拟输出(部分灰度模块),那就需要 ADC 采样加软件阈值:

/* 模拟量方案:ADC 采样后与阈值比较,阈值取黑白中值 */ static uint16_t adc_buf[5]; uint8_t Track_ReadByAdc(void) { uint8_t s = 0; uint16_t th = (g_white[0] + g_black[0]) / 2; /* 示例:单路阈值 */ for (int i = 0; i < 5; i++) { if (adc_buf[i] < th) s |= (1 << i); /* 黑线吸收,反射弱 */ } return s; }

模拟方案的好处是可以通过读值判断探头离地高度是否合适:离地太近,白底读数饱和;离地太远,黑白差值变小,噪声占比升高。经验值是探头距地面 8 到 15mm,具体看模块发射管和接收管的夹角,太高了反射光回不来,表现是白底也读成黑线。

标定完之后把五路探头在黑线上的实际宽度记下来。如果黑线宽度只有 15mm,而五个探头总跨度 60mm,那么正常循迹时最多两路同时压线,三路以上压线就该怀疑是十字或者探头装歪了。这个数值关系决定了状态机里 cnt 的阈值该设多少。

4.3 参数整定:先让轮子转对,再让车走直

电机验证放在 PID 之前。写一个测试函数,让左右轮分别以固定占空比正转、反转、停止,观察四个动作是否都正确。常见问题是某一侧轮子反转时转得比正转慢,这是电机个体差异,或者是驱动某一桥的接线接触不良。这个阶段要把左右轮在相同占空比下的转速差异记下来,如果差异超过 10%,在代码里给慢的一侧乘一个补偿系数,否则车天生就跑偏,PID 会一直带着一个固定偏差工作,直道也走不直。

然后调 PID。按这个顺序来:

步骤操作观察目标
1kp 从小往大加,kd=0车能过弯,直道轻微摆动
2加 kd 压制摆动直道稳定,过弯不甩尾
3提高 base 速度高速下仍能过弯
4微调 out_max急弯不冲出,直道不抖

每一步只改一个参数,改完跑三圈再判断。同时改两个参数,出现问题你无法归因。基础速度的初始值建议给最大速度的 40%,这个速度下即使 PID 参数不完美,车也来得及修正。等参数稳定了再逐步提速,每次提 10%,提到某个速度开始过弯不稳,就退回去一档,那就是这套底盘和这套参数的极限。

还有一点:电池电压会影响实际转速。满电和快没电时,同样的占空比对应的转速差很多,PID 参数在满电时调好,电量掉到 7V 以下可能就过不了弯了。要么在代码里做电池电压检测并补偿,要么接受这个衰减,比赛前换满电电池。

5. 避坑与排查:跑偏、抖舵、丢线背后的五个真凶

调试阶段遇到的问题,八成集中在电气和算法两类。下面这五条是实际撞过的,现象、原因、解决办法都写清楚,遇到了可以对照着查。

5.1 电气与供电类

现象一:上电后小车原地打转,串口打印的传感器状态乱跳。原因是共地没做好。电机驱动和 MCU 各自供电但没有连 GND,PWM 信号的参考电平漂移,驱动收到的占空比时大时小,两个轮子转速不一致。解决是把电池负极、驱动 GND、MCU GND 用粗线短接在一起,最好单点接地,避免地线上流过电机大电流产生压降。顺手在电机端子并 0.1μF 电容,能明显减少状态乱跳。

现象二:跑着跑着 MCU 复位,串口重新打印启动信息。原因是电机启停的电流尖峰把电源拉低,触发了 MCU 的欠压复位。解决是电源入口加 470μF 以上的电解电容做储能,电机走线和 MCU 供电走线分开,不要从同一对细线上取电。如果用的是 L298N 板载 5V 给 MCU 供电,尤其容易出这个问题,因为板载稳压的余量很小,建议 MCU 单独用一块小容量电池或者加一级稳压。

现象三:换了新电池,车突然不听话,原来调好的参数全部失效。不是参数失效,是驱动压降变了。L298N 在 7.4V 输入下实际输出可能只有 5.5V,电池满电 8.4V 时输出 6.5V,两者对应的电机转速差一截。解决是把基础速度按电压做补偿,或者换 TB6612 降低压降。短期办法是每次换电池重新确认一遍直道行为,长期办法是加一路 ADC 测电池电压,在代码里按比例修正 base 值。

5.2 采样与算法类

现象四:直道上车左右高频摆动,像在画正弦波。先看是不是 kp 太大,把 kp 减半再跑,如果摆动频率降低但幅度还在,那就是 kd 太小的同时微分被噪声污染了。检查串口打印的 raw 值,如果状态在 0x04 和 0x06 之间高频跳变,说明探头在黑白边界上抖动,这是物理问题不是算法问题。解决是把探头抬高 2mm 增大光斑,或者在软件里对连续三次采样做多数表决,滤掉单次跳变。多数表决的代价是引入一个控制周期的延迟,200Hz 下是 5ms,可以接受。

现象五:直道跑得很好,一进弯就冲出去,减速也来不及。先确认 out_max 是不是给得太小,转向量被限幅,车想转但转不动。如果 out_max 已经给到 base 的 80%,那就是 kp 不够或者 base 太快。把车放在弯道入口手动推过去,看串口打印的误差值,如果误差已经到 4(最外侧探头压线)但 turn 输出只有 200 多,说明 kp 太小。另一种可能是权重法在多路压线时取了平均,弯道上内侧两路同时压线,平均后的误差反而变小,车以为偏得不多。解决办法是在弯道状态下不给平均,直接取最外侧那一路的权重,牺牲平滑换响应速度。

现象六(补充):过十字路口时车突然转向。原因是十字处五路全压线,如果代码里对 cnt==5 没有单独处理,加权平均的结果虽然是 0,但某些实现里会把全压线误判成“最左和最右同时压线”,误差算出正负抵消的中间值之外的东西。解决是按 5.1 里的状态机,cnt>=4 时强制 err=0 并保持直行,同时用连续 N 个周期确认,避免单次误判。

排查的通用手法是串口打印加手动推车。把车架起来或者放在赛道上用手推,观察每一帧的 raw、err、turn 三个值,正常情况下 turn 应该随 err 平滑变化,符号正确,限幅生效。如果推车时数值都正常,下地跑就出问题,那基本是供电或者机械问题,不是算法问题。分清楚这两类,能省掉大量无效调参。

6. 二次开发:在这套底盘上加测距、遥控与速度闭环

代码跑通之后,多数人下一步想的是加东西。这套工程的分层结构就是为扩展留的口子:track 只管采样和误差,pid 只管误差到转向,motor 只管转向到 PWM,三者通过 main 里的一个控制周期串起来。加功能时尽量不破坏这个边界,新模块挂在边界外面。

一个典型的改造方向是加超声波测距做避障。超声波模块用定时器输入捕获测回波宽度,或者用简单的 GPIO 加延时测距。关键是不要把它塞进 200Hz 的控制循环里,因为测距一次可能要几十毫秒,会拖慢循迹。正确做法是单独开一个低频任务,比如 20Hz,在主循环里用计数器分频调用,测到障碍物时把 g_base_speed 降到 0 或者切换到一个避障状态。共享变量用一个 volatile 标志位传递,避免在中断和主循环里同时写。

改造方向接入点注意事项
超声波避障主循环低频任务,改 base 速度不要阻塞控制周期
蓝牙遥控串口中断收指令,覆盖 base 和 turn遥控模式下要禁用循迹输出
编码器测速定时器编码器模式,读计数差需要改成速度闭环,PID 串一级
灰度模拟量ADC 规则组 + DMA阈值要在线标定,不能写死

速度闭环是提升最大的一项。现在是开环给占空比,电池电压一变转速就变。加上编码器之后,外环用速度 PID 算出目标占空比,内环还是原来的循迹 PD 给目标速度,两级串起来。改造的时候有一个顺序问题:先把单轮速度闭环调稳,确认给定 100 的目标转速实际能稳在 100,再把循迹的输出接进去。我见过有人两级一起调,结果车抖得看不出是哪一级的问题,最后把编码器拆了退回开环才好。

最后说个习惯。这套代码里所有可调参数我都强制放在 config.h,并且每个参数后面写一行注释说明单位和调整方向,比如#define BASE_SPEED 450 // PWM 计数,0~999,建议从 400 起调。早期我不写这些,隔两周回来改车,看到kp = 3.2f完全想不起来这个 3.2 是在什么速度下标定的,只能从头再调一遍。从那以后我每次开新工程,第一件事就是把参数区建好、注释写满,哪怕当时觉得麻烦。希望这套代码能帮你少走几段弯路,把时间花在真正有意思的改造上。

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

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

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

立即咨询