← Índice del curso

Sensores y Actuadores · Tema 41

Lectura y control sin bloqueos

Un microcontrolador sin sistema operativo ejecuta una sola cosa a la vez, pero un programa útil tiene que hacer varias: leer sensores, atender botones, mover un motor, actualizar una pantalla, vigilar fallas. La técnica que lo permite es renunciar a esperar: en lugar de delay(), se pregunta qué hora es y se decide qué corresponde hacer ahora. Este tema reúne las herramientas con las que ya nos cruzamos en los talleres: el tiempo con millis(), las tareas periódicas, las máquinas de estados, los botones sin rebotes y las interrupciones.

01 · Punto de partida

El problema de esperar

El primer programa de todo curso hace parpadear un LED: encender, delay(1000), apagar, delay(1000). Agreguemos un botón que debe apagar el LED. Durante esos dos segundos de delay() el microcontrolador no mira el botón: una pulsación corta se pierde, una larga se atiende con hasta un segundo de retraso. Con un sensor DS18B20 que tarda 750 ms en convertir (tema 40), un lazo de control de 10 ms pierde 75 ciclos; con un OLED por I2C a 100 kHz (tema 38), una pantalla cuesta 105 ms.

La causa es siempre la misma: delay() y las funciones que esperan ocupan al procesador haciendo nada. La solución es la multitarea cooperativa: el programa gira en un lazo, y cada tarea hace un trabajo corto y devuelve el control sin esperar nada. Cada vez que le toca, mira el reloj y decide si ya corresponde actuar. No hay sistema operativo, ni hilos, ni interrupciones obligatorias: solo una disciplina.

  • Regla 1: ninguna tarea espera. Si algo no está listo, se anota el estado y se vuelve al lazo.
  • Regla 2: el tiempo se consulta, no se consume: millis() en lugar de delay().
  • Regla 3: cada tarea es corta; la más larga fija cuánto tarda el sistema en reaccionar a cualquier cosa.

02 · El reloj

El tiempo con millis()

millis() devuelve los milisegundos desde el encendido en un uint32_t y micros(), los microsegundos. Un entero de 32 bits sin signo da la vuelta a cero tras 232 unidades: 49,71 días para millis() y 71,6 minutos para micros(). Un equipo que deba funcionar más de siete semanas tiene que tratar esa vuelta bien, y el error es tan silencioso que el programa funciona en el escritorio y falla en el campo.

vencido  ⇔  (uint32_t)(ahora − inicio) ≥ intervalo

La resta de enteros sin signo es una aritmética módulo 232, de modo que da el tiempo transcurrido correcto aunque haya habido una vuelta en medio. Con inicio = 4 294 967 000 e intervalo = 1000, cuando millis() vale 704 (ya dio la vuelta), 704 − 4 294 967 000 módulo 232 es exactamente 1000. La forma que parece equivalente, millis() >= inicio + intervalo, compara instantes absolutos y falla en dos sentidos: si la suma también da la vuelta, el resultado es 704 y la condición se cumple de inmediato; si la suma no la da pero millis() sí, la condición queda falsa durante 49 días y la tarea deja de ejecutarse.

Otras propiedades del reloj que conviene conocer:

  • Resolución: en un Arduino Uno, millis() se actualiza con una interrupción del Timer 0 cada 1,024 ms y se corrige, de modo que avanza de a 1 ms pero a veces salta de a 2. Por eso se compara con >=, nunca con ==. micros() avanza de a 4 µs a 16 MHz.
  • Exactitud: la del oscilador. Un cristal de 50 ppm se adelanta o atrasa 4,32 s por día; un resonador cerámico de 0,5 % (algunas placas), 7,2 minutos por día. Para una hora de reloj exacta se usa un RTC (como el DS3231, de ±2 ppm) o la red.
  • Interrupciones desactivadas: mientras lo están, millis() pierde ticks. Un noInterrupts() de varios milisegundos atrasa el reloj.
  • En otras plataformas (ESP32, RP2040), millis() tiene el mismo tipo y la misma vuelta, y además existe un sistema operativo (FreeRTOS en el ESP32) con tareas de verdad. El principio de no bloquear sigue valiendo dentro de cada tarea.

03 · Planificar

Tareas periódicas

Una tarea periódica es una función que debe correr cada T ms: leer un sensor cada 100 ms, refrescar la pantalla cada 500 ms, hacer parpadear un LED cada segundo. Con la regla de vencimiento, cada tarea se reduce a una estructura: período, instante de la próxima ejecución y función. El lazo recorre la lista y ejecuta las que vencieron.

El detalle que más importa es cómo se reprograma la próxima ejecución:

  • ultimo = millis() (desde el instante actual): cada ejecución llega algo tarde (el lazo tardó en volver) y ese retraso se acumula. Con 2 ms de retraso por vuelta, un período de 1000 ms pasa a 1002 ms y en una hora se pierden 8 ejecuciones (3592 en lugar de 3600): una deriva.
  • proxima += periodo (desde lo previsto): cada ejecución puede llegar con 0 a 2 ms de retraso, pero el retraso no se acumula y el período medio es exactamente el pedido: 3600 en una hora.

Y una salvaguarda: si el sistema estuvo bloqueado más de un período, proxima quedó muy atrás y la tarea se ejecutaría en ráfaga para «recuperar»; es mejor reiniciarla desde el instante actual. Con eso, un planificador cooperativo de tres líneas por tarea alcanza para la mayoría de los proyectos de este curso. Conviene además medir el lazo: guardar con micros() el tiempo máximo entre dos vueltas es el instrumento más barato y más revelador para encontrar una tarea que se demora.

