← Índice del curso

Sistemas Embebidos · Tema 30 · Bloque 6

Asignación estática y dinámica

En una computadora de escritorio, pedir memoria con malloc es lo más natural del mundo y casi nunca falla. En un microcontrolador que debe funcionar años sin reiniciarse, con unos pocos kilobytes de RAM, es una de las decisiones más peligrosas que se pueden tomar. Este tema explica por qué, cómo se llega a un fallo que ocurre un martes a las tres de la mañana después de seis meses de funcionamiento, y qué se hace en su lugar: asignación estática, pools de bloques fijos, arenas y buffers circulares.

01 · El menú completo

Tres maneras de obtener memoria

En el tema 29 vimos dónde queda cada cosa. Ahora miramos cómo se obtiene. Un programa en C tiene exactamente tres formas de conseguir un lugar donde guardar datos, y cada una tiene un momento distinto en que se decide el tamaño y se puede fallar:

Las tres formas de obtener memoria en C.
FormaDóndeCuándo se decideDuraciónCuándo y cómo puede fallar
Estática (globales, static).data o .bssAl compilarToda la vida del programaAl enlazar: si no entra, el enlazador lo dice. Nunca falla en ejecución
Automática (variables locales)Pila (o registros)Al compilar el tamaño, al ejecutar la profundidadHasta que la función retornaEn ejecución, sin aviso: el desborde de pila corrompe (tema 29)
Dinámica (malloc, free)HeapEn ejecuciónHasta que se la libera (si se la libera)En ejecución, de muchas maneras: falta de memoria, fragmentación, fugas, uso tras liberar

La tabla ya dice casi todo. La asignación estática tiene una propiedad extraordinaria: si el programa enlaza, la memoria está garantizada para siempre. No hay un caso raro en que «no queden» esos bytes. En cambio, la asignación dinámica traslada el problema de «¿alcanza la memoria?» del momento de compilar al de ejecutar, y ahí hay que responderlo con cada llamada, para cada combinación de situaciones posibles, durante años. Un sistema embebido típico no tiene a nadie delante para reiniciarlo cuando falla.

02 · Los cargos contra malloc

Qué tiene de malo malloc

No es que malloc «no ande»: anda perfectamente en el escritorio. Es que en un microcontrolador chocan seis propiedades suyas con los requisitos del tema 2:

Los seis problemas de la asignación dinámica en un sistema embebido.
ProblemaQué pasaPor qué duele en un microcontrolador
1. Puede fallar en ejecuciónmalloc devuelve NULL cuando no hay un hueco suficienteEl código tiene que manejar ese caso en cada llamada, y casi nadie lo prueba. Un NULL sin chequear es un acceso a la dirección 0 y una falla de ejecución
2. Tiempo no deterministaBuscar un hueco recorre una lista de bloques; cuánto tarda depende del estado del heapUn sistema de tiempo real (tema 13) necesita cotas de tiempo. Un malloc que a veces tarda 2 µs y a veces 200 µs rompe el plazo, y el peor caso es casi imposible de acotar
3. FragmentaciónCon el tiempo, el heap se parte en trozos chicos intercalados con bloques en usoPuede haber memoria libre de sobra y que aun así un pedido falle. Se apreciará en el apartado siguiente
4. Sobrecarga por bloqueCada bloque lleva una cabecera con su tamaño y enlaces, y se alinea a 8 bytesDe 8 a 16 bytes extra por bloque, según la biblioteca. Para mensajes de 20 bytes, es más de un tercio de desperdicio
5. Fugas y uso tras liberarOlvidar un free deja bloques perdidos; liberar dos veces o usar un bloque liberado corrompe el heapEn una PC, un programa que pierde 100 bytes por hora se reinicia el lunes. En un equipo que corre años, esa fuga lo mata. Y estos errores aparecen lejos de su causa
6. No es reentranteEl estado interno del heap (listas, contadores) se modifica sin protecciónLlamarlo desde una interrupción, o desde dos tareas a la vez, corrompe el heap. Con un RTOS hay que protegerlo con un bloqueo (tema 21), que agrega bloqueo y latencia

Estos problemas no son teóricos: son la razón por la que los estándares de programación para sistemas críticos los restringen. La regla 21.3 de MISRA C:2012 prohíbe usar las funciones de reserva y liberación de stdlib.h; la regla 3 de la guía «Power of 10» de la NASA/JPL exige no asignar memoria dinámicamente después de la inicialización. No son caprichos de la normativa: resumen décadas de fallos en el campo.

Ninguno de estos seis problemas aparece con una prueba de diez minutos en la mesa. La fragmentación y las fugas son fallos de acumulación: el sistema funciona perfectamente durante días o meses y después deja de hacerlo, y el cliente es quien lo descubre. Por eso no alcanza con «probé y andaba».

03 · El problema más traicionero

La fragmentación

La fragmentación es la situación en la que hay memoria libre suficiente, pero repartida en trozos demasiado chicos para atender un pedido. Se distinguen dos tipos:

  • Fragmentación externa: el espacio libre está dividido en huecos separados por bloques en uso. Es la que sorprende, y la que ilustra el diagrama de abajo.
  • Fragmentación interna: el bloque entregado es más grande que lo pedido (por la alineación, la cabecera o porque el pool tiene bloques fijos), y el sobrante se desperdicia dentro del bloque.
Fragmentación de un heap de 512 bytes Arriba, un heap de 512 bytes lleno con ocho bloques de 64 bytes numerados del 1 al 8. Abajo, el mismo heap después de liberar los bloques 2, 4, 6 y 8: quedan cuatro huecos de 64 bytes separados, con un total de 256 bytes libres. Un pedido que necesita 112 bytes seguidos no entra en ninguno de los huecos y falla, aunque haya 256 bytes libres en total. #1#2#3#4 #5#6#7#8 #1#3#5#7 libre 64libre 64libre 64libre 64 Un heap de 512 bytes Lleno: ocho bloques de 56 B útiles (64 B con cabecera y relleno) malloc(100) necesita 112 B seguidos ✗ no cabe en ningún hueco de 64 B Tras liberar #2, #4, #6 y #8: 256 B libres, mayor hueco = 64 B Fragmentación = 1 − mayor hueco / libre total = 1 − 64/256 = 75 %

