wandres.dev
BIBLIOTECARIO · Bibliotecas estáticas y dinámicas

Bibliotecas estáticas

Un archivo .a es un contenedor de objetos con un índice de símbolos, y de ese único hecho se deducen sus tres comportamientos desconcertantes: la extracción selectiva de miembros, la sensibilidad al orden de los operandos y la desaparición silenciosa del código que nadie referencia por nombre.

⏱ 18 min

Un archivo .a no es una biblioteca en el sentido que sugiere la palabra: es un contenedor de ficheros objeto con un índice de símbolos pegado delante, y nada más. Esa diferencia, que parece de vocabulario, gobierna todo lo demás. Explica por qué el enlazador se lleva unos miembros y descarta otros, por qué el mismo conjunto de objetos enlaza o no según el orden en que los nombras en la línea de órdenes, y por qué un registro automático que funcionaba al compilar los .o sueltos desaparece sin dejar rastro en cuanto los empaquetas. Los tres misterios comparten la misma raíz, y esta lección los deduce de ella.

🎯 Al terminar esta lección sabrás
  • Construir e inspeccionar un archivo .a con ar y nm, y explicar para qué existe su índice de símbolos.
  • Enunciar la regla de extracción del enlazador y reconocer que su granularidad es el miembro, no la función.
  • Justificar por qué el orden de los operandos importa y cuándo hacen falta grupos o repeticiones.
  • Detectar los patrones que el enlace estático rompe en silencio y las opciones que los rescatan.

Un contenedor con índice

ar es más antiguo que casi todo lo que usas: nació como archivador de propósito general, hermano lejano de tar, y solo después se especializó en agrupar objetos. Un .a no está enlazado, ni relocalizado, ni resuelto. Es una concatenación de miembros, cada uno con su cabecera, precedida de una tabla que asocia cada símbolo definido con el desplazamiento del miembro que lo define.

gcc -std=c23 -c cadena.c vector.c hash.c
ar rcs libutil.a cadena.o vector.o hash.o

ar t libutil.a           # lista los miembros
nm -s libutil.a          # imprime el indice: simbolo y miembro que lo define

Las tres letras de rcs son exactamente las que hacen falta: r inserta o reemplaza miembros, c crea el archivo sin quejarse de que no existía y s escribe el índice. Ese índice es lo que antaño generaba ranlib en un paso aparte; hoy ar s hace el trabajo y ranlib sobrevive por compatibilidad. Sin él, el enlazador tendría que recorrer los miembros de forma lineal y repetir pasadas hasta estabilizarse; con él, resolver un símbolo es una búsqueda directa.

Conviene insistir en lo que ar no hace. No comprueba nada, no resuelve nada, no elimina duplicados. Dos miembros que definen el mismo símbolo conviven sin protestar dentro del archivo, y el conflicto solo se manifiesta si el enlazador acaba extrayendo los dos. El archivo tampoco recuerda de qué depende: un .a que llama a sqrt no anota en ninguna parte que necesita libm, así que esa información viaja fuera del artefacto, en la documentación o en un fichero de pkg-config. Es la primera asimetría importante frente a las bibliotecas dinámicas, que sí llevan sus dependencias escritas dentro, y la razón de que exista pkg-config --libs --static, que emite la clausura transitiva completa en lugar de la lista directa.

Existe además una variante que ahorra copias durante la construcción: el archivo delgado, creado con ar rcsT, que no incorpora los objetos sino que guarda referencias a sus rutas. Acelera y adelgaza los árboles de compilación intermedios, pero deja de ser autocontenido, así que nunca se instala ni se distribuye.

💡
Cuatro instrumentos para no adivinar

ar t responde qué hay dentro; nm -s responde quién define qué; nm --undefined-only sobre un miembro responde qué necesita a su vez; y ld --trace o -Wl,-t durante el enlace imprime cada fichero y cada miembro que el enlazador acaba incorporando. Esa última opción es la que convierte una discusión sobre qué debería entrar en una lista de lo que entró de verdad, y suele resolver en un minuto los conflictos de definición duplicada que de otro modo se depuran a ciegas.

