← Índice del curso

Sistemas Embebidos · Tema 46 · Bloque 10

Fallos típicos y análisis de errores

Los dos temas anteriores dieron las herramientas: LEDs, pines, registros de eventos y el depurador. Este tema responde a la pregunta que sigue: ¿qué buscar? Aunque cada proyecto tiene sus propios errores, los fallos de los sistemas embebidos se repiten con una regularidad notable: la pila que crece más de lo previsto, el buffer que pisa lo que tiene al lado, la ISR y el programa principal que se pisan un dato compartido, el cálculo que da la vuelta en un entero. Conocer el catálogo de fallos acorta la búsqueda, porque cada uno deja una firma reconocible, y un buen análisis de errores se parece más a un diagnóstico médico que a una adivinanza. Y, sobre todo, aprenderemos a hacer lo más difícil: reproducir los errores que ocurren una vez cada cuatro horas.

02 · El fallo más común

Pila desbordada y corrupta

La pila (tema 29) guarda las variables locales y las direcciones de retorno de cada función, y en la mayoría de los microcontroladores crece hacia abajo, hacia el espacio donde están las variables globales o el montículo. Si crece demasiado, pisa esas variables sin ninguna advertencia, y el síntoma aparece lejos de la causa: un contador que se pone en cero, una cadena que cambia, y por fin un HardFault cuando se pisa una dirección de retorno o la pila sale de la RAM (el caso STKERR del tema 45).

Las causas habituales son pocas: variables locales grandes (un arreglo de 512 bytes dentro de una función), recursión sin límite, funciones de biblioteca que usan mucha pila (snprintf con coma flotante, vimos que puede pasar de 300 bytes), interrupciones anidadas que apilan un cuadro de 32 bytes cada una más lo que use su manejador, y una pila simplemente subdimensionada por una estimación a ojo. La Parte 3 del programa calcula el presupuesto de un firmware de ejemplo a partir del grafo de llamadas y los tamaños de marco:

  • Camino más profundo: main → tarea_sensor → procesar_muestra → formatear_trama → snprintf: 32 + 48 + 96 + 232 + 320 = 728 B.
  • Interrupciones: dos niveles anidados de 32 B (apilados por el hardware) más 48 B del manejador son 80 B cada uno: 160 B. Peor caso total: 888 B.
  • Margen: con un 25 % de margen, 1110 B. Una pila de 1 KB (1024 B) alcanza sin margen (quedan 136 B en el peor caso, y cualquier cambio la rompe); con 2 KB sobran 1160 B.

Una función recursiva de 24 B por nivel, llamada desde procesar_muestra con la pila de 1 KB, admite 28 niveles antes de agotar la pila: un dato cualquiera que aumente la profundidad la rompe. Por eso la recursión en un sistema embebido se evita, o se limita con un contador comprobado.

Cómo detectar y prevenir un desborde de pila.
TécnicaCómo funcionaCuándo detecta
Análisis estático-fstack-usage da el marco de cada función; con el grafo de llamadas se calcula el peor caso (Parte 3). -Wstack-usage=N avisa si una función pasa de N bytesAl compilar; no ve la recursión ni los punteros a función
Pintado de la pilaAl arrancar, llenar la pila con un patrón (0xAA). Después, ver cuánto del patrón se conserva: la marca máxima de uso (el programa de Python: tras el peor caso quedan 1160 B de 2048 sin tocar)En pruebas; da la medida real, no la teórica
Canario en el extremoUna palabra conocida (0xDEADBEEF) en el límite de la pila; se verifica en una tarea periódica o en el cambio de contextoDespués del desborde, antes del desastre
MPU (unidad de protección de memoria)Una región de guarda de 32 bytes bajo la pila, no accesible: tocarla produce una falla inmediata (MemManage)En el instante del desborde; con la causa exacta
/* Pintado y medida de la pila (símbolos del enlazador: ver tema 29) */
extern uint32_t _estack, _sstack;                         /* límite alto y bajo de la pila, definidos en el script del enlazador */
#define PATRON 0xAAAAAAAAu

void pila_pintar(void) {                                  /* muy temprano, antes de usar mucha pila */
    uint32_t *p = &_sstack;
    uint32_t *sp;  __asm volatile ("mov %0, sp" : "=r"(sp));
    while (p < sp - 8) *p++ = PATRON;                     /* se deja un margen por encima del SP actual */
}
uint32_t pila_maximo_usado(void) {                        /* bytes usados como máximo desde el arranque */
    uint32_t *p = &_sstack;
    while (p < &_estack && *p == PATRON) p++;
    return (uint32_t)((uint8_t *)&_estack - (uint8_t *)p);
}
/* Se informa junto con el resto de la salud del equipo (tema 37) y se compara con el tamaño de la pila: si supera el 75 %, hay que actuar. */

La regla de oro: nunca confiar en una estimación de la pila; medirla y mantener la medida en el producto, informándola en la telemetría. Un firmware que usa el 90 % de su pila en la mesa va a desbordarla en el campo, el día que se combine la peor ruta con una interrupción en el peor momento.

03 · Pisar al vecino

Buffers y punteros

El lenguaje C no comprueba los límites. Copiar una cadena de 24 bytes a un buffer de 16 bytes funciona: escribe los 8 bytes sobrantes en lo que haya a continuación. La Parte 4 del programa arma el marco de una función con un buffer local de 16 B seguido de un registro guardado (R4) y de la dirección de retorno (LR) y copia una cadena de L bytes sin comprobar el largo:

Qué pisa un desborde del buffer local (resultado de la Parte 4).
Bytes copiadosSin canarioCon canario
16Nada: justo entraNada
17 a 20Pisa el registro guardado R4; el programa sigue «normal» hasta que se usa ese registroDetectado al volver de la función
21Cambia un solo byte de la dirección de retorno: el programa vuelve a otra función de la flash (0x08000C41 en lugar de 0x08000C5D), sin ninguna fallaDetectado al volver
24 o másLa dirección de retorno queda en 0x41414141: salta a una dirección sin código, y ocurre una falla (HardFault)Detectado al volver

La fila de 21 bytes es la más peligrosa: un error de un solo byte que no produce ninguna falla, y el programa continúa en el lugar equivocado; el síntoma es un comportamiento absurdo, sin ningún aviso. Y el caso de 24 o más es el que un atacante explota deliberadamente (tema 43): si en lugar de bytes «A» entra una dirección elegida, la función retorna a donde él quiera. Por eso estos errores son, además de fallos, vulnerabilidades.

