← Índice del curso

Robótica y Automatización · Tema 37

Máquinas de estados sin bloqueos

Dibujar una máquina de estados es una cosa; hacerla funcionar en un microcontrolador que además debe leer pulsadores, medir tiempos y atender otras tareas es otra. La regla de oro es una sola: el programa nunca espera. Este tema muestra cómo escribir los estados, los eventos y los tiempos para que el loop() siga girando miles de veces por segundo.

01 · Punto de partida

Del modelo al programa

En el tema 36 la máquina de estados era un modelo: funciones puras que, dado un estado y un evento, devuelven el estado siguiente. Pero un equipo real no recibe eventos «ya hechos». Tiene que generarlos leyendo pulsadores y sensores, y tiene que medir el tiempo: «pasaron 3 s en amarillo», «el portón lleva 20 s moviéndose». Y todo eso, mientras sigue pendiente de lo demás.

La forma ingenua de esperar es delay(). Es sencilla y funciona en un ejemplo de laboratorio; en cuanto el programa debe hacer dos cosas a la vez, se vuelve un obstáculo. Este tema explica por qué y qué hacer en su lugar.

Es la misma idea del ciclo de scan de un PLC (tema 35) y de los temas 16 y 19 sobre movimiento sin bloqueo: leer, decidir, actuar, y volver enseguida.

02 · Por qué no esperar

El costo de esperar

Considerá este programa, que hace parpadear un LED y quiere reaccionar a un pulsador:

void loop() {
  if (digitalRead(PIN_BOTON) == LOW) accionar();   // se mira UNA vez por vuelta
  digitalWrite(PIN_LED, HIGH); delay(500);
  digitalWrite(PIN_LED, LOW);  delay(500);
}

Cada vuelta dura un segundo, y durante 999 ms el programa duerme: no ve el pulsador, no lee sensores, no atiende el puerto serie. Las consecuencias:

  • Latencia. Una pulsación que llega justo después de la lectura espera hasta un segundo para ser atendida.
  • Pulsaciones perdidas. Si el pulsador se aprieta y se suelta (100 ms) entre dos lecturas, el programa nunca se entera. No hay error: simplemente no pasa nada.
  • Imposibilidad de hacer dos cosas a la vez. Un segundo LED con otro ritmo, un motor que debe avanzar, un temporizador de seguridad: nada de eso puede correr durante un delay().
  • Seguridad. Una parada de emergencia leída por software puede tardar en ser atendida exactamente lo que dura la espera.

En general, la latencia máxima de un programa con esperas bloqueantes es la suma de los bloqueos de una vuelta. El laboratorio 1 la calcula: con dos delay(500) y un pulsador que se mantiene 100 ms, una pulsación a los 300 ms se pierde, y una a los 950 ms se atiende con 50 ms de retraso. El mismo programa sin bloqueo detecta ambas en menos de un milisegundo.

Para una máquina de estados, el efecto es peor: un delay(3000) dentro del estado AMARILLO congela la máquina durante tres segundos, y cualquier evento que llegue en ese lapso queda sin atender (la fotocélula, el botón de emergencia).

03 · El esqueleto

La estructura del loop

Un loop() sin bloqueos tiene siempre la misma forma, que reconoce el ciclo de scan:

  1. Leer las entradas y convertirlas en eventos (flancos, filtros, tiempos vencidos).
  2. Actualizar la máquina de estados: con cada evento, decidir la transición.
  3. Aplicar las salidas según el estado nuevo.
  4. Volver al principio, sin pausas.

Cada paso es rápido: unas decenas de microsegundos. La vuelta completa dura bastante menos que un milisegundo, de modo que el programa «mira» cada entrada miles de veces por segundo. Las funciones no deben contener esperas ni bucles largos: si una operación necesita tiempo, se divide en pasos que se avanzan una vez por vuelta (como el planificador del brazo en el tema 24).

Una condición de diseño que ayuda: un solo punto de salida. Solo el paso 3 escribe a los pines, de modo que el estado de las salidas siempre es una función de la máquina. Si cualquier parte del código puede escribir a un actuador, cuesta razonar sobre él.

04 · Medir sin esperar

El tiempo con millis()

