ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32红外循迹小车源码解析:采样、PID与电机控制

STM32红外循迹小车源码解析:采样、PID与电机控制

简介:围绕STM32F103系列单片机展开的红外智能循迹小车完整源码,面向电子设计竞赛、课程设计及希望从单片机基础过渡到嵌入式项目实践的开发者。工程由信号检测、主控和L298N电机驱动三个核心模块组成,利用红外对管识别黑线并反馈给主控,结合PWM波调节左右电机转速实现差速转弯,同时在OLED屏上显示实时路程与速度,还可通过红外按键控制前进、后退及左右转向,便于理解循迹判断、外设配置与电机调速的完整链路。压缩包共294个文件,约7.18MB,以121个h头文件、74个c源文件为主,并含Keil工程文件、调试中间文件、hex烧录文件及STM32CubeMX配置,目录较完整,适合直接编译验证和二次修改。已有745人学习下载,可据此梳理STM32F103系列主控方案、红外检测逻辑与PWM差速控制思路。

1. 车一上电就原地画圈?先看清这套 STM32 红外循迹源码解决的是哪一段

把小车放到黑线上,按下电源,两个轮子一正一反地转,车在线上原地画圈;或者把车拎起来悬空,传感器全白,电机依然全速狂转,一落地就冲出赛道。这类现象九成不是“算法不够高级”,而是采样、方向、时序三处没对齐。这套基于 STM32 单片机的红外循迹小车源代码,给的是完整一条链:红外对管采集黑白线信息、GPIO 或 ADC 读入、加权误差计算、增量式 PID、定时器 PWM 输出到双 H 桥、电机差速转向,中间还留了一条串口通道用于回看数据。它适合电子设计竞赛入门、课程设计、毕业设计起步这种“要在一周内让车稳稳跑完一圈”的场合,也适合已经会点灯但没做过闭环控制的人拿来对照。下面按我拆包的顺序讲,从工程骨架一路讲到参数标定。

2. 工程骨架先立住:目录分层、时钟树与主循环职责

拿到一份 STM32 工程,最怕的是打开就一头扎进main.c里从上往下读,读完还是不知道哪个文件管电机、哪个文件管传感器。我一般先做两件事:看目录分层,看主循环。

2.1 目录分层:把“已经能跑的”和“你接下来要改的”分开

这类循迹小车工程的目录结构,常见的分法是四层:内核与启动层、芯片驱动层、板级支持层(BSP)、应用层。内核启动层是启动文件和 CMSIS,不用动;芯片驱动层是 HAL 库或标准外设库,除非要改时钟或者换封装,也不用动;真正需要改的只有 BSP 和 App 两层。把这条界限记住,后面调参时就不会误伤底层配置。

目录职责是否需要改
Core/Startup启动文件、中断向量表、复位入口基本不改
Drivers/芯片外设驱动、CMSIS 头文件不改,最多确认版本
BSP/电机、红外、按键、串口的板级封装常改(引脚、极性)
App/循迹决策、PID、状态机、遥测主要改这里
Config/阈值、PID 参数、速度上限等宏调参必改

我最先翻的是Config/。如果一份工程把阈值和 PID 参数硬编码在line.c中间,那调参就得反复改源码重编译,效率极低。合格的写法是把这些集中到app_config.h:

/* Config/app_config.h —— 调参入口集中在这里 */ #ifndef APP_CONFIG_H #define APP_CONFIG_H #define LINE_SENSOR_NUM 5 /* 红外探头路数 */ #define LINE_ADC_THRESHOLD 2200 /* 12 位 ADC 判黑阈值,实测标定 */ #define LINE_LOST_TIMEOUT_MS 120 /* 丢线超过该时间进入停车保护 */ #define MOTOR_PWM_PERIOD 999 /* ARR,决定 PWM 频率 */ #define SPEED_BASE 420 /* 基础占空比 0~999 */ #define SPEED_MAX 780 /* 单轮占空比上限,防过冲 */ #define PID_KP 18.0f #define PID_KI 0.35f #define PID_KD 6.0f #endif

