← Índice del curso

Sistemas Embebidos · Tema 39 · Bloque 8

Programación defensiva y recuperación

Los temas 37 y 38 enseñaron a reiniciar bien y a detectar un cuelgue. Pero reiniciar es la respuesta más cara a un fallo, y muchos fallos no cuelgan nada: un sensor que miente, un mensaje malformado, un bus que se trabó, un dato de configuración dañado. La programación defensiva parte de una premisa humilde: todo lo que puede fallar, en algún momento falla, y el firmware tiene que haber decidido de antemano qué hacer. Este tema cierra el bloque de confiabilidad con las técnicas para detectar los fallos cerca de donde nacen, contenerlos, degradar el servicio de forma ordenada y recuperarse, de modo que el reinicio sea el último recurso y no el primero.

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:

Las cuatro etapas de la respuesta a un fallo.
EtapaPreguntaTé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:

La escalera de respuestas a un fallo Cinco escalones, de menor a mayor gravedad: registrar y seguir, reintentar, degradar el servicio, pasar a modo seguro y reiniciar. A la derecha de cada uno, un ejemplo: una lectura aislada fuera de rango, un bus I2C que no responde, un sensor fallado que se reemplaza por el último valor, una configuración corrupta que obliga a un comportamiento mínimo, y un estado interno inválido que solo se arregla reiniciando. Registrar y seguirReintentarDegradarModo seguroReiniciar una lectura aislada fuera de rango: se descarta y se cuenta un bus que no responde: se reintenta con espera creciente un sensor que falló: se usa el último valor válido, con potencia limitada datos corruptos o fallas repetidas: función mínima, salidas seguras, aviso estado interno inválido: lo único seguro es volver a empezar Más abajo: más drástico, más caro y con más pérdida de servicio. Se usa el menos drástico que alcance.

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:

Tres respuestas posibles ante un fallo.
FilosofíaQué hace ante un falloEjemplo
Falla segura (fail-safe)Pasa a un estado que no causa daño, aunque deje de prestar el servicioUn 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 redundanciaUn 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 falsosUn 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:

Las seis verificaciones de un dato que entra.
VerificaciónPreguntaEjemplo
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:

Comprobaciones de plausibilidad de un sensor.
ComprobaciónDetectaCómo se configura
Rango físicoUn sensor desconectado, en cortocircuito o con una lectura de errorLos límites del fenómeno y del sensor (−40 a 125 °C)
Velocidad máxima de cambioPicos de ruido, lecturas absurdasLo que puede cambiar la magnitud entre dos muestras (una temperatura no cambia 5 °C en un segundo)
Valor congeladoUn sensor trabado, o un dato que ya no se actualizaEl mismo valor exacto durante N lecturas seguidas, con N mayor que lo que un sensor sano repetiría
Comparación cruzadaUn sensor que se desvió sin salir del rangoDos sensores de la misma magnitud (o un modelo físico) que deben coincidir dentro de una tolerancia
Coherencia con otras magnitudesUna lectura que contradice el contextoEl 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):

Dos validadores frente a las mismas fallas (resultado de la Parte 1).
SituaciónA: compara con la lectura anteriorB: compara con la última aceptada, con rebase
Pico aislado (1 lectura)1 de 1 rechazada1 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 rechazadas21 de 21 rechazadas
Ruido fuerte (10 lecturas)7 de 10 rechazadas10 de 10 rechazadas
Lecturas buenas rechazadas por error4: 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:

Aserciones y validaciones: dos herramientas para dos clases de problema.
AspectoValidaciónAserció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 fallaSe maneja: se rechaza el dato, se reintenta, se degradaEl programa tiene un defecto: se registra y se lleva el sistema a un estado seguro
EjemploUn byte recibido por la UART fuera del rango válidoUna función interna recibe un puntero nulo que su único llamador nunca debería pasarle
Se quita en producciónNo: es parte del comportamientoDepende 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 assert con NDEBUG): 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:

Reglas de codificación robusta y el error que previenen.
ReglaError que previene
Sin recursiónDesborde de pila dependiente de los datos (tema 29): la pila máxima se puede calcular
Todos los bucles tienen una cota máxima demostrableUn 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 arranqueFragmentación, fugas, fallos de malloc (tema 30)
Inicializar todo al declararloVariables con basura (tema 31)
Comprobar el resultado de toda función que puede fallarErrores ignorados que reaparecen lejos de su causa
Funciones cortas, con una sola responsabilidadLógica que no se puede revisar ni probar a fondo
Alcance mínimo de las variables; pocos globalesDependencias ocultas, condiciones de carrera (tema 22)
Compilar con todas las advertencias como erroresUna larga lista de errores que el compilador sabe detectar (tema 31)
Un default en cada switch sobre un enumeradoUn 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:

Tres modos de operación de un equipo ante fallos.
ModoCuándoQué hace
NormalTodo funciona dentro de lo esperadoTodas las funciones, con todas las prestaciones
DegradadoHay un fallo no crítico, o uno crítico con una alternativa aceptableMantiene 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
SeguroEl fallo impide operar con garantías, o las fallas se repitenLas 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, detección y respuesta para un termostato (ejemplo de diseño).
FalloCómo se detectaRespuesta
Lectura aislada fuera de rangoPlausibilidad (apartado 2)Se descarta y se cuenta; se sigue con la última válida
Varias lecturas inválidas seguidas3 o más inválidas en las últimas 10Degradado: calefactor al 30 % como máximo, con el último valor válido; aviso
Sensor desconectado o congelado de forma persistente8 o más inválidas en las últimas 10Seguro: calefactor apagado, alarma visible; reintentos periódicos
Configuración corruptaCRC incorrecto en ambas copiasValores de fábrica conservadores; aviso; no escribir en flash hasta confirmar
Comunicación perdida con el servidorTimeout sin respuestaSeguir operando localmente; acumular datos; reintentar con espera creciente
Estado interno inválidodefault del switch o complemento que no coincideRegistro, salidas seguras y reinicio (tema 37)
Reinicios repetidosContador 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:

Cambios de modo con las mismas lecturas, con y sin histéresis (resultado de la Parte 2).
Regla para mejorarCambios de modoComportamiento
Sin histéresis: el modo sigue a la ventana de las últimas 10 lecturas14Oscila en cuanto la ventana baja de un umbral
Con histéresis: se vuelve a normal solo tras 20 lecturas válidas seguidas11Cada 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:

Niveles de recuperación.
NivelQué se reiniciaEjemplo
ReintentoLa operaciónReleer el sensor; reenviar el mensaje
Reinicio del periféricoUn módulo de hardwareReconfigurar la UART tras un error de desborde (overrun) o de trama; reiniciar el periférico I²C
Recuperación del busEl estado de los dispositivos conectadosLiberar un bus I²C trabado (abajo)
Reinicio del módulo externoUn chip o módulo aparteCortar y dar la alimentación a un módem o sensor desde un pin; una señal de reset
Restauración de datosLa configuraciónUsar la copia de respaldo, o los valores de fábrica
Reinicio del sistemaTodo el microcontroladorReinicio por software o por watchdog (temas 37 y 38)
Retorno al firmware anteriorEl programaTras 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.

Fallas que conviene inyectar y qué comprobar.
Falla a inyectarCómoQué se verifica
Sensor fuera de rango, congelado, desconectadoUn sensor simulado (en el modelo de la PC o con una resistencia en la placa) que entrega esos valoresEl validador lo rechaza, el modo cambia como se diseñó, las salidas quedan seguras
Mensajes malformadosEntradas 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ónCorromper una copia, la otra, ambasSe usa la buena; con ambas dañadas, valores de fábrica y aviso
Un periférico que no respondeDesconectar el sensor; mantener SDA en bajo con un interruptorTimeout, reintentos acotados, recuperación del bus; el programa no se bloquea
Una tarea colgadaUn bucle infinito en una tarea de pruebaEl supervisor y el watchdog la detectan y el sistema se recupera (tema 38)
Tensión que bajaUna fuente variable (tema 37)Aviso de baja tensión, estado guardado, reinicio ordenado, sin datos corruptos
Estado interno corruptoEscribir un valor inválido en la variable de estado con el depuradorEl default o el complemento lo detectan y llevan a un estado seguro
Falla repetidaUna falla que reaparece sin pararEl contador escala (degradado, seguro) y los reintentos se espacian; no hay bucle de reinicios
Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
El equipo se reinicia ante un mensaje malformadoUna aserción (o un acceso inválido) sobre un dato externo, en lugar de una validaciónValidar 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éricoUna espera sin timeoutEsperas con límite y manejo del vencimiento
El modo cambia de un lado a otro sin pararUn fallo intermitente con una regla simétricaHistéresis: empeorar rápido, mejorar despacio
Un sensor sano queda rechazado para siempre tras un cambio brusco realValidación contra el último valor aceptado sin regla de rebaseAceptar un nivel nuevo si se repite N veces
Tras un corte de energía durante un guardado, la configuración se pierdeUna sola copia, escrita en el lugar sin protecciónDos copias con CRC y secuencia; escribir primero la más vieja (tema 40)
El I²C deja de funcionar tras un reinicio inesperadoUn esclavo mantiene SDA en bajoRecuperación del bus al arrancar y tras cada fallo
Las aserciones desaparecen en el producto y los errores pasan inadvertidosSe compilan fuera con NDEBUG sin una política de producciónAserciones propias que registran y llevan al estado seguro
El modo seguro funciona en la mesa y falla en el campoNunca 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

Última lectura—
Veredicto del validador—
Ventana (últimas 10)—
Racha—
Modo—
Cambios de modo—
Calefactor—

Cambios de modo

—

Elegí una falla y avanzá algunas lecturas.

Recorrido guiado

  1. Con «Ninguna», avanzá 50 lecturas: el veredicto es siempre OK, el modo NORMAL y el calefactor decide con el valor del sensor.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. Validá en las fronteras de confianza: longitud, rango, formato, integridad, coherencia con el estado y frescura. Dentro del programa, confiá.
  4. 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.
  5. 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.
  6. Que ningún resultado se ignore: advertencias como errores, warn_unused_result, contadores de errores por tipo.
  7. 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.
  8. Protegé los datos importantes con CRC y dos copias; escribí la más vieja primero; ofrecé valores de fábrica como último recurso.
  9. 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.
  10. Inyectá cada falla a propósito y verificá la respuesta. Un camino de error nunca ejecutado es una hipótesis.
  11. 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_assert para 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 default en cada switch sobre 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.