La función millis() devuelve los milisegundos desde el arranque, como un entero sin signo de 32 bits. En lugar de «dormir tres segundos» se anota cuándo empezó el estado y se pregunta, en cada vuelta, si ya pasó el tiempo:

unsigned long tEntrada;                 // cuándo entramos al estado actual

void entrar(Estado nuevo) { estado = nuevo; tEntrada = millis(); }

if (millis() - tEntrada >= T_AMARILLO) entrar(Estado::ROJO);

Tres reglas que evitan los errores clásicos:

  • Restar siempre el instante de inicio: ahora − inicio ≥ duración. Nunca guardar un «instante de vencimiento» absoluto para compararlo con millis().
  • Usar unsigned long para todos los instantes y duraciones. Un int de 16 bits se desborda a los 32 s.
  • Comparar con ≥, no con ==. Si el programa se retrasa (una operación larga, una escritura serie), el instante exacto puede pasar sin ser visto, y la condición de igualdad nunca se cumple.

El contador de 32 bits se desborda y vuelve a cero cada 49,7 días. La resta de enteros sin signo lo maneja bien por una propiedad aritmética: es una resta módulo 232. Con el inicio en 4 294 967 290 y el contador ya vuelto a 10, la diferencia es 16 ms, que es lo correcto; una resta con signo daría un número enorme y negativo. Por eso el patrón «ahora − inicio» es seguro y los instantes absolutos no.

Para tareas periódicas (leer un sensor cada 20 ms) hay dos variantes:

if (ahora - ultimo >= PERIODO) { ultimo = ahora;  tarea(); }   // el período real crece con cada retraso
if (ahora - ultimo >= PERIODO) { ultimo += PERIODO; tarea(); }  // el período medio es exacto (con retrasos, se recupera)

La primera acumula el retraso de cada ejecución (deriva); la segunda mantiene el ritmo medio. Si interesa medir la duración real entre dos ejecuciones, se calcula con ahora − ultimo antes de actualizar, como hicimos con Δt en los temas 16 y 18.

Dentro de una máquina de estados, el tiempo se expresa como tiempoEnEstado(): lo transcurrido desde que se entró. Un estado con duración (AMARILLO: 3 s) simplemente espera esa condición. Los temporizadores del tema 35 (TON, TOF) son esta misma idea.

05 · De la entrada al evento

Generar eventos desde las entradas

Un pulsador mantenido durante medio segundo no debe producir cientos de eventos BOTON: debe producir uno. Y un contacto mecánico rebota (varias transiciones en unos milisegundos, tema 7). Se resuelve con dos técnicas:

  • Antirrebote por tiempo. Se acepta un cambio solo cuando la lectura se mantuvo estable un lapso mínimo (por ejemplo, 20 ms). Se hace con millis(), sin esperas.
  • Detección de flanco. El evento se genera una sola vez, al pasar de «suelto» a «apretado» (flanco de subida), no mientras esté apretado.
const unsigned long REBOTE_MS = 20;
bool estable = false, previa = false;
unsigned long tCambio = 0;

bool hayFlancoBoton() {                          // true una sola vez por pulsación
  const bool lectura = digitalRead(PIN_BOTON) == LOW;
  if (lectura != previa) { previa = lectura; tCambio = millis(); }        // cambió: empezar a medir
  if (millis() - tCambio >= REBOTE_MS && lectura != estable) {          // estable el tiempo suficiente
    estable = lectura;
    return estable;                              // solo el flanco de subida
  }
  return false;
}

Si pueden llegar varios eventos en la misma vuelta (un botón y un final de carrera al mismo tiempo), la máquina procesa uno por vez. Se puede guardar los pendientes en una cola circular pequeña (unos 8 elementos) y vaciar una por vuelta. Los eventos urgentes (emergencia) se atienden primero. Una cola que se llena indica que la máquina es demasiado lenta o que llegan más eventos de los que puede procesar: se debe detectar y registrar, no ignorar.

Los pedidos que llegan en un momento en que la máquina no los puede atender son una decisión de diseño: se ignoran, se guardan o se rechazan. En el semáforo de este tema, el pedido de cruce solo se registra con los autos en verde; uno que llega durante el ciclo de peatones se descarta (el siguiente peatón volverá a pulsar).

06 · Código

Tres formas de escribir los estados

