← Índice del curso

Sistemas Embebidos · Tema 29 · Bloque 6

El mapa de memoria

En el tema anterior vimos qué memorias tiene un microcontrolador. Ahora vemos cómo se reparte entre ellas un programa concreto: qué queda en la flash, qué queda en la RAM, en qué dirección exacta y cuánto espacio sobra. Ese reparto lo decide el enlazador, y se puede leer, medir y controlar. Quien sabe leer el mapa encuentra en minutos errores que de otro modo cuestan semanas: la pila que pisa a los datos, la tabla que ocupa RAM sin necesidad, la variable que no sobrevive a un reinicio.

01 · El panorama

Una vista general del mapa

Los temas 7 y 8 mostraron las piezas: el enlazador agrupa el código compilado en secciones, el linker script las asigna a regiones de memoria y el código de arranque copia .data y limpia .bss. Este tema junta todo en una sola imagen, la del mapa de memoria del firmware, y agrega las dos zonas que no aparecen en el archivo objeto porque se crean en tiempo de ejecución: la pila y el heap.

Mapa de memoria de un firmware en un Cortex-M A la izquierda, la flash desde 0x08000000: la tabla de vectores, el código .text, las constantes .rodata y la copia inicial de .data. A la derecha, la RAM desde 0x20000000: .data, .bss, el heap que crece hacia arriba, el hueco libre y la pila que crece hacia abajo desde 0x20005000. Una flecha muestra que al arrancar se copia la copia de .data de la flash a la RAM. FLASH (64 KB)RAM (20 KB) .isr_vector .text .rodata .data (copia) sin usar .data .bss HEAP hueco libre PILA copia al arrancar 0x20000000_end0x20000170SP (baja)_estack0x20005000 0x080000000x08000C7C0x08000EF4 heap subepila baja el resto de la flash, libre

Cuatro cosas conviene leer en esta figura antes de seguir:

  • Hay dos mapas, uno por memoria, y se llenan con reglas distintas. La flash se llena de abajo hacia arriba con lo que el enlazador coloca. La RAM se llena desde ambos extremos: lo estático desde abajo, la pila desde el tope.
  • Lo estático (.data y .bss) tiene tamaño conocido al enlazar. El enlazador lo sabe y puede avisar si no entra.
  • El heap y la pila no tienen tamaño conocido al enlazar: crecen en tiempo de ejecución, uno hacia el otro, y comparten el hueco que quedó. Por eso son el origen de los errores más difíciles de memoria.
  • .data existe dos veces: una copia con los valores iniciales en la flash y la ubicación definitiva en la RAM. El arranque (tema 8) copia una sobre la otra.

02 · El vocabulario

Las secciones, una por una

Cada declaración en C produce contenido en alguna sección. Esta tabla es la que conviene saber de memoria:

Secciones de un firmware típico, qué contienen y dónde viven.
SecciónContenidoMemoriaEjemplo en C
.isr_vectorTabla de vectores: pila inicial y rutinas de interrupciónFlashEl arreglo de punteros del tema 8
.textInstrucciones de las funcionesFlashvoid led_alternar(void) {...}
.rodataDatos de solo lectura: constantes, tablas y literalesFlashconst uint8_t senos[256], "Error"
.dataVariables globales y estáticas con valor inicial distinto de ceroRAM (con copia en flash)int umbral = 50;
.bssVariables globales y estáticas sin valor inicial, o con valor ceroRAM (no ocupa flash)uint16_t lecturas[100];
.noinitVariables que el arranque no tocaRAMCon un atributo de sección (apartado 8)
HeapMemoria dinámica pedida con mallocRAM (no es una sección del ejecutable)uint8_t *p = malloc(64);
PilaVariables locales, direcciones de retorno, registros guardadosRAM (no es una sección del ejecutable)int i; dentro de una función

Dos cosas de esta tabla sorprenden la primera vez. La primera: .bss no ocupa espacio en la flash. Un arreglo de 4 KB sin inicializar agrega 4 KB al uso de RAM pero cero bytes al archivo binario, porque el programa de arranque lo llena de ceros y no hay nada que guardar. En cambio, un arreglo de 4 KB con valores iniciales agrega 4 KB a la RAM y 4 KB a la flash (la copia). La segunda: el heap y la pila no están en el ejecutable. No hay ninguna sección que los contenga: son zonas de la RAM que el programa usa mientras corre.

Algunos casos que confunden, resueltos de una vez:

int           g_valor   = 7;            /* .data   : tiene valor inicial distinto de cero          */
int           g_cero    = 0;            /* .bss    : el valor cero no se guarda, se genera al limpiar la zona */
int           g_sin;                    /* .bss    : sin valor inicial                                        */
const int     k_limite  = 5;            /* .rodata : constante, vive solo en la flash                         */
static int    s_cuenta;                 /* .bss    : estática de archivo                                      */

char         *p_texto   = "hola";       /* p_texto (4 bytes) en .data;   el texto "hola" (5 bytes) en .rodata */
char          a_texto[] = "hola";       /* a_texto (5 bytes) en .data:   una COPIA modificable del literal    */
const char    c_texto[] = "hola";       /* c_texto (5 bytes) en .rodata: solo lectura                         */

void f(void) {
    int        local;                   /* pila (o un registro)                                               */
    static int veces;                   /* .bss: una estática local vive para siempre, no en la pila          */
    int       *dinamico = malloc(16);   /* 'dinamico' en la pila; los 16 bytes a los que apunta, en el heap   */
}

La diferencia entre p_texto y a_texto vale la pena recordarla: con el puntero, el texto queda en la flash y ocupa 5 bytes más 4 del puntero; con el arreglo, el texto se copia a la RAM y ocupa 5 bytes de RAM (más los 5 de la copia en flash). Para un mensaje que no se modifica, el puntero a const char es lo que corresponde.

03 · Dos direcciones para una sección

