01 · El problema de fondo
Por qué no avisan
En una PC, el sistema operativo y una unidad de hardware (la MMU) le dan a cada programa su propio espacio de direcciones y marcan lo que le pertenece. Tocar una dirección ajena provoca un segmentation fault: el programa se detiene en el acto y en el lugar del error. Un microcontrolador típico no tiene nada de eso. Hay un solo espacio de direcciones plano, sin dueños: la pila, las variables, los registros de los periféricos y hasta la memoria de otra tarea están todos al alcance de cualquier instrucción. El lenguaje C, por su parte, no verifica los límites de un arreglo ni la validez de un puntero: confía en el programador.
El resultado es que la mayoría de los errores de memoria no producen ningún error en el momento en que ocurren. Escriben un byte en una dirección válida del chip, que simplemente no era la que debían. El daño queda latente hasta que alguien lee ese byte, a veces segundos o semanas después, y el programa falla en una línea que no tiene nada de malo. Esto hace que la depuración (tema 44) sea enormemente más difícil: el error y el síntoma están separados en el tiempo y en el código.
| Error | Qué ocurre | Síntoma típico |
|---|---|---|
| Desborde de buffer | Se escribe más allá del final de un arreglo | Variables vecinas que cambian solas; saltos a direcciones absurdas |
| Desborde de pila | La pila crece más de lo reservado y pisa otra zona | Datos corruptos, fallas intermitentes, reinicios sin causa |
| Puntero nulo | Se accede a la dirección 0 con un puntero sin inicializar o un NULL sin chequear | Lecturas de basura o falla de bus, según el chip |
| Puntero colgante | Se usa un puntero a algo que ya no existe (local de una función que retornó, bloque liberado) | Datos que «cambian solos»; funciona en pruebas y falla en campo |
| Doble liberación | Se libera dos veces el mismo bloque | Corrupción del heap; fallas lejos del free |
| Variable sin inicializar | Se lee una variable local antes de escribirla | Comportamiento distinto en cada ejecución o con cada compilador |
| Acceso desalineado | Se lee un dato de 32 bits en una dirección que no es múltiplo de 4 | HardFault en algunos núcleos; lectura lenta en otros |
| Desborde de entero en un índice | Un cálculo da la vuelta y produce un índice fuera de rango | Acceso a un elemento que no existe |
| Escritura en memoria de solo lectura | Se escribe en una constante o en la flash | Sin efecto, o una falla de bus |
02 · El clásico
Desbordamiento de buffer
Un desbordamiento de buffer (buffer overflow) ocurre cuando se escriben más datos de los que caben en un arreglo, y el excedente pisa lo que está a continuación en memoria. Lo que está a continuación depende de dónde vive el buffer: si es una variable global, son otras variables; si es una variable local, son los datos del marco de la pila, incluida la dirección de retorno de la función.
Los desbordes se producen siempre de las mismas maneras. Estos son los patrones que conviene reconocer a simple vista en una revisión de código:
char nombre[8];
/* 1. Funciones de la biblioteca que no conocen el tamaño del destino */
strcpy(nombre, recibido); /* ¿cuánto mide 'recibido'? nadie lo comprueba */
sprintf(nombre, "Equipo %d", numero); /* "Equipo 12345" ya son 12 caracteres + terminador */
gets(nombre); /* no existe forma segura: por eso se eliminó del estándar */
/* 2. El error de uno: el terminador también ocupa un byte */
char saludo[5];
strcpy(saludo, "HOLA!"); /* 5 letras + '\0' = 6 bytes en un arreglo de 5 */
for (int i = 0; i <= 8; i++) nombre[i] = 0; /* '<=' escribe nombre[8], que no existe */
/* 3. Una longitud que viene de afuera y nadie validó */
uint8_t largo = mensaje[1]; /* el largo lo dice el emisor del mensaje... */
memcpy(nombre, &mensaje[2], largo); /* ...y puede mentir: 200 bytes en un buffer de 8 */
La forma correcta de cada uno es la misma idea: el tamaño del destino viaja siempre junto con el destino, y se comprueba antes de copiar.
/* 1. Funciones con límite, y comprobando si hubo truncamiento */
int n = snprintf(nombre, sizeof nombre, "Equipo %d", numero);
if (n < 0 || (size_t)n >= sizeof nombre) { /* el texto no entró: decidir qué hacer (truncar, rechazar, registrar) */ }
/* 2. Los límites con 'sizeof', no con números escritos a mano */
for (size_t i = 0; i < sizeof nombre; i++) nombre[i] = 0;
/* 3. Validar todo largo que venga de afuera, ANTES de usarlo */
uint8_t largo = mensaje[1];
if (largo > sizeof nombre || (size_t)largo + 2 > largo_total_del_mensaje) {
contar_error(ERR_LARGO_INVALIDO); /* se rechaza, se cuenta y se sigue */
return false;
}
memcpy(nombre, &mensaje[2], largo);
La función strncpy suele proponerse como «la versión segura» de strcpy, y no lo es del todo: si el origen es más largo que el límite, no escribe el terminador, y el resultado no es una cadena válida. Hay que agregarlo a mano (nombre[sizeof nombre - 1] = '\0') o usar snprintf, que siempre lo escribe.
El desborde de buffer no es solo un problema de fiabilidad: es la vulnerabilidad de seguridad más conocida. Quien controla los datos que se copian puede elegir los bytes que pisan la dirección de retorno, y con ella decidir qué código se ejecuta cuando la función termina. El tema 43 la retoma desde el punto de vista de la seguridad; acá importa que la misma defensa (límites siempre comprobados, y un canario) sirve contra los dos problemas, el accidente y el ataque.
El canario (stack canary, por el canario de las minas de carbón) es una palabra con un valor conocido que el compilador coloca entre los buffers locales y la dirección de retorno, y que verifica justo antes de retornar. Como cualquier desborde que llegue a la dirección de retorno tiene que atravesarlo primero, si su valor cambió es que hubo un desborde, y la función no usa la dirección de retorno: llama a una rutina de falla. Con GCC se activa con -fstack-protector-strong, pero en un sistema sin sistema operativo hay que proveer las dos piezas que el compilador espera:
/* canario.c: las dos piezas que -fstack-protector espera encontrar */
uintptr_t __stack_chk_guard = 0xDEADBEEF; /* el valor del canario; idealmente aleatorio, tomado del generador del chip al arrancar */
void __attribute__((noreturn)) __stack_chk_fail(void) {
registrar_falla(FALLA_CANARIO); /* guardar la causa donde sobreviva al reinicio (.noinit, tema 29) */
sistema_reiniciar(); /* un reinicio controlado es mejor que seguir con la pila corrupta */
}
El canario detecta, no previene: el daño ya ocurrió (el buffer vecino, el propio canario) y lo único que se evita es el salto a una dirección corrupta. Y detecta tarde, al retornar, y solo los desbordes que llegan hasta él: uno que pisa una variable local vecina antes del canario pasa desapercibido. Es una capa útil, no una garantía; por eso se combina con la comprobación de límites en el código.
03 · Cuando se agota la pila
Desbordamiento de pila
El tema 29 mostró cómo estimar la pila necesaria y cómo medir su uso real. Acá vemos qué pasa cuando se agota. Una pila desbordada es un caso particular de desborde de memoria, pero con una diferencia que la hace más traicionera: crece hacia abajo, hacia las direcciones donde viven otros datos (el heap, o las variables de .bss y .data si no hay nada en el medio). No pisa lo que está «a continuación» de un arreglo, sino que invade lo que había debajo de ella.
Las causas habituales, de más a menos frecuente:
- Arreglos locales grandes: un
char linea[512]o unfloat muestras[256]declarado dentro de una función. - Funciones de formato:
printfysprintfcon coma flotante pueden necesitar cientos de bytes de pila por sí solas. - Recursión sin una cota de profundidad conocida.
- Interrupciones anidadas que se suman al peor camino del programa principal.
- Pilas de tareas subdimensionadas en un RTOS (tema 20): cada tarea tiene la suya, y cualquiera puede desbordarse sin que las otras lo noten.
Los síntomas son los de cualquier corrupción: variables que cambian solas, comportamientos que dependen de qué otra cosa se ejecutó antes, y fallos de aparición irregular que desaparecen al agregar una línea de depuración (que cambia el tamaño de los marcos). Una buena pista: si el programa se comporta distinto cuando se cambia el nivel de optimización o se agrega un printf, sospechá de la pila.
Cómo se detecta
| Técnica | Cómo funciona | Cuándo detecta |
|---|---|---|
| Pintado y marca de agua (tema 29) | Se llena la pila con un patrón y se mide hasta dónde fue pisado | Después: al revisar la marca. No evita nada, pero da el margen real |
| Canario en el fondo de la pila | Una palabra con valor conocido en el límite inferior; se la verifica periódicamente (en el tick, o al cambiar de tarea) | Con un retraso de hasta un período de verificación |
| Verificación del RTOS | FreeRTOS: configCHECK_FOR_STACK_OVERFLOW en 1 (compara el puntero de pila al cambiar de tarea) o 2 (además verifica los últimos bytes pintados); llama a vApplicationStackOverflowHook | Al cambiar de contexto; el método 2 es más seguro pero más lento |
| Región de guarda con la MPU | Una zona sin acceso debajo de la pila: cualquier escritura en ella dispara una falla | En el acto, en la instrucción que desbordó |
En un RTOS, además, la función uxTaskGetStackHighWaterMark() informa de cada tarea el mínimo de pila libre que tuvo desde su creación, en palabras: es la herramienta para dimensionar cada pila con datos, y el equivalente por tarea del pintado. Un valor cercano a cero es una tarea al borde del desborde.
04 · Direcciones que no son lo que parecen
Punteros inválidos
Un puntero es una dirección, y el hardware no tiene forma de saber si la dirección es sensata. Los errores con punteros se agrupan en cinco familias:
/* 1. Sin inicializar: el puntero contiene basura y se usa como dirección */
uint8_t *p;
*p = 0x55; /* escribe en una dirección cualquiera: puede ser un registro de un periférico */
/* 2. Nulo sin comprobar */
Sensor *s = sensor_obtener(indice); /* devuelve NULL si el índice es inválido (tema 30) */
s->estado = LISTO; /* desreferencia la dirección 0 */
/* 3. Colgante: apunta a algo que ya no existe */
char *nombre_equipo(void) {
char texto[16];
snprintf(texto, sizeof texto, "EQ-%04u", numero_serie);
return texto; /* devuelve la dirección de una local: al retornar, la pila la reutiliza */
}
/* 4. Usado después de liberar */
free(bloque);
bloque->campo = 1; /* el bloque ya no es nuestro */
/* 5. Fuera de rango por aritmética */
uint8_t *fin = tabla + CANTIDAD;
for (uint8_t *q = tabla; q <= fin; q++) *q = 0; /* '<=' recorre un elemento de más */
La dirección cero merece una nota aparte, porque en un microcontrolador no se comporta como en una PC. En una PC, la dirección 0 nunca está mapeada y tocarla es un error seguro. En un Cortex-M, la dirección 0 suele ser un alias de la flash (donde está la tabla de vectores, tema 8): leer un puntero nulo devuelve datos válidos (el valor inicial de la pila, por ejemplo) y no produce ningún fallo, y escribir puede no hacer nada o producir un fallo de bus según el chip. Por eso un NULL sin comprobar en firmware es especialmente peligroso: el programa sigue con un dato que parece válido pero no lo es.
Otra fuente de fallas son los accesos desalineados. Un dato de 32 bits debe estar en una dirección múltiplo de 4. Los núcleos Cortex-M3 y M4 toleran muchos accesos desalineados (con un costo de ciclos), pero los Cortex-M0 y M0+ producen un HardFault. Es un error clásico al interpretar un mensaje recibido como estructura:
uint8_t rx[16]; /* el mensaje llegó en un arreglo de bytes */
uint32_t mal = *(uint32_t *)&rx[1]; /* rx[1] no está alineado a 4: HardFault en un Cortex-M0, y comportamiento
indefinido en C aunque «ande» en otro chip */
uint32_t bien;
memcpy(&bien, &rx[1], sizeof bien); /* copia byte a byte lo que haga falta: correcto en cualquier núcleo
(el compilador lo optimiza a una lectura directa si es seguro) */
La regla de oro para punteros: un puntero tiene que estar siempre en uno de dos estados, «apunta a algo válido» o «es NULL y se lo comprueba». Se inicializa al declararlo, se anula tras liberar (bloque = NULL), no se devuelve nunca la dirección de una local y se prefiere pasar tamaño e índices antes que aritmética de punteros libre. Con arreglos estáticos (tema 30), un índice validado contra una constante reemplaza a la mayoría de los punteros.
05 · La misma familia
Otros errores del mismo tipo
Cuatro errores más producen los mismos síntomas (corrupción silenciosa, fallas lejos de la causa) y se tratan con las mismas defensas:
- Variables locales sin inicializar. Una local no tiene valor definido: contiene lo que haya dejado en la pila la última función que usó ese lugar. El programa puede andar durante meses porque «casi siempre» esa basura es cero, y fallar el día que no. Se evita inicializando al declarar, y se detecta con
-Wall -Wextra(el compilador avisa de muchos casos con la optimización activada). - Desborde de enteros en índices. Un contador de 8 bits que da la vuelta, o una resta de enteros sin signo que da un número enorme (
indice - 1cuandoindicevale 0), producen un índice fuera de rango. En enteros con signo, el desborde es comportamiento indefinido en C: el compilador puede suponer que no ocurre y eliminar comprobaciones. - Escribir en memoria de solo lectura. Una constante en la flash (tema 28) no se puede modificar. Escribir en ella no corrompe la constante, pero puede no producir efecto alguno o una falla de bus, y el programa cree haber guardado un dato que no se guardó. Los literales de texto asignados a
char *son la fuente habitual: se declaraconst char *para que el compilador lo impida. - Carreras de datos. Dos contextos (la ISR y el programa principal, dos tareas) que modifican el mismo dato sin protección producen valores a medio escribir (temas 16 y 22). No es un error de dirección, pero corrompe datos del mismo modo y se diagnostica con las mismas dificultades.
/* Un índice con 'unsigned' que se pasa de largo */
uint8_t indice = 0;
tabla[indice - 1] = 0; /* indice - 1 se promueve a int: vale -1. tabla[-1] pisa lo que está ANTES de la tabla */
uint32_t anterior(uint32_t i) { return i - 1; } /* anterior(0) = 4 294 967 295: un índice gigante */
/* Bien: comprobar el límite explícitamente, y con el tipo correcto */
if (i == 0 || i > CANTIDAD) { contar_error(ERR_INDICE); return; }
valor = tabla[i - 1];
06 · El hardware como auditor
La MPU: hacer que el hardware avise
Los Cortex-M de gama media y alta incluyen una MPU (Memory Protection Unit, unidad de protección de memoria): una versión simplificada de la MMU de una PC. No traduce direcciones ni hace memoria virtual; solo marca regiones del espacio de direcciones con permisos. Cuando una instrucción intenta un acceso que la región no permite, la MPU lo bloquea y dispara una excepción de falla (MemManage fault) en esa misma instrucción, antes de que el daño ocurra. Convierte el error silencioso en un error ruidoso y exacto.
Cada región tiene una dirección base, un tamaño, permisos de acceso (lectura, escritura, y distintos para el código privilegiado y el no privilegiado) y un bit de XN (execute never: prohibido ejecutar código desde ahí). Con eso se pueden expresar políticas útiles:
| Política | Región | Qué errores atrapa |
|---|---|---|
| Guarda de pila | Una zona de 32 bytes sin acceso, justo debajo del fondo de la pila | Desborde de pila: falla en la instrucción que lo produce |
| Flash de solo lectura | La flash con lectura y ejecución, sin escritura | Escrituras por un puntero errático sobre el código |
| RAM sin ejecución | La RAM con lectura y escritura y XN activado | Un salto a datos corruptos que serían «ejecutados» como código |
| Zona de guarda de un buffer | 32 bytes sin acceso inmediatamente después de un buffer crítico | Desborde de ese buffer: falla en el primer byte de más |
| Aislar una tarea | Con un RTOS con soporte de MPU: cada tarea con acceso solo a su pila y sus datos | Una tarea defectuosa que pisa a otra |
| Dirección nula | Una región sin acceso en las direcciones bajas (el alias de la flash) | Los punteros nulos, que de otro modo «funcionan» |
La MPU tiene reglas propias que hay que conocer antes de configurarla, y que el programa de Python de este tema reproduce:
- Cantidad limitada: típicamente 8 regiones (algunos Cortex-M7 y M33, 16). Hay que elegir qué proteger.
- Tamaño potencia de dos, de al menos 32 bytes, y base alineada al tamaño (en la arquitectura clásica del Cortex-M3/M4). Una RAM de 20 KB no puede ser una región: hay que cubrirla con una de 16 KB y otra de 4 KB.
- Si dos regiones se superponen, manda la de número más alto. Así se hace un «agujero»: una región grande con permiso y encima una chica de guarda sin permiso.
- Mapa de fondo: con el bit
PRIVDEFENA, el código privilegiado puede acceder a todo lo que no esté cubierto por una región (periféricos, por ejemplo); sin él, cualquier dirección sin región es una falla. - Hay que habilitar la falla: la excepción MemManage está desactivada por defecto, y una violación se escala a HardFault si no se la habilita.
/* Guarda de 32 bytes sin acceso debajo de la pila (Cortex-M3/M4, región 3) */
#define MPU_CTRL (*(volatile uint32_t *)0xE000ED94)
#define MPU_RNR (*(volatile uint32_t *)0xE000ED98) /* qué región se configura */
#define MPU_RBAR (*(volatile uint32_t *)0xE000ED9C) /* dirección base */
#define MPU_RASR (*(volatile uint32_t *)0xE000EDA0) /* atributos, tamaño y habilitación */
#define SHCSR (*(volatile uint32_t *)0xE000ED24)
void mpu_guarda_pila(uint32_t base) { /* 'base' debe ser múltiplo de 32 */
MPU_CTRL = 0; /* apagar la MPU mientras se configura */
MPU_RNR = 3; /* región número 3 */
MPU_RBAR = base;
MPU_RASR = (1u << 28) /* XN: no se ejecuta código desde aquí */
| (0u << 24) /* AP = 000: ningún acceso, ni siquiera privilegiado */
| (4u << 1) /* SIZE = 4: el tamaño es 2^(4+1) = 32 bytes */
| 1u; /* habilitar la región */
SHCSR |= (1u << 16); /* habilitar la excepción MemManage */
MPU_CTRL = (1u << 2) | 1u; /* PRIVDEFENA + ENABLE */
__DSB(); __ISB(); /* esperar a que la configuración surta efecto */
}
/* En el linker script la guarda tiene que existir: reservar 32 bytes alineados a 32 debajo de _estack - tamaño_de_pila */
La MPU tiene límites que conviene tener claros. Solo protege entre regiones: un desborde que se queda dentro de una región con permiso (un buffer que pisa a la variable de al lado, en la misma RAM) pasa de largo, salvo que se intercale una guarda. La guarda puede saltearse: un marco de pila grande que solo escribe en su parte baja puede pasar por encima de 32 bytes sin tocarlos (lo muestra la Parte 3 del programa), así que la guarda es más eficaz cuanto más ancha, y se complementa con el cálculo del peor caso (tema 29). Y existe un costo de configuración y de mantenimiento, que lo paga quien agrega una variable global y debe reubicar una región. Es una herramienta potente, no una solución automática.
07 · Cuando el hardware dice «no»
Diagnosticar un HardFault
Cuando la MPU bloquea un acceso, o cuando el procesador encuentra una instrucción inválida, un acceso desalineado o un error de bus, se produce una excepción de falla, y si no se la maneja, termina en el HardFault. La rutina por defecto es un bucle infinito: el equipo se «cuelga» y no dice nada. Una buena rutina de falla, en cambio, convierte esa muerte muda en un informe. Lo que hace falta saber es dónde estaba el programa y qué falló, y el procesador lo guarda por vos: al entrar a una excepción, apila automáticamente ocho registros (tema 15), incluido el PC de la instrucción responsable.
/* Un HardFault_Handler que informa: elige la pila que estaba en uso y llama a una función en C */
void HardFault_Handler(void) {
__asm volatile (
"tst lr, #4 \n" /* bit 2 de EXC_RETURN: ¿el programa usaba la pila MSP (0) o PSP (1)? */
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"b hardfault_c \n" /* r0 = puntero al marco apilado */
);
}
void hardfault_c(uint32_t *marco) {
/* marco[0..7] = R0, R1, R2, R3, R12, LR, PC, xPSR (lo apiló el hardware) */
FallaInfo f;
f.pc = marco[6]; /* la instrucción que falló o la siguiente */
f.lr = marco[5];
f.cfsr = *(volatile uint32_t *)0xE000ED28; /* causa: qué tipo de falla (tabla de abajo) */
f.mmfar = *(volatile uint32_t *)0xE000ED34; /* dirección del acceso, si MMARVALID */
f.bfar = *(volatile uint32_t *)0xE000ED38; /* dirección del error de bus, si BFARVALID */
guardar_falla_noinit(&f); /* sobrevive al reinicio (tema 29) */
sistema_reiniciar(); /* o: quedarse en un bucle con un depurador conectado */
}
El registro CFSR (Configurable Fault Status Register) dice qué pasó. Los bits que más se usan:
| Bit | Nombre | Significado | Causas típicas |
|---|---|---|---|
| 0 | IACCVIOL | Violación de acceso al buscar una instrucción (MPU) | Salto a una zona sin permiso de ejecución: datos corruptos usados como dirección de retorno |
| 1 | DACCVIOL | Violación de acceso a datos (MPU) | Escritura en una guarda o en flash; puntero errático |
| 7 | MMARVALID | MMFAR contiene la dirección del acceso | — |
| 9 | PRECISERR | Error de bus preciso: se conoce la instrucción responsable | Acceso a una dirección que no existe |
| 10 | IMPRECISERR | Error de bus impreciso: se detectó después | Escritura en dirección inválida (el PC apilado no es el exacto) |
| 15 | BFARVALID | BFAR contiene la dirección del error | — |
| 16 | UNDEFINSTR | Instrucción indefinida | Se ejecutó basura: salto a datos o a una dirección corrupta |
| 17 | INVSTATE | Estado inválido del procesador | Salto a una dirección de función sin el bit 0 en 1 (modo Thumb) |
| 24 | UNALIGNED | Acceso desalineado | Interpretar bytes como un entero de 32 bits en una dirección impar |
| 25 | DIVBYZERO | División por cero (si está habilitado) | Un divisor que no se validó |
Con tres datos (el PC, el CFSR y la dirección de MMFAR o BFAR), un error de memoria pasa de «el equipo se colgó» a «la instrucción en 0x08001A3C intentó escribir en 0x20004BF8 y la MPU lo impidió». Con el .map (tema 29) y arm-none-eabi-addr2line -e firmware.elf 0x08001A3C se obtiene el archivo y la línea de código. El tema 45 muestra cómo ver todo esto con un depurador SWD, y el tema 37, cómo registrar la causa del reinicio posterior.
08 · Atrapar errores antes del campo
Detección temprana
Las técnicas anteriores detectan el error cuando ocurre en el equipo. Lo mejor es detectarlo antes de llegar ahí. Hay una batería de herramientas, que se aplican en distintos momentos del desarrollo:
| Cuándo | Herramienta | Qué encuentra |
|---|---|---|
| Al compilar | -Wall -Wextra -Wformat-security -Warray-bounds -Wstack-usage=N (y tratar las advertencias como errores) | Variables sin inicializar, índices fuera de rango evidentes, formatos peligrosos, marcos de pila sobre un límite |
| Al analizar | Análisis estático (cppcheck, clang-tidy, el analizador de GCC) | Fugas, uso tras liberar, desreferencias de NULL en caminos que el compilador no ve |
| En la PC | La lógica del firmware compilada para el escritorio, con -fsanitize=address,undefined (AddressSanitizer y UBSan) | Desbordes de buffer y de pila, uso tras liberar, doble liberación, desborde de enteros con signo, accesos desalineados: con línea y archivo exactos |
| En la PC, con azar | Pruebas con entradas al azar (fuzzing) sobre el analizador de mensajes y los protocolos, con los sanitizers activos | Longitudes y formatos que nadie pensó en probar; es lo que atrapa los desbordes por datos externos |
| En el equipo | Canarios, pintado de pila, MPU, assert (tema 39), contadores de errores | Lo que solo ocurre con el hardware real, los tiempos reales y las interrupciones |
| En el campo | Rutina de HardFault que registra el PC y el CFSR, y reinicio controlado (tema 37) | Lo que escapó a todo lo anterior: se vuelve diagnosticable a partir de un informe |
La tercera fila es la de mayor rendimiento y la más subutilizada. Los sanitizers no corren en un microcontrolador, pero la lógica del programa (analizadores, máquinas de estados, buffers, cálculos) sí se puede compilar para la PC si está separada del hardware, como se viene haciendo desde el tema 10 con las capas de abstracción y desde el tema 25 con el hardware simulado. Entonces, una prueba que alimenta el analizador de mensajes con cientos de miles de mensajes al azar, bajo AddressSanitizer, encuentra en segundos desbordes que en el equipo se manifestarían como corrupciones esporádicas, tras meses de uso.
/* test/test_mensajes.c: se compila para la PC con: gcc -g -fsanitize=address,undefined test_mensajes.c mensajes.c */
int main(void) {
uint8_t entrada[300];
srand(1);
for (unsigned n = 0; n < 500000; n++) {
size_t largo = (size_t)(rand() % sizeof entrada);
for (size_t i = 0; i < largo; i++) entrada[i] = (uint8_t)rand(); /* bytes al azar... */
if (largo > 1 && (rand() & 1)) entrada[1] = (uint8_t)(rand() % 250); /* ...a veces con un largo mentiroso */
mensaje_procesar(entrada, largo); /* si hay un desborde, AddressSanitizer se detiene aquí con línea y archivo */
}
return 0;
}
Si mensaje_procesar copia sin validar el largo, la primera entrada con un largo mentiroso la detiene con un informe del tipo «stack-buffer-overflow: WRITE of size 200», la línea exacta y qué buffer fue. Es el equivalente, para los errores de memoria, de lo que hizo la verificación con eventos al azar en el tema 27.
09 · Comprobar que funciona
Verificación y diagnóstico
| Qué verificar | Cómo | Resultado esperado |
|---|---|---|
| Que el canario realmente detecte | Provocar a propósito un desborde de un buffer local (en una prueba, no en el producto) | Se ejecuta __stack_chk_fail; la causa queda registrada; el equipo se reinicia |
| Que la guarda de la MPU funcione | Escribir a propósito en la zona de guarda con el depurador o en una función de prueba | Entra el HardFault o MemManage, con DACCVIOL y MMFAR igual a la dirección escrita |
| Que el informe de HardFault sea útil | Provocar un fallo (*(volatile int *)0x30000000 = 1;) y leer el informe tras el reinicio | PC, CFSR y dirección correctos, y un reinicio limpio |
| Que las pilas tengan margen | Pintado o uxTaskGetStackHighWaterMark con la peor carga | Al menos el 25-30 % libre en cada pila |
| Que las entradas externas estén validadas | Pruebas con entradas al azar y con valores límite bajo AddressSanitizer | Cero informes; las entradas inválidas se rechazan y se cuentan |
| Que el compilador no tenga quejas | Compilar con -Wall -Wextra -Werror | Cero advertencias |
| Que no haya punteros nulos sin comprobar | Revisar cada función que puede devolver NULL y cada llamada | Cada resultado se comprueba antes de usarse |
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Una variable cambia de valor sin que el código la toque | Un desborde de un buffer vecino, o de la pila | Mirar qué hay justo antes en el .map; poner un watchpoint en la variable con el depurador (tema 45); ver quién escribe |
El programa salta a una dirección absurda (0x41414141, 0xFFFFFFFF) | La dirección de retorno o un puntero a función fue pisado | Revisar los buffers locales de la cadena de llamadas; activar el canario; mirar el PC y el LR apilados |
Un fallo que desaparece al agregar un printf | Un error de memoria sensible a la distribución (pila, tamaños de marco o de datos) | No darlo por resuelto: medir la pila y usar sanitizers en la PC |
HardFault con UNALIGNED (bit 24) | Un acceso de 32 bits a una dirección no múltiplo de 4, típico en un Cortex-M0 | Reemplazar el cast de puntero por memcpy; alinear las estructuras |
HardFault con INVSTATE (bit 17) | Se saltó a una dirección con el bit 0 en 0 (no Thumb): puntero a función corrupto o mal convertido | Revisar la tabla de punteros a función y los casts; buscar quién escribe en esa tabla |
| Funciona en la mesa y falla en el campo con ciertos mensajes | Una longitud o un formato que nunca se probó | Fuzzing sobre el analizador, con sanitizers |
Un cambio de optimización (-O0 a -Os) rompe el programa | Comportamiento indefinido (índice fuera de rango, local sin inicializar) que el optimizador explota | Compilar con -fsanitize=undefined en la PC; revisar las advertencias |
10 · Analogía con programación
Probar el modelo en Python
El programa simula en pequeño lo que pasa en memoria. Tiene tres partes: un marco de pila de 24 bytes con un buffer de 8 bytes seguido de un contador y de la dirección de retorno, sobre el que se corre un strcpy sin límite con textos de distinta longitud; el mismo marco con un canario en lugar del contador; y un modelo de la MPU con las reglas de tamaño y alineación, las regiones de un chip de 64 KB de flash y 20 KB de RAM, y una zona de guarda bajo la pila.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
# Errores de memoria: un buffer que se desborda, el canario que lo detecta y una MPU que bloquea accesos.
CANARIO = [0xEF, 0xBE, 0xAD, 0xDE] # 0xDEADBEEF en little-endian
RETORNO = [0x51, 0x0A, 0x00, 0x08] # 0x08000A51: a dónde debe volver la función
def marco(con_canario):
"""Una 'pila' de 24 bytes: buf[8], luego el contador (o el canario), luego la dirección de retorno, luego otros datos."""
m = [0] * 24
m[8:12] = CANARIO if con_canario else [5, 0, 0, 0] # contador = 5
m[12:16] = RETORNO
return m
def strcpy_ingenuo(m, texto):
for i, b in enumerate(texto.encode() + b"\0"): # copia el texto Y el terminador, sin mirar el tamaño
if i < len(m): m[i] = b
def direccion(m, ini): return int.from_bytes(bytes(m[ini:ini + 4]), "little")
print("Parte 1: strcpy sin límite sobre un buffer de 8 bytes (contador = 5 justo después)")
for n in (4, 7, 8, 12, 20):
m = marco(False)
strcpy_ingenuo(m, "A" * n)
contador, ret = direccion(m, 8), direccion(m, 12)
estado = []
if contador != 5: estado.append(f"contador pasó de 5 a {contador:#x}")
if ret != 0x08000A51: estado.append(f"RETORNO pisado: la función volvería a {ret:#010x}")
print(f" {n:>2} letras ({n + 1:>2} bytes con el terminador): {'; '.join(estado) if estado else 'sin daño'}")
print()
print("Parte 2: el mismo desborde con canario (0xDEADBEEF) entre el buffer y la dirección de retorno")
for n in (4, 7, 8, 12, 20):
m = marco(True)
strcpy_ingenuo(m, "A" * n)
canario_ok = m[8:12] == CANARIO
if canario_ok: veredicto = "canario intacto: la función retorna con normalidad"
else: veredicto = f"CANARIO PISADO ({direccion(m, 8):#010x}): se detecta ANTES de usar la dirección de retorno (que vale {direccion(m, 12):#010x})"
print(f" {n:>2} letras: {veredicto}")
print()
print("Parte 3: una MPU de Cortex-M con regiones de potencia de dos")
def region_valida(base, tam):
return tam >= 32 and tam & (tam - 1) == 0 and base % tam == 0
for base, tam, nombre in ((0x20000000, 20 * 1024, "RAM entera de 20 KB"), (0x20000000, 16 * 1024, "RAM, primeros 16 KB"),
(0x20004000, 4 * 1024, "RAM, últimos 4 KB"), (0x20004BE0, 32, "guarda de 32 B bajo la pila"), (0x20004BF0, 32, "guarda desalineada")):
print(f" {nombre:<30} base {base:#010x}, tamaño {tam:>6}: {'válida' if region_valida(base, tam) else 'INVÁLIDA'}")
REGIONES = [ # (número, base, tamaño, permisos, nombre); si dos coinciden, manda el número MÁS ALTO
(0, 0x08000000, 64 * 1024, "RX", "flash"),
(1, 0x20000000, 16 * 1024, "RW", "RAM baja"),
(2, 0x20004000, 4 * 1024, "RW", "RAM alta"),
(3, 0x20004BE0, 32, "--", "guarda de la pila"),
]
def acceso(direccion, tipo):
decisivo = None
for r in REGIONES:
if r[1] <= direccion < r[1] + r[2]: decisivo = r # la última que coincide es la de mayor número
if decisivo is None: return "FALLA (fuera de toda región)"
perm = decisivo[3]
if tipo == "X" and decisivo[4] != "flash": return f"FALLA (ejecutar en {decisivo[4]}: sin permiso de ejecución)"
if tipo == "W" and "W" not in perm: return f"FALLA (escribir en {decisivo[4]})"
if tipo == "R" and perm == "--": return f"FALLA (leer en {decisivo[4]})"
return f"ok ({decisivo[4]})"
for d, t, que in ((0x08000100, "R", "leer código"), (0x08000100, "W", "escribir en la flash"), (0x20004FF0, "W", "escribir en la pila"),
(0x20004BF8, "W", "escribir en la guarda"), (0x20004C00, "W", "escribir en el límite de la pila"),
(0x20001000, "X", "ejecutar desde RAM"), (0x30000000, "W", "escribir donde no hay región")):
print(f" {que:<30} {d:#010x} {t}: {acceso(d, t)}")
def marco_ok(ini, tam):
"""Un marco que escribe TODOS sus bytes (como uno que inicializa sus locales): falla si cualquiera cae en una zona prohibida."""
return all(acceso(a, "W").startswith("ok") for a in range(ini, ini + tam))
sp, marco_bytes, profundidad = 0x20005000, 64, 0
while marco_ok(sp - marco_bytes, marco_bytes):
sp -= marco_bytes; profundidad += 1
print(f" recursión con marcos de {marco_bytes} B desde 0x20005000: {profundidad} niveles seguros; el siguiente ({sp - marco_bytes:#010x} a {sp - 1:#010x}) toca la guarda -> FALLA")
salto = sp - 512 # una función con un arreglo local de 512 B que solo toca su byte más bajo
print(f" limitación: un marco de 512 B que solo escribe en {salto:#010x} (más abajo que la guarda) -> {acceso(salto, 'W')}: la guarda de 32 B se saltea")
Resultado, parte 1: con 4 y con 7 letras no hay daño (7 letras y el terminador ocupan justo los 8 bytes); con 8 letras el terminador, que es el noveno byte, cae en el contador y lo pasa de 5 a 0, el clásico error de uno; con 12 letras el contador pasa a 0x41414141 y el primer byte de la dirección de retorno se anula, de modo que la función volvería a 0x08000A00 en lugar de 0x08000A51; con 20 letras la dirección de retorno queda en 0x41414141. Nadie avisa en ningún caso. Parte 2: con el canario, 4 y 7 letras retornan con normalidad, y con 8, 12 y 20 letras el canario aparece pisado (0xDEADBE00 con 8 letras, 0x41414141 con 12 y 20) y se detecta antes de usar la dirección de retorno. Parte 3: una RAM de 20 KB no puede ser una región (no es potencia de dos), pero sí 16 KB en 0x20000000 y 4 KB en 0x20004000; la guarda de 32 bytes en 0x20004BE0 es válida y la de 0x20004BF0, no (desalineada). La flash se lee pero no se escribe; la escritura en la guarda falla; ejecutar desde la RAM falla (sin ejecución); una dirección sin región (0x30000000) falla. Una recursión con marcos de 64 bytes tiene 16 niveles seguros y el 17.º toca la guarda; pero un marco de 512 bytes que solo escribe en 0x20004A00 se saltea la guarda y no falla.
Probá cambiar el tamaño del buffer del marco (y los desplazamientos del contador y de la dirección de retorno) para ver cómo un buffer más grande cambia la longitud del texto que destruye la pila, o agregá en la Parte 3 una región de 32 bytes en la dirección 0 para convertir en falla los accesos a un puntero nulo.
11 · Exploración interactiva
Desbordar un buffer
El laboratorio es el marco de pila del Python: un buffer de 8 bytes, seguido de un contador (o de un canario) y de la dirección de retorno 0x08000A51. Elegís una protección, escribís un texto y se copia con strcpy. Las celdas naranja son las que el desborde pisó fuera del buffer, las verdes las que cambiaron dentro del buffer, y las rayadas, la zona de guarda de la MPU.
Un strcpy, cuatro defensas
Elegí una protección y copiá un texto.
Recorrido guiado
- Con Ninguna, apretá «7 letras»: entran justo (7 letras y el terminador ocupan los 8 bytes). Sin daño.
- Apretá «8 letras»: el terminador es el noveno byte y cae en la primera celda del contador, que pasa de 5 a 0. Una letra de más, el error de uno, y nadie lo avisa: el programa siguió como si nada y tiene un contador corrupto.
- Apretá «12 letras»: el contador queda en
0x41414141y el terminador pisa el primer byte de la dirección de retorno. La función volvería a0x08000A00: una dirección válida del chip pero equivocada, que ejecutaría código ajeno. - Apretá «20 letras»: la dirección de retorno es
0x41414141(las «A»). Al retornar, el procesador salta a una dirección inexistente: HardFault. Es la forma «ruidosa» del error, pero llegó mucho después y lejos de la línea delstrcpy. - Cambiá a Copia acotada y repetí con 12 y 20 letras: se guardan 7 caracteres y el terminador, nada se pisa. El costo es que el texto queda truncado, algo que el programa tiene que decidir cómo tratar (rechazar, registrar, truncar a propósito).
- Cambiá a Canario y copiá 8 letras: el canario pasa de
0xDEADBEEFa0xDEADBE00y el laboratorio lo marca. Con 12 y 20 letras, también. Fijate que la dirección de retorno sí quedó pisada, pero se detecta antes de usarla: el desborde no se evitó, solo se evitó su peor consecuencia. - Cambiá a Zona de guarda y copiá 8 letras: el noveno byte cae en la guarda y la MPU lo bloquea en el acto; el buffer tiene sus 8 letras y nada más se tocó. Es la única defensa que detiene el daño en el primer byte, con el costo de reservar y alinear la guarda.
Desafío: ¿cómo combinarías las defensas en un producto real?
Se usan en capas, cada una tapando los huecos de la anterior. Primera capa, el código: límites siempre comprobados (snprintf, tamaños con sizeof, validación de toda longitud externa), que evita la mayoría de los desbordes y es la única que los previene. Segunda capa, el compilador: -fstack-protector-strong y advertencias como errores, que atrapa los que se escaparon, aunque tarde. Tercera capa, la MPU: guarda de pila, flash de solo lectura y RAM sin ejecución, que detiene en el acto los accesos más graves. Cuarta capa, el diagnóstico: una rutina de HardFault que registre el PC y el CFSR y reinicie, para que cualquier fallo que llegue al campo deje un informe. Y por fuera de todo, en el desarrollo: pruebas con sanitizers en la PC y entradas al azar, que encuentran los errores antes de grabar el equipo. Ninguna capa alcanza sola; juntas, un error que escapa a una suele caer en la siguiente.
12 · Método de trabajo
Método: defenderse en capas
- Prevenir en el código. El tamaño del destino viaja con el destino. Se usan
snprintf,memcpycon tamaños verificados ysizeof. Toda longitud, índice o formato que venga de afuera se valida antes de usarse. - Inicializar todo. Variables locales al declararlas, punteros en
NULLo apuntando a algo válido, y punteros anulados trasfree. - Compilar con rigor.
-Wall -Wextra -Werror, más-Wstack-usagey las advertencias de formato. Una advertencia ignorada es un error esperando su día. - Sacar la lógica del hardware y probarla en la PC con AddressSanitizer y UBSan, con casos límite y entradas al azar. Es la técnica con mejor relación entre esfuerzo y errores encontrados.
- Medir la pila (pintado, marca de agua por tarea) con la peor carga, y dejar un 25-30 % de margen.
- Activar las defensas del equipo: canario, MPU con guarda de pila y flash de solo lectura, y la excepción MemManage habilitada.
- Escribir una buena rutina de HardFault desde el primer día: que registre el
PC, elCFSRy las direcciones de falla en.noinit, y reinicie. Es barata y convierte los fallos del campo en datos. - Provocar los errores a propósito para comprobar que las defensas funcionan: un canario que nunca se probó no se sabe si funciona.
- Tratar cada fallo como una pista, no como un incidente cerrado. Un reinicio que «se arregló solo» sin causa conocida es un error de memoria que sigue ahí.
13 · Comprensión
Confusiones frecuentes
«Si hubiera un error de memoria, el programa se caería»
En un microcontrolador casi nunca: no hay MMU que detecte el acceso. Un error de memoria suele corromper datos en silencio, y el programa «funciona» con valores equivocados. Una caída es el caso afortunado: indica el error, aunque lejos de su causa.
«Esa variable cambió: el error está donde se lee mal»
El error está donde se escribió mal, que suele ser otro lugar del programa. Para encontrarlo hay que mirar quién escribe en esa dirección: un watchpoint del depurador sobre la variable se detiene en la instrucción culpable, que es lo más rápido que existe.
«strncpy es la versión segura de strcpy»
Limita la cantidad de bytes, pero si el origen es tan largo como el límite o más, no escribe el terminador, y deja una cadena inválida que desborda en la primera lectura. Además rellena con ceros todo el destino, lo cual es lento. snprintf con la comprobación del valor devuelto es la opción más segura.
«El canario previene los desbordes»
Los detecta, y solo si llegaron a él. No evita el daño a lo que está antes del canario ni a los buffers vecinos. Lo único que impide es usar la dirección de retorno pisada. La prevención real está en comprobar los límites.
«Con la MPU configurada, los errores de memoria ya no son un problema»
La MPU atrapa los accesos que violan una región, pero un desborde que se queda dentro de una región con permiso (variables vecinas en la misma RAM) no la activa, y una guarda angosta puede saltearse. Es una capa muy valiosa, no una solución total.
«Un puntero nulo siempre provoca una falla»
En un PC, sí. En un Cortex-M, la dirección 0 suele ser un alias de la flash: leerla devuelve datos válidos y no produce fallo. El programa sigue con un dato que parece correcto. Por eso hay que comprobar los NULL explícitamente, y una región de la MPU sin acceso en las direcciones bajas puede convertirlos en fallas.
«Un acceso desalineado funciona en mi placa, así que está bien»
Funciona en un Cortex-M3/M4, que lo toleran, y falla con HardFault en un M0/M0+. Y en el estándar de C es comportamiento indefinido en todos los casos. Si el código debe ser portable (o el chip puede cambiar), se usa memcpy.
«Si lo probé con muchos datos y no falló, no hay desbordes»
Con datos «normales», casi ningún desborde se manifiesta: el problema aparece con la longitud o el formato que nadie pensó. Por eso hacen falta pruebas con entradas al azar y con valores límite, bajo herramientas que detecten el acceso fuera de rango aunque no cambie el resultado visible.
14 · Práctica guiada
Ejercicios
1. El error de uno
Un programa declara char destino[16]; y copia con strcpy una cadena de exactamente 16 caracteres. ¿Cuántos bytes se escriben? ¿Cuántos exceden el buffer? ¿Qué se pisa?
Ver solución
Se escriben 16 caracteres más el terminador: 17 bytes. Excede en 1 byte: el terminador '\0' cae justo en la posición siguiente al buffer, que pertenece a lo que haya a continuación en memoria (otra variable, un canario, la dirección de retorno). Como el valor escrito es cero, el daño depende de qué dato estaba ahí: poner a cero el byte bajo de un contador, o el de una dirección, como se vio con las 8 letras del laboratorio. Es el error de uno, el más común y el más sutil, porque el texto «cabía» si se cuentan solo los caracteres visibles.
2. Regiones de la MPU
Querés proteger con la MPU una RAM de 20 KB que empieza en 0x20000000. ¿Cabe en una región? ¿Cómo la cubrirías? ¿Es válida una guarda de 32 bytes en 0x20004BF0? ¿Y en 0x20004BE0?
Ver solución
20 KB (20 480 bytes) no es potencia de dos, así que no puede ser una región. Se cubre con dos: 16 KB en 0x20000000 y 4 KB en 0x20004000 (ambas con base múltiplo de su tamaño). Guarda en 0x20004BF0: 0x4BF0 / 0x20 = 607,5, no es múltiplo de 32, es inválida (desalineada). En 0x20004BE0: 0x4BE0 / 0x20 = 607, sí es múltiplo, es válida. La guarda se coloca encima de la región de RAM, en una región de número más alto, que prevalece sobre la inferior en la zona superpuesta.
3. El campo SIZE
El tamaño de una región de la MPU se codifica con un campo SIZE, donde el tamaño es 2SIZE+1 bytes. ¿Qué valor de SIZE corresponde a regiones de 32 bytes, 4 KB y 16 KB?
Ver solución
32 = 25, entonces SIZE + 1 = 5 y SIZE = 4. 4 KB = 4096 = 212, entonces SIZE = 11. 16 KB = 16 384 = 214, entonces SIZE = 13. El tamaño mínimo de 32 bytes corresponde a SIZE = 4, y por eso la guarda más chica que se puede definir es de 32 bytes.
4. Leer un informe de falla
Tres informes: (a) CFSR = 0x00000082, (b) CFSR = 0x00008200, (c) CFSR = 0x01000000. ¿Qué pasó en cada uno y qué dirección conviene mirar?
Ver solución
(a) 0x82 = bits 1 y 7: DACCVIOL (violación de acceso a datos, típica de la MPU) y MMARVALID: MMFAR contiene la dirección a la que se intentó acceder; es lo que hay que mirar, junto con el PC apilado. (b) 0x8200 = bits 9 y 15: PRECISERR (error de bus preciso) y BFARVALID: BFAR tiene la dirección inexistente a la que se accedió, y el PC apilado apunta a la instrucción responsable. (c) 0x01000000 = bit 24: UNALIGNED: un acceso desalineado; el PC apilado indica el lugar, y la causa suele ser un cast de bytes a un entero de 32 bits.
5. El canario fijo
El canario del apartado 2 vale siempre 0xDEADBEEF. ¿Por qué esto es una debilidad si el problema es un ataque, y no un accidente? ¿Qué se hace?
Ver solución
Si el valor es constante y público, quien controla los datos que desbordan puede escribir ese mismo valor en el lugar del canario y seguir con la dirección de retorno: el canario «intacto» no delata nada. Contra los accidentes basta cualquier valor poco probable (un desborde casual casi nunca reproduce 0xDEADBEEF); contra un ataque, el valor tiene que ser impredecible: se toma de un generador de números aleatorios del chip al arrancar, en cada reinicio. Con 32 bits aleatorios, adivinarlo en un intento tiene una probabilidad de 1 en 4294 millones. El tema 43 profundiza en este tipo de protección.
6. Dimensionar la pila de una tarea
Una tarea de FreeRTOS se creó con una pila de 128 palabras (512 bytes). Tras una prueba larga, uxTaskGetStackHighWaterMark devuelve 20. ¿Cuánta pila usó como máximo? ¿Qué margen tiene? ¿Qué harías?
Ver solución
La marca de agua es el mínimo de pila libre en palabras: 20. Usó como máximo 128 − 20 = 108 palabras = 432 bytes. Le quedaron 20 palabras = 80 bytes: un margen del 20 / 128 = 15,6 %, menor al 25-30 % recomendado. Conviene agrandar la pila (por ejemplo, a 160 palabras: 108 usadas y 52 libres, un 32 %) y repetir la prueba con la peor carga, porque la marca de agua solo refleja los caminos que se ejercitaron.
15 · Síntesis
Ideas para recordar
- Un microcontrolador típico tiene un espacio de direcciones plano, sin MMU, y C no verifica límites: casi ningún error de memoria produce un error en el momento. Corrompe en silencio y falla lejos de su causa.
- El desborde de buffer pisa lo que está a continuación (variables, canario, dirección de retorno). Los patrones son
strcpy/sprintf, el error de uno, y longitudes externas sin validar. La defensa es que el tamaño viaje con el destino y se compruebe siempre. - El canario detecta un desborde al retornar y evita usar una dirección de retorno pisada, pero no previene el daño previo;
-fstack-protector-strongnecesita__stack_chk_guardy__stack_chk_failen un sistema sin sistema operativo. - El desborde de pila invade las zonas de abajo. Se estima y se mide (tema 29) y se detecta con pintado, canario de fondo, verificación del RTOS o una guarda de MPU.
- Los punteros inválidos (sin inicializar, nulos, colgantes, tras liberar) se evitan con la regla «o apunta a algo válido o es NULL y se comprueba». La dirección 0 suele ser flash y no falla; los accesos desalineados fallan en Cortex-M0/M0+, y
memcpyes la salida portable. - La MPU marca regiones con permisos y detiene el acceso indebido en el acto. Sus reglas: pocas regiones, tamaño potencia de dos (mínimo 32 B) con base alineada, la de número más alto manda y hay que habilitar la excepción MemManage. Solo protege entre regiones, y una guarda angosta puede saltearse.
- Una buena rutina de HardFault (apila
PC,CFSR,MMFAR,BFARy reinicia) convierte un colgado mudo en un informe: «la instrucción en tal dirección accedió a tal otra». - Se defiende en capas: código con límites, compilación rigurosa, pruebas en la PC con sanitizers y entradas al azar, defensas en el equipo (canario, MPU) y diagnóstico en el campo. Ninguna capa alcanza sola, y cada defensa se comprueba provocando el error a propósito.
¿Listo para el tema siguiente?
Antes de avanzar, tomá una función de tu proyecto que reciba datos de afuera (un mensaje de la UART, un comando) y revisá tres cosas: ¿dónde se valida cada longitud o índice antes de usarse?, ¿qué pasa con un mensaje de largo cero, de 255 y con un largo que miente?, y ¿qué mostraría el equipo si esa función pisara la memoria de al lado? Si no tenés respuesta para alguna, ahí está el próximo error de memoria. Después compilá esa función en la PC con -fsanitize=address,undefined y alimentala con miles de mensajes al azar.
En el próximo tema cerramos el bloque de memoria con el costado constructivo: optimizar el uso de memoria. Cómo reducir el tamaño del código, qué poner en la flash, cómo leer el mapa para encontrar el exceso y qué hacen los niveles de optimización del compilador.