教程 · 入门 · 阅读约 20 分钟

制作循迹机器人:从传感器到 PID 转向

一台循迹机器人到底是怎样工作的:底部的反射式传感器、由读数算出的加权位置误差,以及让差速底盘平滑转向的 PID 控制环。

循迹车是经典的第一个自主机器人:它读取引脚下的线并转向,让车始终留在线上。底层的控制问题很小——把传感器读数变成一个转向修正量——这让它成为学习反馈控制的绝佳切入点。可以在循迹模拟器里跟着做。

感知这条线

反射传感器向下发射红外光,测量有多少光被反射回来。深色的线比周围明亮的地面反射得少,所以每个传感器报的就是自己是否压在线上。车头横着排一排若干传感器,告诉你的不仅是在不在线上,还有线相对于中心在哪儿。

五个传感器是个不错的起点。低速下三个就够;竞赛机器人常用八个,以获得更细的分辨率。

并排的五个循迹传感器,每个上方标一个权重,从最左的负二经过中间的零到最右的正二,下方各标一个一或零的读数。权重为零和正一的两个传感器读数为一并高亮,其余读数为零。加权平均的公式是权重乘读数求和再除以读数求和,算得零加一除以二等于正零点五。旁边警告说:全为零意味着丢线,那是另一种情况,并不是误差为零。
五个开关量读数收敛成一个带符号的数,转向环就可以拿它动作。符号是方向,大小是偏得有多远。 下载 SVG

把读数变成一个误差数

不要对单个传感器做反应,而是把它们合成一个加权位置。给每个传感器一个位置值(左侧为负、中间为零、右侧为正),再按各传感器看到线的强度加权取平均:

// positions: {-2, -1, 0, 1, 2}; readings: reflectance per sensor
float weightedPosition = sum(position[i] * reading[i]) / sum(reading[i]);

得到的就是你的误差:零表示居中,负值表示线在左边,正值表示线在右边。一个平滑连续的误差,正是控制器需要的输入。

用 PID 转向

把这个误差喂给 PID 控制器。修正量会让一个轮子加速、另一个减速,把车转回线上:

float correction = kp * error + ki * integral + kd * derivative;
setMotorSpeeds(baseSpeed - correction, baseSpeed + correction);

比例增益承担了转向动作的大部分;微分增益抑制弯道上的摆动;一个小的积分项消除稳态漂移。如果你刚开始学怎么选增益,先走一遍怎样整定 PID 控制器。

修掉常见故障

  • **围着线振荡:**比例增益太大或微分太小。先降速,然后重新整定。在真实硬件上,循环里一个长 delay() 会引入死区时间,把微分项彻底废掉。
  • **急弯处丢线:**车跑得比传感器更新率还快。减速,把横排加宽,或者复用上一次已知的线的方向,让车转回来而不是径直冲下赛道。
  • **偏向一侧漂移:**标定传感器。让它们在线上和地面上扫一遍,记下每个传感器的最小值和最大值,再把实时读数按这个范围归一化。

整个闭环放在一起

上面那些片段只有把时序、标定和丢线这三种情况一并处理,才会变成一台能用的车:

const int N = 5;
const int PIN[N] = {A0, A1, A2, A3, A4};
const int POS[N] = {-2000, -1000, 0, 1000, 2000};   // weights, in the error's units

int minv[N], maxv[N];
long lastError = 0;
float integral = 0;
unsigned long lastMicros = 0;

const float KP = 0.10f, KI = 0.0f, KD = 1.6f;
const int   BASE = 140;                              // out of 255

void loop() {
  // 1. Real elapsed time — never assume the loop period.
  const unsigned long now = micros();
  const float dt = (now - lastMicros) * 1e-6f;
  if (dt < 0.002f) return;                           // cap the rate at 500 Hz
  lastMicros = now;

  // 2. Position from the weighted average.
  long weighted = 0, total = 0;
  for (int i = 0; i < N; i++) {
    const int v = normalized(analogRead(PIN[i]), i);
    if (v > 100) { weighted += (long)v * POS[i]; total += v; }
  }

  // 3. Line lost: hold the last error, do NOT zero it.
  const long error = (total == 0) ? lastError : (weighted / total);

  // 4. PID, with derivative on measurement and conditional integration.
  const float derivative = -(float)(error - lastError) / dt;
  const float candidate = integral + error * dt;
  const float raw = KP * error + KI * candidate + KD * derivative;
  const float correction = constrain(raw, -BASE, BASE);
  if (raw == correction) integral = candidate;

  lastError = error;

  // 5. Differential steering.
  setMotors(BASE - correction, BASE + correction);
}

