01 · Punto de partida
Del modelo al código
Una implementación de una máquina de estados es buena si cumple cinco condiciones, que sirven para juzgar cualquiera de los estilos de este tema:
- Se parece al modelo. Quien mira la tabla del tema 24 debe poder encontrar cada celda en el código, y quien mira el código debe poder reconstruir la tabla. Si hay que «traducir» mentalmente, los dos se desincronizan.
- Hace visible lo que falta. Un estado sin tratar o una celda sin decidir deberían saltar a la vista, o al compilador, y no quedar escondidos.
- No bloquea. Procesa un evento y devuelve el control. Las esperas son estados, no bucles (temas 14 y 19).
- Se separa del hardware. Las acciones llaman a una interfaz (
motor_abrir()), no a registros. Así la misma máquina corre en el equipo y en una computadora, que es donde se prueba. - Se puede probar celda por celda. Si probarla exige reproducir veinte eventos de contexto cada vez, no se va a probar.
Todos los ejemplos son del portón del tema anterior. Comparten estas definiciones y esta interfaz con el hardware, que no cambia de un estilo a otro:
/* porton.h */
typedef enum { E_CERRADO, E_ABRIENDO, E_ABIERTO, E_CERRANDO, E_DETENIDO, E_CANT } Estado;
typedef enum { EV_BOTON, EV_FC_ABIERTO, EV_FC_CERRADO, EV_OBSTACULO, EV_TIMEOUT, EV_CANT } Evento;
void porton_iniciar(void);
void porton_evento(Evento ev); /* procesa UN evento hasta terminar y devuelve el control */
Estado porton_estado(void);
/* hal_porton.h: lo único que la máquina conoce del mundo físico */
void motor_abrir(void); void motor_cerrar(void); void motor_parar(void);
void luz(bool encendida);
void temporizador_armar(uint32_t ms); void temporizador_cancelar(void);
E_CANT y EV_CANT al final de cada enumeración son un truco estándar: valen la cantidad de elementos, de modo que se usan para dimensionar tablas y para validar rangos sin tener que actualizar un número a mano cada vez que se agrega un estado.
02 · La forma más directa
Estilo 1: el switch anidado
El primer estilo es el que sale solo: un switch sobre el estado y, dentro de cada caso, comparaciones sobre el evento. Se lee casi como la tabla, fila por fila:
static Estado estado = E_CERRADO;
void porton_evento(Evento ev) {
switch (estado) {
case E_CERRADO:
if (ev == EV_BOTON) { motor_abrir(); estado = E_ABRIENDO; }
break;
case E_ABRIENDO:
if (ev == EV_FC_ABIERTO) { motor_parar(); estado = E_ABIERTO; }
else if (ev == EV_BOTON) { motor_parar(); estado = E_DETENIDO; }
break;
case E_ABIERTO:
if (ev == EV_BOTON || ev == EV_TIMEOUT) { motor_cerrar(); estado = E_CERRANDO; }
break;
case E_CERRANDO:
if (ev == EV_FC_CERRADO) { motor_parar(); estado = E_CERRADO; }
else if (ev == EV_BOTON) { motor_parar(); estado = E_DETENIDO; }
else if (ev == EV_OBSTACULO) { motor_abrir(); estado = E_ABRIENDO; }
break;
case E_DETENIDO:
if (ev == EV_BOTON) { motor_cerrar(); estado = E_CERRANDO; }
break;
case E_CANT: /* centinela: nunca es un estado válido */
break;
}
}
Sus virtudes son la simplicidad y la ausencia de infraestructura: sin tablas, sin tipos auxiliares. Para una máquina de cuatro o cinco estados con pocas transiciones es una elección perfectamente razonable. Sus problemas aparecen al crecer, y vale la pena conocerlos:
- La completitud no es visible. Una celda vacía es la ausencia de una línea, y las ausencias no se ven. Nadie se da cuenta de que falta
EV_OBSTACULOenE_CERRANDOhasta que el portón baja sobre un objeto. - Las acciones comunes se repiten. Si hay que encender una luz al salir de
E_CERRADO, hay que hacerlo en cada transición que parte de él, y recordarlo en cada transición nueva. Es la fuente típica de «funciona salvo cuando llego por ese camino». - El estado se puede asignar en cualquier parte. Cualquier línea que escriba
estado = ...es una transición, y no hay un solo punto donde registrarlas todas para depurar. - Crece mal. Con 12 estados y 8 eventos el
switchpasa de las 100 líneas y deja de leerse como una tabla.
El compilador ayuda con una opción poco usada: -Wswitch (incluida en -Wall) advierte cuando un switch sobre una enumeración no trata algún valor y no tiene rama default. Por eso el ejemplo trata explícitamente todos los valores (incluido el centinela) y no usa default: si mañana se agrega E_FALLO a la enumeración y se olvida tratarlo, el compilador lo señala. Un default apagaría esa protección.
03 · El modelo como datos
Estilo 2: la tabla de transiciones
La segunda forma es llevar la tabla del tema 24, literalmente, a un arreglo de dos dimensiones indexado por estado y evento. Cada celda dice si hay transición, a qué estado va y qué acción ejecuta (un puntero a función). El código que ejecuta la máquina se reduce a cinco líneas, idénticas para cualquier máquina:
typedef void (*Accion)(void);
typedef struct {
bool valida; /* false: el estado ignora este evento */
Estado siguiente;
Accion accion;
} Transicion;
#define T(sig, acc) { true, (sig), (acc) }
static const Transicion TABLA[E_CANT][EV_CANT] = {
[E_CERRADO] = { [EV_BOTON] = T(E_ABRIENDO, motor_abrir) },
[E_ABRIENDO] = { [EV_FC_ABIERTO] = T(E_ABIERTO, motor_parar),
[EV_BOTON] = T(E_DETENIDO, motor_parar) },
[E_ABIERTO] = { [EV_BOTON] = T(E_CERRANDO, motor_cerrar),
[EV_TIMEOUT] = T(E_CERRANDO, motor_cerrar) },
[E_CERRANDO] = { [EV_FC_CERRADO] = T(E_CERRADO, motor_parar),
[EV_BOTON] = T(E_DETENIDO, motor_parar),
[EV_OBSTACULO] = T(E_ABRIENDO, motor_abrir) },
[E_DETENIDO] = { [EV_BOTON] = T(E_CERRANDO, motor_cerrar) },
};
static Estado estado = E_CERRADO;
void porton_evento(Evento ev) {
if ((unsigned)estado >= E_CANT || (unsigned)ev >= EV_CANT) { falla_segura(); return; }
const Transicion *t = &TABLA[estado][ev];
if (!t->valida) return; /* ignorado: una decisión que está escrita en la tabla */
if (t->accion) t->accion();
estado = t->siguiente;
}
Las ventajas son las que el estilo anterior no tenía:
- La tabla es el modelo. Se compara contra el diagrama a simple vista; las filas son estados, las columnas son eventos, y el código se lee igual que el documento de diseño.
- El despachador no cambia nunca. Agregar un estado o un evento es agregar una fila o una columna y ajustar las enumeraciones; no se toca la lógica que lo ejecuta, y por lo tanto no se rompe.
- Un solo punto de cambio de estado, la última línea de
porton_evento: ahí se puede agregar un registro de cada transición para depurar, o la ejecución de las acciones de entrada y de salida (apartado 5). - La validación es automática. Los índices fuera de rango se detectan en un lugar. Con
_Static_assert(sizeof TABLA / sizeof TABLA[0] == E_CANT, "...")el compilador comprueba que la tabla tiene una fila por estado. - Se puede generar. Una hoja de cálculo o un script puede producir la tabla a partir de un archivo de diseño, evitando transcripciones manuales.
Dos detalles de oficio. El campo valida existe por una trampa de C: con inicializadores designados, las celdas no mencionadas quedan en cero, y un cero en siguiente significaría E_CERRADO, una transición real a un estado real. Sin el campo, cada celda vacía «transitaría» a CERRADO con acción nula. Y el costo: en un Cortex-M cada celda ocupa 12 bytes (el booleano, el enumerado y el puntero, con relleno), de modo que 5 × 5 celdas son 300 bytes de memoria de programa, que pueden bajar a 200 si el siguiente estado y el indicador se codifican en un byte. Para una máquina de 20 estados y 15 eventos serían 3600 bytes: hay que mirarlo. En ese caso conviene una tabla dispersa (solo las transiciones existentes, como una lista de cuádruplas estado-evento-siguiente-acción) que se recorre linealmente, a costa de tiempo.
Y la advertencia opuesta: una tabla densa con muchos huecos es desperdicio, pero también es documentación, porque cada hueco es una decisión. La mayoría de los equipos acepta pagar esos cientos de bytes por la claridad.
04 · Cada estado, un manejador
Estilo 3: un manejador por estado
El tercer estilo (el patrón State de la programación orientada a objetos, escrito en C) da a cada estado una función que sabe reaccionar a los eventos y devuelve el estado siguiente. Es el estilo más cómodo cuando cada estado tiene lógica propia (guardas, contadores, decisiones) que no cabe en una celda de una tabla:
typedef Estado (*Manejador)(Evento ev);
static Estado en_cerrado(Evento ev) {
if (ev == EV_BOTON) { motor_abrir(); return E_ABRIENDO; }
return E_CERRADO; /* ignorado: seguimos donde estábamos */
}
static Estado en_abriendo(Evento ev) {
if (ev == EV_FC_ABIERTO) { motor_parar(); return E_ABIERTO; }
if (ev == EV_BOTON) { motor_parar(); return E_DETENIDO; }
return E_ABRIENDO;
}
/* ...en_abierto, en_cerrando y en_detenido de la misma forma... */
static const Manejador MANEJADORES[E_CANT] = {
[E_CERRADO] = en_cerrado, [E_ABRIENDO] = en_abriendo, [E_ABIERTO] = en_abierto,
[E_CERRANDO] = en_cerrando, [E_DETENIDO] = en_detenido,
};
static Estado estado = E_CERRADO;
void porton_evento(Evento ev) {
Estado nuevo = MANEJADORES[estado](ev);
cambiar_estado(nuevo); /* un único lugar donde se asigna el estado */
}
Cada manejador es una función pequeña, con su propio ámbito y sus propias variables locales, y las guardas se escriben con toda la libertad del lenguaje. Es el estilo natural para máquinas jerárquicas y para estados con mucho comportamiento (el tema 26), y también para lograr que un estado de una máquina con lógica pesada no ensucie a los demás. El costo es que se pierde la vista de conjunto: la «tabla» está repartida en cinco funciones, y ver todas las celdas a la vez exige recorrerlas. La completitud vuelve a ser invisible, como en el switch.
| Criterio | Switch anidado | Tabla de transiciones | Manejador por estado |
|---|---|---|---|
| Se parece al modelo | Por filas, razonable | Idéntica a la tabla | Repartida en funciones |
| Hace visibles las celdas vacías | No | Sí | No |
| Un punto único de cambio de estado | No (cada estado = ...) | Sí | Sí (cambiar_estado) |
| Guardas y lógica por estado | Cómodo | Incómodo (la acción tiene que decidir) | Muy cómodo |
| Memoria | Mínima | Una celda por par (estado, evento) | Un puntero por estado |
| Crece con el número de estados | Mal | Bien (si la tabla no es enorme) | Bien |
| Se puede generar a partir de un diseño | Difícil | Fácil | Posible |
| Mejor para... | Máquinas chicas | Máquinas medianas, con muchas transiciones planas | Estados con mucha lógica propia |
05 · Lo que siempre hay que hacer
Acciones de entrada y de salida
Del tema 24: lo que debe ocurrir siempre que se entra a un estado, o se sale de él, sin importar por cuál transición, se define una sola vez. En el portón: la luz se enciende al salir de CERRADO y se apaga al entrar; el temporizador de autocierre se arma al entrar a ABIERTO y se cancela al salir. Para que esto funcione en cualquiera de los estilos, todo cambio de estado pasa por una única función, que garantiza el orden:
typedef struct { void (*entrada)(void); void (*salida)(void); } EstadoDef;
static void entrar_cerrado(void) { luz(false); }
static void salir_cerrado(void) { luz(true); }
static void entrar_abierto(void) { temporizador_armar(10000); } /* autocierre a los 10 s */
static void salir_abierto(void) { temporizador_cancelar(); }
static const EstadoDef DEF[E_CANT] = { /* los estados sin acciones quedan en NULL */
[E_CERRADO] = { entrar_cerrado, salir_cerrado },
[E_ABIERTO] = { entrar_abierto, salir_abierto },
};
static void ir_a(Estado nuevo, Accion accion_transicion) {
if (nuevo == estado) { /* transición INTERNA: no se sale ni se entra */
if (accion_transicion) accion_transicion();
return;
}
if (DEF[estado].salida) DEF[estado].salida(); /* 1) salir del estado viejo */
if (accion_transicion) accion_transicion(); /* 2) la acción de la transición */
estado = nuevo;
if (DEF[nuevo].entrada) DEF[nuevo].entrada(); /* 3) entrar al estado nuevo */
}
void porton_iniciar(void) {
estado = E_CERRADO;
if (DEF[estado].entrada) DEF[estado].entrada(); /* el estado inicial también "se entra" */
}
El orden salida, acción, entrada es el de UML y el que conviene respetar: primero se deja el estado viejo en orden, después se hace lo que corresponde a la transición, y por último se prepara el nuevo. En el portón, cuando un obstáculo lleva de CERRANDO a ABRIENDO, ocurre: salida de CERRANDO (nada), acción motor_abrir(), entrada a ABRIENDO (nada). Cuando el temporizador vence en ABIERTO: salida (cancelar el temporizador, ya vencido), acción motor_cerrar(), entrada a CERRANDO.
Tres sutilezas que causan defectos reales:
- Transición propia. ¿Qué pasa si un evento lleva de un estado al mismo? Hay dos políticas: interna (solo se ejecuta la acción de la transición) y externa (se sale y se vuelve a entrar, reiniciando lo que la entrada arma). Hay que decidir cuál, escribirlo, y que sea la misma para todo el proyecto.
- El estado inicial. La acción de entrada del estado inicial hay que ejecutarla en el arranque, como hace
porton_iniciar: si no, la luz del portón cerrado queda en el estado en que la dejó el hardware. - Eventos viejos. El temporizador de
ABIERTOpuede vencer justo cuando el usuario aprieta el botón; elEV_TIMEOUTya está en la cola cuando la salida cancela el temporizador, y aparece cuando la máquina ya está enCERRANDO. En este modelo es inofensivo porqueCERRANDOignoraTIMEOUT: es una de esas celdas vacías que están vacías con razón. En otros diseños se resuelve numerando los temporizadores y descartando los eventos de una generación anterior.
Y una regla que evita bucles sorprendentes: las acciones no procesan eventos. Si una acción de entrada necesita producir un evento (por ejemplo «ya estoy abierto, avisá»), lo encola para después; si lo procesara en el acto, se llamaría a la máquina dentro de sí misma, en medio de una transición, con el estado a medio actualizar.
06 · Conectar con el mundo
Eventos y bucle principal
La máquina recibe eventos, pero quien los genera son interrupciones, tareas y temporizadores, con ritmos propios. El elemento que los une es una cola de eventos: un buffer circular donde los productores dejan eventos y de donde el bucle principal los saca de a uno. Es el buffer del tema 16, y vale lo mismo sobre sus riesgos: si hay varios productores (una ISR y una tarea) la inserción necesita una sección crítica corta.
#define COLA_EV 16u
static volatile Evento cola_ev[COLA_EV];
static volatile uint8_t cab, cola;
static volatile uint32_t eventos_perdidos; /* para detectar un desborde */
bool evento_publicar(Evento ev) { /* desde ISR o desde el bucle: varios productores */
uint32_t previo = critica_entrar();
uint8_t sig = (uint8_t)((cab + 1u) % COLA_EV);
bool ok = (sig != cola);
if (ok) { cola_ev[cab] = ev; cab = sig; } else { eventos_perdidos++; }
critica_salir(previo);
return ok;
}
bool evento_tomar(Evento *ev) { /* un solo consumidor: el bucle principal */
if (cola == cab) return false;
*ev = cola_ev[cola];
cola = (uint8_t)((cola + 1u) % COLA_EV);
return true;
}
/* temporizador por software: arma, cancela y produce EV_TIMEOUT cuando vence */
static uint32_t t_arme, t_plazo; static bool armado;
void temporizador_armar(uint32_t ms) { t_arme = tick_ms(); t_plazo = ms; armado = true; }
void temporizador_cancelar(void) { armado = false; }
static void temporizador_revisar(void) {
if (armado && tick_vencido(t_arme, t_plazo)) { armado = false; evento_publicar(EV_TIMEOUT); }
}
int main(void) {
hardware_iniciar(); tick_iniciar(); porton_iniciar();
for (;;) {
entradas_leer(); /* convierte señales en eventos: flancos, sin rebote (tema 24) */
temporizador_revisar();
Evento ev;
while (evento_tomar(&ev)) porton_evento(ev); /* de a uno, hasta terminar cada uno */
/* sin eventos pendientes: dormir hasta la próxima interrupción */
}
}
Este esquema da el comportamiento run to completion del tema 24: cada evento se procesa completo (acciones de salida, de transición y de entrada) antes de tomar el siguiente. Con un RTOS (tema 20) el bucle se reemplaza por una tarea que espera en una cola del sistema (xQueueReceive) y la máquina queda idéntica. El contador eventos_perdidos cumple el rol que ya conocés: convertir un desborde silencioso en un dato visible. Dimensionar la cola es, otra vez, una cuenta: eventos por segundo por el mayor intervalo sin atender.
Mantener la máquina desacoplada de cómo llegan los eventos es lo que permite probarla: en la computadora, un programa de prueba llama directamente a porton_evento() con la secuencia que quiere.
07 · Que no se escapen los defectos
Cómo probarla
Una máquina de estados es casi ideal para probar, porque su comportamiento es una función pura de (estado, evento): no hace falta hardware, solo una forma de forzar el estado, aplicar un evento y observar el estado resultante y las acciones que se ejecutaron. La técnica estándar es una prueba por celda, escrita como una tabla de casos, compilada en la computadora con un «hardware» falso que solo registra qué se llamó:
/* test/test_porton.c: se compila en la PC; el HAL falso anota las llamadas en una cadena */
static char registro[64];
void motor_abrir(void) { strcat(registro, "A"); }
void motor_cerrar(void) { strcat(registro, "C"); }
void motor_parar(void) { strcat(registro, "P"); }
void luz(bool on) { (void)on; }
void temporizador_armar(uint32_t ms) { (void)ms; strcat(registro, "T"); }
void temporizador_cancelar(void) { strcat(registro, "t"); }
typedef struct { Estado desde; Evento ev; Estado hacia; const char *acciones; } Caso;
static const Caso CASOS[] = { /* una fila por celda: 25 en total */
{ E_CERRADO, EV_BOTON, E_ABRIENDO, "A" },
{ E_CERRADO, EV_FC_ABIERTO, E_CERRADO, "" }, /* ignorado: no cambia, no hace nada */
{ E_ABRIENDO, EV_FC_ABIERTO, E_ABIERTO, "PT" }, /* parar, y la entrada arma el temporizador */
{ E_ABIERTO, EV_TIMEOUT, E_CERRANDO, "tC" }, /* la salida cancela el temporizador; luego cerrar */
{ E_CERRANDO, EV_OBSTACULO, E_ABRIENDO, "A" },
/* ... las 20 restantes ... */
};
int main(void) {
int fallas = 0;
for (unsigned i = 0; i < sizeof CASOS / sizeof CASOS[0]; i++) {
registro[0] = '\0';
porton_forzar_estado(CASOS[i].desde); /* solo existe en la compilación de pruebas */
porton_evento(CASOS[i].ev);
if (porton_estado() != CASOS[i].hacia || strcmp(registro, CASOS[i].acciones) != 0) {
printf("FALLA caso %u: esperado %d '%s', obtenido %d '%s'\n", i, CASOS[i].hacia, CASOS[i].acciones,
porton_estado(), registro);
fallas++;
}
}
return fallas != 0;
}
Dos ventajas de este diseño de pruebas. Primero, forzar el estado hace que cada caso sea independiente: no hay que reproducir la secuencia de eventos que lleva hasta CERRANDO para probar una celda de CERRANDO, de modo que un fallo señala una celda y no «algo en la secuencia». Segundo, la tabla de casos es exactamente la tabla del modelo: 25 filas, una por celda, incluyendo las ignoradas. Se pueden complementar con unas pocas pruebas de escenario (secuencias largas como «abrir, obstáculo, reabrir») para verificar que las celdas funcionan juntas, pero las celdas son la base.
¿Por qué tanta insistencia en recorrer todas las celdas? Porque un conjunto de pruebas que «pasa» no dice nada si no ejerció el código defectuoso. Una forma de medir la calidad de una prueba es la inyección de defectos (mutation testing): se modifica a propósito una celda de la tabla —se la borra, se cambia su destino, se la llena donde estaba vacía— y se comprueba si el conjunto de pruebas lo detecta. Si el defecto pasa sin ser notado, la prueba es insuficiente, por buena que parezca. El programa de Python de este tema lo hace con 14 defectos y cuatro conjuntos de pruebas, y el laboratorio lo deja explorar.
Otras defensas que complementan las pruebas, y que cuestan pocas líneas:
- Validar el estado. Un valor fuera del rango de la enumeración (por una corrupción de memoria) lleva a un estado seguro y a un registro del fallo, como hace el despachador del estilo tabla.
- Registrar las transiciones en un buffer circular de depuración (estado, evento, estado nuevo). Con los últimos 32 pasos a la vista, un «no sé cómo llegó acá» se resuelve leyendo.
- Aserciones sobre invariantes: «nunca están activos
motor_abrirymotor_cerrara la vez» se verifica en cada paso de las pruebas. - Revisar el diseño contra el código: en la revisión, abrir el diagrama y la tabla de casos, y recorrer celda por celda.
08 · Resumen de oficio
Buenas prácticas y cómo elegir
- Un solo lugar cambia el estado. Todas las transiciones pasan por una función (
ir_ao el final del despachador), donde se ejecutan las acciones de salida y de entrada y se registra la transición. - El estado es privado. Una variable
staticcon un getter. Nadie fuera de la máquina asigna un estado. - Enumeraciones con centinela (
E_CANT) para dimensionar y validar; nada de números mágicos. - Que el compilador ayude:
-Wall -Wextra,-Wswitch-enumyswitchsobre enumeraciones sindefault, de modo que un estado nuevo sin tratar se señale. - Acciones breves y sin bloqueo; lo largo es un estado. Las acciones no procesan eventos: los encolan.
- Eventos de a uno, hasta completar, desde una cola con contador de pérdidas.
- Datos de contexto en una estructura, no en globales:
typedef struct { Estado estado; uint8_t fallos; } Porton;yporton_evento(Porton *p, Evento ev). Permite varias instancias (dos portones) y simplifica las pruebas. - La tabla (o el diagrama) vive junto al código, en un comentario o un archivo, y se actualiza con él.
- Una prueba por celda, con el estado forzado y un HAL falso, ejecutada en la PC en cada cambio.
Y para elegir el estilo, una guía simple. Con hasta cinco o seis estados y transiciones sencillas, el switch alcanza y es lo más legible, siempre que se centralice el cambio de estado. Con más estados o muchas transiciones planas, la tabla: se revisa, se prueba y se genera. Con estados que tienen lógica propia (guardas, contadores, subestados), un manejador por estado, eventualmente apoyado en una tabla para las transiciones simples. En ningún caso conviene mezclar estilos distintos dentro de una misma máquina, y vale la pena no cambiar de estilo a mitad de un proyecto sin una razón de peso.
Las herramientas de modelado (como los editores UML con generación de código, o los generadores a partir de SCXML o de hojas de cálculo) automatizan la traducción del diagrama a una tabla en C. Para equipos con muchas máquinas son una gran ayuda; para una o dos, un script de veinte líneas hace lo mismo. Lo esencial es que exista una única fuente de verdad y que el código se derive de ella o se verifique contra ella.
09 · Analogía con programación
Tres estilos y defectos inyectados, en Python
El programa tiene tres partes. La primera escribe la misma máquina del portón en los tres estilos (if/elif, una tabla en un diccionario y un objeto por estado) y comprueba que son equivalentes: en las 25 celdas y en todas las secuencias de hasta 5 eventos. La segunda inyecta 14 defectos en la tabla (transiciones borradas, destinos equivocados, una acción errónea y transiciones de más en celdas que debían ignorar) y cuatro conjuntos de pruebas, y muestra cuáles detectan cuáles. La tercera mide cuántos eventos al azar hacen falta para recorrer las 25 celdas.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
from itertools import product
from collections import deque
ESTADOS = ["CERRADO", "ABRIENDO", "ABIERTO", "CERRANDO", "DETENIDO"]
EVENTOS = ["BOTON", "FC_ABIERTO", "FC_CERRADO", "OBSTACULO", "TIMEOUT"]
# ---- Tres maneras de escribir la MISMA máquina del portón --------------------------------------------------------
def paso_switch(estado, ev):
"""Estilo 1: if/elif anidados, como un switch/case de C."""
if estado == "CERRADO":
if ev == "BOTON": return "ABRIENDO", "abrir"
elif estado == "ABRIENDO":
if ev == "FC_ABIERTO": return "ABIERTO", "parar"
if ev == "BOTON": return "DETENIDO", "parar"
elif estado == "ABIERTO":
if ev in ("BOTON", "TIMEOUT"): return "CERRANDO", "cerrar"
elif estado == "CERRANDO":
if ev == "FC_CERRADO": return "CERRADO", "parar"
if ev == "BOTON": return "DETENIDO", "parar"
if ev == "OBSTACULO": return "ABRIENDO", "abrir"
elif estado == "DETENIDO":
if ev == "BOTON": return "CERRANDO", "cerrar"
return estado, None
TABLA = {("CERRADO", "BOTON"): ("ABRIENDO", "abrir"),
("ABRIENDO", "FC_ABIERTO"): ("ABIERTO", "parar"), ("ABRIENDO", "BOTON"): ("DETENIDO", "parar"),
("ABIERTO", "BOTON"): ("CERRANDO", "cerrar"), ("ABIERTO", "TIMEOUT"): ("CERRANDO", "cerrar"),
("CERRANDO", "FC_CERRADO"): ("CERRADO", "parar"), ("CERRANDO", "BOTON"): ("DETENIDO", "parar"),
("CERRANDO", "OBSTACULO"): ("ABRIENDO", "abrir"), ("DETENIDO", "BOTON"): ("CERRANDO", "cerrar")}
def paso_tabla(estado, ev, tabla=TABLA):
"""Estilo 2: una tabla de datos y un único despachador."""
return tabla.get((estado, ev), (estado, None))
class Cerrado:
def manejar(self, ev): return ("ABRIENDO", "abrir") if ev == "BOTON" else None
class Abriendo:
def manejar(self, ev): return {"FC_ABIERTO": ("ABIERTO", "parar"), "BOTON": ("DETENIDO", "parar")}.get(ev)
class Abierto:
def manejar(self, ev): return ("CERRANDO", "cerrar") if ev in ("BOTON", "TIMEOUT") else None
class Cerrando:
def manejar(self, ev): return {"FC_CERRADO": ("CERRADO", "parar"), "BOTON": ("DETENIDO", "parar"),
"OBSTACULO": ("ABRIENDO", "abrir")}.get(ev)
class Detenido:
def manejar(self, ev): return ("CERRANDO", "cerrar") if ev == "BOTON" else None
OBJETOS = {"CERRADO": Cerrado(), "ABRIENDO": Abriendo(), "ABIERTO": Abierto(), "CERRANDO": Cerrando(), "DETENIDO": Detenido()}
def paso_objetos(estado, ev):
"""Estilo 3: cada estado es un objeto (en C, una función) que sabe manejar sus eventos."""
r = OBJETOS[estado].manejar(ev)
return r if r else (estado, None)
def traza(paso, eventos, inicial="CERRADO"):
estado, salida = inicial, []
for ev in eventos:
estado, accion = paso(estado, ev)
salida.append((estado, accion))
return salida
print("Parte 1: tres estilos, una sola máquina")
iguales = all(paso_switch(s, e) == paso_tabla(s, e) == paso_objetos(s, e) for s, e in product(ESTADOS, EVENTOS))
print(f" los tres coinciden en las {len(ESTADOS) * len(EVENTOS)} celdas: {iguales}")
total, distintas = 0, 0
for largo in range(1, 6):
for seq in product(EVENTOS, repeat=largo):
total += 1
distintas += not (traza(paso_switch, seq) == traza(paso_tabla, seq) == traza(paso_objetos, seq))
print(f" secuencias de hasta 5 eventos probadas: {total}; con resultados distintos: {distintas}")
# ---- Parte 2: defectos inyectados (mutantes) y conjuntos de prueba --------------------------------------------------
def mutantes():
lista = [("sin " + f"{s}+{e}", {k: v for k, v in TABLA.items() if k != (s, e)}) for (s, e) in TABLA]
m = dict(TABLA); m[("CERRANDO", "OBSTACULO")] = ("DETENIDO", "parar"); lista.append(("CERRANDO+OBSTACULO va a DETENIDO", m))
m = dict(TABLA); m[("ABIERTO", "TIMEOUT")] = ("ABRIENDO", "abrir"); lista.append(("ABIERTO+TIMEOUT va a ABRIENDO", m))
m = dict(TABLA); m[("CERRANDO", "FC_CERRADO")] = ("CERRADO", "cerrar"); lista.append(("CERRANDO+FC_CERRADO con acción equivocada", m))
m = dict(TABLA); m[("CERRADO", "OBSTACULO")] = ("ABRIENDO", "abrir"); lista.append(("agrega CERRADO+OBSTACULO (antes ignorado)", m))
m = dict(TABLA); m[("DETENIDO", "TIMEOUT")] = ("CERRANDO", "cerrar"); lista.append(("agrega DETENIDO+TIMEOUT (antes ignorado)", m))
return lista
def azar(n, semilla=1):
salida = []
for _ in range(n):
semilla = (semilla * 1103515245 + 12345) & 0x7FFFFFFF
salida.append(EVENTOS[(semilla >> 16) % 5])
return salida
def cobertura_total():
"""Una secuencia que recorre las 25 celdas (caminos mínimos entre estados, como en el análisis del tema 24)."""
def camino(origen, destino):
previo, cola = {origen: None}, deque([origen])
while cola:
s = cola.popleft()
for ev in EVENTOS:
n = paso_tabla(s, ev)[0]
if n not in previo: previo[n] = (s, ev); cola.append(n)
ruta, s = [], destino
while previo[s] is not None: p, ev = previo[s]; ruta.append(ev); s = p
return ruta[::-1]
actual, seq = "CERRADO", []
for estado, evento in product(ESTADOS, EVENTOS):
for ev in camino(actual, estado): seq.append(ev); actual = paso_tabla(actual, ev)[0]
seq.append(evento); actual = paso_tabla(actual, evento)[0]
return seq
PRUEBAS = {
"A camino feliz (4)": [["BOTON", "FC_ABIERTO", "BOTON", "FC_CERRADO"]],
"B tres escenarios": [["BOTON", "FC_ABIERTO", "BOTON", "FC_CERRADO"],
["BOTON", "FC_ABIERTO", "BOTON", "OBSTACULO", "FC_ABIERTO"],
["BOTON", "BOTON", "BOTON", "FC_CERRADO"]],
"C 25 celdas": [cobertura_total()],
"D 50 eventos al azar": [azar(50)],
}
def celdas_cubiertas(secuencias):
visto = set()
for seq in secuencias:
estado = "CERRADO"
for ev in seq:
visto.add((estado, ev)); estado = paso_tabla(estado, ev)[0]
return len(visto)
def detecta(mutante, secuencias):
return any(traza(lambda s, e: paso_tabla(s, e, mutante), seq) != traza(paso_tabla, seq) for seq in secuencias)
print()
print("Parte 2: ¿qué conjunto de pruebas detecta cada defecto? (X = lo detecta, . = pasa sin notarlo)")
print(f" {'':44s}" + "".join(f"{n[:1]:>3s}" for n in PRUEBAS))
for nombre, tabla in mutantes():
print(f" {nombre:44s}" + "".join(f"{'X' if detecta(tabla, s) else '.':>3s}" for s in PRUEBAS.values()))
print(" celdas recorridas de 25: " + ", ".join(f"{n[:1]} = {celdas_cubiertas(s)}" for n, s in PRUEBAS.items()))
print(" eventos: " + ", ".join(f"{n[:1]} = {sum(len(x) for x in s)}" for n, s in PRUEBAS.items()))
# ---- Parte 3: ¿cuántos eventos al azar hacen falta para recorrer las 25 celdas? ------------------------------------
def eventos_hasta_cubrir(semilla):
visto, estado, n = set(), "CERRADO", 0
while len(visto) < 25:
semilla = (semilla * 1103515245 + 12345) & 0x7FFFFFFF
ev = EVENTOS[(semilla >> 16) % 5]
visto.add((estado, ev)); estado = paso_tabla(estado, ev)[0]; n += 1
return n
cuentas = sorted(eventos_hasta_cubrir(s) for s in range(1, 301))
print()
print("Parte 3: eventos al azar hasta recorrer las 25 celdas (300 pruebas distintas)")
print(f" mediana {cuentas[150]}, peor caso {cuentas[-1]}, mejor caso {cuentas[0]}; la secuencia dirigida necesita {len(cobertura_total())}")
Resultado, parte 1: los tres estilos coinciden en las 25 celdas y en las 3905 secuencias de hasta 5 eventos, sin una sola diferencia. Elegir entre ellos es una decisión de legibilidad, de memoria y de mantenimiento, no de comportamiento. Parte 2: el conjunto A (el camino feliz de 4 eventos) recorre solo 4 de las 25 celdas y deja pasar 9 de los 14 defectos; el B (los tres escenarios de la prueba manual, 13 eventos, 7 celdas) deja pasar 5, entre ellos la ausencia de CERRANDO + BOTON y la de ABIERTO + TIMEOUT, porque nunca recorre esas celdas; el C (una secuencia dirigida de 44 eventos que recorre las 25 celdas) detecta los 14, como debía; y el D (50 eventos al azar, 22 celdas) también detecta los 14, pero con suerte: otra semilla podría haber dejado más celdas sin tocar. Los dos defectos de «transición de más» (CERRADO + OBSTACULO y DETENIDO + TIMEOUT, celdas que debían ignorar) solo los encuentran los conjuntos que visitan esas celdas ignoradas: C y D. Parte 3: el azar necesita una mediana de 138 eventos para recorrer las 25 celdas, con un mejor caso de 47 y un peor caso de 444; la secuencia dirigida lo logra en 44.
Probá escribir tu propio conjunto de pruebas (por ejemplo, cinco escenarios de tu invención) y calculá cuántas celdas recorre y cuántos defectos detecta. Después agregá nuevos defectos y fijate cuáles se te escapan. Es un buen entrenamiento para saber cuándo una prueba que «pasa» no significa nada.
10 · Exploración interactiva
¿Qué prueba detecta qué defecto?
El laboratorio ejecuta la parte 2 del programa anterior. Elegí un defecto inyectado en la tabla del portón y un conjunto de pruebas; el laboratorio ejecuta cada secuencia contra la máquina correcta y contra la defectuosa, y te dice si la diferencia aparece, en qué evento y qué celdas recorrió la prueba. Una prueba que no recorre la celda del defecto no puede detectarlo.
Defectos en la tabla y pruebas que los buscan
—
Elegí un defecto y un conjunto de pruebas para ver si se detecta.
Recorrido guiado
- Con «Ninguno (código correcto)» y cualquier prueba: la prueba pasa, claro. Pero fijate en la cobertura: A recorre 4 de 25 celdas, B 7, C 25 y D 22.
- Elegí «Falta la transición CERRANDO + BOTON» con la prueba A y después con la B: el defecto pasa sin ser notado en las dos. Ninguna recorre esa celda. Un portón que no se puede detener mientras baja sigue «pasando todas las pruebas». Con la C, que visita las 25 celdas, se detecta.
- Probá «Falta la transición ABRIENDO + BOTON» con A y con B. A no la ve; B sí (en el evento 2 de la tercera secuencia: el escenario del botón a mitad de camino). Una prueba mejor encuentra más, pero solo lo que recorre.
- Probá «CERRANDO + OBSTACULO va a DETENIDO (destino equivocado)»: B lo detecta en el evento 4 de la segunda secuencia, el escenario del obstáculo. No es que falte la celda: está mal, y solo se nota al observar el estado resultante y las acciones.
- Elegí «Sobra CERRADO + OBSTACULO (antes ignorado)»: A y B no lo notan; C y D, sí. Los defectos de «evento de más» solo se encuentran recorriendo las celdas que deben ignorar. Es la razón por la que la tabla de casos tiene 25 filas y no 9.
- Con la prueba D repasá varios defectos: el azar de 50 eventos recorre 22 celdas y los detecta a todos, pero con esa semilla. La parte 3 del programa muestra que cubrir las 25 al azar necesita una mediana de 138 eventos (hasta más de 400). Un conjunto dirigido de 44 eventos lo garantiza.
Desafío: ¿una prueba que recorre las 25 celdas detecta cualquier defecto?
De una sola celda, sí: si la celda defectuosa se recorre y el resultado (estado y acciones) se compara con el esperado, la diferencia aparece. Pero no cualquier defecto es de una celda. Un error en una acción de entrada (la luz no se enciende al salir de CERRADO) solo se ve si la prueba observa también las salidas que no son de la transición; un defecto que depende de datos de contexto (el contador de intentos) o de tiempos (el autocierre a los 10 s y no a los 5) exige pruebas específicas; y un error en la generación de eventos (un rebote que produce dos BOTON) no es de la máquina sino de la capa que la alimenta. La prueba por celda garantiza la tabla; el resto se cubre con otras capas de prueba.
11 · Método de trabajo
Método para implementar
- Partí del modelo del tema 24: diagrama, tabla con todas las celdas decididas, acciones de entrada y de salida, eventos con su detección. Sin eso no hay nada que implementar.
- Declará las enumeraciones (estados, eventos) con centinela, y la interfaz de hardware que usan las acciones.
- Elegí el estilo según el apartado 8 y escribilo en un módulo propio (
porton.cyporton.h), sin ninguna inclusión de registros del fabricante. - Centralizá el cambio de estado con las acciones de salida, transición y entrada en el orden convenido, y decidí si las transiciones propias son internas o externas.
- Armá el HAL falso y la tabla de casos, una fila por celda, y hacé que todo pase en la PC antes de tocar el equipo.
- Conectá la cola de eventos: productores (entradas, temporizadores, mensajes) y un bucle (o una tarea) que procesa de a uno.
- Agregá los tiempos máximos en todo estado que espera algo, y los temporizadores como acciones de entrada y de salida.
- Agregá las defensas: validación del estado, registro de transiciones, contador de eventos perdidos, aserciones sobre las salidas prohibidas.
- Medí la calidad de las pruebas inyectando defectos: si algún defecto pasa, falta una prueba.
- Probá en el equipo con los escenarios reales y los casos malos, y compará lo que registra la máquina con lo esperado.
12 · Comprensión
Confusiones frecuentes
«La tabla de punteros a función es siempre mejor que el switch»
Es mejor para máquinas medianas y grandes, y para quien quiere ver y generar las celdas. Para una máquina de cuatro estados, un switch bien escrito es más corto y más claro. El criterio es la legibilidad y el mantenimiento, no la sofisticación.
«Con un default: en el switch estoy a salvo de estados no tratados»
El default apaga la advertencia del compilador que justamente avisa de los estados nuevos sin tratar. Conviene tratar explícitamente todos los valores de la enumeración y validar el rango por separado.
«Las acciones de entrada son un lujo; las pongo en cada transición»
Funciona hasta que se agrega una transición nueva hacia ese estado y se olvida la acción. Una acción de entrada se escribe una vez y se cumple por todos los caminos, presentes y futuros.
«Si el estado está mal, la máquina se arregla sola con el próximo evento»
Un estado corrupto que no está en la tabla produce un acceso fuera de rango o una celda aleatoria. Hay que validarlo y llevar al equipo a un estado seguro, con registro del fallo.
«Probar los escenarios principales alcanza»
Los escenarios principales ejercitan las celdas principales. Los defectos viven en las demás: las poco frecuentes, las que se ignoran, las que combinan eventos extraños. Una prueba por celda, más unos pocos escenarios, es lo mínimo razonable.
«Probar con datos al azar es lo mismo que probar con una secuencia dirigida»
El azar necesita muchos más eventos para recorrer todas las celdas (una mediana de 138 contra 44 en el portón) y no ofrece garantías. Es un buen complemento para encontrar sorpresas, no un reemplazo.
«Procesar un evento dentro de una acción es más rápido»
Y más peligroso: se ejecuta la máquina dentro de una transición en curso, con el estado a medio actualizar. Un evento nuevo se encola y se procesa cuando la transición actual terminó.
13 · Práctica guiada
Predecí antes de comprobar
1. La trampa del cero
En el estilo tabla, el campo valida parece redundante. ¿Qué pasaría si se lo quitara y se usara accion != NULL para saber si hay transición?
Ver solución
Habría que prohibir transiciones sin acción, que son perfectamente legítimas (cambiar de estado sin hacer nada). Usando siguiente como marca no sirve, porque la celda sin inicializar vale 0, que es E_CERRADO, un estado real: cada celda vacía «transitaría» a CERRADO. El campo explícito valida (o un valor centinela distinto de cero para «ninguno») evita ambas ambigüedades. Es un caso en que una trampa del lenguaje (cero por omisión) exige una decisión de diseño.
2. El orden de las acciones
El temporizador vence en ABIERTO y la máquina pasa a CERRANDO. Con el orden salida–acción–entrada, ¿qué se ejecuta y en qué orden, contando las acciones de entrada y de salida del apartado 5?
Ver solución
1) Salida de ABIERTO: temporizador_cancelar() (ya vencido, inofensivo). 2) Acción de la transición: motor_cerrar(). 3) Entrada a CERRANDO: no tiene acción de entrada definida. Si el orden fuera entrada–acción–salida, la entrada del estado nuevo se ejecutaría antes de que el viejo termine de limpiarse, y recursos compartidos entre los dos estados (un temporizador, una salida) se pisarían.
3. Memoria de la tabla
Una máquina tiene 14 estados y 11 eventos, con unas 40 transiciones definidas. ¿Cuántos bytes ocupa una tabla densa con 12 bytes por celda, y cuántos una dispersa con 8 bytes por transición definida? ¿Cuál elegirías?
Ver solución
Densa: 14 × 11 × 12 = 1848 bytes. Dispersa: 40 × 8 = 320 bytes, pero con una búsqueda lineal por evento (40 comparaciones como máximo) y sin la vista de «celdas vacías». En un chip de 64 KB de memoria de programa, 1,8 KB es el 3 %: la densa es mejor si se aprecia la claridad y la velocidad constante. En uno de 8 KB, la dispersa. La decisión depende de cuánto cuesta la memoria en ese producto, y se toma con la cuenta a la vista.
4. Eventos viejos
Un temporizador de 10 s se arma al entrar a ABIERTO. El usuario aprieta el botón a los 9,999 s, y el temporizador vence apenas después de que la máquina pasó a CERRANDO. ¿Qué ocurre si CERRANDO no ignorara TIMEOUT? ¿Cómo se evitaría el problema en general?
Ver solución
Un TIMEOUT ya encolado (o que llega antes de que se cancele el temporizador) se procesaría en CERRANDO con el significado equivocado: según lo que la tabla dijera, podría disparar una acción fuera de lugar. En el portón es inofensivo porque la celda (CERRANDO, TIMEOUT) está vacía por decisión. En general se evita (a) ignorando conscientemente el evento en los estados donde no tiene sentido, o (b) numerando cada armado del temporizador, de modo que un TIMEOUT con número de una generación anterior se descarte al llegar.
5. Elegir estilo
Una cerradura con 4 estados y 3 eventos, otra máquina de un cargador con 12 estados y 9 eventos y muchas transiciones planas, y un estado de un protocolo con una lógica propia de 40 líneas de guardas y contadores. ¿Qué estilo elegirías para cada una?
Ver solución
La cerradura (4 × 3): un switch; una tabla de 12 celdas es posible pero no gana nada. El cargador (12 × 9 = 108 celdas, muchas transiciones planas): una tabla, que permite revisar las celdas y generarla. El estado con lógica pesada: un manejador por estado, que puede apoyarse en funciones auxiliares con guardas, quizá con una tabla para las transiciones simples del resto de la máquina. En ningún caso se mezclan estilos dentro de una misma máquina sin una razón clara.
6. Un caso de prueba que falla
Una prueba por celda reporta fallas en (ABRIENDO, FC_ABIERTO): esperado ABIERTO con acciones "PT", obtenido ABIERTO con "P". ¿Qué defecto sugiere?
Ver solución
El estado final es correcto pero falta la acción de entrada a ABIERTO (temporizador_armar, la T del registro): la máquina nunca arma el autocierre. Probablemente la tabla de definiciones DEF[E_ABIERTO] no tiene la entrada, o la función de cambio de estado no la llama. Una prueba que solo mirara el estado resultante lo habría dejado pasar; por eso el caso también registra las acciones.
14 · Síntesis
Ideas para recordar
- Una buena implementación se parece al modelo, hace visible lo que falta, no bloquea, se separa del hardware y se puede probar celda por celda.
- Tres estilos equivalentes en comportamiento: el switch anidado (simple, pero oculta las celdas vacías y repite acciones), la tabla de transiciones (es el modelo, el despachador no cambia, se valida y se genera, a un costo de memoria por celda) y el manejador por estado (cómodo para estados con lógica propia, pero se pierde la vista de conjunto).
- Todo cambio de estado pasa por un solo lugar, que ejecuta en orden la acción de salida, la acción de la transición y la de entrada; hay que decidir si una transición al mismo estado es interna o externa, y ejecutar la entrada del estado inicial al arrancar.
- Los eventos llegan por una cola con contador de pérdidas y se procesan de a uno, hasta completar; las acciones no procesan eventos, los encolan. Los temporizadores se arman y se cancelan en las acciones de entrada y de salida, y los eventos viejos se descartan o se ignoran a propósito.
- Se prueba en la PC con un HAL falso: una prueba por celda, con el estado forzado, comparando estado y acciones resultantes. Son las 25 filas del modelo, incluidas las celdas ignoradas.
- Un conjunto de pruebas que pasa no demuestra nada si no recorre las celdas defectuosas: la inyección de defectos lo mide. Una secuencia dirigida recorre las 25 celdas con 44 eventos; el azar necesita una mediana de 138.
- Defensas baratas: validar el estado, registrar transiciones, contar eventos perdidos y verificar invariantes de las salidas.
- El estilo se elige por tamaño y por la lógica de cada estado, no por moda; una sola fuente de verdad (la tabla) de la que se deriva o contra la que se verifica el código.
¿Listo para el tema 26?
Antes de avanzar, implementá en C la cerradura de código del tema 24 en dos estilos (switch y tabla), escribí una tabla de casos con una fila por celda, compilala en la PC y comprobá que ambas implementaciones pasan las mismas pruebas. Después inyectá un defecto y verificá que tus pruebas lo detecten. Si lo encuentran, vas bien.
En el próximo tema vemos la programación orientada a eventos: colas de eventos como columna vertebral del firmware, máquinas jerárquicas que comparten comportamiento entre estados y la separación entre quien produce un evento y quien lo atiende.