Lo importante de la figura es lo que no se ve en ninguna medición del sistema: hay 256 bytes libres, la mitad de la memoria, y un pedido de 112 falla. Quien mira el mallinfo o el contador de memoria libre ve «256 B libres» y no encuentra el problema. Para medirlo hay que mirar el mayor hueco, que casi ninguna biblioteca informa, y por eso la fragmentación se estudia con pruebas y simulaciones largas, como hace el programa de Python de este tema.

La fragmentación empeora con tres factores: tamaños muy variados (los bloques chicos quedan encajados entre los grandes), duraciones de vida distintas (los bloques de larga vida quedan como columnas en medio del heap) y mucho tiempo de funcionamiento (hay más oportunidades de que se acomoden mal). Y mejora con la unión de huecos vecinos al liberar (coalescing): si dos bloques adyacentes quedan libres, se funden en uno más grande. Pero esa unión solo ayuda cuando los vecinos se liberan, y en el ejemplo de arriba ningún vecino lo está.

Un detalle más que desmiente la intuición: las pruebas de rutina no la encuentran. En un programa que reserva y libera siempre en el mismo orden (como un ejemplo de manual) la fragmentación nunca aparece. Surge cuando el orden de liberación se vuelve impredecible, lo que sucede en producción, con eventos reales, y casi nunca en el laboratorio.

04 · La opción por defecto

Asignación estática bien hecha

La alternativa a malloc es declarar todo con tamaño fijo, decidido al diseñar. Suena rígido, pero en la práctica casi todo en un sistema embebido tiene una cota natural: el número de sensores es fijo, el mensaje más largo de un protocolo está definido, la cola de eventos tiene un tamaño máximo. La clave es hacer esa decisión explícita, centralizada y verificable:

/* config.h: TODOS los límites en un solo lugar, cada uno con su razón */
#define MAX_SENSORES        8        /* los de la placa: 8 canales del conversor          */
#define LARGO_MENSAJE       64       /* el mensaje más largo del protocolo (tema 41)       */
#define PROFUNDIDAD_COLA    16       /* eventos pendientes en el peor caso (tema 26)       */

/* sensores.c */
typedef struct { uint16_t crudo; int16_t calibrado; uint8_t estado; } Sensor;
static Sensor sensores[MAX_SENSORES];                       /* reserva estática: 8 x 6 = 48 bytes, en .bss */

/* El compilador verifica el presupuesto: si alguien agranda Sensor o MAX_SENSORES, no compila */
_Static_assert(sizeof(sensores) <= 128, "los sensores no deben pasar de 128 bytes de RAM");

Sensor *sensor_obtener(uint8_t i) {
    return (i < MAX_SENSORES) ? &sensores[i] : NULL;       /* el límite se comprueba explícitamente */
}

Cuatro ventajas se acumulan, y por eso es la opción por defecto en los sistemas embebidos serios:

  • Se ve en el mapa. Cada byte reservado aparece en size y en el .map (tema 29). El presupuesto de RAM se puede auditar sin ejecutar nada.
  • Cuesta cero en ejecución. No hay búsqueda ni cabecera: la dirección es una constante del enlazador.
  • Falla en el momento correcto. Si no entra, falla el enlace, que es cuando el desarrollador puede reaccionar.
  • Los límites quedan escritos. «Hasta 8 sensores» es una decisión de diseño, visible, discutible y probable; con malloc el límite real es «lo que alcance», y nadie sabe cuánto es.

El costo es que se reserva el máximo aunque no se use. Si el equipo solo suele tener 3 sensores conectados pero puede tener 8, los 5 restantes ocupan RAM de todos modos. Esa es una decisión consciente: con RAM de sobra es una pérdida aceptable; con RAM ajustada, se evalúa si algún límite puede bajarse, o si conviene un pool compartido (el apartado siguiente).

Dos hábitos más. Primero, validar los límites en cada entrada: un índice que viene de un mensaje externo se compara con el máximo antes de usarse, porque con arreglos estáticos un índice fuera de rango no «falla»: pisa lo que haya al lado (tema 31). Segundo, declarar los búferes grandes static a nivel de archivo, no como variables locales: un arreglo local de 512 bytes consume pila (y puede desbordarla), mientras que uno estático vive en .bss, donde el enlazador lo cuenta.

05 · Dinámica, pero sin sorpresas

Pools de bloques fijos

A veces el número de objetos vivos varía en ejecución: mensajes en tránsito, conexiones abiertas, eventos con datos. No se sabe cuántos habrá a la vez, pero sí un máximo, y los objetos tienen el mismo tamaño. Para eso existe el pool de memoria (memory pool): una reserva estática dividida en bloques de tamaño fijo, que se entregan y se devuelven uno por uno.

Resuelve casi todos los problemas de malloc de un golpe. Como todos los bloques son iguales, cualquier bloque libre sirve para cualquier pedido: no hay búsqueda, no hay fragmentación externa y el tiempo es constante. La implementación usa una lista de bloques libres en la que el enlace se guarda dentro del propio bloque libre (un bloque libre no guarda datos, así que su memoria sirve para guardar el puntero al siguiente), de modo que no hace falta ninguna estructura extra:

/* pool.h / pool.c: bloques de tamaño fijo, reservar y liberar en tiempo constante */
typedef struct {
    uint8_t *memoria;          /* el área, reservada estáticamente                              */
    size_t   tam_bloque;       /* múltiplo de 8, y al menos sizeof(void *)                      */
    size_t   cantidad;
    void    *libres;           /* cabeza de la lista de bloques libres                          */
    size_t   en_uso, maximo;   /* para medir: cuántos hay en uso, y el máximo alcanzado         */
} Pool;

void pool_iniciar(Pool *p, void *area, size_t tam_bloque, size_t cantidad) {
    p->memoria = area;  p->tam_bloque = tam_bloque;  p->cantidad = cantidad;
    p->libres = NULL;   p->en_uso = p->maximo = 0;
    for (size_t i = cantidad; i > 0; i--) {                /* encadena todos los bloques libres */
        void *bloque = p->memoria + (i - 1) * tam_bloque;
        *(void **)bloque = p->libres;                      /* el primer campo del bloque libre apunta al siguiente */
        p->libres = bloque;
    }
}

