← Programación en C para Sistemas Operativos Bloque 8 · Compilación, enlazado y bibliotecas · Tema 31 de 42

Tema 31

El enlazador y los símbolos

Desde el tema 4 sabemos que un programa en C pasa por cuatro etapas, y que la última, el enlace, une los archivos objeto en un ejecutable. En el tema 13 dividimos un proyecto en módulos y usamos nm para ver qué definía y qué necesitaba cada uno. Este tema abre el bloque 8 mirando esa etapa por dentro: qué hay en un archivo objeto, qué es exactamente un símbolo, qué son los huecos que el compilador deja en el código de máquina y qué cuenta hace el enlazador para llenarlos, qué ocurre cuando dos archivos definen lo mismo, y por qué cada uno de los errores de enlace dice lo que dice. Es el conocimiento que convierte un undefined reference de un misterio en un diagnóstico de dos minutos, y la base de las bibliotecas estáticas y dinámicas de los próximos temas.

1. Un archivo objeto por dentro

Para mirar el enlace de cerca hace falta un proyecto chico, con tres archivos que se necesitan mutuamente. Uno define datos, otro una función que los usa, y el tercero llama a la función y a printf:

datos.c, suma.c y principal.c
/* datos.c */
int limite = 100;                         /* .data: global con valor */
int visitas;                              /* .bss: global sin valor */
static int secreto = 7;                   /* .data, pero local al archivo */
const char saludo[] = "hola";             /* .rodata */

int leer_secreto(void)
{
    return secreto;
}

/* suma.c */
extern int limite;
extern int visitas;

int sumar_hasta(int n)
{
    visitas++;
    if (n > limite)
        n = limite;
    int total = 0;
    for (int i = 1; i <= n; i++)
        total += i;
    return total;
}

/* principal.c */
#include <stdio.h>

int sumar_hasta(int n);
extern const char saludo[];

int main(void)
{
    printf("%s: %d\n", saludo, sumar_hasta(500));
    return 0;
}

Cada archivo se compila por separado hasta su objeto, y los objetos se enlazan. Primero, qué es un archivo objeto:

terminal
$ gcc -std=c17 -Wall -Wextra -c datos.c suma.c principal.c
$ file datos.o suma.o principal.o
datos.o:     ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
suma.o:      ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
principal.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ readelf -SW suma.o
There are 13 section headers, starting at offset 0x2b8:

Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            0000000000000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        0000000000000000 000040 000055 00  AX  0   0  1
  [ 2] .rela.text        RELA            0000000000000000 0001d0 000060 18   I 10   1  8
  [ 3] .data             PROGBITS        0000000000000000 000095 000000 00  WA  0   0  1
  [ 4] .bss              NOBITS          0000000000000000 000095 000000 00  WA  0   0  1
  [ 5] .comment          PROGBITS        0000000000000000 000095 000027 01  MS  0   0  1
  [ 6] .note.GNU-stack   PROGBITS        0000000000000000 0000bc 000000 00      0   0  1
  [ 7] .note.gnu.property NOTE            0000000000000000 0000c0 000020 00   A  0   0  8
  [ 8] .eh_frame         PROGBITS        0000000000000000 0000e0 000038 00   A  0   0  8
  [ 9] .rela.eh_frame    RELA            0000000000000000 000230 000018 18   I 10   8  8
  [10] .symtab           SYMTAB          0000000000000000 000118 000090 18     11   3  8
  [11] .strtab           STRTAB          0000000000000000 0001a8 000023 00      0   0  1
  [12] .shstrtab         STRTAB          0000000000000000 000248 00006c 00      0   0  1

La respuesta de file contiene la palabra clave: relocatable. Un objeto está en formato ELF, el mismo de los ejecutables, pero es una pieza todavía no ubicada: todas las secciones empiezan en la dirección 0, porque nadie decidió aún dónde van a quedar. Es una colección de secciones con un propósito cada una, y las que importan para el enlace son estas:

SecciónQué contieneEn suma.o
.textEl código de máquina de las funciones del archivo.85 bytes (0x55): sumar_hasta.
.data, .bss, .rodataLas variables con valor, sin valor y las constantes del archivo (tema 6).Vacías: suma.c no define ninguna variable.
.symtab, .strtabLa tabla de símbolos y los nombres que esta usa.6 entradas de 24 bytes.
.rela.textLas reubicaciones del código: los huecos que el enlazador tiene que llenar.4 entradas de 24 bytes, que veremos en la sección 3.
.eh_frame, .rela.eh_frameInformación para recorrer la pila al depurar o al manejar excepciones.El enlazador la une igual que el resto.
.comment, .note.*Versión del compilador y propiedades como la pila no ejecutable.Informativas.

Todo objeto tiene así tres tipos de contenido: datos de sus secciones, símbolos que dicen qué nombre vive dónde, y reubicaciones que dicen qué lugares del contenido dependen de direcciones todavía desconocidas. El enlace es el proceso de resolver las dos últimas.

2. La tabla de símbolos

Un símbolo es un nombre asociado a una posición: una función o una variable. En un objeto hay dos clases de símbolos que le importan al enlazador: los que el archivo define y ofrece a los demás, y los que necesita y no define. readelf -s los muestra con todos sus datos:

terminal
$ readelf -sW datos.o
Symbol table '.symtab' contains 9 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS datos.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 .data
     4: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 secreto
     5: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 limite
     6: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    4 visitas
     7: 0000000000000000     5 OBJECT  GLOBAL DEFAULT    5 saludo
     8: 0000000000000000    16 FUNC    GLOBAL DEFAULT    1 leer_secreto
