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

Tema 33

Bibliotecas dinámicas

En el tema 32 la biblioteca se copiaba dentro del ejecutable al enlazar. La alternativa es dejarla fuera: el ejecutable solo anota el nombre de lo que necesita, y el sistema la busca y la carga cada vez que el programa arranca. Es lo que hacen casi todos los programas de un Linux (libc.so.6 es el ejemplo que ya conocemos), y trae ventajas muy concretas: un solo ejemplar de la biblioteca en disco y en memoria, y la posibilidad de corregirla sin tocar los programas que la usan. A cambio, hay un paso nuevo que puede fallar: encontrarla al ejecutar. Este tema enseña a crear una biblioteca dinámica con -fPIC y -shared, a ponerle un soname, y sobre todo a entender cómo la encuentra el cargador (LD_LIBRARY_PATH, rpath, la caché de ldconfig), porque casi todos los problemas con estas bibliotecas son problemas de búsqueda.

1. Qué es una biblioteca dinámica

Una biblioteca dinámica (también llamada compartida, o shared object, de ahí la extensión .so) es un archivo ELF como el ejecutable, pero pensado para ser cargado por otros. Se diferencia de la estática en tres cosas:

Usaremos el mismo ejemplo del tema 32: dos módulos (numeros.c y texto.c), un encabezado único en inc/util.h y un cliente prog.c. Esta vez agregamos dos detalles para ver mejor lo que pasa: una variable global llamadas que cuenta cuántas funciones numéricas se han usado, y una función version_util() en version.c que devuelve la versión de la biblioteca como texto.

inc/util.h, numeros.c, version.c y prog.c (texto.c es igual al del tema 32)
/* inc/util.h */
#ifndef UTIL_H
#define UTIL_H
#include <stddef.h>

extern int llamadas;

int maximo(const int *v, size_t n);
double promedio(const int *v, size_t n);
void invertir(char *s);
void mayusculas(char *s);
const char *version_util(void);
#endif

/* numeros.c */
#include "util.h"

int llamadas;

int maximo(const int *v, size_t n)
{
    llamadas++;
    int m = v[0];
    for (size_t i = 1; i < n; i++)
        if (v[i] > m)
            m = v[i];
    return m;
}

double promedio(const int *v, size_t n)
{
    llamadas++;
    long s = 0;
    for (size_t i = 0; i < n; i++)
        s += v[i];
    return (double)s / (double)n;
}

/* version.c */
#include "util.h"

const char *version_util(void)
{
    return "1.0";
}

/* prog.c */
#include <stdio.h>
#include "util.h"

int main(void)
{
    int v[] = {4, 9, 2, 7};
    char s[] = "enlace";

    printf("maximo=%d promedio=%.2f\n", maximo(v, 4), promedio(v, 4));
    invertir(s);
    printf("%s (util %s, %d llamadas)\n", s, version_util(), llamadas);
    return 0;
}

2. Crearla: -fPIC y -shared

Hacen falta dos cambios respecto de una biblioteca estática. Al compilar cada fuente se agrega -fPIC, y al enlazar se usa -shared en lugar de ar:

terminal
$ gcc -std=c17 -Wall -Wextra -Iinc -fPIC -c numeros.c texto.c version.c
$ mkdir libs
$ gcc -shared -o libs/libbasico.so numeros.o texto.o version.o
$ file libs/libbasico.so | sed 's/, BuildID.*//'
libs/libbasico.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked
$ nm -D libs/libbasico.so | grep -E ' (T|U|B|D) '
0000000000001259 T invertir
000000000000401c B llamadas
0000000000001139 T maximo
00000000000012e6 T mayusculas
00000000000011b7 T promedio
                 U strlen@GLIBC_2.2.5
                 U toupper@GLIBC_2.2.5
0000000000001325 T version_util

El resultado es un shared object ELF. nm -D muestra la tabla de símbolos dinámicos, la que usa el cargador (-D es de dynamic): lo que la biblioteca ofrece (T, y B para la variable llamadas) y lo que le falta (U): strlen y toupper se resolverán contra libc.so.6, con la versión de símbolo GLIBC_2.2.5 que pide cada una. A diferencia del .a del tema anterior, una biblioteca dinámica sí recuerda de qué otras depende: lo registra con entradas NEEDED, que se ven con readelf -d:

terminal
$ readelf -d libs/libbasico.so | grep -E 'SONAME|NEEDED'
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

¿Qué es -fPIC?

Son las siglas de Position Independent Code, código independiente de la posición. Una biblioteca dinámica se mapea en la dirección que el sistema elija en cada ejecución, y varios procesos pueden verla en direcciones distintas. Para que una sola copia del código sirva en todas, ese código no puede contener direcciones absolutas: accede a sus propias funciones con saltos relativos y a las variables globales a través de una tabla (la GOT, Global Offset Table) que el cargador rellena. Como el código no necesita ajustes al cargarlo, queda de solo lectura y se puede compartir entre procesos.