这段宏定义的意义不只是“好看”。SPEED_MAX卡在 780 而不是 999,是因为电池满电时 999 的占空比会让车在弯道直接甩出去,留 20% 余量给 PID 输出。LINE_LOST_TIMEOUT_MS是后悔药:传感器全白时如果还按上次误差猛打方向,车会原地转圈直到撞墙,加个超时就能强制减速停车。

提示:改引脚定义前先看BSP/里有没有引脚宏。有些工程在.ioc文件里配置了引脚,源码里又写死一份,两边不一致时编译能过但外设不工作,这类问题排查起来非常费时间。

2.2 时钟树与 SysTick:先让延时准了,再谈控制周期

循迹小车的控制效果对时间极其敏感。1 ms 的控制周期如果实际跑成了 3 ms,PID 的微分项就会被放大三倍,车在直道上开始左右高频抖。所以拿到工程第一步不是改 PID,是确认时钟。

STM32 常见的配置是外部 8 MHz 晶振经 PLL 倍频到 72 MHz 作为系统时钟,AHB 不分频,APB1 二分频到 36 MHz,APB2 不分频到 72 MHz。这个配置下 SysTick 每 1 ms 中断一次,HAL_GetTick()才有意义。代码里如果用了HAL_Delay(1)做控制节拍,实际精度受中断影响,我一般不这么干,而是拿HAL_GetTick()做非阻塞时间片。

/* Core/Src/main.c —— 时钟核对与主循环时间片骨架 */ int main(void) { HAL_Init(); /* 内含 SysTick 1 ms 配置 */ SystemClock_Config(); /* 72 MHz,需与 RCC 配置一致 */ MX_GPIO_Init(); MX_TIM3_Init(); /* 电机 PWM */ MX_ADC1_Init(); /* 红外采样 */ MX_USART1_UART_Init(); /* 遥测 */ Motor_Init(); Line_Init(); Telemetry_Init(); uint32_t t_ctrl = 0, t_sample = 0, t_tele = 0; while (1) { uint32_t now = HAL_GetTick(); if ((now - t_ctrl) >= 1) { /* 1 ms:控制环 */ t_ctrl = now; Car_ControlStep(); } if ((now - t_sample) >= 5) { /* 5 ms:传感器刷新 */ t_sample = now; Line_UpdateError(); } if ((now - t_tele) >= 100) { /* 100 ms:串口上报 */ t_tele = now; Telemetry_Send(); } } }

三个时间片的划分不是随便定的。控制环 1 ms 是因为电机 PWM 频率取 1 kHz 时,一个 PWM 周期正好 1 ms,控制输出和 PWM 周期对齐,占空比更新不会出现“半周期跳变”。传感器 5 ms 刷新是因为红外对管本身有响应延迟,采太快只是重复读同一个值,还会白白占 CPU。遥测 100 ms 是串口带宽限制,波特率 115200 下每帧 40 字节左右,100 ms 一帧刚好不丢数据。

Car_ControlStep()里只做三件事:取当前误差、算 PID、写占空比。任何带HAL_Delay的操作都不能放进去,否则时间片立刻失准。这是我看过最多新手翻车的地方——在控制函数里加一句HAL_Delay(10)等电机响应,结果整条时间链全乱。

2.3 中断优先级:别让串口把控制环挤掉

工程里通常有三个中断源在跑:SysTick、定时器更新(如果用了中断方式产生节拍)、串口接收。优先级排错的典型症状是“一开串口打印,车就开始抖”。原因是串口中断服务函数里做了格式化输出,占用时间过长,把控制环挤到了下一个周期。

我一般的排法是:SysTick 最高(它只做计数,极短),电机 PWM 定时器次之,串口最低。串口接收改成中断里只存字节、主循环里解析,格式化输出用 DMA 发送,发完再发下一帧。

/* 中断优先级配置示例,数值越小优先级越高 */ HAL_NVIC_SetPriority(SysTick_IRQn, 0, 0); HAL_NVIC_SetPriority(TIM3_IRQn, 1, 0); /* PWM 更新 */ HAL_NVIC_SetPriority(USART1_IRQn, 3, 0); /* 串口最低 */

这段配置放在外设初始化之后、主循环之前。参数里第二个是抢占优先级,第三个是子优先级。如果工程里只用了抢占优先级,子优先级统一填 0 即可。核对的土办法是:把串口上报频率从 100 ms 改成 10 ms,如果车的直道抖动明显变大,说明串口占用了控制环的时间,得回去查优先级和发送方式。