La regla de extracción

Cuando el enlazador procesa la línea de órdenes mantiene un conjunto de símbolos pendientes: los que alguien referenció y nadie ha definido todavía. Al llegar a un archivo consulta su índice y busca miembros que definan alguno de esos pendientes. Extrae los que encuentre, incorpora las referencias sin resolver que traen consigo, y repite el barrido sobre el mismo archivo hasta que ninguna extracción nueva sea posible. Después pasa al siguiente operando y no vuelve la vista atrás.

flowchart TB
A[Conjunto de simbolos pendientes] --> B[El enlazador abre libutil.a]
B --> C{Algun miembro define un pendiente}
C -->|No| D[El archivo se ignora por completo]
C -->|Si| E[Extrae ese miembro]
E --> F[Suma sus referencias sin resolver a los pendientes]
F --> C
D --> G[Sigue con el siguiente operando]

De aquí sale la propiedad que hace atractivo el enlace estático: lo que nadie usa no llega al binario. Pero la granularidad de esa poda es el miembro, es decir, una unidad de traducción completa. Si vector.c define cuarenta funciones y tu programa llama a una sola, el enlazador arrastra las cuarenta, más todo lo que ellas referencien a su vez. Por eso las implementaciones históricas de libc reparten una función por fichero: no es manía estilística, es la única forma de que la extracción selectiva funcione a la resolución que interesa.

La alternativa moderna consiste en dar al enlazador una granularidad más fina dentro de cada objeto, colocando cada función y cada dato en su propia sección para después recolectar las secciones que ningún camino alcanza.

gcc -std=c23 -ffunction-sections -fdata-sections -c vector.c
ar rcs libutil.a vector.o
gcc main.c -L. -lutil -Wl,--gc-sections -Wl,--print-gc-sections -o prog

En el extremo opuesto está -Wl,--whole-archive, que ordena incluir todos los miembros sin preguntar. Se aplica a los archivos que vienen detrás y se desactiva con -Wl,--no-whole-archive, así que casi siempre aparece encerrando un único operando.

La regla de extracción tiene además una consecuencia que sorprende la primera vez: un símbolo duplicado solo es un error si ambas definiciones acaban dentro. Si dos miembros del mismo archivo definen registrar y tu programa solo arrastra uno de ellos, la construcción es limpia; el día que alguien añada una llamada que arrastre el otro, aparecerá un fallo de definición múltiple sin que nadie haya tocado el código culpable. Los símbolos débiles, marcados con __attribute__((weak)), matizan esa regla al permitir que una definición fuerte gane sobre una débil en lugar de chocar con ella, y son el mecanismo con el que libc deja que sobrescribas malloc sin conflictos.

Cuando compilas con optimización en tiempo de enlazado, los miembros del archivo no contienen código máquina sino representación intermedia del compilador, y el enlazador delega la extracción en un complemento. La regla de fondo no cambia, pero el rendimiento sí: la frontera entre unidades de traducción deja de bloquear la inserción en línea, de modo que buena parte del coste de repartir el código en muchos ficheros desaparece. Conviene entonces construir el archivo con gcc-ar en lugar de ar, para que el índice se genere entendiendo esos objetos.

Para discutir tamaños con datos en lugar de con intuiciones existe el mapa de enlace, un informe que el enlazador emite bajo petición y que enumera cada sección incorporada, su origen y su tamaño.

gcc main.o -L. -lutil -Wl,-Map=prog.map -o prog
sort -k2 -n prog.map | tail -20     # las contribuciones mas gordas
size --format=sysv prog             # reparto por sección del binario final

Leer un mapa la primera vez resulta árido y después se vuelve un reflejo: casi siempre revela que el peso del binario no está donde uno creía, sino en dos o tres miembros que entraron por una dependencia indirecta que nadie pretendía. Esa es exactamente la información que permite decidir si vale la pena partir un fichero, romper una dependencia o activar la recolección de secciones.

