01 · El punto de partida
La filosofía: asumir el fallo
El código que se escribe pensando «esto no puede pasar» es el código que falla en el campo. Los sistemas embebidos viven en condiciones que no se parecen a las del escritorio: tensiones que bajan, interferencias, temperaturas extremas, sensores que se desconectan, memorias que se corrompen, usuarios que hacen lo imprevisto. Y viven mucho tiempo, sin nadie que los vigile. En esas condiciones, la pregunta de diseño cambia: ya no es «¿qué hace el programa cuando todo sale bien?» sino «¿qué hace cuando algo sale mal, y cómo lo nota?».
La programación defensiva organiza la respuesta en cuatro etapas, que conviene mantener separadas porque cada una tiene sus técnicas:
| Etapa | Pregunta | Técnicas |
|---|---|---|
| 1. Prevenir | ¿Cómo evito que el fallo ocurra? | Reglas de codificación, tipos estrictos, bucles acotados, inicialización completa, compilación con todas las advertencias (tema 31) |
| 2. Detectar | ¿Cómo me entero lo antes posible, y cerca de la causa? | Validación de entradas, aserciones, CRC, comprobaciones de plausibilidad, timeouts, watchdog (tema 38) |
| 3. Contener | ¿Cómo evito que se propague? | No usar el dato dudoso, aislar el módulo, estados seguros de las salidas, redundancia |
| 4. Responder y recuperar | ¿Qué hago ahora? | Reintentar, degradar, pasar a modo seguro, restaurar, reiniciar, y registrar todo (tema 37) |
La respuesta se elige según la gravedad del fallo y lo que está en juego. Hay una escalera de reacciones, de la menos a la más drástica, y la regla es usar la menos drástica que resuelva el problema:
Y una decisión que hay que tomar de antemano para cada salida del producto: qué significa «seguro» cuando algo falla. Hay tres filosofías, y la elección depende del producto:
| Filosofía | Qué hace ante un fallo | Ejemplo |
|---|---|---|
| Falla segura (fail-safe) | Pasa a un estado que no causa daño, aunque deje de prestar el servicio | Un calefactor que se apaga; una cerradura de oficina que queda trabada; una válvula de gas que se cierra |
| Falla operativa (fail-operational) | Sigue funcionando con lo que le queda, a veces con redundancia | Un control de vuelo con tres computadoras; una cerradura de emergencia que queda destrabada para que la gente pueda salir |
| Falla silenciosa (fail-silent) | Se apaga y deja de emitir, para no contaminar a quien depende de él con datos falsos | Un nodo de un bus que se desconecta cuando detecta que sus datos no son confiables |
02 · No confiar en lo que viene de afuera
Validar todo lo que entra
Todo dato que cruza una frontera de confianza (viene de otro chip, de un sensor, de un cable, de la memoria no volátil, del usuario, de la red) debe tratarse como potencialmente incorrecto hasta que se demuestre lo contrario. Dentro del programa, entre funciones propias bien probadas, se puede confiar; en la frontera, no. Hay seis cosas que se verifican en un dato recibido:
| Verificación | Pregunta | Ejemplo |
|---|---|---|
| Longitud | ¿Tiene el tamaño esperado, y cabe donde va a ir? | El largo declarado de un mensaje coincide con los bytes recibidos y no supera el buffer (tema 31) |
| Rango | ¿Está dentro de los valores posibles? | Una temperatura entre −40 y 125 °C; un porcentaje entre 0 y 100 |
| Formato | ¿Tiene la estructura esperada? | Un comando que pertenece al conjunto cerrado de comandos definidos |
| Integridad | ¿Llegó sin alteraciones? | El CRC del mensaje o del bloque de configuración coincide |
| Coherencia con el estado | ¿Tiene sentido recibirlo ahora? | Un comando de «abrir» mientras el equipo está en modo seguro se rechaza (tema 27) |
| Frescura | ¿Es reciente? | Un dato de sensor con más de un segundo de antigüedad no se usa para controlar |
typedef enum { MSG_OK, MSG_CORTO, MSG_LARGO_INVALIDO, MSG_CRC, MSG_COMANDO_DESCONOCIDO, MSG_ESTADO } ResultadoMsg;
ResultadoMsg mensaje_procesar(const uint8_t *m, size_t n) {
if (n < 4) return MSG_CORTO; /* comando, largo y CRC de 2 bytes como mínimo */
uint8_t cmd = m[0], largo = m[1];
if (largo > MAX_PAYLOAD || (size_t)largo + 4 != n) return MSG_LARGO_INVALIDO; /* el largo declarado debe coincidir con lo recibido */
uint16_t crc_rx = (uint16_t)((m[n - 2] << 8) | m[n - 1]);
if (crc16(m, n - 2) != crc_rx) return MSG_CRC; /* integridad */
if (cmd >= CMD_CANT) return MSG_COMANDO_DESCONOCIDO; /* el conjunto de comandos es cerrado */
if (!comando_permitido_en(estado_actual(), cmd)) return MSG_ESTADO; /* ¿tiene sentido ahora? */
despachar_comando(cmd, &m[2], largo); /* recién aquí se usa el contenido */
return MSG_OK;
}
/* Cada rechazo se cuenta por tipo (contar_error(ERR_MSG_CRC)...): los contadores son la ventana al estado de salud del enlace. */
Desconfiar de los sensores: la plausibilidad
Un sensor entrega un número, y un número «válido» puede ser una mentira: un cable desconectado da una lectura constante o un valor de error (un sensor de temperatura DS18B20, por ejemplo, devuelve −127 °C cuando falla), un cortocircuito da el fondo de escala, una interferencia da un pico, un sensor trabado repite siempre el mismo valor. Se detectan con comprobaciones de plausibilidad que usan lo que se sabe de la física del fenómeno:
| Comprobación | Detecta | Cómo se configura |
|---|---|---|
| Rango físico | Un sensor desconectado, en cortocircuito o con una lectura de error | Los límites del fenómeno y del sensor (−40 a 125 °C) |
| Velocidad máxima de cambio | Picos de ruido, lecturas absurdas | Lo que puede cambiar la magnitud entre dos muestras (una temperatura no cambia 5 °C en un segundo) |
| Valor congelado | Un sensor trabado, o un dato que ya no se actualiza | El mismo valor exacto durante N lecturas seguidas, con N mayor que lo que un sensor sano repetiría |
| Comparación cruzada | Un sensor que se desvió sin salir del rango | Dos sensores de la misma magnitud (o un modelo físico) que deben coincidir dentro de una tolerancia |
| Coherencia con otras magnitudes | Una lectura que contradice el contexto | El calefactor lleva diez minutos encendido y la temperatura no sube |
typedef enum { S_OK, S_FUERA_RANGO, S_SALTO, S_CONGELADO } EstadoSensor;
#define RANGO_MIN (-400) /* décimas de °C: -40,0 */
#define RANGO_MAX (1250) /* 125,0 */
#define SALTO_MAX (50) /* 5,0 °C entre lecturas */
#define CONGELADO_N 30
static int16_t ultimo_aceptado; static bool hay_ultimo;
static int16_t previa; static uint8_t repetidas;
EstadoSensor sensor_validar(int16_t dc) {
repetidas = (dc == previa) ? (uint8_t)(repetidas + 1) : 0; previa = dc;
if (dc < RANGO_MIN || dc > RANGO_MAX) return S_FUERA_RANGO;
if (repetidas >= CONGELADO_N - 1) return S_CONGELADO; /* 30 lecturas idénticas seguidas */
if (hay_ultimo && abs(dc - ultimo_aceptado) > SALTO_MAX) return S_SALTO; /* contra la última ACEPTADA, no contra la anterior */
ultimo_aceptado = dc; hay_ultimo = true;
return S_OK;
}
La Parte 1 del programa de Python prueba dos versiones de este validador sobre 600 lecturas con fallas inyectadas (un pico, un sensor congelado, uno desconectado y ruido fuerte) y un cambio real de +8 °C a partir de la lectura 520 (alguien abrió una puerta):
| Situación | A: compara con la lectura anterior | B: compara con la última aceptada, con rebase |
|---|---|---|
| Pico aislado (1 lectura) | 1 de 1 rechazada | 1 de 1 rechazada |
| Sensor congelado (61 lecturas) | 35 rechazadas (desde la lectura 226) | 35 rechazadas (desde la lectura 226) |
| Sensor desconectado (21 lecturas) | 21 de 21 rechazadas | 21 de 21 rechazadas |
| Ruido fuerte (10 lecturas) | 7 de 10 rechazadas | 10 de 10 rechazadas |
| Lecturas buenas rechazadas por error | 4: la vuelta a la normalidad tras cada falla (lecturas 101, 321, 460) y el primer valor del cambio real (520) | 4: todas las del cambio real, hasta que se acepta el nivel nuevo (lecturas 520 a 523) |
La comparación revela un compromiso, no una solución perfecta. El validador A rechaza la lectura buena que sigue a una mala (compara con el valor erróneo anterior), y se pierde la mitad del ruido fuerte. El B compara con la última lectura aceptada: detecta todo el ruido, no castiga el regreso a la normalidad, y acepta un cambio real cuando se repite cinco veces (la regla de rebase), a costa de rechazar las primeras lecturas del nuevo nivel. Sin esa regla, un cambio verdadero de más de 5 °C haría que el validador rechazara para siempre al sensor sano. Un detalle más: el congelado se detecta recién tras 30 lecturas idénticas (la lectura 226 en la simulación): una detección más rápida produciría falsas alarmas con sensores de baja resolución.
03 · Declarar lo que debe ser verdad
Aserciones
Una aserción (assertion) es una afirmación que el programador hace en el código sobre algo que debe ser verdadero: «este puntero no es nulo», «este índice está dentro del arreglo», «el estado es uno de los definidos». Si la afirmación resulta falsa, el programa tiene un error de programación: algo que nunca debería haber ocurrido ocurrió, y la ejecución no es confiable. Sirven para atrapar el error en el lugar en que se produce, en lugar de varios pasos más adelante donde ya no se entiende su origen.
Es fundamental distinguir una aserción de una validación:
| Aspecto | Validación | Aserción |
|---|---|---|
| Para qué | Condiciones que pueden ocurrir en un sistema correcto (datos externos malos, fallas de hardware) | Condiciones que no pueden ocurrir si el programa es correcto (errores del programador) |
| Qué hace si falla | Se maneja: se rechaza el dato, se reintenta, se degrada | El programa tiene un defecto: se registra y se lleva el sistema a un estado seguro |
| Ejemplo | Un byte recibido por la UART fuera del rango válido | Una función interna recibe un puntero nulo que su único llamador nunca debería pasarle |
| Se quita en producción | No: es parte del comportamiento | Depende de la política (apartado siguiente) |
Confundirlas es un error frecuente y costoso: poner una aserción sobre un dato externo (el equipo se reinicia cuando alguien le manda un mensaje malformado) convierte cualquier entrada incorrecta en una denegación de servicio, y manejar con una validación elegante un error de programación lo oculta, y el defecto sigue ahí.
Qué hacer cuando una aserción falla en producción
En un programa de escritorio, una aserción que falla detiene el programa con un mensaje. En un firmware no hay quien lea el mensaje, y la política de producción es una decisión de diseño. Tres opciones, de menor a mayor protección:
- Quitar las aserciones en producción (el comportamiento de
assertconNDEBUG): no cuestan nada, pero el error sigue ahí sin señal, y el programa continúa con un estado que ya se sabe incorrecto. - Dejarlas y detener el sistema en un bucle infinito: el watchdog (tema 38) lo rescata, pero sin evidencia de qué falló.
- Dejarlas, registrar y reaccionar de forma controlada: guardar dónde falló en la caja negra (tema 37), dejar las salidas en estado seguro y reiniciar. Es lo más útil: cada aserción que falla en el campo es un informe de error con ubicación exacta.
/* assert.h propio: en desarrollo, detiene y muestra; en producción, registra y lleva al sistema a un estado seguro */
#define MODULO_ID 7 /* cada archivo define su identificador: no se guarda el nombre (ocupa flash) */
#ifdef DEPURACION
#define ASSERT(c) do { if (!(c)) { printf("ASSERT %s:%d\n", __FILE__, __LINE__); for (;;) {} } } while (0)
#else
#define ASSERT(c) do { if (!(c)) assert_fallo(MODULO_ID, __LINE__); } while (0)
#endif
void __attribute__((noreturn)) assert_fallo(uint8_t modulo, uint16_t linea) {
caja.assert_modulo = modulo; /* dónde: módulo y línea, para ubicarlo con el código fuente (tema 37) */
caja.assert_linea = linea;
caja.pc = (uint32_t)__builtin_return_address(0);
sistema_reiniciar(MOTIVO_ASSERT); /* salidas seguras, motivo anotado y reinicio (tema 37) */
}
/* Uso: precondiciones al entrar, invariantes y postcondiciones al salir */
void cola_poner(Cola *c, uint8_t dato) {
ASSERT(c != NULL);
ASSERT(c->cabeza - c->cola <= TAM_COLA); /* invariante: nunca hay más elementos que lugar */
/* ... */
}
Cuatro reglas de uso: sin efectos secundarios dentro de la condición (ASSERT(i++ < n) cambia el programa según se compile con aserciones o sin ellas); solo para errores de programación, nunca para condiciones externas; densidad razonable (la guía «Power of 10» de la NASA/JPL pide un promedio de al menos dos aserciones por función en código crítico); y comprobaciones de compilación con _Static_assert para todo lo que se pueda verificar sin ejecutar (tamaños de estructuras, valores de configuración consistentes), que no cuestan nada en ejecución.
04 · Qué hacer cuando algo no sale
Manejar errores sin bloquearse
Una función que puede fallar tiene que decirlo, y quien la llama tiene que enterarse. En C, la forma habitual es un código de retorno (un enumerado que distingue éxito y las clases de error). El peor defecto es ignorarlo: el programa sigue como si la operación hubiera salido bien, y el error reaparece lejos de su causa. Hay dos defensas, una del compilador y otra de disciplina:
- Que el compilador avise si se ignora el resultado: con GCC,
__attribute__((warn_unused_result))en las funciones que pueden fallar, junto con-Werror, convierte un resultado ignorado en un error de compilación. - Que ningún error se descarte en silencio: o se maneja, o se registra y se cuenta (
contar_error(ERR_X)). Un contador que sube es una señal de salud; un error ignorado no deja rastro.
La segunda regla del manejo de errores en firmware: ninguna espera es infinita. Todo lo que depende de un agente externo (un periférico, otro chip, un usuario, un mensaje) puede no llegar nunca, y un while (!listo) ; convierte esa ausencia en un cuelgue. Toda espera tiene un tiempo máximo, y su vencimiento es un error que se maneja:
/* Una espera con límite: nunca bloquea más de timeout_ms */
bool esperar_con_timeout(bool (*condicion)(void), uint32_t timeout_ms) {
uint32_t t0 = ahora_ms();
while (!condicion()) {
if ((int32_t)(ahora_ms() - t0) >= (int32_t)timeout_ms) return false; /* diferencia con signo: segura ante el desborde (tema 37) */
energia_esperar_corto(); /* no se espera con la CPU a pleno (tema 35) */
}
return true;
}
/* Un acceso a un sensor con reintentos acotados y espera creciente */
__attribute__((warn_unused_result))
Resultado sensor_leer(int16_t *valor) {
for (uint8_t intento = 0; intento < 3; intento++) {
if (i2c_leer(DIR_SENSOR, REG_TEMP, valor) == I2C_OK) return R_OK;
i2c_recuperar_bus(); /* devolver el bus a un estado conocido (apartado 7) */
esperar_ms_dormido((uint32_t)10 << intento); /* espera creciente: 10, 20 y 40 ms (backoff) */
}
contar_error(ERR_SENSOR_NO_RESPONDE); /* queda constancia: es una señal de salud del sistema */
return R_ERR_SENSOR; /* el llamador decide qué hacer: usar el último valor, degradar... */
}
Los reintentos son una herramienta con dos filos: ayudan con los fallos transitorios (una interferencia, un bus ocupado) pero, sin límite, se convierten en un bucle sin salida, y sin espera creciente (backoff) pueden agravar el problema (martillar un periférico colgado). Reglas: un número máximo, una espera que crece, y un peor caso calculable: con tres intentos de 5 ms de espera cada uno más las esperas de 10, 20 y 40 ms, la función no tarda más de 3 × 5 + 70 = 85 ms, y ese número tiene que ser menor que el timeout del watchdog (tema 38) para que el sistema no se reinicie por un simple fallo del sensor.
05 · Prevenir por diseño
Código robusto por construcción
Muchos fallos se evitan con decisiones de estilo que cierran de antemano categorías enteras de errores. Las reglas de codificación para sistemas críticos (MISRA C, la guía «Power of 10» de la NASA/JPL) son, en gran parte, una colección de estas decisiones. Las que más rinden en firmware:
| Regla | Error que previene |
|---|---|
| Sin recursión | Desborde de pila dependiente de los datos (tema 29): la pila máxima se puede calcular |
| Todos los bucles tienen una cota máxima demostrable | Un bucle que no termina, por una condición que nunca se cumple (los bucles de eventos infinitos son la excepción declarada) |
| Sin memoria dinámica después del arranque | Fragmentación, fugas, fallos de malloc (tema 30) |
| Inicializar todo al declararlo | Variables con basura (tema 31) |
| Comprobar el resultado de toda función que puede fallar | Errores ignorados que reaparecen lejos de su causa |
| Funciones cortas, con una sola responsabilidad | Lógica que no se puede revisar ni probar a fondo |
| Alcance mínimo de las variables; pocos globales | Dependencias ocultas, condiciones de carrera (tema 22) |
| Compilar con todas las advertencias como errores | Una larga lista de errores que el compilador sabe detectar (tema 31) |
Un default en cada switch sobre un enumerado | Un estado o evento inválido (por corrupción) que ningún caso trata |
La última merece un ejemplo, porque es el punto de contacto con las máquinas de estados (temas 24 a 27). Una variable de estado es un número en la RAM, y si se corrompe (un desborde, una interferencia), puede tomar un valor que no corresponde a ningún estado. Un switch sin default lo ignoraría y la máquina quedaría «viva pero perdida». Con un default que lo trata como una falla, el defecto se detecta en el acto. Una variante más protectora, para estados críticos, guarda el valor junto con su complemento, de modo que una corrupción de un solo lado se note:
void maquina_paso(Evento e) {
switch (estado_leer_o_falla()) {
case E_REPOSO: /* ... */ break;
case E_ABIERTA: /* ... */ break;
case E_BLOQUEADA: /* ... */ break;
default: /* un valor que no es ningún estado: la memoria se corrompió */
falla(FALLA_ESTADO_INVALIDO); /* se registra y se pasa a un estado seguro */
break;
}
}
/* Un estado crítico guardado con su complemento a uno: si un lado se corrompe, ya no coinciden */
typedef struct { uint8_t valor; uint8_t inv; } EstadoRedundante;
static inline void estado_poner(EstadoRedundante *e, uint8_t v) { e->valor = v; e->inv = (uint8_t)~v; }
static inline bool estado_leer(const EstadoRedundante *e, uint8_t *v) {
if ((uint8_t)(e->valor ^ e->inv) != 0xFF) return false; /* dato corrupto: se detecta en lugar de usarlo */
*v = e->valor; return true;
}
06 · Seguir funcionando con menos
Modo seguro y degradación
Cuando un fallo no se puede resolver, el equipo no tiene que elegir entre «funciona perfecto» y «reinicia»: hay un terreno intermedio, la degradación elegante (graceful degradation): seguir prestando el servicio esencial con menos prestaciones, de manera controlada, y avisando. La clave es definir, en el diseño, qué es esencial y qué es prescindible. Un diseño típico tiene tres modos:
| Modo | Cuándo | Qué hace |
|---|---|---|
| Normal | Todo funciona dentro de lo esperado | Todas las funciones, con todas las prestaciones |
| Degradado | Hay un fallo no crítico, o uno crítico con una alternativa aceptable | Mantiene la función esencial con menos prestaciones: usa el último valor válido, un sensor secundario o un modelo, limita la potencia, reduce la frecuencia de actividad |
| Seguro | El fallo impide operar con garantías, o las fallas se repiten | Las salidas en su estado seguro, solo las funciones mínimas, una señal visible o enviada de que algo anda mal, registro detallado, y reintentos de recuperación espaciados |
Cada fallo del producto se asigna a una respuesta, que se decide al diseñar y no cuando ocurre. Una tabla de ese tipo, para un equipo de calefacción con un sensor de temperatura:
| Fallo | Cómo se detecta | Respuesta |
|---|---|---|
| Lectura aislada fuera de rango | Plausibilidad (apartado 2) | Se descarta y se cuenta; se sigue con la última válida |
| Varias lecturas inválidas seguidas | 3 o más inválidas en las últimas 10 | Degradado: calefactor al 30 % como máximo, con el último valor válido; aviso |
| Sensor desconectado o congelado de forma persistente | 8 o más inválidas en las últimas 10 | Seguro: calefactor apagado, alarma visible; reintentos periódicos |
| Configuración corrupta | CRC incorrecto en ambas copias | Valores de fábrica conservadores; aviso; no escribir en flash hasta confirmar |
| Comunicación perdida con el servidor | Timeout sin respuesta | Seguir operando localmente; acumular datos; reintentar con espera creciente |
| Estado interno inválido | default del switch o complemento que no coincide | Registro, salidas seguras y reinicio (tema 37) |
| Reinicios repetidos | Contador de reinicios seguidos (tema 37) | Modo seguro; configuración de fábrica o firmware anterior |
Entrar rápido, salir despacio: la histéresis
Pasar de un modo al otro se decide con una regla, y la regla tiene que ser asimétrica: empeorar debe ser rápido (ante un fallo, hay que proteger el sistema ya) y mejorar debe ser lento (hay que estar seguro de que el problema se fue). Sin esa asimetría el sistema oscila entre modos cada vez que el fallo es intermitente, y cada oscilación es un cambio de comportamiento, un registro y a veces un transitorio en las salidas. La Parte 2 del programa usa las mismas 600 lecturas con dos reglas para volver a normal:
| Regla para mejorar | Cambios de modo | Comportamiento |
|---|---|---|
| Sin histéresis: el modo sigue a la ventana de las últimas 10 lecturas | 14 | Oscila en cuanto la ventana baja de un umbral |
| Con histéresis: se vuelve a normal solo tras 20 lecturas válidas seguidas | 11 | Cada falla produce una entrada y una salida limpias |
Con histéresis, las transiciones de la simulación son pocas y legibles: la falla del sensor congelado lleva a normal → degradado → seguro en las lecturas 228 y 233, y vuelve a normal en la 280; la del sensor desconectado, en las 302 y 307, con regreso en la 340; el ruido fuerte, en 452 y 457, con regreso en la 479; y el cambio real de temperatura provoca un breve paso por degradado (522 a 543) hasta que se acepta el nivel nuevo. Es el costo de la validación del apartado 2: los rechazos de las primeras lecturas del nuevo nivel cuentan en la ventana.
typedef enum { MODO_NORMAL, MODO_DEGRADADO, MODO_SEGURO } Modo; /* el orden importa: mayor valor = peor */
static Modo modo = MODO_NORMAL;
static uint16_t ventana; /* un bit por lectura: 1 = inválida (últimas 10) */
static uint8_t seguidas; /* lecturas válidas seguidas */
void modo_actualizar(bool lectura_valida) {
ventana = (uint16_t)(((ventana << 1) | (lectura_valida ? 0u : 1u)) & 0x3FFu);
seguidas = lectura_valida ? (uint8_t)(seguidas < 255 ? seguidas + 1 : 255) : 0;
int malas = __builtin_popcount(ventana);
Modo peor = malas >= 8 ? MODO_SEGURO : malas >= 3 ? MODO_DEGRADADO : MODO_NORMAL;
if (peor > modo) modo_cambiar(peor); /* empeorar: de inmediato */
else if (modo != MODO_NORMAL && seguidas >= 20) modo_cambiar(MODO_NORMAL); /* mejorar: solo tras 20 válidas seguidas */
}
void modo_cambiar(Modo nuevo) {
registrar_cambio_de_modo(modo, nuevo); /* siempre queda constancia (tema 37) */
modo = nuevo;
if (nuevo == MODO_SEGURO) { calefactor_apagar(); alarma_encender(); }
}
Una última precaución: el modo seguro tiene que poder salir. Un equipo que entra en modo seguro y no vuelve a intentarlo nunca queda inútil por un fallo pasajero. Se diseñan reintentos espaciados (cada cierto tiempo, con espera creciente) y una condición clara de regreso. Y el modo seguro debe ser simple y muy probado: es el código que se ejecuta cuando todo lo demás falló, y no puede depender de lo mismo que acaba de fallar.
07 · Volver a un estado conocido
Recuperarse
Recuperarse es llevar un componente que falló de vuelta a un estado de funcionamiento, sin reiniciar todo el equipo si no hace falta. Se hace por capas, de la menos a la más costosa:
| Nivel | Qué se reinicia | Ejemplo |
|---|---|---|
| Reintento | La operación | Releer el sensor; reenviar el mensaje |
| Reinicio del periférico | Un módulo de hardware | Reconfigurar la UART tras un error de desborde (overrun) o de trama; reiniciar el periférico I²C |
| Recuperación del bus | El estado de los dispositivos conectados | Liberar un bus I²C trabado (abajo) |
| Reinicio del módulo externo | Un chip o módulo aparte | Cortar y dar la alimentación a un módem o sensor desde un pin; una señal de reset |
| Restauración de datos | La configuración | Usar la copia de respaldo, o los valores de fábrica |
| Reinicio del sistema | Todo el microcontrolador | Reinicio por software o por watchdog (temas 37 y 38) |
| Retorno al firmware anterior | El programa | Tras una actualización defectuosa (tema 42) |
Un caso clásico de recuperación de bus es el I²C trabado. Si el microcontrolador se reinicia (o hay una interferencia) en medio de una transferencia, un esclavo puede quedar esperando los bits que faltan de un byte, sosteniendo la línea de datos (SDA) en nivel bajo, y el bus queda bloqueado: ninguna transferencia nueva puede empezar. La salida estándar es generar hasta nueve pulsos de reloj (SCL) hasta que el esclavo termine su byte y suelte SDA, y entonces una condición de STOP:
/* Liberar un bus I²C en el que un esclavo mantiene SDA en bajo */
void i2c_recuperar_bus(void) {
i2c_periferico_apagar(); /* tomar el control de los pines como GPIO */
gpio_salida_open_drain(PIN_SCL, 1); gpio_entrada(PIN_SDA);
for (uint8_t i = 0; i < 9 && !gpio_leer(PIN_SDA); i++) { /* un esclavo a medio byte suelta SDA tras, como mucho, 9 pulsos */
gpio_escribir(PIN_SCL, 0); delay_us(5);
gpio_escribir(PIN_SCL, 1); delay_us(5);
}
gpio_salida_open_drain(PIN_SDA, 0); delay_us(5); /* condición de STOP: SDA sube mientras SCL está en alto */
gpio_escribir(PIN_SCL, 1); delay_us(5);
gpio_escribir(PIN_SDA, 1); delay_us(5);
i2c_periferico_iniciar(); /* devolver los pines al periférico */
}
Proteger los datos que importan: CRC y copias
La configuración y los datos de calibración viven en memoria no volátil y deben sobrevivir a cortes de energía, desgaste y corrupción. Dos técnicas se combinan: un CRC para detectar que un bloque se dañó, y copias redundantes para tener con qué reemplazarlo. La Parte 3 del programa mide las dos:
- Un CRC de 16 bits sobre 16 bytes (128 bits) detectó los 128 errores posibles de 1 bit y los 8128 de 2 bits, sin excepciones. Con errores de 3 bits al azar (60 000 muestras) no dejó pasar ninguno; con errores de 4 bits, 4 sin detectar; con errores de 8 bits, 1 en 60 000, del orden de 2−16 (una de cada 65 536). El CRC no es una garantía absoluta: es una barrera muy eficaz contra los daños pequeños y aleatorios, y detecta casi todos los grandes. No protege contra un atacante que recalcule el CRC (tema 43).
- Dos copias independientes reducen la probabilidad de perder la configuración de p a aproximadamente p2: con una probabilidad de corrupción del 1 %, la configuración se perdió el 0,98 % de las veces con una copia y el 0,009 % con dos (el teórico es 0,01 %); con un 5 %, el 5,03 % contra el 0,258 % (teórico 0,25 %).
typedef struct { uint32_t secuencia; ConfigDatos datos; uint16_t crc; } ConfigCopia;
/* Cargar la configuración: la copia válida más reciente; si ninguna es válida, valores de fábrica y aviso */
bool config_cargar(ConfigDatos *salida) {
ConfigCopia a, b;
leer_copia(0, &a); leer_copia(1, &b);
bool va = copia_valida(&a), vb = copia_valida(&b); /* marca y CRC correctos */
if (va && vb) *salida = ((int32_t)(a.secuencia - b.secuencia) >= 0) ? a.datos : b.datos; /* la más nueva (resta con signo: tema 37) */
else if (va) *salida = a.datos;
else if (vb) *salida = b.datos;
else {
*salida = CONFIG_DE_FABRICA; /* ninguna sirve: valores conservadores */
registrar_evento(EV_CONFIG_PERDIDA);
return false;
}
return true;
}
/* Al GUARDAR: escribir primero la copia más vieja con secuencia+1, así siempre queda al menos una válida si se corta la energía (tema 40). */
08 · Probar lo que casi nunca ocurre
Verificación: inyectar fallas
Los caminos de error tienen una propiedad cruel: son los que menos se ejecutan en las pruebas, porque en la mesa casi nada falla, y los que más importan cuando algo falla en el campo. Un camino de error nunca ejecutado es una hipótesis. La técnica para probarlos es la inyección de fallas (fault injection): provocar a propósito, en el entorno de prueba, cada falla que el diseño dice manejar, y comprobar que la respuesta es la prevista.
| Falla a inyectar | Cómo | Qué se verifica |
|---|---|---|
| Sensor fuera de rango, congelado, desconectado | Un sensor simulado (en el modelo de la PC o con una resistencia en la placa) que entrega esos valores | El validador lo rechaza, el modo cambia como se diseñó, las salidas quedan seguras |
| Mensajes malformados | Entradas al azar y con valores límite sobre el analizador, bajo sanitizers (tema 31) | Ningún acceso fuera de rango; cada rechazo se cuenta; el programa sigue |
| CRC incorrecto en la configuración | Corromper una copia, la otra, ambas | Se usa la buena; con ambas dañadas, valores de fábrica y aviso |
| Un periférico que no responde | Desconectar el sensor; mantener SDA en bajo con un interruptor | Timeout, reintentos acotados, recuperación del bus; el programa no se bloquea |
| Una tarea colgada | Un bucle infinito en una tarea de prueba | El supervisor y el watchdog la detectan y el sistema se recupera (tema 38) |
| Tensión que baja | Una fuente variable (tema 37) | Aviso de baja tensión, estado guardado, reinicio ordenado, sin datos corruptos |
| Estado interno corrupto | Escribir un valor inválido en la variable de estado con el depurador | El default o el complemento lo detectan y llevan a un estado seguro |
| Falla repetida | Una falla que reaparece sin parar | El contador escala (degradado, seguro) y los reintentos se espacian; no hay bucle de reinicios |
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| El equipo se reinicia ante un mensaje malformado | Una aserción (o un acceso inválido) sobre un dato externo, en lugar de una validación | Validar en la frontera y rechazar con un código; reservar las aserciones para errores de programación |
| El equipo se queda esperando para siempre a un periférico | Una espera sin timeout | Esperas con límite y manejo del vencimiento |
| El modo cambia de un lado a otro sin parar | Un fallo intermitente con una regla simétrica | Histéresis: empeorar rápido, mejorar despacio |
| Un sensor sano queda rechazado para siempre tras un cambio brusco real | Validación contra el último valor aceptado sin regla de rebase | Aceptar un nivel nuevo si se repite N veces |
| Tras un corte de energía durante un guardado, la configuración se pierde | Una sola copia, escrita en el lugar sin protección | Dos copias con CRC y secuencia; escribir primero la más vieja (tema 40) |
| El I²C deja de funcionar tras un reinicio inesperado | Un esclavo mantiene SDA en bajo | Recuperación del bus al arrancar y tras cada fallo |
| Las aserciones desaparecen en el producto y los errores pasan inadvertidos | Se compilan fuera con NDEBUG sin una política de producción | Aserciones propias que registran y llevan al estado seguro |
| El modo seguro funciona en la mesa y falla en el campo | Nunca se lo probó con la falla real o depende de lo que falló | Inyectar la falla y probar el modo seguro completo, incluida la salida |
09 · Analogía con programación
Probar el modelo en Python
El programa reproduce tres piezas del tema con datos sintéticos pero con las dificultades reales: la validación de 600 lecturas de un sensor con cuatro fallas inyectadas y un cambio real de temperatura, comparando dos validadores; la máquina de modos (normal, degradado, seguro) con y sin histéresis sobre las mismas lecturas; y la protección de un bloque de configuración con un CRC de 16 bits y dos copias, con la medición de cuántos errores se detectan y de la probabilidad de perder la configuración.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar. La parte del CRC tarda unos segundos.
# Programación defensiva: validar un sensor, degradar con histéresis y proteger la configuración con CRC y copias.
import math, random
# ---- Parte 1: validación de un sensor de temperatura con fallas inyectadas ----------------------------------------
rnd = random.Random(2)
N = 600
lecturas = [round(21.0 + 0.8 * math.sin(k / 60) + rnd.gauss(0, 0.05), 1) for k in range(N)]
reales_malas = set()
lecturas[100] = 85.0; reales_malas.add(100) # un pico aislado
for k in range(200, 261): lecturas[k] = lecturas[199]; reales_malas.add(k) # sensor congelado: repite el último valor
for k in range(300, 321): lecturas[k] = -127.0; reales_malas.add(k) # sensor desconectado (valor típico de error)
for k in range(450, 460): lecturas[k] = round(lecturas[k] + rnd.choice((-1, 1)) * rnd.uniform(8, 20), 1); reales_malas.add(k) # ruido fuerte
for k in range(520, N): lecturas[k] = round(lecturas[k] + 8.0, 1) # un cambio REAL y permanente de 8 °C (se abrió una puerta): NO es una falla
RANGO, SALTO_MAX, CONGELADO_N, REBASE_N = (-40.0, 125.0), 5.0, 30, 5
def validador(contra_ultimo_aceptado):
"""Devuelve la lista de veredictos. A: compara con la lectura anterior. B: con la última ACEPTADA, y acepta un nivel nuevo si se repite REBASE_N veces."""
veredictos, historial, ultimo, candidatos = [], [], None, []
for v in lecturas:
if not (RANGO[0] <= v <= RANGO[1]): r = "FUERA_RANGO"
elif len(historial) >= CONGELADO_N - 1 and all(h == v for h in historial[-(CONGELADO_N - 1):]): r = "CONGELADO"
else:
ref = ultimo if contra_ultimo_aceptado else (historial[-1] if historial else None)
r = "OK" if ref is None or abs(v - ref) <= SALTO_MAX else "SALTO"
if contra_ultimo_aceptado and r == "SALTO":
candidatos.append(v) # ¿se repite el nivel nuevo? entonces es real
if len(candidatos) >= REBASE_N and max(candidatos[-REBASE_N:]) - min(candidatos[-REBASE_N:]) <= SALTO_MAX: r, candidatos = "OK", []
else: candidatos = []
if r == "OK": ultimo = v
veredictos.append(r); historial.append(v)
return veredictos
print("Parte 1: validación de 600 lecturas con cuatro fallas inyectadas y un cambio real de +8 °C en k=520")
for etiqueta, flag in (("A: compara con la lectura anterior", False), ("B: compara con la última aceptada, con rebase", True)):
veredictos = validador(flag)
print(f" Validador {etiqueta}")
for nombre, ks in (("pico aislado (k=100)", range(100, 101)), ("congelado (k=200 a 260)", range(200, 261)), ("desconectado (k=300 a 320)", range(300, 321)), ("ruido fuerte (k=450 a 459)", range(450, 460))):
detectadas = [k for k in ks if veredictos[k] != "OK"]
primera = f"primera en k={detectadas[0]}" if detectadas else "ninguna"
print(f" {nombre:<28}: {len(detectadas):>2} de {len(ks):>2} rechazadas ({primera})")
falsos = [k for k in range(N) if k not in reales_malas and veredictos[k] != "OK"]
print(f" lecturas buenas rechazadas: {len(falsos)} ({', '.join(f'k={k}' for k in falsos[:8])})")
veredictos = validador(True)
# ---- Parte 2: degradación con y sin histéresis -----------------------------------------------------------------
def modos(histeresis):
modo, ventana, seguidas, cambios, linea = "NORMAL", [], 0, 0, []
for k, r in enumerate(veredictos):
ventana = (ventana + [r != "OK"])[-10:]
seguidas = seguidas + 1 if r == "OK" else 0
malas = sum(ventana)
peor = "SEGURO" if malas >= 8 else "DEGRADADO" if malas >= 3 else "NORMAL"
nuevo = peor
if histeresis:
orden = ["NORMAL", "DEGRADADO", "SEGURO"]
nuevo = peor if orden.index(peor) > orden.index(modo) else (modo if seguidas < 20 else "NORMAL")
if nuevo != modo: cambios += 1; linea.append((k, modo, nuevo)); modo = nuevo
return cambios, linea
print()
print("Parte 2: el modo de operación (NORMAL / DEGRADADO / SEGURO) con las mismas lecturas")
for h in (False, True):
cambios, linea = modos(h)
print(f" {'con' if h else 'sin'} histéresis: {cambios:>2} cambios de modo", end="")
print("" if not h else "; transiciones: " + "; ".join(f"k={k} {a}->{b}" for k, a, b in linea))
# ---- Parte 3: proteger la configuración: CRC y dos copias ---------------------------------------------------------
def crc16(datos, crc=0xFFFF):
for b in datos:
crc ^= b << 8
for _ in range(8): crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
TABLA = []
for i in range(256):
c = i << 8
for _ in range(8): c = ((c << 1) ^ 0x1021) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF
TABLA.append(c)
def crc_rapido(datos):
crc = 0xFFFF
for b in datos: crc = ((crc << 8) & 0xFFFF) ^ TABLA[((crc >> 8) ^ b) & 0xFF]
return crc
assert crc16(b"123456789") == crc_rapido(b"123456789") == 0x29B1 # valor de referencia conocido de CRC-16/CCITT-FALSE
config = bytes(range(16))
base = crc_rapido(config)
print()
print("Parte 3: ¿cuántos errores detecta un CRC de 16 bits sobre 16 bytes (128 bits)?")
def flips(bits):
d = bytearray(config)
for b in bits: d[b // 8] ^= 1 << (b % 8)
return bytes(d)
det1 = sum(crc_rapido(flips([a])) != base for a in range(128))
det2 = sum(crc_rapido(flips([a, b])) != base for a in range(128) for b in range(a + 1, 128))
print(f" errores de 1 bit: {det1} de 128 detectados; errores de 2 bits: {det2} de {128 * 127 // 2} detectados")
rr = random.Random(9)
for k in (3, 4, 8):
muestras = [rr.sample(range(128), k) for _ in range(60_000)]
perdidos = sum(crc_rapido(flips(m)) == base for m in muestras)
print(f" errores de {k} bits (60 000 muestras al azar): {perdidos} sin detectar")
print(" protección con dos copias (cada copia se corrompe de forma independiente con probabilidad p):")
rc = random.Random(4)
for p in (0.05, 0.01):
ensayos = 400_000
una = sum(rc.random() < p for _ in range(ensayos))
dos = sum((rc.random() < p) and (rc.random() < p) for _ in range(ensayos))
print(f" p = {p:<5}: con una copia se pierde la configuración {una / ensayos * 100:5.2f} % de las veces; con dos copias, {dos / ensayos * 100:6.3f} % (teórico p² = {p * p * 100:.3f} %)")
Resultado, parte 1: con las dos versiones del validador, el pico aislado se rechaza, el sensor desconectado se rechaza en las 21 lecturas y el congelado se detecta desde la lectura 226 (35 de 61 rechazadas); la diferencia está en el ruido fuerte (A: 7 de 10, B: 10 de 10) y en las lecturas buenas rechazadas: 4 en cada versión, pero distintas (el A rechaza la vuelta a la normalidad tras cada falla y el primer valor del cambio real, y el B rechaza las lecturas 520 a 523 del cambio real hasta que se acepta el nivel nuevo). Parte 2: sin histéresis, el modo cambia 14 veces; con histéresis, 11, con transiciones limpias: congelado (228 normal→degradado; 233 degradado→seguro; 280 seguro→normal), desconectado (302, 307, 340), ruido (452, 457, 479) y un breve paso por degradado durante el cambio real (522 a 543). Parte 3: el CRC detectó los 128 errores de 1 bit y los 8128 de 2 bits; en 60 000 errores al azar de 3, 4 y 8 bits dejó pasar 0, 4 y 1; con dos copias, la probabilidad de perder la configuración baja de 5,03 % a 0,258 % (p = 5 %) y de 0,98 % a 0,009 % (p = 1 %), cerca de p2.
Probá cambiar REBASE_N de 5 a 2 y mirá cómo cambian los rechazos del cambio real (menos) y de los picos consecutivos (más peligro de aceptar un falso nivel), o la ventana de 10 lecturas y los umbrales de 3 y 8. La lección es que cada parámetro de un validador o de un gestor de modos es un compromiso entre falsas alarmas y fallas no detectadas, y que se ajusta con datos, no con intuición.
10 · Exploración interactiva
Sensor, modos y calefactor
El laboratorio simula un termostato: un sensor de temperatura con ruido, el validador del apartado 2 (versión B), el gestor de modos del apartado 6 y un calefactor con consigna de 21,5 °C. Elegís una falla del sensor, avanzás lecturas de a 1, 10 o 50, y mirás qué decide el validador, en qué modo queda el equipo y qué hace con el calefactor. Con la casilla de histéresis comparás las dos reglas de regreso a normal.
Del sensor que falla al calefactor que se protege
Cambios de modo
—
Elegí una falla y avanzá algunas lecturas.
Recorrido guiado
- Con «Ninguna», avanzá 50 lecturas: el veredicto es siempre OK, el modo NORMAL y el calefactor decide con el valor del sensor.
- Elegí «Un pico aislado» y avanzá 1 lectura: el validador responde SALTO y la lectura se descarta, pero el modo sigue en NORMAL: una sola lectura mala no basta. El calefactor sigue con el último valor válido.
- Elegí «Sensor desconectado» y avanzá de a 1: con la tercera lectura inválida en la ventana, el modo pasa a DEGRADADO (calefactor limitado al 30 %); con la octava, a SEGURO: calefactor apagado y alarma. Entrar es rápido.
- Elegí «Ninguna» (el sensor «se reconecta») y avanzá de a 10: con la histéresis, el modo se queda en SEGURO hasta acumular 20 lecturas válidas seguidas. Salir es lento. Desmarcá la casilla, repetí la secuencia y mirá que ahora vuelve a NORMAL en cuanto la ventana se limpia, y con una falla intermitente los cambios de modo se multiplican.
- Elegí «Sensor congelado» y avanzá 50: durante los primeros 29 valores idénticos el validador no se queja; recién con 30 repetidos aparece CONGELADO. Una falla que parece un dato válido tarda en detectarse, y ese retraso es un parámetro de diseño.
- Elegí «Cambio real de +8 °C» y avanzá 10 lecturas: las primeras se rechazan (SALTO) hasta que se repiten cinco veces y el validador acepta el nivel nuevo (la regla de rebase). El equipo pasa un momento por DEGRADADO por un cambio que no era una falla: el costo de la prudencia. Sin la regla de rebase, el sensor sano quedaría rechazado para siempre.
Desafío: ¿qué cambiarías para que el cambio real de temperatura no provoque el paso por DEGRADADO?
Hay varias opciones, cada una con su precio. Se puede aflojar el umbral de salto (de 5 a 10 °C): el cambio real de 8 °C pasaría directamente, pero también pasarían saltos falsos de 8 °C. Se puede bajar el rebase a tres lecturas (acepta el nivel nuevo antes, pero acepta más fácilmente un falso nivel si hay dos picos seguidos). Se puede no contar los rechazos por SALTO en la ventana del modo hasta que se confirme (un salto aislado es sospechoso, no una falla), contando solo los fuera de rango y los congelados: se pierde sensibilidad ante el ruido fuerte. O se puede usar un segundo sensor como comparación cruzada: si los dos sensores saltan juntos, el cambio es real y se acepta de inmediato; si salta uno solo, es una falla. Esta última es la opción más robusta y la más cara. La decisión depende de qué es peor para el producto: una falsa alarma (el calefactor limitado unos minutos) o una falla no detectada (calentar de más). En un calefactor, lo segundo es peor, y por eso el diseño actual elige la prudencia.
11 · Método de trabajo
Método: diseñar para el fallo
- Listá los fallos posibles de cada entrada, salida, módulo y recurso del producto (sensores, comunicaciones, memoria, alimentación, el propio programa). Es un análisis de modos de falla, aunque sea una tabla.
- Para cada fallo, decidí tres cosas de antemano: cómo se detecta, qué respuesta es la menos drástica que resuelve el problema (registrar, reintentar, degradar, modo seguro, reiniciar) y qué significa «seguro» para cada salida.
- Validá en las fronteras de confianza: longitud, rango, formato, integridad, coherencia con el estado y frescura. Dentro del programa, confiá.
- Usá aserciones para los errores de programación y validaciones para los errores del mundo. Definí qué hace una aserción en producción: registrar y llevar al sistema a un estado seguro.
- Que ninguna espera sea infinita y que ningún reintento sea ilimitado: máximo de intentos, espera creciente, y un peor caso calculado menor que el timeout del watchdog.
- Que ningún resultado se ignore: advertencias como errores,
warn_unused_result, contadores de errores por tipo. - Definí los modos de operación (normal, degradado, seguro) con reglas asimétricas: empeorar rápido, mejorar despacio, con histéresis. El modo seguro es simple, está muy probado y puede salir.
- Protegé los datos importantes con CRC y dos copias; escribí la más vieja primero; ofrecé valores de fábrica como último recurso.
- Diseñá la recuperación por capas, de la menos a la más costosa: reintento, periférico, bus, módulo externo, datos, sistema, firmware anterior.
- Inyectá cada falla a propósito y verificá la respuesta. Un camino de error nunca ejecutado es una hipótesis.
- Registrá todo (contadores, motivos, cambios de modo) y sacalo del equipo (tema 37): la confiabilidad en el campo se mide, no se supone.
12 · Comprensión
Confusiones frecuentes
«Con un watchdog no hace falta programación defensiva»
Son capas distintas. El watchdog rescata de un cuelgue reiniciando, que es la respuesta más cara y pierde el estado y el servicio. La programación defensiva atrapa el fallo cerca de su causa y lo maneja sin reiniciar. Un equipo que depende solo del watchdog se reinicia por cada fallo que podría haber resuelto con una validación.
«Validar todo en todos lados hace el código más seguro»
Hace el código más grande, más lento y más difícil de leer, y esconde errores de programación detrás de comprobaciones que nunca fallan en el diseño correcto. Se valida en las fronteras de confianza; dentro del programa se usan aserciones para lo que «no puede pasar». Más comprobaciones no es más seguridad: son las comprobaciones correctas, en los lugares correctos.
«Si una aserción falla en producción, lo mejor es ignorarla y seguir»
Una aserción que falla indica un error de programación: el estado ya es incorrecto, y seguir ejecutando sobre él puede causar un daño peor y más difícil de entender. La política útil es registrar dónde falló, llevar las salidas a un estado seguro y reiniciar, con el motivo anotado. Cada aserción que falla en el campo es un informe de error con ubicación exacta.
«Reintentar siempre es buena idea»
Con un límite y una espera creciente, sí. Sin límite, un fallo permanente se convierte en un bucle que bloquea al sistema; sin espera, se martilla a un periférico colgado y se agrava el problema; y siempre hay que calcular el peor caso para que no supere el timeout del watchdog.
«El modo seguro es apagar todo»
Apagar todo es una de las formas de modo seguro, y muchas veces la correcta, pero no la única: una cerradura de emergencia en modo seguro queda destrabada, un sistema de control de flujo cierra una válvula y mantiene la comunicación para avisar. «Seguro» se define salida por salida, según qué cause daño, y el modo seguro debe conservar lo mínimo necesario para avisar y recuperarse.
«Un buen modo seguro nunca debe salir hasta que alguien lo repare»
Un equipo que entra en modo seguro por un fallo pasajero y nunca vuelve a intentarlo queda inútil sin necesidad. Se diseñan reintentos espaciados y una condición clara de regreso, con histéresis. Solo se queda bloqueado a la espera de intervención cuando la falla es grave o se repite sin remedio, y aun así avisando.
«Con un CRC, los datos están a salvo»
El CRC detecta daños, no los repara, y no es infalible (deja pasar uno de cada 65 536 corrupciones aleatorias grandes, para 16 bits). Para sobrevivir al daño hacen falta copias redundantes con las que reemplazar el bloque dañado, y valores de fábrica como último recurso. Y un CRC no protege contra una modificación intencional, que puede recalcularlo.
«Mi equipo funciona bien en las pruebas, así que sus caminos de error funcionan»
En las pruebas normales los caminos de error casi no se ejecutan, de modo que no se probaron. Se prueban inyectando cada falla a propósito. Un camino de error nunca ejecutado es el lugar donde más errores se esconden, y se ejecuta cuando el equipo está en su peor momento.
13 · Práctica guiada
Ejercicios
1. Dos validadores
Una secuencia de lecturas de temperatura es: 21,0; 21,2; 85,0; 21,1; 21,3. El salto máximo es de 5 °C. ¿Qué lecturas rechaza un validador que compara con la lectura anterior? ¿Y uno que compara con la última aceptada?
Ver solución
Comparando con la lectura anterior: 21,2 vs 21,0 (0,2): OK. 85,0 vs 21,2: salto de 63,8: rechazada. 21,1 vs 85,0: salto de 63,9: rechazada (una lectura buena rechazada por compararse con la mala). 21,3 vs 21,1: OK. Comparando con la última aceptada: 85,0 vs 21,2: rechazada. 21,1 vs 21,2 (la última aceptada es 21,2, no 85,0): 0,1: OK. 21,3: OK. El segundo rechaza solo la lectura mala. El precio del segundo es el que muestra el apartado 2: ante un cambio real y permanente de más de 5 °C rechazaría todo para siempre si no tuviera una regla de rebase.
2. La histéresis, en números
Una ventana de 10 lecturas, con umbrales de 3 (degradado) y 8 (seguro) lecturas inválidas, y 20 válidas seguidas para volver a normal con histéresis. Las lecturas son: inválida, inválida, inválida, y luego todas válidas. ¿En qué lectura pasa a degradado? ¿En cuál vuelve a normal sin histéresis y con histéresis?
Ver solución
Con la tercera lectura (tres inválidas en la ventana) pasa a degradado. Las tres inválidas permanecen en la ventana de 10 hasta que la primera sale: en la lectura 11, la ventana (lecturas 2 a 11) tiene solo dos inválidas. Sin histéresis, vuelve a normal en la lectura 11. Con histéresis, las válidas empiezan en la lectura 4 y hacen falta 20 seguidas: las lecturas 4 a 23, de modo que vuelve a normal en la lectura 23. La diferencia (12 lecturas) es el margen de seguridad que se paga para no oscilar con una falla intermitente.
3. CRC e injusticia estadística
Un CRC de 16 bits deja pasar, en promedio, una de cada 65 536 corrupciones aleatorias grandes. Si un equipo recibe diez millones de bloques corruptos de ese tipo, ¿cuántos se aceptan por error? ¿Cómo cambia si se usa un CRC de 32 bits?
Ver solución
10 000 000 / 65 536 ≈ 153 bloques corruptos aceptados. Con un CRC de 32 bits, la fracción es 1 / 232 = 1 / 4 294 967 296: 10 000 000 / 4 294 967 296 ≈ 0,002, es decir, es muy probable que ninguno. El CRC de 32 bits cuesta 2 bytes más por bloque y algo más de cálculo; se justifica cuando el costo de aceptar un bloque corrupto es alto (la configuración de un equipo de seguridad). Y ninguno de los dos protege contra una modificación intencional.
4. Dos copias
La probabilidad de que una copia de la configuración quede corrupta es del 2 %. En una flota de 10 000 equipos, ¿cuántos pierden la configuración con una copia y cuántos con dos copias independientes?
Ver solución
Con una copia: 2 % de 10 000 = 200 equipos. Con dos copias independientes, hay que perder ambas: 0,02 × 0,02 = 0,0004 = 0,04 %: 4 equipos. La redundancia reduce las pérdidas 50 veces. Dos advertencias: la fórmula supone que las copias fallan de forma independiente (si las dos viven en la misma página de flash o comparten la misma causa, como un corte de energía durante la escritura, la independencia no se cumple, y por eso se escribe una copia por vez, empezando por la más vieja); y los 4 equipos restantes necesitan el último recurso: valores de fábrica.
5. El peor caso de un reintento
Una lectura de sensor tiene un timeout de 8 ms por intento, hasta 4 intentos y esperas de 10, 20 y 40 ms entre ellos (no hay espera tras el último). El watchdog es de 550 ms. ¿Cuál es el peor tiempo de la función? ¿Es compatible con el watchdog? ¿Y si el ciclo del programa tiene un presupuesto de 100 ms?
Ver solución
Peor caso: 4 intentos × 8 ms = 32 ms, más las esperas entre intentos (10 + 20 + 40 = 70 ms): 102 ms. Con el watchdog de 550 ms es compatible (102 ≪ 550, un 19 % del timeout, bien por debajo de la mitad). Pero con un presupuesto de ciclo de 100 ms, no entra: el peor caso lo excede. Opciones: reducir los intentos a 3 (3 × 8 + 10 + 20 = 54 ms), acortar el timeout por intento, o sacar el reintento del camino crítico (hacerlo en una tarea aparte que actualice un valor, y que el control use el último valor válido). Es el tipo de cuenta que hay que hacer antes de poner reintentos: un reintento sin un peor caso conocido es una bomba de tiempo.
6. ¿Aserción o validación?
Decidí para cada caso si corresponde una aserción o una validación con manejo: (a) una función interna recibe un puntero nulo de su único llamador, que lo calcula siempre antes; (b) un byte recibido por la UART está fuera del rango de los comandos; (c) un índice que calcula el propio programa queda fuera de su arreglo; (d) el usuario ingresa un código incorrecto en el teclado; (e) la lectura de un sensor da −127 °C.
Ver solución
(a) Aserción: el llamador es interno y nunca debería pasar un nulo; si lo hace, es un error de programación. (b) Validación: es un dato externo, puede llegar corrupto en un sistema correcto; se rechaza, se cuenta y se sigue. (c) Aserción: un índice que el programa calcula mal es un defecto, no una condición del mundo. (d) Validación (y manejo normal: contador de intentos, bloqueo, tema 27): el código incorrecto es una entrada legítima de un usuario. (e) Validación: un sensor desconectado es una falla de hardware esperable en el campo; se detecta por plausibilidad, se descarta la lectura y se escala al modo degradado o seguro si persiste. La regla: las aserciones son para lo que no puede pasar si el programa es correcto; las validaciones, para lo que puede pasar aunque el programa sea correcto.
14 · Síntesis del bloque
Ideas para recordar
- La programación defensiva asume el fallo y lo organiza en cuatro etapas: prevenir, detectar, contener y responder y recuperar. La respuesta usa la escalera de menor a mayor costo (registrar, reintentar, degradar, modo seguro, reiniciar) y se elige la menos drástica que alcance. Para cada salida se decide qué significa «seguro»: falla segura, operativa o silenciosa.
- Se valida todo lo que cruza una frontera de confianza: longitud, rango, formato, integridad, coherencia con el estado y frescura. Los sensores se vigilan con plausibilidad (rango, velocidad de cambio, valor congelado, comparación cruzada); comparar con la última lectura aceptada y con una regla de rebase evita castigar el regreso a la normalidad y el cambio real.
- Las aserciones declaran lo que no puede ser falso si el programa es correcto (errores de programación); las validaciones manejan lo que puede ocurrir en un sistema correcto. En producción, una aserción propia registra la ubicación, deja las salidas seguras y reinicia. Sin efectos secundarios, y con
_Static_assertpara lo comprobable al compilar. - Ninguna espera es infinita, ningún reintento es ilimitado, ningún resultado se ignora. Timeouts, reintentos con máximo y espera creciente, un peor caso calculado menor que el timeout del watchdog, advertencias como errores y contadores de errores.
- El código robusto cierra categorías de errores de antemano: sin recursión, bucles acotados, sin memoria dinámica tras el arranque, todo inicializado, un
defaulten cadaswitchsobre estados y los estados críticos guardados con su complemento. - Modos de operación: normal, degradado y seguro, con reglas asimétricas, empeorar rápido y mejorar despacio (histéresis), para no oscilar con un fallo intermitente. El modo seguro es simple, muy probado y puede salir.
- Recuperarse por capas (reintento, periférico, bus, módulo externo, datos, sistema, firmware anterior). Los datos importantes, con CRC (que detecta los daños pequeños casi siempre) y dos copias (la probabilidad de perderlos baja de p a unos p2), con valores de fábrica como último recurso.
- Los caminos de error se prueban inyectando las fallas a propósito: un camino nunca ejecutado es una hipótesis. El bloque en conjunto: saber por qué se reinició (37), detectar el cuelgue con un watchdog bien alimentado (38) y diseñar el firmware para que casi nunca llegue a necesitarlo (39). La confiabilidad no es una propiedad que se agrega al final: se diseña, se prueba y se mide.
¿Listo para el bloque siguiente?
Antes de avanzar, tomá un firmware tuyo y hacé la tabla del apartado 6: cinco cosas que pueden fallar (un sensor, una comunicación, la configuración, la alimentación, un estado interno), cómo se detecta cada una y qué responde el equipo. Para cada fila, preguntá: ¿está implementado?, ¿está probado inyectando la falla? Si alguna respuesta es «no», ahí está el próximo fallo de campo.
En el próximo bloque vemos cómo el equipo conserva y actualiza lo que sabe. Empezamos por el almacenamiento persistente: la configuración en EEPROM y flash, el desgaste, la nivelación y cómo guardar datos de modo que un corte de energía en el momento justo no los destruya.