Skip to content

第 8 章:循迹探头与自动驾驶

本章是本册中最经典的部分。

8.1 轨道搭设:黑色电工胶布铺轨

循迹(自动驾驶)的第一步是铺设轨道。D3 的轨道无需专用器材:用常见的黑色电工胶布(绝缘胶布)粘贴在浅色地板上即可制成——黑色胶带与浅色地面形成的强烈反差,正是循迹探头识别黑线的依据。

  • 材料:黑色电工胶布(宽约 18 mm)一卷,浅色、平整的地板或 KT 板;

  • 粘贴:边拉边贴、压实抹平,避免气泡与翘边(翘边会导致探头读数跳变);

  • 弯道:所有转弯处用圆弧过渡,弯道半径不小于 20 cm,不要出现直角弯或锐角弯;

  • 规模:建议先铺设 1.5~3 m 的环形闭合轨道(圆角矩形或椭圆形),跑通后再增加岔路、十字等复杂元素;

  • 环境:避开阳光直射与强反光地面;地面本身偏深时,需垫浅色背景板。

铺轨要点:

轨道铺设要点示意图

说明:闭环轨道最适合入门——小车可以持续绕圈行驶,便于反复观察与调试。调试通过后,可在轨道末端放置蜡烛,衔接第 10 章的综合任务,优秀的自动循迹算法的轨道不用铺的像示意图那样规整。

用黑色电工胶布在浅色地板上贴出任意轨迹,让小车沿轨迹行驶且不偏离轨道。

电工胶布轨道实拍

8.2 模块原理:四路循迹探头

循迹探头(⑪)安装于车头底部,向下发射红外光并接收反射光。黑色表面吸收红外光、探头收不到回波,白色表面反射红外光、探头能收到回波,模块据此输出数字信号:**压黑线(收不到回波)输出 0,对应指示灯点亮;位于白色区域(收到回波)输出 1,指示灯熄灭。**四个探头横向排列,根据哪些探头位于黑线上方,即可判断黑线相对于车身的位置。

重要提示:四个探头从左至右的物理顺序为 X2、X1、X3、X4(即 2134),并非 1234。循迹程序效果不符合预期时,多数情况并非算法或代码错误,而是探头编号识别错误。编程前务必按 8.3 节的方法实测确认。

探头编号Pico 管脚物理位置(从左至右)
X2GP11第 1 个(最左)
X1GP10第 2 个
X3GP12第 3 个
X4GP13第 4 个(最右)

8.3 代码实现第一步:读取四个探头

循环读取 GP10~GP13 四个管脚的数值并显示。将小车分别置于"探头压黑线"与"探头位于白色区域"两种状态,观察读数与指示灯的对应关系:压黑线的探头读数为 0、指示灯点亮;位于白色区域的探头读数为 1、指示灯熄灭。同时验证 8.2 表格中的物理顺序,确认实际小车的编号排列与表格一致。

读取四个探头积木

python
# 四个循迹探头:压黑线输出 0(指示灯亮),白色区域输出 1(指示灯灭)
# 接管脚:S1→GP10(X1)、S2→GP11(X2)、S3→GP12(X3)、S4→GP13(X4)
# 注意物理顺序:从左至右是 S2、S1、S3、S4(即 X2、X1、X3、X4),不是 1234!
GP10 = Pin(10, Pin.IN)
GP11 = Pin(11, Pin.IN)
GP12 = Pin(12, Pin.IN)
GP13 = Pin(13, Pin.IN)

while True:
    # 循环读取四个管脚并存入变量 S1~S4,再合并打印出来
    S1 = GP10.value()
    S2 = GP11.value()
    S3 = GP12.value()
    S4 = GP13.value()
    # (截图打印的最后一个变量是笔误,应为 S4,代码按 S4 写)
    print("左→右:", S1, S2, S3, S4)
cpp
// 四个循迹探头:压黑线输出 0(指示灯亮),白色区域输出 1(指示灯灭)
// 接管脚:S1→GP10(X1)、S2→GP11(X2)、S3→GP12(X3)、S4→GP13(X4)
// 注意物理顺序:从左至右是 S2、S1、S3、S4(即 X2、X1、X3、X4),不是 1234!
const int GP10 = 10;
const int GP11 = 11;
const int GP12 = 12;
const int GP13 = 13;