El orden de los operandos

El enlazador clásico hace una sola pasada de izquierda a derecha, y esa decisión de diseño, tomada cuando la memoria se medía en kilobytes, sigue vigente. La consecuencia es incómoda para quien llega de otros ecosistemas: los operandos no son un conjunto, son una secuencia, y gcc -lutil main.o falla donde gcc main.o -lutil funciona. En el primer caso, cuando el enlazador abre el archivo todavía no hay ningún símbolo pendiente que justifique extraer nada, de modo que lo descarta entero y después se encuentra con un main.o cuyas referencias ya no tienen dónde resolverse.

La regla práctica se enuncia en una línea: quien usa va antes que quien provee. Si libalta.a llama a libbaja.a, se escribe -lalta -lbaja. Los ficheros .o sueltos escapan a todo esto porque se incluyen siempre, incondicionalmente, definan lo que definan; la extracción condicional es una propiedad exclusiva de los archivos.

Las dependencias circulares entre archivos no tienen orden correcto, y para ellas existe el grupo, que reexamina sus miembros hasta alcanzar un punto fijo.

gcc main.o -Wl,--start-group -la -lb -Wl,--end-group -o prog
gcc main.o -la -lb -la -o prog          # el truco artesanal equivalente

El grupo cuesta pasadas repetidas sobre los mismos índices, y su presencia suele delatar una arquitectura en la que dos módulos deberían haber sido uno. Los enlazadores más recientes, como lld y mold, resuelven archivos de forma menos ingenua y perdonan órdenes que ld rechazaría, e incluso ofrecen --start-lib para tratar un puñado de .o sueltos con semántica de archivo, sin llegar a construirlo. Nada de eso es portable: escribir la línea en orden topológico sigue siendo lo único que funciona en todas partes, y los sistemas de construcción serios lo calculan por ti a partir del grafo de dependencias declarado.

Lo que el enlace estático rompe en silencio

Hay un patrón que el archivo destruye sin emitir un solo aviso: el autorregistro. Un módulo que se apunta a sí mismo en una tabla global desde un constructor, sin exportar ningún símbolo que alguien referencie por nombre, no tiene ninguna razón para ser extraído. Compilado como .o suelto funciona; empaquetado en un .a, la tabla amanece vacía y el programa se comporta como si el módulo no existiera.

/* plugin_jpeg.c: nadie referencia nada de este fichero por su nombre */
__attribute__((constructor))
static void registrar(void) {
    formato_registrar(&FORMATO_JPEG);   /* jamas se ejecuta desde un .a */
}

Las salidas son tres, en orden de preferencia decreciente. La primera es renunciar a la magia y hacer que alguien referencie explícitamente cada módulo desde una tabla escrita a mano: el registro se vuelve verboso pero deja de depender del enlazador. La segunda es encerrar ese archivo concreto entre -Wl,--whole-archive y -Wl,--no-whole-archive, pagando el precio de incluirlo entero. La tercera, propia de núcleos y sistemas empotrados, consiste en emitir los descriptores en una sección propia y protegerla con KEEP en el guion del enlazador, para que la recolección no se la lleve.

Hay una segunda trampa, menos vistosa y más cara de diagnosticar: una biblioteca estática no impone su propio orden interno. Si construyes tu archivo con -ffunction-sections pero enlazas sin --gc-sections, arrastras todo; si lo construyes sin -fPIC y luego alguien intenta incorporarlo dentro de una biblioteca compartida, el enlazador rechaza la reubicación y el mensaje de error habla de un tipo de reubicación que nada tiene que ver con el problema real. La regla que evita ese callejón es simple: si existe la menor posibilidad de que un archivo estático acabe dentro de un .so, compílalo con -fPIC desde el principio.

📦

ar rcs

Construye el archivo y escribe el índice de símbolos. Con T produce un archivo delgado, útil solo dentro del árbol de construcción.

🔎

nm y ld --trace

Uno responde qué define y qué necesita cada miembro; el otro, cuáles acabó incorporando el enlace real.