04 · Cuentas

Presupuesto de tiempo

Cada tarea i tarda Ci en ejecutarse y se repite cada Ti. Dos números dicen si el conjunto es viable:

utilización U = Σ Ci / Ti   respuesta en el peor caso ≈ Σ Ci
  • Utilización: la fracción del tiempo que la CPU pasa trabajando. Por encima de 100 % es imposible; por encima de 50 a 70 %, el sistema tiene poco margen para picos.
  • Peor tiempo de respuesta: en un lazo cooperativo no hay prioridades que interrumpan: un botón que se pulsa justo al empezar una tarea larga espera a que termine. Si varias tareas vencen a la vez, espera a todas: la suma de los tiempos de ejecución es una cota del peor caso.
Un conjunto de tareas con sensor (2 ms), pantalla (C variable), LED (0,05 ms cada 1 s) y registro serie (5 ms cada 5 s).
Pantalla (C · T)Sensor (T)UtilizaciónRetraso máximo del sensorRespuesta en el peor caso
26 ms · 500 ms (OLED por I2C a 400 kHz)100 ms7,3 %0,05 ms33,05 ms
1 ms · 500 ms (OLED por SPI)100 ms2,3 %0,05 ms8,05 ms
105 ms · 250 ms (OLED por I2C a 100 kHz)100 ms44,1 %55,05 ms112,05 ms
105 ms · 100 ms100 ms107,1 %68,05 ms112,05 ms
26 ms · 100 ms20 ms36,1 %13,05 ms33,05 ms

La tabla enseña tres cosas. La pantalla de 26 ms rinde bien a baja frecuencia, pero a 105 ms por cuadro un botón responde con más de 100 ms de latencia aunque la CPU esté al 44 %: la utilización baja no garantiza buena respuesta. Una tarea de 26 ms retrasa hasta 13 ms a un sensor que debe leerse cada 20 ms: el período del sensor y la duración de la tarea larga son incompatibles. Y el remedio no es acelerar el código: es partir la tarea larga (la pantalla se envía por páginas de 128 bytes en varias vueltas, con una máquina de estados) o pasar a SPI, mil veces más rápido. Cuando de verdad hay plazos estrictos, se usa una interrupción para lo urgente (sección 7) o un sistema operativo de tiempo real con tareas apropiativas.

05 · Comportamiento

Máquinas de estados

Si una tarea no puede esperar, tiene que recordar dónde estaba. Ese recuerdo es el estado, y el patrón que lo ordena es la máquina de estados finita: un conjunto finito de estados, los eventos que provocan transiciones entre ellos y las acciones que se hacen al entrar, al salir o mientras se permanece. Ya la usamos en el plotter del tema 34 (INICIO, SUBIENDO, YENDO…), en la lectura de la estación del tema 13 y en los reproductores del tema 39.

Por qué es mejor que una colección de variables booleanas: con n banderas hay 2n combinaciones, la mayoría imposibles pero ninguna prohibida por el código; con una enumeración, cada estado es una situación nombrada y las transiciones válidas son las que están escritas. Un ejemplo mínimo es el semáforo: tres estados con su duración.

Semáforo: estados, duración y transición (el ciclo completo dura 12 s).
EstadoAcción de entradaDuraEvento de salida → siguiente estado
VERDEEncender solo la luz verde5 sTiempo cumplido → AMARILLO
AMARILLOEncender solo la luz amarilla2 sTiempo cumplido → ROJO
ROJOEncender solo la luz roja5 sTiempo cumplido → VERDE

Buenas prácticas de las máquinas de estados:

  • Una función por transición (entrar(nuevo)) que hace las acciones de entrada una sola vez y guarda el instante de entrada; los tiempos del estado se miden contra él.
  • Los tiempos son eventos: «pasaron 5 s en este estado» se comprueba con la comparación segura de la sección 2.
  • Un tiempo límite en cada estado de espera: si el dispositivo no responde, la máquina no se queda esperando para siempre, sino que pasa a un estado de falla (tema 44).
  • Un caso default que lleva a un estado seguro, por si una variable se corrompe.
  • Separar «qué ocurre» de «qué se hace»: las transiciones deciden el estado; las acciones se ejecutan al entrar. Así el mismo diagrama sirve para probar sin hardware (tema 40).

06 · Entradas

Lecturas por etapas y botones

La lectura por etapas del tema 40 (iniciar(), lista(), leer()) es una máquina de estados de dos estados: REPOSO y ESPERANDO. Cuando venció el período, se pide la medición y se pasa a esperar; en cada vuelta se pregunta si terminó; al terminar, se toma el dato y se vuelve a reposo. Falta un detalle: el tiempo límite. Si el sensor se desconecta, nunca estará listo; con un límite de 1,5 veces el tiempo de conversión, la máquina marca la falla y vuelve a reposo para reintentar.

El botón es el otro caso clásico. Un contacto mecánico rebota (tema 6): entre 1 y 20 ms puede conmutar varias veces antes de quedar estable. La solución sin bloqueo es medir cuánto hace que la lectura no cambia:

  1. En cada vuelta se lee el pin. Si la lectura difiere de la anterior, se anota la hora del cambio y no se concluye nada.
  2. Si la lectura difiere del estado estable y lleva sin cambiar el tiempo exigido (20 ms), se acepta como el nuevo estado estable y se genera el evento.