void *pool_reservar(Pool *p) {
    uint32_t pm = __get_PRIMASK();  __disable_irq();        /* sección crítica corta (tema 16) */
    void *bloque = p->libres;
    if (bloque != NULL) {
        p->libres = *(void **)bloque;                        /* sacar el primero de la lista */
        if (++p->en_uso > p->maximo) p->maximo = p->en_uso;
    }
    __set_PRIMASK(pm);
    return bloque;                                          /* NULL si el pool está vacío: falla clara y predecible */
}

bool pool_liberar(Pool *p, void *bloque) {
    uintptr_t desplazamiento = (uintptr_t)bloque - (uintptr_t)p->memoria;
    if (desplazamiento >= p->cantidad * p->tam_bloque || desplazamiento % p->tam_bloque != 0)
        return false;                                       /* no es un bloque de este pool: se rechaza */
    uint32_t pm = __get_PRIMASK();  __disable_irq();
    *(void **)bloque = p->libres;                            /* volver a ponerlo al frente de la lista */
    p->libres = bloque;
    p->en_uso--;
    __set_PRIMASK(pm);
    return true;
}

/* Uso: 16 mensajes de 32 bytes, todo reservado estáticamente */
static uint8_t area_mensajes[16 * 32] __attribute__((aligned(8)));
static Pool    pool_mensajes;
/* en la inicialización: pool_iniciar(&pool_mensajes, area_mensajes, 32, 16); */

Las dos operaciones son unas pocas instrucciones sin bucles: el tiempo es el mismo con el pool vacío, medio lleno o casi agotado, y se puede usar dentro de una interrupción (la sección crítica dura tres instrucciones). El campo maximo es oro para el diseño: al final de las pruebas en el peor caso, dice cuántos bloques se usaron realmente como máximo, y permite ajustar la cantidad con datos. Y el fallo, cuando ocurre, es claro e inmediato: pool_reservar devuelve NULL porque no quedan bloques, y no porque «hay memoria pero no junta».

El pool tiene dos costos honestos que hay que dimensionar:

  • Fragmentación interna: cada objeto ocupa un bloque entero. Si los mensajes miden entre 16 y 64 bytes y el bloque es de 64, un mensaje de 16 desperdicia 48. Si los tamaños son muy variados, se usan varios pools con bloques de distinto tamaño (por ejemplo 32, 128 y 512 bytes) y cada pedido va al más chico que lo contenga.
  • Hay que dimensionarlo para el peor caso. Como cada pool tiene su cantidad fija, si un tipo de pedido se agota, falla aunque los otros pools estén vacíos. La Parte 2 del programa de Python lo muestra con números.

Una regla de propiedad que ahorra errores: todo bloque tiene un dueño que es quien lo libera. Si un productor reserva un bloque y se lo pasa a una cola (tema 26), el consumidor es quien lo libera; esa regla se escribe en el diseño y se respeta siempre. Con las reglas escritas, los dos errores clásicos (fuga y doble liberación) se vuelven visibles en una revisión.

06 · El caso de los flujos de datos

Buffers circulares

Cuando lo que se mueve es un flujo (los bytes de una UART, las muestras de un conversor, las líneas de un registro) en lugar de objetos discretos, la estructura natural es el buffer circular (ring buffer): un arreglo de tamaño fijo con dos índices, uno donde se escribe y otro donde se lee, que dan la vuelta al llegar al final. El tema 16 lo usó para pasar datos de una ISR al programa principal sin pérdida; acá lo vemos desde el lado de la memoria: cómo se dimensiona y qué se decide cuando se llena.

#define RB_TAM  128                          /* POTENCIA de dos: el módulo se hace con una máscara, sin dividir */

typedef struct {
    uint8_t           datos[RB_TAM];
    volatile uint16_t cabeza;                /* cuántos bytes se escribieron en total (solo escribe el productor) */
    volatile uint16_t cola;                  /* cuántos bytes se leyeron en total   (solo escribe el consumidor) */
    uint16_t          perdidos;              /* cuántos datos se descartaron por falta de lugar */
} RingBuffer;

static inline bool rb_poner(RingBuffer *r, uint8_t b) {
    if ((uint16_t)(r->cabeza - r->cola) == RB_TAM) { r->perdidos++; return false; }   /* lleno: se descarta el NUEVO y se cuenta */
    r->datos[r->cabeza & (RB_TAM - 1)] = b;
    r->cabeza++;
    return true;
}

static inline bool rb_sacar(RingBuffer *r, uint8_t *b) {
    if (r->cabeza == r->cola) return false;                                       /* vacío */
    *b = r->datos[r->cola & (RB_TAM - 1)];
    r->cola++;
    return true;
}

Dos trucos de este código valen la pena. Los índices cabeza y cola no se reducen al tamaño: cuentan bytes totales y se enmascaran solo al indexar. Así, «lleno» es cabeza − cola == RB_TAM y «vacío» es cabeza == cola, sin desperdiciar una posición para distinguirlos y sin ambigüedad (funciona aunque los contadores de 16 bits den la vuelta, porque el tamaño es una potencia de dos). Y como cada índice lo escribe un solo lado, no hace falta ninguna sección crítica entre una ISR productora y un programa principal consumidor (el tema 16 explica por qué).

Cuánto tiene que medir

El tamaño del buffer sale de una cuenta, no de una corazonada: cuántos datos llegan mientras el consumidor no puede atenderlos. Con una UART a 115 200 baudios, cada byte ocupa 10 bits (inicio, 8 de datos y parada), o sea 11 520 bytes por segundo. Si el programa principal puede tardar hasta 5 ms en volver a leer, llegan 11 520 × 0,005 = 57,6 bytes: hace falta un mínimo de 58, y con margen y potencia de dos, 128. Pero si algún día una operación bloquea 50 ms (un borrado de flash, tema 28), llegarían 576 bytes y el buffer de 128 se desbordaría. El tamaño se calcula con el peor tiempo de respuesta del consumidor, no con el promedio.

Qué hacer cuando se llena

Un buffer que se llena obliga a elegir una política, y debe ser una decisión de diseño explícita, no un accidente:

Políticas para un buffer circular lleno.
PolíticaQué se pierdeCuándo conviene
Descartar el dato nuevo (la del código)Lo más recienteComunicaciones y comandos: se prefiere conservar lo ya recibido, y el protocolo detecta el hueco (tema 39)
Sobrescribir el más viejoLo más antiguoRegistros, muestras de un sensor y telemetría: importa el estado reciente, no el historial completo (tema 41)
Bloquear al productorNada, pero se demoraSolo entre tareas, nunca desde una ISR (que no puede esperar); tema 21
Señalar el desborde—Siempre, además de cualquiera de las anteriores: el contador perdidos convierte un fallo mudo en un número medible