La flash: LMA y VMA

El enlazador maneja dos direcciones para cada sección, y la distinción es la clave para entender .data:

Las dos direcciones de una sección.
SiglaNombreSignificadoPara .data del ejemplo
VMAVirtual Memory Address (dirección de ejecución)Dónde estará la sección cuando el programa corra; es la dirección que el código usa para acceder a las variables0x20000000 (en la RAM)
LMALoad Memory Address (dirección de carga)Dónde se guarda la sección en el archivo y en la flash, para poder cargarla0x08000EF4 (en la flash)

Para .text y .rodata, VMA y LMA coinciden: el código se ejecuta y se lee donde está guardado. Para .data son distintas, y por eso el script usa > RAM AT> FLASH: «ubicala en la RAM (VMA) pero guardala en la flash (LMA)». El programa de arranque (tema 8) completa el puente copiando de la LMA a la VMA, con los símbolos _sidata (origen), _sdata y _edata (destino).

De ahí sale un cálculo importante, el que hay que repetir cada vez que se piensa en cuánto ocupa un programa:

Cuánto ocupa un firmware en cada memoria.
MemoriaFórmulaCon el ejemplo del tema 7
Flash usada.isr_vector + .text + .rodata + .data (la copia)0x130 + 0xB4C + 0x278 + 0x18 = 3852 bytes (5,9 % de 64 KB)
RAM estática.data + .bss0x18 + 0x158 = 368 bytes (1,8 % de 20 KB)
RAM disponible para heap y pilaRAM total − RAM estática20 480 − 368 = 20 112 bytes

Observá que .data aparece en las dos filas: ocupa lugar en la flash y en la RAM. Y que ni el heap ni la pila figuran en esa cuenta: lo que sobra es lo que se disputan.

04 · El reparto de la RAM

La RAM: estática, heap y pila

La RAM se llena de ambos lados, y entre las dos zonas queda un hueco que ninguno de los dos «posee»:

  1. Desde la dirección más baja hacia arriba: primero .data, luego .bss (y .noinit si existe). Después viene el heap, que empieza justo donde termina lo estático (el símbolo _end o end) y crece hacia direcciones altas cada vez que malloc pide más.
  2. Desde la dirección más alta hacia abajo: la pila. Su punto de partida es el símbolo _estack (el tope de la RAM), que el hardware carga en el puntero de pila al arrancar (palabra 0 de la tabla de vectores). Cada llamada a una función y cada variable local la hacen bajar.
  3. En el medio: el hueco libre. Mientras el heap crece hacia arriba y la pila baja, el hueco se achica. Si se cruzan, hay un desastre silencioso: cada uno pisa los datos del otro.

¿Por qué esta disposición, con dos zonas que crecen una hacia la otra? Porque nadie sabe de antemano cuál va a necesitar más. Un programa con muchas llamadas anidadas y casi sin malloc usa pila; uno con listas dinámicas y llamadas superficiales usa heap. Si cada una tuviera una zona fija, habría que dimensionar las dos para el peor caso de cada una, desperdiciando RAM. Con el hueco compartido, lo que una no usa lo aprovecha la otra. El precio es que nada impide el cruce.

En los AVR la idea es la misma con otras direcciones: la RAM de un ATmega328P va de 0x0100 a 0x08FF (los registros y los periféricos ocupan las direcciones anteriores), .data y .bss están abajo, el heap arranca en __heap_start y la pila parte de RAMEND y baja. Lo que cambia son los nombres y el hecho de que cada byte vale mucho más.

05 · Lo que crece sin que lo pidas

La pila en detalle

La pila es una región de la RAM manejada con un puntero, el SP (stack pointer). En los Cortex-M es «completa y descendente»: el SP apunta al último dato apilado, y apilar significa restar primero al SP y después escribir. Cada llamada a función crea sobre ella un marco (stack frame) con:

  • La dirección de retorno (en los ARM, el registro de enlace LR guardado).
  • Los registros que la función debe preservar si los usa.
  • Las variables locales que no entraron en registros, incluyendo arreglos locales.
  • Los argumentos que no entraron en registros, y el relleno de alineación (a 8 bytes en ARM).

Al volver, el marco se descarta restaurando el SP. No hay nadie que lo limpie: los bytes quedan con su contenido, y por eso una variable local sin inicializar contiene basura (tema 8).

De qué depende cuánta pila se usa

La profundidad máxima de la pila depende de tres factores que se suman, y que conviene separar para estimarlos:

  • El camino de llamadas más profundo del programa principal: la suma de los marcos de las funciones anidadas en el peor camino. Una función que declara char buf[256] local cuesta 256 bytes más sus propios marcos, mientras esté activa.
  • Las interrupciones. Cuando llega una, el hardware apila automáticamente un marco de excepción de 8 palabras (R0 a R3, R12, LR, PC y xPSR: 32 bytes; con coma flotante y apilado perezoso puede llegar a 104 bytes), y luego se ejecuta la rutina con su propio marco. Si pueden anidarse (prioridades, tema 15), se suman. El peor caso es una interrupción que llega justo cuando el programa estaba en su punto más profundo.
  • La recursión. Cada llamada recursiva agrega un marco completo. Una función recursiva con 32 bytes de marco y profundidad 100 necesita 3200 bytes: una profundidad que depende de los datos de entrada, y que por eso casi siempre está prohibida en firmware (tema 39).

Si el sistema usa un RTOS (tema 20), la cuenta se multiplica: cada tarea tiene su propia pila, reservada en la RAM al crearla, además de la pila principal que usan el arranque y las interrupciones. Con 6 tareas de 512 bytes, son 3 KB solo para pilas, y es una de las razones por las que los RTOS ocupan más RAM de lo que parece.

Qué pasa cuando se agota