Si se omite, el enlazador lo detecta cuando el código accede a datos globales. Para demostrarlo hay que apagar a propósito el modo que gcc usa por defecto, porque en una distribución moderna gcc compila con -fPIE (el ejecutable de posición independiente del tema 31) y ese código ya es casi reubicable:

terminal · numeros.c sin PIC
$ gcc -std=c17 -Wall -Wextra -Iinc -fno-pic -c numeros.c -o np.o
$ gcc -shared -o np.so np.o
/usr/bin/x86_64-linux-gnu-ld.bfd: np.o: warning: relocation against `llamadas' in read-only section `.text'
/usr/bin/x86_64-linux-gnu-ld.bfd: np.o: relocation R_X86_64_PC32 against symbol `llamadas' can not be used when making a shared object; recompile with -fPIC
/usr/bin/x86_64-linux-gnu-ld.bfd: final link failed: bad value
collect2: error: ld returned 1 exit status

El mensaje nombra la solución (recompile with -fPIC). La regla es sencilla: todo objeto que vaya a una biblioteca dinámica se compila con -fPIC, aunque en algún caso concreto el enlace no se queje. El costo en rendimiento es pequeño en x86-64 y casi nunca es una razón para no usarlo.

3. Usarla y no encontrarla

El enlace del programa es idéntico al de la biblioteca estática: -L para el directorio y -l para el nombre. Si en el directorio hay libbasico.so, el enlazador la prefiere a un libbasico.a (lo vemos en la sección 9):

terminal
$ gcc -std=c17 -Wall -Wextra -Iinc -c prog.c
$ gcc -o prog prog.o -Llibs -lbasico
$ ./prog
./prog: error while loading shared libraries: libbasico.so: cannot open shared object file: No such file or directory
$ echo $?
127

El enlace fue bien, pero la ejecución falla antes de llegar a main. Es el error más típico de este tema, y la primera cosa para entenderlo es la diferencia entre dos momentos: -L le dice al enlazador dónde mirar al construir el programa; no deja ninguna huella para el cargador. Cuando se ejecuta, ld.so empieza de cero a buscar libbasico.so y no sabe nada de libs/.

La herramienta para ver el estado de las dependencias es ldd:

terminal
$ ldd prog
	linux-vdso.so.1 (0x00007ffcef59f000)
	libbasico.so => not found
	libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6 (0x00007faf47a00000)
	/lib64/ld-linux-x86-64.so.2 (0x00007faf47dfd000)
$ readelf -d prog | grep -E 'NEEDED|RPATH|RUNPATH'
 0x0000000000000001 (NEEDED)             Shared library: [libbasico.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

ldd simula la carga y muestra, para cada dependencia, el archivo que encontraría o not found. La salida es la lista del readelf -d resuelta: libbasico.so y libc.so.6 son las entradas NEEDED; linux-vdso.so.1 no es un archivo, sino una pequeña biblioteca que el propio núcleo inyecta en cada proceso (para llamadas al sistema rápidas, como la hora), y el último renglón es el intérprete.

Una advertencia sobre ldd. En algunas implementaciones ldd ejecuta el programa con una variable especial para que el cargador imprima la lista. Con un programa que no es de confianza, eso es ejecutarlo. Para inspeccionar un binario ajeno es más seguro readelf -d (que solo lee el archivo, pero no resuelve nada) o objdump -p.

4. El soname y los nombres con versión

Hasta aquí, el nombre que quedó anotado en el programa es el del archivo (libbasico.so). Eso es frágil: la biblioteca evoluciona, y un programa enlazado contra la versión 1 no puede funcionar con una versión 2 incompatible aunque ambas se llamen igual. Para resolverlo, las bibliotecas dinámicas llevan un nombre interno, el soname (shared object name), que se graba en la propia biblioteca y se copia al ejecutable como su NEEDED.

La convención usa tres nombres para un mismo archivo:

NombreQuién lo usaQué es
libbasico.so.1.0.0Nadie directamenteEl archivo real, con la versión completa (real name).
libbasico.so.1El cargador, al ejecutarEl soname: enlace simbólico al archivo real. Cambia solo cuando cambia la interfaz de forma incompatible.
libbasico.soEl enlazador, al compilarEl «nombre del enlazador» (linker name): enlace simbólico que permite escribir -lbasico.

Se arma con la opción -Wl,-soname,… y dos enlaces simbólicos:

terminal
$ gcc -shared -Wl,-soname,libbasico.so.1 -o libs/libbasico.so.1.0.0 numeros.o texto.o version.o
$ ln -s libbasico.so.1.0.0 libs/libbasico.so.1
$ ln -s libbasico.so.1 libs/libbasico.so
$ ls -l libs | awk 'NR>1{print $9,$10,$11}'
libbasico.so -> libbasico.so.1
libbasico.so.1 -> libbasico.so.1.0.0
libbasico.so.1.0.0
$ readelf -d libs/libbasico.so.1.0.0 | grep SONAME
 0x000000000000000e (SONAME)             Library soname: [libbasico.so.1]
$ gcc -o prog prog.o -Llibs -lbasico
$ readelf -d prog | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libbasico.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

El programa se enlazó con -lbasico (a través del enlace libbasico.so), pero lo que quedó anotado es libbasico.so.1, el soname. Eso es lo que buscará el cargador. De esta forma, una versión nueva y compatible, libbasico.so.1.1.0, se instala cambiando el enlace libbasico.so.1 y todos los programas la aprovechan; una versión incompatible se publica como libbasico.so.2, que convive con la anterior, y los programas viejos siguen usando la 1 hasta que se recompilen.

Los tres nombres de una biblioteca dinámica. En tiempo de compilación, gcc con -lbasico busca libbasico.so, que es un enlace simbólico a libbasico.so.1, que a su vez es un enlace simbólico al archivo real libbasico.so.1.0.0. Ese archivo contiene grabado su soname, libbasico.so.1, y el ejecutable copia ese soname como entrada NEEDED. En tiempo de ejecución, el cargador busca libbasico.so.1 y llega al mismo archivo real libbasico.so gcc ... -lbasico libbasico.so.1 soname libbasico.so.1.0.0 el archivo real NEEDED libbasico.so.1 en el ejecutable Línea continua: lo que busca el enlazador / el cargador. Punteada: el soname grabado se copia al ejecutable. Al compilar: -lbasico → libbasico.so → libbasico.so.1 → archivo real. Al ejecutar: NEEDED → libbasico.so.1 → archivo real.
El enlazador entra por libbasico.so; el cargador, por el soname que el ejecutable guardó.

Los números de la versión siguen una convención de tres partes, mayor.menor.parche: el mayor (el del soname) cambia cuando se rompe la compatibilidad binaria (se quita o cambia una función, o la forma de una estructura pública), el menor cuando se agrega algo sin romper nada, y el de parche con las correcciones internas. Lo importante: lo que el programa exige es solo el mayor, y la compatibilidad binaria (ABI, Application Binary Interface) es una promesa del autor de la biblioteca, que ni gcc ni el cargador verifican.

Una última herramienta: el comando ldconfig -n directorio crea los enlaces del soname a partir de los archivos de ese directorio. No necesita permisos si el directorio es tuyo; borramos libbasico.so.1 y la regeneramos:

terminal
$ rm libs/libbasico.so.1
$ ldconfig -n libs; ls -l libs | awk 'NR>1{print $9,$10,$11}'
libbasico.so -> libbasico.so.1
libbasico.so.1 -> libbasico.so.1.0.0
libbasico.so.1.0.0

Lo que ldconfig -n arregla es el enlace del soname (lee el SONAME de cada archivo); el enlace libbasico.so, que solo importa para compilar, existe porque ya lo habíamos creado y lo sigue ofreciendo el empaquetador (en Debian/Ubuntu va en el paquete -dev, por eso instalar solo una biblioteca no basta para compilar con ella).

5. Cómo busca el cargador

Cuando ld.so necesita libbasico.so.1 busca, en este orden:

  1. Las rutas del RPATH del ejecutable (si lo tiene y no tiene RUNPATH; sección 6).
  2. La variable de entorno LD_LIBRARY_PATH.
  3. Las rutas del RUNPATH del ejecutable.
  4. La caché /etc/ld.so.cache, que arma ldconfig (sección 7).
  5. Los directorios por defecto: /lib y /usr/lib (con los subdirectorios de la arquitectura, como /lib/x86_64-linux-gnu).

Hay una forma de verlo trabajar sin adivinar: la variable LD_DEBUG=libs hace que el cargador cuente lo que intenta. Con el programa sin encontrar su biblioteca:

terminal · la búsqueda fallida (resumida)
$ LD_DEBUG=libs ./prog 2>&1 | head -9 | cut -c1-110
       538:	find library=libbasico.so.1 [0]; searching
       538:	 search cache=/etc/ld.so.cache
       538:	 search path=/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3:/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v2:...
       538:	  trying file=/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3/libbasico.so.1
       538:	    (no such file)
       538:	  trying file=/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v2/libbasico.so.1
       538:	    (no such file)
       538:	  trying file=/lib/x86_64-linux-gnu/libbasico.so.1
       538:	    (no such file)

Primero la caché, luego los directorios del sistema (probando antes los subdirectorios glibc-hwcaps con variantes optimizadas para procesadores más nuevos), y al final el error. El número es el pid del proceso. Esta salida es la mejor respuesta a «¿por qué no encuentra mi biblioteca?»: muestra cada ruta que se probó.

LD_LIBRARY_PATH

La forma rápida de decirle al cargador dónde buscar es la variable LD_LIBRARY_PATH, con directorios separados por :. Se puede poner solo para un comando:

terminal
$ LD_LIBRARY_PATH=libs ./prog
maximo=9 promedio=5.50
ecalne (util 1.0, 2 llamadas)
$ LD_LIBRARY_PATH=libs ldd prog
	linux-vdso.so.1 (0x00007ffe74b1b000)
	libbasico.so.1 => libs/libbasico.so.1 (0x00007b119a6d1000)
	libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6 (0x00007b119a400000)
	/lib64/ld-linux-x86-64.so.2 (0x00007b119a6dd000)

Es muy útil para probar y para desarrollo, pero es una mala herramienta para distribuir un programa: depende de cómo lo lancen (un menú gráfico, un servicio de systemd o cron no heredan tu entorno), afecta a todos los programas que arranquen con esa variable, y el cargador la ignora por completo en los programas con setuid, por seguridad. Tampoco es lo mismo que LIBRARY_PATH del tema 32: aquella es del enlazador (en la compilación), ésta es del cargador (al ejecutar).

Un riesgo de seguridad. Quien controla LD_LIBRARY_PATH controla qué código se carga dentro de un programa. Por eso el cargador la desactiva en ejecutables setuid y nunca debe apuntar a directorios donde otros puedan escribir (/tmp, por ejemplo). Tampoco es buena idea dejarla permanentemente en el ~/.bashrc: es una fuente clásica de «solo falla en mi máquina».

6. Incrustar la ruta: rpath y $ORIGIN

Si la biblioteca no va a instalarse en el sistema sino a viajar junto con el programa, lo más limpio es grabar la ruta de búsqueda dentro del ejecutable. Se hace con -Wl,-rpath,RUTA. Con una ruta absoluta el programa solo sirve en esa ubicación; con $ORIGIN, que el cargador reemplaza por el directorio donde está el ejecutable, la ruta es relativa al programa, y el conjunto puede moverse:

terminal · el literal $ORIGIN lleva comillas simples para que el shell no lo expanda
$ gcc -o prog_rp prog.o -Llibs -lbasico -Wl,-rpath,'$ORIGIN/libs'
$ readelf -d prog_rp | grep -E 'RUNPATH|RPATH'
 0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN/libs]
$ ./prog_rp
maximo=9 promedio=5.50
ecalne (util 1.0, 2 llamadas)
$ mkdir mov && cp prog_rp mov/ && cp -r libs mov/
$ ./mov/prog_rp
maximo=9 promedio=5.50
ecalne (util 1.0, 2 llamadas)

El programa funciona sin variables de entorno, y también desde la copia en mov/, porque busca su biblioteca en libs/ al lado de donde esté él. Es la técnica que usan los programas que se distribuyen como un directorio autocontenido.

Hay dos variantes de la entrada, y la diferencia importa: RPATH es la antigua y se busca antes que LD_LIBRARY_PATH; RUNPATH es la moderna y se busca después. gcc genera RUNPATH por defecto en las distribuciones actuales; con -Wl,--disable-new-dtags se pide el antiguo. Para comprobarlo se arma una versión 2.0 de la biblioteca en el directorio v2/ y se la ofrece con LD_LIBRARY_PATH a dos ejecutables, uno con cada tipo:

terminal · quién gana, la variable o la ruta incrustada
$ gcc -o prog_rp2 prog.o -Llibs -lbasico -Wl,-rpath,'$ORIGIN/libs' -Wl,--disable-new-dtags
$ readelf -d prog_rp2 | grep -E 'RUNPATH|RPATH'
 0x000000000000000f (RPATH)              Library rpath: [$ORIGIN/libs]
$ LD_LIBRARY_PATH=v2 ./prog_rp
maximo=9 promedio=5.50
ecalne (util 2.0, 2 llamadas)
$ LD_LIBRARY_PATH=v2 ./prog_rp2
maximo=9 promedio=5.50
ecalne (util 1.0, 2 llamadas)
$ LD_LIBRARY_PATH=v2 LD_DEBUG=libs ./prog_rp 2>&1 | grep -E 'search path=|calling init: .*basico' | cut -c1-120
       472:	 search path=v2/glibc-hwcaps/x86-64-v3:v2/glibc-hwcaps/x86-64-v2:v2		(LD_LIBRARY_PATH)
       472:	calling init: v2/libbasico.so.1

Con RUNPATH (prog_rp), la variable gana y se carga la versión 2.0; con RPATH (prog_rp2), manda la ruta incrustada y la variable se ignora. La razón por la que se reemplazó RPATH es esa: con RUNPATH el usuario conserva la posibilidad de redirigir una biblioteca con LD_LIBRARY_PATH, por ejemplo para probar una versión nueva o depurar. El mismo LD_DEBUG lo confirma: el cargador encontró libbasico.so.1 en v2/ y ya no siguió buscando.

Una trampa conocida: si un directorio de instalación fijo termina en el rpath de un ejecutable, actualizar la biblioteca en otro lugar no tiene efecto. El rpath de un programa se puede modificar después de compilado con la herramienta patchelf (en los repositorios, no viene en todas las instalaciones) si hace falta.

7. La caché del sistema: ldconfig

Buscar en veinte directorios cada vez que arranca un programa sería lento. Por eso el cargador consulta primero un archivo binario, /etc/ld.so.cache, que contiene una tabla «nombre de biblioteca → ruta». Lo genera el comando ldconfig, que recorre los directorios por defecto más los que se listan en /etc/ld.so.conf y en los archivos de /etc/ld.so.conf.d/ (uno o más directorios por línea). Se puede consultar:

terminal
$ ldconfig -p | head -1
322 libs found in cache `/etc/ld.so.cache'
$ ldconfig -p | grep -E 'libc.so.6|libm.so.6'
	libm.so.6 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libm.so.6
	libc.so.6 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libc.so.6