Sobrescribir el dato más viejo parece una sutileza, pero cambia quién escribe cada índice: el productor tendría que mover la cola (que es del consumidor), y hace falta proteger el cambio con una sección crítica o diseñar el buffer para un único contexto. Por eso la política por defecto para flujos entre una ISR y el programa principal es descartar lo nuevo y contarlo.

07 · Un término medio muy usado

Arenas: reservar al arrancar y no liberar

Existe una forma de asignación dinámica casi tan segura como la estática: la arena (arena o bump allocator). Es un área estática con un único puntero que avanza: cada pedido se atiende moviendo el puntero, y nunca se libera nada individualmente. Sirve para objetos cuya cantidad se conoce recién al arrancar (por ejemplo, según la configuración leída o el hardware detectado) pero que viven hasta el apagado:

static uint8_t arena[2048] __attribute__((aligned(8)));
static size_t  usado;
static bool    cerrada;

void *arena_reservar(size_t n) {
    if (cerrada) return NULL;                       /* terminada la inicialización, no se reserva más */
    n = (n + 7u) & ~7u;                             /* alineación a 8 bytes */
    if (usado + n > sizeof arena) return NULL;
    void *p = &arena[usado];
    usado += n;
    return p;
}

void arena_cerrar(void) { cerrada = true; }         /* se llama al final de la inicialización */
size_t arena_usada(void) { return usado; }          /* para medir el presupuesto real */

/* main(): reservar según la configuración, cerrar la arena y entrar al bucle principal */

La reserva es de tiempo constante y no hay fragmentación, porque nada se libera. Y el patrón «reservar al inicio, nunca liberar, cerrar» acerca el comportamiento al de la asignación estática: si el sistema arranca, la memoria está reservada para siempre, y el único fallo posible ocurre en el arranque, cuando es fácil de detectar. Es el patrón al que apuntan las normas que dicen «no asignar después de inicializar».

08 · Cuando no queda otra

Si hay que usar malloc

A veces no hay alternativa razonable: una biblioteca de terceros (una pila TCP/IP, un intérprete) que usa el heap, o un producto sin requisitos críticos donde la comodidad pesa más. Entonces se contiene el daño:

  • Reservar en el arranque y no liberar (una arena disfrazada). Si se llama a malloc solo durante la inicialización, no hay fragmentación ni fugas durante el funcionamiento.
  • Comprobar siempre el resultado. Cada malloc tiene un if (p == NULL) con un camino de recuperación probado (tema 39).
  • Medir. Con newlib, mallinfo() informa cuánto heap se pidió al sistema y cuánto hay en uso; hay que registrar su evolución a lo largo de pruebas largas:
#include <malloc.h>

void heap_informe(void) {
    struct mallinfo mi = mallinfo();
    /* mi.arena   : bytes que el heap obtuvo del sistema (con _sbrk), el «tope» alcanzado   */
    /* mi.uordblks: bytes en bloques ocupados                                               */
    /* mi.fordblks: bytes en bloques libres DENTRO de lo obtenido (no dice cuán juntos están) */
    log_printf("heap: obtenido=%d en uso=%d libre=%d\n", mi.arena, mi.uordblks, mi.fordblks);
}

/* Si 'uordblks' crece sin parar con el equipo en régimen, hay una fuga.
   Si 'arena' crece mientras 'uordblks' se mantiene, hay fragmentación. */
  • Interceptar las llamadas con -Wl,--wrap=malloc: el enlazador redirige cada llamada a malloc hacia __wrap_malloc, que se define para contar, registrar el tamaño y la dirección de quien llama, o directamente detener el programa en depuración. Permite descubrir quién usa el heap (una biblioteca puede hacerlo sin que se note).
  • Proteger el acceso si hay un RTOS o interrupciones: en newlib, se implementan los ganchos __malloc_lock y __malloc_unlock; y nunca se llama a malloc desde una ISR.
  • Evitar la fragmentación con tamaños previsibles: pedir siempre los mismos pocos tamaños, o reservar un único bloque grande en el arranque y repartirlo con un pool propio.

Los RTOS suelen traer su propio administrador de memoria, que conviene conocer porque determina el comportamiento de todo el sistema. FreeRTOS ofrece cinco implementaciones intercambiables del heap, de más simple a más completa:

Las implementaciones de heap de FreeRTOS.
ArchivoQué haceCuándo conviene
heap_1Solo reserva, nunca libera (es una arena)Sistemas que crean todas las tareas al arrancar: sin fragmentación, determinista
heap_2Reserva y libera, pero no une huecos vecinosCasi obsoleto: se fragmenta con tamaños variados
heap_3Envuelve el malloc y free de la biblioteca, con protecciónCuando se quiere reutilizar el heap de la toolchain
heap_4Primer ajuste y unión de huecos adyacentesEl uso general más común: menos fragmentación, tiempo no determinista
heap_5Como heap_4, pero sobre varias regiones de RAM no contiguasChips con varios bloques de RAM separados

Y FreeRTOS permite evitar el heap por completo con la creación estática de tareas, colas y semáforos (xTaskCreateStatic, xQueueCreateStatic, con configSUPPORT_STATIC_ALLOCATION): el programador entrega los búferes, que viven en .bss, y el kernel no pide memoria. Para firmware que debe ser predecible, es la forma recomendada. Las funciones xPortGetFreeHeapSize() y xPortGetMinimumEverFreeHeapSize() informan del heap libre actual y de su valor mínimo histórico, que es la cifra que importa: cuánto estuvo cerca de agotarse.

09 · Comprobar que funciona

Verificación y diagnóstico