3. 红外采样的信号链:数字量还是模拟量,决定了后面能不能用 PID

循迹的精度上限,在你决定用数字量还是模拟量的那一刻就基本定死了。这一章讲采样链路怎么选、怎么配、怎么把五路探头压成一个误差值。

3.1 数字量还是模拟量:先看你的赛道

红外对管(TCRT5000 这类)输出的是随反射光强连续变化的电压。数字量接法是在模块上用电位器加比较器,直接给出高/低电平;模拟量接法是把原始电压送进 ADC。两者差别很大:

维度数字量接法模拟量接法
硬件模块自带比较器,一路 GPIO需要 ADC 通道,5 路占 5 个引脚
分辨率只有 0/1,位置量化粗糙12 位,可做亚像素加权
阈值调整拧电位器,车上不好调改一个宏,可在线标定
抗干扰阈值附近会抖可做滑动平均、滞回
适合场景直角弯、粗线、低速弧线、细线、需要平滑转向

如果赛道只有直角弯和直线,数字量五路够用,误差只能取 -2 到 +2 五档,转向会一顿一顿的。想要车在 S 弯上贴着线走,就必须上模拟量加权——把五路 ADC 值当成权重,算出一个连续的误差,PID 才有东西可调。代码包如果同时留了两套接口,先用数字量跑通,再切模拟量,这个顺序别反过来。

3.2 定时器触发 ADC + DMA:让采样不占 CPU

模拟量采样的推荐做法是定时器触发 + DMA 搬运,CPU 完全不参与。这样主循环里读到的永远是一组刚刚刷新的数组,不会出现“读到一半被新数据覆盖”的脏数据。

/* BSP/line.c —— ADC1 多通道扫描 + 定时器触发 + DMA 循环搬运 */ static uint16_t s_adc_raw[LINE_SENSOR_NUM]; void Line_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; /* 多通道扫描 */ hadc1.Init.ContinuousConvMode = DISABLE; /* 由 TIM2 触发 */ hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.NbrOfConversion = LINE_SENSOR_NUM; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.DMAContinuousRequests = ENABLE; /* DMA 循环模式 */ HAL_ADC_Init(&hadc1); /* 5 个通道的采样顺序与采样时间,长采样时间换稳定性 */ for (uint32_t ch = 0; ch < LINE_SENSOR_NUM; ch++) { ADC_ChannelConfTypeDef cfg = {0}; cfg.Channel = s_channel_map[ch]; cfg.Rank = ch + 1; cfg.SamplingTime = ADC_SAMPLETIME_71CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &cfg); } HAL_ADC_Start_DMA(&hadc1, (uint32_t *)s_adc_raw, LINE_SENSOR_NUM); }

关键参数逐个说。ADC_CLOCK_SYNC_PCLK_DIV4:ADC 时钟不能超过芯片手册上限,72 MHz 四分频到 18 MHz 是稳妥值,分频太小采样值会飘。ScanConvMode = ENABLE加NbrOfConversion = 5:一次触发依次转换五个通道,顺序由Rank决定,DMA 按顺序填数组,所以s_adc_raw[0]永远对应第一路探头,这个映射关系一旦定下就不能改。ExternalTrigConv = T2_TRGO:定时器 2 的更新事件作为触发源,把采样率锁死在硬件上,不受主循环影响。DMAContinuousRequests = ENABLE:DMA 循环模式,采完自动回到数组开头,不需要在回调里重启。

采样率怎么定:定时器 2 配成 200 Hz(周期 5 ms),正好和主循环里 5 ms 的Line_UpdateError()对齐。采样太快没有意义,红外对管的上升沿本身就有几百微秒。

3.3 加权误差:五路探头压成一个连续值

这是整份源码里最值得抄的一段。原理很简单:给每路探头一个位置权重,判黑的探头参与加权平均,得到一个带符号的误差,负表示线偏左,正表示线偏右。

