← Índice del curso

Microcontroladores · Tema 42

Máquinas de estados y tareas cooperativas

Encender una luz durante cinco segundos, esperar la conversión de un sensor, recibir un mensaje byte a byte: casi todo lo que hace un dispositivo dura en el tiempo, y el bucle principal no puede esperar a que termine. La solución es recordar «en qué paso estoy» en una variable y avanzar un poquito en cada vuelta. Ese recuerdo es una máquina de estados; varias de ellas, con un reloj común, son un sistema de tareas cooperativas.

01 · Punto de partida

Comportamientos que duran

En el tema 41 vimos que el bucle principal no puede esperar. Pero muchas cosas que el dispositivo tiene que hacer sí necesitan tiempo: la luz de una escalera que se apaga a los cinco segundos, un sensor que tarda 750 ms en convertir, un menú con pantallas, un protocolo que recibe una cabecera, un largo y un dato. En un programa de escritorio se escribirían de forma secuencial, con una espera entre un paso y el otro. Aquí esa espera no existe: tiene que convertirse en memoria.

La idea es dividir el comportamiento en estados. Una variable guarda el estado actual; en cada vuelta del bucle, la función de esa máquina mira qué pasó (un evento, un tiempo cumplido), decide si cambia de estado y vuelve enseguida. No espera: vuelve. Lo que antes era «posición en el código» pasa a ser «valor de una variable», y eso permite que otras funciones avancen mientras tanto.

Este tema cierra el diseño de la estructura de un programa: el tema 41 puso el esqueleto (inicio, bucle, interrupciones, módulos) y aquí se le suma el tiempo. Los dos proyectos que siguen (temas 43 y 44) se construyen con estas piezas.

02 · El modelo

Estados, eventos y transiciones

Máquina de estados de una luz de escalera Tres estados: reposo, encendida y aviso. Desde reposo, el botón lleva a encendida y enciende la luz. En encendida, otro botón reinicia el tiempo, y a los cinco segundos sin botón pasa a aviso, donde la luz parpadea. En aviso, un botón vuelve a encendida y a los dos segundos pasa a reposo y apaga la luz. REPOSOluz apagada ENCENDIDAluz fija AVISOluz parpadea botón / encender 5 s sin botón botón botón / reiniciar el tiempo 2 s sin botón / apagar inicio

Una máquina de estados finitos (FSM, por finite state machine) tiene cuatro ingredientes:

  • un conjunto finito de estados, de los cuales exactamente uno está activo;
  • un conjunto de eventos que pueden ocurrir (un botón, un tiempo cumplido, un byte recibido, una lectura fuera de rango);
  • las transiciones: «en este estado, ante este evento, paso a aquel estado»;
  • las acciones: lo que se hace al transitar (encender un pin, iniciar un temporizador) o al entrar o permanecer en un estado.

El diagrama de arriba se lee como un mapa: los círculos son estados y las flechas, transiciones con la forma evento / acción. Un diagrama de estados es la mejor documentación de un comportamiento: se ve de un vistazo qué puede pasar desde cada punto, y sobre todo qué no puede pasar. Tabla equivalente:

Tabla de transiciones de la luz de escalera. Una celda vacía significa «se ignora el evento».
EstadoBotónTiempo cumplido
REPOSO→ ENCENDIDA (enciende la luz)—
ENCENDIDA→ ENCENDIDA (reinicia el tiempo)(5 s) → AVISO
AVISO→ ENCENDIDA (luz fija)(2 s) → REPOSO (apaga la luz)

Completar la tabla obliga a pensar los casos que se olvidan: ¿qué pasa si se aprieta el botón en medio del aviso? ¿Y si llega un tiempo cumplido en reposo? Cada celda vacía es una decisión («lo ignoro») y no un descuido. Esa es la mayor ventaja del enfoque: el comportamiento queda completo y verificable, en lugar de repartido en if anidados con banderas sueltas.

Dos formas de asociar acciones se distinguen en los libros. En una máquina de Moore la acción depende solo del estado (en ENCENDIDA la luz está fija, sin importar cómo se llegó). En una de Mealy depende de la transición (al apretar el botón en REPOSO se enciende). En la práctica se mezclan: acciones de entrada a un estado y acciones en transiciones.

03 · La implementación habitual

Implementar con switch

La forma más directa es una variable de tipo enum para el estado y un switch con un case por estado. La función se llama en cada vuelta del bucle, mira los eventos y el reloj, y siempre retorna.

typedef enum { REPOSO, ENCENDIDA, AVISO } estado_t;

#define T_ENCENDIDA_MS  5000
#define T_AVISO_MS      2000

static estado_t estado = REPOSO;
static uint32_t t_entrada;                    // instante en que se entró al estado actual

static void ir_a(estado_t nuevo) {            // única forma de cambiar de estado
    estado = nuevo;
    t_entrada = tick_ms();
    switch (nuevo) {                          // acciones de entrada
    case REPOSO:    luz_set(0); break;
    case ENCENDIDA: luz_set(1); break;
    case AVISO:     break;                    // el parpadeo lo hace escalera_update
    }
}