Formas de implementar la lógica de transición.
FormaCómo esVentajasLímites
switch sobre el estadoUn caso por estado; en cada uno, los if de sus transicionesDirecto, fácil de leer, sin estructuras auxiliaresLa lógica queda repartida en el código; crece mal con muchos estados
Tabla de transicionesUn arreglo de filas (estado, condición, destino, acción) recorrido por un motor genéricoLos datos separados del motor; se ve toda la máquina junta; se verifica con herramientasMás abstracta; las condiciones se vuelven funciones
Punteros a función por estadoCada estado es una función que devuelve el siguienteCada estado se escribe aislado, con entrada y salida propiasMás difícil de depurar; no se ve la máquina entera

Para máquinas de hasta unos diez estados, el switch es suficiente y claro. Cuando la máquina crece o cambia seguido, la tabla paga su costo: agregar un estado es agregar filas, y el motor no se toca. Una fila típica:

struct Transicion {
  Estado desde;
  bool (*condicion)();        // función sin argumentos que devuelve true si la transición corresponde
  Estado hacia;
  void (*accion)();           // opcional: algo que hacer al transitar (puede ser nullptr)
};

const Transicion TABLA[] = {
  {Estado::AMARILLO, []() { return tiempoEnEstado() >= T_AMARILLO; }, Estado::ROJO, nullptr},
  // ...
};
// El motor: recorre las filas; la primera cuyo "desde" coincide con el estado y cuya condición se cumple, gana.

Las dos versiones deben dar exactamente el mismo comportamiento; el código de Python de este tema lo comprueba ejecutando ambas sobre la misma secuencia. En cualquier forma, conviene:

  • Acciones de entrada en una única función entrar(nuevo): cambia el estado, anota el instante, y hace lo que cada estado necesite al empezar (por ejemplo, borrar el pedido pendiente).
  • Salidas en un solo lugar (máquina de Moore, tema 36): aplicarSalidas() decide las luces por el estado.
  • Registro de cada transición con la hora. Es la herramienta de diagnóstico más barata.

07 · Lo que vuelve a bloquear

Trampas frecuentes

  • Un delay() escondido. En una biblioteca, en una función de utilidad o dentro de un caso del switch. Un solo delay(100) en un camino frecuente arruina la latencia.
  • Funciones bloqueantes de lectura. pulseIn() (hasta su tiempo de espera), Serial.readString() (espera el tiempo de espera del puerto), lecturas I2C lentas. Se usan con tiempos de espera cortos o se reemplazan por versiones sin bloqueo (tema 32).
  • Imprimir demasiado. Serial.print bloquea cuando el búfer de salida se llena. A 9600 baudios, 50 caracteres tardan unos 50 ms. Imprimir en cada vuelta degrada todo; se imprime en cada transición o cada cierto tiempo.
  • Bucles while que esperan una condición física («esperar que se cierre el final de carrera»). Se convierten en un estado y un evento.
  • Cálculos largos. Operaciones con muchos float o String pueden tardar milisegundos; se dividen o se evitan.
  • Variables compartidas con interrupciones sin volatile ni protección (tema 9).

Una prueba sencilla: contar cuántas vueltas de loop() se hacen por segundo, o medir la duración de la vuelta más larga. Si supera la latencia que tolerás, hay algo que bloquea.

08 · Código para la placa

Un semáforo de cruce en Arduino

El ejemplo es un semáforo con pulsador para peatones. Estados: VERDE (autos), AMARILLO (3 s), ROJO (autos detenidos, peatones cruzan 8 s), PARPADEO (la luz de peatones parpadea 4 s) y DESPEJE (todo en rojo 1 s) antes de volver a VERDE. Un peatón puede pedir cruzar durante el verde, pero los autos tienen derecho a un mínimo de 10 s de verde: el pedido se recuerda y se atiende cuando se cumplen los 10 s.

// Semáforo con pulsador de peatones, sin delay(): una máquina de estados con tiempos medidos con millis().
// SIMULAR = 1: reloj virtual de 1 ms y pulsaciones guionadas (no necesita hardware; imprime las transiciones).
// SIMULAR = 0: pines y millis() reales; ver las conexiones más abajo.
#define SIMULAR 1