/* App/line.c —— 加权误差计算 */ static const int16_t kWeight[LINE_SENSOR_NUM] = { -2000, -1000, 0, 1000, 2000 }; static int16_t s_last_err = 0; int16_t Line_UpdateError(void) { int32_t sum = 0, wsum = 0; for (int i = 0; i < LINE_SENSOR_NUM; i++) { /* 白底黑线:反射弱,ADC 值大即为黑 */ int16_t on = (s_adc_raw[i] > LINE_ADC_THRESHOLD) ? 1 : 0; sum += on; wsum += on * kWeight[i]; } if (sum == 0) { /* 全白:丢线 */ s_lost_ms += 5; return s_last_err; /* 保持上次误差,交给超时保护 */ } if (sum == LINE_SENSOR_NUM) { /* 全黑:十字或终点 */ return 0; /* 误差归零,直行通过 */ } s_lost_ms = 0; s_last_err = (int16_t)(wsum / sum); return s_last_err; }

权重取 ±2000 / ±1000 / 0,是因为后面 PID 的kp按这个量级标定。如果换成 0~4 的权重,kp就得放大 500 倍,参数看起来会很别扭,但效果一样。权重绝对值越大,边缘探头对误差的拉动越强,车过弯越“急”;想让它柔和一点,把 ±2000 缩到 ±1500 即可。

sum == 0分支里那句s_lost_ms += 5是配合前面的LINE_LOST_TIMEOUT_MS用的:连续丢线超过 120 ms,控制层会强制把速度降到零。别小看这个,脱线后车如果还按上次误差全速打方向,几秒钟就能把自己转到电池耗尽。

注意:阈值LINE_ADC_THRESHOLD不能拍脑袋填 2048。正确做法是把车悬空读一遍全白值,压在黑线上读一遍全黑值,取两者中点,再往下偏 10% 左右,宁可把灰判成黑,也不要漏判。

4. 电机侧:双 H 桥真值表、PWM 配置与增量式 PID

传感器给出误差,最终要靠两个轮子的转速差表达出来。这一章从接线讲到 PID 输出。

4.1 方向控制真值表:接反一根线,调参全白费

双 H 桥模块(L298N、TB6612 这类)的控制逻辑大同小异:两路方向脚 + 一路 PWM。方向脚组合决定正反转,PWM 决定快慢。先把真值表贴墙上:

IN1 / AIN1IN2 / AIN2PWM电机状态
10占空比正转,速度随占空比
01占空比反转,速度随占空比
00任意滑行停车(自由停)
11任意刹车(短接制动)

刹车和滑行的区别很多人忽略。急弯时用刹车模式能让车更快地把横向速度吃掉,用滑行则会多滑一段。代码包里如果只实现了“正转/反转”,可以在停车函数里把两路方向脚同时置 1,改成刹车。

/* BSP/motor.c —— 单轮驱动,带死区补偿 */ void Motor_SetDuty(MotorId_t id, int16_t duty) { /* duty 范围 -SPEED_MAX ~ +SPEED_MAX,符号表示方向 */ uint16_t pwm; GPIO_TypeDef *port = (id == MOTOR_L) ? MOTOR_L_PORT : MOTOR_R_PORT; uint16_t in1 = (id == MOTOR_L) ? MOTOR_L_IN1 : MOTOR_R_IN1; uint16_t in2 = (id == MOTOR_L) ? MOTOR_L_IN2 : MOTOR_R_IN2; if (duty > 0) { HAL_GPIO_WritePin(port, in1, GPIO_PIN_SET); HAL_GPIO_WritePin(port, in2, GPIO_PIN_RESET); pwm = (uint16_t)duty; } else if (duty < 0) { HAL_GPIO_WritePin(port, in1, GPIO_PIN_RESET); HAL_GPIO_WritePin(port, in2, GPIO_PIN_SET); pwm = (uint16_t)(-duty); } else { HAL_GPIO_WritePin(port, in1, GPIO_PIN_RESET); HAL_GPIO_WritePin(port, in2, GPIO_PIN_RESET); pwm = 0; } /* 死区补偿:占空比低于 60 时电机根本转不动,直接抬到 60 */ if (pwm > 0 && pwm < 60) pwm = 60; __HAL_TIM_SET_COMPARE(&htim3, (id == MOTOR_L) ? TIM_CHANNEL_1 : TIM_CHANNEL_2, pwm); }

