wandres.dev
ENLAZADOR · El linker y ELF

El linker y el formato ELF

La fase más incomprendida de la compilación, por dentro: resolución de símbolos, secciones y segmentos de ELF, visibilidad, versionado de símbolos y LTO.

⏱ 16 min

El linker es la caja negra que casi nadie entiende y que causa los errores más crípticos. Abrirla es un salto de nivel: entenderás de una vez todos esos “undefined reference” y “multiple definition”, el formato de tus binarios, y optimizaciones como LTO. Es territorio de PhD, explicado con claridad.

🎯 Al terminar esta lección sabrás
  • Qué hace exactamente el linker.
  • El formato ELF: secciones y segmentos.
  • Visibilidad y versionado de símbolos.
  • Link-Time Optimization (LTO).

El trabajo del linker

El linker toma los .o (y bibliotecas) y hace dos cosas: resolver símbolos (conectar cada uso de un nombre con su definición) y relocalizar (asignar direcciones finales y rellenar las referencias). De ahí vienen sus dos errores clásicos:

  • undefined reference to ‘x’ → usaste x pero ninguna unidad lo define (falta un .o o una -l).
  • multiple definition of ‘x’ → dos unidades definen x (a menudo, una definición en un header incluido dos veces).
El linker no entiende C: solo casa nombres

El cambio de perspectiva clave: para el linker, tu programa no es código C, es un montón de símbolos —nombres con una dirección— que hay que conectar. Una función sumar es el símbolo sumar; una variable global contador es el símbolo contador. El linker recorre todos los .o, hace una lista de qué símbolos define cada uno y cuáles necesita, y los empareja. Si un símbolo necesario no aparece en ningún sitio: “undefined reference”. Si aparece dos veces: “multiple definition”. Cuando piensas en términos de símbolos —no de código— estos errores dejan de ser crípticos y se vuelven obvios: “¿quién define este nombre? ¿nadie, o dos?”. Herramientas como nm (nivel 3) te dejan ver exactamente qué símbolos define y necesita cada objeto.

El formato ELF

En Linux, los .o, las .so y los ejecutables son ELF (Executable and Linkable Format). ELF organiza el contenido en dos vistas:

🔗

Secciones (linking view)

Cómo ve el linker el archivo: .text (código), .data (datos inicializados), .bss (datos a cero), .rodata (constantes), tabla de símbolos, relocalizaciones.

🚀

Segmentos (execution view)

Cómo lo carga el sistema al ejecutar: agrupa secciones en segmentos con permisos (código ejecutable, datos escribibles).

readelf -h prog      # cabecera ELF
readelf -S prog      # secciones (.text, .data, .bss…)
readelf -l prog      # segmentos de carga
objdump -d prog      # desensamblar el código
size prog            # tamaño de text/data/bss
💡
.bss no ocupa espacio en el archivo

Un detalle que ilumina cómo funciona todo: las variables globales inicializadas a cero van a la sección .bss, que no ocupa espacio en el archivo en disco — solo guarda “reserva N bytes a cero al cargar”. Por eso un array global enorme puesto a cero no engorda tu ejecutable, pero uno inicializado con valores sí (va a .data, que sí se almacena). Entender secciones explica el tamaño real de tus binarios y decisiones de rendimiento y arranque.

Visibilidad y versionado de símbolos

En una biblioteca dinámica, controlar qué símbolos se exportan es clave (menos superficie, menos colisiones, mejor optimización):

// exportar explícitamente solo la API pública
__attribute__((visibility("default"))) int api_publica(void);
// compilar con -fvisibility=hidden oculta el resto por defecto

El versionado de símbolos permite que una .so mantenga varias versiones de una función para no romper binarios antiguos — la maquinaria fina que sostiene la estabilidad de ABI (nivel 21).

Normalmente el compilador optimiza cada unidad por separado y no puede, por ejemplo, incrustar una función de otro .c. LTO difiere parte de la optimización al enlazado, cuando se ve el programa entero:

gcc -std=c23 -O2 -flto -c a.c -o a.o
gcc -std=c23 -O2 -flto -c b.c -o b.o
gcc -flto a.o b.o -o prog      # optimiza a través de las fronteras de módulo

LTO puede reducir tamaño y acelerar, a cambio de enlaces más lentos. Es una de las optimizaciones “gratis” que un ingeniero conoce.

⚔️ Abre la caja negra del linker
  1. Provoca un “undefined reference” y un “multiple definition”, y explica cada uno en términos de símbolos.
  2. Usa nm para ver los símbolos definidos (T) e indefinidos (U) de un .o.
  3. Explora un ejecutable con readelf -S (secciones) y size (text/data/bss).
  4. Comprueba que un array global a cero no engorda el archivo, pero uno inicializado sí.
  5. Compila un proyecto con -flto y compara.