Con un rebote de 10 ms y 20 ms de estabilidad, el evento llega unos 30 ms después de pulsar, imperceptible, y se genera una sola vez por pulsación aunque el contacto haya conmutado doce veces. Con 5 ms de estabilidad y un rebote de 30 ms (un contacto malo), el filtro ya no alcanza y se detectan eventos falsos. Y el muestreo debe ser más rápido que el tiempo de estabilidad: si el lazo gira cada 50 ms, la estabilidad real se redondea a 50 ms. El mismo método sirve para finales de carrera (tema 6) y para cualquier entrada ruidosa.

07 · Plazos estrictos

Interrupciones

Algunos sucesos no pueden esperar a que el lazo vuelva: los pulsos de un encoder a 10 kHz (tema 25), el flanco de un sensor de ultrasonido, el fin de un conversor. Una interrupción hace que el procesador suspenda lo que hace, ejecute una rutina corta (ISR) y retome. Es la excepción a la multitarea cooperativa y tiene sus propias reglas:

  • La ISR es corta (microsegundos): incrementar un contador, anotar un instante, levantar una bandera. El trabajo pesado se hace en el lazo, que mira la bandera. Su costo es la frecuencia por la duración: 1000 pulsos por segundo con una rutina de 5 µs son el 0,5 % de la CPU; 10 000, el 5 %; 50 000, el 25 %.
  • Dentro de la ISR no se usa delay() (no avanza el tiempo), millis() (no se actualiza), Serial.print (espera el puerto) ni I2C (necesita interrupciones).
  • Las variables compartidas son volatile: le dicen al compilador que pueden cambiar en cualquier momento y que no las guarde en un registro.
  • Las lecturas de más de un byte no son atómicas en un microcontrolador de 8 bits: leer un uint32_t son cuatro instrucciones, y una interrupción entre dos de ellas da un valor mezclado. Un contador que pasa de 65 535 a 65 536, leído en partes, puede dar 131 071 (un error de 65 535). Se desactivan las interrupciones un instante: noInterrupts(); copia = contador; interrupts();.
  • Los pocos pines con interrupción y los conflictos de temporizadores (tema 39) hacen que se reserven para lo que lo necesita.

Una regla de oro de prioridades: lo que tiene un plazo en microsegundos va en una interrupción o en hardware (un contador de pulsos, un PWM, tema 34); lo que tolera decenas de milisegundos, en el lazo cooperativo.

08 · Trampas

Bloqueos ocultos

El delay() se ve; los demás bloqueos no. Antes de dar por «sin bloqueos» un programa, hay que revisar cada llamada a una biblioteca o a una función del sistema:

Operaciones que esperan, con duraciones típicas.
OperaciónCuánto bloqueaAlternativa
delay(ms)Exactamente lo pedidomillis() y estados
pulseIn() sin límiteHasta 1 s si no llega el pulsopulseIn(pin, nivel, 30000): un eco a 5 m tarda 29 ms
Serial.print de un texto largoA 9600 baudios, 100 caracteres: 38 ms; 200: 143 ms. A 115 200: 3 y 12 msMenos texto, más velocidad, enviar por trozos
Pantalla OLED por I2C26 ms a 400 kHz; 105 ms a 100 kHzEnviar por páginas, SPI, solo lo que cambió
analogRead en un AVR~ 100 µsRara vez un problema; sí si se lee muchas veces seguidas
EEPROM.write en un AVR~ 3,3 ms por byteEscribir poco y solo cuando cambia (update)
Sensores como DS18B20 o DHT22750 ms; 5 a 25 ms con interrupciones desactivadasLectura por etapas; en el DHT, leer pocas veces
Conexión Wi-Fi o escritura en tarjeta SDSegundos o cientos de msTarea propia (ESP32) o máquina de estados con tiempo límite

El Serial.print merece una explicación: el puerto serie de un AVR tiene un búfer de transmisión de 64 bytes. Mientras el texto cabe, print vuelve enseguida y el hardware lo envía por su cuenta; cuando se llena, el programa espera a que salgan bytes. A 9600 baudios salen 960 bytes por segundo (10 bits por byte), y un mensaje de 200 caracteres bloquea 143 ms. Los mensajes de depuración que se dejan en un lazo de control son una fuente clásica de «ciclos perdidos» inexplicables.

09 · Programación

Código sin bloqueos

Siete piezas, de las más generales a las más específicas: la comparación segura, el planificador de tareas con medición del lazo, el botón con antirrebote, la máquina de estados del semáforo, la lectura de un sensor por etapas con tiempo límite, la interrupción con lectura atómica y la corrección de una lectura bloqueante.

// ---------- 1. Vencimiento seguro ante la vuelta de millis() ----------
inline bool vencido(uint32_t ahora, uint32_t inicio, uint32_t intervalo) {
    return (uint32_t)(ahora - inicio) >= intervalo;          // resta sin signo: correcta aunque millis() haya dado la vuelta
}

// ---------- 2. Tareas periódicas sin deriva ----------
struct Tarea {
    uint32_t periodoMs;
    uint32_t proxima;                                        // instante previsto del próximo disparo
    void (*funcion)();
};

void ejecutar(Tarea &t) {
    uint32_t ahora = millis();
    if ((int32_t)(ahora - t.proxima) < 0) return;            // todavía no es la hora
    if ((int32_t)(ahora - t.proxima) > (int32_t)t.periodoMs) t.proxima = ahora;   // muy atrasada: no recuperar en ráfaga
    t.proxima += t.periodoMs;                                // se acumula sobre lo previsto, no sobre "ahora": sin deriva
    t.funcion();
}

