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:
| Forma | Dónde | Cuándo se decide | Duración | Cuándo y cómo puede fallar |
|---|---|---|---|---|
Estática (globales, static) | .data o .bss | Al compilar | Toda la vida del programa | Al 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 profundidad | Hasta que la función retorna | En ejecución, sin aviso: el desborde de pila corrompe (tema 29) |
Dinámica (malloc, free) | Heap | En ejecución | Hasta 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:
| Problema | Qué pasa | Por qué duele en un microcontrolador |
|---|---|---|
| 1. Puede fallar en ejecución | malloc devuelve NULL cuando no hay un hueco suficiente | El 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 determinista | Buscar un hueco recorre una lista de bloques; cuánto tarda depende del estado del heap | Un 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ón | Con el tiempo, el heap se parte en trozos chicos intercalados con bloques en uso | Puede haber memoria libre de sobra y que aun así un pedido falle. Se apreciará en el apartado siguiente |
| 4. Sobrecarga por bloque | Cada bloque lleva una cabecera con su tamaño y enlaces, y se alinea a 8 bytes | De 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 liberar | Olvidar un free deja bloques perdidos; liberar dos veces o usar un bloque liberado corrompe el heap | En 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 reentrante | El estado interno del heap (listas, contadores) se modifica sin protección | Llamarlo 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.
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
sizey 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
mallocel 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ítica | Qué se pierde | Cuándo conviene |
|---|---|---|
| Descartar el dato nuevo (la del código) | Lo más reciente | Comunicaciones y comandos: se prefiere conservar lo ya recibido, y el protocolo detecta el hueco (tema 39) |
| Sobrescribir el más viejo | Lo más antiguo | Registros, muestras de un sensor y telemetría: importa el estado reciente, no el historial completo (tema 41) |
| Bloquear al productor | Nada, pero se demora | Solo 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
mallocsolo durante la inicialización, no hay fragmentación ni fugas durante el funcionamiento. - Comprobar siempre el resultado. Cada
malloctiene unif (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 amallochacia__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_locky__malloc_unlock; y nunca se llama amallocdesde 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:
| Archivo | Qué hace | Cuándo conviene |
|---|---|---|
heap_1 | Solo reserva, nunca libera (es una arena) | Sistemas que crean todas las tareas al arrancar: sin fragmentación, determinista |
heap_2 | Reserva y libera, pero no une huecos vecinos | Casi obsoleto: se fragmenta con tamaños variados |
heap_3 | Envuelve el malloc y free de la biblioteca, con protección | Cuando se quiere reutilizar el heap de la toolchain |
heap_4 | Primer ajuste y unión de huecos adyacentes | El uso general más común: menos fragmentación, tiempo no determinista |
heap_5 | Como heap_4, pero sobre varias regiones de RAM no contiguas | Chips 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
| Qué verificar | Cómo | Resultado esperado |
|---|---|---|
Que nadie use malloc sin querer | Enlazar con -Wl,--wrap=malloc y detener o registrar en __wrap_malloc; o poner _Min_Heap_Size = 0 | Cero llamadas durante el funcionamiento; las del arranque, identificadas |
| Pico de uso de un pool | Leer maximo tras las pruebas en el peor caso | Por debajo de la cantidad con margen; si llega a la cantidad, se subió muy poco |
| Fugas | Registrar mallinfo().uordblks o en_uso de cada pool durante horas con carga constante | Una meseta estable; una línea que sube es una fuga |
| Fragmentación | Prueba larga con tamaños y tiempos de vida variados; observar fallos con memoria «libre» | Cero fallos de malloc con memoria suficiente |
| Desbordes de buffers circulares | Leer perdidos tras una prueba de carga máxima | Cero (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 programa | Un comportamiento definido: descarta, avisa y sigue; nunca una falla de ejecución |
| Tiempo de reserva | Medir con un pin y un analizador (tema 18) el peor malloc frente a pool_reservar | El pool tarda lo mismo siempre; malloc varía |
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| El equipo funciona semanas y después falla sin causa aparente | Una fuga de memoria o una fragmentación progresiva | Registrar 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 pedido | Pools 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 liberado | Verificar cada resultado; dejar de usar el puntero tras free (asignarle NULL) |
| Datos que cambian solos en estructuras no relacionadas | Doble liberación o escritura fuera de un bloque: se corrompió la cabecera del siguiente | Pools con validación de rango; revisar tamaños; herramientas del tema 31 |
| Un sistema con RTOS falla al crear una tarea en pleno funcionamiento | El heap del RTOS está fragmentado o agotado | Crear todo al arrancar; xPortGetMinimumEverFreeHeapSize; creación estática |
| Se pierden bytes de la UART a cierta velocidad | Buffer circular chico, o consumidor que tarda más que lo calculado | Recalcular 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.
Reservá un bloque.
Recorrido guiado
- 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.
- 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 %.
- 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.
- 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.
- 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.
- 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
- 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_assertdel presupuesto y la validación de los índices. - 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.
- 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. - 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.
- 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.
- Si no hay manera de evitar
malloc, contenelo: envolvelo con--wrap, chequeá cada resultado, medí conmallinfoo con las funciones de tu RTOS, y no lo llames desde una ISR. - Probá el camino de «sin memoria». Un pool agotado o un
mallocque devuelveNULLno son errores de programación: son situaciones previstas, y su manejo se verifica forzándolas. - 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).
mallocen 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_assertdel 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 conmallinfoy 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.