/* Mal: nada limita la copia */
void guardar_nombre(const char *entrada) {
    char nombre[16];
    strcpy(nombre, entrada);                         /* si entrada tiene más de 15 caracteres, pisa la pila */
    /* ... */
}

/* Bien: se comprueba el largo y se rechaza lo que no entra (no se trunca en silencio sin avisar) */
bool guardar_nombre_ok(const char *entrada) {
    char nombre[16];
    size_t n = strnlen(entrada, sizeof nombre);
    if (n == sizeof nombre) return false;            /* no hay lugar para el terminador: entrada demasiado larga */
    memcpy(nombre, entrada, n + 1);
    return true;
}

/* Canario del compilador (GCC): inserta una palabra entre los locales y la dirección de retorno y la verifica al salir */
/*   arm-none-eabi-gcc -fstack-protector-strong ...  y definir __stack_chk_guard y __stack_chk_fail (esta última, un reinicio con informe) */

Los punteros producen su propia colección: el puntero nulo o sin inicializar (apunta a cualquier parte), el colgante (apunta a una variable local de una función que ya terminó: la dirección sigue siendo válida, pero ese espacio de pila lo reutiliza la próxima llamada), la aritmética de punteros fuera de rango (un índice que se pasa en uno) y el uso después de liberar con memoria dinámica (tema 31). El análisis de estos errores combina tres cosas: leer el PC apilado y los bits de CFSR (tema 45), mirar con un watchpoint quién escribe en la zona dañada (tema 45) y, sobre todo, prevenirlos: longitudes comprobadas, punteros inicializados, ningún retorno de la dirección de una variable local, y la MPU para marcar como no ejecutable la RAM y como inaccesible la página cero.

04 · Dos hilos de ejecución

Condiciones de carrera

Un programa con interrupciones tiene, en realidad, dos o más hilos de ejecución: el programa principal y cada ISR, que puede interrumpirlo entre cualquier par de instrucciones. Cuando ambos tocan el mismo dato, el resultado depende del orden exacto en que se intercalan: una condición de carrera. El caso clásico es incrementar un contador compartido, algo que en C es una sola línea y en el procesador son tres pasos:

volatile uint32_t eventos;                 /* compartido entre main y una ISR */

void main_tarea(void) { eventos++; }       /* LDR r0,[eventos]  /  ADDS r0,#1  /  STR r0,[eventos]   (leer, sumar, escribir) */
void TIM2_IRQHandler(void) { eventos++; }  /* lo mismo, desde la interrupción */

/* Si la ISR llega entre el LDR y el STR de main_tarea: la ISR lee N, escribe N+1 y vuelve;
   main_tarea sigue con su copia vieja (N), suma 1 y escribe N+1: el incremento de la ISR se perdió. */

Notar que volatile no arregla esto: obliga al compilador a leer y escribir la memoria cada vez, pero no hace que el leer-sumar-escribir sea indivisible. Es una confusión frecuente (tema 16): volatile resuelve que el compilador no guarde el dato en un registro; la atomicidad es otro asunto. La Parte 1 simula el contador con el programa principal incrementando 100 000 veces y una ISR que llega al azar entre los pasos: sin protección se pierden 1885 de los incrementos (alrededor de 2/3 de los de la ISR) porque llegar entre la lectura y la escritura son 2 de las 3 posiciones posibles; con una sección crítica (las interrupciones deshabilitadas durante los tres pasos) no se pierde ninguno.

Ahora lo importante: ¿por qué en el equipo real este error es esporádico? Porque la ventana real es de unos pocos ciclos dentro de un programa que casi no toca el dato. Con una ventana de 3 ciclos a 48 MHz (62,5 ns), una ISR de 1 kHz y una actualización por segundo, la probabilidad de colisión es 6,25 × 10-5 por segundo: un fallo cada 4,4 horas. En una prueba de 10 minutos, el error se ve con una probabilidad de 3,7 %: el firmware «pasa». Con diez mil equipos funcionando, ocurre 54 000 veces por día. Es el escenario típico: un producto que pasa todas las pruebas y falla en el campo; no por mala suerte, sino por aritmética.

Protecciones contra la condición de carrera y su precio.
TécnicaCómoPrecio
Sección críticaDeshabilitar interrupciones alrededor del acceso (PRIMASK), guardando y restaurando el estado anterior (tema 16)Retrasa las ISR el tiempo que dure la sección: hay que mantenerla de pocas instrucciones
Operación atómicaInstrucciones de lectura/escritura exclusivas (LDREX/STREX en Cortex-M3 y superiores; stdatomic.h en C11): si hubo una interferencia, se reintentaNo retrasa la ISR; no existe en los Cortex-M0 (allí, una sección crítica)
Un solo escritorDiseñar para que cada dato lo modifique un solo contexto; el otro solo lo leeNinguno: es la solución más limpia cuando se puede
Cola o doble bufferLa ISR deposita datos en una cola (tema 22) y el programa principal los consume; o dos buffers que se intercambianMemoria y un poco de código; resuelve datos compuestos
Una bandera de «hay datos»La ISR solo levanta una bandera (una escritura de una palabra, indivisible); el programa principal hace el trabajoNinguno si la bandera es de una sola palabra y se limpia con cuidado

La estrategia preferida es la de menos compartir: cuanto menos datos compartidos entre ISR y programa principal, menos carreras posibles. Lo compartido se identifica, se anota (un comentario «compartido con TIM2_IRQ») y se protege de una de las formas anteriores, siempre.

05 · Un dato en pedazos

Lecturas desgarradas y datos compuestos

Una variante más sutil es la lectura desgarrada (torn read): un dato que no cabe en una sola operación de acceso se lee en dos o más pasos, y una ISR lo cambia a la mitad. El ejemplo clásico es un contador de 16 bits en un procesador de 8 bits, que se lee byte por byte: la Parte 2 lo explora todo. Si el contador pasa de 0x00FF a 0x0100 entre la lectura del byte bajo (0xFF) y la del byte alto (ya 0x01), el resultado es 0x01FF: un error de 256 cuentas que ocurre en 256 de las 65 536 lecturas posibles (0,39 %), justo en el cambio del byte alto. Parece un contador que «a veces da saltos enormes».

En un Cortex-M las variables de 32 bits alineadas se leen y escriben en una sola operación indivisible, así que el problema se traslada a lo que no es indivisible: datos de más de una palabra (un contador de 64 bits, una marca de tiempo, una estructura con la posición y la hora de una medición) y conjuntos de variables que deben ser coherentes entre sí (la temperatura y el instante en que se midió). El síntoma es una medición con la hora de la anterior, o una posición con una coordenada nueva y otra vieja.