void setup() {
  Serial.begin(115200);
  pinMode(GP10, INPUT);
  pinMode(GP11, INPUT);
  pinMode(GP12, INPUT);
  pinMode(GP13, INPUT);
}

void loop() {
  // 循环读取四个管脚并存入变量 S1~S4,再合并打印出来
  int S1 = digitalRead(GP10);
  int S2 = digitalRead(GP11);
  int S3 = digitalRead(GP12);
  int S4 = digitalRead(GP13);
  // (截图打印的最后一个变量是笔误,应为 S4,代码按 S4 写)
  Serial.print("左→右:");
  Serial.print(S1); Serial.print(" ");
  Serial.print(S2); Serial.print(" ");
  Serial.print(S3); Serial.print(" ");
  Serial.println(S4);
}

8.4 循迹(自动驾驶)策略

循迹的基本逻辑为:**黑线偏向哪一侧,车身即向该侧修正。**探头组合与修正动作的对应关系如下(压黑线 = 读数 0,不压线 = 读数 1):

压黑线(读数 0)的探头黑线位置判断修正动作
X1、X3 同时压线黑线位于车身正下方(居中)前进
仅 X1 压线黑线微偏左向左微调:左轮稍减速 / 右轮稍加速
仅 X2 压线黑线大偏左向左大转:左轮大减速 / 右轮大加速
仅 X3 压线黑线微偏右向右微调:左轮稍加速 / 右轮稍减速
仅 X4 压线黑线大偏右向右大转:左轮大加速 / 右轮大减速
四个均为 1(全部脱线)丢失黑线继续上次转向,利用惯性找回黑线

循迹修正策略示意图

8.5 代码实现第二步:完整循迹(自动驾驶)程序

程序结构:循环执行「读取四个探头 → 按 8.4 表格判定动作 → 执行修正」。

8.5.1 循迹算法详解

同电压下两电机转速不可能绝对一致,地板也非绝对平整。若只要探头偏线就大幅转向,车身会剧烈摆尾;若修正力度不分路况,弯道又容易丢线。

解决思路:不自己算轮速,而是复用第 4 章的差速转弯函数 turn1()——转向量既是方向也是力度:负值向左修、正值向右修,绝对值越大转得越急。程序用两个变量把"方向"和"力度"分开算,最后相乘交给 turn1(e * n)

  • 方向 e:由中间两个探头 S1、S3 决定。压黑线读 0、不压线读 1,因此 e = S1 - S3——仅 S1 压线(黑线偏左)时 e = -1,向左修;仅 S3 压线(黑线偏右)时 e = 1,向右修;S1、S3 同时压线(居中)时 e = 0,直行。左负右正,一目了然。

  • 力度 n:由外侧两个探头 S2、S4 判断偏离的严重程度。S2 或 S4 压线,说明黑线已经跑到最外侧探头底下、车身严重偏离,n 置为 1,用最大力度回轨;外侧没压线而中间有探头压线,说明只是轻微偏离,n 置为 0.25,即最大拐弯力度的四分之一,轻轻带一把即可。

  • 脱线处理:若 S1、S3 同时为 1(中间探头都没压到线),说明黑线已脱出中间视野——此刻若按 e = S1 - S3 计算会得到 0,小车直行、彻底丢线。因此这种情况下不重新计算,直接继承脱线前上一瞬间的方向:e = last_e,遵循左负右正——刚才在向左拐就继续向左拐,刚才在向右拐就继续向右拐,靠惯性把黑线找回来。

程序每轮循环依次做三件事:

  1. 读取四路探头状态,存入变量 S1~S4

  2. 按上面三条规则算出 en,并记录 last_e = e

  3. 调用第 4 章 turn1(e * n) 执行修正。

turn1() 内部 sp = 150、p = 80:e * n = 0 时左右轮均为 150,直行;e * n = ±0.25 时两侧轮速 170 / 130,差速 40,车身微微调整;e * n = ±1 时两侧轮速 230 / 70,差速 160,快速回轨。