Cuando la pila baja más de lo que debía, no hay excepción ni mensaje: el SP simplemente apunta a una zona que pertenece a otra cosa. Escribir un marco ahí corrompe lo que había (el heap, variables de .bss o .data si no hay nada en el medio) y el programa se comporta de manera extraña, a veces mucho después y lejos de la causa. Los síntomas típicos: valores de variables que cambian solos, funciones que retornan a lugares absurdos, fallos duros (HardFault) intermitentes. El tema 31 trata la detección; por ahora lo importante es la prevención: estimar y medir.

$ arm-none-eabi-gcc -c -fstack-usage -Os sensor.c
$ cat sensor.su
sensor.c:14:6:leer_sensor        32   static
sensor.c:27:6:formatear          64   static
sensor.c:41:6:procesar_buffer   144   dynamic,bounded

La opción -fstack-usage del compilador genera un archivo .su con el marco de cada función: static significa que el tamaño es fijo y conocido; dynamic, que depende de datos (por ejemplo, un arreglo de largo variable), y bounded que, aun así, tiene un tope. Con esas cifras y el grafo de llamadas se calcula el peor camino, exactamente como hace la Parte 2 del programa de Python de este tema. Es una estimación que nunca reemplaza a la medición real, pero detecta los problemas gruesos antes de ejecutar.

06 · La memoria que se pide en marcha

El heap en detalle

El heap es la zona de la RAM desde donde malloc entrega bloques. En un microcontrolador con la biblioteca newlib, el mecanismo es simple y vale la pena conocerlo: malloc administra bloques, pero cuando se queda sin espacio le pide más al sistema con la función _sbrk, que mueve hacia arriba un único puntero (el «tope del heap») que empieza en _end. El tema 30 trata a fondo malloc, la fragmentación y las alternativas; acá importa el aspecto del mapa: quién impide que ese puntero llegue a la pila.

La respuesta honesta es que, en la versión por defecto, nadie. Una implementación mínima y razonable de _sbrk sí lo comprueba:

/* syscalls.c: _sbrk consciente de la pila (para newlib) */
extern char _end;                       /* primer byte libre después de .bss: lo define el linker script */
#define MARGEN_PILA  256                /* bytes que se dejan siempre para la pila */

void *_sbrk(ptrdiff_t incremento) {
    static char *tope = &_end;                       /* el tope del heap, inicializado una sola vez */
    char *anterior = tope;
    char *sp = (char *)__builtin_frame_address(0);   /* aproximación del puntero de pila actual */

    if (tope + incremento > sp - MARGEN_PILA) {
        errno = ENOMEM;
        return (void *)-1;                           /* malloc devolverá NULL: falla visible, no corrupción silenciosa */
    }
    tope += incremento;
    return anterior;
}

Con esta comprobación, el choque entre heap y pila se convierte de corrupción silenciosa en un malloc que devuelve NULL, algo que el programa puede detectar y manejar. Tiene un límite que hay que decir en voz alta: compara contra el SP de ahora, no contra el que tendrá la pila en el peor momento. Por eso se deja un margen, y por eso la solución más robusta es otra: no usar el heap en la fase de funcionamiento (tema 30) o reservar su tamaño de forma estática, como en el siguiente apartado.

07 · Decidirlo en el enlazador

El linker script completo

El script del tema 7 tenía solo flash, .data y .bss. La versión completa de un proyecto real agrega dos cosas: una sección .noinit para lo que no debe tocarse al arrancar y una reserva de espacio para heap y pila, que convierte «ojalá entren» en un error de enlace si no entran:

/* Reserva mínima: si lo estático no deja este espacio, el ENLAZADOR falla, no el producto en el campo */
_Min_Heap_Size  = 0x400;                       /* 1 KB para el heap  (0 si no se usa malloc) */
_Min_Stack_Size = 0x400;                       /* 1 KB para la pila, medido o estimado        */

SECTIONS
{
  /* ... .isr_vector, .text, .rodata, .data, .bss como en el tema 7 ... */

  .noinit (NOLOAD) :                           /* NOLOAD: no se guarda en la flash ni se inicializa */
  {
    . = ALIGN(4);
    *(.noinit*)
    . = ALIGN(4);
  } > RAM

  ._user_heap_stack :                          /* sección ficticia: solo reserva lugar */
  {
    . = ALIGN(8);
    PROVIDE ( end  = . );                      /* 'end' y '_end': dónde empieza el heap */
    PROVIDE ( _end = . );
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
  } > RAM
}

La sección ._user_heap_stack no contiene nada: solamente avanza el contador de ubicación para «ocupar» 2 KB de RAM. Si .data, .bss y esos 2 KB no caben en los 20 KB de la RAM, el enlazador termina con region 'RAM' overflowed. Es una barrera de seguridad barata: el aviso aparece al compilar y no en la puerta del cliente. Con los números del ejemplo, 368 bytes estáticos más 2048 reservados son 2416 de 20 480; el programa puede crecer su parte estática hasta 18 432 bytes antes de que el enlace falle.

Hay una limitación que conviene entender: la reserva garantiza que el mínimo entra, no que la pila no pueda crecer más allá de lo reservado (sigue disponiendo de todo el hueco). Es un seguro contra la falta de espacio estático, no contra el desborde dinámico. Para lo segundo hay que medir, y es lo próximo.

08 · Tomar el control

Ubicar datos a mano

El enlazador asigna cada cosa por reglas automáticas, pero a veces hace falta una excepción. GCC permite elegir la sección de una variable o función con un atributo, y el linker script decide qué pasa con esa sección. Dos usos frecuentes:

Datos que sobreviven a un reinicio: .noinit

Una variable en .bss se pone en cero cada vez que arranca el programa; una en .noinit no se toca. Si lo único que pasó fue un reinicio (por software, por el watchdog), la RAM conserva sus valores. Es una forma barata de dejar un dato para el arranque siguiente, por ejemplo la causa del reinicio o un contador de reinicios (tema 37):