const byte PIN_AUTO_R = 2, PIN_AUTO_A = 3, PIN_AUTO_V = 4, PIN_PEA_R = 5, PIN_PEA_V = 6, PIN_BOTON = 7;   // LEDs con resistencia; botón a GND
const unsigned long T_VERDE_MIN = 10000, T_AMARILLO = 3000, T_ROJO = 8000, T_PARPADEO = 4000, T_DESPEJE = 1000;

enum class Estado : byte { VERDE, AMARILLO, ROJO, PARPADEO, DESPEJE };
const char *NOMBRE[] = {"VERDE", "AMARILLO", "ROJO", "PARPADEO", "DESPEJE"};

Estado estado = Estado::VERDE;
unsigned long tEntrada = 0;
bool solicitud = false, botonPrevio = false;

// ---- Reloj y entradas: lo único que cambia entre la prueba y el equipo real ----
#if SIMULAR
unsigned long reloj = 0;
unsigned long ahora() { return reloj; }
bool botonPresionado() { return (reloj >= 4000 && reloj < 4100) || (reloj >= 40000 && reloj < 40100); }
#else
unsigned long ahora() { return millis(); }
bool botonPresionado() { return digitalRead(PIN_BOTON) == LOW; }          // en un equipo real, agregar antirrebote por tiempo
#endif

// Tiempo transcurrido en el estado actual. La resta de enteros sin signo da el resultado correcto
// aunque millis() haya dado la vuelta (a los 49,7 días): nunca guardar "vencimientos" absolutos.
unsigned long tiempoEnEstado() { return ahora() - tEntrada; }

void entrar(Estado nuevo) {
  Serial.print("t="); Serial.print(ahora()); Serial.print(" ms: ");
  Serial.print(NOMBRE[(byte)estado]); Serial.print(" -> "); Serial.println(NOMBRE[(byte)nuevo]);
  estado = nuevo;
  tEntrada = ahora();                                       // acción de entrada: arrancar el temporizador del estado
  if (nuevo == Estado::AMARILLO) solicitud = false;         // el pedido ya fue atendido
}

void leerEntradas() {
  const bool boton = botonPresionado();
  if (boton && !botonPrevio && estado == Estado::VERDE) solicitud = true;   // flanco de subida: se recuerda el pedido
  botonPrevio = boton;
}

void actualizar() {                                         // las transiciones: ninguna espera, solo comprueba
  switch (estado) {
    case Estado::VERDE:    if (solicitud && tiempoEnEstado() >= T_VERDE_MIN) entrar(Estado::AMARILLO); break;
    case Estado::AMARILLO: if (tiempoEnEstado() >= T_AMARILLO) entrar(Estado::ROJO); break;
    case Estado::ROJO:     if (tiempoEnEstado() >= T_ROJO) entrar(Estado::PARPADEO); break;
    case Estado::PARPADEO: if (tiempoEnEstado() >= T_PARPADEO) entrar(Estado::DESPEJE); break;
    case Estado::DESPEJE:  if (tiempoEnEstado() >= T_DESPEJE) entrar(Estado::VERDE); break;
  }
}

void aplicarSalidas() {                                     // máquina de Moore: las luces dependen del estado (y del tiempo en él)
  bool ar = false, aa = false, av = false, pr = false, pv = false;
  switch (estado) {
    case Estado::VERDE:    av = true; pr = true; break;
    case Estado::AMARILLO: aa = true; pr = true; break;
    case Estado::ROJO:     ar = true; pv = true; break;
    case Estado::PARPADEO: ar = true; pv = (tiempoEnEstado() / 250) % 2 == 0; break;   // parpadeo a 2 Hz sin delay()
    case Estado::DESPEJE:  ar = true; pr = true; break;
  }
#if !SIMULAR
  digitalWrite(PIN_AUTO_R, ar); digitalWrite(PIN_AUTO_A, aa); digitalWrite(PIN_AUTO_V, av);
  digitalWrite(PIN_PEA_R, pr);  digitalWrite(PIN_PEA_V, pv);
#endif
}

void ciclo() { leerEntradas(); actualizar(); aplicarSalidas(); }