$ readelf -sW suma.o
Symbol table '.symtab' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS suma.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000    85 FUNC    GLOBAL DEFAULT    1 sumar_hasta
     4: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND visitas
     5: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND limite
$ readelf -sW principal.o
Symbol table '.symtab' contains 8 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS principal.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 .rodata
     4: 0000000000000000    57 FUNC    GLOBAL DEFAULT    1 main
     5: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND sumar_hasta
     6: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND saludo
     7: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

Cada columna tiene un significado, y entre todas cuentan la historia del módulo:

El lenguaje no tiene nada que ver con esto: una variable int limite y una función int limite(void) son, para el enlazador, un nombre con una dirección. C no «mangla» los nombres como C++ (que codifica los tipos de los parámetros en el símbolo): el nombre de la función en C es exactamente el símbolo, y por eso nada impide declarar una función con un tipo y definirla con otro en archivos distintos, y que el enlace salga bien y el programa falle. El enlazador no ve tipos.

nm presenta la misma información de manera compacta, con una letra por símbolo, la que usamos desde el tema 4:

terminal
$ nm datos.o
0000000000000000 T leer_secreto
0000000000000000 D limite
0000000000000000 R saludo
0000000000000004 d secreto
0000000000000000 B visitas
$ nm -S datos.o
0000000000000000 0000000000000010 T leer_secreto
0000000000000000 0000000000000004 D limite
0000000000000000 0000000000000005 R saludo
0000000000000004 0000000000000004 d secreto
0000000000000000 0000000000000004 B visitas
$ nm -u principal.o suma.o
principal.o:
                 U printf
                 U saludo
                 U sumar_hasta

suma.o:
                 U limite
                 U visitas
LetraSignificaEjemplo
T / tCódigo (.text), global / local.leer_secreto, sumar_hasta, main.
D / dDatos con valor (.data).limite / secreto.
B / bDatos sin valor (.bss).visitas.
R / rDatos de solo lectura (.rodata).saludo.
UIndefinido: se usa y se define en otro lugar.printf, limite.
W / wDébil, definido / indefinido (sección 5).Más adelante.
CComún (sección 5).Más adelante.

Mayúscula es global, minúscula es local. Con -u se listan solo los indefinidos, que son exactamente lo que cada objeto le pide al resto del programa; con -S se agrega el tamaño; con --defined-only, lo opuesto de -u. Para buscar quién define un nombre entre muchos objetos, nm -A *.o | grep ' T nombre' imprime el archivo en cada línea.

3. Los huecos: las reubicaciones

Cuando el compilador genera suma.o, no sabe en qué dirección va a quedar visitas, ni limite, ni dónde va a estar sumar_hasta para quien la llame. Sin embargo, tiene que emitir las instrucciones que los usan. Lo que hace es dejar un hueco en el código, de ceros, y anotar en una tabla aparte qué hueco es de qué símbolo y cómo llenarlo. Se ve con objdump -dr, que desensambla y muestra las anotaciones debajo de cada instrucción afectada:

terminal · el código de suma.o y su tabla de reubicaciones
$ objdump -dr suma.o
0000000000000000 <sumar_hasta>:
   0:	f3 0f 1e fa          	endbr64
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	89 7d ec             	mov    %edi,-0x14(%rbp)
   b:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 11 <sumar_hasta+0x11>
			d: R_X86_64_PC32	visitas-0x4
  11:	83 c0 01             	add    $0x1,%eax
  14:	89 05 00 00 00 00    	mov    %eax,0x0(%rip)        # 1a <sumar_hasta+0x1a>
			16: R_X86_64_PC32	visitas-0x4
  1a:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 20 <sumar_hasta+0x20>
			1c: R_X86_64_PC32	limite-0x4
  20:	39 45 ec             	cmp    %eax,-0x14(%rbp)
  23:	7e 09                	jle    2e <sumar_hasta+0x2e>
  25:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 2b <sumar_hasta+0x2b>
			27: R_X86_64_PC32	limite-0x4
  2b:	89 45 ec             	mov    %eax,-0x14(%rbp)
  …
$ objdump -dr principal.o
0000000000000000 <main>:
   …
   8:	bf f4 01 00 00       	mov    $0x1f4,%edi
   d:	e8 00 00 00 00       	call   12 <main+0x12>
			e: R_X86_64_PLT32	sumar_hasta-0x4
  12:	89 c2                	mov    %eax,%edx
  14:	48 8d 0d 00 00 00 00 	lea    0x0(%rip),%rcx        # 1b <main+0x1b>
			17: R_X86_64_PC32	saludo-0x4
  1b:	48 8d 05 00 00 00 00 	lea    0x0(%rip),%rax        # 22 <main+0x22>
			1e: R_X86_64_PC32	.rodata-0x4
  …
  2d:	e8 00 00 00 00       	call   32 <main+0x32>
			2e: R_X86_64_PLT32	printf-0x4
$ readelf -rW suma.o
Relocation section '.rela.text' at offset 0x1d0 contains 4 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
000000000000000d  0000000400000002 R_X86_64_PC32          0000000000000000 visitas - 4
0000000000000016  0000000400000002 R_X86_64_PC32          0000000000000000 visitas - 4
000000000000001c  0000000500000002 R_X86_64_PC32          0000000000000000 limite - 4
0000000000000027  0000000500000002 R_X86_64_PC32          0000000000000000 limite - 4