void tareaSensor();  void tareaPantalla();  void tareaLed();
Tarea tareas[] = { {100, 0, tareaSensor}, {500, 0, tareaPantalla}, {1000, 0, tareaLed} };

uint32_t maxLazoUs = 0, ultimoLazoUs = 0;

void loop() {
    uint32_t ahoraUs = micros();                             // medir el lazo: es el instrumento más barato y más útil
    if (ultimoLazoUs) maxLazoUs = max(maxLazoUs, (uint32_t)(ahoraUs - ultimoLazoUs));
    ultimoLazoUs = ahoraUs;
    for (auto &t : tareas) ejecutar(t);                      // cada tarea es corta y no espera nada
}

// ---------- 3. Botón con antirrebote sin bloqueo ----------
class Boton {
public:
    Boton(uint8_t pin, uint16_t estableMs) : pin_(pin), estableMs_(estableMs) { pinMode(pin_, INPUT_PULLUP); }
    void actualizar() {                                      // llamar en cada vuelta de loop()
        bool lectura = digitalRead(pin_) == LOW;             // con pull-up, pulsado = nivel bajo
        uint32_t ahora = millis();
        if (lectura != previo_) { previo_ = lectura; cambio_ = ahora; }            // cambió: reiniciar la cuenta
        else if (previo_ != estable_ && (uint32_t)(ahora - cambio_) >= estableMs_) {
            estable_ = previo_;                              // estable el tiempo exigido: ahora sí es un cambio real
            if (estable_) pulsaciones_++;
        }
    }
    bool pulsado() const { return estable_; }
    uint16_t pulsaciones() const { return pulsaciones_; }
private:
    uint8_t pin_; uint16_t estableMs_, pulsaciones_ = 0;
    bool previo_ = false, estable_ = false; uint32_t cambio_ = 0;
};

// ---------- 4. Máquina de estados: un semáforo ----------
enum Estado : uint8_t { VERDE, AMARILLO, ROJO };
const uint32_t DURACION[] = { 5000, 2000, 5000 };
Estado estado = ROJO;
uint32_t inicioEstado = 0;

void entrar(Estado nuevo) {                                  // acciones de entrada: se hacen una sola vez, al cambiar
    estado = nuevo;
    inicioEstado = millis();
    digitalWrite(PIN_V, nuevo == VERDE);
    digitalWrite(PIN_A, nuevo == AMARILLO);
    digitalWrite(PIN_R, nuevo == ROJO);
}

void semaforo() {
    if (!vencido(millis(), inicioEstado, DURACION[estado])) return;
    switch (estado) {
        case VERDE:    entrar(AMARILLO); break;
        case AMARILLO: entrar(ROJO);     break;
        case ROJO:     entrar(VERDE);    break;
        default:       entrar(ROJO);     break;              // estado desconocido: el más seguro
    }
}

// ---------- 5. Lectura de un sensor por etapas, con tiempo límite ----------
enum class Lect : uint8_t { REPOSO, ESPERANDO };
Lect lect = Lect::REPOSO;
uint32_t tLect = 0;
float temperatura = NAN;
bool falla = false;

void tareaSensor() {
    switch (lect) {
        case Lect::REPOSO:
            if (vencido(millis(), tLect, 2000)) { sensor.requestTemperatures(); tLect = millis(); lect = Lect::ESPERANDO; }
            break;
        case Lect::ESPERANDO:
            if (sensor.isConversionComplete()) { temperatura = sensor.getTempCByIndex(0); falla = false; tLect = millis(); lect = Lect::REPOSO; }
            else if (vencido(millis(), tLect, 1500)) { falla = true; tLect = millis(); lect = Lect::REPOSO; }   // el dispositivo no responde
            break;
    }
}

// ---------- 6. Interrupción corta y lectura atómica ----------
volatile uint32_t pulsos = 0;                                // compartida con la rutina de interrupción
void isrEncoder() { pulsos++; }                              // sin millis(), delay() ni Serial dentro de la ISR

uint32_t leerPulsos() {                                      // en un AVR, leer 32 bits son 4 instrucciones: no es atómico
    noInterrupts();
    uint32_t copia = pulsos;
    interrupts();
    return copia;
}

// ---------- 7. Las lecturas bloqueantes ocultas ----------
// unsigned long us = pulseIn(PIN_ECO, HIGH);                // espera hasta 1 s si no hay eco
unsigned long us = pulseIn(PIN_ECO, HIGH, 30000);            // con límite de 30 ms (un eco a 5 m tarda 29 ms)

Con esta estructura, loop() se vuelve una lista de tareas y el programa crece agregando tareas, no tocando las existentes. Si una tarea nueva espera, se nota enseguida en el máximo del lazo medido.

10 · Verificación

Fallas y verificación