/* Marca de tiempo de 64 bits actualizada por una ISR cada milisegundo: dos palabras, lectura desgarrable */
static volatile uint32_t t_alto, t_bajo;

void SysTick_Handler(void) { if (++t_bajo == 0) t_alto++; }

/* Lectura segura 1: leer, comprobar y repetir (sin deshabilitar interrupciones) */
uint64_t tiempo_ms(void) {
    uint32_t a1, b, a2;
    do { a1 = t_alto;  b = t_bajo;  a2 = t_alto; } while (a1 != a2);   /* si el alto cambió, hubo desbordamiento durante la lectura */
    return ((uint64_t)a1 << 32) | b;
}

/* Lectura segura 2: sección crítica corta */
uint64_t tiempo_ms_sc(void) {
    uint32_t pm = __get_PRIMASK();  __disable_irq();
    uint64_t t = ((uint64_t)t_alto << 32) | t_bajo;
    __set_PRIMASK(pm);
    return t;
}

/* Datos compuestos: la ISR llena un buffer y publica con un número de secuencia (patrón «seqlock») */
typedef struct { volatile uint32_t seq; int32_t temperatura; uint32_t instante; } Muestra;
Muestra m;
void adc_isr(void) { m.seq++;  m.temperatura = leer_adc();  m.instante = ahora();  m.seq++; }     /* seq impar: escritura en curso */
bool muestra_leer(int32_t *t, uint32_t *i) {
    uint32_t s1 = m.seq;
    if (s1 & 1) return false;                                   /* se está escribiendo: reintentar luego */
    *t = m.temperatura;  *i = m.instante;
    return m.seq == s1;                                         /* si seq cambió, la lectura es inválida */
}

La lectura con comprobación (alto, bajo y alto otra vez) corrige la desgarradura del ejemplo: en la Parte 2, tras repetir la lectura si el byte alto cambió, las lecturas incorrectas bajan de 256 a 0. El patrón general es el mismo en los tres casos: o se hace el acceso indivisible (sección crítica), o se lo hace verificable (leer y comprobar que nada cambió) y se repite si falló. Nunca se confía en que «la ISR no va a caer justo ahí»: va a caer, tarde o temprano, como acaba de mostrar la cuenta.

06 · Cuando la aritmética engaña

Errores numéricos y comportamiento indefinido

Otra familia produce fallos deterministas pero escondidos: el código hace exactamente lo que dice, pero no lo que el programador cree.

Errores numéricos y de lenguaje habituales.
ErrorQué pasaCómo evitarlo
Desborde de enteros sin signo255 + 1 = 0 en un uint8_t; da la vuelta (definido, pero casi nunca intencional)Usar el ancho suficiente; comprobar antes de sumar cuando importe
Desborde de enteros con signoComportamiento indefinido en C: el compilador puede suponer que no ocurre y optimizar de forma inesperadaAritmética sin signo para contadores; comprobar los límites antes
Mezcla de signosint comparado con uint32_t: el valor negativo se convierte en un número enorme; if (x < 0) sobre un sin signo nunca es verdaderoAdvertencias (-Wsign-compare, -Wconversion); tipos coherentes
Contador de tiempo que da la vueltaUn contador de 32 bits en milisegundos se desborda a los 49,7 días; if (ahora > limite) falla en esa fechaComparar con diferencias con signo: (int32_t)(ahora - limite) >= 0 (tema 17)
División por ceroEn Cortex-M3 y superiores da cero si no se activó la trampa; en un M0 depende de la bibliotecaValidar el divisor; activar DIV_0_TRP en desarrollo para que falle a la vista
Variables sin inicializarUna variable local contiene lo que quedó en la pila: depende de lo que corrió antesInicializar todo; -Wuninitialized; -Wmaybe-uninitialized
Comparar flotantes con igualdad0.1f + 0.2f == 0.3f es falso por el redondeo (tema 32)Comparar con una tolerancia, o usar enteros escalados
Desplazamientos fuera de rango1 << 32 en un uint32_t es indefinidoComprobar el rango del desplazamiento

El comportamiento indefinido merece una advertencia aparte: es el caso en que el lenguaje no define qué debe pasar, y el compilador está autorizado a hacer cualquier cosa, incluida la de eliminar una comprobación que «no puede ocurrir». Un código que se comporta distinto con -O0 y con -O2 es la señal clásica. La defensa son las advertencias del compilador activadas al máximo (-Wall -Wextra -Wconversion), el análisis estático y, sobre todo, ejecutar la lógica en la PC con sanitizers (-fsanitize=undefined,address), que detectan los desbordes y los accesos fuera de límites en la ejecución (tema 47).

07 · El reloj como causa

Fallos de tiempo y de estado

Dos familias más cierran el catálogo. Los fallos de tiempo son los que dependen de la carga: una ISR que dura más que su período pierde interrupciones (tema 44, donde un printf la rompía), una prioridad mal elegida retrasa algo urgente, una espera activa larga bloquea todo lo demás, una inversión de prioridad hace que una tarea importante espere a una poco importante que tiene un recurso (tema 22), y un interbloqueo deja a dos tareas esperándose para siempre. Su firma es que funcionan en la mesa y fallan cuando el sistema está ocupado: se buscan con pines de prueba y analizador, midiendo duraciones y latencias reales (tema 44), no con el depurador, que detiene el programa y borra la carga.

Los fallos de estado son los de una máquina de estados (tema 24) en una transición que nadie previó: un evento que llega en un estado donde no se esperaba, una variable que no se reinició al volver a empezar, un reinicio que restaura el procesador pero no la memoria no inicializada o el periférico (que quedó configurado de la ejecución anterior). Su firma es que el equipo falla solo después de una secuencia específica: «después de apagar el motor y encender la radio dentro de un segundo». Se atacan con el registro de eventos (tema 44), que cuenta qué secuencia llevó al fallo; con una máquina de estados completa (cada estado define qué hace con cada evento, con un caso default que registra el error); y con aserciones (tema 39) que cierran los estados imposibles.

Y el hardware merece una sospecha sistemática antes de tocar el código (tema 44): un reinicio aleatorio es, antes que nada, un hundimiento de la alimentación o un watchdog (tema 37); una comunicación con basura, un reloj o una masa; un fallo que cambia con la temperatura, un componente al límite de su margen. El análisis de la causa de reinicio, que se guarda al arrancar, separa de entrada lo que es software de lo que no.

08 · La parte difícil

Cómo reproducir lo que no se deja reproducir

