wandres.dev
ENLAZADOR · El linker y ELF

Scripts de linker: controlar la disposición de memoria

El fichero que decide dónde vive cada byte. Esta lección lee el script por defecto que siempre estuvo ahí, construye desde cero un mapa de memoria con MEMORY y SECTIONS, domina el contador de posición y los símbolos exportados hacia C, y resuelve el problema de VMA frente a LMA que separa a un programa hospedado de uno que arranca sobre metal desnudo.

⏱ 20 min

En un sistema operativo, la pregunta de dónde acaba cada sección tiene una respuesta cómoda: donde el enlazador quiera, porque hay un cargador que lo resolverá. En un microcontrolador o en un núcleo no hay tal cargador. La memoria flash empieza en una dirección física concreta, la memoria RAM en otra, el vector de reinicio debe estar en el primer byte del mapa porque así lo cablearon, y los datos inicializados están grabados en flash pero deben ejecutarse desde RAM. Todo eso se expresa en un lenguaje de configuración que el enlazador siempre ha estado usando sin decírtelo. Aprenderlo es dejar de aceptar la disposición del binario y empezar a dictarla.

🎯 Al terminar esta lección sabrás
  • Leer el script por defecto que el enlazador aplica y localizar en él las decisiones que creías automáticas.
  • Describir un mapa de memoria con MEMORY y repartir la salida con SECTIONS.
  • Manipular el contador de posición y exportar símbolos del script hacia el código C.
  • Distinguir dirección virtual de dirección de carga y escribir el arranque que las concilia.

El script que siempre estuvo ahí

Cuando enlazas sin decir nada, el enlazador no improvisa: aplica un script interno compilado en su binario. Puedes leerlo, y el ejercicio es revelador porque muestra que el orden de las secciones, la alineación de los segmentos y la existencia de símbolos como __bss_start o _end no son propiedades del formato ELF sino decisiones escritas en un fichero de configuración que alguien redactó.

ld --verbose | less                   # el script por defecto completo
gcc -Wl,--verbose prog.c 2>&1 | less  # el mismo, tal como lo invoca el compilador
ld -T enlace.ld obj.o -o imagen.elf   # sustituirlo por el tuyo
gcc -T enlace.ld ...                  # o pasarlo a traves del compilador

Un script se compone de comandos de nivel superior. ENTRY fija el punto de entrada que acabará en el campo e_entry de la cabecera. MEMORY describe las regiones físicas disponibles. SECTIONS es el corazón: dicta qué secciones de entrada acaban en qué sección de salida y en qué dirección. PROVIDE define símbolos que el código podrá usar. Y ASSERT permite fallar el enlazado si una condición de tamaño no se cumple, que es la forma correcta de descubrir que el binario no cabe en flash.

💡
El mapa de enlazado es el informe que nadie lee

Al enlazar con -Wl,-Map=salida.map obtienes un fichero que enumera cada sección de entrada, de qué objeto vino, en qué dirección quedó y por qué se incluyó. Cuando una imagen crece sin explicación o un símbolo aparece donde no debía, ese fichero contiene la respuesta literal. Léelo antes de formular cualquier hipótesis.

MEMORY y SECTIONS

El bloque MEMORY declara regiones con un nombre, unos atributos de lectura, escritura y ejecución, una dirección de origen y una longitud. Es una descripción del hardware, no una petición: si pides colocar allí más de lo que cabe, el enlazado falla con un mensaje explícito, y eso es exactamente lo que quieres que ocurra.

El bloque SECTIONS define las secciones de salida. Dentro de cada una se listan patrones de secciones de entrada, donde el asterisco inicial significa cualquier objeto. La sufijo .text* con comodín es importante porque recoge también las secciones que genera -ffunction-sections, una por función.

ENTRY(_reset)

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 256K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS
{
  .vectores : {
    KEEP(*(.vectores))        /* KEEP lo salva de --gc-sections */
  } > FLASH

  .text : {
    *(.text .text.*)
    *(.rodata .rodata.*)
  } > FLASH

  .data : {
    __data_inicio = .;
    *(.data .data.*)
    __data_fin = .;
  } > RAM AT > FLASH          /* vive en RAM, se almacena en FLASH */

  .bss (NOLOAD) : {
    __bss_inicio = .;
    *(.bss .bss.* COMMON)
    __bss_fin = .;
  } > RAM

  __data_origen = LOADADDR(.data);
  __pila_cima   = ORIGIN(RAM) + LENGTH(RAM);
}

La construcción KEEP merece atención: sin ella, la tabla de vectores de interrupción no está referenciada por ningún código y --gc-sections la elimina, produciendo un binario perfectamente válido que no arranca. Es uno de los fallos más desconcertantes del desarrollo embebido y tiene esta única línea como causa.

El contador de posición y los símbolos que cruzan a C

Dentro de SECTIONS, el punto es el contador de posición: la dirección actual, que avanza sola conforme se colocan datos y que puedes leer, asignar y alinear. Asignarle un valor mayor crea un hueco; asignarle uno menor es un error. Con ALIGN lo redondeas hacia arriba, algo obligatorio antes de una región que el hardware exija alineada.

Los símbolos definidos en el script se convierten en símbolos ELF normales, y ahí está el detalle que confunde a todo el mundo la primera vez: su valor es la dirección, no el contenido. Para usarlos desde C hay que declararlos como arreglos externos de tipo incompleto y tomar su dirección, nunca leerlos como si fueran variables.

