Tutorial · Principiante · 20 min

Leggere un HC-SR04: distanza affidabile nel codice

Come leggere un HC-SR04 su Arduino: pulseIn con timeout, conversione del tempo dell'eco in centimetri e filtro mediano che elimina i picchi casuali.

Un robot che evita ostacoli è tanto efficace quanto la distanza su cui governa. Questo dato proviene da un sensore ultrasonico HC-SR04: emette un segnale, ascolta l’eco e il tempo di percorrenza di andata e ritorno indica quanto è lontano l’oggetto più vicino. Questa guida trasforma il segnale grezzo in una distanza in centimetri attendibile, resistente alle interruzioni del sensore e agli echi indesiderati, che altrimenti potrebbero mandare il robot contro un muro.

Come il sensore comunica la distanza

HC-SR04 parla un semplice linguaggio a due pin. Porti Trigger a HIGH per 10 microsecondi: il sensore emette otto impulsi da 40 kHz e mantiene Echo alto finché l’eco non torna. Il tempo per cui Echo resta HIGH è il tempo di percorrenza di andata e ritorno e, poiché il suono viaggia a circa 343 m/s, ottieni:

distance_cm = echo_time_us * 0.0343 / 2

La divisione per due dipende dal fatto che il suono ha percorso sia l’andata sia il ritorno. La pagina del componente spiega il cablaggio e la fisica; qui ci concentriamo su come leggerlo in modo pulito nel codice.

Leggilo con pulseIn e imposta sempre un timeout

Il timeout è importante anche per un secondo motivo oltre alla correttezza: pulseIn blocca, quindi un eco mancante ferma l’intero loop per l’intervallo consentito. Consulta la temporizzazione non bloccante per capire quanto ciò costi a un robot in movimento.

pulseIn misura per quanto tempo un pin resta HIGH. Il problema è questo: se l’eco non torna mai, perché non c’è nulla nel raggio d’azione o perché una superficie morbida disperde il suono, pulseIn resta bloccato per l’intero timeout predefinito e il loop di controllo si arresta. Passa sempre un timeout esplicito, dimensionato sulla portata massima:

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);
}

Restituire -1 per un timeout anziché 0 è importante. Un valore 0 indica «un ostacolo sta toccando il sensore», l’esatto contrario di «la via è libera». Devi mantenere distinte le condizioni «nessun eco» e «molto vicino».

Perché la lettura grezza oscilla

Se punti un HC-SR04 verso un muro, le letture oscillano comunque, perché il mondo reale è complesso:

  • Bersagli morbidi o inclinati disperdono l’eco lontano dal ricevitore, quindi occasionalmente ottieni un valore enorme o un’interruzione.
  • Il fascio di ~15° fa sì che il sensore riporti l’oggetto più vicino in un cono, perciò una gamba laterale di un tavolo può far salire di colpo una lettura breve.
  • Eseguire le interrogazioni troppo rapidamente (a meno di ~60 ms di distanza) permette all’eco del ping precedente di contaminare la lettura successiva.

Se passi questi improvvisi valori grezzi direttamente a una decisione di arresto o svolta, il robot si muove a scatti. Devi uniformare il flusso senza aggiungere ritardo.

Un filtro mediano su 5 valori

Lo strumento giusto è la mediana, non la media. Una media distribuisce un singolo picco anomalo su più letture; la mediana lo scarta semplicemente. Effettua cinque ping e usa quello centrale:

Grafico a linee delle letture di distanza di HC-SR04 mentre un robot si avvicina a un muro da circa 58 cm a 15 cm. Il segnale grezzo (grigio, con marcatori) presenta un picco fino a circa 85 cm etichettato 'eco indesiderato respinto' e due interruzioni vicino a zero etichettate 'timeout respinto', mentre la linea della mediana su 5 valori (ciano) resta uniforme in entrambi i casi.
Una singola mediana su 5 valori respinge sia l'eco lontano indesiderato sia le interruzioni per timeout, lasciando una distanza uniforme su cui il controllore può agire. Scarica 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
}

Due dettagli rendono questa soluzione robusta: mappare un timeout su un valore elevato di «lontano», così un eco perso non viene mai letto come ostacolo, e inserire un piccolo delay tra i ping, così gli echi non si sovrappongono. Per la teoria sul filtraggio senza eccessiva uniformazione, consulta rumore dei sensori, bias e filtraggio.

Smetti di bloccare il loop

Tutto quanto sopra ferma ancora il loop. pulseIn attende, delay(30) attende e cinque ping distanti 30 ms richiedono 150 ms nei quali il robot non legge altri sensori e non prende decisioni. A 0.3 m/s sono 4.5 cm di percorso alla cieca per ogni misurazione.

La soluzione è comandare il sensore tramite una piccola macchina a stati che il loop principale attraversa senza mai attendere:

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;
  }
}

Ora il loop chiama updatePing() e prosegue. lastDistance è sempre la misurazione completata più recente, risalente al massimo a 60 ms, e nient’altro nel robot ha atteso per ottenerla.

Misurare l’eco con un interrupt