void escalera_update(uint8_t boton) {         // se llama desde el bucle; nunca espera
    uint32_t en_estado = tick_ms() - t_entrada;
    switch (estado) {
    case REPOSO:
        if (boton) ir_a(ENCENDIDA);
        break;
    case ENCENDIDA:
        if (boton)                              ir_a(ENCENDIDA);   // reinicia el tiempo
        else if (en_estado >= T_ENCENDIDA_MS)   ir_a(AVISO);
        break;
    case AVISO:
        luz_set((en_estado / 250) & 1);         // parpadeo: 250 ms apagada, 250 ms encendida
        if (boton)                              ir_a(ENCENDIDA);
        else if (en_estado >= T_AVISO_MS)       ir_a(REPOSO);
        break;
    }
}

Observá tres decisiones de estilo, que se repiten en toda buena máquina de estados:

  • El estado se cambia en un solo lugar (ir_a), que también registra el instante de entrada y ejecuta las acciones de entrada. Así no se olvida reiniciar el temporizador del estado.
  • El tiempo se mide con la resta sin signo contra el tick de sistema (tema 41), nunca con delay().
  • No hay default silencioso. Si el estado tomara un valor imposible (por una escritura fuera de rango), un default que vuelva a REPOSO y encienda un contador de errores convierte un cuelgue misterioso en un fallo visible.

Una variante útil es separar la lectura de eventos de la máquina: la función recibe el evento ya calculado (EV_BOTON, EV_NINGUNO) y no toca el hardware. Eso la vuelve comprobable en una computadora, con una secuencia de eventos de prueba, sin placa.

04 · Los datos en lugar del código

Implementar con una tabla

Cuando hay muchos estados y eventos, el switch anidado crece y se llena de repeticiones. Una alternativa es describir la máquina como datos: una matriz indexada por estado y evento, con el siguiente estado y la acción a ejecutar. El motor que la recorre es siempre el mismo.

typedef enum { EV_BOTON, EV_TIEMPO, EV_N } evento_t;

typedef struct {
    estado_t siguiente;
    void   (*accion)(void);                   // NULL: no hace nada
} trans_t;

static const trans_t TABLA[3][EV_N] = {
    //                 EV_BOTON                 EV_TIEMPO
    /* REPOSO    */ { {ENCENDIDA, luz_on},     {REPOSO,    NULL}     },
    /* ENCENDIDA */ { {ENCENDIDA, reiniciar},  {AVISO,     aviso_on} },
    /* AVISO     */ { {ENCENDIDA, luz_on},     {REPOSO,    luz_off}  },
};

void fsm_evento(evento_t ev) {
    const trans_t *t = &TABLA[estado][ev];
    if (t->accion) t->accion();
    estado = t->siguiente;
    t_entrada = tick_ms();
}

Las ventajas: el comportamiento completo se ve en una tabla, agregar un estado o un evento no toca el motor, y se puede revisar fácilmente que no queden celdas sin pensar. Las desventajas: las acciones deben ser funciones sin argumentos (o llevar un contexto aparte), el control de tiempos y condiciones necesita un evento aparte (EV_TIEMPO, que alguien debe generar) y la lectura es menos natural que un switch para máquinas chicas. En el ATmega328P hay un detalle más: una tabla const sin PROGMEM se copia a la SRAM (tema 41); una tabla grande se guarda en la Flash y se lee con pgm_read_*.

Cuándo conviene cada implementación.
CriterioswitchTabla
Máquinas chicas (2 a 6 estados)Más simple y legibleMás cargada de lo necesario
Muchos estados y eventos (protocolos, menús)Se vuelve larga y repetitivaLa estructura sigue siendo clara
Condiciones y tiempos dentro del estadoNatural, en el propio caseHay que convertirlas en eventos
Verificar que no falte ningún casoDepende de la disciplina (y de -Wswitch)Se revisa celda por celda
Costo de memoriaSolo códigoTabla en Flash (con PROGMEM) o en SRAM

05 · Qué empuja a la máquina

De dónde vienen los eventos

Una máquina de estados reacciona a eventos, y un evento es una noticia, no una condición permanente. Esa diferencia es la raíz de la mitad de los errores. «El botón está apretado» es un nivel (dura mientras se aprieta); «el botón fue apretado» es un evento (ocurre una vez). Si se trata el nivel como evento, la máquina avanza una vez por vuelta mientras el dedo siga presionando.

Los eventos nacen de cuatro fuentes:

  • Interrupciones que dejan una bandera o un dato (tema 41): llegó un byte, venció un temporizador, cambió un pin.
  • Detección de cambio comparando una entrada con su valor anterior en cada vuelta (flanco por sondeo).
  • Plazos: «pasaron 5 s desde que entré a este estado».
  • Otras máquinas: una máquina emite un evento (ocupado, listo, error) que otra consume. Se comunican a través de variables o de una cola.