Comprobaciones sobre la asignación de memoria de un proyecto.
Qué verificarCómoResultado esperado
Que nadie use malloc sin quererEnlazar con -Wl,--wrap=malloc y detener o registrar en __wrap_malloc; o poner _Min_Heap_Size = 0Cero llamadas durante el funcionamiento; las del arranque, identificadas
Pico de uso de un poolLeer maximo tras las pruebas en el peor casoPor debajo de la cantidad con margen; si llega a la cantidad, se subió muy poco
FugasRegistrar mallinfo().uordblks o en_uso de cada pool durante horas con carga constanteUna meseta estable; una línea que sube es una fuga
FragmentaciónPrueba larga con tamaños y tiempos de vida variados; observar fallos con memoria «libre»Cero fallos de malloc con memoria suficiente
Desbordes de buffers circularesLeer perdidos tras una prueba de carga máximaCero (o un valor aceptado y documentado)
Camino de «sin memoria»Forzar el fallo (pool vacío, malloc que devuelve NULL) y ver qué hace el programaUn comportamiento definido: descarta, avisa y sigue; nunca una falla de ejecución
Tiempo de reservaMedir con un pin y un analizador (tema 18) el peor malloc frente a pool_reservarEl pool tarda lo mismo siempre; malloc varía
Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
El equipo funciona semanas y después falla sin causa aparenteUna fuga de memoria o una fragmentación progresivaRegistrar la memoria en uso a lo largo del tiempo; buscar la tendencia; eliminar el malloc de la ruta en funcionamiento
malloc devuelve NULL pero «hay memoria libre»Fragmentación: ningún hueco alcanza para el pedidoPools de tamaño fijo; o reservar los bloques grandes primero, al arrancar
Una falla dura (HardFault) al leer un dato que «recién se recibió»Un malloc sin chequear que devolvió NULL, o uso de un bloque ya liberadoVerificar cada resultado; dejar de usar el puntero tras free (asignarle NULL)
Datos que cambian solos en estructuras no relacionadasDoble liberación o escritura fuera de un bloque: se corrompió la cabecera del siguientePools con validación de rango; revisar tamaños; herramientas del tema 31
Un sistema con RTOS falla al crear una tarea en pleno funcionamientoEl heap del RTOS está fragmentado o agotadoCrear todo al arrancar; xPortGetMinimumEverFreeHeapSize; creación estática
Se pierden bytes de la UART a cierta velocidadBuffer circular chico, o consumidor que tarda más que lo calculadoRecalcular con el peor tiempo de respuesta; revisar el contador perdidos
Un pool se agota aunque «nunca hay tantos mensajes»Bloques que no se liberan en algún camino (fuga de bloques)Registrar en_uso en el tiempo; revisar quién es el dueño en cada camino, incluyendo los de error

10 · Analogía con programación

Probar el modelo en Python

El programa implementa un heap con cabecera de 8 bytes por bloque, alineación a 8, primer ajuste y unión de huecos vecinos, o sea el comportamiento típico de un malloc simple. Tiene tres partes: un caso armado a mano que reproduce el diagrama de fragmentación, una simulación larga (200 000 operaciones) que compara el heap con pools de bloques fijos, y el cálculo del tamaño de un buffer circular a partir de una velocidad y un tiempo de respuesta.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar. La simulación de 200 000 operaciones tarda unos segundos.

# Heap con malloc/free (primer ajuste, con cabecera por bloque) frente a pool de bloques fijos.
import random
CAB, ALIN = 8, 8                                   # bytes de cabecera por bloque y alineación

class Heap:
    def __init__(self, tam):
        self.bloques = [[0, tam, True]]            # [inicio, tamaño total, libre]
        self.max_visitados = 0
    def malloc(self, n):
        total = (n + CAB + ALIN - 1) // ALIN * ALIN
        for i, (ini, tam, libre) in enumerate(self.bloques):
            self.max_visitados = max(self.max_visitados, i + 1)
            if libre and tam >= total:             # primer ajuste: el primer hueco que alcance
                if tam - total >= 16:              # sobra bastante: se parte el bloque
                    self.bloques[i] = [ini, total, False]
                    self.bloques.insert(i + 1, [ini + total, tam - total, True])
                else:
                    self.bloques[i][2] = False
                return ini
        return None
    def free(self, ini):
        for i, b in enumerate(self.bloques):
            if b[0] == ini: b[2] = True; break
        j = 0
        while j < len(self.bloques) - 1:           # unir huecos vecinos
            if self.bloques[j][2] and self.bloques[j + 1][2]:
                self.bloques[j][1] += self.bloques.pop(j + 1)[1]
            else: j += 1
    def libre_total(self): return sum(b[1] for b in self.bloques if b[2])
    def mayor_hueco(self): return max([b[1] for b in self.bloques if b[2]] or [0])

print("Parte 1: la fragmentación en un caso armado a mano")
h = Heap(1024)
punteros = [h.malloc(100) for _ in range(9)]       # nueve bloques de 100 bytes (112 con cabecera y alineación)
for p in punteros[1:8:2]: h.free(p)               # se liberan los bloques 1, 3, 5 y 7
print(f"  tras liberar la mitad: libre total = {h.libre_total()} B, mayor hueco = {h.mayor_hueco()} B")
print(f"  fragmentación = 1 - mayor hueco / libre total = {1 - h.mayor_hueco() / h.libre_total():.0%}")
print(f"  malloc(200) -> {'FALLA' if h.malloc(200) is None else 'ok'}   (hay {h.libre_total()} B libres, pero ninguno junto)")
print(f"  malloc(100) -> {'FALLA' if h.malloc(100) is None else 'ok'}")

print()
print("Parte 2: 200 000 operaciones con mensajes de 16 a 512 bytes (memoria de 4096 B, hasta 12 mensajes vivos)")
TAMANOS = (16, 32, 64, 128, 256, 512)
CLASES = ((64, 16), (256, 6), (512, 3))            # pools: (tamaño de bloque, cantidad) = 1024 + 1536 + 1536 = 4096 B

def simular(usa_pool, semilla, ops=200_000, tam_mem=4096, max_vivos=12, clases=CLASES):
    rnd = random.Random(semilla)
    heap, vivos = Heap(tam_mem), []
    libres = {tam: cant for tam, cant in clases}
    pedidos = fallos_frag = fallos_lleno = 0
    for _ in range(ops):
        if vivos and (len(vivos) >= max_vivos or rnd.random() < 0.5):
            x = vivos.pop(rnd.randrange(len(vivos)))
            if usa_pool: libres[x] += 1
            else: heap.free(x)
            continue
        n = rnd.choice(TAMANOS)
        pedidos += 1
        if usa_pool:
            utiles = [tam for tam, _ in clases if tam >= n and libres[tam] > 0]
            clase = utiles[0] if utiles else None
            if clase is None: fallos_lleno += 1
            else: libres[clase] -= 1; vivos.append(clase)
        else:
            p = heap.malloc(n)
            if p is None:
                if heap.libre_total() >= n + CAB: fallos_frag += 1
                else: fallos_lleno += 1
            else: vivos.append(p)
    return pedidos, fallos_frag, fallos_lleno, heap.max_visitados