死区补偿这一段是血泪经验。直流减速电机在低占空比下有静摩擦,给 20、30 的占空比电机只是嗡嗡响不转,PID 以为输出生效了,继续积分,等占空比攒到能转的时候车猛地窜出去。把低于 60 的值直接抬到 60,等于人为削掉这段非线性区。60 这个数不是通用的,跟电机、减速比、电池电压都有关,我一般是让车悬空,从 0 开始以 5 为步进往上加,记录轮子刚开始转的那个值,再取它的 1.2 倍。

4.2 定时器 PWM:频率选 1 kHz 的理由

PWM 频率太低(几十赫兹)电机会发出明显啸叫并且转速脉动,太高(20 kHz 以上)又会因为 H 桥开关损耗发热。1 kHz 到 8 kHz 是这类小车的常用区间,代码包里一般配 1 kHz。

/* 1 kHz PWM:72 MHz / (71+1) = 1 MHz 计数频率,再 / (999+1) = 1 kHz */ void MotorPwm_Init(void) { htim3.Instance = TIM3; htim3.Init.Prescaler = 71; /* PSC */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = MOTOR_PWM_PERIOD; /* ARR = 999 */ htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&htim3); TIM_OC_InitTypeDef oc = {0}; oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 0; oc.OCPolarity = TIM_OCPOLARITY_HIGH; oc.OCFastMode = TIM_OCFAST_DISABLE; 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和Period = 999这两个数要会自己算:计数频率 = 72 MHz / (71 + 1) = 1 MHz,PWM 频率 = 1 MHz / (999 + 1) = 1 kHz。想要 8 kHz,把 ARR 改成 124 就行,但占空比分辨率会从 1000 档掉到 125 档,PID 输出的量化误差变大,直道容易抖。这是个取舍,我一般留在 1 kHz。

AutoReloadPreload = ENABLE的作用是让新的 ARR 值在下一个周期才生效,避免改周期时出现一个畸变脉冲。改占空比走__HAL_TIM_SET_COMPARE,它是直接写 CCR 寄存器,下一周期生效,不会撕裂波形。

4.3 增量式 PID:为什么不用位置式

循迹小车的被控量是“线相对车头中心的偏移”,执行量是“左右轮速差”。这个场景用增量式 PID 更合适:输出的是本次的调整量 Δu,累加到上一次的输出上。好处是切换模式(比如从循迹切到停车)时不会有积分残留导致的猛冲。

/* App/pid.c —— 增量式 PID,带输出限幅 */ typedef struct { float kp, ki, kd; float e1, e2; /* 前两次误差 */ int16_t out; /* 当前输出(差速量) */ int16_t out_max; } IncPid_t; int16_t IncPid_Update(IncPid_t *p, int16_t target, int16_t actual) { float e0 = (float)(target - actual); /* 增量公式:Δu = Kp*(e0-e1) + Ki*e0 + Kd*(e0-2*e1+e2) */ float du = p->kp * (e0 - p->e1) + p->ki * e0 + p->kd * (e0 - 2.0f * p->e1 + p->e2); p->e2 = p->e1; p->e1 = e0; int32_t next = p->out + (int32_t)du; /* 抗积分饱和:输出撞限幅时不再继续累加同方向的量 */ if (next > p->out_max) next = p->out_max; if (next < -p->out_max) next = -p->out_max; p->out = (int16_t)next; return p->out; }

target恒为 0(希望误差为零),actual就是第 3 章算出来的加权误差。输出p->out是差速量,叠加到基础速度上:左轮 =SPEED_BASE - out,右轮 =SPEED_BASE + out,再各自做限幅。这三个参数的量级关系是:kp决定回正力度,kd抑制过冲,ki消除稳态偏差。加权误差满量程约 ±2000,kp取 18 意味着误差 100 时产生 1800 的差速量,已经接近满速,所以起步时kp不要超过 25,否则一有偏差车就猛打方向。

注意:du里减2.0f * p->e1这部分是微分项,它对误差的跳变极其敏感。如果传感器采样有毛刺(比如某一路偶尔误判),微分项会瞬间放大成一个大脉冲。代码包里如果没做滤波,可以在Line_UpdateError()返回前加一层中值滤波,三个值取中间那个。

5. 避坑与排查:五类翻车现场的现象、原因和改法