✂️

--gc-sections

Baja la granularidad de la poda del miembro a la sección. Exige compilar con -ffunction-sections y -fdata-sections.

🧲

--whole-archive

Desactiva la extracción selectiva para el archivo que encierra. El rescate del autorregistro y también la forma más fácil de duplicar el tamaño del binario.

Un caso extremo cierra el panorama y anticipa el nivel 22: enlazar el programa entero de forma estática con -static, incluida la biblioteca de C. Produce un ejecutable que no depende de nada instalado y se despliega copiando un fichero, que es exactamente lo que quiere un contenedor mínimo. Con glibc, sin embargo, esa promesa tiene una grieta conocida: las funciones de resolución de nombres y de identificación de usuarios cargan módulos compartidos en ejecución, de modo que un binario supuestamente estático vuelve a depender del sistema justo en la operación que menos lo esperabas, y sin diagnóstico claro cuando falla. La respuesta habitual es cambiar de biblioteca de C —musl no arrastra ese mecanismo— y no fingir que el problema no existe.

📝
El archivo no desaparece con el enlace dinámico

Aunque distribuyas bibliotecas compartidas, seguirás construyendo archivos estáticos: son la moneda interna de cualquier proyecto grande, el artefacto que agrupa un subsistema antes de incorporarlo a un .so o a un ejecutable. Todo lo de esta lección sigue aplicándose ahí dentro, con un requisito añadido: si el archivo va a acabar dentro de una biblioteca compartida, sus objetos deben compilarse con -fPIC, que es justo el asunto de la lección siguiente.

La estructura de tus ficheros es una propiedad del binario

Casi todo lo que decides sobre la organización del código fuente es reversible y no deja huella: renombrar un fichero, mover una función de un módulo a otro o partir un archivo grande en tres son operaciones que el compilador olvida en cuanto termina. El archivo estático es la excepción notable, y merece que te detengas en ella. Como la unidad de extracción es la unidad de traducción, el reparto físico de tus funciones entre ficheros deja de ser una cuestión de gusto y se convierte en una propiedad medible del ejecutable final: el mismo código, exactamente el mismo, produce binarios de tamaños distintos según cómo lo hayas repartido. Ahí está el fenómeno interesante. La extracción selectiva es una forma de eliminación de código muerto, pero de una especie muy peculiar: no se basa en analizar el flujo del programa, sino en preguntar quién nombra a quién. Es alcanzabilidad calculada sobre el grafo de símbolos, no sobre el grafo de llamadas, y por eso el registro por constructor se le escapa: existe un camino de ejecución hacia ese código, pero no existe ningún nombre que lo señale. Esa distinción entre estar referenciado y ser alcanzable atraviesa toda la informática de sistemas —reaparece en los recolectores de basura, en el árbol de dependencias de un empaquetador y en el análisis de vivacidad de un compilador— y el enlazador es el lugar donde la ves en su forma más desnuda. Comprenderla convierte --gc-sections, --whole-archive y KEEP en tres respuestas a la misma pregunta: dónde termina el alcance de un nombre y empieza el de un programa.

⚔️ Disecciona un archivo
  1. Compila tres unidades, empaquétalas con ar rcs y compara ar t, nm -s y el volcado de readelf -h sobre un miembro extraído con ar x.
  2. Escribe un vector.c con diez funciones, usa solo una y mide el tamaño del binario; repite con -ffunction-sections -fdata-sections -Wl,--gc-sections y explica la diferencia con -Wl,--print-gc-sections.
  3. Provoca deliberadamente el fallo del orden invirtiendo los operandos y transcribe el mensaje exacto del enlazador; después arréglalo sin cambiar una línea de código.
  4. Construye dos archivos con dependencia circular, comprueba que ningún orden los enlaza y resuélvelo con -Wl,--start-group.
  5. Reproduce el fallo del autorregistro: verifica que funciona con el .o suelto y desaparece con el .a, y rescátalo con --whole-archive.