La macchina a stati deve ancora sapere quando è arrivato l’eco e interrogare digitalRead nel loop principale costa risoluzione. Un interrupt su entrambi i fronti del pin Echo non costa nulla:

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);
}

Su un Arduino Uno deve essere il pin 2 o 3, gli unici due pin con interrupt esterni, e se il robot ha encoder delle ruote li vuole entrambi. Il conflitto è reale ed è uno dei motivi più comuni per cui un progetto passa agli interrupt su cambio di pin di un Nano, a un Mega o a un ESP32.

Nota la protezione echoStart != 0. Senza di essa, un fronte di discesa senza un precedente fronte di salita, cosa che accade al primo interrupt dopo un timeout, calcolerebbe una durata priva di senso partendo da una marca temporale obsoleta.

Scegliere un filtro

La mediana è la scelta predefinita corretta, ma vale la pena sapere cosa fanno davvero le alternative, perché falliscono in modi diversi:

Filtro Respinge un singolo picco? Ritardo Costo Quando usarlo
Nessuno No 0 — Mai, su un robot in movimento
Media di 5 No: distribuisce il picco su 5 campioni 2 campioni Basso Il rumore è gaussiano, non a picchi
Mediana di 5 Completamente 2 campioni Ordinamento, trascurabile con n=5 Ultrasuoni. È la risposta giusta
Mediana di 3 Sì, un picco 1 campione Il più basso Ti serve una reattività maggiore
Esponenziale (EMA) No Dipende da α Il più basso Uniformare un segnale già pulito
Mediana e poi EMA Sì, più uniformazione 2 campioni + α Basso Loop veloci che richiedono entrambe

Il rumore ultrasonico non è gaussiano: si tratta di valori anomali estremi occasionali su un segnale altrimenti stabile, ossia proprio il caso in cui una media gestisce male il segnale e una mediana lo gestisce in modo perfetto. Un picco di 400 cm in un insieme di cinque letture da 30 cm sposta la media di 74 cm e la mediana di nulla.

Temperatura, se ti serve la precisione

Il divisore 58.2 presuppone 343 m/s, la velocità del suono a 20 °C. Non è costante:

speed (m/s) = 331.3 + 0.606 x temperature_celsius
Temperatura Errore rispetto all’ipotesi di 20 °C A 2 m
0 °C Legge il 3.5% troppo lontano 7 cm
10 °C 1.8% troppo lontano 3.5 cm
30 °C 1.7% troppo vicino 3.4 cm
40 °C 3.5% troppo vicino 7 cm

Per evitare ostacoli, ignora del tutto questo aspetto: 7 cm a 2 m non cambiano alcuna decisione. Per un robot che misura uno spazio, o che lavora in un garage d’inverno, compensa:

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

Se il robot monta già un MPU-6050, o praticamente qualsiasi sensore I²C, hai a disposizione una lettura della temperatura senza alcun costo aggiuntivo.

Utilizzare più sensori

Due HC-SR04 che emettono ping contemporaneamente sentiranno entrambi il segnale dell’altro, producendo letture estreme su entrambi. Devono alternarsi:

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);
  }
}

Il costo è la frequenza di aggiornamento: tre sensori a 60 ms ciascuno significano che ogni singolo sensore si aggiorna ogni 180 ms. Se è troppo lento, cosa che spesso accade su un robot in movimento, la risposta è usare meno sensori ultrasonici oppure aggiungere un VL53L0X, che usa la luce e quindi non può interferire in alcun modo con il sonar.

Risoluzione dei problemi

Sintomo Causa probabile Soluzione
Il robot si blocca in modo casuale pulseIn senza timeout Passa un timeout oppure usa la macchina a stati non bloccante
Arresti di emergenza in una stanza vuota Il timeout viene trattato come 0 cm Un timeout significa lontano. Restituisci un valore sentinella e gestiscilo
Le letture oscillano in modo estremo Interrogazioni più rapide di ~60 ms Lascia estinguersi l’eco precedente
Valori stabili ma errati di qualche percentuale Temperatura Compensa con la formula precedente
Letture estreme con due sensori Si sentono a vicenda Alternanza circolare, mai simultanea
Funziona sul banco, ma è rumoroso sul robot Rumore dei motori sulla linea dei 5 V Disaccoppia l’alimentazione del sensore; tieni i suoi cavi lontani dai cavi dei motori
Un valore enorme sporadico supera il filtro Media anziché mediana Usa la mediana: la media distribuisce il picco, non lo respinge
Un pin dell’ESP32 ha smesso di rispondere Eco a 5 V su un GPIO a 3.3 V Dividitore di tensione su Echo; verifica che il pin funzioni ancora

Come proseguire

Ora hai un unico dato pulito: la distanza frontale. Da qui puoi fare altri due passaggi. Monta il sensore su un servomotore ed effettua una scansione per vedere in che direzione c’è spazio, con una scansione servo di un sensore ultrasonico, e alimenta il risultato al loop decisionale dell’algoritmo di evitamento ostacoli. Prima puoi provare l’intero comportamento di rilevamento e sterzo nel simulatore di evitamento ostacoli.

Esplora il grafo

Fa parte di queste costruzioni

Progetti e percorsi di apprendimento che includono questo tutorial.

Per approfondire

Fonti