Para instalar nuestra biblioteca de forma que todos los programas del sistema la encuentren, el procedimiento (como administrador) es: copiar el archivo real al directorio, por convención /usr/local/lib, que ya figura en la configuración; ejecutar sudo ldconfig, que actualiza la caché y crea el enlace del soname; y comprobar con ldconfig -p | grep basico. Si se usa un directorio propio, se agrega un archivo en /etc/ld.so.conf.d/ con la ruta y se vuelve a ejecutar ldconfig. Cuando la instalación se hace con un gestor de paquetes, el ldconfig corre solo al final.

Para probar el mecanismo sin tocar el sistema, ldconfig acepta una caché y una configuración propias, con -C y -f:

terminal · una caché privada que incluye libs/ (sin permisos de administrador)
$ echo "$PWD/libs" > mi.conf
$ ldconfig -C mi.cache -f mi.conf
$ ldconfig -C mi.cache -p | grep basico
	libbasico.so.1 (libc6,x86-64) => /tmp/t33/libs/libbasico.so.1
	libbasico.so (libc6,x86-64) => /tmp/t33/libs/libbasico.so

La caché registra el soname y el nombre del enlazador, y apunta al enlace. El cargador usaría esa tabla si fuera /etc/ld.so.cache. El resultado de la caché se queda desactualizado si se instala una biblioteca nueva sin ejecutar ldconfig: otra causa clásica de «el archivo está ahí, pero no lo encuentra».

