Estrategias sin heap
Memoria estática, buffers de tamaño fijo y presupuestos calculados en compilación: cómo se diseñan los sistemas que nunca reservan en tiempo de ejecución, por qué la aviación y el automóvil lo exigen, y qué se paga por esa rigidez.
Hay una categoría de software que no llama a malloc ni una sola vez: controladores de vuelo, unidades de freno, marcapasos, firmware de arranque, núcleos de tiempo real. No es conservadurismo ni falta de medios. Es la consecuencia de tomarse en serio una pregunta que el resto del software evita: si reservar memoria puede fallar y no tienes a dónde retroceder, entonces el único momento seguro para reservar es antes de que el sistema empiece a ser responsable de algo.
- Justificar la prohibición del montón desde el determinismo temporal y la ausencia de fragmentación.
- Dimensionar almacenamiento estático con presupuesto explícito verificado por
static_assert. - Diseñar búferes de tamaño fijo con política de truncamiento y contrapresión explícitas.
- Valorar honestamente el coste de la rigidez y saber cuándo no aplica.
Por qué se prohíbe el montón
Las normas del sector son inequívocas. La regla 21.3 de MISRA C 2012 prohíbe las funciones de reserva dinámica de la biblioteca estándar. La tercera de las diez reglas de Gerard Holzmann para el Laboratorio de Propulsión a Chorro de la NASA dice literalmente que no se debe usar memoria dinámica después de la inicialización. Los procesos de certificación aeronáutica bajo DO-178C no la prohíben en abstracto, pero exigen demostrar que el sistema no puede agotarse ni fragmentarse, y esa demostración es tan cara que casi todo el mundo prefiere eliminar el problema.
Las razones son cuatro y ninguna es supersticiosa.
La primera es el determinismo temporal. Un asignador de propósito general no tiene peor caso acotado: puede recorrer listas de huecos, coalescer bloques, pedir páginas al sistema y provocar un fallo de página mayor. En un lazo de control que debe responder en un milisegundo, una función sin cota superior demostrable es inadmisible aunque su caso medio sea excelente.
La segunda es el fallo sin salida. Cuando malloc devuelve nulo en un servidor, devuelves un error y sigues vivo. Cuando lo devuelve en un sistema de frenado, no hay nada sensato que hacer: la operación era obligatoria y su fracaso no es recuperable. Un fallo que no admite tratamiento no debe poder ocurrir.
La tercera es la fragmentación a largo plazo. Un proceso que se reinicia a diario la disimula; uno que debe correr siete años sin apagarse, no. Con tamaños heterogéneos y vidas variadas, el montón puede llegar a tener memoria libre de sobra y aun así no encontrar un hueco contiguo del tamaño pedido, y ese fallo aparece en el mes cuarenta y uno.
La cuarta es la analizabilidad. Si toda la memoria está declarada en compilación, el consumo máximo del programa es una suma que se puede leer del binario. Con reserva dinámica, el consumo máximo es una propiedad del comportamiento en ejecución y solo se puede acotar con razonamientos que nadie firma con gusto.
size firmware.elf # text, data, bss: el presupuesto entero, visible
Almacenamiento estático y presupuesto explícito
La técnica base es trivial y todo lo demás son variantes: declarar la memoria como estática, dimensionarla al peor caso y demostrar en compilación que el peor caso cabe.
#define MAX_SESIONES 64
#define MAX_MENSAJE 512
static Sesion sesiones[MAX_SESIONES];
static bool sesion_activa[MAX_SESIONES];
static unsigned char buzon[MAX_SESIONES][MAX_MENSAJE];
static_assert(sizeof sesiones + sizeof buzon <= 96 * 1024,
"el presupuesto de RAM del modulo se ha excedido");
Ese static_assert es la pieza que convierte una buena intención en una garantía: cualquiera que amplíe una estructura o suba una constante rompe la compilación en lugar de descubrir el problema en el laboratorio. C23 permite omitir el mensaje, pero en un presupuesto conviene escribirlo.
Merece la pena saber dónde acaba cada cosa. Los objetos estáticos inicializados a valores distintos de cero viven en .data y ocupan espacio en el binario, porque sus valores viajan con él; los inicializados a cero o sin inicializar viven en .bss, que solo ocupa una anotación de tamaño y lo pone a cero el arranque. Un array de un mebibyte declarado static no engorda el ejecutable, pero sí la memoria del dispositivo, y confundir ambas cosas produce sorpresas al medir.
El asignador que sí es admisible en este mundo es el pool estático: un array fijo de ranuras con su lista libre, sin nada que pedir al sistema. Tiene peor caso acotado, no fragmenta y su agotamiento es un valor de retorno.
static Nodo almacen[MAX_NODOS];
static Nodo *libres; // enhebrado una vez en el arranque
Nodo *nodo_tomar(void) { // coste constante, fallo explicito
Nodo *n = libres;
if (n != nullptr) libres = n->siguiente;
return n; // nulo significa presupuesto agotado
}
De ahí sale el patrón general, conocido como reservar en la inicialización: durante el arranque, y solo entonces, el sistema construye todo lo que va a necesitar; a partir del momento en que asume responsabilidades, la topología de la memoria ya no cambia nunca. Muchos sistemas embebidos con sistema operativo hacen precisamente esto y luego llegan a bloquear el asignador.
Determinismo
Toda operación de memoria tiene cota superior conocida. No hay peor caso que descubrir.
Presupuesto visible
El consumo máximo se lee del binario con size. Auditable y verificable en integración continua.
Sin fallo tardío
Si cabe al compilar, cabe siempre. El agotamiento se traslada al momento en que aún es barato.
Coste
Se dimensiona al peor caso, así que en el caso medio hay memoria dormida. Es el precio.
Búferes fijos: truncamiento y contrapresión
Con capacidades fijas aparece una pregunta que la reserva dinámica escondía: qué hacer cuando no cabe. La respuesta no puede ser crecer, así que debe ser una política, y la virtud del diseño sin montón es que obliga a nombrarla.
Para texto, la política es truncar de forma detectable. strcpy desborda, strncpy no siempre termina en nulo y rellena de ceros, y strcat obliga a recorrer dos veces. La herramienta correcta es snprintf, que siempre termina en nulo y devuelve la longitud que habría escrito, de modo que el truncamiento es comprobable.
char destino[64];
int n = snprintf(destino, sizeof destino, "%s:%d", host, puerto);
if (n < 0 || (size_t)n >= sizeof destino) return ERROR_TRUNCADO; // no lo ignores
Para colas, la política es una decisión de dominio con tres respuestas posibles y ninguna neutral: descartar lo nuevo, sobrescribir lo viejo o rechazar al productor. Un registro de telemetría prefiere sobrescribir lo viejo; una cola de órdenes de actuación prefiere rechazar y avisar; un flujo de vídeo descarta lo nuevo. Un búfer circular de capacidad fija con potencia de dos hace la aritmética barata y la política explícita.
typedef struct {
Evento datos[64]; // capacidad potencia de dos
uint32_t cabeza, cola;
} Cola;
bool cola_meter(Cola *c, Evento e) {
if (c->cabeza - c->cola == 64) return false; // lleno: el llamante decide
c->datos[c->cabeza & 63] = e;
c->cabeza++;
return true;
}
flowchart TD
A[Llega un dato] --> B{Cabe en el buffer}
B -->|Si| C[Escribir y continuar]
B -->|No| D{Politica del dominio}
D --> E[Descartar el nuevo]
D --> F[Sobrescribir el viejo]
D --> G[Rechazar y aplicar contrapresion]
style G fill:#a6e3a1,color:#11111bEsa tercera rama es la contrapresión, y es el motivo profundo por el que estos sistemas resultan más robustos. Un sistema con montón absorbe una sobrecarga creciendo hasta que el crecimiento lo mata; uno con capacidad fija tiene que decir que no mucho antes, cuando decir que no todavía es barato y ordenado. La saturación deja de ser un accidente y pasa a ser un estado previsto del diseño.
El coste de la rigidez
Ser honesto con el precio es parte de la técnica. Dimensionar al peor caso significa que en el caso medio hay memoria reservada sin usar, y en un dispositivo con doscientos cincuenta y seis kibibytes de RAM eso duele. Peor: cuando el peor caso no es acotable de verdad —un servidor que sirve a un número desconocido de clientes— fijar la capacidad no elimina el problema, solo mueve la decisión a un valor que alguien tuvo que inventar, y elegirlo mal es un fallo de disponibilidad autoinfligido.
La rigidez también penetra en el código. Sin montón, devolver una estructura de tamaño variable exige que el llamante aporte el almacenamiento, así que las interfaces se llenan de un parámetro de búfer y otro de capacidad, y las funciones informan de cuánto habrían necesitado. Es un estilo más verboso, aunque tiene una compensación real: la propiedad de la memoria queda escrita en la firma en lugar de vivir en un comentario.
Por eso el criterio no es prohibir el montón en todas partes, sino saber qué se compra al hacerlo. Un compilador de escritorio debe usar arenas y crecer todo lo que la máquina permita; un servidor web hace bien en reservar por petición y devolver un error quinientos tres cuando no puede. La disciplina sin reserva dinámica es la respuesta correcta cuando el fallo no admite retroceso, cuando hay que demostrar el consumo máximo o cuando el sistema debe correr años sin reiniciarse. Fuera de esos casos, aplicarla entera es pagar rigidez por una garantía que no necesitabas; aplicar sus hábitos —presupuestar, medir, acotar, nombrar la política de saturación— es casi siempre buena idea.
Mira lo que ocurre cuando un sistema decide no reservar nunca en ejecución. Desaparece la fuga de memoria, porque no hay nada que liberar. Desaparece el uso tras liberar y la doble liberación, porque no existe liberar. Desaparece la fragmentación, porque no hay bloques que se acomoden entre sí. Desaparece el fallo por agotamiento en el peor momento, porque el agotamiento ocurre al compilar o al arrancar, cuando todavía es gratis. No se han corregido esos errores: se ha eliminado la operación que los generaba, y con ella todo su cono de consecuencias. Ese movimiento —restringir la potencia expresiva para comprar una garantía— es uno de los patrones más profundos de la ingeniería del software, y lo has visto ya tres veces en este nivel: la arena renuncia a la liberación individual y gana un modelo de propiedad trivial; el pool renuncia a la variedad de tamaños y gana coste constante; el diseño sin montón renuncia a la reserva dinámica y gana un consumo máximo demostrable. El mismo patrón explica por qué los lenguajes funcionales puros prohíben la mutación, por qué los tipos opacos ocultan sus campos, por qué las API de solo lectura son más fáciles de razonar y por qué las arquitecturas serias limitan deliberadamente lo que un componente puede hacer. La intuición del principiante es que más capacidades hacen mejor a un sistema; la del ingeniero maduro es que cada capacidad que un componente conserva es una obligación de prueba para todos los que lo usan. Cuando ya no puedas hacer que tu programa sea más correcto añadiéndole comprobaciones, pregunta qué operación podrías prohibirte para que la comprobación deje de hacer falta. Esa pregunta, y no la búsqueda de más herramientas, es la que produce sistemas que aguantan décadas.
- Toma un módulo tuyo que use
mallocy reescríbelo con almacenamiento estático dimensionado al peor caso, con unstatic_assertque fije el presupuesto de RAM. - Compara con
sizelas secciones.data,.bssy.textantes y después, y explica por qué un array estático grande no engorda el binario. - Implementa un pool estático con lista libre y sustituye cada fallo de reserva por un valor de retorno tratado explícitamente en todos los llamantes.
- Escribe un búfer circular de capacidad potencia de dos y elige de forma razonada su política de saturación; documenta en la cabecera qué ocurre cuando se llena y por qué.
- Compila con
-ffreestandingy sin biblioteca estándar un programa mínimo que use solo tus búferes fijos, y comprueba connmque no queda ninguna referencia amalloc.