Carga en tiempo de ejecución
La interfaz dlopen y dlsym como forma de convertir una dependencia de enlace en una decisión del programa, el diseño de un contrato de complementos que se pueda verificar antes de ejecutar nada, y el inventario de riesgos que aparecen cuando la resolución de símbolos se aplaza hasta después del arranque.
Hasta ahora el juego consistía en decidir de antemano: el enlazador estático fija qué código entra y el dinámico resuelve al arrancar, pero en ambos casos la lista de dependencias estaba escrita antes de que el programa empezara. La carga explícita rompe esa premisa. Con dlopen tu programa decide en ejecución qué código incorporar, a partir de un fichero de configuración, del formato de un dato que acaba de leer o de lo que encuentre en un directorio. Eso habilita los sistemas de complementos, y a la vez desplaza al tiempo de ejecución una familia de errores que el compilador y el enlazador te detectaban gratis.
- Usar
dlopen,dlsym,dlerrorydlclosecorrectamente, incluido el rodeo obligatorio para punteros a función. - Elegir entre resolución inmediata y perezosa, y entre ámbito local y global, con criterio.
- Diseñar un contrato de complementos con un único símbolo de entrada y verificación de versión.
- Enumerar los riesgos de la resolución tardía y las medidas que los contienen.
La interfaz de carga explícita
Cuatro funciones declaradas en dlfcn.h cubren todo el mecanismo. dlopen mapea un objeto compartido y devuelve un manejador opaco; dlsym busca un símbolo dentro de él y devuelve su dirección; dlerror informa del último fallo; dlclose suelta la referencia. En glibc reciente ya no hace falta enlazar con -ldl, pues su contenido se absorbió en la biblioteca estándar, pero escribirlo sigue siendo lo portable.
#include <dlfcn.h>
void *h = dlopen("./libcodec.so", RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
dlerror(); /* limpia el estado previo */
void *dir = dlsym(h, "codec_decodificar");
const char *err = dlerror(); /* la unica comprobacion fiable */
if (err) { fprintf(stderr, "%s\n", err); dlclose(h); return 1; }
Ese baile alrededor de dlerror no es superstición. Un símbolo puede estar definido y valer legítimamente nulo, así que comprobar el valor devuelto por dlsym no distingue entre encontrado y no encontrado. La forma correcta es limpiar el estado de error antes, llamar y consultar después.
Hay una segunda incomodidad, esta vez con el estándar. dlsym devuelve void *, y convertir un puntero a objeto en un puntero a función no está garantizado por ISO C: son categorías distintas y nada obliga a que compartan representación. POSIX exige desde 2008 que la conversión funcione, pero el compilador no lo sabe y -Wpedantic protesta. El rodeo habitual copia los bits en lugar de convertirlos.
typedef int (*fn_decodificar)(const uint8_t *ent, size_t n, uint8_t *sal);
fn_decodificar decodificar;
memcpy(&decodificar, &dir, sizeof dir); /* portable y sin aviso */
Las banderas de dlopen son la parte que de verdad exige criterio. RTLD_NOW resuelve todos los símbolos del objeto en el acto y falla ahí mismo si falta alguno; RTLD_LAZY los deja para la primera llamada. RTLD_LOCAL mantiene los símbolos del objeto fuera del ámbito global, de modo que no interfieran con nadie; RTLD_GLOBAL los publica para que otros objetos cargados después puedan resolverse contra ellos.
Con RTLD_LAZY, un complemento al que le falta un símbolo se carga sin protestar y aborta el proceso más tarde, en la primera llamada que atraviese esa entrada, quizá horas después y con trabajo a medias. El enlace perezoso no puede fallar con elegancia porque ocurre dentro de una llamada que no espera errores. Para código cargado explícitamente, RTLD_NOW combinado con RTLD_LOCAL es el punto de partida sensato: convierte un aborto diferido e imposible de manejar en un fallo temprano que tu programa puede tratar como cualquier otro.
Un contrato que se pueda verificar
El error de diseño más común consiste en llamar a dlsym una vez por cada función que el complemento debe ofrecer. Multiplica los puntos de fallo, dispersa la comprobación y no dice nada sobre si el complemento y el anfitrión coinciden en el significado de las estructuras que se van a intercambiar. La alternativa madura expone un solo símbolo que devuelve un descriptor con toda la interfaz y un número de versión que se comprueba antes de invocar nada.
/* codec_api.h: la cabecera que comparten anfitrion y complemento */
#define CODEC_ABI 3
typedef struct {
uint32_t abi; /* debe coincidir con CODEC_ABI */
const char *nombre;
int (*abrir)(void **estado, const char *opciones);
int (*decodificar)(void *estado, const uint8_t *ent, size_t n, uint8_t *sal);
void (*cerrar)(void *estado);
} CodecApi;
typedef const CodecApi *(*fn_entrada)(void); /* el unico simbolo buscado */
/* en el complemento */
static const CodecApi API = {
.abi = CODEC_ABI, .nombre = "jpeg",
.abrir = jpeg_abrir, .decodificar = jpeg_decodificar, .cerrar = jpeg_cerrar,
};
__attribute__((visibility("default")))
const CodecApi *codec_entrada(void) { return &API; }
flowchart TB
A[El anfitrion recorre el directorio de complementos] --> B[dlopen con RTLD_NOW y RTLD_LOCAL]
B --> C{Se cargo correctamente}
C -->|No| Z[Registra el motivo de dlerror y descarta]
C -->|Si| D[dlsym del simbolo de entrada]
D --> E{Existe el simbolo}
E -->|No| Z
E -->|Si| F{El campo abi coincide}
F -->|No| Y[dlclose y aviso de version incompatible]
F -->|Si| G[Guarda el descriptor y el manejador juntos]
G --> H[El complemento queda disponible]El campo abi merece atención porque hace explícito lo que en el enlace normal quedaba implícito. Anfitrión y complemento se compilaron por separado, quizá con meses de diferencia, y comparten estructuras cuya disposición fijó cada compilación. Todo lo que aprendiste sobre romper la interfaz binaria vuelve aquí sin ninguno de los amortiguadores del caso anterior: no hay soname que impida cargar el fichero equivocado ni cargador que compare nada. La comprobación explícita del número, y la regla de incrementarlo ante cualquier cambio del descriptor o de los tipos que atraviesan la frontera, es la única defensa que existe.
Conviene además compilar el anfitrión con -Wl,--export-dynamic si los complementos van a llamar a funciones suyas: sin esa opción, los símbolos del ejecutable no entran en la tabla dinámica y el complemento no los encuentra por mucho que estén ahí. La alternativa más limpia, y la que escalan mejor los proyectos grandes, es invertir la dirección: en lugar de que el complemento busque servicios del anfitrión por nombre, el anfitrión le entrega en la llamada de apertura un segundo descriptor con los servicios disponibles. Así ninguna de las dos partes depende de la tabla dinámica de la otra y el contrato entero cabe en una cabecera.
El descriptor tiene además una virtud que se aprecia al mantenerlo: añadir funciones al final es compatible si el anfitrión comprueba el número de interfaz antes de tocar los campos nuevos, exactamente igual que ocurría con las estructuras públicas de una biblioteca compartida. Algunos diseños llevan la idea un paso más allá y colocan un campo de tamaño al principio del descriptor, de modo que cada lado puede saber cuántos campos entiende el otro y degradar con elegancia en lugar de rechazar.
if (api->abi != CODEC_ABI) { /* antes de invocar nada */
fprintf(stderr, "complemento %s: abi %u, esperada %u\n",
ruta, api->abi, (unsigned)CODEC_ABI);
dlclose(h);
continue;
}
Los riesgos de resolver tarde
El más grave es la descarga. dlclose decrementa una cuenta de referencias y, si llega a cero, puede desmapear las páginas del objeto. En ese instante quedan colgando muchas más cosas de las que uno recuerda: todo puntero a función que apuntara a su código, todo puntero a sus datos estáticos, toda cadena literal que devolviera y toda estructura suya que alguien guardara. Llamar a cualquiera de ellos ya no es un fallo lógico sino un salto a memoria no mapeada.
La cuenta, además, es difícil de razonar: un objeto puede seguir cargado porque otro depende de él, o porque se abrió dos veces, o porque tiene datos locales al hilo que impiden su descarga. En glibc, dlclose a veces sencillamente no desmapea, lo que produce el peor escenario de todos: un fallo que no se manifiesta en tu máquina y sí en la del usuario. La política defensiva consiste en descargar solo durante un apagado ordenado, después de invocar la función de cierre de cada complemento, o en no descargar nunca —cargando incluso con RTLD_NODELETE— y aceptar que la memoria de un complemento vive lo que el proceso.
Nadie comprueba los tipos
dlsym devuelve una dirección desnuda. Si el tipo que declaras no coincide con el real, la llamada corrompe la pila sin un solo aviso.
Colisiones de símbolos
Con RTLD_GLOBAL, dos complementos que definan el mismo nombre se interponen entre sí. Usa RTLD_LOCAL salvo que necesites lo contrario.
Cargar es ejecutar
Un directorio de complementos escribible es ejecución arbitraria de código. Rutas absolutas, permisos restringidos y nunca componer la ruta con datos de entrada.
Inicialización y hilos
Los constructores del objeto corren dentro de dlopen, bajo un cerrojo global del cargador. Abrir complementos desde varios hilos serializa y puede interbloquear.
La tercera tarjeta merece desarrollarse porque suele tratarse como un detalle de despliegue cuando es un límite de confianza. dlopen no valida, no verifica firmas y no pregunta: mapea el fichero y ejecuta sus constructores antes de que tu código vea nada, de modo que un objeto malicioso no necesita siquiera exportar el símbolo de entrada para haber ganado ya. De ahí se deducen las reglas: la ruta se construye siempre a partir de un directorio fijado en tiempo de compilación o de configuración privilegiada, nunca concatenando datos que vengan del usuario; el directorio y todo su camino de ancestros pertenecen a una cuenta administrativa y no son escribibles por el proceso; y si el programa es de los que elevan privilegios, el cargador ya ignora por su cuenta LD_LIBRARY_PATH y las rutas relativas, pero tu llamada explícita no queda cubierta por esa protección.
La cuarta tiene una consecuencia práctica menos obvia. Como los constructores del objeto se ejecutan dentro de dlopen, con el cerrojo del cargador tomado, un constructor que a su vez llame a dlopen, cree hilos o espere a otro hilo que esté cargando algo puede quedarse bloqueado de una forma difícil de diagnosticar porque la pila no menciona ningún cerrojo tuyo. La política sana es que los constructores de un complemento no hagan nada: registrar el descriptor, inicializar constantes y devolver. Todo trabajo real pertenece a la función de apertura, que se invoca ya fuera del cargador y puede fallar de forma explícita.
Queda una última pregunta de diseño que conviene plantearse antes que ninguna: si de verdad necesitas la carga explícita. Cuando el conjunto de módulos se conoce al construir, una tabla estática de descriptores da el mismo desacoplamiento en el código, con verificación de tipos, sin dlerror, sin problemas de descarga y sin superficie de ataque. La carga en ejecución solo compra algo cuando el conjunto no se conoce: complementos de terceros, extensiones instalables por el usuario, formatos que se añaden después de vender el producto. Si tu respuesta a por qué usas dlopen es que queda más elegante, la elegancia sale muy cara.
Hay una sola magnitud que ordena todo este nivel, y es el momento en que un nombre se convierte en una dirección. En el archivo estático ocurre al construir: el enlazador comprueba que todo cuadra y, si algo falta, el programa no llega a existir. En la biblioteca compartida ocurre al arrancar, en la máquina del usuario, y el precio de esa demora es que un fallo de dependencia ya no es un error de compilación sino un mensaje al ejecutar. Con dlopen ocurre en mitad de la ejecución, cuando el usuario está esperando, y el precio vuelve a subir: ahora ni siquiera hay un compilador que verifique que el tipo con el que declaras la función coincide con el que la definió. Cada paso en esa escala compra flexibilidad —extender el programa sin recompilarlo, cargar solo lo que hace falta, admitir código que no existía cuando compilaste— y paga con garantías que se pierden en el mismo orden en que se ganan libertades. La conclusión práctica no es evitar la carga dinámica, que sostiene desde los codecs de un reproductor hasta los módulos de un servidor web, sino reconstruir a mano, en el punto donde resuelves, las comprobaciones que el enlazador ya no puede hacer por ti: un número de interfaz binaria que se verifica antes de la primera llamada, un descriptor único en lugar de veinte búsquedas sueltas, RTLD_NOW para que el fallo llegue mientras todavía puedes manejarlo, y una política de descarga tan conservadora que nunca deje un puntero apuntando a páginas que ya no existen. Programar sistemas es, en buena medida, saber a qué hora se paga cada cosa y llegar preparado a esa hora.
- Escribe un anfitrión que recorra un directorio, cargue cada
.soconRTLD_NOW | RTLD_LOCAL, resuelva el símbolo de entrada y rechace los descriptores cuyo campoabino coincida. - Implementa dos complementos y comprueba que el anfitrión los distingue; después publica uno con el número de interfaz cambiado y verifica que se rechaza sin cargarse a medias.
- Provoca deliberadamente el fallo de
RTLD_LAZY: un complemento con un símbolo sin definir que carga bien y aborta en la primera llamada. Compara con el diagnóstico que daRTLD_NOW. - Guarda un puntero a función de un complemento, llama a
dlclosey observa bajo un depurador qué ocurre al invocarlo; repite conRTLD_NODELETEy explica la diferencia. - Compila el anfitrión con y sin
-Wl,--export-dynamic, haz que el complemento llame a una función suya y contrastanm -Dsobre ambos ejecutables.