8. Actualizar sin reenlazar y LD_PRELOAD

La gran ventaja de lo anterior se ve en la práctica. Con un programa ya compilado, ejecutado con RUNPATH en libs/, se reemplaza el archivo real por la versión 2.0 (compatible, mismo soname) sin tocar el programa:

terminal · cambiar la biblioteca y ejecutar el mismo binario
$ ./prog_rp
maximo=9 promedio=5.50
ecalne (util 1.0, 2 llamadas)
$ cp v2/libbasico.so.1 libs/libbasico.so.1.0.0
$ ./prog_rp
maximo=9 promedio=5.50
ecalne (util 2.0, 2 llamadas)

El mismo ejecutable (prog_rp no se recompiló ni se volvió a enlazar) imprime ahora util 2.0. Es el mecanismo con el que una corrección de seguridad de libc o de openssl llega a todos los programas del sistema con una sola actualización. Y es también el peligro: si la versión «nueva» cambia una firma sin cambiar el soname, los programas viejos fallan o, peor, se comportan mal sin avisar. Es la promesa de ABI de la que hablamos en la sección 4.

El mismo principio permite una técnica de depuración muy usada, la interposición de símbolos. La variable LD_PRELOAD lista bibliotecas que se cargan antes que las demás, y el cargador resuelve cada símbolo con la primera que lo defina. Una biblioteca de una función:

mi_max.c, compilada como biblioteca, y ejecutada con LD_PRELOAD
/* mi_max.c */
#include <stddef.h>

int maximo(const int *v, size_t n)
{
    (void)v; (void)n;
    return -1;
}

$ gcc -std=c17 -Wall -Wextra -fPIC -shared -o mi_max.so mi_max.c
$ LD_PRELOAD=./mi_max.so ./prog_rp
maximo=-1 promedio=5.50
ecalne (util 2.0, 1 llamadas)

El programa no se tocó, y la función maximo que ejecuta es la de mi_max.so; promedio sigue siendo la original. Notar que el contador llamadas vale 1, no 2: la versión sustituta no incrementa la variable. Es lo que hacen las herramientas que registran las llamadas a malloc o simulan fallos para probar cómo reacciona un programa. Con las bibliotecas estáticas del tema 32 el mismo efecto se lograba al enlazar (un objeto propio ganaba a la biblioteca); aquí se consigue al ejecutar, sin recompilar. Como el cargador la ignora en programas setuid, no sirve para escalar privilegios.

Si alguna vez la depuración te muestra una función «imposible» en una biblioteca, comprobar echo $LD_PRELOAD y el archivo /etc/ld.so.preload es un diagnóstico barato.

Cómo se ve en memoria