Un error que no se reproduce no se arregla: se «cree» arreglado. El truco es convertir la probabilidad baja en una probabilidad alta sin cambiar la causa. Como la probabilidad de colisión es proporcional a la ventana, a la tasa de interrupciones y a la tasa de uso del dato, se pueden subir esos factores a propósito:

  • Aumentar la frecuencia de la interrupción. Si la ISR corre a 1 kHz, probar a 10 kHz multiplica la probabilidad por diez (el laboratorio de abajo lo muestra: de un fallo cada 4,4 horas a uno cada 27 minutos).
  • Aumentar el uso del dato. Hacer que el programa principal lo actualice cien veces por segundo en vez de una.
  • Ensanchar la ventana a propósito. Insertar unas instrucciones (un NOP, un retardo corto) entre la lectura y la escritura. Si el error aparece mucho más seguido, la teoría de la carrera queda confirmada: es la mejor prueba de la hipótesis.
  • Forzar la interrupción en el peor momento. Disparar la ISR por software (NVIC_SetPendingIRQ) en un punto elegido; o, con el depurador, detener el núcleo entre la lectura y la escritura y disparar la ISR a mano.
  • Reducir los recursos. Una pila más chica que el cálculo (o con el pintado, el uso real medido) hace aparecer un desborde que no se ve con la pila holgada; un buffer más corto, el desborde que se está sospechando.
  • Estresar el entorno: alimentación al límite inferior, temperatura alta y baja, carga máxima de comunicaciones, todas las ISR activas a la vez.
  • Entradas aleatorias (fuzzing): lanzar secuencias de entradas al azar o malformadas (tramas con largos absurdos, comandos en orden inverso) y registrar la que causa la falla.
  • Automatizar y repetir: un banco de pruebas que ejecuta la secuencia miles de veces y cuenta las fallas convierte «a veces» en «tres de cada mil», un número que se puede comparar antes y después de una corrección.

Y la contrapartida estadística, que importa al validar una corrección: si un error ocurre una vez cada 4,4 horas, ver cero fallos en 10 minutos no demuestra nada (la probabilidad de verlo era 3,7 %). Para tener un 95 % de confianza de que un error con esa tasa se corrigió, hay que correr unas tres veces el tiempo medio entre fallos (cerca de 13 horas, porque 1 − e-3 ≈ 95 %). La forma de validar de verdad es aumentar la probabilidad con las técnicas anteriores y verificar que, con las mismas condiciones forzadas, el fallo ya no aparece.

09 · Después del fallo

Análisis posterior y prevención

Cuando el fallo ya ocurrió, el análisis recorre un camino ordenado, que reúne lo de los dos temas anteriores:

  1. Capturar el estado antes de reiniciar: PC, LR, SP, CFSR (tema 45), registro de eventos y causa del reinicio (tema 37). Es lo que más rápido se pierde.
  2. Clasificar con la tabla de familias: ¿PC fuera de la flash o SP fuera de la RAM (memoria)? ¿Falla impreciso o dependiente de la carga (concurrencia, tiempo)? ¿Tras mucho tiempo encendido (numérico, contador que da la vuelta, fuga)? ¿Tras una secuencia (estado)?
  3. Formular una hipótesis con la teoría de la familia y probarla forzando las condiciones (apartado anterior).
  4. Corregir la causa (no el síntoma) y escribir la prueba que reproduce el fallo, que desde entonces se ejecuta siempre (tema 47).
  5. Buscar el mismo error en otras partes: si un buffer se desbordó en una función, ¿cuántas copias de ese patrón hay? Un error de un tipo casi siempre tiene hermanos.
Herramientas de prevención y detección, de la más barata a la más costosa.
HerramientaQué detectaCosto
Advertencias del compilador (-Wall -Wextra -Wconversion -Wstack-usage)Variables sin inicializar, mezcla de signos, funciones con marcos grandesNinguno: se activan siempre y se tratan como errores
Análisis estático (cppcheck, clang-tidy, las reglas MISRA C)Accesos fuera de rango, punteros nulos, código inalcanzable, patrones peligrososEjecutarlo en la integración continua; algunos falsos positivos
Aserciones y canariosViolaciones de invariantes y desbordes, en el momento (tema 39)Unos ciclos y bytes; se pueden dejar en producción con un reinicio informado
MPUAccesos a regiones prohibidas: pila, página cero, ejecución desde RAMConfiguración inicial; en Cortex-M3 y superiores (y M0+ en algunos modelos)
Sanitizers en la PC (address, undefined, thread)Desbordes, accesos inválidos, comportamiento indefinido, carrerasRequiere poder compilar la lógica en la PC (temas 10 y 47)
Revisión de códigoTodo lo anterior, y los errores de diseñoTiempo de las personas: de lo mejor invertido
Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
HardFault con el PC fuera de la flashDirección de retorno pisada: desborde de un buffer local o de la pilaMirar los buffers locales de las funciones en la pila de llamadas; pintado de la pila; canario
Una variable global cambia solaDesborde de un arreglo vecino, o la pila avanzó sobre ellaWatchpoint de escritura (tema 45); revisar el orden de las variables en el .map
Contador que a veces salta o cuenta de menosLectura desgarrada o actualización perdidaLeer con comprobación, sección crítica o un solo escritor
Falla solo con muchas interrupcionesPila insuficiente por anidamiento, o ISR demasiado largaMedir la pila con el peor caso; pin de prueba en las ISR
El equipo falla después de 49 díasContador de milisegundos de 32 bits que da la vueltaComparaciones con diferencia con signo; probar adelantando el contador
El resultado difiere entre -O0 y -O2Comportamiento indefinido o falta de volatileAdvertencias y sanitizers; revisar los datos compartidos con ISR
Se «arregló» al agregar un retardo o un printfUna carrera o un problema de pila que el cambio escondeNo es una corrección: reproducir con el tiempo ensanchado y corregir la causa

10 · Analogía con programación

Probar el modelo en Python

El programa reúne cuatro piezas del tema: la simulación del contador compartido con una ISR que llega entre los pasos, y la cuenta de cada cuánto ocurre eso en un equipo real; las lecturas desgarradas de un contador de 16 bits; el presupuesto de pila de un firmware a partir de su grafo de llamadas, con el pintado de la pila; y el desborde de un buffer local sobre la dirección de retorno, con y sin canario.

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

# Fallos típicos: carrera entre una ISR y el programa principal, lectura desgarrada, presupuesto de pila y desborde de un buffer sobre la dirección de retorno.
import random, math