El caso típico es el antirrebote (tema 17), que es en sí una máquina de estados de cuatro estados: confirma que el nivel se mantuvo estable un tiempo antes de declarar el evento. No hay delay(); el mismo tiempo de 20 ms corre en segundo plano mientras el bucle atiende todo lo demás.

typedef enum { B_SUELTO, B_CONF_PRESION, B_PRESIONADO, B_CONF_SUELTA } bstate_t;
enum { BTN_NADA = 0, BTN_PRESIONADO, BTN_SOLTADO };          // los eventos que emite

static bstate_t bst = B_SUELTO;
static uint32_t b_t0;

uint8_t boton_update(void) {                  // se llama cada pocos ms; devuelve un evento (o BTN_NADA)
    uint8_t crudo = !(PIND & (1 << PD2));    // activo en bajo, con pull-up
    switch (bst) {
    case B_SUELTO:
        if (crudo) { bst = B_CONF_PRESION; b_t0 = tick_ms(); }
        break;
    case B_CONF_PRESION:                      // ¿se mantiene 20 ms?
        if (!crudo) bst = B_SUELTO;           // era un rebote
        else if (tick_ms() - b_t0 >= 20) { bst = B_PRESIONADO; return BTN_PRESIONADO; }
        break;
    case B_PRESIONADO:
        if (!crudo) { bst = B_CONF_SUELTA; b_t0 = tick_ms(); }
        break;
    case B_CONF_SUELTA:
        if (crudo) bst = B_PRESIONADO;
        else if (tick_ms() - b_t0 >= 20) { bst = B_SUELTO; return BTN_SOLTADO; }
        break;
    }
    return BTN_NADA;
}

La función entrega un evento por pulsación, ya filtrado, y la máquina de la aplicación no necesita saber que existen los rebotes. Este es el patrón general de una cadena de máquinas: una capa baja convierte señales ruidosas en eventos limpios; una capa alta decide qué hacer con ellos.

Si varios eventos pueden ocurrir antes de que la máquina los procese, se guardan en una cola de eventos: el mismo búfer circular del tema 41, con un byte por evento. La regla de oro es la de ejecutar hasta completar (run to completion): la máquina procesa un evento por completo (transición y acciones) antes de recibir el siguiente, de modo que nunca queda a medio cambiar de estado.

06 · Muchas cosas a la vez

La tarea cooperativa

Un dispositivo real corre varias máquinas: un botón, un LED, un sensor, una comunicación. Cada una es una tarea: una función que se invoca periódicamente, hace un trabajo corto y vuelve. Se llaman cooperativas porque cada tarea coopera devolviendo el control; si una se queda con el procesador, nadie la puede sacar. No hay un árbitro que la interrumpa (salvo las interrupciones de hardware, que son otra cosa).

Dos maneras de repartir la CPU entre tareas.
AspectoCooperativaCon desalojo (RTOS)
Quién cambia de tareaLa propia tarea, al terminarEl planificador, por tick o prioridad
PilaUna sola, compartidaUna por tarea (cientos de bytes cada una)
Variables compartidas entre tareasCasi no hay carreras (solo con las ISR)Hacen falta semáforos y exclusión mutua
Respuesta a un evento urgenteHasta la tarea más larga que esté corriendoInmediata para la tarea de mayor prioridad
Código y SRAM extraCasi ningunoVarios KB de Flash y SRAM del núcleo
Complejidad de razonarBaja: el orden es explícitoMayor: prioridades, inversión de prioridad, bloqueos

Para un ATmega328P con 2 KB de SRAM, el esquema cooperativo es casi siempre lo correcto: sin núcleo, sin pilas múltiples, con un comportamiento determinista que se entiende leyendo el código. Un RTOS (FreeRTOS, por ejemplo) se justifica cuando hay tareas largas que no se pueden partir o requisitos de tiempo estricto con varias prioridades, y se paga con memoria.

Cada tarea cooperativa sigue el mismo contrato:

  • Vuelve rápido. Su tiempo de ejecución máximo es un dato de diseño.
  • Guarda su propio estado en variables static; no asume que la anterior ejecución dejó nada en los registros ni en la pila.
  • No espera. Si necesita tiempo, lo anota y se va; la próxima vez pregunta si ya pasó.
  • Se comunica por datos (variables, banderas, colas), no llamando a funciones de otras tareas que podrían bloquear.

07 · El director de orquesta

Un planificador simple

El planificador es una lista de tareas y un bucle que ejecuta las que ya vencieron. Cada entrada tiene una función, un período y el instante de su última ejecución. El reloj lo da el tick de sistema (tema 41): un temporizador que cuenta milisegundos.

typedef struct {
    void     (*fn)(void);                     // la tarea
    uint16_t periodo_ms;                      // cada cuánto debe ejecutarse
    uint32_t ultimo;                          // cuándo le tocaba la última vez
} tarea_t;

static tarea_t tareas[] = {                   // el orden en la lista es la prioridad
    { boton_tarea,    10,   0 },
    { serie_tarea,    50,   0 },
    { led_tarea,     500,   0 },
    { sensor_tarea,  100,   0 },
    { pantalla_tarea,100,   0 },
};
#define N_TAREAS (sizeof tareas / sizeof tareas[0])

