Símbolos y reubicaciones: cómo el linker resuelve cada referencia
La resolución de nombres deja de ser magia. Esta lección lee la tabla de símbolos campo a campo, distingue definiciones fuertes, débiles y comunes, explica el modelo de archivo de biblioteca que hace que el orden de la línea de enlazado importe, y deduce la aritmética exacta con la que una reubicación rellena el hueco que el compilador dejó abierto.
El compilador trabaja con una venda en los ojos: al traducir una unidad no sabe dónde vivirán las funciones de las demás, ni siquiera dónde vivirá el código que está generando. Ante cada referencia a un nombre externo hace lo único razonable —dejar un hueco de cuatro u ocho bytes y anotar en una lista qué habría que escribir ahí si algún día se supiera la dirección—. Esa lista es la tabla de reubicaciones, el hueco es su objetivo, y el enlazador es quien cierra el círculo. Entender ese contrato convierte los dos errores más temidos de C, la referencia indefinida y la definición múltiple, en consecuencias predecibles de un algoritmo que puedes recorrer a mano.
- Leer una entrada de la tabla de símbolos: tipo, vinculación, sección y valor.
- Distinguir definiciones fuertes, débiles y comunes, y prever qué gana en cada combinación.
- Explicar el modelo de extracción de archivos y por qué el orden de la línea de enlazado altera el resultado.
- Descifrar una reubicación y calcular a mano el valor que el enlazador escribirá.
Qué es exactamente un símbolo
Para el enlazador tu programa no es código: es un conjunto de nombres. Cada entrada de .symtab describe uno con cinco datos que bastan para todo el proceso. El nombre, un índice dentro de la tabla de cadenas .strtab. El valor, que en un objeto reubicable es un desplazamiento dentro de su sección y en un ejecutable ya es una dirección virtual. El tamaño en bytes. El tipo, casi siempre FUNC para funciones y OBJECT para datos. Y el índice de sección, que además codifica los casos especiales: UND para un símbolo usado pero no definido aquí, y COM para las definiciones comunes que veremos enseguida.
A eso se añade la vinculación, que decide la visibilidad entre unidades. LOCAL corresponde a lo declarado static: existe solo dentro de su objeto y ninguna otra unidad puede referirse a él, razón por la cual static es la herramienta más barata que tienes para evitar colisiones. GLOBAL es la vinculación externa habitual. WEAK es la tercera vía y merece su propia sección.
nm -C prog.o # T definido en texto, D en datos, B en bss, U indefinido
readelf -s prog.o # la tabla completa con tipo, vinculacion y seccion
nm --undefined-only prog.o # exactamente lo que este objeto le exige al enlazador
La letra que imprime nm es un resumen de esos campos: mayúscula significa global y minúscula local, de modo que T es una función global, t una función static, B un dato global en .bss y U una referencia sin dueño. Casi toda sesión de depuración de enlazado empieza y termina leyendo esas letras.
0000000000000000 T procesar funcion global definida aqui
0000000000000020 t auxiliar funcion static, invisible fuera
0000000000000000 B contador dato global en bss
U printf referencia pendiente
w __gmon_start__ referencia debil sin definir
Merece la pena insistir en la asimetría entre declarar y definir, porque el enlazador la trata de forma radicalmente distinta. Una declaración sin definición produce una entrada UND que crea una deuda; una definición produce una entrada con sección y valor que la salda. El compilador acepta sin protestar cualquier declaración porque confía en que alguien la cumplirá, y por eso una firma equivocada en una cabecera no da error de compilación sino un desastre en ejecución: los nombres casan aunque los tipos no, ya que el símbolo no lleva la firma consigo. Ese es exactamente el problema que C++ resuelve decorando los nombres y que C, por decisión deliberada de compatibilidad con el enlazador clásico, deja en manos del programador.
Fuerte, débil y común
Una definición fuerte es la normal: una función con cuerpo o una variable con inicializador. Dos definiciones fuertes del mismo nombre son un error inmediato y así debe ser.
Una definición débil, marcada con __attribute__((weak)), declara un valor por defecto sustituible. Si en algún otro objeto aparece una definición fuerte del mismo nombre, esa gana y la débil se descarta sin ruido; si no aparece ninguna, se usa la débil. Existe además la referencia débil, con __attribute__((weakref)) o declarando débil un símbolo que no se define en ningún sitio: si nadie lo aporta, no hay error de enlazado y el símbolo vale cero, lo que permite preguntar en tiempo de ejecución si una función opcional está presente.
// Punto de extension: la biblioteca ofrece un comportamiento por defecto
__attribute__((weak)) void al_fallar(const char *msg) {
(void)msg; // no hacer nada, salvo que el usuario lo redefina
}
// Deteccion de una funcion opcional sin errores de enlazado
extern void perfilar_inicio(void) __attribute__((weak));
void arrancar(void) {
if (perfilar_inicio) perfilar_inicio(); // nulo si nadie la define
}
Los símbolos comunes son la herencia más incómoda del modelo. Históricamente, una variable global sin inicializador se emitía como símbolo común, y varias definiciones idénticas en distintas unidades se fusionaban en silencio en lugar de dar error. Eso convertía un olvido de extern en una cabecera en un solapamiento invisible entre dos variables que el programador creía independientes. Desde GCC 10 el comportamiento por defecto es -fno-common, y esas definiciones repetidas producen por fin el error de definición múltiple que siempre debieron producir.
Escribir int contador; en un fichero de cabecera crea una definición en cada unidad que la incluya. Con -fno-common obtienes un error claro; con el comportamiento antiguo obtenías un programa que compilaba y en el que todas las unidades compartían por accidente la misma variable. La forma correcta es declarar extern int contador; en la cabecera y definirla en exactamente un .c.
Queda un cuarto caso que el modelo clásico no contemplaba y que el compilador resuelve por debajo: los grupos de secciones. Cuando una función inline con vinculación externa, o cualquier construcción que deba emitirse en varias unidades sin colisionar, necesita una definición por objeto, el compilador la coloca en un grupo marcado como fusionable y el enlazador conserva una sola copia descartando las demás en silencio. Es el mismo mecanismo que sostiene las plantillas de C++, y en C aparece con menos frecuencia pero explica por qué readelf -S muestra a veces secciones con nombres compuestos y una bandera de grupo. La regla mental es que hay tres formas de que un nombre repetido no sea un error —vinculación local, definición débil o pertenencia a un grupo fusionable— y solo la primera es responsabilidad tuya.
Reubicaciones: el hueco y la fórmula
Cuando el compilador traduce una llamada a una función de otra unidad, emite la instrucción con el operando de destino a cero y añade una entrada a .rela.text. Esa entrada contiene tres cosas: el desplazamiento del hueco dentro de la sección, el símbolo al que se refiere, y el tipo de reubicación, que no es una etiqueta decorativa sino el nombre de una fórmula aritmética.
En x86-64, dos tipos cubren la mayor parte del código ordinario. R_X86_64_PC32 corresponde a un desplazamiento relativo al contador de programa y su fórmula es la resta entre la dirección del símbolo más el addend y la dirección del propio hueco: es lo que rellena una instrucción call o jmp cercana. R_X86_64_64 escribe la dirección absoluta de sesenta y cuatro bits y aparece, por ejemplo, en una tabla de punteros a función inicializada estáticamente.
flowchart TD A[Compilador traduce una llamada externa] --> B[Emite la instruccion con el operando a cero] B --> C[Anota una entrada en rela punto text] C --> D[Enlazador asigna direcciones finales] D --> E[Aplica la formula del tipo de reubicacion] E --> F[Escribe el valor en el hueco] style C fill:#f9e2af,color:#11111b style F fill:#a6e3a1,color:#11111b
readelf -r prog.o # todas las reubicaciones pendientes
objdump -dr prog.o # desensamblado con la reubicacion anotada al lado
objdump -d prog | grep -A2 'call' # el mismo hueco, ya relleno, en el ejecutable
La comprobación que fija la idea para siempre consiste en desensamblar el .o con objdump -dr y observar que el operando del call vale literalmente cero, con la reubicación impresa justo debajo, y repetir después sobre el ejecutable enlazado para ver el desplazamiento real ya escrito. Ahí, en dos comandos, está todo el trabajo del enlazador hecho visible.
Dos precisiones evitan confusiones frecuentes. La primera es el addend: en las arquitecturas que usan el formato RELA, como x86-64, el sumando forma parte de la entrada de reubicación en lugar de estar preescrito en el hueco, y por eso las secciones se llaman .rela.text y no .rel.text. Ese sumando es el que permite referirse a una posición desplazada respecto del símbolo, por ejemplo al indexar dentro de un arreglo global. La segunda es que el desplazamiento relativo de treinta y dos bits impone un alcance máximo de dos gigabytes en cada sentido, lo que basta para cualquier programa normal pero se queda corto en imágenes gigantescas; de ahí que existan los modelos de código medio y grande, y que un núcleo cargado en direcciones altas necesite un modelo específico.
Conviene además separar dos familias que a menudo se confunden. Las reubicaciones que acabamos de ver son estáticas: las resuelve el enlazador y desaparecen del ejecutable. Existe una segunda familia, las dinámicas, que sobreviven en el binario final y las aplica el enlazador dinámico al cargar, porque dependen de una dirección base que solo se conoce en ejecución. Sus nombres aparecen en readelf -r sobre el ejecutable: R_X86_64_RELATIVE para ajustar punteros internos a la base de carga, R_X86_64_GLOB_DAT para datos externos y R_X86_64_JUMP_SLOT para funciones. Esa última es la puerta de entrada a la lección siguiente.
El algoritmo de resolución, paso a paso
El enlazador recorre la línea de comandos de izquierda a derecha una sola vez, manteniendo tres conjuntos: los objetos que ya ha incluido, los símbolos definidos hasta el momento y los símbolos aún indefinidos. Ante cada argumento actúa según su naturaleza, y esa distinción explica los errores que parecen caprichosos.
Un objeto suelto
Un .o en la línea se incluye siempre, se necesite o no. Sus definiciones se añaden al conjunto y sus referencias indefinidas quedan pendientes.
Un archivo estático
De un .a solo se extraen los miembros que resuelven algún símbolo pendiente en ese instante. Lo que aún no se ha pedido, no se saca.
Una biblioteca compartida
Una .so no aporta código al ejecutable: aporta la promesa de que el símbolo existirá en ejecución, y deja constancia en la sección dinámica.
El balance final
Si al terminar el recorrido queda algún símbolo pendiente, se emite la referencia indefinida. No hay segunda pasada por defecto.
De ese recorrido salen tres opciones que todo el mundo acaba necesitando y que ahora puedes justificar en lugar de copiar. La pareja --start-group y --end-group encierra un conjunto de archivos y obliga a recorrerlos repetidamente hasta que no queden símbolos pendientes, que es la solución cuando dos bibliotecas estáticas se necesitan mutuamente. La opción --as-needed descarta las bibliotecas compartidas de las que no se acabó usando ningún símbolo, evitando dependencias fantasma en la sección dinámica. Y --gc-sections, combinada al compilar con -ffunction-sections -fdata-sections, baja la granularidad de la inclusión del objeto completo a la función individual, con lo que la imagen final adelgaza de forma a veces espectacular.
gcc -std=c23 -ffunction-sections -fdata-sections -c mod.c
gcc -Wl,--gc-sections -Wl,--print-gc-sections mod.o -o prog
ld --start-group -la -lb --end-group ... # dependencias mutuas
gcc ... -Wl,--as-needed -lfoo # descarta lo que no se use
Un archivo estático merece una mirada directa, porque desmitifica la mitad de las dudas sobre bibliotecas. No es un formato elaborado: es un contenedor de objetos con un índice de símbolos al principio, y ese índice es justo lo que el enlazador consulta para decidir qué miembros extraer.
ar t libmia.a # que objetos contiene
ar x libmia.a mod.o # extraer uno cualquiera y tratarlo como .o normal
nm -s libmia.a | head -30 # el indice: que simbolo aporta cada miembro
ranlib libmia.a # regenerar el indice si quedo obsoleto
Un aviso sobre el diagnóstico: cuando el mensaje de referencia indefinida no cuadra con lo que ves en el código, la explicación casi siempre es una de tres. O el símbolo se define en una unidad que nunca llegó a la línea de enlazado, o se define pero con vinculación local por un static olvidado, o el nombre no coincide exactamente por un extern "C" ausente al mezclar con C++. Las tres se distinguen en segundos comparando la salida de nm de todos los objetos implicados.
Aquí está la explicación del error que todo el mundo ha sufrido sin entender. Escribir gcc -lm main.o falla mientras que gcc main.o -lm funciona, y no por una convención arbitraria sino porque el enlazador procesa los argumentos en orden y sin memoria del futuro. Cuando encuentra -lm en primera posición, ningún símbolo está pendiente todavía: la biblioteca matemática se examina, no resuelve nada, y se descarta entera. Al llegar después main.o aparece la referencia a sin, pero ya no queda ningún archivo por recorrer donde buscarla. Invierte el orden y el archivo se consulta cuando la deuda ya existe. De este mismo mecanismo se deducen tres cosas más. Primera: dos bibliotecas con dependencias mutuas requieren repetir una de ellas en la línea o encerrarlas entre las opciones de grupo, porque una sola pasada no basta. Segunda: un archivo estático es literalmente un contenedor de objetos con un índice, y por eso enlazar contra él solo arrastra los miembros que usas, mientras que enlazar un .o suelto lo mete entero aunque no lo toques. Tercera: la granularidad de esa extracción es el objeto completo, y de ahí sale la utilidad de compilar con -ffunction-sections -fdata-sections y enlazar con --gc-sections para bajar la granularidad a la función individual. Quien entiende que el enlazador es una máquina de una sola pasada sobre una cola de argumentos deja de probar permutaciones al azar.
- Escribe dos unidades donde una llame a una función definida en la otra. Compila ambas sin enlazar y usa
nmpara localizar laTen una y laUen la otra. - Ejecuta
objdump -drsobre el objeto que llama y comprueba que el operando delcalles cero con la reubicación anotada. Enlaza y repite sobre el ejecutable para ver el desplazamiento ya escrito. - Reproduce el fallo de orden: enlaza un programa que use
sqrtponiendo-lmantes y después del objeto, y explica cada resultado en términos del conjunto de símbolos pendientes. - Define una función débil con
__attribute__((weak))en una unidad y una fuerte con el mismo nombre en otra. Comprueba connmcuál sobrevive y añade después una referencia débil no definida para verificar que vale cero. - Declara
int contador;en una cabecera incluida por dos unidades y compila con-fcommony con-fno-common. Contrasta ambos comportamientos y razona por qué el segundo es el correcto.