下面这些每一条我都在不同板子上真实遇到过,按“现象 → 原因 → 解决”写,照着对照能省不少时间。

5.1 传感器侧:全白不刹车、全黑乱转向

现象一:把车拎起来(全白无反射)通电,电机仍然全速转,一落地就冲出去。原因:Line_UpdateError()里全白分支直接return s_last_err,而上电时s_last_err初值是 0,PID 认为没有误差,基础速度照常输出。丢线保护的超时计数虽然加了,但控制层没读它。 解决:在Car_ControlStep()开头先判丢线,超时就降速停车,别等 PID 算完再处理。

void Car_ControlStep(void) { int16_t err = Line_GetError(); if (Line_IsLost() && Line_GetLostMs() > LINE_LOST_TIMEOUT_MS) { Motor_SetDuty(MOTOR_L, 0); /* 强制停车 */ Motor_SetDuty(MOTOR_R, 0); IncPid_Reset(&g_pid); /* 清掉历史误差,防止下次启动猛冲 */ return; } int16_t diff = IncPid_Update(&g_pid, 0, err); Motor_SetDuty(MOTOR_L, SPEED_BASE - diff); Motor_SetDuty(MOTOR_R, SPEED_BASE + diff); }

IncPid_Reset()必须实现,把e1、e2、out全清零。少了这一步,停车后重新上线,PID 手里的历史误差还在,第一帧就会给出一个很大的调整量。

现象二:过十字路口时车突然急转,甚至掉头。原因:十字路口五路探头同时压黑,sum == LINE_SENSOR_NUM,如果代码里没单独处理这个分支,加权平均会算出接近 0 的误差,看起来没问题;但如果权重设计成不对称,或者某一路探头灵敏度偏高,就会算出一个偏大的误差。更隐蔽的情况是十字路口有一侧反光,误判成全白。 解决:全黑单独判为“直行通过”,并且在进入全黑状态时锁存上一帧的方向,出十字后按锁存方向继续,而不是重新按当前误差找线。

现象三:同样的场地,上午跑得好,下午开始左右摆。原因:红外对管的输出受环境光影响很大。下午阳光斜射进场地,接收管饱和,全白时的 ADC 值被抬高,原来 2200 的阈值不再适用,部分探头对黑线也不再有明显反应。 解决:加阈值自适应。车在直道上跑的时候,周期性统计五路的最小值和最大值,动态调整阈值;或者简单点,在车底加一圈遮光围裙,把环境光挡掉,这招便宜且有效。

5.2 电机与电源侧:单轮不转、堵转烧管

现象四:一侧轮子转,另一侧只嗡不转,用手拨一下才转起来。原因:不是电机坏了,是死区。这一侧电机的静摩擦比另一侧大,占空比 40 时另一侧能转它不能。PID 看到车往一边偏,继续加大这一侧输出,但输出还没过死区,等过了死区又猛冲。 解决:一是加死区补偿(第 4 章那段),二是把两侧电机的死区值分别标定后写进配置,不要共用一个数。还有个容易忽略的点:电池电压下降后死区会变大,满电标定的 60,电量剩一半时可能要 80 才能转,所以死区补偿最好按电压做一次修正。

现象五:跑几分钟后 H 桥模块发烫,偶尔整车复位。原因:电机启动瞬间电流是额定值的数倍,加上循迹时频繁正反转,H 桥一直在承受冲击电流,电源被拉低触发欠压复位。共地处理不好也会这样:电机地和逻辑地分开走,最后只在电源处一点相连,如果代码包的接线图里把两个地随便接在一起,干扰会顺着地线窜进 ADC。 解决:电源侧并大电容(几百微法)吸收启动电流,逻辑侧单独用一路稳压,地线单点汇合;软件上限制占空比变化率,别让 PID 输出在一帧内从 0 跳到 800。

/* 占空比斜率限制:每 1 ms 最多变化 25,防电流冲击 */ static int16_t s_last_l = 0, s_last_r = 0; static int16_t RampLimit(int16_t target, int16_t last, int16_t step) { if (target > last + step) return last + step; if (target < last - step) return last - step; return target; }

step取 25 是试出来的,太小车响应迟钝,太大起不到限流作用。这个函数放在 PID 输出之后、写寄存器之前。