Cada instrucción afectada tiene un campo de cuatro bytes en cero, 00 00 00 00: el mov 0x0(%rip),%eax que lee visitas debería llevar la distancia hasta ella, y no se conoce. Debajo de cada una, una línea R_X86_64_… dice cuatro cosas, las mismas que las columnas de readelf -r:

Ese −4 es la clave de la cuenta. Una instrucción con direccionamiento relativo a %rip calcula la dirección efectiva sumando el desplazamiento al valor de %rip en el momento de ejecutarla, que ya apunta a la instrucción siguiente, 4 bytes después del final del campo del desplazamiento si el campo es lo último de la instrucción. Si P es la dirección del hueco y S la del símbolo, el valor que hay que escribir es S − (P + 4), que es S + A − P con A = −4. Esa es la fórmula de las reubicaciones PC32 y PLT32, y todo lo que hace el enlazador en este punto es calcularla para cada entrada y escribir el resultado en el hueco.

4. Lo que hace el enlazador

El enlazador (ld) recibe los objetos y las bibliotecas y hace, en esencia, tres cosas, en este orden:

  1. Resolver los símbolos. Recorre las tablas de símbolos de todos los objetos y construye una tabla única: para cada nombre global, quién lo define. Cada símbolo indefinido tiene que encontrar exactamente una definición; si no hay ninguna, es un undefined reference; si hay varias, un multiple definition (sección 5 para las excepciones). resolver
  2. Asignar direcciones. Junta, sección por sección, el contenido de todos los objetos: primero las .text una detrás de otra, después las .rodata, .data y .bss, en el orden en que llegaron los archivos. Con eso, cada símbolo recibe su dirección definitiva. ubicar
  3. Aplicar las reubicaciones. Para cada entrada de las tablas .rela.*, calcula el valor con la fórmula de la sección anterior y lo escribe en el hueco. Después de este paso el código está completo. llenar

El resultado se puede ver tal como lo decidió el enlazador, pidiéndole un mapa de enlace (-Wl,-Map=archivo; -Wl,opción pasa la opción al enlazador a través de gcc):

terminal · gcc -Wl,-Map=calculo.map -o calculo principal.o suma.o datos.o, mostrando lo que importa del mapa
$ gcc -o calculo principal.o suma.o datos.o && ./calculo
hola: 5050
$ grep -E '^\.text|^ \.text' calculo.map
.text           0x0000000000001060      0x187
 .text          0x0000000000001060       0x26 …/Scrt1.o
 .text          0x0000000000001086        0x0 …/crti.o
 .text          0x0000000000001090       0xb9 …/crtbeginS.o
 .text          0x0000000000001149       0x39 principal.o
 .text          0x0000000000001182       0x55 suma.o
 .text          0x00000000000011d7       0x10 datos.o
 .text          0x00000000000011e7        0x0 …/crtendS.o
 .text          0x00000000000011e7        0x0 …/crtn.o
$ nm -n calculo | grep -E ' (_start|main|sumar_hasta|leer_secreto|saludo|limite|secreto|visitas)$'
0000000000001060 T _start
0000000000001149 T main
0000000000001182 T sumar_hasta
00000000000011d7 T leer_secreto
000000000000200c R saludo
0000000000004010 D limite
0000000000004014 d secreto
000000000000401c B visitas
Cómo el enlazador junta las secciones .text de los tres objetos en el ejecutable. A la izquierda, principal.o con 0x39 bytes, suma.o con 0x55 y datos.o con 0x10, todos con direcciones desde 0. A la derecha, la región .text del ejecutable, que empieza en 0x1060 con el código de arranque de los archivos crt, y sigue con principal.o en 0x1149, suma.o en 0x1182 y datos.o en 0x11d7. Una flecha curva une la llamada a sumar_hasta, en el hueco de principal.o, con el comienzo de suma.o, y se rotula con el valor escrito 0x27 entradas: .text de cada objeto salida: .text del ejecutable principal.o 0x0 · 0x39 bytes suma.o 0x0 · 0x55 bytes datos.o · 0x10 crt: arranque · 0x1060 principal.o 0x1149 suma.o 0x1182 · sumar_hasta datos.o · 0x11d7 call sumar_hasta: el hueco de 4 bytes en 0x1157 recibe 0x1182 − 4 − 0x1157 = 0x27 Los .text de los otros objetos se apilan en el orden en que llegaron a la línea de gcc.
El enlazador apila las secciones del mismo tipo en el orden de la línea de órdenes, y después llena los huecos con las direcciones definitivas.

El mapa confirma el reparto. Delante de nuestro código está el de arranque de Scrt1.o, crti.o y crtbeginS.o, que gcc agrega solo (tema 4): son los 0x26 + 0xb9 bytes que hay desde _start, en 0x1060, hasta main, en 0x1149. Después, los tres objetos en el orden en que se escribieron en la línea de órdenes, con sus tamaños: principal.o ocupa 0x39 bytes (el 57 que decía Size de main), suma.o 0x55 y datos.o 0x10. Cambiar el orden de los archivos en la línea cambia las direcciones, no el resultado.

Falta comprobar la cuenta. Estos son los huecos de main y de sumar_hasta después del enlace, y las direcciones que dio nm:

terminal · objdump -d calculo
0000000000001149 <main>:
    1151:	bf f4 01 00 00       	mov    $0x1f4,%edi
    1156:	e8 27 00 00 00       	call   1182 <sumar_hasta>
    115b:	89 c2                	mov    %eax,%edx
    115d:	48 8d 0d a8 0e 00 00 	lea    0xea8(%rip),%rcx        # 200c <saludo>
    1164:	48 8d 05 99 0e 00 00 	lea    0xe99(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    …
    1176:	e8 d5 fe ff ff       	call   1050 <printf@plt>

0000000000001182 <sumar_hasta>:
    118d:	8b 05 89 2e 00 00    	mov    0x2e89(%rip),%eax        # 401c <visitas>
    119c:	8b 05 6e 2e 00 00    	mov    0x2e6e(%rip),%eax        # 4010 <limite>
ReubicaciónP (el hueco)S (el símbolo)S + A − PBytes escritos
call sumar_hasta0x11570x11820x1182 − 4 − 0x1157 = 0x2727 00 00 00
call printf0x11770x1050 (printf@plt)0x1050 − 4 − 0x1177 = −0x12b = 0xfffffed5d5 fe ff ff
lea saludo0x11600x200c0x200c − 4 − 0x1160 = 0xea8a8 0e 00 00
mov visitas0x118f0x401c0x401c − 4 − 0x118f = 0x2e8989 2e 00 00

Las cuatro cuentas dan exactamente los bytes que el enlazador escribió. Un detalle más: la llamada a printf da un valor negativo, en complemento a dos, porque la tabla de saltos está antes que main. Y un límite: el campo de la reubicación tiene 32 bits, de modo que el código y los datos a los que llega no pueden estar a más de ±2 GiB; en programas gigantescos, el modelo de código del compilador tiene que cambiar.

Se puede pedir además al enlazador que explique lo que hace con ciertos símbolos. -Wl,-y,nombre (--trace-symbol) informa cada objeto que lo referencia o lo define, y -Wl,--cref imprime una tabla de referencias cruzadas:

terminal
$ gcc -Wl,-y,sumar_hasta -Wl,-y,limite -o calculo principal.o suma.o datos.o
/usr/bin/x86_64-linux-gnu-ld.bfd: principal.o: reference to sumar_hasta
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.o: definition of sumar_hasta
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.o: reference to limite
/usr/bin/x86_64-linux-gnu-ld.bfd: datos.o: definition of limite
$ gcc -Wl,--cref -o calculo principal.o suma.o datos.o
Symbol                                            File
leer_secreto                                      datos.o
limite                                            datos.o
                                                  suma.o
saludo                                            datos.o
                                                  principal.o
sumar_hasta                                       suma.o
                                                  principal.o
visitas                                           datos.o
                                                  suma.o

En la tabla cruzada, el primer archivo de cada símbolo es el que lo define y los siguientes, los que lo usan. Es el equivalente, para todo el programa, del nm de cada objeto. Una última observación sobre el papel de gcc: no es el enlazador, sino un conductor que lo invoca con todos los archivos de arranque y las bibliotecas. Si se llama a ld directamente con solo nuestro objeto, protesta por lo que gcc habría puesto:

terminal
$ ld -o vacio principal.o
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
ld: principal.o: in function `main':
principal.c:(.text+0xe): undefined reference to `sumar_hasta'

Sin Scrt1.o no hay _start, y sin -lc no habría printf: el enlazador solo une lo que le dan.

5. Símbolos fuertes, débiles y comunes

La regla general es que cada símbolo global tiene una sola definición. Pero el formato ELF distingue tres clases de definición, con reglas para cuando hay más de una:

ClaseCómo se creanmRegla del enlazador
FuerteUna función, o una variable global inicializada.T, DSolo puede haber una. Dos fuertes con el mismo nombre: multiple definition.
Débil__attribute__((weak)).W (w si está indefinido)Cede ante una fuerte; si solo hay débiles, GNU ld toma la primera que encuentra. Un débil indefinido que nadie define vale 0, sin error.
ComúnUna variable global sin valor y sin extern, con -fcommon.CVarias comunes con el mismo nombre se unifican en una sola variable.

Los símbolos débiles son la herramienta para ofrecer una definición «por omisión» que otro módulo puede reemplazar sin tocar el original. La biblioteca de C usa el mecanismo para permitir que un programa sustituya funciones de la biblioteca, y los núcleos de los sistemas, para manejadores de interrupciones por omisión. Este ejemplo reúne tres casos: una función con definición débil y otra fuerte, y una función débil que ni siquiera se define:

principal2.c, aviso_debil.c y aviso_fuerte.c
/* principal2.c */
#include <stdio.h>

void aviso(void);                         /* ¿cuál definición habrá? */
void opcional(void) __attribute__((weak));   /* débil sin definición: vale 0 */

int main(void)
{
    aviso();
    if (opcional != NULL)
        opcional();
    else
        puts("opcional no está definida: su dirección es NULL");
    return 0;
}

/* aviso_debil.c */
#include <stdio.h>

__attribute__((weak)) void aviso(void)
{
    puts("aviso por omisión (definición débil)");
}

/* aviso_fuerte.c */
#include <stdio.h>

void aviso(void)
{
    puts("aviso personalizado (definición fuerte)");
}
terminal
$ gcc -std=c17 -Wall -Wextra -c principal2.c aviso_debil.c aviso_fuerte.c
$ nm aviso_debil.o aviso_fuerte.o principal2.o
aviso_debil.o:
0000000000000000 W aviso
                 U puts

aviso_fuerte.o:
0000000000000000 T aviso
                 U puts

principal2.o:
                 U _GLOBAL_OFFSET_TABLE_
                 U aviso
0000000000000000 T main
                 w opcional
                 U puts
$ gcc -o w1 principal2.o aviso_debil.o && ./w1
aviso por omisión (definición débil)
opcional no está definida: su dirección es NULL
$ gcc -o w2 principal2.o aviso_debil.o aviso_fuerte.o && ./w2
aviso personalizado (definición fuerte)
opcional no está definida: su dirección es NULL
$ gcc -o w3 principal2.o aviso_fuerte.o aviso_debil.o && ./w3
aviso personalizado (definición fuerte)
opcional no está definida: su dirección es NULL
$ gcc -o w4 principal2.o
principal2.c:(.text+0x9): undefined reference to `aviso'

La W de aviso_debil.o y la w minúscula de opcional en principal2.o marcan los dos casos. Con solo la definición débil, se usa; con las dos, gana la fuerte, en cualquier orden de la línea de órdenes (w2 y w3); y sin ninguna, el símbolo aviso (fuerte indefinido) es un error. En cambio, opcional es débil e indefinida, y el enlace sale bien: su dirección es 0, el programa lo comprueba con if (opcional != NULL) y evita llamarla. Es el mecanismo con que un programa detecta, al cargarse, si una función opcional de una biblioteca está presente.

Los símbolos comunes son un caso histórico que causa problemas hasta hoy. En C, int cuenta; fuera de una función, sin extern y sin valor, es una «definición tentativa»: el estándar permite repetirla en un mismo archivo. Durante décadas, gcc extendió esa libertad a todo el programa, con -fcommon: el objeto marcaba cuenta como común, y el enlazador unificaba todas las que encontraba. Desde gcc 10, el valor por omisión es -fno-common: la definición va a .bss como una definición fuerte, y repetirla en dos archivos es un error:

terminal · cuenta_a.c y cuenta_b.c declaran los dos «int cuenta;»
$ gcc -std=c17 -Wall -Wextra -c cuenta_a.c cuenta_b.c && nm cuenta_a.o
0000000000000000 B cuenta
0000000000000000 T incrementar
$ gcc -o cuenta cuenta_a.o cuenta_b.o
/usr/bin/x86_64-linux-gnu-ld.bfd: cuenta_b.o:(.bss+0x0): multiple definition of `cuenta'; cuenta_a.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status
$ gcc -std=c17 -fcommon -c cuenta_a.c -o ca.o; gcc -std=c17 -fcommon -c cuenta_b.c -o cb.o; nm ca.o cb.o
ca.o:
0000000000000004 C cuenta
0000000000000000 T incrementar

cb.o:
0000000000000004 C cuenta
                 U incrementar
0000000000000000 T main
$ gcc -o cuenta_c ca.o cb.o && ./cuenta_c; echo $?
1

Con -fcommon, los dos archivos comparten la misma variable y el programa «funciona»: main devuelve 1, el valor que incrementó el otro archivo. El enlace sale bien con una C (el 4 en la primera columna es el tamaño, no la dirección). Pero un error de ese tipo es una trampa: los dos archivos podrían declarar cuenta con tipos distintos (int y long) y el enlazador no se queja, sino que reserva el tamaño mayor y deja que cada uno acceda a su manera. Por eso el valor por omisión cambió, y por eso la regla del tema 13 es la correcta: la variable compartida se define en un solo .c, y se declara con extern en el encabezado. Los proyectos antiguos que usaban el comportamiento común fallan hoy con multiple definition, y se arreglan corrigiendo el diseño y no volviendo a -fcommon.

6. Los errores de enlace

Con lo anterior, los mensajes del enlazador dejan de ser crípticos. Tienen todos la misma forma: archivo.o: in function `f': a veces, seguido de la posición archivo.c:(.sección+0xN) donde está el hueco que falló, y la causa. Nunca hay un número de línea de C, porque el enlazador no ve C: ve secciones y reubicaciones; el .text+0x9 es el offset del hueco dentro de la sección, el Offset de readelf -r. Los casos reales:

terminal · sin suma.o, y sin datos.o
$ gcc -o c5 principal.o datos.o
/usr/bin/x86_64-linux-gnu-ld.bfd: principal.o: in function `main':
principal.c:(.text+0xe): undefined reference to `sumar_hasta'
collect2: error: ld returned 1 exit status
$ gcc -o c6 principal.o suma.o
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.o: warning: relocation against `visitas' in read-only section `.text'
/usr/bin/x86_64-linux-gnu-ld.bfd: principal.o: in function `main':
principal.c:(.text+0x17): undefined reference to `saludo'
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.o: in function `sumar_hasta':
suma.c:(.text+0xd): undefined reference to `visitas'
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.c:(.text+0x16): undefined reference to `visitas'
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.c:(.text+0x1c): undefined reference to `limite'
/usr/bin/x86_64-linux-gnu-ld.bfd: suma.c:(.text+0x27): undefined reference to `limite'
/usr/bin/x86_64-linux-gnu-ld.bfd: warning: creating DT_TEXTREL in a PIE
collect2: error: ld returned 1 exit status

El primero, sin suma.o, es un error por falta de definición, en principal.o: el mismo del tema 4. El segundo, sin datos.o, es instructivo por otra razón: cada reubicación fallida genera una línea. Aparecen saludo (1 vez), visitas (2) y limite (2), y los offsets .text+0xd, +0x16, +0x1c y +0x27 son exactamente los de las reubicaciones de suma.o de la sección 3. Se ve también que los dos avisos al principio y al final (relocation against … in read-only section, creating DT_TEXTREL in a PIE) son consecuencias del error principal, no problemas adicionales: el enlazador, al no encontrar el símbolo, no pudo hacer el cálculo normal. Se lee de arriba abajo, pero se corrige la causa y no los efectos: falta un solo archivo.

terminal · una función static, la biblioteca matemática, y dos main
$ gcc -std=c17 -Wall -Wextra -c estatica_a.c estatica_b.c && nm estatica_a.o
0000000000000000 t ayudante
000000000000000f T usar_ayudante
$ gcc -o est estatica_a.o estatica_b.o
/usr/bin/x86_64-linux-gnu-ld.bfd: estatica_b.o: in function `main':
estatica_b.c:(.text+0x9): undefined reference to `ayudante'
collect2: error: ld returned 1 exit status
$ gcc -std=c17 -c raiz.c && gcc -o raiz raiz.o
/usr/bin/x86_64-linux-gnu-ld.bfd: raiz.o: in function `main':
raiz.c:(.text+0x46): undefined reference to `sqrt'
collect2: error: ld returned 1 exit status
$ gcc -o raiz raiz.o -lm && ./raiz 2
sqrt(2) = 1.41421
$ gcc -o dosm m1.c m2.c
/usr/bin/x86_64-linux-gnu-ld.bfd: /tmp/ccTXBltp.o: in function `main':
m2.c:(.text+0x0): multiple definition of `main'; /tmp/ccs9CdR2.o:m1.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

Un procedimiento sirve para casi todos los casos de undefined reference to X:

  1. Copiar el nombre exacto del mensaje, y comprobar que está bien escrito en el lugar que lo usa. nombre
  2. Buscar quién lo define: nm -A *.o | grep ' T X', o grep -n 'X(' *.c si se duda del módulo. Si nadie lo define, falta escribirlo; si lo define un archivo que no está en la línea, falta agregarlo. buscar
  3. Si lo define alguien, mirar la letra: una t o una d minúscula significa que es static. visibilidad
  4. Si es una función de una biblioteca del sistema, buscar su página de manual: man 3 sqrt dice en la sinopsis «Link with -lm». biblioteca

7. Los símbolos del ejecutable dinámico

El ejecutable que produjo el enlace no está completo. La llamada a printf apunta a printf@plt, y el código de printf está en libc.so.6, un archivo aparte que el cargador buscará y mapeará al ejecutar el programa (tema 27). Entonces el ejecutable guarda, además de su tabla de símbolos habitual, la información para que el cargador termine el trabajo del enlace en tiempo de ejecución:

terminal
$ readelf --dyn-syms -W calculo
Symbol table '.dynsym' contains 7 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34 (2)
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTable
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.2.5 (3)
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     5: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
     6: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (3)
$ readelf -rW calculo | sed -n '/rela.plt/,$p'
Relocation section '.rela.plt' at offset 0x5f0 contains 1 entry:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000003fd0  0000000300000007 R_X86_64_JUMP_SLOT     0000000000000000 printf@GLIBC_2.2.5 + 0
$ readelf -d calculo | grep -E 'NEEDED|FLAGS'
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE
$ nm -D calculo
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 w __cxa_finalize@GLIBC_2.2.5
                 w __gmon_start__
                 U __libc_start_main@GLIBC_2.34
                 U printf@GLIBC_2.2.5

El ejecutable tiene dos tablas de símbolos para dos propósitos:

El enlazador dejó además reubicaciones dinámicas, que completa el cargador (ld.so) al cargar el programa: R_X86_64_JUMP_SLOT para printf, que llena una entrada de la tabla de saltos (.got, en 0x3fd0) con la dirección real de la función en libc, y R_X86_64_GLOB_DAT para los demás. El call de main va a printf@plt, un trampolín en .plt.sec que salta a través de esa entrada. La bandera BIND_NOW indica que Ubuntu pide resolverlas todas al cargar, no al primer uso, y dejar la tabla de solo lectura después (el RELRO del tema 27). La línea NEEDED es la lista de bibliotecas que el cargador tiene que buscar, que se estudia en los temas 33 y 34. Lo importante para este tema es la idea: el enlace tiene dos tiempos. El enlazador resuelve lo que está en los objetos y las bibliotecas estáticas, y deja pendiente, con símbolos y reubicaciones, lo que viene de bibliotecas dinámicas; el cargador lo termina cuando el programa arranca.

8. Quitar lo que sobra

Dos herramientas reducen el ejecutable. La primera elimina la información que el sistema no necesita para ejecutar:

terminal
$ cp calculo calculo_strip && strip calculo_strip
$ ls -l calculo calculo_strip | awk '{print $5, $9}'
16224 calculo
14472 calculo_strip
$ nm calculo_strip
nm: calculo_strip: no symbols
$ readelf -SW calculo_strip | grep -E 'symtab|strtab|dynsym'
  [ 4] .dynsym           DYNSYM          00000000000003b8 0003b8 0000a8 18   A  5   1  8
$ ./calculo_strip
hola: 5050
$ gdb -q -batch -ex 'info functions sumar' ./calculo_strip
All functions matching regular expression "sumar":

strip borra .symtab y .strtab, que suman unos 1,7 KB de los 16 KB de este ejemplo, y mucho más en programas reales, donde también quita la información de depuración de -g. El programa funciona igual, y se conserva .dynsym, que es la que el cargador necesita. El precio es que ya no hay nombres: nm no encuentra nada, y gdb no puede encontrar la función sumar_hasta por nombre, que sí aparece en el ejecutable sin recortar. Por eso en los programas que se entregan se separa la información de depuración en un archivo aparte, y se despoja el ejecutable. También hay una consecuencia de seguridad: los nombres de las funciones facilitan el análisis inverso.

La segunda herramienta quita código que nadie usa. El enlazador trabaja con secciones enteras: si un archivo objeto tiene cinco funciones en la misma sección .text y se usa una, entra el archivo entero. Con -ffunction-sections el compilador pone cada función en su propia sección, y con -Wl,--gc-sections el enlazador descarta las secciones a las que nadie llega desde el punto de entrada:

terminal · muchas.c define cinco funciones informe_a … informe_e y main solo llama a la primera
$ gcc -std=c17 -O1 -o muchas1 muchas.c
$ gcc -std=c17 -O1 -ffunction-sections -Wl,--gc-sections -o muchas2 muchas.c
$ size muchas1 muchas2 | awk '{print $1, $6}'
text filename
1894 muchas1
1574 muchas2
$ nm muchas1 | grep -c informe; nm muchas2 | grep -c informe
5
1

Con la opción, el ejecutable no contiene las cuatro funciones que nadie llama. Es una técnica de los programas para sistemas pequeños y de las bibliotecas estáticas (tema 32), donde pocas funciones de una biblioteca grande se usan. Hay que usar las dos opciones juntas: la del compilador crea las secciones, y la del enlazador descarta las inalcanzables.

9. Las herramientas, en una tabla

HerramientaPara quéEjemplo
fileQué tipo de archivo ELF es: objeto (relocatable), ejecutable, biblioteca compartida.file suma.o
nmLos símbolos, una letra por símbolo. -u indefinidos, -S tamaño, -n por dirección, -D dinámicos, -A con el archivo.nm -u principal.o
readelf -s, --dyn-symsLa tabla de símbolos con todos sus campos (Bind, Ndx, Type).readelf -sW datos.o
readelf -S, -l, -dSecciones, segmentos y dependencias dinámicas (NEEDED).readelf -d calculo
readelf -r, objdump -drLas reubicaciones, solas o debajo de cada instrucción.objdump -dr suma.o
objdump -d, -h, -tDesensamblado, secciones y tabla de símbolos.objdump -d calculo
sizeTamaño de text, data y bss.size calculo
-Wl,-Map=archivoEl mapa de enlace: qué secciones de qué objeto quedaron en qué dirección.gcc -Wl,-Map=m.map …
-Wl,-y,nombre, -Wl,--crefQuién define y quién referencia un símbolo; tabla cruzada.gcc -Wl,-y,limite …
stripQuita .symtab y la información de depuración.strip calculo
-ffunction-sections y -Wl,--gc-sectionsDescartar las funciones que nadie usa.sección 8

10. Errores frecuentes

ErrorQué pasaCorrección
Buscar un error de enlace en el código CEl enlazador no ve C ni líneas: ve símbolos y secciones.Mirar nm de los objetos y la línea de gcc.
Olvidar un archivo .o o .c en la línea de gccundefined reference a todo lo que define.Agregarlo; nm -A dice cuál es.
Función definida pero declarada staticundefined reference aunque el código exista.Quitar static si debe verse desde afuera.
Olvidar -lm (u otra biblioteca)undefined reference to `sqrt'.Agregar -lm al final de la línea.
Corregir las líneas de warning del enlazador antes del errorSon efectos de la misma causa.Resolver el primer undefined reference.
Definir una variable o función en un encabezadomultiple definition con cada .c que lo incluya.Definir en un .c; declarar con extern en el .h.
int x; en dos archivosError desde gcc 10; antes funcionaba por los símbolos comunes.Una definición y extern en las demás.
Arreglar un viejo proyecto con -fcommonOculta dos variables distintas con el mismo nombre.Corregir el diseño.
Declarar con un tipo y definir con otroEl enlace sale bien y el programa falla: el enlazador no ve tipos.Un solo encabezado, incluido en la definición.
Suponer que el orden de los archivos no importaCambia las direcciones; con bibliotecas, puede cambiar el resultado (tema 32).Objetos primero, bibliotecas al final.
Hacer strip antes de depurarSin nombres ni líneas, gdb y nm no ayudan.Conservar una copia con símbolos.
Usar --gc-sections sin -ffunction-sectionsCasi no descarta nada: cada objeto es una sola sección.Las dos opciones juntas.

11. Resumen

12. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué significa que suma.o sea un archivo «relocatable»?

Que es una pieza todavía no ubicada: sus secciones empiezan en la dirección 0, y el código contiene huecos para las direcciones que se conocerán recién al enlazar. Lleva consigo las reubicaciones que dicen cómo llenarlos. El enlazador decide dónde queda cada sección y escribe los valores definitivos.

2. En readelf -s, ¿cómo se distingue un símbolo definido de uno indefinido, y uno local de uno global?

Un símbolo definido tiene en la columna Ndx el número de la sección donde está; uno indefinido dice UND. La columna Bind distingue GLOBAL (visible desde otros archivos) de LOCAL (propio del archivo, como una variable o función static) y de WEAK.

3. ¿Qué diferencia hay entre una t y una T de nm, y por qué importa en un undefined reference?

La T es una función global y la t, una local, es decir, static. El enlazador no une un símbolo local con las referencias de otros archivos. Por eso una función definida como static produce undefined reference desde otro módulo aunque el código exista: nm muestra la t y delata el problema.

4. ¿Por qué el compilador deja 00 00 00 00 en un call a otra función, y qué es la entrada R_X86_64_PLT32 sumar_hasta-0x4?

Porque todavía no sabe a qué distancia va a quedar la función. El hueco de cuatro bytes se llenará después, y la entrada de reubicación dice dónde está (el Offset), cómo calcular el valor (el tipo PLT32, una distancia relativa de 32 bits), a qué símbolo se refiere y qué sumar (el addend, −4).

5. ¿De dónde sale el −4 del addend, y cómo se calcula el valor que se escribe?

El procesador calcula una dirección relativa a %rip sumando el desplazamiento a la dirección de la instrucción siguiente, cuatro bytes después del campo. El valor a escribir es S − (P + 4) = S + A − P, con S la dirección del símbolo, P la del hueco y A = −4.

6. Comprueba la reubicación de call sumar_hasta: el hueco está en 0x1157 y sumar_hasta en 0x1182. ¿Qué bytes se escriben?

0x1182 − 4 − 0x1157 = 0x27, que en cuatro bytes little-endian es 27 00 00 00. En el desensamblado del ejecutable, la instrucción aparece como e8 27 00 00 00, que es lo que calculó el enlazador.

7. ¿Cuáles son los tres pasos del enlazador?

Resolver los símbolos (cada indefinido se empareja con una definición); asignar direcciones, juntando las secciones del mismo tipo de todos los objetos en el orden de la línea; y aplicar las reubicaciones, escribiendo en cada hueco el valor calculado con las direcciones definitivas.

8. ¿Qué pasa si una función tiene una definición débil en un archivo y una fuerte en otro?

Gana la fuerte, en cualquier orden de la línea de órdenes, y no hay error. Si solo hay una definición débil, se usa esa. Esto permite ofrecer una versión por omisión que otro módulo puede reemplazar sin modificar el original.

9. ¿Qué vale la dirección de un símbolo débil indefinido, y cómo lo aprovecha un programa?

Vale 0 (NULL), y el enlace no da error. El programa puede comprobar if (funcion != NULL) antes de llamarla, para usar una función opcional solo si algún módulo la define.

10. ¿Por qué dos archivos con int cuenta; enlazaban antes y hoy dan multiple definition?

Antes, gcc trataba una variable global sin valor y sin extern como un símbolo común, que el enlazador unificaba con las demás del mismo nombre. Desde gcc 10 el valor por omisión es -fno-common: cada una es una definición fuerte en .bss. La forma correcta es definir la variable en un solo .c y declararla con extern en los demás.

11. Un enlace falla con undefined reference to `sqrt', aunque se incluyó math.h. ¿Qué pasó?

math.h solo declara la función, lo que satisface al compilador. La definición está en la biblioteca matemática, que es aparte de libc, y hay que pedirla al enlazar con -lm. El error es del enlace, y por eso el mensaje cita el offset de la llamada y no una línea de C.

12. En el enlace sin datos.o aparecen advertencias como «creating DT_TEXTREL in a PIE». ¿Hay que corregirlas?

No: son consecuencias de los símbolos indefinidos, porque el enlazador, al no encontrar visitas ni limite, no puede resolver esas reubicaciones de la manera normal. Se corrige la causa, el archivo que falta, y las advertencias desaparecen con ella.

13. ¿Qué diferencia hay entre .symtab y .dynsym?

.symtab es la tabla completa de símbolos del programa, para herramientas como nm y gdb; no se necesita para ejecutarlo y strip la elimina. .dynsym es la tabla mínima de lo que cruza la frontera con las bibliotecas compartidas, que el cargador usa al arrancar el programa; strip la conserva.

14. ¿Para qué se combinan -ffunction-sections y -Wl,--gc-sections?

El enlazador solo puede descartar secciones enteras. Con -ffunction-sections el compilador pone cada función en su propia sección, y con --gc-sections el enlazador elimina las que no se alcanzan desde el punto de entrada. Con solo la segunda, cada objeto sigue siendo una única sección .text y no se descarta casi nada.

Para practicar

  1. Compila datos.c, suma.c y principal.c con -O2 en lugar de -O0, y compara con objdump -dr las reubicaciones de suma.o. ¿Cuántas quedan? ¿Siguen siendo R_X86_64_PC32?
  2. Calcula a mano, con la fórmula S + A − P, la reubicación de la segunda referencia a limite (la del offset 0x27 de suma.o) con las direcciones del ejecutable, y compárala con los bytes que muestra objdump -d calculo.
  3. Enlaza los tres objetos en otro orden (datos.o suma.o principal.o), pide el mapa y verifica que cambian las direcciones pero el programa imprime lo mismo. ¿Dónde queda ahora main?
  4. Agrega una función static a suma.c y llámala desde sumar_hasta. Compila con -O0 y con -O2, y mira con nm si el símbolo existe en cada caso. ¿Por qué puede desaparecer?
  5. Escribe dos archivos con una función débil por omisión y una fuerte que la reemplaza, y agrega una tercera definición débil en otro archivo. Enlaza en varios órdenes y confirma qué definición se usa. ¿Qué pasa si hay dos definiciones fuertes?
  6. Define int x; en dos archivos y enlázalos con -fcommon; luego cambia el tipo de uno a long x; y vuelve a enlazar. Mira nm -S y explica por qué el enlace no se queja.
  7. Escribe un programa que use pthread_create o sin, y reproduce los dos errores clásicos: olvidar -lm o -pthread. Diagnostícalos solo con el mensaje y man.
  8. Compila con -ffunction-sections -Wl,--gc-sections un programa con funciones sin usar y compara size y nm con y sin las opciones. Después haz strip y compara otra vez.