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.
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. Memorizzaremillis()in uninto in unlongcon segno reintroduce subito il bug; su un AVR,intva 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.
- ProgettoCostruisci un plotter a penna: due assi passo-passo che arrivano
- ProgettoCostruisci un robot di copertura che percorre un'intera stanza
- ProgettoCostruisci un robot di prelievo e posa che sa quando ha mancato
- ProgettoCostruisci un robot Mecanum che si muove lateralmente
- ProgettoMini sumo robot: presa, cuneo e rilevamento del bordo
- PercorsoFondamenti di elettronica robotica: percorso di apprendimento
Per approfondire