5.3 控制与调试侧:抖振、积分饱和、串口一开就失控

现象六:直道上车头左右高频摆动,幅度不大但频率很高。原因:kd太大,或者传感器采样有毛刺被微分项放大。也可能是控制周期不固定,HAL_Delay和HAL_GetTick混用导致时间片忽长忽短。 解决:先把kd降到 0 看摆不摆,不摆就是微分项的问题;仍然摆就查采样,把 ADC 值打出来看有没有单点跳变,加个三点中值滤波。控制周期用HAL_GetTick()差值的写法,别用HAL_Delay卡节拍。

现象七:接上串口调试助手开始打印,车立刻失控。原因:串口中断里做了printf格式化,一帧几十个字节在中断里阻塞发送,把 1 ms 控制环挤掉了。或者串口引脚和电机 PWM 引脚在同一个端口,配置时互相覆盖。 解决:中断里只把数据塞进环形缓冲区,发送用 DMA,主循环里检查 DMA 是否空闲再发下一帧。格式化输出改用轻量的整数转字符串,别用浮点printf,那玩意儿在 Cortex-M 上耗时惊人。

现象八:改了一个 PID 参数,重新编译下载,效果反而更差,改回去也不对了。原因:参数改了但没清历史状态。增量式 PID 的e1、e2是跨帧延续的,改参数瞬间输出会有跳变。另外有些工程把参数存在 Flash 里,改宏定义没用,实际生效的是上次写入的值。 解决:调参时每次下载后先让车静止上电,等 PID 状态清零再跑;确认参数来源只有一个(要么宏,要么 Flash,别两套并存)。

6. 调参与验收:从一圈 15 秒压到 9 秒的标定顺序

调参最忌讳一上来就动kp。我习惯按“先静态、再开环、后闭环”三段走,每一段都有明确的过关标准,不过关不进入下一段。

6.1 三段式标定顺序

第一段是静态标定,车架空,轮子不着地。用串口把五路 ADC 原始值和算出来的误差打出来,用手拿一张黑卡纸从最左缓慢移到最右,观察误差是否从 -2000 连续变到 +2000、中间有没有跳变或卡死。这一步能揪出阈值不对、通道映射反了、权重写错三类问题。

第二段是开环标定,还是架空,PID 关掉,直接用固定占空比驱动两个轮子。从 0 开始以 10 为步进加到 300,记录每个轮子开始转动的临界值,写进死区配置。然后给同样的占空比,看两个轮子的转速是否接近,差得多就在软件里给慢的一侧乘一个补偿系数。这一步不做,闭环调参时会一直跟左右不平衡较劲。

第三段才是闭环。先只开kp,从 5 开始加,加到车能跟线但明显过冲为止;再加kd压过冲,从 0 加到直道不摆;最后加一点点ki(0.1 到 0.5 之间)消除弯道出口的稳态偏差。每一轮只改一个参数,改完至少跑三圈再判断。

阶段过关标准常见失败点
静态标定误差随黑卡纸连续变化,无跳变通道顺序错、阈值偏移
开环标定两轮同占空比转速接近死区未补偿、轮径差异
闭环kp能跟线,允许过冲kp过大导致画龙
闭环kd直道不摆,弯道不甩kd过大放大噪声
闭环ki弯道出口不偏ki过大引起低频振荡

6.2 用串口数据回放做最后验证

最后一步别靠眼睛看。让车跑一圈,串口把每一帧的误差、PID 输出、左右占空比按固定格式发出来,存成文本,回来对着数据看:直道段误差应该围绕 0 小幅波动;弯道段误差应该是一个平滑的斜坡;如果看到误差在正负之间高频翻转,就是抖振;如果看到误差长时间贴着满量程不回来,就是丢线或者转向不足。数据比眼睛诚实得多。

我吃过一次亏:车在场地上看着跑得挺稳,结果换了个地面材质(从哑光地胶换到亮面地板),反光变了,全白值整体抬高 400,原来标定的阈值直接失效,车一上场就往一边偏。从那以后我每次换场地,都强制走一遍静态标定,先用黑卡纸把阈值重新扫一遍再上车,哪怕只花两分钟。这套源码的结构把阈值和参数都抽到了配置头文件里,改起来不用翻业务代码,算是省了不少事。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进