# ---- Parte 1: actualización perdida en un contador compartido (leer-modificar-escribir en tres pasos) ----------------------------------
rnd = random.Random(46)
def simular(N, p, protegido):
    contador = esperados = 0
    for _ in range(N):
        esperados += 1                                             # el incremento del programa principal
        diferidas = 0
        for k in range(3):                                         # tres pasos: leer, sumar, escribir; la ISR puede llegar antes de cada uno
            if rnd.random() < p:
                esperados += 1                                     # incremento de la ISR
                if protegido and k > 0: diferidas += 1             # sección crítica: la ISR espera a que termine la escritura
                else: contador += 1
            if k == 0: r = contador
            elif k == 1: r += 1
            else: contador = r
        contador += diferidas
    return contador, esperados
N, p = 100_000, 0.01
print("Parte 1: un contador compartido que el programa principal y una ISR incrementan (leer, sumar, escribir)")
for protegido in (False, True):
    c, e = simular(N, p, protegido)
    print(f"  {'con sección crítica' if protegido else 'sin protección     '}: esperado {e}, quedó {c}, perdidos {e - c} ({(e - c) / e * 100:.2f} % de los incrementos)")
isr_total = 3 * p * N
print(f"  sin protección se pierden unos 2/3 de los incrementos de la ISR ({2 / 3 * isr_total:.0f} de {isr_total:.0f} esperados): llegar entre la lectura y la escritura es 2 de 3 posiciones posibles")
F_MHZ, VENTANA, ISR_HZ, ACT_HZ = 48, 3, 1000, 1                    # SUPUESTOS: 3 ciclos entre leer y escribir, ISR de 1 kHz, el programa actualiza el dato una vez por segundo
lam = ACT_HZ * ISR_HZ * VENTANA / (F_MHZ * 1e6)
print(f"  en un equipo real: ventana de {VENTANA} ciclos a {F_MHZ} MHz, ISR a {ISR_HZ} Hz, una actualización por segundo -> {lam:.2e} colisiones por segundo")
print(f"  tiempo medio entre fallos {1 / lam / 3600:.1f} h; probabilidad de verlo en una prueba de 10 min: {(1 - math.exp(-lam * 600)) * 100:.1f} %")
print(f"  con 10 000 equipos funcionando, {10_000 * lam * 86_400:,.0f} fallos por día".replace(",", " "))

# ---- Parte 2: lectura desgarrada de un contador de 16 bits en un procesador de 8 bits ----------------------------------------------------
def leer_desgarrado(v):                                            # la ISR incrementa el contador entre la lectura del byte bajo y la del alto
    bajo = v & 0xFF
    nuevo = (v + 1) & 0xFFFF
    return (nuevo & 0xFF00) | bajo
print()
print("Parte 2: leer un contador de 16 bits con dos lecturas de 8 bits mientras una ISR lo incrementa")
errores = [v for v in range(65536) if leer_desgarrado(v) not in (v, (v + 1) & 0xFFFF)]
print(f"  lecturas incorrectas: {len(errores)} de 65536 ({len(errores) / 65536 * 100:.2f} %), justo cuando el byte bajo pasa de 0xFF a 0x00")
ejemplo = errores[0]
print(f"  ejemplo: el contador pasa de 0x{ejemplo:04X} a 0x{ejemplo + 1:04X}; se lee 0x{leer_desgarrado(ejemplo):04X} (error de {leer_desgarrado(ejemplo) - ejemplo} cuentas)")
malas = 0
for v in range(65536):
    alto1, bajo = v >> 8, v & 0xFF
    despues = (v + 1) & 0xFFFF                                     # la ISR incrementa tras leer el byte bajo
    alto2 = despues >> 8
    if alto1 != alto2:                                             # el alto cambió: se descarta y se repite la lectura
        alto1, bajo, despues = despues >> 8, despues & 0xFF, despues
    lectura = (alto1 << 8) | bajo
    if lectura not in (v, (v + 1) & 0xFFFF, (v + 2) & 0xFFFF): malas += 1
print(f"  leyendo alto, bajo y alto otra vez (y repitiendo si el alto cambió): lecturas incorrectas {malas}")

# ---- Parte 3: presupuesto de pila a partir de un grafo de llamadas -------------------------------------------------------------------------
marcos = {"main": 32, "tarea_sensor": 48, "procesar_muestra": 96, "filtrar": 64, "formatear_trama": 232, "snprintf": 320,
          "enviar_trama": 56, "uart_escribir": 40}
llamadas = {"main": ["tarea_sensor", "enviar_trama"], "tarea_sensor": ["procesar_muestra"], "procesar_muestra": ["filtrar", "formatear_trama"],
            "formatear_trama": ["snprintf"], "enviar_trama": ["uart_escribir"], "filtrar": [], "snprintf": [], "uart_escribir": []}
def peor(f):
    mejor, camino = 0, []
    for g in llamadas[f]:
        d, c = peor(g)
        if d > mejor: mejor, camino = d, c
    return marcos[f] + mejor, [f] + camino
base, camino = peor("main")
ISR_MARCO, ISR_NIVELES = 32 + 48, 2                                # 32 B apilados por el hardware + 48 B del manejador; 2 niveles de interrupción anidados
total = base + ISR_MARCO * ISR_NIVELES
print()
print("Parte 3: pila necesaria en el peor caso")
print(f"  camino más profundo: {' -> '.join(camino)} = {base} B")
print(f"  más {ISR_NIVELES} niveles de interrupción de {ISR_MARCO} B = {ISR_MARCO * ISR_NIVELES} B: peor caso {total} B; con 25 % de margen, {math.ceil(total * 1.25)} B")
for tam in (1024, 2048):
    print(f"  pila de {tam} B: {'ALCANZA' if tam >= total * 1.25 else 'NO alcanza' if tam < total else 'alcanza sin margen'} (libre en el peor caso: {tam - total} B)")
inicio_rec = marcos["main"] + marcos["tarea_sensor"] + marcos["procesar_muestra"] + ISR_MARCO * ISR_NIVELES
FRAME_REC = 24
print(f"  una función recursiva de {FRAME_REC} B por nivel, llamada desde procesar_muestra, con la pila de 1024 B: profundidad máxima {(1024 - inicio_rec) // FRAME_REC} niveles")
PINTADO = 0xAA
pila = bytearray([PINTADO]) * 2048
for i in range(total):                                             # la ejecución del peor caso escribe desde el extremo alto hacia abajo
    pila[2047 - i] = 0x11
libres = next(i for i, b in enumerate(pila) if b != PINTADO)
print(f"  pintado de la pila (2048 B con 0x{PINTADO:02X}): tras el peor caso quedan {libres} B sin tocar; marca máxima de uso {2048 - libres} B")