#define MARCA_VALIDA  0xCAFEF00Du

typedef struct { uint32_t marca; uint32_t reinicios; uint32_t ultimo_error; } Diagnostico;

__attribute__((section(".noinit"))) static Diagnostico diag;   /* el arranque no la limpia */

void diagnostico_iniciar(void) {
    if (diag.marca != MARCA_VALIDA) {         /* primer encendido (o corte de energía): la RAM tiene basura */
        diag.marca = MARCA_VALIDA;
        diag.reinicios = 0;
        diag.ultimo_error = 0;
    } else {
        diag.reinicios++;                     /* hubo un reinicio sin pérdida de energía: el dato sobrevivió */
    }
}

La marca es obligatoria: tras un corte de energía la SRAM contiene valores aleatorios, y sin una marca que distinga «datos míos» de «basura» no hay forma de saber cuál es cuál. Y .noinit no es memoria no volátil: no sobrevive a un apagado ni a un brown-out profundo, solo a reinicios en los que la alimentación se mantuvo.

Funciones que corren desde la RAM: .ramfunc

El apartado del tema 28 sobre la flash dijo que, mientras se borra, la CPU no puede leer de ella. La solución es que el código de borrado y escritura esté en la RAM. Se logra con un atributo y una sección del script que, como .data, vive en la RAM pero se guarda en la flash:

/* En el código */
__attribute__((section(".ramfunc"), noinline)) void borrar_y_programar(uint32_t dir, uint16_t valor);

/* En el linker script, junto a .data */
.ramfunc :
{
  . = ALIGN(4);
  *(.ramfunc*)
  . = ALIGN(4);
} > RAM AT> FLASH                              /* se ejecuta desde la RAM, se guarda en la flash */

El mismo mecanismo permite poner en RAM una rutina de interrupción crítica para que no dependa de las esperas de la flash. Y se puede usar para reservar una página de flash para datos (la del tema 28): se la declara en MEMORY como una región aparte, para que el enlazador no coloque código allí.

Dos opciones del compilador y del enlazador completan el control y bajan el tamaño del programa sin tocar el código: -ffunction-sections -fdata-sections hace que cada función y cada variable tenga su propia sección, y -Wl,--gc-sections permite al enlazador descartar las que nadie usa. Sin ellas, una sola función que llama una biblioteca arrastra el archivo objeto entero.

09 · Mirar lo que pasó

Medir y leer el mapa

Cuatro herramientas bastan, de la más general a la más detallada. Todas están en la toolchain (tema 5) y se corren después de enlazar:

Herramientas para ver el mapa de memoria.
HerramientaQué muestraCuándo usarla
arm-none-eabi-sizeTotales por categoría: text, data, bssSiempre; es la primera mirada y la que se pone en la compilación automática
arm-none-eabi-nm --size-sort -SCada símbolo con su dirección y su tamaño, ordenadosPara encontrar «qué variable o función ocupa tanto»
Archivo .map (-Wl,-Map=...)Todo lo que hizo el enlazador: orden, direcciones, de qué objeto vino cada piezaPara auditar y para entender por qué algo quedó donde quedó
-fstack-usage y pintado de la pilaEl marco de cada función; y la profundidad real alcanzadaPara estimar y para medir la pila

La salida de size para el ejemplo del tema 7 es la siguiente. Leer bien esa línea evita el error más común de principiante:

$ arm-none-eabi-size firmware.elf
   text    data     bss     dec     hex filename
   3828      24     344    4196    1064 firmware.elf
  • text (3828) es todo lo que vive en la flash y no cambia: .isr_vector + .text + .rodata (0x130 + 0xB4C + 0x278). Incluye las constantes aunque su nombre diga «text».
  • data (24) es .data: ocupa RAM y flash (la copia).
  • bss (344) es .bss: ocupa solo RAM.
  • dec (4196) es la suma de las tres, y no significa nada útil: mezcla flash con RAM. Lo que importa son los dos totales de cada memoria: flash = text + data = 3852 y RAM estática = data + bss = 368.

Para saber cuánta pila se usó realmente existe una técnica clásica y muy efectiva, el pintado de la pila (stack painting): al arrancar, antes de main, se llena toda la zona libre de la pila con un patrón reconocible (por ejemplo 0xA5). Después de correr el equipo bajo carga durante un tiempo, se busca desde el fondo de la zona el primer byte que ya no tenga el patrón: lo que quedó intacto es el margen real, y lo que fue pisado, la marca de agua (high-water mark) de uso máximo. La Parte 4 del programa de Python lo reproduce. Es la medición que hay que repetir con la peor carga antes de liberar un producto.

10 · Comprobar que funciona

Verificación y diagnóstico

