Tutorial · Principiante · 30 min

Perché delay() blocca i robot: temporizzazione con millis()

Scopri quanto costa delay() in millimetri, come millis() rende il ciclo non bloccante e quali bug nascono confrontando tempi durante l'overflow ciclico.

Introduzione

Quasi tutti i robot di questo sito eseguono un ciclo che deve fare più cose contemporaneamente: leggere un sensore, aggiornare un controllore, azionare i motori e forse rispondere a una radio. delay() è il primo strumento di temporizzazione che impara chiunque, ed è quello che rende impossibile tutto ciò.

Il problema non è che delay() sia lento. È che durante delay() il robot non fa assolutamente nulla: non legge i sensori, non sterza, non si accorge che sta per sbattere contro un muro. È un programma che ha deciso di fermarsi.

Questa pagina spiega che cosa costa davvero, il modello che lo sostituisce e l’unico bug che la sostituzione più ovvia contiene ancora.

Che cosa fa davvero delay()

delay(50) resta in un ciclo stretto osservando un timer finché non sono passati 50 ms. Il codice non avanza. Le uniche cose che continuano a verificarsi sono gli interrupt: il timer che gestisce millis(), l’interrupt di ricezione seriale e qualsiasi cosa tu abbia collegato con attachInterrupt().

Questa eccezione conta ed è anche una trappola: un encoder contato tramite interrupt continua a contare durante delay(), quindi il numero resta corretto mentre il comportamento del robot si ferma. Questo rende il guasto più difficile, non più facile, da individuare.

Quanto costa, in millimetri

Un ciclo di controllo può reagire solo alla frequenza con cui campiona. Un elemento che compare subito dopo una lettura non viene notato fino alla successiva.

Due linee temporali impilate su 220 millisecondi. Quella superiore, etichettata ritardo di 50 millisecondi fra le letture, mostra letture dei sensori come segni alti ogni 50 millisecondi con gli intervalli ombreggiati e contrassegnati come bloccati, senza null'altro in esecuzione. Quella inferiore, etichettata millis con ciclo di 5 millisecondi, mostra letture ravvicinate ogni 5 millisecondi. Una linea rossa tratteggiata indica un ostacolo che compare a 51 millisecondi; la linea temporale superiore lo nota 49 millisecondi dopo, quella inferiore 4 millisecondi dopo.
L'ostacolo compare subito dopo una lettura, quasi il caso peggiore per entrambi i cicli: è il numero che conta, perché un ciclo di controllo si giudica sulla latenza peggiore, non su quella media. A 0.5 m/s il robot bloccato percorre 24 mm prima di saperlo; quello campionato ne percorre 2 mm. Scarica SVG

Ventiquattro millimetri sono quasi tutta la matrice di sensori di un robot seguilinea. Sono anche più che sufficienti per uscire da una cella del labirinto, superare un incrocio o far superare a un robot bilanciato il limite di recupero: quest’ultimo richiede un ciclo interno a 200 Hz, quindi un budget di 5 ms per tutto, e un solo delay(50) ne consuma dieci cicli. La latenza è sempre una distanza: quanto il robot percorre prima di poter agire decide se si ferma in tempo o esce dal bordo.

Il modello che lo sostituisce

Invece di fermarti finché non è ora, a ogni passaggio chiediti se è ora:

unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 50;

void loop() {
  unsigned long now = millis();

  if (now - lastRead >= READ_INTERVAL) {
    lastRead = now;
    readSensor();
  }

  // everything else keeps running, every pass
  updateMotors();
  checkBumper();
}

Ora loop() viene eseguito migliaia di volte al secondo e la lettura del sensore avviene al momento giusto al suo interno. Nulla è bloccato e aggiungere una seconda operazione temporizzata richiede altre quattro righe, non una riscrittura.

Il bug di overflow ciclico

millis() restituisce un unsigned long — 32 bit su un AVR — che conta i millisecondi dall’avvio. Esaurisce i valori dopo 2³² ms e torna a zero: 49.7 giorni.

Ci sono due modi di scrivere lo stesso confronto, ma solo uno sopravvive a questo evento:

if (now - lastRead >= READ_INTERVAL)   // correct
if (now >= lastRead + READ_INTERVAL)   // wrong

Prendi lastRead = 4294967290 con un intervallo di 100 ms, poco prima del ritorno a zero. lastRead + 100 va in overflow e diventa 94, e now >= 94 è immediatamente vero: il timer smette di aspettare e si attiva a ogni passaggio finché il contatore non raggiunge quel valore. La forma con la sottrazione non soffre di questo problema, perché la sottrazione senza segno va in overflow esattamente nel modo che rende corretta la differenza.

Ne seguono tre regole:

  • Sottrai sempre, non addizionare mai. now - last >= interval.
  • Mantieni tutte le marche temporali come unsigned long. Memorizzare millis() in un int o in un long con segno reintroduce subito il bug; su un AVR, int va in overflow dopo 32 secondi.
  • Non verificare mai l’uguaglianza. now == target è una scommessa: un passaggio impegnato la supera e l’evento non si attiva mai.

micros() merita un avviso dedicato. Va in overflow dopo 2³² microsecondi, cioè 71.6 minuti: un tempo di funzionamento perfettamente normale per un robot. Stesse regole, molto prima.

Più operazioni, a frequenze diverse

Quando ci sono più di due o tre operazioni temporizzate, assegna loro una tabella invece di un mucchio di variabili:

struct Task {
  unsigned long last;
  unsigned long interval;
  void (*run)();
};

Task tasks[] = {
  { 0,   5, readSensors   },
  { 0,  10, updateControl },
  { 0, 200, reportStatus  },
};

void loop() {
  unsigned long now = millis();
  for (Task &t : tasks) {
    if (now - t.last >= t.interval) {
      t.last = now;
      t.run();
    }
  }
}

Questo è uno scheduler cooperativo ed è tutto ciò di cui la maggior parte dei robot ha bisogno. Due note:

t.last = now riavvia l’orologio dal momento in cui l’operazione viene davvero eseguita, quindi un passaggio in ritardo ritarda anche il successivo e la cadenza deriva lentamente. t.last += t.interval mantiene invece la cadenza: è meglio per un ciclo di controllo, ma peggio se un’operazione supera mai il proprio intervallo, perché cerca di recuperare il tempo eseguendosi a passaggi consecutivi. Usa += per il ciclo di cui conta il tempo e = now per tutto il resto.

Inoltre, tutto dipende dal fatto che nessuna operazione blocchi. Un solo delay() dentro reportStatus() ritarda tutte le altre operazioni.

Quando il bloccaggio è ancora la risposta giusta

Non ogni attesa è un errore. Il test è capire se è più breve di un passaggio del ciclo.

  • delayMicroseconds(10) per generare l’impulso di trigger di un HC-SR04 va bene. Dieci microsecondi non sono nulla e non esiste un modo non bloccante di generarlo.
  • pulseIn() non va bene. Resta bloccato finché l’eco non torna oppure scade il timeout. Un HC-SR04 al limite di 4 m percorre 8 m tra andata e ritorno, che a 343 m/s equivalgono a 23 ms; un eco mancato ti costa tutto il timeout impostato. Ecco perché quel tutorial insiste affinché ne imposti uno.
  • Serial.print() nasconde un ritardo. Ritorna subito mentre il buffer da 64 byte ha spazio e si blocca quando non ne ha più. A 9600 baud il buffer impiega 67 ms a svuotarsi, quindi stampare una riga di 40 caratteri a ogni passaggio a 100 Hz bloccherà sicuramente il ciclo. Usa 115200, stampa meno e usa un timer lento.

Il tempo del ciclo è un numero reale

Smetti di stimarlo a caso:

unsigned long worst = 0;

void loop() {
  unsigned long t0 = micros();

  // ... the whole loop body ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // report this on a slow timer, not here
}

Monitora il passaggio peggiore, non quello medio. La media indica che il ciclo è comodo; il peggiore indica ciò che il ciclo di controllo deve davvero sopportare ed è di solito una Serial.print, un timeout di pulseIn o una lettura I²C che ha dovuto attendere.

Questo conta soprattutto per un controllore PID. Il termine derivativo divide la variazione dell’errore per il tempo fra i campioni, quindi le fluttuazioni (jitter) di quell’intervallo diventano direttamente rumore su D e guadagni regolati a una frequenza di ciclo smettono di essere corretti a un’altra. Esegui il ciclo a intervalli fissi e considera dt costante, oppure misura dt a ogni passaggio e usa il valore reale. Il fatto di non fare né l’uno né l’altro è il motivo per cui un controllore che si comportava bene in laboratorio si comporta male quando aggiungi il codice della radio.

Che cosa usare invece

Invece di Usa
delay() per distanziare una lettura del sensore Controllo dell’intervallo con millis()
delay() per far lampeggiare un LED Un timer nello stesso ciclo
delay() per attendere l’arrivo di un servomotore Stima il tempo di corsa e controlla millis()
delay() per effettuare l’eliminazione del rimbalzo di un pulsante Assegna una marca temporale al fronte e ignora i cambi per 20 ms
delay() fra gli incrementi di velocità dei motori Un profilo di movimento basato sul tempo trascorso
delay() per rallentare l’intero ciclo Niente: esegui rapidamente e temporizza le singole operazioni
pulseIn() senza timeout pulseIn(pin, HIGH, 30000) e un ramo «nessun eco»

Quando qualcosa va storto

Sintomo Causa più probabile
Il robot ignora il paraurti mentre gira Un delay() dentro la manovra
Le letture del sensore arrivano a gruppi Tutto condivide un timer invece di avere intervalli separati
Funziona in laboratorio, ma è lento con la registrazione attiva Serial.print si blocca quando il buffer si riempie
Il timer si attiva continuamente dopo circa 50 giorni La forma now >= last + interval
Il timer si comporta in modo errato dopo circa un’ora Overflow ciclico di micros() dopo 71.6 minuti
Il timer non si attiva mai Marca temporale memorizzata in int, oppure test di uguaglianza
Il PID è ben regolato, poi si comporta diversamente È cambiata la frequenza del ciclo; dt non è il valore presupposto dai guadagni
L’encoder conta correttamente ma il robot reagisce in ritardo Gli interrupt continuano a funzionare durante delay(): il problema non sono i conteggi

L’abitudine da costruire è considerare loop() qualcosa che deve sempre tornare rapidamente. Quando è vero, aggiungere una funzionalità significa aggiungere un’operazione e il robot mantiene i propri riflessi. Quando non lo è, ogni nuova funzionalità rende il robot leggermente più cieco, e la causa non si trova mai dove appare il sintomo.

Esplora il grafo

Fa parte di queste costruzioni

Progetti e percorsi di apprendimento che includono questo tutorial.

Per approfondire

Fonti