Fallas típicas de los programas sin bloqueos.
SíntomaCausaDefensa
El equipo falla o se detiene a las siete semanasComparación de instantes absolutos con millis()(uint32_t)(ahora − inicio) >= intervalo
Las tareas periódicas se atrasan con las horasReprogramación con ultimo = millis()proxima += periodo
Una tarea se dispara varias veces seguidasRecuperación en ráfaga tras un bloqueo largoReiniciar la tarea atrasada desde el instante actual
Un botón responde tarde o se pierden pulsacionesUna tarea larga, o un delay escondido en una bibliotecaMedir el máximo del lazo; partir la tarea; revisar bloqueos ocultos
Un evento falso por pulsaciónRebote mayor que el tiempo de estabilidadEstabilidad de 20 a 50 ms; muestrear cada 1 a 5 ms
La máquina de estados se queda trabadaEstado de espera sin tiempo límiteTiempo límite y estado de falla en cada espera
Valores del contador absurdos, de vez en cuandoLectura de 32 bits partida por una interrupciónnoInterrupts() o bloque atómico al copiar
Una variable cambia en la ISR pero el lazo no lo veFalta volatileDeclarar volatile toda variable compartida
El reloj millis() se atrasaInterrupciones desactivadas durante varios msSecciones críticas de microsegundos
Un sensor de ultrasonido congela el programa un segundopulseIn sin tiempo límiteLímite de 30 ms
  1. Medí el máximo del lazo con micros() durante unos minutos de uso real: ese número es la latencia de todo el sistema.
  2. Probá la vuelta de millis() sin esperar 49 días: aislá la comparación en una función pura y probala con valores cercanos a 232 (o con un reloj simulado, tema 40).
  3. Quitá y poné las tareas de a una y mirá cómo cambia el máximo del lazo: la que lo dispara es la culpable.
  4. Provocá las fallas: desconectá el sensor, mantené un botón apretado, mandá pulsos a la máxima frecuencia a la entrada de la interrupción.
  5. Comprobá los estados: hacé una tabla con cada estado, sus eventos y su tiempo límite; ningún estado debe quedar sin salida.

11 · Analogía con programación

Vueltas del reloj, deriva, planificador, rebotes y estados

Calculemos y simulemos los siete elementos del tema con un reloj ficticio: la vuelta de millis() con la comparación correcta y la incorrecta, la deriva de una tarea periódica, un planificador cooperativo con su utilización y su peor retraso, un antirrebote sin bloqueo frente a un contacto que rebota, la lectura partida de un contador, el tiempo de bloqueo del puerto serie y la línea de tiempo de un semáforo.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

M32 = 2 ** 32

# ---- millis() es un uint32_t: da la vuelta cada 49,7 días ----
print(f"millis() da la vuelta a los {M32 / 86_400_000:.2f} días; micros(), a los {M32 / 1e6 / 60:.1f} minutos")


def vencido_bien(ahora, inicio, intervalo):
    return (ahora - inicio) % M32 >= intervalo          # resta sin signo: correcta aunque haya vuelta


def vencido_mal(ahora, inicio, intervalo):
    return ahora >= (inicio + intervalo) % M32          # compara con el instante absoluto: falla en la vuelta


for inicio, intervalo, transcurrido in ((4_294_966_000, 500, 600), (4_294_967_000, 1000, 0), (4_294_967_000, 1000, 1000), (4_294_966_000, 500, 2000)):
    ahora = (inicio + transcurrido) % M32
    print(f"inicio {inicio}, intervalo {intervalo}, pasaron {transcurrido:4d} ms (millis = {ahora:10d}): correcta {str(vencido_bien(ahora, inicio, intervalo)):5s} | incorrecta {vencido_mal(ahora, inicio, intervalo)}")

# ---- Deriva de una tarea periódica ----
print()
retraso = 2          # ms que tarda en llegar el lazo al chequeo, en promedio
periodo_real = 1000 + retraso
print(f"con ultimo = millis() y {retraso} ms de retraso por vuelta: período real {periodo_real} ms -> {3_600_000 // periodo_real} disparos por hora (esperados 3600, {3600 - 3_600_000 // periodo_real} menos)")
print("con ultimo += intervalo: 3600 disparos por hora, cada uno con 0 a 2 ms de retraso pero sin acumularlo")
print(f"cristal de 50 ppm: {50e-6 * 86400:.2f} s por día; resonador cerámico de 0,5 %: {0.005 * 86400 / 60:.1f} min por día")

# ---- Planificador cooperativo: utilización y peor retraso ----
UNIT = 0.05                                             # ms por tick
HORIZONTE = 1000


def simular(tareas):
    t = 0
    siguiente = [0] * len(tareas)
    peor = [0] * len(tareas)
    while t < HORIZONTE * 20:
        corrio = False
        for i, (nombre, c, p) in enumerate(tareas):
            if t >= siguiente[i]:
                peor[i] = max(peor[i], t - siguiente[i])
                t += c
                siguiente[i] += p
                corrio = True
        if not corrio:
            t += 2
    return [x * UNIT for x in peor]


def conjunto(c_pant, p_pant, p_sens):
    ms = lambda v: round(v / UNIT)
    return [("sensor", ms(2), ms(p_sens)), ("pantalla", ms(c_pant), ms(p_pant)), ("LED", ms(0.05), ms(1000)), ("serie", ms(5), ms(5000))]


print()
print("pantalla (C, período) | sensor (período) | utilización | peor retraso: sensor / pantalla / LED / serie")
for c_pant, p_pant, p_sens in ((26, 500, 100), (1, 500, 100), (105, 250, 100), (105, 100, 100), (26, 100, 20)):
    u = 2 / p_sens + c_pant / p_pant + 0.05 / 1000 + 5 / 5000
    retrasos = simular(conjunto(c_pant, p_pant, p_sens))
    print(f"{c_pant:4d} ms, {p_pant:4d} ms     | {p_sens:4d} ms          | {u * 100:7.1f} %    | " + " / ".join(f"{r:6.2f}" for r in retrasos) + " ms")

# ---- Antirrebote sin bloqueo ----
def rebote(inicio, b, x):
    bordes, t = [inicio], inicio
    while True:
        x = (x * 1664525 + 1013904223) % M32
        t += (0.08 + 0.2 * (x / M32)) * b
        if t >= inicio + 0.9 * b:
            break
        bordes.append(t)
    if len(bordes) % 2 == 0:
        bordes.append(inicio + b)
    return bordes, x


