教程 · 入门 · 20 分钟

读取 HC-SR04 超声波传感器:用代码测出可靠距离

如何在 Arduino 上读取 HC-SR04:使用带超时的 pulseIn、把回波时间换算成厘米,以及用中值滤波消除随机出现的尖峰。

一台做避障的机器人,转向质量就等于它所依据的那个距离。那个数来自一颗 HC-SR04 超声波传感器:它发出一声超声脉冲,听回声,而往返时间告诉你最近的东西有多远。本文把那一下原始的超声波脉冲变成一个你可以信赖的厘米距离——一个能扛住传感器丢包和游离回波、而不是把你的机器人送进墙里的距离。

传感器怎么报告距离

HC-SR04 说的是一门简单的双脚语言。你把 Trigger 拉高 10 微秒;它发出八串 40 kHz 脉冲,并把 Echo 拉高直到反射回来。Echo 保持高电平的时间就是往返时间,而声音大约以 343 m/s 传播,于是:

distance_cm = echo_time_us * 0.0343 / 2

除以二,是因为声音出去又回来了。零件页覆盖接线与物理;这里关心的是如何在代码里干净地读出它。

用 pulseIn 读它——并且永远要传超时

除了正确性之外,超时还有第二个要紧的原因:pulseIn 是阻塞的,所以一次漏掉的回波会把你的整个循环卡住你允许的那么久。一台移动中的机器人为此付出多少,见非阻塞时序。

pulseIn 测量一个引脚保持高电平多久。陷阱在这里:如果回波永远不回来——范围内没东西,或者有个软表面把声音散射掉了——pulseIn 会阻塞满它的默认超时,你的控制环就卡住了。永远传一个显式超时,按你的最大量程来定:

const int TRIG = 9, ECHO = 10;
const unsigned long TIMEOUT_US = 25000UL;  // ~4 m of round trip

long pingCm() {
  digitalWrite(TRIG, LOW);  delayMicroseconds(2);
  digitalWrite(TRIG, HIGH); delayMicroseconds(10);
  digitalWrite(TRIG, LOW);

  unsigned long us = pulseIn(ECHO, HIGH, TIMEOUT_US);
  if (us == 0) return -1;             // timed out: nothing in range
  return (long)(us * 0.0343 / 2.0);
}

超时时返回 -1 而不是 0 很重要。一个 0 读起来像“有障碍正贴着传感器”,而真实意思恰恰相反——“路是通的”。要把“没有回波”和“非常近”区分开。

原始读数为什么会跳

把 HC-SR04 对着墙,读数仍然会闪,因为真实世界是杂乱的:

  • 柔软或有角度的目标把回波散射走,于是你偶尔会得到一个巨大的值或者一次丢包。
  • 约 15° 的波束意味着传感器报告的是一个锥内最近的东西,所以旁边一条桌腿就能把读数顶上去。
  • 轮询太快(间隔短于约 60 ms)会让上一次的回波漏进下一次读数。

把那些跳变直接喂进“停车还是转弯”的决策,机器人就会抽搐。你需要在不加延迟的前提下把数据流平滑掉。

五点中值滤波

正确的工具是中值,不是平均值。平均会把一个野生的尖峰抹到好几次读数上;中值直接把它扔掉。取五次脉冲,用中间那个:

HC-SR04 距离读数折线图,展示机器人从约 58 cm 接近到 15 cm 的墙。原始信号(灰色,带标记)有一个冲到约 85 cm 的尖峰,标注“游离回波被剔除”,以及两次接近零的丢包,标注“超时被剔除”,而五点中值那条线(青色)穿过两者时始终平滑。
一遍五点中值同时剔除了游离的远回波和超时丢包,留下控制器能据以动作的平滑距离。 下载 SVG
int cmpLong(const void* a, const void* b) {
  return (*(long*)a) - (*(long*)b);
}

long medianCm() {
  long s[5];
  for (int i = 0; i < 5; i++) {
    long v = pingCm();
    s[i] = (v < 0) ? 400 : v;   // treat timeout as "far", not "touching"
    delay(30);                  // let the previous echo die away
  }
  qsort(s, 5, sizeof(long), cmpLong);
  return s[2];                  // the middle value
}

有两个细节让它健壮:把超时映射成一个很大的“远”值(这样一次丢失的回波永远不会读成有障碍),以及脉冲之间那个小 delay,让回波不互相串话。关于滤波而不过度平滑的理论,见传感器噪声、偏置与滤波。

别再阻塞循环

上面这些仍然会卡住循环。pulseIn 在等,delay(30) 在等,而五次间隔 30 ms 的脉冲就是 150 ms,这期间机器人不读任何别的传感器、也不做任何决策。在 0.3 m/s 下,那意味着每次测量 4.5 cm 的盲走。

解法是把这颗传感器做成一个小型状态机,让主循环一步一步推进它、从不等待:

enum PingState { IDLE, TRIGGERED, WAITING };

PingState state = IDLE;
unsigned long stateSince = 0;
long lastDistance = -1;

const unsigned long PING_PERIOD_MS = 60;   // let the previous echo die away
const unsigned long ECHO_TIMEOUT_MS = 30;  // beyond the sensor's range

void updatePing() {
  const unsigned long now = millis();

  switch (state) {
    case IDLE:
      if (now - stateSince < PING_PERIOD_MS) return;
      digitalWrite(TRIG, HIGH);
      stateSince = micros();          // note: microseconds for this short step
      state = TRIGGERED;
      break;

    case TRIGGERED:
      if (micros() - stateSince < 10) return;
      digitalWrite(TRIG, LOW);
      stateSince = now;
      state = WAITING;
      break;

    case WAITING:
      if (echoComplete) {             // set by the interrupt below
        lastDistance = echoMicros / 58.2;
        echoComplete = false;
        stateSince = now;
        state = IDLE;
      } else if (now - stateSince > ECHO_TIMEOUT_MS) {
        lastDistance = -1;            // no echo — far, or invisible
        stateSince = now;
        state = IDLE;
      }
      break;
  }
}