extern char __data_inicio[], __data_fin[], __data_origen[];
extern char __bss_inicio[],  __bss_fin[];

void _reset(void) {
    // Copiar los datos inicializados desde flash hacia RAM
    for (char *d = __data_inicio, *s = __data_origen; d < __data_fin; )
        *d++ = *s++;

    // Poner a cero la region bss, que nadie almacena en flash
    for (char *b = __bss_inicio; b < __bss_fin; )
        *b++ = 0;

    extern int main(void);
    main();
    for (;;) { }
}
📍

El contador de posición

Se lee y se escribe con el punto. Avanza automáticamente, y asignarle un valor deja un hueco deliberado en el mapa.

📐

ALIGN y ALIGNOF

Redondean la dirección actual hacia arriba. Imprescindibles antes de una tabla de vectores o de una región con requisitos de la unidad de protección de memoria.

🏷️

PROVIDE

Define un símbolo solo si el código no lo ha definido ya. Permite ofrecer valores por defecto sin provocar definiciones múltiples.

🚨

ASSERT

Detiene el enlazado si una expresión de tamaño o alineación no se cumple. Convierte un desbordamiento de flash en un error de construcción.

VMA frente a LMA: el problema del arranque

Toda sección de salida tiene dos direcciones. La dirección virtual, o VMA, es donde el código espera encontrarla al ejecutarse, y es la que usan las reubicaciones. La dirección de carga, o LMA, es donde los bytes residen físicamente en la imagen. En un sistema hospedado coinciden casi siempre y por eso la distinción pasa desapercibida. En un microcontrolador no pueden coincidir para .data: los valores iniciales tienen que estar grabados en memoria no volátil, pero las variables deben ser escribibles y por tanto vivir en RAM.

La cláusula AT es la que expresa esa disyunción, y LOADADDR recupera la dirección de carga resultante para que el código de arranque sepa desde dónde copiar. Con esas tres piezas —VMA en RAM, LMA en flash y una copia al arrancar— queda explicado por completo el prólogo de cualquier firmware.

flowchart LR
A[Imagen grabada en FLASH] --> B[Copia inicial de la seccion data]
B --> C[Bucle de arranque copia hacia RAM]
C --> D[Seccion data operativa en RAM]
A --> E[Seccion text ejecutada en sitio desde FLASH]
F[Seccion bss sin contenido almacenado] --> G[Puesta a cero por el arranque]
style C fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b
readelf -l imagen.elf                      # compara VirtAddr con PhysAddr por segmento
objcopy -O binary imagen.elf imagen.bin    # la imagen plana que se graba, ordenada por LMA
size -A imagen.elf                         # cuanto ocupa cada seccion de salida
El script es el contrato entre el software y el silicio

Vale la pena detenerse en lo que este fichero significa, porque reorganiza mucho de lo aprendido. Un programa hospedado disfruta de una ficción muy elaborada: un espacio de direcciones plano, privado y aparentemente infinito, en el que la pregunta de dónde vive cada cosa la responde el sistema operativo. El script de enlazado es lo que queda cuando esa ficción se retira. Aquí las direcciones no son virtuales sino cableadas, y cada una tiene una razón física detrás: el vector de reinicio está donde está porque el procesador lee su primera palabra de ahí al soltar la señal de reset; la flash es de solo lectura porque el silicio lo impone y no porque un bit de página lo diga; el tamaño de la RAM es un número finito y pequeño que ninguna paginación va a estirar. De esa dureza salen consecuencias que en un hosted jamás aparecen. Sale la necesidad de copiar .data a mano, porque no hay cargador que lo haga por ti. Sale la de poner .bss a cero explícitamente, y de ahí que el estándar prometa esa inicialización aunque alguien tenga que cumplirla en un bucle de veinte instrucciones. Sale la de colocar la pila en el extremo alto de la RAM para que crezca hacia abajo alejándose de tus datos, y la de reservar un símbolo para su cima. Y sale, sobre todo, la comprensión de que el entorno freestanding del estándar no es una versión mutilada de C, sino C sin las suposiciones que un sistema operativo regala. Cuando escribes tu propio script, no estás configurando una herramienta: estás asumiendo el papel del cargador que ya no existe.

⚔️ Dicta tú el mapa de memoria
  1. Ejecuta ld --verbose y localiza en el script por defecto dónde se colocan .text, .rodata y .bss, y qué símbolos define automáticamente. Contrasta con readelf -S sobre un binario tuyo.
  2. Enlaza un programa con -Wl,-Map=salida.map y usa el fichero para responder qué objeto aportó la sección más grande de .text.
  3. Escribe un script mínimo con dos regiones en MEMORY, coloca .text en la primera y .data en la segunda con la cláusula AT, y comprueba con readelf -l que VirtAddr y PhysAddr difieren.
  4. Exporta desde el script los cuatro símbolos de límites del ejemplo, decláralos en C como arreglos externos y escribe el bucle de copia y el de puesta a cero. Verifica con nm que sus valores son las direcciones esperadas.
  5. Añade un ASSERT que falle si el tamaño de .text supera la longitud de la región, y provoca el fallo añadiendo una tabla grande en .rodata. Después elimina el KEEP de la tabla de vectores, enlaza con --gc-sections y confirma con objdump que desapareció.