def antirrebote(b, estable, lazo):
    pulsar, x = rebote(50, b, 1000 + b)
    soltar, _ = rebote(300, b, x)
    todos = pulsar + soltar
    nivel = lambda t: (sum(1 for e in todos if e <= t)) & 1
    estado, previo, cambio, eventos, t = 0, 0, 0, [], 0
    while t <= 450:
        muestra = nivel(t)
        if muestra != previo:                           # la lectura cambió: se reinicia la cuenta
            previo, cambio = muestra, t
        elif previo != estado and t - cambio >= estable:   # estable durante el tiempo exigido
            estado = previo
            eventos.append((round(t), estado))
        t += lazo
    return len(todos), eventos


print()
print("rebote | estable | lazo | flancos crudos | eventos válidos (ms, nivel)")
for b, estable, lazo in ((10, 20, 1), (10, 5, 1), (30, 5, 1), (30, 20, 1), (10, 20, 50)):
    crudos, eventos = antirrebote(b, estable, lazo)
    print(f"{b:4d} ms | {estable:4d} ms | {lazo:2d} ms | {crudos:8d}       | {eventos}")

# ---- Lectura no atómica de un contador de 32 bits en un AVR ----
print()
antes, despues = 0x0000FFFF, 0x00010000
leido = (despues & 0xFFFF0000) | (antes & 0x0000FFFF)    # parte baja leída antes de la interrupción, alta después
print(f"contador pasa de {antes} a {despues}: una lectura partida da {leido} (error de {leido - despues})")

# ---- Tiempo de enviar texto por el puerto serie ----
print()
for baudios in (9600, 115200):
    for n in (20, 100, 200):
        t_total = n * 10 / baudios * 1000
        bloqueo = max(0, n - 63) * 10 / baudios * 1000    # el búfer de 64 bytes absorbe los primeros bytes
        print(f"{baudios:6d} baudios, {n:3d} caracteres: {t_total:6.1f} ms en el cable; el programa se bloquea unos {bloqueo:5.1f} ms")
print(f"pulseIn sin tiempo límite espera hasta 1 s; un eco de 5 m tarda {2 * 5 / 343 * 1000:.1f} ms: conviene un límite de 30 ms")

# ---- Costo de una interrupción ----
print()
for f in (1_000, 10_000, 50_000):
    print(f"{f:6d} pulsos por segundo con una rutina de 5 µs: {f * 5e-6 * 100:4.1f} % de la CPU")

# ---- Máquina de estados: semáforo ----
print()
estados = (("VERDE", 5000), ("AMARILLO", 2000), ("ROJO", 5000))
t, i, linea = 0, 0, []
for _ in range(7):
    nombre, dur = estados[i % 3]
    linea.append(f"{nombre} {t / 1000:.0f}-{(t + dur) / 1000:.0f} s")
    t += dur
    i += 1
print(" | ".join(linea))
print(f"ciclo completo: {sum(d for _, d in estados) / 1000:.0f} s")

Resultado: millis() da la vuelta a los 49,71 días y micros() a los 71,6 minutos. Con inicio 4 294 967 000 e intervalo 1000, la comparación correcta dice «no» a 0 ms y «sí» a 1000 ms, mientras que la incorrecta dice «sí» de inmediato; con inicio 4 294 966 000 e intervalo 500, tras la vuelta (2000 ms) la correcta sigue diciendo «sí» y la incorrecta, «no». Con 2 ms de retraso por vuelta, ultimo = millis() da 3592 disparos por hora en lugar de 3600; ultimo += intervalo, 3600. Un cristal de 50 ppm deriva 4,32 s por día y un resonador del 0,5 %, 7,2 minutos. En el planificador, la pantalla de 26 ms cada 500 ms deja una utilización de 7,3 % y una respuesta de 33 ms; con 105 ms cada 250 ms (44,1 %), el sensor se retrasa hasta 55 ms; con 105 ms cada 100 ms, la utilización es de 107,1 %: sobrecarga. Con 10 ms de rebote y 20 ms de estabilidad hay un evento por pulsación (80 ms y 329 ms; 12 flancos crudos); con 30 ms de rebote y 5 de estabilidad, cuatro eventos en lugar de dos. Un contador que pasa de 65 535 a 65 536 se lee como 131 071 si la lectura se parte. A 9600 baudios, 100 caracteres bloquean unos 38,5 ms y 200, 142,7 ms; a 115 200, 3,2 y 11,9 ms. Una rutina de 5 µs a 10 000 pulsos por segundo cuesta el 5 % de la CPU. El semáforo cumple VERDE 0–5 s, AMARILLO 5–7 s y ROJO 7–12 s, en un ciclo de 12 s.

12 · Exploración interactiva

Vuelta, planificador, rebotes y estados

El primer instrumento compara la forma correcta e incorrecta de decidir si venció un intervalo cuando millis() está cerca de dar la vuelta. El segundo simula un planificador cooperativo durante un segundo y calcula utilización, respuesta y retrasos. El tercero genera un contacto que rebota y lo pasa por un antirrebote sin bloqueo. El cuarto ejecuta la máquina de estados de un semáforo con tiempos ajustables.

Instrumento 1: la vuelta de millis()

millis() ahora—
ahora − inicio (sin signo) · inicio + intervalo—
¿Venció? Correcta · incorrecta—
Estado
—

Instrumento 2: el planificador cooperativo

Utilización de la CPU—
Respuesta en el peor caso (Σ C)—
Retraso máximo del sensor (simulado)—
Estado
—

