做智能小车这类项目真正耗时间的从来不是把电机、轮子、传感器拧上去而是拧上去之后代码怎么组织。我见过太多队伍硬件搭得漂漂亮亮上电之后电机一转循迹就飘超声波一扫主控直接复位调了两天参数最后发现是电池电压掉了。这套「百科智能小车」的主训练代码本质上就是把这些零零碎碎的问题收拢成一套可复现的工程骨架——它管的是底层驱动怎么封、传感器数据怎么滤波、循迹 PID 怎么整定、避障和循迹怎么用状态机错开、参数怎么在线改而不用反复烧录。适合刚接触嵌入式小车、能看懂 C 语言基础语法、手里已经有一台差速小车但代码写得比较随意的朋友也适合带训练任务、需要一套统一代码模板的人。下面我按自己实际搭车、调车、翻车的顺序把整套东西拆开讲。1. 先划清功能边界这套训练代码到底管什么1.1 从「能动」到「跑得稳」中间隔着三层能力很多人对智能小车的期待是线性叠加的能前进后退算入门能循迹算进阶能避障算高级。实际做下来会发现这三件事根本不在一个维度上。让轮子转起来只需要几行给 PWM 的代码半天就能搞定让小车沿黑线稳定跑完一圈牵扯到传感器阈值、采样节拍、偏差计算、输出限幅、积分饱和可能要磨一周而让它在跑的过程中遇到障碍物能停下来再绕开、绕开之后还能找回黑线本质上是多任务调度问题跟前面两件事完全不是一个工种。所以我在动手写代码之前会先把训练代码的能力边界写成一张清单明确哪些是这一版必须做到的哪些是留给后续扩展的。这套「百科智能小车」主训练代码里我把功能切成三层运动执行层负责把「左轮多少速度、右轮多少速度」翻译成 GPIO 电平和 PWM 占空比感知决策层负责把灰度、超声波、编码器的原始读数变成「当前偏差多少、前方多远有障碍」任务调度层负责决定此刻该听谁的——是继续循迹还是切到避障还是响应遥控指令。三层之间只通过固定的数据结构交互上层不直接碰寄存器下层不关心自己被谁调用。这么切的直接好处是排错变快。小车跑偏了你只需要判断是执行层输出不对称左右电机特性不一致、感知层偏差算错了阈值标定问题还是调度层切错了模式状态机跳转条件写反。如果没有分层一个「跑偏」能让你从电机查到电源再查到代码逻辑一整天就没了。提示功能清单一定要写成可判定的句子。「循迹稳定」不是需求「在 2cm 宽黑线、半径 30cm 弯道上连续跑 5 圈不脱线」才是需求。训练代码的验收标准越具体后面调参越有方向。1.2 主控资源预算别等写满了才发现引脚不够选主控这件事我吃过亏。第一次做小车用的是资源很紧的八位主控一开始只接了电机驱动和两路红外引脚还剩一堆觉得绰绰有余。后来加了五路灰度、超声波、编码器、蓝牙模块、OLED 屏引脚瞬间告急最后不得不砍掉屏幕、把两个传感器复用同一个模拟口分时读取代码复杂度直接翻倍。所以现在我的习惯是先列一张资源表把每个外设需要的引脚类型和数量写清楚再倒推主控够不够用。这套训练代码对应的配置大致是这样外设接口类型占用数量备注双 H 桥电机驱动PWM 方向 GPIO2 路 PWM 4 路 GPIO左右各一路调速、两路方向增量式编码器左右外部中断或编码器接口4 路两路做四倍频时中断压力较大五路灰度循迹模拟输入或数字输入5 路模拟口能拿到连续值阈值更好调超声波测距触发输出 回响输入2 路回响需要能读脉宽红外避障数字输入12 路只做近距离兜底串口调试 / 无线指令串口2 路一路下载程序一路收指令状态指示数字输出2 路一颗电源灯、一颗运行灯算下来 PWM 通道至少要 2 路且能独立调频定时器要留一个给超声波测距或者系统节拍串口至少一个。这里的关键判断不是「够不够」而是「够不够留余量」——引脚刚好用满的方案后面想加一个蜂鸣器提示音都加不进去训练任务稍微一扩展就得换板子。我一般要求至少留 3 个可用 IO 和 1 个定时器通道做缓冲。还有一点容易被忽略中断资源。编码器四倍频意味着每个轮子每转一圈产生上千次电平变化如果两个轮子都挂在外部中断上再加上超声波回响的中断处理主循环很容易被切碎。我的做法是编码器只用单路中断做计数、另一路读电平判方向把中断频率砍一半实测在巡航速度下完全够用丢脉冲概率可以忽略。1.3 代码分层驱动层、算法层、任务层分层不是画架构图好看是为了「改一处不影响另一处」。这套训练代码包我按三个目录组织drivers/下面全是电机、灰度、超声波、编码器的读写函数每个文件只负责一件事对外只暴露 init 和 read/write 两类接口algo/放 PID、滤波、偏差计算这些纯数学的东西不包含任何硬件操作可以拿到电脑上单独跑单元测试app/放主循环、状态机、指令解析负责把前两层拼起来。这么分的实际收益我举个具体例子。有次小车在瓷砖地面上循迹正常换到木地板就开始抖。按分层思路排查先看驱动层输出PWM 波形正常再看算法层灰度读数在木地板上整体偏低且波动大偏差曲线毛刺明显最后确认是灰度传感器的发光管电流偏小、地面反光率不同导致的信噪比下降。解决方式是在算法层加了一阶低通滤波同时把灰度采样从 5ms 一次改成 2ms 一次取中值。整个过程没有动一行电机代码也没有重新标定电机死区。如果代码是一坨写在loop()里的这个排查过程就会变成「改一行试试、烧录、看效果、再改一行」效率差十倍不止。2. 底层驱动电机和传感器这些「手眼」怎么接才靠谱2.1 双 H 桥驱动与差速转向的数学关系差速小车转向的本质特别朴素左右轮速度不一样车就会拐弯。左轮慢右轮快车往左偏左轮反转右轮正转车原地打转。听起来简单但代码里必须把「速度」这个概念落到 PWM 占空比上中间隔着一层非线性。我用的封装是这样的对外只暴露一个setWheelSpeed(left, right)函数参数范围 -1000 到 1000正数前进、负数后退、0 停止内部再映射到 PWM 的 0255 和方向引脚。这样做的好处是上层算法完全不用关心电机接反了没有、驱动芯片是高电平有效还是低电平有效全在驱动层一次性处理干净。// drivers/motor.cpp static const int PWM_MAX 255; static const int DEAD_ZONE 38; // 实测死区低于此值轮子不转 void setWheelSpeed(int left, int right) { left constrain(left, -1000, 1000); right constrain(right, -1000, 1000); int lpwm map(abs(left), 0, 1000, 0, PWM_MAX); int rpwm map(abs(right), 0, 1000, 0, PWM_MAX); // 死区补偿只要不是完全停止就抬到能转起来的最小占空比 if (lpwm 0 lpwm DEAD_ZONE) lpwm DEAD_ZONE; if (rpwm 0 rpwm DEAD_ZONE) rpwm DEAD_ZONE; digitalWrite(L_IN1, left 0 ? HIGH : LOW); digitalWrite(L_IN2, left 0 ? LOW : HIGH); digitalWrite(R_IN1, right 0 ? HIGH : LOW); digitalWrite(R_IN2, right 0 ? LOW : HIGH); analogWrite(L_PWM, lpwm); analogWrite(R_PWM, rpwm); }死区补偿这行代码看着不起眼但它是低速循迹稳不稳的分水岭。直流减速电机在低占空比下静摩擦力占主导PWM 给 20 和给 0 效果一样都是不转PID 发现车不动就拼命加大输出一旦超过死区车突然窜出去然后过冲、回摆、再窜整台车就在线上左右横跳。把死区抬起来之后PID 的输出和实际速度之间才近似线性参数也才有意义。另一个必须处理的是左右电机的个体差异。同一批次的电机空载转速能差 5%10%装到车上就是直行跑偏。我的处理方式是在驱动层加两个系数做补偿而不是在算法层用 PID 硬掰——毕竟直行跑偏是机械特性不该让循迹算法去背这个锅。// 标定方法让车直行 2 米量左右轮实际走过的距离比值就是补偿系数 float kLeft 1.00f; float kRight 0.94f; // 右侧偏慢放大 6% void setWheelSpeedCalibrated(int left, int right) { setWheelSpeed((int)(left * kLeft), (int)(right * kRight)); }2.2 编码器测速把脉冲换算成毫米每秒没有编码器的小车也能循迹但没法做速度闭环速度会随电池电压一路下滑——满电时跑得飞快跑十分钟后开始迟钝PID 参数等于白调。加上编码器之后代码里就有了「真实速度」这个物理量控制逻辑立刻从「给多少 PWM」升级成「保持多少毫米每秒」。换算过程我按实际参数走一遍。假设编码器是 11 线霍尔、四倍频计数、减速比 1:30、轮径 65mm输出轴转一圈的脉冲数 11 × 4 × 30 1320轮子周长 π × 65 ≈ 204.2 mm单个脉冲对应行程 204.2 ÷ 1320 ≈ 0.1547 mm采样周期 10ms若这 10ms 内计到 n 个脉冲速度 n × 0.1547 ÷ 0.01 15.47 × n mm/s反过来想让车跑 300 mm/s每 10ms 应该计到约 19.4 个脉冲。这个数字很重要它告诉你采样周期的下限如果采样周期设成 1ms300mm/s 时每周期只有 2 个脉冲量化误差高达 50%测出来的速度基本不能用。经验上一个采样周期内至少要有 10 个以上脉冲速度读数才稳定所以我固定在 10ms低速时改成 20ms。// drivers/encoder.cpp volatile long pulseL 0, pulseR 0; void onLeftPulse() { pulseL; } void onRightPulse() { pulseR; } float readSpeedMMps(int side) { // 每 10ms 调用一次 static long lastL 0, lastR 0; long now (side 0) ? pulseL : pulseR; long last (side 0) ? lastL : lastR; long delta now - last; last now; return delta * 15.47f; // 已含单位换算 }这里有个坑要提醒pulseL是中断里改的主循环里读的时候必须做原子保护否则读一个 32 位变量被中断打断可能读到高 16 位是新的、低 16 位是旧的速度瞬间跳成天文数字。八位主控上最省事的做法是关中断读、读完立刻开中断临界区只有几条指令不会影响其他中断响应。2.3 五路灰度循迹的排布与阈值标定灰度传感器是循迹的灵魂但它的输出不是「黑」和「白」两个值而是一个跟地面反光率、环境光、供电电压都相关的模拟量。同一块传感器在白纸上读到 800在黑线上读到 120换了深色木地板可能变成 600 和 400——差距被压得很小你还按原来的阈值判断必然全线误判。我的标定流程固定三步。第一步让车在白底上缓慢横扫一遍串口打印五路读数记下每路的最大值第二步让车在黑线上横扫一遍记下每路最小值第三步取中点作为阈值并且要求最大值和最小值之差至少 300按 10 位 ADC 算差得太小说明传感器离地太高或者发光管老化先调机械再调代码。// algo/track.cpp const int SENSOR_PIN[5] {A0, A1, A2, A3, A4}; int threshold[5] {450, 450, 450, 450, 450}; // 由标定流程写入 // 返回加权偏差范围约 -2.0 ~ 2.0负数表示线在左边 float getLineError() { int weight[5] {-2, -1, 0, 1, 2}; float sumW 0, sumV 0; for (int i 0; i 5; i) { int raw analogRead(SENSOR_PIN[i]); // 黑线读数低取反后变成「越黑权重越大」 int v threshold[i] - raw; if (v 0) v 0; sumV v; sumW weight[i] * v; } if (sumV 200) return 99.0f; // 五路都没看到线返回哨兵值 return sumW / sumV; }加权求和的写法比「判断哪一路压线然后查表」要平滑得多。查表法的输出是离散的 -2、-1、0、1、2PID 输入一跳就是 1微分项直接炸掉加权法输出是连续值线在两路之间时能得到 0.5 这样的中间量控制自然就顺了。注意sumV 200这个判据相当于「丢失黑线」的识别条件阈值不能拍脑袋定。正确做法是把车放在白底上跑一遍记录 sumV 的波动范围取它的上限再乘 1.5 作为判据。定低了会在正常行驶中误判丢线定高了真丢线时又识别不出来。2.4 超声波和红外别打架分工要明确很多人两个都装结果两个一起触发逻辑互相覆盖车在原地抽搐。我的原则是超声波负责「远距离测量」红外负责「近距离兜底」职责不重叠。超声波模块的工作方式是给触发脚一个 10μs 以上的高电平然后等回响脚变高量高电平持续时间。声速在 20℃ 时约 343 m/s距离 时间 × 343 ÷ 2。代码里不用真算声速直接用经验系数距离(cm) 脉宽(μs) / 58。这个 58 是怎么来的往返 2 米对应约 11600μs200cm ÷ 11600μs ≈ 0.0172 cm/μs取倒数就是 58。温度变化 10℃ 会让声速变 6 m/s误差约 1.7%做训练任务完全够用不用做温度补偿。float readDistanceCM() { digitalWrite(TRIG, LOW); delayMicroseconds(2); digitalWrite(TRIG, HIGH); delayMicroseconds(12); digitalWrite(TRIG, LOW); unsigned long dur pulseIn(ECHO, HIGH, 25000); // 25ms 超时约 4.3 米 if (dur 0) return -1; // 超时视为无障碍或读数无效 return dur / 58.0f; }pulseIn是阻塞的最坏情况会卡住 25ms。25ms 对循迹来说是致命的——车以 300mm/s 跑这期间盲开 7.5mm加上之前的一次采样一次避障判断可能让车多跑两厘米。所以我的做法是超声波单独放一个 40ms 周期的软定时任务里循迹主循环完全不等它读到的距离存进全局变量供状态机查询。40ms 对应 12mm 的位移对 20cm 以上的避障距离判断绰绰有余。红外传感器就简单粗暴多了输出就是开关量检测到障碍拉低。我只在车头左右各装一个触发距离调到 10cm 以内作用是「超声波漏检时的最后一道保险」。为什么超声波会漏检对斜面和细杆这类反射面声波会散掉读数经常是超时。这时候红外虽然只能测「有没有」但足够让车停下来。2.5 供电和干扰九成的「玄学故障」出在这我敢说小车调试中最难查的问题一半以上跟电源有关。典型症状电机一转主控复位超声波读数突然从 30cm 跳到 200cm串口打印出乱码灰度读数整体上浮。原因不复杂。直流电机启动瞬间的堵转电流能到额定电流的 35 倍如果电机和主控共用一路电源、中间只靠一根细线连接启动瞬间线阻上的压降能把主控电压拉到复位阈值以下。我的处理方式是三条电机和主控分开供电同一块电池但走两路稳压或者干脆两块电池电机两端并联 0.1μF 陶瓷电容抑制换向火花主控电源入口并一个 470μF 电解电容做能量缓冲。换向火花这个事特别容易被忽略。有刷电机换向时电刷和换向器之间会产生高频尖峰顺着电源线和空间辐射出去轻则干扰模拟采样重则让编码器计数乱跳。我在一个项目里遇到过编码器每隔几秒就多计十几个脉冲的问题查了两天最后在电机引脚上焊了两个 0.1μF 电容一端接电机正极、一端接外壳、再一端接负极问题立刻消失。还有一条电池电压要监测。锂电池从 8.4V 掉到 7.0V 的过程中同样的 PWM 对应的实际转速会掉 15% 以上速度环还能靠编码器拉回来纯开环的循迹就会越跑越慢PID 参数在满电时调好的跑十分钟就变味了。所以代码里留一路 ADC 分压监测电池低于阈值就通过指示灯告警训练时也知道该换电池了。3. 训练代码落地从骨架到参数整定3.1 工程骨架与主循环节拍设计主循环怎么写直接决定了后面调参痛不痛苦。我最开始写的是「顺序执行 delay」的写法读灰度、算偏差、调 PID、写电机、delay(10)。这种写法的问题在于只要任何一个环节耗时变化比如超声波阻塞了 25ms整个控制周期就被拉长PID 的积分和微分全部失真参数怎么调都不对。正确的做法是固定节拍 非阻塞。主循环里不出现任何delay每个任务用「上次执行时间 周期」判断该不该跑跑完立刻返回。// app/main.cpp unsigned long tTrack 0, tSonic 0, tReport 0; void loop() { unsigned long now millis(); // 循迹控制10ms 一次优先级最高 if (now - tTrack 10) { tTrack now; trackTask(); } // 超声波测距40ms 一次允许被循迹打断 if (now - tSonic 40) { tSonic now; sonicTask(); } // 状态上报200ms 一次调试用 if (now - tReport 200) { tReport now; reportTask(); } handleCommand(); // 串口指令解析本身非阻塞 }节拍的选择有讲究。循迹 10ms 一次对应 100Hz 的控制频率对 300mm/s 的车速来说每个周期走 3mm5 路灰度间距 10mm 左右能保证线不会在两帧之间「溜过去」。如果车速提到 600mm/s10ms 就走 6mm接近传感器间距这时候要么把周期压到 5ms要么降低车速没有第三条路。任务优先级的隐含规则耗时短、频率高的任务放在前面耗时长的放后面。超声波虽然周期长但它单次执行可能阻塞 25ms所以一定要排在循迹之后。反过来如果先跑超声波循迹就要等它控制周期立刻变得不均匀。3.2 循迹 PID 从零整定我习惯的三步法PID 这三个字母吓退过不少人但用在循迹上其实只有 P 和 D 是主力I 基本可以不用——循迹是个零稳态误差系统线在中间时偏差就是 0不需要积分去消除静态误差反而容易积分饱和导致出弯后甩尾。我的整定顺序是先 P、再加 D、最后视情况点一点 I。具体操作把 Ki 和 Kd 都置 0Kp 从 20 开始每次加 20观察车在直线上的表现。Kp 太小的表现是「反应迟钝」——线已经偏到最外侧了车才开始修出弯直接跑飞Kp 太大的表现是「高频抖动」——车在线上左右画小波浪电机发出嗡嗡声。找到临界抖动的那个 Kp然后乘 0.6 作为最终值留出余量。加 D 的目的是抑制过冲。Kd 从 Kp 的 5 倍开始试因为偏差变化率通常远小于偏差绝对值需要放大观察过弯表现。D 合适的时候车进弯会提前减速转向动作干脆D 过大则对噪声极度敏感灰度读数的一点毛刺都会被放大成电机抖动表现为车在直线上也轻微发颤。// algo/pid.cpp float Kp 60.0f, Ki 0.0f, Kd 300.0f; float integral 0, lastError 0; const float I_LIMIT 500; // 积分限幅防止饱和 const int OUT_LIMIT 1000; // 输出限幅对应 setWheelSpeed 的量程 float pidCompute(float error) { integral error; if (integral I_LIMIT) integral I_LIMIT; if (integral -I_LIMIT) integral -I_LIMIT; float derivative error - lastError; lastError error; float out Kp * error Ki * integral Kd * derivative; if (out OUT_LIMIT) out OUT_LIMIT; if (out -OUT_LIMIT) out -OUT_LIMIT; return out; } void trackTask() { float err getLineError(); if (err 90) { // 丢线进入搜索模式 enterState(STATE_SEARCH); return; } float steer pidCompute(err); int base 400; // 基础速度占量程的 40% setWheelSpeedCalibrated(base - (int)steer, base (int)steer); }注意最后两行的加减方向偏差为负线在左边时steer 为负左轮速度 base - (-steer) 变大右轮变小车向左转。方向搞反是新手最常见的错误表现是车一上线就朝反方向冲出去。判断方法很简单把车拿在手里悬空手动把车头往左偏看左轮是不是转得更快如果不是把加减号对调。积分项什么时候需要当小车左右电机差异很大、或者赛道有长坡时纯 PD 会存在一个固定偏移车总是偏向一侧跑。这时候加一点点 Ki让积分慢慢把这个偏移吃掉。但一定要配合积分限幅和「偏差过大时清零积分」的保护否则一旦丢线积分会疯狂累积重新找到线时车会猛地甩一下。3.3 状态机让循迹、避障、遥控不互相踩脚多模式小车最容易出的问题是「模式打架」。比如车正在循迹超声波检测到障碍程序里如果只是简单地在循迹代码后面加一句if (距离 20) 停车那么停车期间 PID 的积分还在累积、偏差还在更新等障碍移开车会带着一堆积分猛冲出去。状态机的作用就是把这些模式做成互斥的状态每个状态有自己的进入条件、执行逻辑和退出条件。这套训练代码里我定义了五个状态状态进入条件执行动作退出条件IDLE上电或收到停止指令电机停转、清空 PID收到启动指令TRACK收到启动指令且未检测到障碍PID 循迹丢线、检测到障碍、收到停止AVOID距离小于 25cm 或红外触发停车→后退→转向障碍解除且持续 500msSEARCH循迹时连续丢线超过 200ms原地左右扫描找线找回线或超时 3 秒REMOTE收到遥控指令按指令直行/转向收到退出指令关键点在于状态切换时要做清理。进入 AVOID 时立刻把 PID 的积分和 lastError 清零进入 TRACK 时重置计时器这样就不会出现「带着上一个状态的情绪进入下一个状态」的情况。enum State { IDLE, TRACK, AVOID, SEARCH, REMOTE }; State state IDLE; unsigned long stateEnterTime 0; void enterState(State s) { state s; stateEnterTime millis(); integral 0; // 每次切状态都清积分 lastError 0; } void stateMachine() { switch (state) { case TRACK: if (getDistanceCM() 0 getDistanceCM() 25) enterState(AVOID); else if (lostLineFor() 200) enterState(SEARCH); else trackTask(); break; case AVOID: doAvoidSequence(); if (millis() - stateEnterTime 500 obstacleGone()) enterState(TRACK); break; case SEARCH: doSearchSweep(); if (foundLine()) enterState(TRACK); else if (millis() - stateEnterTime 3000) enterState(IDLE); break; // ... } }避障动作我建议写成「停车 → 后退 15cm → 原地转向 90° → 前进一段 → 重新找线」的固定序列而不是「一边前进一边微调方向」。前者行为可预测调试时能明确知道卡在哪一步后者在狭窄空间里容易反复横跳看着像车坏了。提示状态切换加一个最短驻留时间比如 300ms非常有必要。否则车在 25cm 这个临界距离上超声波读数一波动状态就在 TRACK 和 AVOID 之间疯狂跳转表现是车一顿一顿地抖。驻留时间等于给状态机加了迟滞。3.4 调试通道参数在线改别反复烧录调 PID 最痛苦的是「改一个参数、编译、烧录、重启、测试」这个循环一轮两三分钟一天下来改不了几十次。我的解决办法是在串口上开一个极简的指令通道支持读写参数、启停、切换模式。// app/command.cpp void handleCommand() { if (!Serial.available()) return; String line Serial.readStringUntil(\n); line.trim(); if (line.startsWith(set )) { // 格式set kp 80 int sp1 line.indexOf( ); int sp2 line.indexOf( , sp1 1); String key line.substring(sp1 1, sp2); float val line.substring(sp2 1).toFloat(); if (key kp) Kp val; else if (key kd) Kd val; else if (key ki) Ki val; else if (key base) baseSpeed val; Serial.print(OK ); Serial.print(key); Serial.print(); Serial.println(val, 2); } else if (line start) { enterState(TRACK); Serial.println(OK START); } else if (line stop) { enterState(IDLE); Serial.println(OK STOP); } else if (line dump) { Serial.print(kp); Serial.print(Kp); Serial.print( kd); Serial.print(Kd); Serial.print( ki); Serial.println(Ki); } }有了这个通道调参就变成了车跑一圈串口发set kp 80再跑一圈对比波形。一轮缩短到二十秒效率提升非常明显。「dump」指令也很有用跑完一轮把当前参数打印出来记到本子上避免调花了的参数找不回来。无线模块也能接上去把串口指令透传过来这样车在赛道上跑人在旁边用手机或手柄发指令不用跟着车跑。注意无线透传有几十毫秒延迟只适合发参数和启停别指望用它做实时遥控。还有一个锦上添花的功能是数据回放。让车每 10ms 通过串口打印一行「时间、偏差、PID 输出、左右轮速度」跑完一圈把数据存成文本画成曲线。很多调参问题看图一目了然——比如你以为是 D 太大导致抖动看图发现是灰度读数本身在跳那就是硬件问题不是参数问题。4. 现场排查实录那些文档里不会写的问题4.1 典型故障速查表下面这张表是我和队友这些年攒下来的按「现象 → 可能原因 → 验证方法」组织遇到问题时按顺序排查八成能命中。现象最可能原因快速验证方法处理方式电机一转主控就复位电机与主控共电源、线阻压降过大万用表测主控供电脚转电机时看是否跌到 4.5V 以下分开供电、加 470μF 电容、加粗电源线直行跑偏越跑越偏左右电机特性不一致空载测两轮转速或直行 2 米量偏移量驱动层加速度补偿系数循迹时左右高频抖动Kp 过大或灰度读数有毛刺串口打印偏差曲线降 Kp加中值滤波进弯冲出赛道Kd 不足或车速过快降低 base 速度重试加 Kd或弯道前减速出弯后甩尾过冲积分饱和打印积分项数值加积分限幅切状态清积分超声波读数乱跳电源干扰或反射面问题静止时连续读 20 次看方差加电容、软件中值滤波、斜面对策编码器速度读数偶尔爆表中断与主循环竞争加临界区保护后复测关中断读、开中断循迹到深色地面全线失效灰度阈值未重新标定打印五路原始值重新走标定三步流程车跑十分钟后变慢电池电压下降监测电池 ADC速度闭环或换电池上电后电机自转方向引脚初始电平未定义上电瞬间观察初始化第一件事就是把方向脚拉低4.2 几个反直觉的调试经验第一条先怀疑机械再怀疑代码。我遇到过小车循迹总是往右偏调了三天 PID 都没用最后发现是右轮的轮胎和轮毂之间有 1mm 的间隙跑起来轮子在轴上打滑。代码再完美也救不了机械误差。所以现在的流程是上电之前先用手推车走直线看四个轮子是不是真的在一条线上轮子有没有晃动传感器是不是装歪了。第二条灰度传感器的高度比阈值更关键。很多人调不好循迹就去改阈值其实传感器离地高度不对怎么调都没用。太高了光斑散开黑线白底的对比度上不去太低了容易蹭到地面。经验值是发光管和接收管离地 815mm具体看传感器型号判断标准就是「白底读数接近满量程、黑线读数接近 0」。第三条慢速调好再提速。训练时总想着一上来就飙速度结果参数全乱。我的做法是把 base 速度设成最大速度的 30%在这个速度下把 P、D 调稳然后以 10% 的步长往上加每加一次重新微调 Kp。速度提高一倍Kp 大致也要提高但不是线性关系得重新试。第四条状态机的日志比参数日志更有用。车跑飞了第一反应是 PID 参数不对但很多时候是状态跳错了——比如在 AVOID 状态下被超声波的错误读数卡住了一直没回到 TRACK。所以我在每次状态切换时打一行日志带上时间戳和触发原因跑完一看就知道车到底经历了什么。第五条别在电池电量不足的时候调参。这个坑踩得最深。有一次下午调好的参数晚上再跑完全不同以为是温度影响折腾半天才想起来下午用的是满电电池晚上那块已经跑了两小时。现在的规矩是调参前先充满或者干脆接稳压电源。4.3 训练任务怎么加难度才不劝退这套代码作为训练载体最忌讳的就是「一次给满」。我一般按四轮递进第一轮只要求直线往返和原地转圈把电机驱动和编码器读数打通验收标准是直行 2 米偏移小于 5cm。这一轮的目的是让人理解死区补偿和速度换算难度低但地基牢。第二轮加五路灰度做单圈循迹赛道用 2cm 宽黑线、两个直角弯一个 S 弯。验收标准是连续 3 圈不脱线、单圈时间稳定。这一轮的核心是阈值标定和 PID 整定也是最磨人的一轮通常会卡两三天。第三轮加超声波和状态机赛道上放两个障碍物要求绕行后重新找回黑线。验收标准是完整跑完且不碰障碍。这一轮的重点不是算法而是异常处理——障碍物摆在弯道上怎么办、绕行后找不到线怎么办、两个障碍物挨得很近怎么办。第四轮才是提速和优化要求单圈时间比第三轮缩短 20%。这时候才需要动速度闭环、前馈补偿、弯道预判这些东西。很多队伍在这一轮才真正理解「为什么需要编码器」——因为开环提速之后电池电压的微小变化都会让成绩不稳定。每一轮结束都要求把参数和调试记录写下来包括当时用的 Kp、Kd、base 速度、电池电压。这个习惯看着麻烦但下一轮出问题时翻记录比重新试快得多。我见过太多人调出好参数不记第二天车跑崩了只能从头再来。最后分享一个我自己的小习惯车上永远贴一张便签写着当前固件的版本号和关键参数。训练场里几台车来回倒腾没有这张纸你根本分不清手里这台跑的是哪一版代码。
阅读完成 · 觉得有帮助?