void planificador_ejecutar(void) {
    uint32_t ahora = tick_ms();
    for (uint8_t i = 0; i < N_TAREAS; i++) {
        tarea_t *t = &tareas[i];
        if (ahora - t->ultimo >= t->periodo_ms) {
            t->ultimo += t->periodo_ms;        // mantiene el ritmo sin deriva
            if (ahora - t->ultimo >= t->periodo_ms)
                t->ultimo = ahora;             // se atrasó más de un período: no recuperar a ráfagas
            t->fn();
        }
    }
}

int main(void) {
    /* inicialización (tema 41) */
    for (;;) {
        planificador_ejecutar();
        dormir_en_idle();                     // sleep_cpu(): el tick de 1 ms despierta al chip
        wdt_reset();
    }
}

Un par de decisiones de diseño merecen atención:

  • Sumar el período al instante anterior (y no copiar la hora actual) mantiene exacta la frecuencia media de cada tarea, aunque alguna ejecución se haya demorado. Es la misma regla de la base de tiempo del tema 41.
  • No recuperar atrasos a ráfagas. Si una tarea se atrasó varios períodos (porque otra bloqueó mucho), repetirla de golpe cinco veces seguidas empeora el problema. La segunda comprobación resincroniza.
  • El orden de la lista es una prioridad: cuando varias vencen en el mismo tick, se ejecutan en el orden de la lista. Las más sensibles al retraso (botón, comunicación) van primero.
  • El sueño integra el ahorro de energía (tema 40): cuando no hay nada vencido, el microcontrolador duerme en Idle hasta la próxima interrupción, en vez de dar vueltas.

Con 15 líneas de planificador, el programa pasa de «un bucle con if sobre millis() dispersos» a una tabla donde se ve cada tarea y su ritmo, y donde agregar una más es agregar una línea. No es un sistema operativo, pero resuelve el 90 % de los casos de un dispositivo pequeño.

08 · ¿Alcanza el tiempo?

Plazos, utilización y retraso

Antes de asumir que el planificador va a cumplir, hay dos cuentas. Cada tarea i tarda Ci en ejecutarse y se repite cada Ti. La utilización es la fracción de la CPU que pide el conjunto:

U = C₁/T₁ + C₂/T₂ + … + Cn/Tn retraso de inicio de la tarea i ≤ Σj ≠ i Cj (peor caso)

Si U es mayor que 1, la CPU no alcanza y no hay planificador que lo arregle. Pero tener U = 6 % no garantiza nada. Con tareas cooperativas el problema está en otro lado: una tarea que ya empezó no se puede interrumpir, así que cualquier otra que venza en ese momento debe esperar hasta que termine. En el peor caso todas vencen a la vez (ocurre cada vez que coinciden los períodos, por ejemplo en el instante 0 y cada mínimo común múltiplo), y la última de la lista espera a todas las demás. El retraso de inicio o jitter de una tarea es, como máximo, la suma de los tiempos de las otras.

Ejemplo. Un botón que se revisa cada 10 ms (60 µs), una comunicación cada 50 ms (900 µs), una pantalla cada 100 ms (4 ms) y un sensor cada segundo (300 µs): U = 0,6 % + 1,8 % + 4 % + 0,03 % = 6,43 %. Se ve un sistema casi vacío. Sin embargo, el botón puede esperar hasta 5,2 ms si venció junto con las demás y ninguna tiene prioridad. Con un antirrebote de 20 ms es tolerable. Pero si el sensor hace una lectura bloqueante de 30 ms, el botón espera 35 ms: peor que su propio período. La utilización sigue baja; el sistema falla igual.

Las consecuencias de diseño:

  • La tarea más larga manda. El retraso que sufren todas las demás tareas está dominado por la ejecución más larga, no por el promedio. Para reducirlo, hay que partir las tareas largas en pasos cortos (una máquina de estados cuyo case hace un trozo por llamada).
  • Partir cuesta poco y rinde mucho. Una pantalla que se refresca en 50 ms puede dibujarse en diez pasos de 5 ms; la pantalla se completa igual, pero cada paso deja correr a las demás.
  • Los eventos urgentes se resuelven con interrupciones, no con tareas: una parada de emergencia no puede esperar la vuelta del planificador. La ISR deja la bandera y el hardware actúa (apaga el motor), y la tarea ordena el resto.
  • Medir. Con el temporizador y un pin (tema 14) se mide el tiempo real de cada tarea. Registrar el máximo observado de cada una, y reiniciar el sistema o avisar si una supera su presupuesto, evita sorpresas.