现在循环调用 updatePing() 然后继续往下走。lastDistance 永远是最近一次完成的测量,最多老 60 ms,而机器人身上别的东西都没有为它等待。

用中断给回波计时

状态机仍然需要知道回波什么时候到了,而主循环里轮询 digitalRead 要付出分辨率的代价。在 Echo 引脚的两个边沿上都挂中断则不花任何代价:

volatile unsigned long echoStart = 0;
volatile unsigned long echoMicros = 0;
volatile bool echoComplete = false;

void echoISR() {
  if (digitalRead(ECHO) == HIGH) {
    echoStart = micros();
  } else if (echoStart != 0) {
    echoMicros = micros() - echoStart;
    echoStart = 0;
    echoComplete = true;
  }
}

void setup() {
  pinMode(TRIG, OUTPUT);
  pinMode(ECHO, INPUT);
  attachInterrupt(digitalPinToInterrupt(ECHO), echoISR, CHANGE);
}

在 Arduino Uno 上这必须是 2 脚或 3 脚——仅有的两个外部中断引脚——而如果你的机器人有轮子编码器,它们两个都想要。这个冲突是真实的,也是一个项目迁到 Nano 的引脚变化中断、Mega 或 ESP32 的更常见原因之一。

注意那个 echoStart != 0 的保护。没有它,一个没有先来上升沿的下降沿——超时之后的第一次中断就会发生这种情况——会用过期的时间戳算出一个毫无意义的时长。

滤波怎么选

中值是正确的默认值,但知道其他几种到底在做什么也值得,因为它们的失效方式不同:

滤波 剔掉单个尖峰? 延迟 成本 什么时候用
无 否 0 — 移动的机器人上,永远不要
5 点均值 否——把尖峰抹到 5 个采样上 2 个采样 低 噪声是高斯的而不是尖峰式的
5 点中值 彻底 2 个采样 排序,n=5 时微不足道 超声波。这就是正确答案
3 点中值 能,一个尖峰 1 个采样 最低 你需要那份额外的响应速度
指数(EMA) 否 取决于 α 最低 平滑一个本来就干净的信号
先中值再 EMA 能,外加平滑 2 个采样 + α 低 快速环,两者都需要

超声波噪声不是高斯的——它是在一个本来稳定的信号上偶尔出现的野生离群点,而这恰恰是均值处理得很糟、中值处理得完美的那种情况。五个 30 cm 的读数里混进一个 400 cm 的尖峰,均值会动 74 cm,中值一动不动。

需要精度的话,温度也算一份

那个 58.2 的除数假定 343 m/s,也就是 20 °C 时的声速。它不是常数:

speed (m/s) = 331.3 + 0.606 x temperature_celsius
温度 相对 20 °C 假设的误差 在 2 m 处
0 °C 读远 3.5% 7 cm
10 °C 远 1.8% 3.5 cm
30 °C 近 1.7% 3.4 cm
40 °C 近 3.5% 7 cm

对避障来说完全无视这个——2 m 处 7 cm 不改变任何决策。对一台在量缝隙的机器人、或者冬天在车库里干活的机器人,就该补偿:

float cmPerMicrosecond(float tempC) {
  return (331.3f + 0.606f * tempC) / 20000.0f;   // /1e6 for us, /2 for round trip
}

如果机器人已经带着一颗 MPU-6050——或者几乎任何 I²C 传感器——你就白得了一个温度读数。

同时跑多颗传感器

两颗同时发脉冲的 HC-SR04 会各自听到对方那一声,在两边都产出乱七八糟的读数。它们必须轮流来:

const int SENSORS = 3;
int current = 0;
long distance[SENSORS];

void updateAll() {
  if (pingComplete(current)) {
    distance[current] = lastDistance;
    current = (current + 1) % SENSORS;    // round-robin
    startPing(current);
  }
}

代价是刷新率:三颗传感器各 60 ms,意味着任何单颗传感器每 180 ms 才更新一次。如果这太慢——在移动的机器人上往往如此——答案要么是少用几颗超声波传感器,要么混进一颗 VL53L0X,它用光,所以完全干扰不到声呐。

故障排查

现象 可能原因 解决
机器人随机卡死 pulseIn 没设超时 传超时,或者用非阻塞状态机
空房间里急停 把超时当成 0 cm 超时意味着远。返回一个哨兵值并处理它
读数剧烈交替 轮询快于约 60 ms 让上一次的回波死透
稳定但偏几个百分点 温度 用上面的公式补偿
两颗传感器读数疯狂 它们听到彼此 轮流来,绝不同时
台架上好、车上嘈杂 5 V 轨上的电机噪声 给传感器电源去耦;它的线远离电机线
偶发巨大值没被滤掉 用了均值而不是中值 用中值——均值是把尖峰抹开,不是剔除
ESP32 引脚不响应了 5 V 的 Echo 进了 3.3 V 的 GPIO Echo 加分压器;也查一下引脚还能不能用

接下来去哪

你现在有了一个干净的数:正前方的距离。有两件事建立在它之上。把传感器装到舵机上扫描,看哪一边是空的——用舵机扫描超声波传感器——再把结果喂进避障算法里的决策环。你也可以先在避障模拟器里把整段感知加转向的行为试一遍。

探索关联图谱

本教程所属的搭建

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

延伸阅读

参考资料