智能小车主控训练代码:时序设计、PID与排查框架
一套智能小车摆在同一张桌子上硬件一模一样有人跑完整圈路线用时二十秒还稳稳不出界有人刚上电就原地画龙、撞墙、电机发烫。多数人第一反应是“买到了劣质传感器”但把两台车的主控训练代码拉出来对比问题往往出在代码的组织方式上有的代码把所有逻辑塞进一个主循环传感器读一次就立刻决定转向电机指令一拍一变有的代码分层清晰采集、计算、输出各有各的节拍。这篇内容就围绕百科智能小车的主控训练代码展开讲清楚这套代码到底在训练什么、每一层该怎么写、参数怎么定、出了问题按什么顺序排查。适合刚拿到小车套件、能照着例程点灯但还写不出稳定循迹逻辑的读者也适合带训练营、想给学员梳理一套可复用代码框架的人。下面所有内容都基于这类教育型智能小车的常见硬件配置展开涉及具体器件时只讲通用类别型号差异需要对照自己手上的板子。1. 训练代码训练的其实是“时序”不是“功能”很多人以为主控训练代码就是把功能凑齐能前进、能转弯、能避障、能显示。功能凑齐了确实能跑但跑不稳。功能是“做什么”时序是“什么时候做、隔多久做一次、做完等多久”。小车的绝大多数不稳定现象追到根上都是时序问题采样太慢导致循迹滞后控制周期抖动导致PID输出抖动通信阻塞导致避障响应延迟显示刷新占用主循环导致电机指令更新不及时。理解这一点后面的代码结构才有落脚点。1.1 先看看手里那块主控的资源够不够用动手写代码之前我习惯先做一件事把主控的资源列一张表。训练代码里最容易发生冲突的不是引脚而是定时器。以常见的 Cortex-M3 内核、主频 72MHz 这一类主控为例通常有若干个通用定时器和一两个高级定时器每个定时器又分几个通道。电机 PWM 要占定时器通道编码器测速要占定时器编码器模式超声测距如果不用外部中断方案就要占输入捕获通道再加上一个毫秒滴答定时器做系统节拍资源很快就见底了。我见过不少同学一开始就把两路电机 PWM 分别挂在两个定时器上结果到了要接编码器的时候发现没有定时器可用了只能改成软件计数测速精度直接掉一个档次。资源盘点的具体做法是拿一张纸画表格列“外设—占用的定时器/通道—是否可复用”。电机 PWM 用同一个定时器的两个通道是最省资源的做法因为频率和计数周期天然一致占空比各自独立。编码器如果左右轮各需要一个定时器那就优先安排在基本定时器之外的通用定时器上别和 PWM 抢。剩余的通道再分给超声和滴答。这张表画完代码里所有初始化函数的顺序和参数基本就定了。提示训练阶段拿到一块新板子第一件事不是烧例程而是把芯片手册里定时器资源那一章翻出来对照原理图上电机驱动、编码器接口的实际连线先把冲突排掉。这一步花二十分钟能省掉后面两天的诡异 bug。1.2 三层结构驱动层、控制层、决策层各管一摊这套训练代码我建议采用三层结构不是为了“显得专业”而是为了让排错有明确的边界。驱动层只干一件事把硬件操作封装成函数比如motor_set(side, duty)、track_read_all(buf)、ultra_measure_cm()这一层不包含任何判断逻辑不做滤波不做决策输入什么就执行什么。控制层负责把传感器原始数据变成控制量包括滤波、归一化、误差计算、PID 运算输出的是电机目标速度或转向量。决策层是状态机负责判断当前该走“直行”“循迹”“避障”“停车”哪个状态以及状态之间的切换条件。这样分层的好处调试的时候一眼就能定位问题在哪一层。小车直行跑偏先看驱动层左右轮给同样的占空比转速是否一致这是机械和驱动层的问题如果一致再看控制层编码器反馈的速度是否对称如果也对称才去怀疑传感器和 PID。要是三层混在一起写一个跑偏现象可能要花半天才能排除掉一半的可能性。目录组织上我通常这样切/* 目录示意 */ app/ main.c // 调度与状态机 task_scheduler.c // 节拍调度 control/ speed_pid.c // 速度环 line_pid.c // 循迹环 filter.c // 滤波与限幅 driver/ motor.c // PWM 与方向 encoder.c // 测速 ir_track.c // 红外循迹 ultrasonic.c // 超声测距 oled.c // 显示 bsp/ bsp_timer.c // 滴答、定时器初始化 bsp_gpio.c bsp_uart.c驱动层函数尽量做到“幂等”同样的输入反复调用硬件状态不变不会因为调用两次就把电机反转。这一点在状态机里特别重要因为状态切换时可能连续好几次调用同一个驱动函数如果函数内部带自增或者带方向翻转计数动作就乱了。2. 主控和执行器之间的联系方式电机是智能小车上最“吃电流”的部件也是主控训练代码里最容易写出硬件损伤的部分。主控引脚输出的是逻辑电平几毫安级别驱动电机必须经过驱动芯片。教育套件里常见的是双 H 桥驱动一个芯片带两路电机每路需要方向控制和调速信号。这一段的代码看着简单实际上有三个坑方向切换的时序、PWM 频率的选择、起步的加速度处理。2.1 方向信号和占空比不是同时给的双 H 桥的方向逻辑通常是两个方向引脚加一个使能/PWM 引脚或者方向引脚加 PWM 组合成四种状态正转、反转、刹车两路同时拉高或拉低、滑行PWM 关闭。很多例程写的是“改方向引脚然后给 PWM”中间没有延时实际测下来在高速运行时突然反向驱动芯片会有短暂的直通风险电流瞬间冲高时间长了芯片发热明显。我的做法是加一个“方向切换保护”任何方向改变之前先把 PWM 占空比降到零延时几毫秒具体看驱动芯片手册里的关断时间常见是微秒到毫秒量级训练代码里给 2 到 5ms 足够保守再改方向引脚再重新给占空比。这个延时不需要精确定时用一个软件计数或者简单的忙等方式就够。这段保护逻辑放在驱动层的motor_set里上层调用者完全感知不到。操作顺序动作目的1占空比置 0关断输出避免带载换向2延时 2–5ms等待驱动内部关断3设置方向引脚切换桥臂状态4设置新的占空比重新输出另外方向引脚和 PWM 引脚的初始化顺序也有讲究。上电复位时引脚是浮空输入如果先初始化了方向引脚而且是高电平PWM 引脚还没配置可能出现短暂的异常状态。稳妥的顺序是先把所有相关引脚配置成推挽输出并输出低电平再配置定时器 PWM最后才使能驱动芯片如果驱动芯片有使能脚的话。2.2 PWM 频率的取舍不是越高越好PWM 频率的选择是个典型的“两头堵”问题。频率太低比如 1kHz电机绕组会发出明显啸叫而且低速时因为电感续流不充分转速波动大人耳能听到“哒哒”声。频率太高比如 40kHz驱动芯片的开关损耗上升发热增加同时定时器计数值变小占空比调节的分辨率下降。举个具体的算法。假设定时器时钟 72MHz预分频设为 71则计数频率为 1MHz计数周期设为 49则 PWM 频率 1MHz / 50 20kHz此时占空比只有 0 到 49 共 50 级也就是说最小调节步进是 2%。对于需要精细低速控制的循迹来说2% 的步进意味着刚起步就是 2% 的占空比可能已经超过了电机的启动死区要么不走要么一走就窜。我一般会把频率落在 15kHz 到 20kHz 之间这个区间超出人耳敏感范围听不到啸叫同时尽量把计数周期拉大一点换取分辨率。如果必须兼顾低频分辨率可以采取“频率分档”的策略低速段用 5kHz 加更大计数周期高速段切到 20kHz。这在训练代码里不一定要实现但知道这个取舍调参的时候就不会对着一个 50 级的分辨率硬调 PID。注意不同驱动芯片支持的最高开关频率差别很大有的芯片标称 20kHz实际在满载时已经明显发热。训练阶段用 15kHz 左右、配合散热片是比较稳的选择。2.3 起步像被踹了一脚是因为缺了斜坡电机从静止到目标速度如果直接给目标占空比由于静摩擦和反电动势实际转速曲线是一个陡峭的上升沿小车表现为“猛地一顿”才动起来。这个问题在循迹里尤其明显因为循迹环的输出是连续变化的如果每一拍都直接把 PID 输出当作占空比电机会在每次偏差变化时反复顿挫。解决办法是加一个速度斜坡本质上是一阶限幅每一拍的目标占空比最多变化一个固定的步长比如每 10ms 最多增加 3%。实现起来就是一个带限幅的累加器。更高阶的做法是一阶低通滤波out out (target - out) * alphaalpha 取 0.1 到 0.3 之间。两种方法都能平滑区别在于限幅法在长距离加速时是线性上升低通法是指数上升前者更容易预测到达时间后者更平滑。训练代码里我更倾向限幅法因为参数直观学员能算出来“从 0 到 60% 需要多少拍”。3. 传感器数据进入主控的节拍设计循迹和避障这两件事对数据的时效性要求完全不同。循迹要求高频因为小车在跑偏差几毫秒就变了避障要求的是可靠宁可贵几十毫秒也要拿到一个准的距离值。把这两类采集塞进同一个主循环并且串行执行结果就是超声测距的几十毫秒等待把循迹的节拍拖垮小车在线内来回摆动。3.1 红外循迹的阈值不能照抄必须现场标定红外循迹模块本质是发射红外光加接收反射光白底反射强、黑线反射弱输出可以是数字量也可以是模拟量。数字量模块自带比较器上面有个电位器可以调阈值问题是这个阈值随环境光、离地高度、电池电压变化换个场地就得重调。模拟量输出则是把反射强度直接送进 ADC由代码来判断黑白。我强烈建议训练代码用模拟量方案并且把标定过程做成代码里的一个独立模式。具体做法是让小车静止手动把每个探头依次放在白底和黑线上通过串口把 ADC 值打出来记录每个探头在两种底色下的典型值然后取中点作为阈值。五路探头的话就是五组阈值分别存进数组。这样做虽然啰嗦但比一个全局阈值可靠得多因为探头之间的一致性往往不好同一个白底五个探头读数可能差出 20%。标定完之后还有一步容易被忽略加权求偏差。五路探头按位置排开最左边权重 -2依次 -1、0、1、2检测到黑线的探头把权重加起来再除以检测到的数量得到的就是偏差量。全黑或者全白属于特殊情况全白说明丢线全黑可能是十字路口或者停车标记这两种情况要单独处理不能直接算偏差否则除零或者偏差跳变。/* 五路循迹加权偏差示意权重需按实际排布调整 */ static const int8_t weight[5] {-2, -1, 0, 1, 2}; int16_t sum 0; uint8_t cnt 0; for (uint8_t i 0; i 5; i) { if (ir_raw[i] threshold[i]) { /* 低于阈值视为黑线 */ sum weight[i]; cnt; } } if (cnt 0) { error last_error 0 ? 4 : -4; /* 丢线沿上次方向找线 */ } else if (cnt 4) { error 0; /* 全黑按路口处理 */ } else { error sum * 10 / cnt; /* 放大便于 PID 计算 */ } last_error error;error放大十倍是为了让 PID 的比例系数好取避免出现 0.03 这种小数定点运算里更好处理。3.2 超声测距的等待时间会阻塞主循环超声测距的工作方式是发一个触发脉冲然后等回响信号的高电平持续时间再按声速换算距离。等待回响的时间最长可能到几十毫秒比如测 4 米如果这段等待用阻塞延时写主循环就被卡住了。常见的入门代码就是这么写的触发、while等回响、算距离整个流程下来十几毫秒循迹节拍直接从 10ms 变成 25ms车就摆得厉害。改进方向有两个。第一个是用输入捕获让定时器硬件去记录回响的上升沿和下降沿主循环完全不等待采到一个数据就置一个标志位。第二个是状态机式采集触发之后马上返回下次调度进来再看回响引脚状态有没有变化超时就放弃这次测量。第一种精度高、占用资源多第二种实现简单、精度略差但够用。训练代码里我倾向第二种因为对学员来说理解“非阻塞采集”这个概念比理解输入捕获更重要。超时判断必须做而且是这类代码里最常见的遗漏点。没有障碍物的时候回响引脚根本不会拉高如果代码里写的是“等回响变高再等回响变低”程序就永远停在那里了。正确做法是设一个最大等待计数比如 30ms 还没等到上升沿就直接判定本次无效沿用上一次的有效值。滤波方面单次超声读数波动很大尤其在小车运动时车身倾斜会让声波打在地面上产生杂波。我的做法是连续采三次取中位数或者用滑动窗口取中位数。中位数比平均值好因为偶发的异常大值打到侧面墙壁的回波会被直接剔除平均值则会被拉偏。3.3 给每个任务分配时间片而不是排队执行主循环的调度方式直接决定小车的响应品质。最朴素的做法是顺序执行读循迹、读超声、算 PID、输出电机、刷显示一轮下来多久看运气。稍微好一点的做法是用滴答定时器累加计数给每个任务分配固定周期任务周期说明电机 PWM 更新10ms速度环周期太短没意义太长响应慢编码器测速10ms与速度环同周期先测后算红外循迹采样5ms循迹要求快可与电机周期错开超声测距状态机20ms非阻塞一次只推进一步显示刷新200ms人眼够用别占主循环串口调试输出100ms训练阶段需要比赛时关掉实现上用一个 1ms 的滴答中断累加几个计数器主循环里判断计数器是否到点到点执行并把计数器清零。这里有个细节主循环里所有任务的总耗时必须小于最短的任务周期否则会出现任务堆积。训练阶段可以在每个任务执行前后翻转一个空闲 GPIO用示波器看占空比一眼就能看出主循环的负载率。没有示波器就用一个全局变量记录主循环一圈的最大耗时通过串口打出来。4. PID 在这套代码里的真实分工一提到智能小车就绕不开 PID但很多人的困惑在于到底该用几个 PID速度环一个、循迹环一个这两个环的关系是什么简单说速度环管“左右轮各自转多快”循迹环管“左右轮的速度差该多大”。外层循迹环的输出是速度差内层速度环把速度差落实成具体的 PWM 占空比。串级结构比单环稳因为电机负载变化、电池电压下降这些扰动速度环自己就消化掉了不会传导到循迹环。4.1 位置式和增量式按环的性质分开选速度环我推荐增量式 PID。增量式的输出是“在上次输出基础上加多少”天然带积分效果而且不怕积分饱和失控因为每次只增量一点点。电机速度环的被控量是转速输出是占空比增量式写起来很顺手/* 增量式速度环示意 */ float speed_pid_inc(pid_t *p, float target, float actual) { float err target - actual; float inc p-kp * (err - p-e1) p-ki * err p-kd * (err - 2 * p-e1 p-e2); p-out inc; if (p-out p-out_max) p-out p-out_max; if (p-out p-out_min) p-out p-out_min; p-e2 p-e1; p-e1 err; return p-out; }循迹环我推荐位置式因为它的输出直接就是转向量左右轮速度差物理意义明确限幅也好做。位置式要处理积分饱和当输出已经达到限幅上限误差还在同方向累积积分项就会越攒越大等偏差反向时输出要很久才能退出来表现为小车冲出线外很远才往回拐。处理办法是“积分分离”或者“遇限停止积分”输出饱和时不再累加积分项。4.2 先只上 P让车摆起来再压下去调参顺序这件事说多少遍都不为过先把 Ki 和 Kd 清零只留 Kp从小到大加直到小车开始沿着线左右摆动。这个摆动是正常的说明 P 已经足够大到能感知偏差了。然后把 Kp 回调到摆动刚好消失的位置记下这个值通常再往回收 10% 到 20% 作为最终值留一点余量。接着上 Kd。Kd 的作用是“预判”偏差变化越快反向修正越强能明显压制过冲。加 Kd 的时候从小往大试加到小车过弯不再甩出去为止。Kd 加过头的表现是车身高频抖动像筛糠一样这时候回调。最后才考虑 Ki而且很多循迹场景根本不需要 Ki因为纯循迹是跟着偏差走的没有稳态误差的概念。只有在直线段总是往一侧偏机械不对称、电机转速不一致的时候加一点 Ki 来补偿。4.3 参数不是调出来的是算出来的起点纯靠试凑调参效率低可以先算一个理论起点。以循迹环为例假设传感器采样周期 T5ms希望偏差在 100ms 内被修正大半那么 Kp 的量级大约是“输出满量程 / 最大偏差 × (T/修正时间)”。具体数值受整车重量、电机功率影响很大但量级上能给你一个起点避免从 0.001 开始盲目试。我在训练营里会让学员填一张调参记录表把每次改动的参数和现象记下来。这张表比任何教程都有用因为它是针对你自己那台车的轮次KpKiKd现象结论10.500直线微摆偏小21.200明显摆动接近临界31.000轻微摆动保留41.000.8摆动收敛Kd 有效51.002.5高频抖动Kd 过大61.001.5过弯略甩继续加71.002.0过弯稳定稿提示电池电压下降会让同样的 PID 参数表现变差因为电机实际转速降低了。训练阶段每次跑之前用万用表量一下电池电压低于额定值 15% 就充电否则你调好的参数第二天就“失效”了白折腾。5. 训练任务怎么拆从单点验证到整圈跑通拿到套件之后最常见的错误是“上来就写完整程序”写完烧进去车不动然后开始一行行猜。正确的推进方式是分阶段验收每个阶段都有明确的通过标准前一个阶段不过不往下走。这套流程我是从一个做教学设备的团队那里学来的他们把训练拆成四个阶段每个阶段只验证一件事。5.1 第一阶段轮子转起来方向是对的这一阶段只写驱动层不接任何传感器。验收标准有三条给左右轮分别发 30% 占空比正转两个轮子都向前转发反转都向后转发零轮子停。很多同学在这一步就翻车表现为一侧正转一侧反转原因是电机线接反了或者驱动芯片的方向定义写反了。这个错误如果不在这里解决后面循迹的时候会表现为“越修越偏”你会怀疑 PID其实是硬件方向错了。这一阶段还要验证占空比和转速的单调性给 10%、20%、30% 一直到 100%轮子转速应该单调上升。如果出现 10% 不转、20% 猛转的情况说明电机的启动死区在这个区间后面低速循迹的时候要记住这个下限别让 PID 输出掉到死区里。5.2 第二阶段定速直行先把速度环调出来接上编码器写测速。测速的方法是在固定周期内读编码器计数值乘以换算系数得到转速。换算系数要实测让车在一个平地上跑固定时间量走过的距离反推出每脉冲对应的位移。这个系数不准速度环的目标值就没有意义。速度环调好之后做定速直行测试给左右轮相同的目标速度让车在直线跑三米看横向偏移。偏移在几厘米内算合格。如果总是往一侧偏先检查机械轮子是不是装歪了、地面是不是平的再检查编码器计数是否对称最后才用速度环的偏置来补偿。这一阶段还有一个必测项堵转保护。用手按住轮子看代码有没有电流限制或者超时停机的逻辑。训练用的驱动芯片一般没有硬件过流保护靠代码里监测“目标速度和实际速度长期差距过大”来判定堵转超过一定时间就降占空比或者停机。这个逻辑不加学员一个操作失误就能把电机烧了。5.3 第三阶段单元素循迹先跑直线和单弯不要一上来就跑完整赛道。先跑一段直线验证丢线找回逻辑再跑一个 90 度弯验证转向幅度够不够最后跑 S 弯验证 PID 的响应速度。每个元素单独跑通了再组合。这个阶段最容易出现的现象是“直道很稳一进弯就冲出去”。原因通常是转向量上限设得太小或者 Kp 不够。诊断方法很简单在弯道入口处手动把车摆到最大偏差位置看 PID 输出的转向量是多少对照电机能接受的最大速度差如果转向量已经饱和了车还是转不过来那就是物理上转不过来得降速过弯。降速过弯的实现方式是在决策层加一个“弯道检测”当偏差绝对值连续几拍超过阈值就把基础速度降下来。注意这个降速要平滑突然降速会让车头点头反而容易丢线。5.4 第四阶段组合赛道加故障注入整圈跑通之后训练并没有结束。真正让代码变稳的是故障注入故意遮挡一个传感器看车怎么反应故意在赛道中间放一个障碍物看避障介入是否及时故意把电池电压降到临界值看行为是否退化故意拔掉一根传感器线看程序会不会卡死。这些测试在比赛前做一遍能发现大部分现场可能遇到的意外。我在一次模拟项目里遇到过这样的情况赛道上有一块反光很强的贴纸红外循迹在那里读数全部失真车直接冲出去。后来加了一条逻辑——连续 N 拍读到的循迹模式如果是不合法的组合比如最左和最右同时黑、中间全白就判定为“传感器异常”降速并按上一次有效偏差继续走同时点亮告警。这条逻辑在正常赛道上永远用不到但就是它救了那一场比赛。6. 上电就跑偏一套从上到下的排查链路代码写完跑不顺最忌讳的是“凭感觉改参数”。我习惯按固定链路走一遍每一层确认没问题再往下一层。这条链路是我踩了无数次坑之后总结的顺序很重要因为越靠前的层出问题症状越像后面的层。6.1 先把现象分类别急着看代码现象分类是排查的起点。同样是“跑偏”有可能是机械偏移、有可能是驱动不对称、有可能是传感器标定错误、有可能是 PID 参数问题症状相似但根因完全不同。先用下面这张表粗筛一遍能砍掉一大半的错误方向现象高概率根因首查位置上电不动无任何反应供电、使能脚、程序没跑起来电源与复位一轮转一轮不转驱动通道、接线、PWM 通道配置驱动层直行跑偏但循迹正常机械不对称、编码器系数机械与测速循迹时左右大幅摆动采样周期抖动、Kp 过大调度与 PID直线稳、弯道冲出转向量限幅、基础速度过高决策层跑一段时间后失稳电池电压下降、驱动芯片过热电源与散热偶发死机或复位看门狗、电源跌落、栈溢出系统层6.2 从电源查起这一层最容易背锅也最容易真出问题电机启动瞬间的电流是稳态的好几倍如果电池内阻大或者接线细启动瞬间主控供电会被拉低轻则 ADC 采样值乱跳重则主控复位。表现就是“小车一动就重启”或者“跑着跑着突然停车”。排查方法是用万用表或者示波器盯着主控的供电脚让电机从静止突然给满占空比看电压跌落多少。跌落超过 0.3V 就要考虑加电容或者换更粗的线。另一个常见问题是共地。主控和驱动芯片如果各自用一组电源两组地必须连在一起否则 PWM 信号没有共同的参考电平表现为驱动时好时坏、占空比不受控。这个坑很隐蔽因为万用表量电压都正常就是逻辑不对。6.3 通信和看门狗别让调试接口拖垮实时性训练阶段习惯用串口打印调试信息printf在阻塞模式下每打一行可能占用几毫秒如果放在控制周期里直接把节拍打乱。我见过最典型的案例是学员在速度环里加了一句打印目标速度和实际速度结果小车疯狂摆动删掉打印就正常了。解决办法是把调试信息先存进环形缓冲区在主循环空闲时批量发出或者干脆只在低优先级的慢任务里打印。看门狗要不要开取决于阶段。调试阶段可以关掉或者把超时设长避免一进断点就复位。整圈跑通之后必须开而且要确认喂狗的位置在主循环里不是在中断里——如果在中断里喂狗主循环卡死了狗照样被喂保护就形同虚设。6.4 一个反直觉的结论大多数“算法问题”其实是标定问题带过几期训练之后我发现一个规律学员报上来的“PID 调不好”十个里有六七个最后查出来是传感器标定或者测速系数的问题。循迹阈值用的是一周前在另一个场地标定的值换个地面反射率变了偏差计算全错PID 怎么调都摆。编码器系数是照抄别人的轮径不一样速度环的目标值和实际速度差了一大截表现出来就是“响应迟钝”。所以我的建议是每次换场地、换电池、换轮胎之后重新跑一遍标定流程把阈值和系数重新写进参数区。这套流程写成代码里的一个“标定模式”上电时长按某个按键进入串口引导一步步做比翻笔记手动改宏定义靠谱得多。最后分享一个小技巧在代码里留一个“黑匣子”用一个固定大小的环形缓冲区记录最近若干拍的循迹原始值、PID 输出、电机占空比和电池电压出问题的时候通过串口一次性 dump 出来。小车冲出去的那一瞬间到底发生了什么看一眼这段数据就清楚了比反复跑车猜原因高效太多。这个缓冲区不用大几百字节就够但对调试效率的提升是数量级的。