Queda calcular el reloj del planificador: el tick. Con el Timer0 en modo CTC, el período es (OCR0A + 1) · prescaler / F_CPU; para 1 ms a 16 MHz sirve prescaler 64 y OCR0A = 249. No todas las frecuencias de reloj dividen exacto: a 12 MHz y prescaler 64 hacen falta 187,5 cuentas, se redondea a 188 y el tick queda 0,267 % largo (casi 4 minutos de atraso por día). Cuando el tiempo absoluto importa, se elige un cristal y un prescaler que den una división exacta. La tercera herramienta del laboratorio calcula todo esto.

09 · Todo junto

Un ejemplo completo

Retomamos el sensor que tarda 750 ms en convertir (un DS18B20 de 12 bits, por ejemplo). En un programa secuencial se escribiría «iniciar, esperar 750 ms, leer». Con una máquina de estados dentro de una tarea, la espera deja de bloquear:

typedef enum { S_ESPERA, S_CONVIRTIENDO } sstate_t;

#define PERIODO_MEDIDA_MS  5000               // una medida cada 5 s
#define TIEMPO_CONV_MS      750               // lo que tarda el sensor

static sstate_t sst = S_ESPERA;
static uint32_t s_t0;
static int16_t  temperatura_dC;               // décimas de grado: lo que ve el resto del programa
static uint8_t  temperatura_valida;

void sensor_tarea(void) {                     // período del planificador: 50 ms
    switch (sst) {
    case S_ESPERA:
        if (tick_ms() - s_t0 >= PERIODO_MEDIDA_MS) {
            sensor_iniciar_conversion();      // un comando corto por el bus
            s_t0 = tick_ms();
            sst = S_CONVIRTIENDO;
        }
        break;
    case S_CONVIRTIENDO:
        if (tick_ms() - s_t0 >= TIEMPO_CONV_MS) {
            temperatura_valida = sensor_leer(&temperatura_dC);   // otro comando corto
            s_t0 = tick_ms();
            sst = S_ESPERA;
        }
        break;
    }
}

void alarma_tarea(void) {                     // período: 100 ms
    if (temperatura_valida && temperatura_dC > 300) alarma_on();
    else                                      alarma_off();
}

La tarea del sensor nunca espera: gasta microsegundos en cada llamada (el comando y la lectura) y los 750 ms son un estado, durante el cual el botón, la comunicación y la alarma siguen funcionando. Las tareas se comunican por temperatura_dC y temperatura_valida (variables que solo escribe una de ellas): como todas corren en la misma línea de ejecución, no hace falta protegerlas, siempre que no las toque una ISR.

La estructura completa del programa queda así: un tick de 1 ms (ISR de 6 µs) → un bucle que llama al planificador y duerme → una lista de tareas, cada una con su máquina de estados → módulos con init y update (tema 41) → un watchdog acariciado desde el bucle. Es la arquitectura de los dos proyectos que siguen.

10 · Lo que suele salir mal

Trampas y buenas prácticas

Síntomas y causas en máquinas de estados y planificadores.
SíntomaCausa probable
Una pulsación produce varias accionesSe usó el nivel del botón en lugar del evento de flanco; falta consumir el evento después de procesarlo
La máquina se «queda pegada» en un estadoFalta una transición de salida (nadie genera el evento esperado) o un plazo de seguridad (timeout)
Se pierde un evento breveLa bandera se borra después de procesar (llegó otro entremedio): borrarla antes, de forma atómica, o usar una cola
El comportamiento cambia según el orden de las tareasDependencias ocultas entre tareas: una lee lo que otra escribe en la misma vuelta
Un botón «a veces» no respondeAlguna tarea tarda más que su pulsación o su período; el retraso de inicio supera lo que tolera
Una tarea se dispara varias veces seguidasSe recupera el atraso a ráfagas tras un bloqueo largo
El reloj de las tareas se atrasa o adelantaTick con error de redondeo (prescaler que no divide exacto) o ultimo = ahora en lugar de sumar el período
Comportamiento imprevisible tras horasUn estado con un valor fuera del enum (escritura fuera de límites) y un switch sin default
Funciona una vez y la segunda fallaLa máquina no reinicia sus variables propias al volver al estado inicial (el tiempo de entrada, los contadores)

Buenas prácticas, en orden de rendimiento: 1) dibujar el diagrama (o la tabla) antes de escribir código; 2) un solo lugar para cambiar de estado, que reinicie lo necesario; 3) eventos limpios y consumidos una sola vez; 4) tareas con un tiempo máximo conocido y partidas si pasa de unos pocos milisegundos; 5) un plazo de seguridad en todo estado que espera algo externo; 6) una salida de depuración que muestre el estado actual y el máximo tiempo de cada tarea.

11 · Analogía con programación

Simular máquinas y planificadores con datos

Si usás async/await, generadores de Python o Promise en JavaScript, ya conocés la multitarea cooperativa: una función que hace un await o un yield devuelve el control y retoma después. El compilador de esos lenguajes convierte la función en una máquina de estados, igual que la que escribimos a mano acá: cada await es una frontera entre estados y las variables locales viven en una estructura. En un microcontrolador no hay esa ayuda, y el estado se escribe explícitamente; a cambio no hay ningún costo oculto de memoria.