如此,直线段行驶平顺不摆尾,入弯时又能及时大力修正,脱线时还会沿着原来的方向把线找回来。

8.5.1.1 可指定鸣笛次数的蜂鸣器函数

编写循迹代码前,先定义一个可指定鸣笛次数的蜂鸣器函数,方便后续调试调用。以蜂鸣器声响标识程序状态,是实用的调试技巧。

蜂鸣器鸣响函数积木

8.5.1.2 完整循迹程序

完整循迹程序积木

python
# 蜂鸣器提示音函数(蜂鸣器接 GP14):鸣 n 声
buzzer = Pin(14, Pin.OUT)

def beep(n):
    # 重复 n 次:高电平响 0.2 秒 → 低电平停 0.2 秒
    for _ in range(n):
        buzzer.value(1)
        time.sleep(0.2)
        buzzer.value(0)
        time.sleep(0.2)

# 四个探头:S1→GP10(X1)、S2→GP11(X2)、S3→GP12(X3)、S4→GP13(X4)
# 物理从左至右:S2、S1、S3、S4
# 读数规律:压黑线 = 0(指示灯亮),不压线 = 1(指示灯灭)
GP10 = Pin(10, Pin.IN)
GP11 = Pin(11, Pin.IN)
GP12 = Pin(12, Pin.IN)
GP13 = Pin(13, Pin.IN)

e = 0        # 方向:负 = 向左修,正 = 向右修,0 = 直行
last_e = 0   # 上一瞬间的方向(脱线时继承)
n = 0.25     # 力度系数:0.25 = 微调,1 = 大转

stop_all()      # 先确保四轮停稳
beep(3)         # 出发提示:鸣三声
time.sleep(2)   # 原地等待 2 秒,从容把车放到轨道上

while True:
    # 读取四个探头
    S1 = GP10.value()
    S2 = GP11.value()
    S3 = GP12.value()
    S4 = GP13.value()

    # 方向:中间两个探头都没压线(都为 1)= 脱线,
    # 继承上一瞬间的方向 e,继续原来的转向把线找回来;
    # 否则 e = S1 - S3:仅 S1 压线得 -1(向左修),
    # 仅 S3 压线得 1(向右修),同时压线得 0(直行)
    if S1 == 1 and S3 == 1:
        e = last_e
    else:
        e = S1 - S3

    # 力度:外侧探头压线(S2 或 S4 为 0)= 严重偏离,全力大转;
    # 仅中间探头压线 = 轻微偏离,用四分之一力度微调
    if S2 == 0 or S4 == 0:
        n = 1
    elif S1 == 0 or S3 == 0:
        n = 0.25
    # 脱线时上面两个分支都不进,n 保持上次的值

    last_e = e      # 记录本次方向,供脱线时继承
    turn1(e * n)    # 调用第 4 章差速转弯函数:负左正右,绝对值越大转得越急
cpp
// 蜂鸣器提示音函数(蜂鸣器接 GP14):鸣 n 声
const int BUZZER = 14;

void beep(int n) {
  // 重复 n 次:高电平响 0.2 秒 → 低电平停 0.2 秒
  for (int i = 0; i < n; i++) {
    digitalWrite(BUZZER, HIGH);
    delay(200);
    digitalWrite(BUZZER, LOW);
    delay(200);
  }
}

// 四个探头:S1→GP10(X1)、S2→GP11(X2)、S3→GP12(X3)、S4→GP13(X4)
// 物理从左至右:S2、S1、S3、S4
// 读数规律:压黑线 = 0(指示灯亮),不压线 = 1(指示灯灭)
const int GP10 = 10;
const int GP11 = 11;
const int GP12 = 12;
const int GP13 = 13;

float e = 0;       // 方向:负 = 向左修,正 = 向右修,0 = 直行
float last_e = 0;  // 上一瞬间的方向(脱线时继承)
float n = 0.25;    // 力度系数:0.25 = 微调,1 = 大转

void setup() {
  pinMode(BUZZER, OUTPUT);
  pinMode(GP10, INPUT);
  pinMode(GP11, INPUT);
  pinMode(GP12, INPUT);
  pinMode(GP13, INPUT);
  stop_all();   // 先确保四轮停稳
  beep(3);      // 出发提示:鸣三声
  delay(2000);  // 原地等待 2 秒,从容把车放到轨道上
}