for etiqueta, pool in (("heap con malloc/free", False), ("3 pools de bloques fijos", True)):
    ped, ff, fl, visitas = simular(pool, 7)
    detalle = f"; bloques recorridos en el peor malloc: {visitas}" if not pool else "; el peor pedido recorre 3 clases como máximo"
    print(f"  {etiqueta:<25} pedidos {ped}: fallos con memoria suficiente (fragmentación) {ff}, fallos por falta real {fl}{detalle}")
ped, ff, fl, _ = simular(True, 7, clases=((512, 12),))
print(f"  1 pool de 12 bloques de 512 B ({12 * 512} B, para el peor caso): pedidos {ped}, fallos {ff + fl}: nunca falla, pero cuesta {12 * 512 - 4096} B más que el heap")

print()
print("Parte 3: tamaño de un buffer circular (UART a 115200 baudios, 10 bits por byte)")
def potencia_de_dos(n):
    p = 1
    while p < n: p *= 2
    return p
baudios, bits_por_byte, pausa_ms = 115200, 10, 5
bytes_por_s = baudios / bits_por_byte
llegan = bytes_por_s * pausa_ms / 1000
print(f"  llegan {bytes_por_s:.0f} B/s; en una pausa de {pausa_ms} ms del bucle principal llegan {llegan:.1f} B")
for margen in (1, 2, 4):
    minimo = int(llegan * margen) + 1
    print(f"  margen x{margen}: mínimo {minimo} B -> potencia de dos: {potencia_de_dos(minimo)} B")
pausa_ms = 50
llegan = bytes_por_s * pausa_ms / 1000
print(f"  si una pausa de {pausa_ms} ms es posible: llegan {llegan:.0f} B -> con margen x2: {potencia_de_dos(int(llegan * 2) + 1)} B")

Resultado, parte 1: nueve bloques de 100 bytes (112 con cabecera y alineación) llenan un heap de 1024 casi por completo (quedan 16 B al final); al liberar los bloques 1, 3, 5 y 7, el libre total es de 464 bytes y el mayor hueco de solo 112, una fragmentación del 76 %: malloc(200) falla con 464 bytes libres, y malloc(100) sí entra. Parte 2: con mensajes de 16 a 512 bytes y hasta 12 vivos en 4096 bytes, el heap tuvo 100 040 pedidos, de los cuales 74 fallaron aunque había memoria suficiente (fragmentación pura) y 4 por falta real, y el peor malloc recorrió 20 bloques antes de decidir; tres pools de bloques fijos (16 de 64 B, 6 de 256 B y 3 de 512 B, los mismos 4096 bytes) tuvieron cero fallos por fragmentación pero 1211 por falta de bloques de su clase, porque el reparto de tamaños no coincide con la demanda; un único pool de 12 bloques de 512 B (6144 bytes) nunca falla, pero cuesta 2048 bytes más que el heap. Parte 3: la UART a 115 200 baudios entrega 11 520 B/s, así que en una pausa de 5 ms llegan 57,6 B: un mínimo de 58, que con margen ×1, ×2 y ×4 da buffers de 64, 128 y 256 bytes; si una pausa de 50 ms es posible, llegan 576 B y con margen ×2 hacen falta 2048.

Lo que enseña esta salida es una compensación honesta: el heap aprovecha mejor la memoria pero falla de forma impredecible, y el pool falla de forma predecible pero exige dimensionarse para el peor caso, a veces con más memoria. Probá cambiar max_vivos a 8 (el heap casi no falla: 1 fallo en 100 002 pedidos) o a 16 (los fallos suben a 750 por fragmentación y 50 por falta real), o cambiar la composición de CLASES para ver cómo un pool bien ajustado a la demanda elimina los fallos.

11 · Exploración interactiva

Heap contra pool

El laboratorio es una memoria de 512 bytes que se puede administrar de dos maneras: como un heap (malloc/free con cabecera de 8 bytes, primer ajuste y unión de huecos) o como un pool de 8 bloques de 64 bytes. Reservá y liberá bloques y mirá cómo se reparte la memoria, cuánto queda libre en total, cuál es el mayor hueco y cuánto vale la fragmentación.

Reservar, liberar y fragmentar

Verde: bloque en uso (con su número) · Rayado: hueco libre. El ancho es proporcional al tamaño.

Libre en total—
Mayor hueco libre—
Fragmentación—
Bloques en uso—
Última operación—

Reservá un bloque.

Recorrido guiado

  1. Con el Heap, apretá «Llenar con 8 bloques de 56 B»: cada pedido de 56 bytes ocupa 64 con cabecera y alineación, y los ocho llenan los 512 B justos. Fijate en la cabecera: 8 bytes de cada 64 no son datos.
  2. Apretá «Liberar los pares»: quedan 256 bytes libres, la mitad de la memoria, pero en cuatro huecos de 64. La fragmentación es del 75 %.
  3. Elegí 100 bytes y apretá «Reservar»: el pedido necesita 112 bytes seguidos, y falla con 256 libres. Es exactamente el diagrama del apartado 3. Ahora elegí 24 bytes y reservá: ese sí entra, porque cabe en un hueco.
  4. Liberá, con el selector, el bloque #3, que está flanqueado por los huecos de #2 y #4: los tres se unen en un solo hueco de 192 bytes. Probá otra vez los 100 bytes: ahora entra. La unión de huecos vecinos repara la fragmentación solo si los vecinos se liberan.
  5. Cambiá a Pool y repetí los pasos 1 a 3: el pool llena 8 bloques con los mismos 56 bytes, se liberan los pares y el mayor hueco sigue siendo un bloque. Reservá 24 o 56 bytes: siempre entra, porque cualquier bloque libre sirve. La fragmentación externa es siempre cero.
  6. En el pool, probá el pedido de 100 bytes: no entra en un bloque de 64, aunque haya bloques libres. Ese es el costo: el pool solo sirve para objetos que caben en su bloque, y cada objeto, por chico que sea, ocupa un bloque entero (un pedido de 16 desperdicia 48).