Instrumento 3: antirrebote sin bloqueo

Flancos de la señal cruda—
Eventos válidos (esperados: 2)—
Retraso de la pulsación—
Estado
—

Instrumento 4: una máquina de estados

Estado actual · tiempo en él—
Falta para la transición—
Ciclo · ciclos completos—
Estado de la máquina
—

Recorrido guiado

  1. Con inicio 4 294 966 000, intervalo 500 ms y 600 ms transcurridos: millis() vale 4 294 966 600 y las dos comparaciones dicen «sí». Subí el tiempo a 2000 ms: millis() dio la vuelta (704), la correcta sigue diciendo «sí» y la incorrecta, «no»: la tarea dejaría de ejecutarse 49 días.
  2. Con inicio 4 294 967 000, intervalo 1000 ms y 0 ms transcurridos: la correcta dice «no» y la incorrecta, «sí»: se dispararía de inmediato. A 1000 ms coinciden. Con inicio 0, nunca discrepan: el error no aparece en las pruebas de escritorio.
  3. En el planificador, con la pantalla de 26 ms cada 500 ms y el sensor cada 100 ms: utilización de 7,3 %, respuesta de 33,05 ms y el sensor casi sin retraso; en la línea de tiempo se ve la barra ancha de la pantalla entre las del sensor.
  4. Cambiá la pantalla a 105 ms cada 250 ms: la utilización sube a 44,1 %, la respuesta a 112,05 ms (más de los 50 ms tolerados) y el sensor se retrasa 55 ms. La CPU no está llena y, sin embargo, el botón responde mal.
  5. Con 105 ms cada 100 ms: 107,1 %, sobrecarga. Volvé a 26 ms y poné el sensor cada 20 ms: la utilización es de 36,1 %, pero la pantalla retrasa 13 ms al sensor, un 65 % de su período.
  6. En el antirrebote, con 10 ms de rebote, 20 ms de estabilidad y muestreo cada 1 ms: 12 flancos crudos y dos eventos válidos, con un retraso de unos 30 ms. Bajá la estabilidad a 5 ms: el retraso baja a unos 15 ms.
  7. Subí el rebote a 30 ms con 5 ms de estabilidad: aparecen cuatro eventos en lugar de dos. Con 20 ms de estabilidad vuelven a ser dos. Poné el muestreo en 50 ms con 20 ms de estabilidad: el filtro funciona, pero su estabilidad real es de 50 ms.
  8. En el semáforo, con 5, 2 y 5 s: a los 8 s el estado es ROJO desde hace 1 s y faltan 4. El ciclo dura 12 s. Con 1 s de amarillo, el estado avisa que es demasiado corto: la máquina funciona, pero no cumple una regla de diseño.
Desafío: un riego automático con cuatro tareas

Un controlador de riego lee un sensor de humedad (tema 12) cada 5 s, refresca una pantalla OLED de 26 ms cada 2 s, atiende un botón y acciona una electroválvula (tema 29) durante 30 s cuando la humedad baja del umbral, sin abrirla más de una vez cada 10 minutos. ¿Cómo se organiza? Tareas: sensor (T = 5 s, por etapas), pantalla (T = 2 s), botón (en cada vuelta, antirrebote de 20 ms) y una máquina de estados de la válvula: REPOSO → REGANDO (30 s) → ENFRIAMIENTO (10 min) → REPOSO. Cada estado tiene su tiempo medido contra su instante de entrada, y el estado REGANDO tiene también una salida inmediata si el botón de parada se pulsa o si el sensor falla (estado seguro: cerrar la válvula). Presupuesto: utilización de 2/5000 + 26/2000 ≈ 1,3 %; respuesta del botón, ≈ 28 ms. Todo el sistema se detiene a la vez solo si alguien agrega un delay(): la prueba es medir el máximo del lazo. Y como el tiempo de enfriamiento son 600 000 ms, lejos de la vuelta de 49 días, se usa igual la comparación sin signo.

13 · Método de trabajo

Cómo escribir un programa sin bloqueos, para cualquier proyecto

  1. Listá las tareas con su período y su duración, y calculá la utilización y la respuesta en el peor caso.
  2. Eliminá toda espera: delay(), lecturas con espera, pulseIn sin límite, textos largos por serie.
  3. Comparaciones de tiempo siempre con la resta sin signo y con >=; reprogramá con proxima += periodo.
  4. Convertí lo que espera en una máquina de estados, con acciones de entrada, tiempos medidos contra la entrada y un tiempo límite en cada espera.
  5. Leé los sensores por etapas y los botones con un antirrebote por tiempo de estabilidad.
  6. Reservá las interrupciones para plazos de microsegundos: rutina corta, variables volatile, copia atómica.
  7. Partí las tareas largas (pantalla, registro, escritura) en pasos que cada vuelta avanza un poco.
  8. Medí el máximo del lazo y probalo con fallas; que el sistema no pueda quedarse esperando para siempre.

Si un botón responde tarde, hay una tarea larga o un delay escondido; si el equipo falla a las siete semanas, la comparación del tiempo; si una máquina se traba, falta un tiempo límite; si un contador da valores absurdos, la lectura no es atómica.

14 · Comprensión

Confusiones frecuentes

«delay(10) es tan corto que no molesta»

Diez milisegundos son 160 000 instrucciones perdidas, y varios delay de 10 ms en cadena suman el tiempo de respuesta de todo el sistema. En un lazo con otras tareas, ninguno es inocente.

«millis() >= inicio + intervalo es lo mismo que la resta»

