STM32红外循迹小车源码解析:采样、PID与电机控制
发布时间:2026/10/30 8:46:24 锦皓数字建站

简介围绕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 msPID 的微分项就会被放大三倍车在直道上开始左右高频抖。所以拿到工程第一步不是改 PID是确认时钟。STM32 常见的配置是外部 8 MHz 晶振经 PLL 倍频到 72 MHz 作为系统时钟AHB 不分频APB1 二分频到 36 MHzAPB2 不分频到 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_DIV4ADC 时钟不能超过芯片手册上限72 MHz 四分频到 18 MHz 是稳妥值分频太小采样值会飘。ScanConvMode ENABLE加NbrOfConversion 5一次触发依次转换五个通道顺序由Rank决定DMA 按顺序填数组所以s_adc_raw[0]永远对应第一路探头这个映射关系一旦定下就不能改。ExternalTrigConv T2_TRGO定时器 2 的更新事件作为触发源把采样率锁死在硬件上不受主循环影响。DMAContinuousRequests ENABLEDMA 循环模式采完自动回到数组开头不需要在回调里重启。采样率怎么定定时器 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 PWM72 MHz / (711) 1 MHz 计数频率再 / (9991) 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 MHzPWM 频率 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*e1e2) */ 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消除稳态偏差。加权误差满量程约 ±2000kp取 18 意味着误差 100 时产生 1800 的差速量已经接近满速所以起步时kp不要超过 25否则一有偏差车就猛打方向。注意du里减2.0f * p-e1这部分是微分项它对误差的跳变极其敏感。如果传感器采样有毛刺比如某一路偶尔误判微分项会瞬间放大成一个大脉冲。代码包里如果没做滤波可以在Line_UpdateError()返回前加一层中值滤波三个值取中间那个。5. 避坑与排查五类翻车现场的现象、原因和改法下面这些每一条我都在不同板子上真实遇到过按“现象 → 原因 → 解决”写照着对照能省不少时间。5.1 传感器侧全白不刹车、全黑乱转向现象一把车拎起来全白无反射通电电机仍然全速转一落地就冲出去。原因Line_UpdateError()里全白分支直接return s_last_err而上电时s_last_err初值是 0PID 认为没有误差基础速度照常输出。丢线保护的超时计数虽然加了但控制层没读它。 解决在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 加到直道不摆最后加一点点ki0.1 到 0.5 之间消除弯道出口的稳态偏差。每一轮只改一个参数改完至少跑三圈再判断。阶段过关标准常见失败点静态标定误差随黑卡纸连续变化无跳变通道顺序错、阈值偏移开环标定两轮同占空比转速接近死区未补偿、轮径差异闭环kp能跟线允许过冲kp过大导致画龙闭环kd直道不摆弯道不甩kd过大放大噪声闭环ki弯道出口不偏ki过大引起低频振荡6.2 用串口数据回放做最后验证最后一步别靠眼睛看。让车跑一圈串口把每一帧的误差、PID 输出、左右占空比按固定格式发出来存成文本回来对着数据看直道段误差应该围绕 0 小幅波动弯道段误差应该是一个平滑的斜坡如果看到误差在正负之间高频翻转就是抖振如果看到误差长时间贴着满量程不回来就是丢线或者转向不足。数据比眼睛诚实得多。我吃过一次亏车在场地上看着跑得挺稳结果换了个地面材质从哑光地胶换到亮面地板反光变了全白值整体抬高 400原来标定的阈值直接失效车一上场就往一边偏。从那以后我每次换场地都强制走一遍静态标定先用黑卡纸把阈值重新扫一遍再上车哪怕只花两分钟。这套源码的结构把阈值和参数都抽到了配置头文件里改起来不用翻业务代码算是省了不少事。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。