Desafío: ¿cómo se atendería un sistema con mensajes de 16, 100 y 200 bytes?

Con tres pools: uno de bloques de 32 bytes (para los de 16), otro de 128 (para los de 100) y otro de 256 (para los de 200). Cada pedido va al pool más chico que lo contiene; cada pool se dimensiona con el máximo de mensajes de su clase que pueden estar vivos a la vez, más un margen. La fragmentación interna es modesta (un mensaje de 100 en un bloque de 128 desperdicia 28 bytes) y la externa, nula. El diseño exige conocer la distribución de los mensajes, algo que casi siempre se sabe en un protocolo definido, y se verifica con el campo maximo de cada pool tras las pruebas. Si un tipo de mensaje cambia de tamaño en una versión futura del protocolo, cambia el pool, y no el heap de todo el sistema.

12 · Método de trabajo

Método: elegir cómo reservar

  1. Por defecto, estático. Si el número de objetos tiene una cota natural y razonable, se declara un arreglo con ese máximo, con la constante en config.h, un _Static_assert del presupuesto y la validación de los índices.
  2. Si el flujo es de bytes, buffer circular. Dimensionalo con la tasa de llegada y el peor tiempo de respuesta del consumidor, y elegí explícitamente qué se pierde cuando se llena. Contá los descartes.
  3. Si son objetos de tamaño fijo que van y vienen, pool. Un bloque por objeto, con un dueño claro, dimensionado con el máximo de objetos simultáneos y verificado con el contador maximo.
  4. Si los tamaños varían mucho, varios pools (uno por clase de tamaño), y una arena para lo que se decide al arrancar.
  5. Asigná al arrancar, nunca después. Todo lo dinámico que sea posible, se reserva en la inicialización y no se libera. Cerrá la arena.
  6. Si no hay manera de evitar malloc, contenelo: envolvelo con --wrap, chequeá cada resultado, medí con mallinfo o con las funciones de tu RTOS, y no lo llames desde una ISR.
  7. Probá el camino de «sin memoria». Un pool agotado o un malloc que devuelve NULL no son errores de programación: son situaciones previstas, y su manejo se verifica forzándolas.
  8. Medí en el tiempo. Los problemas de memoria dinámica son de acumulación: una prueba de horas o días con carga real vale más que cien pruebas de minutos.

13 · Comprensión

Confusiones frecuentes

«Si malloc funciona hoy, va a funcionar siempre»

Los fallos de la asignación dinámica son de acumulación. Con un uso breve y ordenado, un malloc simple se porta perfecto; con meses de uso y un orden de liberación impredecible, se fragmenta o pierde memoria. Que haya andado en la mesa no demuestra nada sobre el equipo instalado.

«Mientras haya memoria libre, malloc no puede fallar»

Puede, y es el caso más traicionero: la memoria libre total no sirve si no está junta. Un heap con la mitad libre puede rechazar un pedido que no llega al diez por ciento de la memoria. Por eso el contador de memoria libre, por sí solo, es un indicador engañoso.

«Con free y NULL alcanza para evitar los errores»

Ayuda (asignar NULL tras free evita usar un puntero colgante), pero no evita la fuga por olvido en un camino de error, ni la doble liberación a través de una segunda copia del puntero. La regla que funciona es la de dueño único, no la del hábito.

«La asignación estática desperdicia memoria»

Reserva el máximo aunque no se use, es cierto. Pero a cambio no necesita cabeceras, no se fragmenta y se ve en el mapa. En un sistema con la RAM ajustada, el desperdicio de un máximo conocido es casi siempre menor que el margen que exigiría un heap dinámico para ser seguro. Y, sobre todo, el desperdicio de la estática es conocido: se mide y se discute.

«Un pool es lo mismo que un heap pequeño»

Se parecen en la interfaz, pero la diferencia de fondo es que un pool solo tiene bloques iguales. Eso elimina la búsqueda y la fragmentación externa, y es lo que le da un tiempo constante y un fallo predecible. Un heap con tamaños variables no puede dar esas garantías.

«Un buffer circular debe tener un elemento de menos para distinguir lleno de vacío»

Es una de las soluciones clásicas, pero no la única ni la mejor. Con los índices que cuentan datos totales y se enmascaran solo al usarlos, «lleno» y «vacío» se distinguen sin desperdiciar nada, siempre que el tamaño sea una potencia de dos.

«Si el buffer circular se llena, basta agrandarlo»

Agrandarlo es la tentación fácil, pero si el consumidor es más lento que el productor de forma sostenida, ningún tamaño alcanza: solo se demora el desborde. Hay que averiguar por qué el consumidor no atiende a tiempo, o aceptar y documentar la pérdida. El tamaño correcto sale del peor tiempo de respuesta, no de ir probando números.

«Puedo llamar a malloc desde una interrupción si es rápido»

No es cuestión de velocidad: malloc modifica estructuras compartidas sin protección. Si la interrupción llega mientras el programa principal estaba dentro de malloc, el heap queda corrompido. Se usa un pool con sección crítica corta o un buffer ya reservado.

14 · Práctica guiada

Ejercicios

1. Tamaño de un buffer circular

Una UART a 9600 baudios (10 bits por byte) alimenta un buffer circular. El programa principal puede tardar hasta 20 ms en volver a leerlo. ¿Cuántos bytes llegan en ese plazo? ¿Qué tamaño de buffer (potencia de dos, con margen ×2) elegirías?

Ver solución

9600 / 10 = 960 bytes por segundo. En 20 ms llegan 960 × 0,02 = 19,2 bytes: un mínimo de 20. Con margen ×2, 40 bytes; la siguiente potencia de dos es 64 bytes. Si algún día una operación bloqueara 200 ms (un borrado de flash), llegarían 192 bytes y el buffer se desbordaría: habría que decidir si esa operación se permite mientras la UART recibe.

2. El costo de la cabecera

Un sistema guarda 100 mensajes de 20 bytes. Con un heap que pone 8 bytes de cabecera y alinea a 8, ¿cuántos bytes ocupan en total? ¿Y con un pool de bloques de 24 bytes? ¿Cuánto se desperdicia en cada caso?

Ver solución