void setup() {
  Serial.begin(115200);
#if SIMULAR
  Serial.println("Simulacion de 60 s (pedidos de cruce a los 4000 y 40000 ms):");
  for (reloj = 0; reloj <= 60000; reloj++) ciclo();
#else
  const byte salidas[] = {PIN_AUTO_R, PIN_AUTO_A, PIN_AUTO_V, PIN_PEA_R, PIN_PEA_V};
  for (byte i = 0; i < 5; i++) pinMode(salidas[i], OUTPUT);
  pinMode(PIN_BOTON, INPUT_PULLUP);
  tEntrada = millis();
#endif
}

void loop() {
#if !SIMULAR
  ciclo();                                                  // sin delay(): el ciclo se repite miles de veces por segundo
#endif
}

El sketch tiene dos modos. Con SIMULAR = 1 (el valor por defecto) usa un reloj virtual y pulsaciones guionadas a los 4000 ms y 40000 ms, y escribe las transiciones. Con SIMULAR = 0 usa millis() y los pines reales (cinco LED con resistencia y un pulsador a GND). Lo único que cambia entre ambos modos son ahora() y botonPresionado(): el resto es idéntico.

La salida de la simulación es: t=10000 ms: VERDE -> AMARILLO (el pedido de los 4000 ms espera el mínimo de 10 s), t=13000 ms: AMARILLO -> ROJO, t=21000 ms: ROJO -> PARPADEO, t=25000 ms: PARPADEO -> DESPEJE, t=26000 ms: DESPEJE -> VERDE. Luego el pedido de los 40000 ms llega con el verde ya cumplido y el ciclo arranca de inmediato: t=40000 ms: VERDE -> AMARILLO, y así hasta t=56000 ms: DESPEJE -> VERDE.

Observá en el código: tiempoEnEstado() con resta sin signo; entrar() como única función que cambia el estado, anota el instante y limpia el pedido; el flanco del pulsador en leerEntradas(); el parpadeo a 2 Hz como función del tiempo en el estado, sin ningún temporizador aparte; y el ciclo leerEntradas → actualizar → aplicarSalidas sin esperas. En un equipo real agregá el antirrebote por tiempo y, si las luces gobiernan una instalación real, una secuencia de seguridad independiente del programa (por ejemplo, un monitor de conflictos que impida dos verdes cruzados).

09 · Exploración interactiva

Laboratorios de latencia y de cruce

1. Lo que pierde un programa con delay()

Un loop() que enciende un LED, espera, lo apaga y espera, leyendo el pulsador una sola vez por vuelta. El eje es el tiempo; las marcas naranja son las lecturas del pulsador; la barra roja, la pulsación. El programa sin bloqueo lee el pulsador cada milisegundo.

Lecturas del pulsador con delayUna línea de tiempo de 4 segundos con los intervalos de espera del programa, las lecturas del pulsador como marcas y la pulsación como una barra roja.
Programa con delay()—
Programa sin bloqueo—
Lecturas del pulsador—

Elegí los tiempos.

Con los valores iniciales, la pulsación cae entre dos lecturas y se pierde. Moverla a 950 ms la hace coincidir con la lectura de los 1000 ms (50 ms de retraso). Con delay(100) las lecturas son cada 200 ms: una pulsación de 100 ms a los 150 ms se detecta con 50 ms de retraso, y a los 250 ms se pierde. Para que una pulsación de duración d no se pierda nunca hace falta leer al menos cada d, es decir, un loop() mucho más rápido que cualquier delay.

2. El semáforo de cruce en tiempo real

El mismo programa del sketch, corriendo con un reloj virtual que podés acelerar. Pulsá «Pedir cruce»: si los autos llevan menos de 10 s en verde, el pedido se guarda y se atiende al cumplirse el mínimo; si ya cumplieron, el ciclo arranca enseguida. Los pedidos fuera del verde se descartan, como en el sketch.