Este programa simula la luz de escalera con dos pulsaciones, ejecuta el planificador con un reloj de 1 ms para dos configuraciones (con un sensor corto y con uno bloqueante) y calcula el tick de 1 ms para cinco relojes.

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

# 1) luz de escalera: una funcion de transicion pura, probada sin hardware
TICK, T_ENCENDIDA, T_AVISO = 100, 5000, 2000                 # ms

def paso(estado, t_en_estado, boton):
    if estado == "REPOSO":
        if boton: return "ENCENDIDA", 0
    elif estado == "ENCENDIDA":
        if boton: return "ENCENDIDA", 0                       # reinicia el tiempo
        if t_en_estado >= T_ENCENDIDA: return "AVISO", 0
    else:                                                     # AVISO
        if boton: return "ENCENDIDA", 0
        if t_en_estado >= T_AVISO: return "REPOSO", 0
    return estado, t_en_estado

estado, t_en = "REPOSO", 0
botones = {1000, 3000}
for t in range(0, 12001, TICK):
    t_en += TICK
    nuevo, t_nuevo = paso(estado, t_en, t in botones)
    if nuevo != estado or t in botones:
        print(f"t = {t:5d} ms  {estado:9s} -> {nuevo:9s}" + ("  (boton)" if t in botones else ""))
    estado, t_en = nuevo, t_nuevo
print("estado final:", estado)

# 2) planificador cooperativo con tick de 1 ms (tiempos en microsegundos)
def simular(tareas, duracion_ms):
    ultimo = {nombre: 0 for nombre, _, _ in tareas}
    stats = {nombre: [0, 0] for nombre, _, _ in tareas}       # ejecuciones, retraso maximo
    libre, ocupado = 0, 0                                     # instante en que la CPU queda libre
    for ms in range(1, duracion_ms + 1):
        tick = ms * 1000
        t = max(tick, libre)                                  # si la pasada anterior se alargo, se empieza tarde
        for nombre, periodo, costo in tareas:
            if ms - ultimo[nombre] >= periodo:
                ultimo[nombre] += periodo
                stats[nombre][0] += 1
                stats[nombre][1] = max(stats[nombre][1], t - tick)
                t += costo
                ocupado += costo
        libre = t
    return stats, ocupado / (duracion_ms * 1000)

TAREAS = [("boton", 10, 60), ("serie", 50, 900), ("led", 500, 20), ("sensor", 1000, 300), ("pantalla", 100, 4000)]
BLOQUEANTE = [(n, p, 30000 if n == "sensor" else c) for n, p, c in TAREAS]
for titulo, conjunto in (("sensor de 0,3 ms", TAREAS), ("sensor bloqueante de 30 ms", BLOQUEANTE)):
    stats, uso = simular(conjunto, 2000)
    print()
    print(f"{titulo}: CPU {uso * 100:.2f} % en 2 s")
    for nombre, (ejec, retraso) in stats.items():
        print(f"  {nombre:9s} {ejec:4d} ejecuciones   retraso maximo {retraso:6d} us")

# 3) tick de 1 ms con el Timer0 (8 bits) en modo CTC
print()
print("F_CPU (MHz)  prescaler  OCR0A   tick real (us)   error     deriva por dia")
for f in (16e6, 8e6, 1e6, 12e6, 11.0592e6):
    for pre in (1, 8, 64, 256, 1024):
        cuentas = f * 0.001 / pre
        if cuentas <= 256:
            n = int(cuentas + 0.5)
            real = n * pre / f
            error = (real - 0.001) / 0.001
            print(f"{f / 1e6:10.4f} {pre:9d} {n - 1:7d} {real * 1e6:14.2f} {error * 100:8.3f} % {error * 86400:10.1f} s")
            break

Resultado: la luz se enciende a 1000 ms; la pulsación de 3000 ms la reinicia (sigue ENCENDIDA) y, como 5 s después ya no hay botón, pasa a AVISO a 8000 ms y a REPOSO a 10 000 ms. En el planificador con el sensor corto, la CPU está ocupada 6,43 % del tiempo: el botón se ejecuta 200 veces sin retraso, la comunicación espera como máximo 60 µs, el LED 960 µs, el sensor 980 µs y la pantalla 1280 µs. Con el sensor bloqueante de 30 ms, la CPU solo sube a 9,40 %, pero el botón llega a esperar 24 980 µs (casi 25 ms, 2,5 veces su período) y la pantalla 30 980 µs: la utilización seguía baja y el sistema falló igual. En el tick de 1 ms, 16 MHz con prescaler 64 y OCR0A = 249, 8 MHz con prescaler 64 y OCR0A = 124, y 1 MHz con prescaler 8 y OCR0A = 124 dan exacto; 12 MHz (OCR0A = 187) queda 0,267 % largo, 230,4 s por día, y 11,0592 MHz (OCR0A = 172), 0,116 % largo, 100,0 s por día.

Los tiempos de cada tarea son valores de ejemplo; en un proyecto real se miden con un pin y un osciloscopio o con el temporizador (tema 14).

