教程 · 入门 · 18 分钟

用舵机带动超声波传感器扫描,寻找开阔空间

把 HC-SR04 超声波传感器装在 SG90 舵机上来回扫动,在几个角度分别测距,让机器人转向最空旷的方向,而不是靠猜。

一颗只朝前看的距离传感器能告诉机器人前面有东西,却说不出哪一边是空的。于是它只能盲转,经常一头撞进另一个障碍或一个死胡同。把 HC-SR04装到一台 SG90 舵机上就解决了这件事:机器人停下来,左右看,然后朝最空旷的那个方向转——这正是一个人在盲角会做的动作。

为什么一颗固定的传感器会把机器人困住

想象一台只能看正前方的机器人。它遇到一堵墙,知道自己必须转,但完全不知道左边好还是右边好。选错了,它就开进自己刚刚出来的那个拐角。而一颗装在舵机上的传感器,把一个数(前方距离)变成机器人正前方空间的一张小地图,于是转弯变成一个有依据的选择,而不是一次抛硬币。

把传感器装到舵机上

把 HC-SR04 固定在舵盘上,把舵机装在底盘的正前方中央,朝向要让舵盘把传感器左右扫(不是上下)。在舵机中位时传感器应当正对前方。这样你就得到一次干净的扫描:从一侧出发,经过正前方,到另一侧。

扫描并采样

让舵机按步长走过几个角度,每个角度取一次滤波后的读数。复用读 HC-SR04那篇里的干净距离函数——扫描中的原始 ping 太嘈杂,根本没法用来转向。

雷达式俯视图,机器人在底部,超声传感器从 30 度扫到 150 度共五个角度。淡淡的量程弧标出 20、40、60 cm。四条射线较短,末端是障碍点(22、34、18、41 cm);120 度那条射线很长(68 cm),用青色高亮并标注“最空旷,转这里”。
扫描在每个角度量距离;最长的那个读数就是最空旷的方位,于是机器人朝那边转,而不是靠猜。 下载 SVG
#include <Servo.h>
Servo scanner;

const int ANGLES[] = {30, 60, 90, 120, 150};
const int N = 5;

int bestAngle() {
  long best = -1;
  int bestA = 90;
  for (int i = 0; i < N; i++) {
    scanner.write(ANGLES[i]);
    delay(180);                 // let the servo REACH the angle first
    long d = medianCm();        // filtered read from the previous tutorial
    if (d > best) { best = d; bestA = ANGLES[i]; }
  }
  scanner.write(90);            // re-centre for driving
  return bestA;                 // > 90 = open to the left, < 90 = right
}

不要在运动途中读

这里最常见的单一缺陷是在舵机还没到位时就读。servo.write() 立刻返回,但 SG90 摆到新角度需要时间。ping 发得太早,量到的距离属于传感器原来所在的地方,而不是你让它指的地方。每次 write() 之后加一个短 delay——够舵机稳定就行——于是每一次读数都可信。

把扫描变成决策

距离最大的那个角度就是最空旷的方向。有两处改进能让它保持合理:

  • **平局时优先正前方。**两个方向读数接近时,向中心偏一点,免得机器人没必要地来回摇摆。
  • **所有方向都很近?**那是死胡同——退回去重新扫,而不是继续往里钻。

覆盖度与速度

角度越多,图越细,但循环越慢,而慢循环意味着机器人反应更迟。三到五个角度——左、前、右,再加上中间的一两个——是常规的甜点。把那个“最空旷”的决策交给避障算法,并且在动手做之前先在避障模拟器里把整段行为试一遍。

舵机到底要多久?

“加一个短延时”是上面代码里最含糊的一条指令,而它值得换成一个你量出来的数。SG90 的规格大约是空载、4.8 V 下每 60° 0.1 s——所以稳定时间取决于它要走多远:

扫描步 行程时间(规格) 加上稳定 采用
30° 50 ms 约 30 ms 80 ms
60° 100 ms 约 40 ms 140 ms
90° 150 ms 约 40 ms 190 ms
180°(从端头回中) 300 ms 约 50 ms 350 ms

有两样东西让真实值比规格更糟。负载:舵盘上挂着一颗 HC-SR04 和它的线缆,量不大但不为零,它会让舵机变慢并增加过冲。电压:在电池塌到 4.0 V 时,同一台舵机明显比 6 V 时慢。

去量你自己的,别信这张表。下发一个 60° 的步进,然后测到距离读数不再变化为止要多久:

void measureSettle() {
  scanner.write(60);  delay(500);            // known start
  scanner.write(120);                        // command the step
  const unsigned long t0 = millis();
  long prev = -1;
  for (int i = 0; i < 40; i++) {             // sample every 10 ms
    const long d = pingCm();
    Serial.print(millis() - t0); Serial.print(' '); Serial.println(d);
    delay(10);
  }
}

读数会在某一个确定时刻稳定下来。用那个数加上一点余量,你就消掉了整段行为里最大的胡来来源。

