教程 · 入门 · 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)会让上一次的回波漏进下一次读数。
把那些跳变直接喂进“停车还是转弯”的决策,机器人就会抽搐。你需要在不加延迟的前提下把数据流平滑掉。
五点中值滤波
正确的工具是中值,不是平均值。平均会把一个野生的尖峰抹到好几次读数上;中值直接把它扔掉。取五次脉冲,用中间那个:
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 加分压器;也查一下引脚还能不能用 |
接下来去哪
你现在有了一个干净的数:正前方的距离。有两件事建立在它之上。把传感器装到舵机上扫描,看哪一边是空的——用舵机扫描超声波传感器——再把结果喂进避障算法里的决策环。你也可以先在避障模拟器里把整段感知加转向的行为试一遍。
探索关联图谱
本教程所属的搭建
包含本教程的项目和学习路线。
延伸阅读