# ---- Parte 4: un buffer local que pisa la dirección de retorno, con y sin canario -------------------------------------------------------------
CANARIO = 0xDEADBEEF
def llamar(carga, canario):
    n_buf = 16
    marco = bytearray(n_buf + (4 if canario else 0) + 4 + 4)       # buffer | [canario] | R4 guardado | LR guardado
    off = n_buf
    if canario: marco[off:off + 4] = CANARIO.to_bytes(4, "little"); off += 4
    marco[off:off + 4] = (0x11111111).to_bytes(4, "little"); r4 = off; off += 4
    marco[off:off + 4] = (0x08000C5D).to_bytes(4, "little"); lr = off
    marco[0:len(carga)] = carga[:len(marco)]                       # el "strcpy" sin comprobar el largo
    lr_v = int.from_bytes(marco[lr:lr + 4], "little")
    r4_v = int.from_bytes(marco[r4:r4 + 4], "little")
    can_ok = (not canario) or int.from_bytes(marco[n_buf:n_buf + 4], "little") == CANARIO
    return r4_v, lr_v, can_ok
print()
print("Parte 4: copiar una cadena de L bytes 'A' a un buffer local de 16 B sin comprobar el largo")
print(f"  {'L':>3} | {'sin canario: R4 / LR':<40} | con canario")
for L in (16, 17, 20, 21, 24, 28):
    r4a, lra, _ = llamar(b"A" * L, False)
    r4b, lrb, ok = llamar(b"A" * L, True)
    est_a = "intacto" if lra == 0x08000C5D and r4a == 0x11111111 else ("R4 pisado" if lra == 0x08000C5D else f"LR=0x{lra:08X}")
    est_b = "intacto" if ok and lrb == 0x08000C5D else ("DETECTADO al volver" if not ok else "LR pisado")
    print(f"  {L:>3} | {est_a:<40} | {est_b}")
print("  con el LR pisado por 0x41414141 el procesador salta a 0x41414140 (el bit 0 solo indica Thumb), una dirección sin código: una falla; con L = 21 solo cambia un byte y salta a otra función de la flash, sin ninguna falla")

Resultado, parte 1: con 100 000 incrementos del programa principal y una ISR al azar, sin protección se pierden 1885 incrementos (de 102 883 esperados) y con sección crítica, 0; en un equipo real (ventana de 3 ciclos a 48 MHz, ISR a 1 kHz, una actualización por segundo) hay 6,25 × 10-5 colisiones por segundo: un fallo cada 4,4 h, 3,7 % de probabilidad de verlo en 10 min y 54 000 fallos por día con 10 000 equipos. Parte 2: 256 de 65 536 lecturas incorrectas (0,39 %), por ejemplo 0x01FF al pasar de 0x00FF a 0x0100 (error de 256); leyendo alto, bajo y alto: 0. Parte 3: camino más profundo de 728 B (main, tarea_sensor, procesar_muestra, formatear_trama, snprintf) más 160 B de interrupciones: 888 B, 1110 B con 25 % de margen; una pila de 1024 B alcanza sin margen (136 B libres) y una de 2048 B sobra (1160 B libres); una recursión de 24 B admite 28 niveles; el pintado marca un uso de 888 B. Parte 4: desde 17 bytes se pisa R4 y con 21 cambia un byte del LR (0x08000C41, otra función de la flash, sin falla); con 24 o más el LR queda en 0x41414141; con canario se detecta desde los 17 bytes.

Probá cambiar en la Parte 1 la probabilidad p o la protección, ensanchar la ventana de 3 a 30 ciclos y ver cómo cae el tiempo entre fallos, agregar una función de 600 B al grafo de la Parte 3 y ver qué pila hace falta, o mover el canario de la Parte 4. La lección del programa es que estos fallos son cuentas, no misterios: la probabilidad de la carrera, el tamaño de la pila y lo que pisa un desborde se pueden calcular y anticipar antes de que el equipo salga de la mesa.

11 · Exploración interactiva

Por qué la prueba no lo vio

El laboratorio calcula, para un contador compartido entre una ISR y el programa principal, cada cuánto ocurre la colisión y con qué probabilidad la verías en una prueba de una duración dada, y qué ocurre en una flota de 10 000 equipos. Elegís la frecuencia de la interrupción, cuántas veces por segundo el programa actualiza el dato, la ventana (ciclos entre leer y escribir), la protección y la duración de la prueba. Se supone un reloj de 48 MHz y que la ISR llega en un instante aleatorio e independiente.

La probabilidad de una carrera

Colisiones esperadas—
Tiempo medio entre fallos—
Probabilidad de verlo en la prueba—
En 10 000 equipos—
Retraso que agrega a la ISR—

Elegí los parámetros.

Recorrido guiado

  1. Con los valores iniciales (1000 Hz, una actualización por segundo, 3 ciclos, sin protección, 10 minutos): 0,23 colisiones por hora, un fallo cada 4,4 horas, y la prueba lo detecta con una probabilidad de 3,7 %. En una flota de 10 000 equipos son 54 000 fallos por día. La prueba pasa, y el producto falla.
  2. Subí la duración de la prueba a 1 hora (20,1 % de probabilidad), 8 horas (83,5 %) y 24 horas (99,5 %): solo una prueba de varias veces el tiempo medio entre fallos lo ve con seguridad. Ese es el costo de buscar un error esporádico sin forzarlo.
  3. Ahora forzá las condiciones, como en la sección 8: subí la ISR a 10 000 Hz, las actualizaciones a 100 por segundo y la ventana a 40 ciclos: el fallo ocurre cada 1 segundo y la prueba de 10 minutos lo ve con certeza. Se pasó de «una vez cada 4,4 horas» a «cada segundo», sin cambiar la causa: ahora se puede reproducir, y se puede validar la corrección.
  4. Con 100 Hz, una actualización por segundo y 3 ciclos, una prueba de 24 horas solo tiene 41,7 % de probabilidad de verlo, y el tiempo medio es de 44,4 horas: cuanto menos frecuente la ISR, más oculto el error. Y, aun así, en 10 000 equipos son 5400 fallos por día.
  5. Elegí Sección crítica: ya no hay colisiones, y el precio es retrasar la ISR hasta 0,06 µs (3 ciclos). Con la ventana de 40 ciclos el retraso sube a 0,83 µs: la sección crítica debe ser corta.
  6. Elegí Operación atómica: tampoco hay colisiones, y no se retrasa la ISR (si hubo interferencia, la operación se reintenta). Es la mejor opción cuando el procesador la ofrece; en un Cortex-M0, no hay LDREX/STREX y queda la sección crítica.
Desafío: la prueba de aceptación dura 30 minutos y el cliente exige «cero fallos». ¿Qué diseñarías?