Comprobaciones sobre el mapa de memoria de un proyecto.
Qué verificarCómoResultado esperado
Uso de flash y de RAM estáticasize y las fórmulas del apartado 3Flash por debajo del 80-90 %; RAM estática con lugar para heap y pila
Que el enlace falle si no entraAgrandar un arreglo .bss más allá de la RAMError region 'RAM' overflowed al enlazar, no un binario defectuoso
Direcciones de las seccionesRevisar el .map.isr_vector en el inicio de la flash; .data con LMA en flash y VMA en RAM; _end alineado a 8
Marco de cada función-fstack-usage y revisar los .suNinguna función con un marco inesperadamente grande; ninguna dynamic sin bounded
Marca de agua de la pilaPintar, correr con la peor carga y medirAl menos el 25-30 % de la pila sin tocar
Hueco entre heap y pilaMostrar el tope del heap y el SP más bajo observadoPositivo, con margen, incluso con todas las interrupciones
Variable en .noinitReiniciar por software, y luego cortar la energíaTras el reinicio el valor sobrevive; tras el corte, la marca no coincide y se reinicializa
Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
Variables que cambian solas, fallos intermitentesLa pila creció hasta pisar datos (o el heap la pisó)Pintar la pila y medir; revisar arreglos locales grandes, recursión e interrupciones anidadas
Funciona en la mesa y falla al activar una función poco frecuenteLa función que usa más pila (por ejemplo printf con coma flotante) no se ejercitó en las pruebasForzar el peor camino durante la medición de la pila
El enlazador dice «RAM overflowed» con pocos datosUn arreglo grande (búfer, tabla) en .bss o .data, o la reserva de heap y pila muy altaBuscar con nm --size-sort; mover constantes a flash; bajar _Min_Heap_Size
El binario es más grande que el código escritoFunciones de biblioteca arrastradas (printf, malloc), sin --gc-sectionsActivar -ffunction-sections y --gc-sections; revisar el .map para ver de dónde viene cada pieza
Las variables inicializadas valen cero o basuraNo se copió .data: símbolos del script incorrectosVerificar _sidata, _sdata, _edata (tema 8)
Un dato en .noinit «se pierde» tras un corte de energíaEs lo esperado: es RAM, no memoria no volátilValidar siempre con una marca; usar EEPROM o flash para lo que deba durar
malloc devuelve NULL con mucha RAM aparentemente libreFragmentación del heap, o el _sbrk protege un margen de pilaVer el tema 30; revisar el tope del heap y el margen

11 · Analogía con programación

Probar el modelo en Python

El programa tiene cuatro partes y trabaja con las mismas cifras del tema 7: la colocación de secciones por el enlazador (con alineación y los cálculos de size), el peor caso de pila a partir de un grafo de llamadas y las interrupciones, el hueco entre el heap y la pila con distintos usos del heap, y el pintado de la pila con su marca de agua.

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

# El mapa de memoria: el enlazador coloca secciones, y la pila y el heap se disputan el resto de la RAM.
FLASH_INI, FLASH_TAM = 0x08000000, 64 * 1024
RAM_INI,   RAM_TAM   = 0x20000000, 20 * 1024
ESTACK = RAM_INI + RAM_TAM                       # tope de la pila (0x20005000)

def alinear(x, a): return (x + a - 1) // a * a

# ---- Parte 1: el enlazador coloca las secciones, una tras otra, respetando la alineación -----------------
OBJETOS = {                                      # (tamaño en bytes) de cada sección por objeto
    ".isr_vector": [("startup.o", 0x130)],
    ".text":   [("startup.o", 0xC0), ("main.o", 0x4B0), ("sensor.o", 0x35C), ("uart.o", 0x280)],
    ".rodata": [("main.o", 0x78), ("sensor.o", 0x200)],
    ".data":   [("main.o", 0x10), ("uart.o", 0x8)],
    ".bss":    [("main.o", 0x40), ("sensor.o", 0x18), ("uart.o", 0x100)],
}
flash, ram, tamanos = FLASH_INI, RAM_INI, {}
print("Parte 1: el enlazador coloca las secciones")
for nombre, piezas in OBJETOS.items():
    en_ram = nombre in (".data", ".bss")
    ini = ram if en_ram else flash
    total = sum(t for _, t in piezas)
    tamanos[nombre] = total
    extra = ""
    if nombre == ".data": extra = f"   (VMA en RAM, copia en flash: LMA {flash:#010x})"
    print(f"  {nombre:<12} {ini:#010x}  {total:#7x} bytes{extra}")
    if en_ram: ram = alinear(ini + total, 4)
    else:      flash = alinear(ini + total, 4)
    if nombre == ".rodata": flash_fin_rodata = flash
    if nombre == ".data":   flash = alinear(flash + total, 4)          # la copia de .data ocupa flash
text = tamanos[".isr_vector"] + tamanos[".text"] + tamanos[".rodata"]
data, bss = tamanos[".data"], tamanos[".bss"]
print(f"  size: text={text} data={data} bss={bss} dec={text + data + bss} hex={text + data + bss:x}")
print(f"  flash usada = text + data = {text + data} bytes ({(text + data) / FLASH_TAM * 100:.1f} % de 64 KB)")
heap_ini = alinear(RAM_INI + data + bss, 8)
print(f"  RAM estática = data + bss = {data + bss} bytes; el heap empieza en {heap_ini:#010x}; libre para heap y pila: {ESTACK - heap_ini} bytes")

# ---- Parte 2: peor caso de pila a partir del grafo de llamadas --------------------------------------------
MARCO = {"main": 48, "leer_sensor": 32, "adc_leer": 24, "formatear": 64, "vsnprintf": 280, "uart_isr": 16, "procesar_byte": 40}
LLAMA = {"main": ["leer_sensor", "formatear"], "leer_sensor": ["adc_leer"], "formatear": ["vsnprintf"],
         "adc_leer": [], "vsnprintf": [], "uart_isr": ["procesar_byte"], "procesar_byte": []}
MARCO_EXCEPCION = 32                              # el hardware apila 8 palabras al entrar a una interrupción (sin FPU)

def peor(f):
    """Pila máxima que puede llegar a necesitar f, incluyendo lo que llame."""
    hijos = [peor(h) for h in LLAMA[f]]
    prof, camino = max(hijos, key=lambda r: r[0]) if hijos else (0, [])
    return MARCO[f] + prof, [f] + camino

print()
print("Parte 2: la pila en el peor caso")
p_main, camino = peor("main")
p_isr, camino_isr = peor("uart_isr")
print(f"  camino más profundo: {' -> '.join(camino)} = {p_main} bytes")
print(f"  una interrupción justo ahí: {MARCO_EXCEPCION} (marco de excepción) + {p_isr} ({' -> '.join(camino_isr)}) = {MARCO_EXCEPCION + p_isr}")
peor_caso = p_main + MARCO_EXCEPCION + p_isr
print(f"  pila total en el peor caso: {peor_caso} bytes; con 25 % de margen: {int(peor_caso * 1.25)} bytes")