void loop() {
  // 读取四个探头
  int S1 = digitalRead(GP10);
  int S2 = digitalRead(GP11);
  int S3 = digitalRead(GP12);
  int S4 = digitalRead(GP13);

  // 方向:中间两个探头都没压线(都为 1)= 脱线,
  // 继承上一瞬间的方向 e,继续原来的转向把线找回来;
  // 否则 e = S1 - S3:仅 S1 压线得 -1(向左修),
  // 仅 S3 压线得 1(向右修),同时压线得 0(直行)
  if (S1 == 1 && S3 == 1) {
    e = last_e;
  } else {
    e = S1 - S3;
  }

  // 力度:外侧探头压线(S2 或 S4 为 0)= 严重偏离,全力大转;
  // 仅中间探头压线 = 轻微偏离,用四分之一力度微调
  if (S2 == 0 || S4 == 0) {
    n = 1;
  } else if (S1 == 0 || S3 == 0) {
    n = 0.25;
  }
  // 脱线时上面两个分支都不进,n 保持上次的值

  last_e = e;     // 记录本次方向,供脱线时继承
  turn1(e * n);   // 调用第 4 章差速转弯函数:负左正右,绝对值越大转得越急
}

注意:循迹效果受探头离地高度与环境光强度影响。若读数异常,先检查探头是否松动、离地间隙是否过大,再用黑色绝缘胶带现场测试确认。

8.6 常见 bug 及理解误区

循迹是整册教程中"看起来简单、调起来容易怀疑人生"的一章。下面这些都是极易踩中的坑,循迹效果不对时,先逐条对照排查,再怀疑代码。

误区一:以为"识别到黑线 = 1"

直觉上"探测到了东西"就该输出 1,这款探头恰恰相反。它靠红外回波工作:黑色表面吸光,探头收不到回波,压黑线时读数为 0;浅色表面反射回波,读数为 1。把极性想当然地记反,是循迹程序最常见的理解误区——写出的程序会"看见黑线就躲",在黑线与地板之间来回横跳。

误区二:拿着小车悬空调试

小车被拿在手里悬空时,探头离地面太远,同样收不到回波,读数也是 0,和压黑线一模一样。所以悬空读到 0 ≠ 检测到黑线。观察读数、验证程序时,请把小车放在铺好轨道的地面上进行,否则会被"假压线"误导。

误区三:以为指示灯亮 = 有信号

这款探头的指示灯逻辑与直觉相反:灯亮 = 读到 0(压黑线),灯灭 = 读到 1(未压线)。用指示灯判断探头状态完全可行,而且比看串口数据更快捷,但前提是先记住这个反直觉的对应关系,否则越调越乱。

误区四:把物理顺序当成 1234

四个探头从左至右是 X2、X1、X3、X4,不是 1234。程序跑起来效果不对时,先别急着改算法——多数情况是探头编号想错了。按 8.3 节的方法逐个遮挡实测,确认每个变量对应车底哪个探头。

误区五:灵敏度旋钮没调准

每个探头上都有灵敏度调节旋钮,出厂状态不一定适合实际的地面环境。标准调试流程:

  1. 小车置于平整桌面,不开电源开关,用电脑 USB 连接 Pico 供电即可;
  2. 在 A4 纸上贴一条黑色轨迹;
  3. 左右移动小车,观察黑轨对准哪个探头时,哪个探头的灯是否亮起;
  4. 对不上的,旋转对应探头的灵敏度旋钮,直到四个探头都能"黑轨对谁谁亮"。

误区六:读数全部异常时只会改代码

如果灵敏度调准后读数仍然异常(比如四个探头一直读 0 或一直读 1),问题可能不出在本章程序:之前某次实验下载过的程序如果把 GP10~GP13 配置成了下拉电阻模式,会持续干扰这四个管脚的读取,本章示例代码怎么改都没有用。解决方法:向 Pico 重新刷入固件包,把管脚配置恢复干净,再重新下载本章程序。

不积跬步,无以至千里;万里之行,始于当下。