Una prueba corta solo puede detectar errores frecuentes: 30 minutos no demuestran la ausencia de uno que ocurre cada 4 horas. El diseño tiene dos partes. Primera: eliminar por construcción las carreras, de modo que no dependan de la suerte (un solo escritor por dato, las lecturas con comprobación, las protecciones de la tabla), y que eso se revise con una lista de todos los datos compartidos entre ISR y programa principal. Segunda: una prueba de estrés que fuerce las condiciones (ISR más rápidas, ventanas ensanchadas, actualizaciones más frecuentes: la sección 8) de modo que en 30 minutos haya, por ejemplo, un millón de colisiones potenciales: si con todo forzado se observa cero fallos, la evidencia es fuerte. Y se complementa con revisión de código y análisis estático, que no dependen del tiempo de prueba. Se le explica al cliente que «cero fallos en 30 minutos» mide la paciencia de la prueba, no la corrección del producto.

12 · Método de trabajo

Método: de la falla a la causa

  1. Preparate antes de que falle: pintado de la pila, manejador de HardFault que guarda el informe en RAM sin inicializar (tema 45), registro de eventos y causa de reinicio (tema 37), aserciones y canarios.
  2. Capturá el estado en cuanto ocurra la falla y antes de reiniciar: PC, LR, SP, CFSR, BFAR y los últimos eventos.
  3. Clasificá el síntoma en una familia (memoria, concurrencia, numérico, tiempo, estado, hardware) con el mapa de la sección 1 y la tabla de síntomas.
  4. Calculá antes de buscar: el presupuesto de pila (peor caso más margen), la probabilidad de la carrera (ventana × tasas), el tiempo de desborde de los contadores (49,7 días en 32 bits a 1 ms).
  5. Forzá las condiciones para reproducir: más interrupciones, ventanas ensanchadas, pila reducida, entradas aleatorias, entorno extremo. Si el error se vuelve frecuente, la hipótesis está confirmada.
  6. Corregí por construcción: compartir menos, proteger lo compartido, comprobar largos, medir la pila, aritmética segura. Corregir «reduciendo la probabilidad» no es corregir.
  7. Escribí la prueba que reproduce el fallo con las condiciones forzadas, y mantenela en la suite (tema 47).
  8. Buscá a los hermanos del error: el mismo patrón en otras funciones y módulos.
  9. Documentá el fallo, su causa y su corrección, y agregá la regla al estándar de código del proyecto si corresponde.

13 · Comprensión

Confusiones frecuentes

«Con volatile el dato compartido ya es seguro»

No. volatile impide que el compilador guarde el valor en un registro entre accesos, pero un incremento sigue siendo leer, sumar y escribir, y una ISR puede caer en medio. Para la atomicidad hace falta una sección crítica, una operación atómica o un diseño de un solo escritor.

«Si el error aparece una vez cada varias horas, es mala suerte o un problema de hardware»

Casi siempre es una carrera cuya probabilidad es baja pero no nula. La cuenta es simple: ventana de pocos ciclos por tasa de interrupciones por tasa de uso del dato. Culpar al hardware sin comprobar la alimentación y la causa de reinicio es una forma de no mirar el software.

«Si la prueba larga no falló, el error no existe»

Depende de cuántas veces se alcanzó la condición. Una prueba de 10 minutos de un error que ocurre cada 4,4 horas lo ve con un 3,7 % de probabilidad. La ausencia de fallos solo prueba algo si se sabe con qué probabilidad se habría visto.

«Un desborde de buffer siempre produce un HardFault»

No: el caso de 21 bytes de la Parte 4 cambia un solo byte de la dirección de retorno y el programa vuelve a otra función de la flash, sin ninguna falla. Los desbordes más dañinos son los que no se anuncian, y por eso importan los canarios y las comprobaciones de largo.

«Mi pila de 1 KB alcanza porque nunca falló»

Nunca falló con las rutas y las interrupciones que pasaron. El peor caso (la ruta más profunda y dos interrupciones anidadas en el peor momento) puede no haber ocurrido todavía. Hay que calcular el peor caso, medir la marca máxima y dejar un margen.

«Agregué un retardo y el problema desapareció, así que estaba bien»

El retardo movió la sincronización y escondió la carrera. El fallo sigue, con otra probabilidad. Se reproduce de nuevo con un retardo en la otra dirección (ensanchando la ventana), y se corrige la causa.

«Los contadores de 32 bits nunca se desbordan en la práctica»

Un contador de milisegundos de 32 bits da la vuelta a los 49,7 días, y los equipos embebidos suelen estar encendidos meses. Las comparaciones de tiempo deben hacerse con la diferencia con signo, y conviene probarlas adelantando artificialmente el contador cerca de su límite.

«Un sanitizer o un análisis estático reemplaza las pruebas en el equipo»

Los complementa: encuentran errores de lógica y de memoria muy rápido en la PC, pero no ven el hardware, las interrupciones reales ni los tiempos. Se necesitan los dos tipos de verificación.

14 · Práctica guiada

Ejercicios

1. Presupuesto de pila

La ruta más profunda de un programa suma marcos de 24, 56, 120 y 280 bytes. Hay tres niveles de interrupciones anidadas, de 80 bytes cada una (cuadro apilado y manejador). Con un 20 % de margen, ¿qué pila hace falta? ¿Alcanza una de 1 KB?

Ver solución

Ruta: 24 + 56 + 120 + 280 = 480 B. Interrupciones: 3 × 80 = 240 B. Peor caso: 720 B. Con un 20 % de margen: 720 × 1,2 = 864 B. Una pila de 1 KB (1024 B) alcanza (sobran 304 B en el peor caso y 160 B incluso después del margen). Y habría que verificarlo con el pintado de la pila en pruebas de estrés.

2. Probabilidad de la carrera

Un dato se actualiza 10 veces por segundo en el programa principal, con una ventana de 10 ciclos a 48 MHz. Una ISR de 2 kHz lo modifica. ¿Cuántas colisiones por segundo se esperan y cada cuánto tiempo?

Ver solución

Ventana: 10 / 48 × 106 = 208,3 ns. Colisiones por segundo = 10 (actualizaciones) × 2000 (ISR por segundo) × 208,3 × 10-9 s = 4,17 × 10-3 por segundo. Tiempo medio: 1 / 0,00417 ≈ 240 s = 4 minutos. Un fallo cada cuatro minutos es detectable con una prueba larga; con ventanas de 3 ciclos, una vez por semana y con actualizaciones más espaciadas, no.

3. Lectura desgarrada

Un contador de 16 bits vale 0x12FF y se lee byte bajo y luego byte alto. Una ISR lo incrementa entre las dos lecturas. ¿Qué valor se obtiene y cuál es el error? ¿Y si se leyera primero el alto y después el bajo?