# ---- Parte 3: heap que crece hacia arriba, pila que crece hacia abajo; ¿se pisan? ------------------------
print()
print("Parte 3: el hueco entre el heap y la pila")
for heap_usado in (0, 4096, 12288, 19000, 19800):
    tope_heap = heap_ini + heap_usado
    sp_peor = ESTACK - peor_caso
    hueco = sp_peor - tope_heap
    print(f"  heap usado {heap_usado:>6} B: tope del heap {tope_heap:#010x}, SP en el peor caso {sp_peor:#010x}, hueco {hueco:>6} B -> {'COLISIÓN' if hueco < 0 else 'ok'}")

# ---- Parte 4: pintar la pila y medir cuánto se usó (marca de agua) -------------------------------------
print()
print("Parte 4: pila pintada con 0xA5 y marca de agua")
TAM_PILA = 1024
pila = bytearray([0xA5] * TAM_PILA)               # la pila ocupa los últimos 1024 bytes de la RAM, y crece hacia abajo
def usar(profundidad):                            # la pila llega a 'profundidad' bytes: esos bytes quedan escritos
    for i in range(profundidad): pila[TAM_PILA - 1 - i] = 0x00
for etapa, profundidad in (("arranque y main", 48), ("formatear + vsnprintf", 392), ("una interrupción encima", 480)):
    usar(profundidad)
    sin_tocar = 0
    while sin_tocar < TAM_PILA and pila[sin_tocar] == 0xA5: sin_tocar += 1
    usados = TAM_PILA - sin_tocar
    print(f"  tras «{etapa}»: usados {usados:>4} B, libres {sin_tocar:>4} B ({sin_tocar / TAM_PILA * 100:.0f} % de la pila)")

Resultado, parte 1: el enlazador ubica .isr_vector en 0x08000000, .text en 0x08000130, .rodata en 0x08000C7C, y .data en la RAM (0x20000000) con su copia (LMA) en 0x08000EF4; .bss sigue en 0x20000018. El cálculo de size da text=3828 data=24 bss=344 (suma 4196, que es 0x1064): la flash usada es 3852 bytes (5,9 % de 64 KB), la RAM estática 368 bytes, el heap empieza en 0x20000170 y quedan 20 112 bytes para heap y pila. Parte 2: el camino más profundo es main → formatear → vsnprintf con 392 bytes; una interrupción en ese instante suma 32 (marco de excepción) + 56 (uart_isr → procesar_byte) = 88; el peor caso es de 480 bytes, y con 25 % de margen, 600. Parte 3: con ese peor caso de pila, el hueco es de 19 632 bytes con el heap vacío, 15 536 con 4096 usados, 7344 con 12 288, 632 con 19 000 y −168 con 19 800: colisión. Parte 4: la pila pintada con 0xA5 muestra 48 bytes usados tras el arranque (976 libres, 95 %), 392 tras formatear + vsnprintf (632 libres, 62 %) y 480 con una interrupción encima (544 libres, 53 %).

Probá agregar una función con char buffer[512] local (un marco de 512 bytes) en el camino de main y mirá cómo cambia el peor caso, o hacé RAM_TAM = 2 * 1024 para ver los mismos números en un chip de 2 KB: con el heap vacío el hueco cae a 1200 bytes, y los casos de heap de 4096 bytes o más pasan a COLISIÓN. Cambiar un número y ver la consecuencia en el acto es lo que convierte el mapa en una herramienta de diseño.

12 · Exploración interactiva

La RAM

El laboratorio es la RAM de 20 KB del ejemplo, con lo estático (368 bytes, el .data y .bss del tema 7), el heap que crece hacia arriba y la pila que crece hacia abajo desde 0x20005000. Movés el uso del heap y la profundidad de la pila, y mirás el hueco que queda en el medio y las direcciones del tope del heap y del SP.

Heap, pila y el hueco entre ambos

Oscuro: .data + .bss · Verde claro: heap · Rayado: hueco libre · Naranja: pila (de derecha a izquierda).

Tope del heap—
SP (puntero de pila)—
Pila total—
Hueco entre ambos—
Hueco / RAM—

Movés los controles y mirá el hueco.

Recorrido guiado

  1. Con los valores iniciales (heap de 4096 y pila de 392), el hueco es de unos 15 KB. Fijate en las dos direcciones: el tope del heap (0x20001170) y el SP (0x20004E78).
  2. Apretá «Pila: solo main» y después «formatear + vsnprintf»: el SP baja 344 bytes. Casi todo ese costo es de la función de formateo (280 de los 344 bytes): una sola llamada cuesta casi seis veces lo que cuesta main.
  3. Marcá «Una interrupción llega en el punto más profundo»: el SP baja otros 88 bytes. Es el peor caso de pila real: 480 bytes. Los 88 bytes extra son los que no aparecen si solo se mira el programa principal.
  4. Subí el heap hasta 19 800 con la pila en 480: el hueco se vuelve negativo y el programa lo avisa. En el equipo real no habría aviso, solo corrupción.
  5. Bajá el heap a 0 y apretá «Pila: recursión de 100 niveles (3200 B)»: la pila ahora ocupa casi el 16 % de la RAM. Subí lentamente el valor hasta 8000: una recursión descontrolada se come la RAM entera sin tocar el heap.
  6. Con el heap en 12 288 y la pila en 392, mirá el hueco (unos 7 KB). Es un diseño con margen. Ahora comparalo con el de 19 000: sigue sin colisión, pero el margen es de apenas 720 bytes (el 3,5 % de la RAM), y cualquier cambio de código puede romperlo. Un hueco que depende de que nada cambie no es un margen.
Desafío: ¿cómo se garantiza que la pila nunca invada el heap?

