wandres.dev
INICIADO · El proceso de compilación

Unidades de traducción y objetos

Qué es realmente un .o por dentro: secciones, tabla de símbolos y relocalizaciones. Y cómo leerlo con nm y objdump hasta que un undefined reference deje de ser un misterio.

⏱ 16 min

Un archivo objeto no es un ejecutable a medio hacer: es un formato completo con su propia estructura, pensado para que otro programa —el enlazador— lo componga con sus hermanos. Dentro hay código, datos, una lista de los nombres que ese código define, otra de los que necesita de fuera, y un conjunto de anotaciones que marcan cada hueco pendiente de rellenar. Aprender a abrirlo con nm y objdump es lo que transforma el enlazado de acto de fe en operación comprensible.

🎯 Al terminar esta lección sabrás
  • Definir con precisión qué es una unidad de traducción y por qué es una frontera.
  • Enumerar las secciones de un ELF reubicable y saber qué guarda cada una.
  • Leer la tabla de símbolos con nm e interpretar sus letras.
  • Inspeccionar código y relocalizaciones con objdump.

La unidad de traducción es una frontera

El estándar define unidad de traducción como un archivo fuente más todos los headers que incluye, una vez completado el preprocesado y descartadas las líneas eliminadas por compilación condicional. Es exactamente el .i que produjiste con -E.

Lo decisivo es que esa unidad es la frontera del conocimiento del compilador. Al compilar a.c, el compilador no sabe nada de b.c: no ve sus tipos, no ve sus funciones, no puede comprobar si la llamada que haces coincide con la definición real. Solo confía en la declaración que le diste en un header. Toda la disciplina de la modularidad en C —headers con declaraciones, static para lo privado, extern para lo compartido— nace de esta limitación estructural.

De ahí salen dos conceptos que conviene fijar ya:

  • Enlace externo: el símbolo es visible para otras unidades. Es lo que ocurre por defecto con funciones y variables globales.
  • Enlace interno: static en ámbito de archivo restringe el símbolo a su unidad. No aparece como global y no puede colisionar con otro del mismo nombre.

Un ejemplo mínimo que ejercita las dos formas y que usaremos en el resto de la lección:

/* modulo.c */
int  contador_publico = 7;      /* global inicializado: enlace externo */
int  acumulado;                 /* global a cero: enlace externo */
static int oculto = 3;          /* enlace interno: solo esta unidad */

static int ayudante(int x)      /* enlace interno */
{
    return x + oculto;
}

int procesar(int x)             /* enlace externo: la API del módulo */
{
    return ayudante(x) + contador_publico;
}

Cuatro símbolos con enlace externo y dos con enlace interno. En un momento verás esa distinción convertida en letras concretas dentro del objeto.

Qué hay dentro de un .o

Compila sin enlazar y mira su estructura:

gcc -std=c23 -c modulo.c -o modulo.o
size modulo.o          # tamaños de text, data y bss
readelf -S modulo.o    # tabla de secciones completa

Las secciones que verás casi siempre, y lo que significan:

⚙️

Código y constantes

.text guarda las instrucciones; .rodata guarda literales y constantes de solo lectura.

⚙️

Datos

.data guarda globales inicializadas a un valor distinto de cero; .bss solo reserva tamaño para las que valen cero.

⚙️

Metadatos

.symtab y .strtab forman la tabla de símbolos y sus nombres; .rela.text lista las relocalizaciones pendientes.

Que .bss no ocupe espacio en el archivo es una elegancia real del formato: un array global de un millón de enteros a cero añade cuatro megabytes al proceso en memoria y cero bytes al .o. Solo se guarda el tamaño; el sistema pone los ceros al cargar.

flowchart TD
O[modulo.o formato ELF REL] --> T[.text instrucciones]
O --> R[.rodata constantes]
O --> D[.data globales con valor]
O --> B[.bss globales a cero: solo tamano]
O --> S[.symtab simbolos definidos y usados]
O --> L[.rela.text huecos a rellenar]
style S fill:#f9e2af,color:#11111b
style L fill:#fab387,color:#11111b

nm: leer la tabla de símbolos

nm lista los símbolos con una letra que codifica dónde vive cada uno. La regla maestra: mayúscula significa global, minúscula significa local.