12 · Exploración interactiva

Estados, tareas y tick

La primera herramienta simula la máquina de la luz de escalera con las pulsaciones que elijas. La segunda analiza un conjunto de cuatro tareas cooperativas: utilización y peor retraso de inicio. La tercera calcula la configuración del temporizador para el tick.

Banco de tareas

1. Luz de escalera: línea de tiempo

Transiciones—
Luz encendida—
Estado al terminar—

2. Análisis de un planificador cooperativo

Utilización—
Tarea más larga—
Peor retraso de inicio—
Veredicto—

3. Tick de sistema con un temporizador

Configuración—
Tick real—
Costo en CPU—
Otros prescalers posibles—

Recorrido guiado

  1. Con 5 s, 2 s y pulsaciones a 1 y 3 s: la luz se enciende a 1 s, la segunda pulsación reinicia el tiempo, pasa a AVISO a 8 s y a REPOSO a 10 s. La luz está encendida 8 s (7 fija y 1 de los 2 s de parpadeo).
  2. Probá con pulsaciones 1, 2, 3, 4, 5, 6, 7, 8, 9: la luz no se apaga mientras alguien siga pulsando. Con 0.5 y 20 de duración se ve el ciclo completo de ENCENDIDA a AVISO y a REPOSO.
  3. En el análisis de tareas, con los valores iniciales: 6,43 % de utilización, la pantalla (4 ms) es la más larga y el botón puede esperar 5,2 ms, dentro de los 6 ms tolerados.
  4. Cambiá el tiempo del sensor a 30 000 µs: la utilización sube solo a unos 9,4 %, pero el botón espera hasta 34,9 ms y el veredicto es «NO CUMPLE». Una utilización baja no garantiza nada.
  5. Con el sensor otra vez en 300 µs, partí la pantalla: 1000 µs en un período de 25 ms (cuatro pasos). La utilización no cambia, pero el peor retraso del botón baja de 5,2 a 2,2 ms.
  6. En el tick, 16 MHz y 1 ms con Timer0: prescaler 64, OCR0A = 249, exacto. A 12 MHz, el error es de 0,267 % (230,4 s por día) y la nota avisa de que ese error se acumula: para medir horas o días hace falta una combinación exacta. Con el Timer1, 16 MHz, 1 ms, es OCR1A = 15 999 con prescaler 1.
Desafío: ¿qué período máximo admite la pantalla?

Un sistema tiene tres tareas: el botón (60 µs cada 10 ms), la comunicación (900 µs cada 50 ms) y una pantalla que se dibuja de a trozos. El botón tolera 3 ms de retraso. Su peor retraso es la suma de las otras: 900 µs + el trozo de pantalla. Para quedar en 3 ms, el trozo debe ser menor que 3000 − 900 = 2100 µs. Si dibujar toda la pantalla toma 40 ms, hacen falta al menos 40 / 2,1 = 19,05 → 20 trozos de 2 ms. Si la pantalla debe estar completa cada 400 ms, la tarea se llama cada 400 / 20 = 20 ms. La utilización de la pantalla es 40 ms / 400 ms = 10 %, igual que antes de partirla: se cambia latencia por más llamadas, no cantidad de trabajo.

13 · Comprensión

Confusiones frecuentes

«Una máquina de estados es solo para sistemas complejos»

Con la más pequeña (encender una luz por cinco segundos) ya aparecen un estado, un evento y un tiempo. Los programas pequeños con banderas sueltas y if repartidos son, en realidad, máquinas de estados mal escritas, difíciles de verificar. Dibujar los estados no cuesta nada y ordena el pensamiento.

«Con un RTOS ya no necesito máquinas de estados»

Un RTOS permite escribir cada tarea de forma secuencial con esperas bloqueantes, pero las máquinas de estados siguen siendo la mejor forma de describir protocolos, menús y secuencias, y no necesitan pila propia. Se usan juntos con frecuencia.

«Si la utilización es baja, las tareas cumplen sus plazos»

En un esquema cooperativo, una sola tarea larga retrasa a todas las demás aunque la CPU esté casi desocupada. Hay que mirar el peor retraso de inicio (la suma de las otras tareas), no el promedio.

«Con un tick más rápido el sistema responde mejor»

El tick determina la resolución del tiempo, pero no la latencia: la respuesta depende de cuánto tarda una vuelta del planificador. Un tick de 100 µs con tareas de 5 ms sigue respondiendo en 5 ms, y se paga con una interrupción diez veces más frecuente (más CPU y más consumo).

«El orden de las tareas en la lista no importa»

Cuando varias vencen a la vez, se ejecutan en el orden de la lista; la última espera a todas las anteriores. El orden es la prioridad: las tareas sensibles al retraso van primero y las largas, al final.

14 · Práctica guiada

Predecí antes de comprobar

1. La línea de tiempo

En la luz de escalera (5 s fija, 2 s de aviso) se pulsa el botón a los 0 s y a los 3 s, y no se vuelve a tocar. ¿En qué instantes cambia de estado?

Ver solución