单方向扫描比跳着扫快

上面那段代码是 30 → 60 → 90 → 120 → 150 逐档走,这本身已经是单方向扫描,而这是有意为之。按零散的顺序读角度,意味着每一步都是大步,而稳定代价按步数累加:

方式 总行程 按约 1.7 ms/度的稳定代价
按序扫描(30→150) 120° 约 200 ms 加五次短稳定
从中心向外(90、60、120、30、150) 300° 约 510 ms
每次都回中心 480° 约 820 ms

一次完整扫描是机器人的失明时刻——它停着、没有进展——所以把它的时间减半,对机器人给人的感觉是实实在在的改善。按序扫描;而如果你要反复扫描,就交替方向,这样永远不用为回程付费。

非阻塞扫描

每一步的 delay(180) 意味着一次五角度扫描要阻塞将近一秒。在一台反正已经停着的机器人上这可以忍受,但它也意味着碰撞开关不再被读、其他任何行为都被冻住。

用在 ping 上用过的那套状态机处理同样适用:

enum ScanState { SCAN_IDLE, SCAN_MOVING, SCAN_READING };

ScanState scanState = SCAN_IDLE;
uint8_t   scanIndex = 0;
unsigned long scanSince = 0;
long      scanResult[N];
bool      scanComplete = false;

void updateScan() {
  switch (scanState) {
    case SCAN_IDLE:
      break;

    case SCAN_MOVING:
      if (millis() - scanSince < SETTLE_MS) return;      // measured, above
      scanState = SCAN_READING;
      break;

    case SCAN_READING:
      scanResult[scanIndex] = medianCm();
      if (++scanIndex >= N) { scanComplete = true; scanState = SCAN_IDLE; scanner.write(90); }
      else { scanner.write(ANGLES[scanIndex]); scanSince = millis(); scanState = SCAN_MOVING; }
      break;
  }
}

主循环调用 updateScan() 然后继续往下走。碰撞开关继续被读,状态机继续跑,扫描在完成时完成。

在角度之间插值

五个读数给出五个方位,但真正的开口很少正好落在其中之一上。对那些“空”的读数取一次加权质心,就能给出采样之间的方位,做法和反射阵列在传感器之间给出线位置是同一个:

// Weight each angle by how open it is, ignoring anything below the stop threshold.
float openBearing() {
  float weighted = 0, total = 0;
  for (int i = 0; i < N; i++) {
    if (scanResult[i] < STOP_CM) continue;             // blocked, contributes nothing
    const float w = scanResult[i] - STOP_CM;           // how much clearance beyond the threshold
    weighted += w * ANGLES[i];
    total    += w;
  }
  return (total > 0) ? (weighted / total) : 90.0f;     // 90 = straight ahead
}

相比“取最大值”,这是一次实在的改进。取最大值意味着一扇被稍微采偏了中心的门会把机器人送到门框边上;质心则送到门的中间。它也优雅地退化——如果两个相邻角度都是空的,结果落在它们之间,而不是随意取其中一个。

那个锥限制了扫描能告诉你的事

HC-SR04 的波束大约有 15° 半角,在 1 m 处是一个约 53 cm 宽的锥。所以按 30° 间隔采样是大量重叠的——相邻读数大体上在看着同一片空间。

采样间距 在 1 m 处 后果
15° 锥几乎完全重叠 浪费时间;没有新信息
30° 可观重叠 实践甜点
45° 锥之间有轻微缝隙 可接受;可能漏掉窄物体
60° 真缝隙 椅子腿可以坐在两个采样之间而不被看见

重要的推论是:**扫描给你的角分辨率不会好过波束本身。**120° 处读到 68 cm 意味着“有东西在 68 cm 外,在 120° 上下约 15° 的某个位置”——用来选一个转弯方向完全够用,用来量缝隙的宽度则毫无用处。如果你需要后者,把一颗 VL53L0X装在同一台舵机上,它的光斑实际上很窄,能给出真正分辨开的扫描。

故障排查

现象 可能原因 解决
车自信地转进墙里 在舵机稳定前就读 量出稳定时间并使用它
读数在角度之间抹开 同上,但轻一些 加大稳定延时
扫描超过一秒 阻塞延时,或角度顺序零散 非阻塞状态机;按序扫描
扫描时板子复位 舵机电流走 Arduino 独立 5 V 供电,共地
车奔着门口边去 取了最大角度 改用加权质心
电机调速罢工 Servo 库占了 1 号定时器(9、10 脚) 把电机 PWM 挪到 3、5、6 或 11
读数之间舵机抖 电源噪声,或软件定时脉冲 舵机上并电容;还抖就上 PCA9685
椅子腿检测不到 采样间距比波束还宽 30° 间距,或者换窄束传感器
扫描没问题,车还是卡住 反应式导航没有记忆 预期如此——加一个卡住检测与脱逃

探索关联图谱

本教程所属的搭建

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

延伸阅读

参考资料