No se puede garantizar solo con el mapa; hace falta ayuda del hardware o del diseño. Opciones, de menor a mayor robustez: (1) medir con pintado de pila bajo la peor carga y dejar un margen del 25-30 %; (2) poner un «canario», una palabra con un valor conocido en el límite de la pila, y verificarla periódicamente (por ejemplo, en el tick del sistema) para detectar que fue pisada; (3) usar la MPU (Memory Protection Unit) de los Cortex-M para marcar una zona de protección debajo de la pila: cualquier escritura ahí dispara una falla en el acto, y no mucho después (tema 31); (4) ubicar la pila al fondo de la RAM, de modo que al desbordar caiga fuera de la memoria y produzca un fallo inmediato en lugar de corrupción silenciosa (algunos chips lo permiten con la pila debajo de .data y .bss); y (5) eliminar la causa: sin heap y sin recursión, el peor caso de pila se puede calcular y la colisión es imposible.

13 · Método de trabajo

Método: presupuesto de memoria

  1. Antes de elegir el chip, estimá. Flash: código esperado más constantes más una página de datos. RAM: variables, búferes, pila (peor camino más interrupciones, más el de cada tarea si hay RTOS) y heap si se usa. Sumá un margen del 30 %.
  2. Tené el mapa a mano desde el primer día. Agregá size a la compilación automática y mirá cómo evolucionan flash y RAM en cada cambio; los crecimientos bruscos señalan quién los causó.
  3. Hacé que el enlazador proteja. Reservá heap y pila en el script con _Min_Heap_Size y _Min_Stack_Size: si lo estático crece demasiado, el enlace falla.
  4. Calculá la pila en el peor caso con -fstack-usage y el grafo de llamadas. Incluí la interrupción de mayor costo cayendo en el punto más profundo. Evitá la recursión y los arreglos locales grandes.
  5. Medí la pila de verdad: pintala al arrancar, corré el peor caso del producto (las funciones de formato, las comunicaciones, los fallos) y leé la marca de agua. Dejá un 25-30 % libre.
  6. Revisá qué ocupa más. Con nm --size-sort, mirá los cinco símbolos más grandes de RAM y de flash: casi siempre hay uno que se puede mover, reducir o eliminar.
  7. Decidí sobre el heap. Si no usás malloc, ponelo en cero en el script y evitá toda una clase de problemas. Si lo usás, protegé _sbrk y planeá para la fragmentación (tema 30).
  8. Documentá el presupuesto en el repositorio: cuánto se reserva a cada zona y por qué. El próximo que agregue una función tiene que saber cuánto margen queda.

14 · Comprensión

Confusiones frecuentes

«La suma dec de size es lo que ocupa mi programa»

Mezcla dos memorias. La flash usada es text + data y la RAM estática es data + bss; el total dec no corresponde a ninguna memoria real. Además, la RAM estática no incluye la pila ni el heap, que son lo que más RAM consume en muchos programas.

«Si size dice que uso el 20 % de la RAM, me sobra el 80 %»

Ese 20 % es solo lo estático. Lo que queda es lo que se disputan el heap y la pila, y un programa con recursión o printf con coma flotante puede necesitar mucho de eso. La RAM «libre» según size es un tope, no una reserva.

«Un arreglo grande sin inicializar es gratis porque no ocupa flash»

No ocupa flash, pero ocupa RAM de forma permanente desde que arranca el programa, y la RAM suele ser el recurso más escaso. Que no cueste espacio en el binario no significa que no cueste.

«Las variables locales no cuentan para el presupuesto de memoria»

Cuentan, y mucho si son grandes. Un char buf[512] local ocupa 512 bytes de pila mientras la función esté activa, y en un AVR con 2 KB es la cuarta parte de la RAM. No aparecen en size ni en el .map, y por eso se estiman con -fstack-usage y se miden con el pintado de la pila.

«Si el programa corre, la pila alcanza»

Solo si ya pasó por su peor camino. Una pila desbordada no avisa: corrompe. El programa puede funcionar meses hasta que ocurre la combinación (una función de formato, una interrupción, un error de comunicación) que lo lleva al límite. Hay que provocar el peor caso a propósito.

«.noinit es una memoria no volátil gratis»

Es RAM: sobrevive a un reinicio mientras la alimentación se mantenga, y se pierde en un apagado. Sin una marca que la valide, tampoco se sabe si lo que contiene es un dato o basura de un encendido nuevo. Para lo que debe durar, hay que usar EEPROM o flash.

«char *p = "texto" y char a[] = "texto" son lo mismo»

El puntero deja el texto en la flash (solo lectura) y ocupa lo del puntero en RAM; el arreglo copia el texto a la RAM (modificable) y gasta tanta RAM como caracteres. Intentar escribir en el literal del puntero es un error de comportamiento indefinido.

«La pila y el heap se reparten la RAM por mitades»

No hay reparto fijo: cada una crece hasta donde la lleve el programa, desde extremos opuestos. Puede que la pila use el 2 % y el heap el 90 %, o al revés. Por eso lo que importa es el hueco que queda entre ambas en el peor momento.

15 · Práctica guiada

Ejercicios

1. Leer la salida de size

Un firmware para un STM32F103C8 (64 KB de flash, 20 KB de RAM) muestra text=12000 data=400 bss=3600. ¿Cuánta flash y cuánta RAM estática usa? ¿Qué porcentaje es de cada memoria? ¿Cuánto queda para el heap y la pila?

Ver solución

Flash: text + data = 12 000 + 400 = 12 400 bytes, o sea 12 400 / 65 536 = 18,9 %. RAM estática: data + bss = 400 + 3600 = 4000 bytes, o sea 4000 / 20 480 = 19,5 %. Para el heap y la pila quedan 20 480 − 4000 = 16 480 bytes. Atención al error típico: sumar las tres cifras (16 000) y creerlo un uso de memoria; esa suma no corresponde a ninguna memoria.

2. ¿Dónde está cada cosa?