Mapeada en el proceso, la biblioteca ocupa varias regiones, cada una con sus permisos. El programa mapas.c lee /proc/self/maps y muestra las líneas de libbasico:

terminal · las regiones de libbasico.so en un proceso
$ ./mapas
2
7e3def6c4000-7e3def6c5000 r--p 00000000 00:47 77   .../libs_ok/libbasico.so.1.0.0
7e3def6c5000-7e3def6c6000 r-xp 00001000 00:47 77   .../libs_ok/libbasico.so.1.0.0
7e3def6c6000-7e3def6c7000 r--p 00002000 00:47 77   .../libs_ok/libbasico.so.1.0.0
7e3def6c7000-7e3def6c8000 r--p 00002000 00:47 77   .../libs_ok/libbasico.so.1.0.0
7e3def6c8000-7e3def6c9000 rw-p 00003000 00:47 77   .../libs_ok/libbasico.so.1.0.0

De arriba abajo: la cabecera ELF (solo lectura), el código (r-xp, la región que se comparte entre procesos: lectura y ejecución, sin escritura), los datos de solo lectura, los datos que el cargador completa y deja protegidos, y los datos escribibles (rw-p, donde vive llamadas). La p final significa privado: cada proceso tiene su propia copia de lo que escribe. La dirección de arranque (7e3def6c4000) cambia en cada ejecución, y es la razón de ser de -fPIC.

9. Dinámica o estática cuando hay ambas

Si en el mismo directorio conviven libbasico.so y libbasico.a, -lbasico toma la dinámica. Para forzar la estática hay tres formas (todas las del tema 32), y ldd confirma qué resultó:

terminal · cuántas veces aparece libbasico en las dependencias de cada ejecutable
$ ar rcs libs/libbasico.a numeros.o texto.o version.o
$ gcc -o p_dyn prog.o -Llibs -lbasico
$ LD_LIBRARY_PATH=libs ldd p_dyn | grep -c basico
1
$ gcc -o p_sta prog.o -Llibs -l:libbasico.a
$ ldd p_sta | grep -c basico
0
$ gcc -o p_sta2 prog.o -Llibs -Wl,-Bstatic -lbasico -Wl,-Bdynamic
$ ldd p_sta2 | grep -c basico
0
$ gcc -o p_sta3 prog.o libs/libbasico.a
$ ldd p_sta3 | grep -c basico
0

La del .a sale con 0: el código está dentro del ejecutable y no hay dependencia que buscar. Cuál elegir es el compromiso de la tabla del tema 32: la dinámica comparte memoria, se actualiza sola y obliga a resolver la búsqueda; la estática es autosuficiente pero hay que reenlazar para actualizarla. Una biblioteca que se publica para que otros la usen suele ofrecer las dos.

Una biblioteca dinámica, además, puede tener símbolos sin definir sin que el enlace se queje, porque la resolución final queda para el ejecutable y el cargador. El error aparece más tarde, cuando alguien la usa:

roto.c y terminal
/* roto.c */
int externa(void);
int f(void) { return externa(); }

$ gcc -shared -fPIC -o libs/libroto.so roto.c && echo "libroto.so creada sin error"
libroto.so creada sin error
$ gcc -shared -fPIC -Wl,-z,defs -o libs/libroto.so roto.c
/usr/bin/x86_64-linux-gnu-ld.bfd: /tmp/ccGa8Pfm.o: in function `f':
roto.c:(.text+0x9): undefined reference to `externa'
collect2: error: ld returned 1 exit status

