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.
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.
- 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
xpero ninguna unidad lo define (falta un.oo una-l). - multiple definition of ‘x’ → dos unidades definen
x(a menudo, una definición en un header incluido dos veces).
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
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).
Link-Time Optimization (LTO)
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.
- Provoca un “undefined reference” y un “multiple definition”, y explica cada uno en términos de símbolos.
- Usa
nmpara ver los símbolos definidos (T) e indefinidos (U) de un.o. - Explora un ejecutable con
readelf -S(secciones) ysize(text/data/bss). - Comprueba que un array global a cero no engorda el archivo, pero uno inicializado sí.
- Compila un proyecto con
-fltoy compara.