Indicá la sección de cada una (variables globales): (a) int g = 7;, (b) int h;, (c) const int k = 5;, (d) char *p = "hola"; (el puntero y el texto), (e) char s[] = "hola";.

Ver solución

(a) .data: valor inicial distinto de cero. (b) .bss: sin valor inicial. (c) .rodata: constante, solo flash. (d) El puntero p en .data (inicializado con una dirección distinta de cero, 4 bytes) y el texto "hola" en .rodata (5 bytes con el terminador). (e) s en .data: 5 bytes en RAM, con los caracteres iniciales copiados desde la flash al arrancar. Gasta más RAM que (d), a cambio de poder modificarse.

3. Recursión

Una función recursiva con un marco de 32 bytes se llama a sí misma hasta una profundidad de 100. ¿Cuánta pila usa? ¿A qué profundidad se agota una pila de 1 KB?

Ver solución

100 × 32 = 3200 bytes. Una pila de 1024 bytes alcanza para 1024 / 32 = 32 niveles; el nivel 33 ya sale de ella y empieza a escribir sobre lo que hay debajo. Y esto sin contar el marco de main, el de las funciones de arriba ni una interrupción: el límite real es todavía menor. Por eso en firmware se evita la recursión, o se limita explícitamente su profundidad con un número calculado.

4. El hueco

En el ejemplo del tema (heap desde 0x20000170, tope de pila en 0x20005000), el heap usa 12 288 bytes y la pila, en su peor caso, 480. ¿En qué direcciones están el tope del heap y el SP, y cuánto es el hueco?

Ver solución

Tope del heap: 0x20000170 + 12 288 (0x3000) = 0x20003170. SP: 0x20005000 − 480 (0x1E0) = 0x20004E20. Hueco: 0x20004E20 − 0x20003170 = 0x1CB0 = 7344 bytes, un 36 % de la RAM. Es un margen sano para el ejemplo.

5. La reserva del script

El script reserva _Min_Heap_Size = 0x400 y _Min_Stack_Size = 0x400. Con .data + .bss = 368 bytes y 20 KB de RAM, ¿cuánto puede crecer la RAM estática antes de que el enlace falle? ¿Qué garantiza y qué no esa reserva?

Ver solución

Se reservan 2048 bytes (1024 + 1024). El enlace falla cuando lo estático supera 20 480 − 2048 = 18 432 bytes. Garantiza que siempre hay al menos 1 KB de heap y 1 KB de pila reservados: lo estático no puede comérselos sin que el enlazador avise. No garantiza que la pila no crezca más de 1 KB (la pila sigue pudiendo usar todo el hueco), ni que el heap y la pila no se crucen en ejecución; para eso hay que medir.

6. Un dato que sobrevive

Querés guardar el número de reinicios del watchdog para mostrarlo luego. Proponés una variable en .noinit. ¿Qué dos problemas tiene la idea, y cómo se resuelven?

Ver solución

Primero, en el primer encendido (o tras un corte de energía) la RAM contiene basura, así que el contador valdría cualquier cosa: se resuelve con una marca (una palabra mágica) que se verifica al arrancar; si no coincide, se inicializa el contador en cero. Segundo, .noinit no sobrevive a un corte de energía, así que el contador se reinicia con cada apagado: si hace falta un total de por vida, no sirve y hay que usar EEPROM o flash, escribiendo solo cuando el contador cambie (y con el cuidado del desgaste del tema 28). Para «cuántas veces se reinició el watchdog desde que se encendió», en cambio, .noinit con marca es exactamente la herramienta correcta.

16 · Síntesis

Ideas para recordar

  • El mapa de memoria es el reparto del programa entre flash y RAM, con direcciones concretas. El enlazador lo calcula para lo estático; la pila y el heap, que se crean en ejecución, quedan en el resto de la RAM.
  • Las secciones son .isr_vector, .text y .rodata (flash), .data (RAM, con copia en flash) y .bss (RAM, no ocupa flash), más .noinit opcional. La pila y el heap no son secciones del ejecutable.
  • .data tiene dos direcciones: la LMA (donde se guarda, en flash) y la VMA (donde se usa, en RAM); el arranque copia de una a otra.
  • Flash usada = text + data; RAM estática = data + bss. El total dec de size no significa nada útil. La RAM estática no incluye pila ni heap.
  • La RAM se llena desde ambos extremos: lo estático y el heap hacia arriba, la pila hacia abajo desde _estack. Entre ambos queda el hueco compartido, y si se cruzan, la corrupción es silenciosa.
  • La pila depende del camino de llamadas más profundo, de las interrupciones (32 bytes de marco de excepción más su propio marco) y de la recursión; con RTOS, cada tarea suma su propia pila. Se estima con -fstack-usage y se mide pintándola.
  • Se puede ubicar datos a mano: .noinit para lo que sobrevive a un reinicio (con una marca de validez) y .ramfunc para código que corre desde RAM. El script puede reservar heap y pila para que el enlace falle si no entran.
  • Medir manda sobre suponer: size da los totales, nm los símbolos, el .map la auditoría y el pintado de pila el uso real. El presupuesto de memoria se hace desde el diseño, con margen.

¿Listo para el tema siguiente?

Antes de avanzar, tomá un proyecto tuyo (o el esqueleto del tema 12), compilalo con -Wl,-Map=firmware.map y respondé con números: cuánta flash y cuánta RAM estática usa, en qué dirección empieza el heap, cuáles son los cinco símbolos más grandes, cuál es el peor camino de pila según -fstack-usage y cuánto hueco queda. Si podés escribir esas seis cifras, ya sabés leer el mapa.

En el próximo tema nos metemos en la zona más delicada del mapa: la asignación estática y dinámica. Qué le pasa a malloc en un sistema con poca RAM, por qué se fragmenta el heap y qué alternativas (pools de memoria y buffers circulares) permiten evitarlo.