Luces del semáforo de autos y de peatonesTres luces de autos (rojo, amarillo y verde) y dos de peatones (rojo y verde) que reflejan el estado actual.AutosPeatones
Estado—
Reloj virtual—
Tiempo en el estado—
Pedido de cruce—

    Probá pedir el cruce a los 4 s: queda guardado y el amarillo empieza a los 10 s. Pedilo después de los 10 s de verde: el amarillo arranca al instante. Pedilo durante el rojo de autos: se descarta. Mirá el parpadeo de la luz verde de peatones en el último tramo: sale de dividir el tiempo en el estado por 250 ms. Cambiá la velocidad a 20×: la lógica no cambia, porque depende del reloj y no del número de vueltas.

    10 · Código ejecutable

    Comparar implementaciones en Python

    La misma máquina escrita con switch (una cadena de condiciones) y con tabla, ejecutadas sobre el mismo guion de 60 s, la aritmética sin signo del desbordamiento y el cálculo de la latencia de un programa con delay().

    Probalo en el navegador. Python se descarga con Pyodide la primera vez y requiere conexión. El código es editable y no controla ningún equipo.

    T_VERDE_MIN, T_AMARILLO, T_ROJO, T_PARPADEO, T_DESPEJE = 10000, 3000, 8000, 4000, 1000
    MASCARA = 2 ** 32                                   # millis() es un entero sin signo de 32 bits
    
    def transcurrido(ahora, inicio):
        return (ahora - inicio) % MASCARA               # resta sin signo: correcta aunque el contador haya dado la vuelta
    
    class CruceSwitch:
        """Versión con switch: la lógica de transición se escribe dentro de cada caso."""
        def __init__(self):
            self.estado, self.t_entrada, self.solicitud, self.registro, self.previo = "VERDE", 0, False, [], False
    
        def entrar(self, nuevo, ahora):
            self.registro.append((ahora, self.estado, nuevo))
            self.estado, self.t_entrada = nuevo, ahora
            if nuevo == "AMARILLO":
                self.solicitud = False
    
        def ciclo(self, ahora, boton):
            if boton and not self.previo and self.estado == "VERDE":      # flanco de subida: se recuerda el pedido
                self.solicitud = True
            self.previo = boton
            t = transcurrido(ahora, self.t_entrada)
            if self.estado == "VERDE" and self.solicitud and t >= T_VERDE_MIN:
                self.entrar("AMARILLO", ahora)
            elif self.estado == "AMARILLO" and t >= T_AMARILLO:
                self.entrar("ROJO", ahora)
            elif self.estado == "ROJO" and t >= T_ROJO:
                self.entrar("PARPADEO", ahora)
            elif self.estado == "PARPADEO" and t >= T_PARPADEO:
                self.entrar("DESPEJE", ahora)
            elif self.estado == "DESPEJE" and t >= T_DESPEJE:
                self.entrar("VERDE", ahora)
    
    class CruceTabla(CruceSwitch):
        """Versión con tabla: cada fila es (estado, condición, destino); el motor es siempre el mismo."""
        def __init__(self):
            super().__init__()
            self.tabla = [
                ("VERDE", lambda s, t: s.solicitud and t >= T_VERDE_MIN, "AMARILLO"),
                ("AMARILLO", lambda s, t: t >= T_AMARILLO, "ROJO"),
                ("ROJO", lambda s, t: t >= T_ROJO, "PARPADEO"),
                ("PARPADEO", lambda s, t: t >= T_PARPADEO, "DESPEJE"),
                ("DESPEJE", lambda s, t: t >= T_DESPEJE, "VERDE"),
            ]
    
        def ciclo(self, ahora, boton):
            if boton and not self.previo and self.estado == "VERDE":
                self.solicitud = True
            self.previo = boton
            t = transcurrido(ahora, self.t_entrada)
            for desde, condicion, hacia in self.tabla:
                if desde == self.estado and condicion(self, t):
                    self.entrar(hacia, ahora)
                    break
    
    def pulsador(ahora):
        return 4000 <= ahora < 4100 or 40000 <= ahora < 40100
    
    def ejecutar(sistema, duracion=60000):
        for ahora in range(duracion + 1):
            sistema.ciclo(ahora, pulsador(ahora))
        return sistema.registro
    
    registro = ejecutar(CruceSwitch())
    print("Transiciones (pedidos de cruce a los 4000 y 40000 ms):")
    for t, a, b in registro:
        print(f"  t={t} ms: {a} -> {b}")
    print("La version con tabla da el mismo resultado:", registro == ejecutar(CruceTabla()))
    
    # --- El desbordamiento de millis() ---
    inicio, ahora = 4294967290, 10
    print(f"Desbordamiento: inicio={inicio}, ahora={ahora} -> transcurrido (sin signo) = {transcurrido(ahora, inicio)} ms; "
          f"la resta con signo daria {ahora - inicio}")
    print(f"Un contador de 32 bits en milisegundos da la vuelta a los {MASCARA / 1000 / 86400:.1f} dias")
    
    # --- Latencia de un programa con delay() ---
    def latencia_bloqueante(demora, inicio_pulso, duracion):
        lecturas = range(0, 20000, 2 * demora)          # el pulsador se lee una vez por vuelta de loop()
        for l in lecturas:
            if inicio_pulso <= l < inicio_pulso + duracion:
                return l - inicio_pulso
        return None
    
    for demora, p, d in ((500, 300, 100), (500, 950, 100), (100, 150, 100), (100, 250, 100)):
        r = latencia_bloqueante(demora, p, d)
        print(f"delay({demora}) dos veces por vuelta, pulso de {d} ms en t={p}: " + ("pulso perdido" if r is None else f"se detecta con {r} ms de retraso"))

    Las diez transiciones ocurren a los 10000, 13000, 21000, 25000, 26000, 40000, 43000, 51000, 55000 y 56000 ms, y las dos implementaciones dan el mismo registro. Con el contador a punto de desbordarse (inicio 4 294 967 290, ahora 10) el tiempo transcurrido es 16 ms y la resta con signo daría −4 294 967 280. Con dos delay(500), la pulsación de 100 ms a los 300 ms se pierde y la de los 950 ms se detecta con 50 ms de retraso.

    Cambiá los instantes de las pulsaciones y comprobá el resultado a mano. Agregá un estado PRE_AMARILLO de 2 s y observá qué hay que tocar en cada versión: en la de switch, una rama nueva y una modificada; en la de tabla, una fila nueva y una modificada. Probá inicio = 4294967000 y ahora = 500 para ver el desbordamiento.

    11 · Método y diagnóstico

    Pruebas y diagnóstico

    1. Buscá en todo el programa y en las bibliotecas cualquier delay(), pulseIn() o while que espere.
    2. Medí cuántas vueltas de loop() se hacen por segundo y cuánto dura la más larga.
    3. Probá la máquina con el reloj simulado: comprobá cada transición contra la tabla y cada tiempo contra su duración.
    4. Probá el desbordamiento: iniciá el contador cerca del máximo (o simulalo) y verificá que los tiempos siguen correctos.
    5. Probá pulsaciones muy breves, muy largas y repetidas (rebote): una pulsación debe producir un evento.
    6. Provocá eventos en momentos inoportunos (un pulso durante un cambio de estado) y comprobá que no se pierden ni se duplican.
    7. Dejalo funcionando horas y revisá que no se desvíe ni acumule errores.
    «Es solo un delay(10), no afecta a nada»

    Si está en el camino de cada vuelta, la latencia pasa a ser de al menos 10 ms, y se suma con todos los demás. Además, es un mal hábito: el siguiente delay será de 100.

    «Con millis() ya no hay problemas de tiempo»

    Hay errores sutiles: comparar con ==, usar int, guardar vencimientos absolutos o dejar derivar el período. Los tres aparecen solo en algún momento.

    «El desbordamiento a los 49 días nunca me va a pasar»

    Un equipo industrial puede estar encendido meses. Con la resta sin signo el problema desaparece sin costo; con un error, el equipo falla una vez al mes.

    «La tabla es siempre mejor que el switch»

    Para pocos estados el switch es más claro. La tabla convence cuando la máquina crece, cambia seguido o debe verificarse con herramientas.

    «Imprimo el estado en cada vuelta para ver qué pasa»

    Eso bloquea el programa y falsea la prueba. Imprimí al cambiar de estado, o cada cierto tiempo.

    «Si el pulsador rebota, filtro con un delay(50) después de leerlo»

    Congela el programa durante el filtrado. El antirrebote se hace midiendo con millis() cuánto tiempo estable lleva la lectura.

    12 · Práctica guiada

    Predecí antes de comprobar

    1. Pulsación perdida

    Un loop() tiene dos delay(300) y lee el pulsador al principio de cada vuelta (t = 0, 600, 1200…). Una pulsación de 150 ms empieza en t = 100. ¿Se detecta?

    Ver solución

    La ventana de la pulsación es [100, 250) ms y no contiene ninguna lectura (0 y 600). Se pierde. Para detectarla con seguridad habría que leer al menos cada 150 ms.

    2. Desbordamiento

    El estado se entró cuando millis() valía 4 294 967 000 y ahora vale 500. ¿Cuánto tiempo pasó?

    Ver solución

    Hasta el desbordamiento faltaban 296 ms (4 294 967 296 − 4 294 967 000) y después pasaron 500: 796 ms. La resta sin signo 500 − 4 294 967 000 módulo 232 da exactamente 796.

    3. Igual contra mayor o igual

    ¿Por qué el código compara tiempoEnEstado() >= T_AMARILLO y no == T_AMARILLO?

    Ver solución

    Si una vuelta de loop() se retrasa (una escritura serie larga), el instante exacto de los 3000 ms puede pasar sin ser observado y la igualdad nunca se cumpliría: la máquina quedaría clavada en AMARILLO. Con ≥ se cumple en la primera vuelta posterior.

    4. Deriva del período

    Una tarea de 100 ms se comprueba con ultimo = ahora y cada comprobación llega 3 ms tarde. ¿Cuánto se atrasa tras 100 ejecuciones? ¿Y con ultimo += 100?

    Ver solución

    Con ultimo = ahora cada período dura 103 ms: tras 100 ejecuciones, unos 300 ms de atraso. Con ultimo += 100 el ritmo medio se conserva: no se acumula atraso (cada ejecución se retrasa 3 ms respecto de su instante ideal, pero no se suman).

    5. Parpadeo

    La luz de peatones usa (tiempoEnEstado()/250) % 2 == 0. ¿Está encendida a los 700 ms y a los 800 ms? ¿A qué frecuencia parpadea?

    Ver solución

    A los 700 ms: 700/250 = 2 (división entera), 2 % 2 = 0: encendida. A los 800 ms: 3 % 2 = 1: apagada. Cada 250 ms cambia: período de 500 ms, 2 Hz.

    6. Un pedido en mal momento

    Un peatón pulsa durante el AMARILLO. En el sketch se descarta. ¿Qué cambiarías para no perderlo y qué efecto tendría?

    Ver solución

    Aceptar el pedido en cualquier estado salvo el ciclo de peatones y marcarlo como «pendiente para el próximo verde». Tendría que borrarse al entrar en ROJO (no al entrar en AMARILLO), para no atender un pedido ya cumplido; pero un pedido guardado en el último instante podría provocar un ciclo innecesario. Es una decisión de diseño con costos en ambas direcciones.

    7. Agregar un estado

    Se agrega un estado PRE_AMARILLO de 2 s (luz verde intermitente de autos) antes del AMARILLO. ¿Qué hay que tocar con cada implementación?

    Ver solución

    Con switch: un caso nuevo (PRE_AMARILLO → AMARILLO a los 2 s), cambiar el destino en VERDE y agregar el caso a aplicarSalidas(). Con tabla: una fila nueva, un destino modificado y la misma línea de salidas. El resto del programa, igual.

    13 · Síntesis

    Ideas para recordar

    • delay() congela el programa: introduce latencia, pierde pulsaciones breves y impide hacer dos cosas a la vez.
    • Un loop() sin bloqueos lee entradas, genera eventos, actualiza la máquina y aplica salidas, sin esperas y con un solo punto de escritura a los actuadores.
    • Los tiempos se miden con millis(): ahora − inicio ≥ duración, con unsigned long y comparando con ≥.
    • La resta sin signo sobrevive al desbordamiento de los 49,7 días; los vencimientos absolutos, no.
    • Un pulsador se convierte en un evento con antirrebote por tiempo y detección de flanco; los eventos concurrentes se encolan.
    • switch o tabla de transiciones: ambas dan el mismo comportamiento; la tabla escala mejor y separa datos de motor.
    • Las acciones de entrada van en una función única, las salidas en un solo lugar y cada transición se registra.
    • Cuidado con los bloqueos escondidos: pulseIn, impresiones largas, while de espera y cálculos pesados.

    ¿Listo para el tema 38?

    Antes de avanzar, revisá un programa tuyo y contá los delay(). Reescribí uno como estado con temporizador, y medí cuántas vueltas por segundo hace tu loop() antes y después.

    En el próximo tema juntamos varias máquinas y modos en una sola estructura: máquinas de estados jerárquicas y comportamientos, con subestados, modos de operación y manejo de fallos.