Sin opciones, la biblioteca se crea aunque externa no exista en ninguna parte. Con -Wl,-z,defs el error aparece al construirla, que es donde conviene verlo: hace que los símbolos sin definir de la biblioteca sean un error de enlace. Es una buena opción por defecto para bibliotecas propias. (Aun sin esa opción, quien intente enlazar un programa con libroto.so verá libs/libroto.so: undefined reference to `externa', y el problema sale antes de ejecutar.)

10. Errores frecuentes

ErrorQué pasaCorrección
Compilar los objetos sin -fPICrelocation … can not be used when making a shared object; recompile with -fPIC.Compilar todo lo que va a la .so con -fPIC.
Enlazar con -L y ejecutar sin decirle nada al cargadorerror while loading shared libraries: …: cannot open shared object file, código 127.LD_LIBRARY_PATH para probar; rpath con $ORIGIN o ldconfig para instalar.
Confundir -L y LIBRARY_PATH (enlace) con LD_LIBRARY_PATH (ejecución)El programa enlaza bien y no arranca.Cada una en su momento; ldd muestra qué ve el cargador.
Olvidar el sonameEl ejecutable queda anotado con el nombre del archivo; cualquier cambio de nombre o versión lo rompe.-Wl,-soname,libNOMBRE.so.N y los enlaces simbólicos.
Cambiar la interfaz sin subir el número mayorLos programas viejos cargan la nueva y fallan o se comportan mal sin aviso.Subir el mayor del soname (libNOMBRE.so.2) ante todo cambio incompatible.
Instalar la biblioteca en un directorio y no ejecutar ldconfigEl archivo existe y el cargador no lo encuentra, porque la caché es vieja.sudo ldconfig tras instalar; ldconfig -p | grep nombre para comprobar.
Dejar LD_LIBRARY_PATH fija en el ~/.bashrcCambia qué bibliotecas usan programas ajenos; «en mi máquina funciona».Ponerla solo en el comando que la necesita, o usar rpath.
Poner /tmp o un directorio compartido en LD_LIBRARY_PATH o en el rpathOtro usuario puede dejar una biblioteca falsa que se cargará en tu programa.Solo directorios que controles.
Escribir -Wl,-rpath,$ORIGIN/libs sin comillasEl shell expande $ORIGIN (vacío) y la ruta queda como /libs.Comillas simples: '$ORIGIN/libs'. En un Makefile (tema 35) se duplica el signo: $$ORIGIN.
Esperar que LD_LIBRARY_PATH gane a un rpathCon RPATH (el antiguo) la variable se ignora; con RUNPATH funciona.readelf -d para ver cuál tiene el binario.
Ejecutar ldd sobre un binario que no es de confianzaEn algunas implementaciones, ldd ejecuta el programa.readelf -d u objdump -p.
Confiar en que la biblioteca no tiene símbolos sin definirEl error se descubre cuando alguien enlaza o ejecuta.-Wl,-z,defs al construirla.
Distribuir el .so sin el encabezado o sin el enlace libNOMBRE.soQuien quiera compilar contra ella no puede.Entregar encabezados y el enlace del enlazador (el paquete -dev).

11. Resumen

12. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿En qué se diferencia, al ejecutar, un programa enlazado con una biblioteca estática de uno enlazado con una dinámica?

Con la estática, el código de la biblioteca se copió dentro del ejecutable y no hace falta nada más. Con la dinámica, el ejecutable solo guarda el nombre de la biblioteca (NEEDED); al arrancar, el cargador ld.so la busca, la mapea en memoria y resuelve las referencias. Por eso puede fallar al ejecutar si no la encuentra.

2. ¿Qué hace -fPIC y por qué es necesario para una biblioteca dinámica?

Genera código independiente de la posición: sin direcciones absolutas, con saltos relativos y accesos a datos globales a través de la GOT. Una biblioteca dinámica se carga en una dirección distinta en cada proceso y ejecución, y para compartir una sola copia del código el código no puede depender de dónde esté.

3. ¿Cuál es la diferencia entre -fPIC y -shared?

-fPIC es una opción de compilación: cambia el código que genera cada .c. -shared es una opción de enlace: pide producir una biblioteca dinámica en lugar de un ejecutable. Se usan las dos, una en cada paso.

4. El programa enlazó sin problemas pero al ejecutar da cannot open shared object file. ¿Qué pasó y cómo se resuelve?

El enlazador encontró la biblioteca gracias a -L, pero eso no se graba en el ejecutable; el cargador, al ejecutar, busca en su propia lista (LD_LIBRARY_PATH, RUNPATH, la caché de ldconfig y los directorios del sistema) y no la halla. Se resuelve con LD_LIBRARY_PATH (para probar), con -Wl,-rpath,'$ORIGIN/…' o instalándola y ejecutando ldconfig.

5. ¿Qué hacen ldd y readelf -d? ¿En qué se parecen y en qué se diferencian?

readelf -d lee la sección dinámica del archivo y lista las entradas NEEDED, SONAME, RPATH, RUNPATH: lo que el binario pide. ldd además resuelve cada dependencia como lo haría el cargador y muestra la ruta o not found, incluyendo las dependencias de las dependencias. Con binarios no confiables conviene readelf, porque ldd puede ejecutar el programa.

6. ¿Qué es el soname y por qué se usan tres nombres para una biblioteca?

El soname es el nombre interno grabado en la biblioteca (libbasico.so.1), que se copia al ejecutable como su NEEDED. Los tres nombres son: el archivo real con la versión completa (libbasico.so.1.0.0), el soname (enlace al real) que usa el cargador, y el nombre del enlazador libbasico.so (enlace al soname) que usa -lbasico. Así se pueden instalar versiones compatibles reemplazando el archivo real, y versiones incompatibles como un soname nuevo que convive con el viejo.

7. ¿Cuándo debe cambiar el número mayor del soname?

Cuando se rompe la compatibilidad binaria: se elimina o cambia la firma de una función pública, se altera una estructura que los clientes usan directamente, etc. Agregar funciones o corregir errores sin cambiar la interfaz solo modifica el menor o el parche. Ni el compilador ni el cargador comprueban que se cumpla; es responsabilidad del autor.

8. ¿En qué orden busca el cargador las bibliotecas?

RPATH del ejecutable (si no hay RUNPATH), LD_LIBRARY_PATH, RUNPATH, la caché /etc/ld.so.cache y por último los directorios por defecto /lib y /usr/lib. Con LD_DEBUG=libs se ve el recorrido real.

9. ¿Qué ventaja tiene $ORIGIN en el rpath, y por qué se escribe entre comillas simples?

El cargador lo reemplaza por el directorio del ejecutable, de modo que la ruta es relativa al programa y el conjunto (programa y bibliotecas) se puede mover o instalar en cualquier lugar. Las comillas evitan que el shell interprete $ORIGIN como una variable (vacía) antes de que llegue a gcc.

10. ¿Qué diferencia hay entre RPATH y RUNPATH?

Los dos son rutas incrustadas en el ejecutable. RPATH (antiguo) se consulta antes que LD_LIBRARY_PATH, que queda sin efecto sobre esas bibliotecas; RUNPATH (actual, el que genera gcc por defecto) se consulta después, de modo que el usuario puede redirigir con la variable. Con -Wl,--disable-new-dtags se pide RPATH.

11. ¿Para qué sirve ldconfig y qué pasa si se instala una biblioteca sin ejecutarlo?

Recorre los directorios por defecto y los configurados en /etc/ld.so.conf y /etc/ld.so.conf.d/, crea los enlaces del soname y regenera la caché /etc/ld.so.cache, que es lo primero que consulta el cargador. Sin ejecutarlo, la biblioteca nueva puede no estar en la caché y el cargador no la encuentra aunque el archivo exista.

12. Se corrige un error en libbasico.so.1.0.0 y se reemplaza el archivo. ¿Hay que recompilar los programas? ¿Y si el cambio altera la firma de maximo?

Si el cambio es compatible, no: los programas cargan el archivo nuevo en su próxima ejecución, porque su NEEDED es el soname y éste apunta al archivo nuevo. Si la firma cambia, la ABI se rompe: hay que publicar la biblioteca con otro soname (.so.2) y recompilar los programas que quieran usarla; si no, los viejos fallarán o se comportarán mal.

13. ¿Qué hace LD_PRELOAD y para qué se usa?

Hace que el cargador cargue primero las bibliotecas indicadas, de modo que sus símbolos tienen prioridad sobre los de las demás. Permite reemplazar una función (por ejemplo maximo o malloc) en un programa ya compilado, y se usa para depurar, registrar llamadas o simular fallos. Se ignora en programas setuid.

14. ¿Para qué sirve -Wl,-z,defs al construir una biblioteca?

Por defecto una .so puede tener símbolos sin definir y el error recién aparece al enlazar o ejecutar un programa que la use. Con -z defs el enlazador lo rechaza al crear la biblioteca, que es donde se detecta antes.

Para practicar

  1. Convierte tu libgeo.a del tema 32 en libgeo.so con soname libgeo.so.1 y los tres nombres. Enlaza un programa y comprueba con readelf -d qué NEEDED quedó.
  2. Intenta ejecutarlo sin ninguna ayuda para el cargador y anota el mensaje. Resuélvelo de tres maneras: con LD_LIBRARY_PATH, con -Wl,-rpath,'$ORIGIN/…' y con una caché privada de ldconfig -C.
  3. Con LD_DEBUG=libs en cada uno de los casos anteriores, anota qué rutas prueba el cargador y en qué orden. ¿Cuál se prueba primero con RPATH? ¿Y con RUNPATH?
  4. Mueve el directorio del programa y su biblioteca a otro lugar. ¿Sigue funcionando con $ORIGIN? ¿Y si usaste una ruta absoluta en el rpath?
  5. Publica una versión «1.1» de libgeo.so.1 con una función más, sustituye el archivo real y comprueba que el programa viejo sigue funcionando sin recompilar. Después cambia la firma de una función sin subir el soname, ejecuta el programa viejo y describe lo que pasa.
  6. Escribe un mi_malloc.so con una función malloc que cuente las llamadas (llamando a la original, que obtienes con dlsym(RTLD_NEXT, …), tema 34) y úsalo con LD_PRELOAD sobre un programa tuyo.
  7. Crea una biblioteca con una dependencia sin definir y comprueba que se crea sin error; después añade -Wl,-z,defs y compara.
  8. Corre ldd sobre /bin/bash y /bin/ls (o sobre un binario que tengas) y busca con readelf -d el SONAME de cada biblioteca de la lista. ¿Cuáles son los sonames de libc y de libm?