Ver solución

El contador pasa a 0x1300. Se lee el bajo 0xFF (antes del incremento) y después el alto 0x13 (ya incrementado): resultado 0x13FF, que supera al valor real (0x12FF o 0x1300) en 256 cuentas. Si se leyera primero el alto (0x12) y después el bajo (0x00, ya incrementado), saldría 0x1200: un error de −255 respecto de 0x12FF. En los dos órdenes hay una lectura imposible; la solución es leer alto, bajo y alto otra vez, y repetir si el alto cambió, o usar una sección crítica.

4. Cuánto probar

Se corrigió un error que ocurría una vez cada 6 horas en promedio. ¿Cuánto hay que probar sin ver fallos para estar un 95 % seguro de que se corrigió? ¿Y si se forzaran las condiciones para que ocurriera cada 6 minutos?

Ver solución

Si el error persistiera, la probabilidad de no verlo en un tiempo T es e-T/6 h. Para que esa probabilidad sea del 5 % se necesita T = −ln(0,05) × 6 h ≈ 3 × 6 h = 18 horas sin fallos. Con las condiciones forzadas a un fallo cada 6 minutos: T ≈ 3 × 6 min = 18 minutos. Es la ventaja de forzar: la misma confianza en una sesenta-ava parte del tiempo, a condición de que el forzado no cambie la causa.

5. Un byte de más

Una función tiene un buffer local de 32 bytes, seguido de la dirección de retorno. Se copia una cadena de 33 caracteres (32 más el terminador). ¿Qué ocurre si no hay nada más entre el buffer y la dirección de retorno, y qué ocurre si hay un canario de 4 bytes?

Ver solución

Los 33 bytes ocupan los 32 del buffer y un byte más: el primero de lo que haya a continuación. Sin canario, ese byte es el menos significativo de la dirección de retorno, que queda cambiada en un solo byte (por ejemplo 0x08000C5D pasa a 0x08000C00): la función vuelve a un lugar cercano pero equivocado, sin ninguna falla inmediata. Con un canario, ese byte pisa el primero del canario: la comprobación al salir lo detecta y el programa informa el error antes de volver. Es el motivo de que un «error de uno» (un terminador olvidado) sea tan peligroso.

6. El contador de los 49 días

Un contador de milisegundos de 32 bits sin signo se compara con if (ahora >= limite). ¿Cuándo falla y cómo se corrige? ¿Cómo se prueba sin esperar 49 días?

Ver solución

232 ms = 4 294 967 296 ms ≈ 49,71 días. Cerca de la vuelta, un limite calculado como ahora + 5000 puede dar la vuelta y quedar chico: ahora >= limite pasa a ser verdadero de inmediato (el plazo expira al instante) o, del otro lado, no se cumple nunca hasta la próxima vuelta. Se corrige con la diferencia con signo: if ((int32_t)(ahora - limite) >= 0), válida mientras los plazos sean menores de unos 24,8 días. Para probarlo, se adelanta el contador en un build de prueba a, por ejemplo, 0xFFFFF000 y se verifica que los plazos que cruzan la vuelta se comportan bien, o se hace la prueba en la PC con la lógica del tema 17.

15 · Síntesis

Ideas para recordar

  • Los fallos embebidos caen en unas pocas familias (memoria, concurrencia, numéricos, tiempo, estado, hardware) y cada una deja una firma. Clasificar el síntoma acorta la búsqueda. Sin sistema operativo que proteja y con el tiempo como parte del programa, los errores son silenciosos y no deterministas.
  • La pila crece hacia las variables y las pisa sin avisar. Se calcula el peor caso con el grafo de llamadas (728 B de camino más 160 B de interrupciones = 888 B en el ejemplo; una pila de 1 KB alcanza sin margen), se mide con el pintado (la marca máxima de uso) y se protege con un canario o una región de guarda de la MPU. La recursión se evita.
  • Un desborde de buffer pisa lo que hay al lado: desde el registro guardado hasta la dirección de retorno. Un solo byte de más puede cambiar el retorno a otra función sin ninguna falla. Se previenen con longitudes comprobadas y canarios, y son también vulnerabilidades (tema 43).
  • Una condición de carrera entre ISR y programa principal pierde actualizaciones (2/3 de las de la ISR en la simulación) porque leer-sumar-escribir son tres pasos. volatile no lo arregla. Se resuelve con una sección crítica corta, una operación atómica, un solo escritor o una cola; y lo mejor es compartir menos.
  • Las lecturas desgarradas afectan a todo dato que no se lee en una sola operación (contadores de 16 bits en 8 bits, marcas de tiempo de 64 bits, estructuras): 256 de 65 536 lecturas erróneas en el ejemplo. Se corrigen leyendo con comprobación (alto, bajo y alto otra vez), con sección crítica o con un número de secuencia.
  • Los errores numéricos (desbordes, mezcla de signos, contador de 49,7 días, variables sin inicializar) y el comportamiento indefinido son deterministas pero engañosos; se atrapan con advertencias del compilador, análisis estático y sanitizers en la PC.
  • Un error esporádico es una probabilidad baja, no nula: ventana × tasa de interrupciones × tasa de uso. Con una ventana de 3 ciclos, 1 kHz y una actualización por segundo hay un fallo cada 4,4 horas, la prueba de 10 minutos lo ve el 3,7 % de las veces, y una flota de 10 000 equipos lo sufre 54 000 veces por día. Para reproducirlo se aumentan esos factores a propósito (ISR más rápidas, ventana ensanchada, pila reducida, entradas aleatorias), y para validar la corrección se prueba con las condiciones forzadas.
  • Preparar el equipo antes del fallo (pintado de pila, informe de HardFault en RAM sin inicializar, registro de eventos, causa de reinicio, aserciones) convierte un cuelgue en un dato. Y toda corrección termina con una prueba que reproduce el fallo y con la búsqueda de sus hermanos en el resto del código.

¿Listo para el tema siguiente?

Antes de avanzar, revisá un proyecto tuyo y respondé: ¿cuántos datos se comparten entre ISR y programa principal, y cuáles están protegidos?, ¿cuánta pila usa el peor caso y cuánto mediste de verdad?, ¿hay algún strcpy o memcpy sin comprobar el largo?, ¿qué pasa en tu equipo a los 49,7 días? Si no sabés responder alguna, esa es la próxima revisión que vale la pena hacer.

En el próximo tema pasamos de encontrar errores a evitar que lleguen: las pruebas del firmware, con pruebas unitarias en la PC, simulación, hardware en el lazo e integración continua.