资讯动态

STM32五路红外循迹小车:PWM差速与PID调参实战

发布时间:2026/10/30 3:08:15 来源:尧图企业网站定制
简介面向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~PB4GPIO 输入输出黑白电平电机驱动L298N 或 TB6612PA6/PA7 接 PWMPB5~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 主频PSC71 得到 1MHz 计数时钟ARR999 得到 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(); /* PA6CH1(左轮), PA7CH2(右轮)复用推挽 */ 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 次溢出一次正好 10kHzPulse 是比较值范围 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); }丢线处理的核心是“不要停也不要瞎转”。停车在赛道上等于弃权随机转向可能转到反方向越走越远。按最后一次有效偏差的方向继续修相当于赌车是从这一侧飞出去的成功率最高。十字处理的核心是“直行通过”因为十字路口五路全压线加权平均本来就是 0PD 输出 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_TYPERelease 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(raw0x%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 从小往大加kd0车能过弯直道轻微摆动2加 kd 压制摆动直道稳定过弯不甩尾3提高 base 速度高速下仍能过弯4微调 out_max急弯不冲出直道不抖每一步只改一个参数改完跑三圈再判断。同时改两个参数出现问题你无法归因。基础速度的初始值建议给最大速度的 40%这个速度下即使 PID 参数不完美车也来得及修正。等参数稳定了再逐步提速每次提 10%提到某个速度开始过弯不稳就退回去一档那就是这套底盘和这套参数的极限。还有一点电池电压会影响实际转速。满电和快没电时同样的占空比对应的转速差很多PID 参数在满电时调好电量掉到 7V 以下可能就过不了弯了。要么在代码里做电池电压检测并补偿要么接受这个衰减比赛前换满电电池。5. 避坑与排查跑偏、抖舵、丢线背后的五个真凶调试阶段遇到的问题八成集中在电气和算法两类。下面这五条是实际撞过的现象、原因、解决办法都写清楚遇到了可以对照着查。5.1 电气与供电类现象一上电后小车原地打转串口打印的传感器状态乱跳。原因是共地没做好。电机驱动和 MCU 各自供电但没有连 GNDPWM 信号的参考电平漂移驱动收到的占空比时大时小两个轮子转速不一致。解决是把电池负极、驱动 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 太小。另一种可能是权重法在多路压线时取了平均弯道上内侧两路同时压线平均后的误差反而变小车以为偏得不多。解决办法是在弯道状态下不给平均直接取最外侧那一路的权重牺牲平滑换响应速度。现象六补充过十字路口时车突然转向。原因是十字处五路全压线如果代码里对 cnt5 没有单独处理加权平均的结果虽然是 0但某些实现里会把全压线误判成“最左和最右同时压线”误差算出正负抵消的中间值之外的东西。解决是按 5.1 里的状态机cnt4 时强制 err0 并保持直行同时用连续 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 是在什么速度下标定的只能从头再调一遍。从那以后我每次开新工程第一件事就是把参数区建好、注释写满哪怕当时觉得麻烦。希望这套代码能帮你少走几段弯路把时间花在真正有意思的改造上。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