A 0 s pasa a ENCENDIDA. A 3 s sigue ENCENDIDA, pero el tiempo se reinicia. Como a partir de 3 s no hay botón, a 3 + 5 = 8 s pasa a AVISO, y a 8 + 2 = 10 s vuelve a REPOSO. Sin la segunda pulsación habría sido a 5 s y 7 s.

2. Configurar el tick

Con un reloj de 8 MHz se quiere un tick de 2 ms con el Timer0 en modo CTC. ¿Qué prescaler y qué valor de OCR0A sirven? ¿Por qué no el 256?

Ver solución

Con el prescaler 64: 8 MHz × 2 ms / 64 = 250 cuentas, o sea OCR0A = 249, exacto. Con el 256 serían 62,5 cuentas: se redondea a 63 y el tick queda 0,8 % largo (unos 11 minutos por día). Con el 8, serían 2000 cuentas, más de lo que cuenta un temporizador de 8 bits.

3. Utilización y retraso

Tres tareas: A de 2 ms cada 10 ms, B de 1 ms cada 50 ms, C de 8 ms cada 100 ms. Calculá la utilización y el peor retraso de inicio de A. ¿Cómo se mejora?

Ver solución

U = 2/10 + 1/50 + 8/100 = 0,20 + 0,02 + 0,08 = 30 %. En el peor caso (todas vencen juntas), A espera a B y C: 1 + 8 = 9 ms, casi todo su período de 10 ms. Se mejora partiendo C en cuatro trozos de 2 ms, uno cada 25 ms: la utilización es la misma, pero el retraso de A baja a 1 + 2 = 3 ms.

4. El evento que se pierde

Una ISR hace flag = 1; y el bucle hace if (flag) { procesar(); flag = 0; }, con procesar() de 3 ms. ¿Qué falla y cómo se corrige?

Ver solución

Un evento que llega durante los 3 ms de procesar() pone flag en 1 y, al terminar, el bucle lo borra: ese evento se pierde. Se corrige leyendo y borrando la bandera antes de procesar, dentro de una sección crítica (ATOMIC_BLOCK), de modo que un evento posterior quede marcado para la vuelta siguiente; o con un contador o una cola si pueden llegar varios.

5. El antirrebote paso a paso

El antirrebote de 4 estados (20 ms de confirmación) lee cada 5 ms la entrada cruda: 0 a 0 ms, 1 a 5, 0 a 10, 1 a 15, y 1 desde 20 hasta 40 ms. ¿En qué instante emite BTN_PRESIONADO?

Ver solución

A 5 ms entra en B_CONF_PRESION (t0 = 5). A 10 ms el valor es 0: vuelve a B_SUELTO (era un rebote). A 15 ms entra otra vez en confirmación (t0 = 15) y se mantiene en 1. Se cumplen los 20 ms a 15 + 20 = 35 ms: emite el evento a los 35 ms, una sola vez.

15 · Síntesis

Ideas para recordar

  • Un comportamiento que dura en el tiempo se divide en estados: una variable recuerda dónde está y cada vuelta avanza un poco y vuelve, sin esperar.
  • Una máquina de estados tiene estados, eventos, transiciones y acciones; la tabla de transiciones obliga a decidir todos los casos, y las celdas vacías son «se ignora».
  • Se implementa con un switch (máquinas pequeñas, con tiempos en el propio estado) o con una tabla (muchos estados y eventos); el estado se cambia en un solo lugar, que registra el instante de entrada.
  • Un evento es una noticia, no un nivel: el antirrebote y la detección de flancos convierten señales ruidosas en un evento por pulsación; los eventos se consumen una vez y, si pueden acumularse, van a una cola.
  • Las tareas cooperativas son funciones cortas que guardan su estado, no esperan y devuelven el control; evitan la pila por tarea y casi todas las carreras de un RTOS, a costa de no poder desalojar.
  • Un planificador simple recorre una tabla de tareas con período y último instante; suma el período (no copia la hora), no recupera atrasos a ráfagas y duerme cuando nada venció.
  • La utilización Σ C/T debe ser menor que 1, pero lo que decide la respuesta es el peor retraso de inicio, que es la suma de los tiempos de las otras tareas: se parten las tareas largas y los eventos urgentes van por interrupción.
  • El tick se calcula con (OCR + 1) · prescaler / F_CPU; hay que buscar combinaciones exactas (16 MHz, prescaler 64, OCR 249 para 1 ms) porque el error de redondeo se acumula como deriva.

¿Listo para el tema 43?

Antes de avanzar, dibujá (o escribí como tabla) la máquina de estados de un comportamiento sencillo, calculá la utilización y el peor retraso de inicio de un conjunto de tareas y elegí el prescaler y el valor de comparación del tick para un reloj dado. Si podés justificar las tres respuestas, tenés las herramientas para armar un proyecto completo.

En el próximo tema armamos un proyecto: la estación de medición de bajo consumo, que junta el sensor, el registro de datos, la comunicación serie y el sueño con la estructura que acabamos de ver.