Tutorial · Principiante · 20 min
Leer un sensor ultrasónico HC-SR04: distancia fiable en código
Cómo leer un HC-SR04 en Arduino: pulseIn con timeout, conversión del tiempo de eco a centímetros y un filtro de mediana que elimina picos aleatorios.
Un robot que evita obstáculos solo es tan bueno como la distancia sobre la que decide. Ese número proviene de un sensor ultrasónico HC-SR04: emite un chirrido, escucha el eco y el tiempo de ida y vuelta indica a qué distancia está lo más cercano. Esta guía convierte ese ping en bruto en una distancia en centímetros en la que puedes confiar, una que sobrevive a las pérdidas y a los ecos parásitos del sensor en lugar de enviar tu robot contra una pared.
Cómo informa la distancia el sensor
El HC-SR04 habla un lenguaje sencillo de dos pines. Pones Trigger en HIGH durante 10 microsegundos; dispara ocho ráfagas de 40 kHz y mantiene Echo en alto hasta que vuelve la reflexión. El tiempo que Echo permanece en HIGH es el tiempo de ida y vuelta, y el sonido viaja a unos 343 m/s, así que:
distance_cm = echo_time_us * 0.0343 / 2
La división entre dos se debe a que el sonido fue y volvió. La página del componente cubre el cableado y la física; aquí nos interesa leerlo de forma limpia en código.
Léelo con pulseIn, y pon siempre un tiempo de espera
El tiempo de espera importa por una segunda razón además de la corrección: pulseIn bloquea, así que un eco perdido detiene todo tu bucle durante el tiempo que permitas. Consulta temporización no bloqueante para ver lo que eso le cuesta a un robot en movimiento.
pulseIn mide cuánto tiempo permanece un pin en HIGH. La trampa: si el eco nunca vuelve —nada al alcance, o una superficie blanda que dispersa el sonido—, pulseIn se bloquea durante todo su tiempo de espera por defecto y tu bucle de control se detiene. Pasa siempre un tiempo de espera explícito dimensionado para tu alcance máximo:
const int TRIG = 9, ECHO = 10;
const unsigned long TIMEOUT_US = 25000UL; // ~4 m de ida y vuelta
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; // tiempo agotado: nada al alcance
return (long)(us * 0.0343 / 2.0);
}
Devolver -1 ante un tiempo de espera —en lugar de 0— importa. Un 0 se lee como «un obstáculo está tocando el sensor», que es lo contrario de «el camino está libre». Mantén «sin eco» separado de «muy cerca».
Por qué salta la lectura en bruto
Apunta un HC-SR04 a una pared y las lecturas siguen parpadeando, porque el mundo real es desordenado:
- Los objetivos blandos o inclinados dispersan el eco lejos del receptor, así que obtienes un valor enorme ocasional o una pérdida.
- El haz de ~15° significa que el sensor informa de lo más cercano en un cono, así que una pata de mesa al lado puede provocar una lectura corta con picos.
- Muestrear demasiado rápido (con menos de ~60 ms entre pings) deja que el eco del ping anterior se filtre en la siguiente lectura.
Si introduces esos saltos en bruto directamente en una decisión de parar o girar, el robot se agita. Necesitas suavizar el flujo sin añadir retardo.
Un filtro de mediana de 5
La herramienta adecuada es una mediana, no un promedio. Un promedio reparte un único pico salvaje entre varias lecturas; una mediana simplemente lo descarta. Toma cinco pings y usa el del medio:
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; // trata el tiempo de espera como «lejos», no como «tocando»
delay(30); // deja que el eco anterior se extinga
}
qsort(s, 5, sizeof(long), cmpLong);
return s[2]; // el valor central
}
Dos detalles hacen que esto sea robusto: mapear un tiempo de espera a un valor grande de «lejos» (para que un eco perdido nunca se lea como un obstáculo), y el pequeño delay entre pings para que los ecos no se interfieran. Para la teoría de filtrar sin suavizar en exceso, consulta ruido, sesgo y filtrado de sensores.
Deja de bloquear el bucle
Todo lo anterior sigue deteniendo el bucle. pulseIn espera, delay(30) espera, y cinco pings con 30 ms entre ellos son 150 ms durante los cuales el robot no lee ningún otro sensor ni toma ninguna decisión. A 0,3 m/s eso son 4,5 cm de recorrido a ciegas por medición.
La solución es manejar el sensor como una pequeña máquina de estados que el bucle principal avanza sin esperar nunca:
enum PingState { IDLE, TRIGGERED, WAITING };
PingState state = IDLE;
unsigned long stateSince = 0;
long lastDistance = -1;
const unsigned long PING_PERIOD_MS = 60; // deja que el eco anterior se extinga
const unsigned long ECHO_TIMEOUT_MS = 30; // más allá del alcance del sensor
void updatePing() {
const unsigned long now = millis();
switch (state) {
case IDLE:
if (now - stateSince < PING_PERIOD_MS) return;
digitalWrite(TRIG, HIGH);
stateSince = micros(); // nota: microsegundos para este paso corto
state = TRIGGERED;
break;
case TRIGGERED:
if (micros() - stateSince < 10) return;
digitalWrite(TRIG, LOW);
stateSince = now;
state = WAITING;
break;
case WAITING:
if (echoComplete) { // puesto por la interrupción de abajo
lastDistance = echoMicros / 58.2;
echoComplete = false;
stateSince = now;
state = IDLE;
} else if (now - stateSince > ECHO_TIMEOUT_MS) {
lastDistance = -1; // sin eco — lejos, o invisible
stateSince = now;
state = IDLE;
}
break;
}
}
El bucle ahora llama a updatePing() y sigue adelante. lastDistance es siempre la medición completada más reciente, con como máximo 60 ms de antigüedad, y nada más en el robot ha tenido que esperarla.
Medir el eco con una interrupción
La máquina de estados sigue necesitando saber cuándo llegó el eco, y sondear digitalRead en el bucle principal cuesta resolución. Una interrupción en ambos flancos del pin Echo no cuesta nada:
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);
}
En un Arduino Uno esto debe ser el pin 2 o 3 —los dos únicos pines de interrupción externa— y si tu robot tiene encoders de rueda, ellos quieren ambos. Ese conflicto es real, y es una de las razones más comunes por las que un proyecto se pasa a las interrupciones por cambio de pin de un Nano, a un Mega o a un ESP32.
Fíjate en la guarda echoStart != 0. Sin ella, un flanco de bajada que llegue sin un flanco de subida previo —lo que ocurre en la primera interrupción tras un tiempo de espera— calcula una duración sin sentido a partir de una marca de tiempo obsoleta.
Elegir un filtro
La mediana es el valor por defecto correcto, pero conviene saber qué hacen realmente las alternativas, porque fallan de forma distinta:
| Filter | Rejects a single spike? | Lag | Cost | Use when |
|---|---|---|---|---|
| None | No | 0 | — | Nunca, en un robot en movimiento |
| Mean of 5 | No — reparte el pico entre 5 muestras | 2 samples | Low | El ruido es gaussiano, no con picos |
| Median of 5 | Por completo | 2 samples | Sorting, trivial at n=5 | Ultrasónico. Esta es la respuesta correcta |
| Median of 3 | Sí, un pico | 1 sample | Lowest | Necesitas la capacidad de respuesta extra |
| Exponential (EMA) | No | Depends on α | Lowest | Suavizar una señal ya limpia |
| Median then EMA | Sí, más suavizado | 2 samples + α | Low | Bucles rápidos que necesitan ambas cosas |
El ruido ultrasónico no es gaussiano —son valores atípicos salvajes ocasionales sobre una señal por lo demás estable, que es justo el caso que una media gestiona mal y una mediana gestiona a la perfección. Un pico de 400 cm en un conjunto de cinco lecturas de 30 cm mueve la media 74 cm y la mediana nada en absoluto.
Temperatura, si necesitas la precisión
El divisor 58,2 supone 343 m/s, que es la velocidad del sonido a 20 °C. No es constante:
speed (m/s) = 331.3 + 0.606 x temperature_celsius
| Temperature | Error against a 20 °C assumption | At 2 m |
|---|---|---|
| 0 °C | Lee un 3,5 % de más | 7 cm |
| 10 °C | 1,8 % de más | 3,5 cm |
| 30 °C | 1,7 % de menos | 3,4 cm |
| 40 °C | 3,5 % de menos | 7 cm |
Para evitación de obstáculos, ignóralo por completo —7 cm a 2 m no cambian ninguna decisión. Para un robot que mide un hueco, o uno que trabaja en un garaje en invierno, compensa:
float cmPerMicrosecond(float tempC) {
return (331.3f + 0.606f * tempC) / 20000.0f; // /1e6 for us, /2 for round trip
}
Si el robot ya lleva un MPU-6050 —o casi cualquier sensor I²C— tienes una lectura de temperatura disponible gratis.
Usar varios sensores
Dos HC-SR04 que emiten a la vez escucharán cada uno el chirrido del otro, produciendo lecturas salvajes en ambos. Deben turnarse:
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);
}
}
El coste es la tasa de refresco: tres sensores a 60 ms cada uno significa que cada sensor individual se actualiza cada 180 ms. Si eso es demasiado lento —y en un robot en movimiento a menudo lo es— la respuesta es o menos sensores ultrasónicos, o mezclar un VL53L0X, que usa luz y por tanto no puede interferir con un sonar en absoluto.
Solución de problemas
| Symptom | Likely cause | Fix |
|---|---|---|
| El robot se congela al azar | pulseIn sin tiempo de espera |
Pasa un tiempo de espera, o usa la máquina de estados no bloqueante |
| Paradas de emergencia en una sala vacía | Tratar un tiempo de espera como 0 cm | Un tiempo de espera significa lejos. Devuelve un centinela y gestiónalo |
| Las lecturas alternan de forma salvaje | Muestreo más rápido que ~60 ms | Deja que el eco anterior se extinga |
| Estable pero con un pequeño error porcentual | Temperatura | Compensa con la fórmula de arriba |
| Lecturas salvajes con dos sensores | Se escuchan entre sí | Turnos rotativos, nunca simultáneos |
| Bien en el banco, ruidoso en el robot | Ruido del motor en el riel de 5 V | Desacopla la alimentación del sensor; aparta sus cables de los del motor |
| Un valor enorme ocasional sobrevive al filtro | Media en lugar de mediana | Usa la mediana —una media difumina un pico, no lo rechaza |
| Un pin del ESP32 dejó de responder | Eco de 5 V en un GPIO de 3,3 V | Divisor en Echo; comprueba si el pin sigue funcionando |
A dónde va esto ahora
Ya tienes un número limpio: la distancia al frente. Dos cosas se construyen sobre él. Monta el sensor en un servo y barrrelo para ver qué dirección está abierta —barrer un sensor ultrasónico con un servo— y mete el resultado en el bucle de decisión de el algoritmo de evitación de obstáculos. Puedes probar primero todo el comportamiento de sentir y girar en el simulador de evitación de obstáculos.
Explora el grafo
Forma parte de estos montajes
Proyectos y rutas de aprendizaje que incluyen este tutorial.
Lecturas adicionales