Bibliotecas dinámicas
Por qué una biblioteca compartida exige código independiente de posición, qué máquinas —tabla de desplazamientos globales y tabla de enlace de procedimientos— hacen posible esa independencia, y qué ocurre exactamente entre el momento en que el núcleo carga tu ejecutable y el momento en que main recibe el control.
Compartir una biblioteca entre veinte procesos suena a problema de disco y resulta ser un problema de direcciones. Si el código de esa biblioteca contuviera direcciones absolutas, cada proceso necesitaría su propia copia parcheada y no habría nada que compartir; para que una única copia física sirva a todos, el código tiene que funcionar sin saber dónde está. Esa exigencia es la que hay detrás de -fPIC, y de ella se derivan las dos tablas que todo binario dinámico lleva dentro, el coste de rendimiento que se paga por acceder a un símbolo global y la coreografía completa que el enlazador dinámico ejecuta antes de que tu main empiece.
- Explicar por qué el código compartido debe ser independiente de posición y qué genera
-fPICque no genera una compilación normal. - Describir el papel de la tabla de desplazamientos globales y de la tabla de enlace de procedimientos.
- Construir un
.so, enlazar contra él y controlar dónde lo busca el sistema en tiempo de ejecución. - Razonar sobre la interposición de símbolos y controlarla con visibilidad y guiones de versión.
El problema que resuelve -fPIC
Un ejecutable clásico se cargaba siempre en la misma dirección virtual y podía permitirse instrucciones que nombran direcciones absolutas. Una biblioteca compartida no tiene ese lujo: la misma .so puede aparecer en direcciones distintas en cada proceso, porque la disposición del espacio de direcciones se aleatoriza y porque nada garantiza que dos procesos carguen el mismo conjunto de bibliotecas.
Ante eso caben dos estrategias. La primera es cargar el código y parchear cada dirección en el momento, técnica que funciona pero destruye la razón de ser del asunto: al escribir sobre las páginas de código, estas dejan de ser compartibles y cada proceso acaba pagando su copia privada. La segunda es generar desde el principio código que no contenga ninguna dirección absoluta. Eso es el código independiente de posición, y -fPIC es la opción que lo pide.
El mecanismo tiene dos piezas. Los saltos y las referencias internas se expresan como desplazamientos relativos al contador de programa, de modo que valen esté donde esté el módulo. Y todo lo que no se puede resolver hasta el enlace final —direcciones de funciones y variables globales que podrían vivir en otro módulo— se convierte en una lectura indirecta a través de una tabla de datos: la tabla de desplazamientos globales, o GOT. El código pregunta a la tabla, la tabla vive en una página de datos escribible y es a ella, y no al código, a quien el enlazador dinámico parchea al arrancar. El código permanece intacto, limpio y compartible entre todos los procesos.
Para las llamadas a función existe una segunda tabla, la tabla de enlace de procedimientos o PLT, que añade un nivel de indirección con un propósito distinto: permitir que la resolución ocurra en la primera llamada real y no antes. La primera vez que se invoca una función externa, el fragmento de PLT salta a una rutina del enlazador dinámico que averigua la dirección, la escribe en la entrada correspondiente de la GOT y salta allí; las llamadas siguientes ya encuentran la dirección escrita. Es el enlace perezoso, y evita resolver miles de símbolos que quizá nunca se usen.
El precio de todo esto es real pero modesto: una indirección extra por acceso a global externa, un registro dedicado a apuntar a la tabla en algunas arquitecturas y menos margen para el optimizador. En una máquina de 64 bits con direccionamiento relativo al contador de programa el sobrecoste típico está en el rango de unos pocos puntos porcentuales.
Conviene distinguir -fPIC de -fpie, que a menudo se confunden. El segundo produce código independiente de posición para un ejecutable, no para una biblioteca, y puede permitirse suposiciones más fuertes —nadie va a interponerse en los símbolos del ejecutable principal— con lo que genera código algo mejor. Las distribuciones actuales compilan todo con -fpie -pie por defecto para que la aleatorización del espacio de direcciones alcance también al binario principal, no solo a las bibliotecas.
Si intentas enlazar con -shared un objeto compilado sin -fPIC, el enlazador rechaza el trabajo con un mensaje sobre una reubicación que no puede usarse al construir un objeto compartido. Lo que está diciendo, en su idioma, es que ese objeto contiene una dirección absoluta que exigiría parchear la página de código, y que parchearla destruiría la posibilidad de compartirla. El error no es un tecnicismo del formato: es la consecuencia directa del razonamiento anterior, y la solución nunca es silenciarlo sino recompilar la unidad.
Construir la biblioteca y enlazar contra ella
gcc -std=c23 -fPIC -c cadena.c vector.c
gcc -shared cadena.o vector.o -o libutil.so
gcc main.c -L. -lutil -o prog # solo se anota la dependencia
LD_LIBRARY_PATH=. ./prog # y el cargador debe encontrarla
Cada opción hace algo distinto y conviene no confundirlas. -fPIC afecta a la generación de código y va en la compilación; -shared afecta al enlazado y produce un objeto compartido en lugar de un ejecutable. Enlazar main.c contra la biblioteca no copia nada: escribe en el ejecutable una entrada de dependencia con el nombre de la biblioteca, y ahí termina el trabajo.
Depender de LD_LIBRARY_PATH está bien para probar y mal para desplegar, porque es una variable de entorno que cualquiera puede alterar y que afecta a todo el proceso. Lo correcto es grabar la ruta de búsqueda dentro del propio binario, preferiblemente relativa a él.
gcc main.c -L. -lutil -Wl,-rpath,'$ORIGIN/../lib' -o prog
readelf -d prog | head # NEEDED, RUNPATH y soname
ldd prog # que resuelve cada dependencia hoy
El token $ORIGIN lo expande el cargador al directorio del ejecutable, lo que permite mover un árbol de instalación completo sin reconstruir nada. Los binutils actuales escriben una entrada de tipo RUNPATH, que se consulta después de LD_LIBRARY_PATH, en lugar del antiguo RPATH, que se consultaba antes y por eso resultaba imposible de sobrescribir para depurar.
Qué ocurre entre el núcleo y main
Cuando el núcleo ejecuta un binario dinámico no le entrega el control a tu programa: lee el intérprete anotado en la cabecera del fichero, carga ese intérprete —el enlazador dinámico, típicamente ld.so— y lo arranca a él. A partir de ahí todo es código de usuario.
flowchart TB
A[El nucleo mapea el ejecutable y el interprete] --> B[ld.so lee las entradas NEEDED]
B --> C[Busca cada biblioteca y la mapea recursivamente]
C --> D[Construye el ambito global de busqueda]
D --> E[Aplica las reubicaciones de datos sobre la GOT]
E --> F{Enlace perezoso activo}
F -->|Si| G[Deja las funciones para la primera llamada]
F -->|No| H[Resuelve todas las funciones ahora]
G --> I[Ejecuta los inicializadores de cada objeto]
H --> I
I --> J[Salta a main]El orden de búsqueda de cada biblioteca está fijado y merece memorizarse: primero la ruta grabada como RPATH si existe y es del tipo antiguo, después LD_LIBRARY_PATH, después el RUNPATH del objeto que la reclama, después la caché de ldconfig y por último los directorios estándar del sistema. Cuando un binario carga la biblioteca equivocada, la respuesta está siempre en esa lista, y LD_DEBUG=libs ./prog la recorre en voz alta.
La construcción del ámbito global es el paso más sutil. El cargador forma una lista ordenada con el ejecutable primero y las bibliotecas después, en orden de anchura, y resuelve cada símbolo tomando la primera definición que encuentra al recorrerla. No hay noción de propietario: si dos objetos definen el mismo nombre, gana quien esté antes en la lista.
Sobre el enlace perezoso pesa hoy una consideración de seguridad que no existía cuando se diseñó. Para que funcione, la tabla de desplazamientos globales debe permanecer escribible durante toda la vida del proceso, y una escritura controlada por un atacante sobre esa tabla equivale a desviar cualquier llamada. La contramedida se llama RELRO completo: resolver todo al arrancar y marcar la tabla como solo lectura antes de ceder el control a main.
gcc main.c -L. -lutil -Wl,-z,relro -Wl,-z,now -o prog
LD_BIND_NOW=1 ./prog # el equivalente por entorno, sin recompilar
checksec --file=prog # informa de RELRO parcial o completo
El coste es un arranque algo más lento, proporcional al número de símbolos, y por eso importa tanto reducir la superficie exportada. Las distribuciones llevan años activándolo por defecto: en un binario normal, el gasto es de milisegundos.
Otro detalle que ahorra depuraciones es el papel de ldconfig. El cargador no recorre los directorios estándar en cada arranque; consulta una caché construida a partir de los soname encontrados en las rutas configuradas. Instalar una biblioteca copiándola a /usr/local/lib y olvidar ldconfig produce el clásico fallo en que el fichero está exactamente donde debe y el programa insiste en no encontrarlo.
Interposición y visibilidad
Esa regla de la primera definición es lo que se llama interposición, y es a la vez una funcionalidad valiosa y una fuente de sorpresas. Valiosa porque permite sustituir cualquier función sin tocar el programa, que es como funcionan los depuradores de memoria y los perfiladores.
LD_PRELOAD=./mi_malloc.so ./prog # se antepone al resto del ambito global
Sorprendente porque también actúa dentro de tu propia biblioteca: una llamada interna a una función global tuya pasa por la PLT y puede acabar en la definición de otro módulo que resultó estar antes en la lista. Además, toda función que no marques queda exportada, lo que aumenta el trabajo del cargador, alarga la tabla de símbolos dinámicos y convierte en pública una superficie que nunca quisiste publicar.
La corrección es doble. Por un lado, ocultar por omisión y exportar de forma explícita.
#define API __attribute__((visibility("default")))
API int util_hash(const char *s); /* pertenece a la interfaz */
static int mezclar(unsigned x); /* interno de la unidad */
gcc -std=c23 -fPIC -fvisibility=hidden -c *.c
gcc -shared *.o -Wl,--version-script=util.map -o libutil.so
Por otro lado, -Wl,-Bsymbolic-functions obliga a que las llamadas internas de la biblioteca se resuelvan dentro de ella, eliminando la indirección y cerrando la puerta a la interposición accidental. Reducir la superficie exportada mejora tres cosas a la vez: el tiempo de arranque, la robustez frente a colisiones y tu libertad para cambiar lo que nunca prometiste.
-fPIC frente a -fpie
El primero es obligatorio en bibliotecas compartidas; el segundo produce ejecutables reubicables con código algo mejor, porque nadie puede interponerse en ellos.
RUNPATH y $ORIGIN
Graban la ruta de búsqueda dentro del binario y permiten mover un árbol de instalación entero sin reconstruir. Sustituyen a LD_LIBRARY_PATH en producción.
relro y now
Resuelven todo al arrancar y dejan la tabla de indirecciones en solo lectura. Endurecen el binario a cambio de unos milisegundos de arranque.
-fvisibility=hidden
Convierte la exportación en una decisión explícita en lugar de la opción por omisión. Es el punto de partida de cualquier biblioteca seria.
Queda una advertencia final sobre el otro extremo de la escala. Una biblioteca compartida puede incorporar dentro un archivo estático, y es una práctica frecuente para empaquetar dependencias internas, pero solo funciona si ese archivo se compiló con -fPIC y solo es sensato si sus símbolos quedan ocultos. En caso contrario acabas exportando la interfaz completa de una dependencia que creías privada, y el día que otra biblioteca del proceso incorpore una versión distinta de la misma dependencia, la regla de la primera definición decidirá cuál gana. Ese fallo, que se manifiesta como corrupción inexplicable y no como error de carga, es una de las razones por las que la visibilidad oculta dejó de ser opcional.
Estamos acostumbrados a pensar que el trabajo de traducción termina cuando el enlazador estático escribe el ejecutable, pero en un sistema dinámico eso es falso: queda una fase más, y esa fase se ejecuta en la máquina del usuario, cada vez, con las bibliotecas que allí haya. El enlazador dinámico decide qué definición gana para cada nombre, escribe direcciones en tablas que el compilador dejó deliberadamente vacías y ejecuta código de inicialización en un orden que depende del grafo de dependencias de esa instalación concreta. Es, en sentido estricto, la última pasada de compilación de tu programa, y es la única que no puedes reproducir en tu escritorio. Ahí está la lección profunda y también la incomodidad. Ganas algo enorme: una corrección de seguridad en una biblioteca arregla de golpe todos los programas que la usan, sin recompilar ninguno, y esa propiedad es la que sostiene el modelo de mantenimiento de las distribuciones desde hace treinta años. Pero pagas con una superficie de acoplamiento que ya no controlas, porque la semántica de tu binario depende de un enlace que ocurrirá en el futuro y en otra parte. Las técnicas de esta lección —visibilidad oculta, guiones de versión, enlace simbólico interno, rutas relativas al origen— no son microoptimizaciones dispersas: son la manera de estrechar deliberadamente ese contrato futuro, de decir con precisión qué nombres estás dispuesto a negociar con el mundo y cuáles pertenecen solo a tu implementación. Un ingeniero de sistemas maduro no elige entre estático y dinámico por costumbre, sino porque sabe exactamente cuánta de esa última compilación quiere delegar y en manos de quién la deja.
- Compila la misma unidad con y sin
-fPIC, desensambla conobjdump -dy localiza en la versión independiente el acceso indirecto a través de la tabla. - Intenta enlazar con
-sharedun objeto compilado sin-fPICen x86-64 y transcribe el error de reubicación que produce el enlazador. - Ejecuta
LD_DEBUG=libs,bindings ./progy sigue el rastro de una función concreta desde la búsqueda de la biblioteca hasta la dirección elegida. - Escribe una
.soque redefina una función de la biblioteca estándar, cárgala conLD_PRELOADy comprueba la interposición sin tocar el programa. - Compila la biblioteca con
-fvisibility=hiddeny comparanm -D --defined-onlyantes y después; mide conLD_DEBUG=statisticsel efecto sobre el tiempo de reubicación.