Heap: cada bloque necesita 20 + 8 = 28, redondeado a 32 bytes. Los 100 mensajes ocupan 3200 bytes, para 2000 de datos útiles: 1200 bytes (37,5 %) de cabecera y relleno. Pool de bloques de 24: 2400 bytes, con 400 bytes (16,7 %) de relleno. El pool ahorra 800 bytes y no tiene cabecera; a cambio, solo sirve para objetos de hasta 24 bytes. Notá además que el heap, con cualquier mezcla de tamaños, sumaría la fragmentación externa encima.

3. Dimensionar un pool

Un productor genera un mensaje cada 10 ms. El consumidor puede tardar hasta 80 ms en procesar y liberar cada uno. ¿Cuántos bloques hacen falta como mínimo? ¿Cuántos pondrías con un margen del 50 %? Si cada bloque mide 32 bytes, ¿cuánta RAM es?

Ver solución

En 80 ms se generan 80 / 10 = 8 mensajes que siguen vivos, y uno más que se está generando cuando el primero aún se procesa: 9 bloques como mínimo. Con un margen del 50 %: 9 × 1,5 = 13,5, o sea 14 bloques. RAM: 14 × 32 = 448 bytes. Y se verifica después con el campo maximo del pool bajo la peor carga: si nunca pasa de 9, el margen es holgado; si llega a 14, estaba mal calculado.

4. Leer una fragmentación

Un heap tiene estos huecos libres: 112, 112, 112, 112 y 16 bytes. ¿Cuánto es el libre total, el mayor hueco y la fragmentación? ¿Entra un pedido de 100 bytes (112 con cabecera)? ¿Y uno de 120?

Ver solución

Libre total: 4 × 112 + 16 = 464 bytes. Mayor hueco: 112. Fragmentación: 1 − 112 / 464 = 75,9 % (la cifra de la Parte 1 del programa). El pedido de 100 bytes necesita 112 y entra en cualquiera de los cuatro huecos grandes. El de 120 bytes necesita 128 y no entra en ninguno, aunque haya 464 libres. La fragmentación crece a medida que se consume el hueco mayor: cada pedido que entra deja menos espacio contiguo.

5. ¿Qué elegir?

Para cada caso, elegí entre asignación estática, pool, buffer circular y arena: (a) la tabla de 12 calibraciones de un instrumento, (b) los paquetes de red recibidos, cada uno de hasta 128 bytes, que van de la interrupción a una tarea, (c) las líneas de un registro de eventos que se quiere conservar en memoria, (d) los objetos de configuración, cuyo número se lee de la flash al arrancar y no cambia.

Ver solución

(a) Estática: un arreglo de 12 elementos con tamaño conocido; es el caso más simple. (b) Pool de bloques de 128 bytes (o de 128 más una cabecera de longitud): la ISR reserva un bloque y lo pasa por una cola a la tarea, que lo libera: dueño claro, tiempo constante, sin fragmentación. (c) Buffer circular con política de sobrescribir el más viejo: lo que importa es el estado reciente, y el tamaño acota la memoria. (d) Arena: se reserva al arrancar con el número leído, no se libera nada y se cierra al terminar la inicialización.

6. Qué se pierde

Un buffer circular de telemetría se llena cada vez que el enlace de radio se cae unos minutos. ¿Qué política elegirías: descartar el dato nuevo o sobrescribir el más viejo? ¿Cambiaría la respuesta si los datos fueran comandos de control en lugar de telemetría?

Ver solución

Para telemetría, sobrescribir el más viejo: cuando el enlace vuelve, interesa el estado reciente del equipo, y un dato de hace minutos vale menos que el actual. Se conserva una ventana de los últimos N datos. Para comandos de control, la respuesta se invierte: se descarta el nuevo y se cuenta. Un comando viejo ya aceptado no debe desaparecer, y perder el orden de los comandos puede ser peligroso; es mejor rechazar lo nuevo, avisar y dejar que el protocolo lo reintente. En los dos casos hay que contar lo perdido, y el desborde debe ser visible en un diagnóstico.

15 · Síntesis

Ideas para recordar

  • Hay tres formas de obtener memoria: estática (se decide al compilar y falla, si falla, al enlazar), automática en la pila (falla en ejecución, sin aviso) y dinámica en el heap (falla en ejecución y de muchas maneras).
  • malloc en un microcontrolador sufre seis problemas: puede fallar, su tiempo no es determinista, fragmenta, agrega cabeceras, permite fugas y errores de uso, y no es reentrante. Normas como MISRA C la restringen.
  • La fragmentación deja memoria libre sin poder usarla: hay que medir el mayor hueco, no solo el total libre. Es un fallo de acumulación que las pruebas cortas no detectan.
  • La opción por defecto es la asignación estática: máximos explícitos en config.h, _Static_assert del presupuesto y validación de los límites. Se ve en el mapa y cuesta cero en ejecución.
  • El pool de bloques fijos reserva y libera en tiempo constante, sin fragmentación externa, con la lista libre guardada dentro de los propios bloques. Hay que dimensionarlo para el peor caso, con un dueño claro por bloque y el contador maximo.
  • El buffer circular es la estructura de los flujos: se dimensiona con la tasa de llegada y el peor tiempo de respuesta, con tamaño potencia de dos, índices que cuentan datos totales y una política de desborde explícita, con un contador de pérdidas.
  • La arena (reservar al arrancar, no liberar y cerrar) acerca la memoria dinámica a la estática. Si hay que usar malloc, se envuelve con --wrap, se mide con mallinfo y nunca se llama desde una ISR.
  • Todas estas técnicas comparten una idea: convertir un problema dinámico (¿alcanza la memoria ahora?) en uno de dimensionamiento, que se resuelve una vez, con números y con margen.

¿Listo para el tema siguiente?

Antes de avanzar, buscá un proyecto tuyo y respondé: ¿hay algún malloc o new? Si lo hay, ¿cuándo se llama (solo al arrancar o durante el funcionamiento)? ¿Se chequea el resultado? ¿Qué pasaría si un día devolviera NULL? Después elegí un flujo de datos (una UART, un sensor) y calculá el tamaño de su buffer con el peor tiempo de respuesta. Si podés responder con números, ya tenés criterio para decidir cómo se reserva cada byte de RAM.

En el próximo tema vemos qué ocurre cuando estas decisiones salen mal: los errores de memoria. Desbordamiento de pila y de buffers, punteros inválidos, la unidad de protección de memoria (MPU) y cómo detectarlos temprano.