nm modulo.o
nm -g modulo.o            # solo los globales
nm --defined-only modulo.o
nm -u modulo.o            # solo los indefinidos

Las letras que importan de verdad:

  • T y t: definido en .text, es decir, una función con código.
  • D y d: variable global inicializada, en .data.
  • B y b: variable global a cero, en .bss.
  • R y r: constante en .rodata.
  • U: indefinido. Este objeto lo usa pero no lo define; alguien tendrá que aportarlo.
  • W y w: símbolo débil, sustituible por una definición fuerte sin conflicto.

Ese U es la pieza central de esta lección. Cuando el enlazador dice undefined reference, está diciendo que quedó un U sin nadie que lo resolviera con una T. Con nm puedes comprobar antes de enlazar quién define qué:

nm -g --defined-only *.o | grep procesar   # ¿alguien define este símbolo?
nm -u main.o                               # ¿qué necesita main de fuera?

Marca una función como static y vuelve a ejecutar nm: su T se convierte en t y desaparece de la lista de -g. Acabas de hacer visible en el binario una decisión de diseño que tomaste en el código fuente.

objdump: código y relocalizaciones

objdump va un nivel más abajo y muestra el contenido real:

objdump -d modulo.o      # desensambla las secciones ejecutables
objdump -t modulo.o      # tabla de símbolos, más detallada que nm
objdump -r modulo.o      # relocalizaciones pendientes
objdump -h modulo.o      # cabeceras de sección
objdump -s -j .rodata modulo.o   # volcado en bruto de una sección

Al desensamblar el .o verás que todas las direcciones empiezan en cero y que las llamadas a funciones externas apuntan a un destino falso, seguido de una anotación como R_X86_64_PLT32 procesar-0x4. Eso es una relocalización: un registro que dice “en este desplazamiento hay cuatro bytes que el enlazador debe rellenar con la dirección final de procesar”. El objeto está lleno de huecos etiquetados, y enlazar consiste esencialmente en rellenarlos.

Compara ese desensamblado con el del ejecutable final y verás las direcciones ya resueltas. Es la misma información antes y después de que el enlazador haga su trabajo.

Un detalle histórico que conviene conocer porque explica errores que verás en proyectos antiguos: hasta GCC 9, una definición tentativa —una global declarada sin inicializador en varias unidades— se emitía como símbolo C de tipo común, y el enlazador fusionaba silenciosamente todas las copias en una. Desde GCC 10 el comportamiento por defecto es -fno-common: cada unidad reserva la suya en .bss y el duplicado se convierte en un error de definición múltiple. Es un cambio a mejor, pero rompe código heredado que dependía de la fusión implícita.

nm modulo.o | grep ' C '            # simbolos comunes: no deberia haber ninguno
gcc -std=c23 -fcommon -c modulo.c   # solo para compilar codigo antiguo
Un símbolo es un contrato sin cuerpo

Aquí está la abstracción que sostiene todo el software compilado moderno. Un símbolo es un nombre al que se le ha separado la referencia de la definición: quien lo usa solo necesita el nombre y una promesa de tipo, y quien lo define puede estar en otro archivo, en otra biblioteca compilada hace años, o incluso resolverse al arrancar el proceso. Esa separación es lo que permite las bibliotecas compartidas, la carga dinámica con dlopen, los símbolos débiles que un módulo puede sobrescribir y hasta la interposición con LD_PRELOAD. Y también es la fuente de una familia entera de fallos: si el compilador solo ve la declaración y esta miente sobre la definición real, nadie te detiene —los tipos no viajan en la tabla de símbolos de C—. Por eso una función declarada con una firma en un header y definida con otra en un .c compila, enlaza y luego corrompe la pila en tiempo de ejecución. Entender que un símbolo es un contrato sin cuerpo explica a la vez el poder del modelo y su peligro.

⚔️ Disecciona un objeto
  1. Escribe un .c con una función global, una static, una variable global inicializada y otra a cero. Compílalo con -c y ejecuta nm sobre él.
  2. Identifica la letra de cada símbolo y explica por qué le corresponde esa y no otra.
  3. Llama desde ese archivo a una función que no definas y localiza su U con nm -u.
  4. Ejecuta objdump -d -r sobre el objeto y encuentra la relocalización asociada a esa llamada.
  5. Declara un array global de un millón de enteros a cero y compara el tamaño del .o con el que muestra size.