No: compara instantes absolutos y falla al dar la vuelta (dispara de inmediato o deja de disparar durante 49 días). La resta sin signo es correcta en ambos casos.

«Si la CPU está al 10 %, el sistema es rápido»

La utilización mide el promedio; la respuesta depende de la tarea más larga. Una tarea de 100 ms cada 5 s usa el 2 % de la CPU y deja a los botones sin respuesta durante 100 ms.

«Una interrupción resuelve cualquier problema de tiempo»

Es la herramienta para plazos de microsegundos, con reglas estrictas: rutina corta, volatile, lecturas atómicas. Usada para todo, produce errores esporádicos difíciles de reproducir.

«El antirrebote se hace con delay(50) después de detectar el flanco»

Detiene todo el programa 50 ms cada pulsación. Medir cuánto tiempo lleva estable la lectura da el mismo resultado sin esperar.

«Una máquina de estados es más código para lo mismo»

Es el código que reemplaza a las esperas: cada estado nombra una situación, y las transiciones y sus tiempos quedan visibles y probables. Con banderas sueltas, las combinaciones imposibles no están prohibidas.

15 · Práctica guiada

Predecí antes de comprobar

1. La vuelta

Una tarea guardó inicio = 4 294 967 200 y espera 500 ms. ¿Qué valor de millis() corresponde al vencimiento y qué da la resta ahora − inicio en ese instante?

Ver solución

4 294 967 200 + 500 = 4 294 967 700, que supera 232 − 1 = 4 294 967 295: da la vuelta y millis() vale 404. La resta sin signo, 404 − 4 294 967 200 módulo 232, es exactamente 500.

2. Deriva

Una tarea cada 500 ms se reprograma con ultimo = millis() y el lazo la atiende, en promedio, 3 ms tarde. ¿Cuántas ejecuciones hace en una hora y cuántas debería?

Ver solución

Período real 503 ms → 3 600 000 / 503 ≈ 7157 ejecuciones; debería hacer 7200: 43 menos. Con proxima += 500, hace las 7200.

3. Utilización

Tareas: sensor 3 ms cada 50 ms, pantalla 40 ms cada 400 ms, registro 8 ms cada 2 s. ¿Utilización y peor tiempo de respuesta?

Ver solución

U = 3/50 + 40/400 + 8/2000 = 0,06 + 0,10 + 0,004 = 16,4 %. Respuesta en el peor caso: 3 + 40 + 8 = 51 ms. El sensor puede retrasarse hasta unos 40 ms, casi un período entero.

4. Antirrebote

Un contacto rebota hasta 15 ms. ¿Qué tiempo de estabilidad elegís y con qué retraso responde el botón?

Ver solución

Un valor mayor que el rebote, como 20 ms. El evento llega aproximadamente 15 + 20 = 35 ms después de pulsar: imperceptible.

5. Serie

Un lazo imprime 120 caracteres por vuelta a 9600 baudios. ¿Cuánto bloquea y cómo se reduce?

Ver solución

El búfer absorbe 63; los 57 restantes salen a 960 B/s: ≈ 59 ms de bloqueo por vuelta. A 115 200 baudios serían unos 5 ms; mejor aún, imprimir menos o cada cierto tiempo.

6. Diagnóstico

Una máquina de estados de un horno pasa a CALENTANDO y espera que el sensor llegue a 200 °C. El sensor se desconecta y el horno sigue calentando. ¿Qué falta?

Ver solución

Faltan un tiempo límite en el estado y una comprobación del estado del sensor: si la lectura es inválida o el tiempo se agota, la máquina pasa a un estado de falla que apaga el calefactor (tema 44).

16 · Síntesis

Ideas para recordar

  • delay() y las funciones que esperan ocupan al procesador sin hacer nada: un programa útil gira en un lazo cuyas tareas cortas consultan el reloj y no esperan.
  • millis() es un entero de 32 bits que da la vuelta a los 49,7 días; el vencimiento correcto es (uint32_t)(ahora − inicio) >= intervalo, nunca una comparación de instantes absolutos.
  • Las tareas periódicas se reprograman con proxima += periodo: así el retraso no se acumula (3600 ejecuciones por hora, no 3592).
  • La utilización es Σ C/T y la respuesta en el peor caso, aproximadamente Σ C: una utilización baja no garantiza buena respuesta si hay una tarea larga.
  • Una máquina de estados guarda dónde estaba cada tarea: acciones de entrada, tiempos medidos contra la entrada, tiempo límite en cada espera y un estado seguro por defecto.
  • Los sensores lentos se leen por etapas (iniciar, lista, leer) con tiempo límite, y los botones con un antirrebote que mide cuánto hace que la lectura no cambia.
  • Las interrupciones son para plazos de microsegundos: rutina corta, variables volatile, lecturas de varios bytes atómicas y nada de delay, millis ni Serial adentro.
  • Los bloqueos se esconden en pulseIn, Serial.print, pantallas, EEPROM y comunicaciones: se encuentran midiendo el máximo del lazo.

¿Listo para el tema 42?

Antes de avanzar, explicá por qué millis() >= inicio + intervalo falla a los 49 días y la resta sin signo no, calculá la utilización de una tarea de 20 ms cada 200 ms más otra de 5 ms cada 50 ms y decí cómo leerías un DS18B20 sin detener el lazo. Si podés justificar las tres respuestas, ya sabés escribir programas sin bloqueos.

En el próximo tema usamos esa base para controlar de verdad: el control todo o nada y el control proporcional, con histéresis, banda muerta y los errores típicos.