这里面有五处,正是它们把“能循线的车”和“差一点就能循线的车”分开。

**真实的 dt。**积分项和微分项都要除以流逝的时间。循环时长会随着你往上加功能而改变——它一定会变——如果你假定一个固定周期,这两项算出来的都是假的。

**v > 100 这个阈值。**没有它,五个通道各自读到的一个小小的底噪值会把加权平均拖向中心,不管线实际在哪里。症状是:粗线跟得很好,浅线跟得很差。

**丢线时保持上一次误差。**零意味着“居中”,所以一台把误差归零的车会以全速直行——而线几乎肯定是弯的,于是车就冲下赛道了。保持上一次误差,车会继续朝它已经在转的方向转。

**微分作用在测量量上。**对误差求微分意味着设定点一变就会产生一个巨大的尖峰。这里设定点永远是零,所以差别不大,但这个习惯值得养成。

**条件积分。**一旦修正量饱和,继续累积积分只是把一次超调存起来,留着以后还。

一套行得通的整定顺序

不要三个一起调。把 KI = 0、KD = 0 设好,然后:

步骤 改什么 观察什么 什么时候停
1 从小值开始加 KP 车开始对线有反应 能跟缓弯,还有一点蛇形摆动
2 继续加 KP 摆动长大成振荡 稳定振荡——把这个值记为 Ku
3 把 KP 设为约一半 Ku 响应快,有超调 —
4 加 KD 摆动被压掉 直线跑干净;再加 KD 就开始发神经
5 只有在持续偏心行驶时才加一点 KI 偏差被关掉 关掉即可——循迹车很少需要 KI
6 提 BASE 速度 所有表现一起变差 在新速度下重调 KP 和 KD

第 6 步是大家会忘的那一步:**增益是随速度变的。**在 140 占空比上完美的整定,到 220 就会振荡,因为同样的转向修正量在单位时间内会产生更大的航向变化。只要速度有明显变化,就预期要重新整定。

积分在这里确实很少有用。循迹车的设定点是零,扰动又是瞬态的,所以通常没有稳态偏差留给 KI 去关。如果你看到持续存在的偏差,先怀疑电机trim不均或传感器没标定,而不是伸手去加积分增益。

循环频率悄悄地给一切设了上限

循环频率 周期 以 0.4 m/s 行进的距离
20 Hz 50 ms 每两次决策之间走 20 mm
50 Hz 20 ms 8 mm
200 Hz 5 ms 2 mm
500 Hz 2 ms 0.8 mm

一辆每 20 mm 才做一次决策的车,无论增益怎么设都跟不紧一个弯,而微分项也救不回根本没被采样到的信息。

常见肇因很好清除:循环里任意处的 delay()、9600 波特率下 Serial.print 超过几个值,以及读了根本不用的传感器。把它们去掉,通常能把第一台车从 50 Hz 提到几百 Hz,**而跟踪效果不碰任何增益就变好了。**调参之前先量频率,而不是之后。

故障排查

现象 可能原因 解决
直道上蛇形摆动 KP 太大或 KD 太小 KP 减半,再加 KD
越荡越快 KP 超过临界增益 往回减直到稳定
弯道切角 速度超出传感器的超前量 减速,或把阵列前移
丢线后径直开出 丢线时把误差归零 保持上一次误差
一个方向跟得好、另一个方向差 阵列不水平,或电机trim不均 把阵列调平;逐个电机做trim
偏向一侧漂移 传感器没标定,或电机不一致 逐通道标定,然后再trim
台架上好、到地面就乱走 离地高度变了 3–8 mm 刚性安装后重新标定
昨天还好、今天就没劲 电池电压 增益是被控对象的属性——重新整定,或闭合一个速度环
只在低速时抖动 低于电机死区 量出每个电机的起动占空比并做补偿
KD 完全没反应 循环里有 delay() 死区时间把微分作用彻底废掉

自己试试

在循迹模拟器里你可以画一条赛道,改传感器数量和速度,一边看误差和单圈时间一边调增益。你在加权误差和转向环上学到的一切,都能原样搬到一台 Arduino 小车上。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料