Tutorial · Principiante · 30 min de lectura
Por qué delay() rompe los robots: temporización con millis()
Un bucle bloqueado no ve nada. Lo que delay() le cuesta al robot en milímetros de colisión, el patrón con millis() que lo sustituye y el error de desborde.
Introducción
Casi todos los robots de este site ejecutan un bucle que tiene que hacer varias cosas a la vez: leer un sensor, actualizar un controlador, mover los motores, quizá responder a una radio. delay() es la primera herramienta de temporización que aprende cualquiera, y es la que hace todo eso imposible.
El problema no es que delay() sea lento. Es que durante un delay() el robot no está haciendo absolutamente nada — ni leer sus sensores, ni girar, ni notar que está a punto de estrellarse contra una pared. Es un programa que ha decidido detenerse por su cuenta.
Esta página trata de lo que eso cuesta de verdad, el patrón que lo sustituye, y el único bug que el sustituye obvio todavía tiene.
Lo que delay() hace en realidad
delay(50) gira en un bucle cerrado mirando un temporizador hasta que pasan 50 ms. Tu código no avanza. Lo único que sigue pasando son las interrupciones — el temporizador que mueve millis(), la interrupción de recepción serie, todo lo que hayas conectado con attachInterrupt().
Esa excepción importa y también es una trampa: un encoder contado en una interrupción sigue contando durante un delay(), así que el número sigue siendo correcto mientras que la conducta del robot se detiene. Lo que hace el fallo más difícil de ver, no más fácil.
Lo que cuesta, en milímetros
Un lazo de control solo puede reaccionar a la tasa a la que muestrea. Algo que aparece justo después de una lectura no se nota hasta la siguiente.
Veinticuatro milímetros es casi toda la matriz de sensores de un seguidor de línea. También es más que suficiente para salirse de una celda del laberinto, pasarse de un cruce, o hacer volcar un robot equilibrador más allá de la recuperación — ese necesita un lazo interior de 200 Hz, que son 5 ms de presupuesto para todo, y un solo delay(50) se gasta el equivalente a diez bucles. La latencia siempre es una distancia — lo lejos que recorre el robot antes de poder actuar es lo que decide si frena a tiempo o se sale por el borde.
El patrón que lo sustituye
En vez de detenerte hasta que sea la hora, pregunta en cada pasada si ya es la hora:
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();
}
Ahora loop() se ejecuta miles de veces por segundo y la lectura del sensor ocurre con su horario dentro de él. Nada queda bloqueado, y añadir un segundo trabajo temporizado son cuatro líneas más en vez de una reescritura.
El bug del desbordamiento
millis() devuelve un unsigned long — 32 bits en un AVR — que cuenta milisegundos desde el arranque. Se agota a los 2³² ms y vuelve a cero, lo que son 49,7 días.
Dos maneras de escribir la misma comparación, y solo una sobrevive a eso:
if (now - lastRead >= READ_INTERVAL) // correct
if (now >= lastRead + READ_INTERVAL) // wrong
Toma lastRead = 4294967290 con un intervalo de 100 ms, momentos antes del desbordamiento. lastRead + 100 desborda a 94, y now >= 94 es cierto de inmediato — así que el temporizador deja de esperar y se dispara en cada pasada hasta que el contador se pone al día. La forma con resta no tiene ese problema, porque la resta sin signo desborda exactamente de la forma que hace que la diferencia salga bien.
De aquí salen tres reglas:
- Resta siempre, nunca sumes.
now - last >= interval. - Mantén todas las marcas de tiempo como
unsigned long. Guardarmillis()en uninto en unlongcon signo reintroduce el bug al instante, y en un AVR elintdesborda a los 32 segundos. - Nunca compruebes igualdad.
now == targetes una moneda al aire; una pasada ocupada se salta el valor de largo y el evento no se dispara nunca.
micros() merece su propio aviso. Desborda a los 2³² microsegundos, que son 71,6 minutos — un tiempo de ejecución perfectamente normal en un robot. Las mismas reglas, mucho antes.
Varias cosas, a ritmos distintos
Una vez que hay más de dos o tres trabajos temporizados, dales una tabla en vez de un montón de variables:
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();
}
}
}
Eso es un planificador cooperativo, y es todo lo que la mayoría de los robots necesitan jamás. Dos notas sobre él:
t.last = now reinicia el reloj desde el momento en que la tarea se ejecutó realmente, así que una pasada tardía hace que la siguiente también sea tardía y la cadencia se desvanece poco a poco. t.last += t.interval mantiene la cadencia en su lugar — mejor para un lazo de control, peor si una alguna vez se pasa de su intervalo, porque entonces intenta recuperar el tiempo perdido y se ejecuta dos veces seguidas. Usa += para el bucle cuya temporización importa y = now para todo lo demás.
Y todo el asunto depende de que ninguna tarea bloquee. Un solo delay() dentro de reportStatus() y todas las demás tareas llegan tarde.
Cuando bloquear sigue siendo la respuesta correcta
No toda espera es un error. La prueba es si es más corta que una pasada del bucle.
delayMicroseconds(10)para hacer el pulso de disparo de un HC-SR04 está bien. Diez microsegundos no son nada, y no hay forma no bloqueante de producirlo.pulseIn()no está bien. Bloquea hasta que el eco vuelve o hasta que expira el tiempo de espera. Un HC-SR04 en su límite de 4 m es un viaje de ida y vuelta de 8 m, que a 343 m/s son 23 ms — y un eco perdido te cuesta todo el tiempo de espera que hayas puesto. Por eso aquel tutorial insiste en poner uno.Serial.print()esconde un retardo. Devuelve de inmediato mientras el buffer de 64 bytes tenga sitio, y bloquea cuando no lo tiene. A 9600 baudios el buffer tarda 67 ms en vaciarse, así que imprimir una línea de 40 caracteres en cada pasada a 100 Hz detendrá el bucle sin duda. Usa 115200, imprime menos e imprime con un temporizador lento.
El tiempo de tu bucle es un número real
Deja de adivinarlo:
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
}
Sigue la pasada peor, no la media. La media te dice que el bucle va sobrado; la peor te dice lo que el lazo de control realmente tiene que aguantar, y suele ser un Serial.print, un tiempo de espera de pulseIn, o una lectura I²C que tuvo que esperar.
Esto importa sobre todo para un controlador PID. El término derivativo divide un cambio de error por el tiempo entre muestras, así que el jitter en ese intervalo se convierte directamente en ruido en la D — y las ganancias ajustadas a una tasa de bucle dejan de ser correctas a otra. O ejecutas el bucle a un intervalo fijo y tratas dt como constante, o mides dt en cada pasada y usas el valor real. No hacer ninguna de las dos cosas es la razón por la que un controlador que se comportaba en el banco se porta mal en cuanto añades el código de la radio.
Qué usar en su lugar
| En vez de | Usa |
|---|---|
delay() para espaciar una lectura de sensor |
Una comprobación de intervalo con millis() |
delay() para parpadear un LED |
Un temporizador en el mismo bucle |
delay() para esperar a que un servo llegue |
Estima el tiempo de recorrido y comprueba millis() |
delay() para hacer antirebote de un botón |
Marca el flanco en el tiempo e ignora cambios durante 20 ms |
delay() entre pasos de velocidad de motor |
Un perfil de movimiento dirigido por el tiempo transcurrido |
delay() para frenar el bucle entero |
Nada — corre rápido y temporiza los trabajos por separado |
pulseIn() sin tiempo de espera |
pulseIn(pin, HIGH, 30000) y una rama de «sin eco» |
Cuando va mal
| Síntoma | Normalmente |
|---|---|
| El robot ignora su bumper mientras gira | Un delay() dentro del giro |
| Las lecturas llegan a ráfagas | Todo compartiendo un temporizador en vez de intervalos separados |
| Bien en el banco, perezoso con el registro activo | Serial.print bloqueando cuando se llena el buffer |
| El temporizador se dispara continuamente a los ~50 días | La forma now >= last + interval |
| El temporizador se porta mal a la hora aproximadamente | Desbordamiento de micros() a los 71,6 minutos |
| El temporizador no se dispara nunca | Marca de tiempo guardada en int, o una prueba de igualdad |
| PID bien ajustado y luego se comporta distinto | La tasa de bucle cambió; dt no es lo que las ganancias asumían |
| El encoder cuenta bien pero el robot reacciona tarde | Las interrupciones siguen corriendo durante delay() — el conteo no es el problema |
El hábito que merece la pena construir es tratar loop() como algo que siempre tiene que volver rápido. Una vez que eso es cierto, añadir una función es añadir una tarea, y el robot conserva sus reflejos. Una vez que no lo es, cada función nueva deja al robot un poco más ciego, y la causa nunca está donde está el síntoma.
Explora el grafo
Forma parte de estos montajes
Proyectos y rutas de aprendizaje que incluyen este tutorial.
- ProyectoConstruya un robot mecanum omnidireccional con ESP32
- ProyectoConstruye un plóter de dibujo: dos ejes paso a paso
- ProyectoConstruye un robot de cobertura de área para limpiar suelos
- ProyectoConstruye un robot mini sumo: tracción, cuña y borde
- ProyectoConstruye un robot pick and place con agarre verificado
- Ruta de aprendizajeFundamentos de electrónica para robótica: ruta guiada
Lecturas adicionales