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

Versionado y soname

Los tres nombres de una biblioteca compartida y por qué son tres, el catálogo preciso de cambios que rompen la compatibilidad binaria frente a los que no, y el versionado de símbolos que permite a una misma biblioteca servir a la vez a binarios compilados con quince años de diferencia.

⏱ 20 min

Publicar una biblioteca compartida es firmar un contrato con programas que aún no existen y con otros que ya no puedes recompilar. El instrumento que hace ese contrato explícito es el soname: un nombre que no identifica un fichero sino una promesa de compatibilidad, grabado dentro del binario y copiado a todo el que enlace contra él. Entender el sistema de tres nombres, saber exactamente qué cambios rompen la compatibilidad binaria y conocer el mecanismo que permite convivir a versiones incompatibles del mismo símbolo separa a quien distribuye software de quien solo lo compila.

🎯 Al terminar esta lección sabrás
  • Distinguir el nombre de enlace, el soname y el nombre real, y explicar qué papel cumple cada uno.
  • Clasificar un cambio como compatible, compatible solo hacia adelante o rotura de ABI.
  • Grabar y verificar el soname de una biblioteca y publicarla con la cadena correcta de enlaces simbólicos.
  • Aplicar versionado de símbolos con un guion de versión para evolucionar sin romper a nadie.

Los tres nombres

Una biblioteca compartida instalada correctamente aparece en el sistema bajo tres nombres, y cada uno existe porque le habla a un interlocutor distinto.

El nombre de enlace es libutil.so, sin sufijo. Solo lo usa el enlazador estático cuando escribes -lutil en tiempo de compilación, y por eso las distribuciones lo empaquetan aparte, en el paquete de desarrollo: quien únicamente ejecuta programas no lo necesita.

El soname es libutil.so.1. No es un fichero cualquiera: es una cadena grabada dentro del binario mediante -Wl,-soname, y el enlazador la copia literalmente en la dependencia de todo ejecutable que enlace contra la biblioteca. Cuando el programa arranque, el cargador buscará ese nombre exacto, no libutil.so.

El nombre real es libutil.so.1.4.2, el fichero que contiene el código de verdad. Su tercer componente sirve a los humanos y a los gestores de paquetes; el cargador jamás lo menciona.

gcc -shared -Wl,-soname,libutil.so.1 *.o -o libutil.so.1.4.2

ln -sf libutil.so.1.4.2 libutil.so.1     # lo que busca el cargador
ln -sf libutil.so.1     libutil.so       # lo que usa -lutil

readelf -d libutil.so.1.4.2 | grep SONAME
readelf -d prog | grep NEEDED            # dice libutil.so.1, no libutil.so
flowchart LR
A[prog con NEEDED libutil.so.1] --> B[libutil.so.1 enlace simbolico]
B --> C[libutil.so.1.4.2 fichero real]
D[gcc -lutil en compilacion] --> E[libutil.so enlace simbolico]
E --> B

La elegancia del esquema está en la indirección. Publicar la versión 1.4.3 significa instalar el fichero nuevo y reapuntar el enlace libutil.so.1; todos los programas que dependen de ese soname recogen la mejora en su siguiente arranque sin recompilar nada. Publicar una versión incompatible significa grabar el soname libutil.so.2, con lo que el fichero nuevo puede instalarse junto al viejo sin desplazarlo: los binarios antiguos siguen encontrando su libutil.so.1 y los nuevos encuentran el suyo. El número mayor no es decorativo ni de mercadotecnia; es la declaración formal de que la promesa anterior ha caducado.

Conviene no confundir este número con el del versionado semántico que anuncias a tus usuarios. Son independientes: un proyecto puede ir por la versión 3.7 de cara al público y seguir instalando libutil.so.1 porque nunca rompió la interfaz binaria. La convención de libtool, con su terna de actual, revisión y edad, no es más que una forma —bastante confusa— de calcular ese mismo número mayor.

Olvidar el soname tiene una consecuencia inmediata y muy instructiva. Si construyes con -shared pero sin -Wl,-soname, el enlazador anota en el cliente la ruta con la que lo enlazaste, tal cual, y el binario resultante busca en ejecución un fichero que quizá solo existía en tu directorio de trabajo. Funciona en tu máquina, falla en cualquier otra, y el diagnóstico llega en forma de biblioteca no encontrada apuntando a una ruta que nadie reconoce. Grabar el soname no es una buena práctica opcional: es lo que convierte un fichero en una biblioteca instalable.

Qué rompe la ABI y qué no

La compatibilidad binaria no es la compatibilidad de código fuente, y la diferencia es exactamente lo que hace este tema difícil. Recompilar arregla muchas cosas; el escenario que importa es el otro, aquel en el que sustituyes el fichero bajo un binario ya compilado que nadie va a reconstruir.

Seguro

Añadir funciones nuevas. Añadir miembros al final de una struct que tus usuarios nunca reservan por valor. Corregir la implementación sin tocar la firma ni la semántica.

💥

Rotura inmediata

Eliminar o renombrar una función exportada. Cambiar un tipo de parámetro o de retorno. Reordenar miembros de una struct pública, cambiar su tamaño o su alineación.

🕳️

Rotura silenciosa

Cambiar el valor de una constante de un enum o de una macro. Alterar el significado de un código de error. Añadir un miembro en medio de una struct que el usuario reserva.

🧭

Solo hacia adelante

Añadir funciones significa que un binario compilado con la biblioteca nueva no funciona con la vieja, aunque lo contrario sí. La compatibilidad tiene dirección.

La tercera tarjeta es la peligrosa, porque no produce ningún error al cargar. Si tu cabecera define un enum de códigos y alguien compiló su programa con la versión antigua, el valor numérico quedó incrustado en su binario; insertar una constante en medio desplaza todas las siguientes y el programa comparará el número viejo contra el significado nuevo. Lo mismo ocurre con el tamaño de una struct que el usuario declara por valor: ese tamaño se fijó en su código, y ampliarla hace que la biblioteca escriba fuera de la reserva ajena.

De ahí sale la disciplina que ya conoces del nivel 12, aplicada ahora con una motivación distinta. Los tipos opacos no son solo higiene de diseño: son el instrumento que traslada al lado de la biblioteca todo lo que necesita poder cambiar. Si el usuario nunca conoce el tamaño de tu struct, ampliarla deja de ser una rotura. Si nunca la reserva, no hay reserva que desbordar. Las constantes públicas se añaden al final y jamás se reordenan, y los tipos opacos que deban vivir en la pila se sobredimensionan desde el primer día, exactamente como hace pthread_mutex_t.

Versionado de símbolos

Existe un mecanismo más fino que el número mayor, y explica una de las hazañas cotidianas más subestimadas del sistema: que una sola libc.so.6, con ese mismo soname desde 1997, ejecute binarios compilados a lo largo de más de dos décadas. La técnica consiste en etiquetar cada símbolo exportado con una etiqueta de versión, de manera que un mismo nombre pueda tener varias definiciones coexistiendo en el mismo fichero.

UTIL_1.0 {
  global:
    util_abrir;
    util_cerrar;
  local:
    *;                  /* todo lo demas queda oculto */
};

UTIL_1.1 {
  global:
    util_abrir_con_opciones;
} UTIL_1.0;             /* hereda de la anterior */
gcc -shared *.o -Wl,--version-script=util.map -Wl,-soname,libutil.so.1 -o libutil.so.1.1.0
objdump -T libutil.so.1.1.0 | head       # simbolos con su etiqueta de version

El guion cumple dos funciones a la vez, y ambas valen la pena por separado. La primera es la que se ve: cada símbolo queda asociado a una etiqueta, y cuando alguien enlaza contra la biblioteca, su binario anota no solo el nombre sino también la versión que espera. La segunda es la línea local: *;, que oculta todo lo no listado y hace por el enlazador lo mismo que -fvisibility=hidden por el compilador, pero de forma centralizada y verificable en un único fichero que puedes revisar como parte del código.

Cuando de verdad necesitas cambiar el comportamiento de una función existente, la maniobra es conservar las dos implementaciones bajo el mismo nombre y etiquetas distintas.

__asm__(".symver util_abrir_v1, util_abrir@UTIL_1.0");
__asm__(".symver util_abrir_v2, util_abrir@@UTIL_1.1");   /* la version por defecto */

int util_abrir_v1(const char *ruta);                       /* semantica antigua */
int util_abrir_v2(const char *ruta);                       /* semantica nueva */

Los binarios antiguos anotaron UTIL_1.0 y siguen llegando a la implementación de siempre; los que se compilen desde ahora reciben la nueva. Nadie se rompe y nadie recompila. El coste es igual de real: mantener para siempre todas las implementaciones antiguas, con sus pruebas, sus fallos y su deuda. Es una técnica de biblioteca de sistema, no de proyecto pequeño, y esa es justamente la razón por la que conviene conocerla antes de pretender necesitarla.

Hay un efecto secundario del versionado que conviene anticipar porque desconcierta a mucha gente. Un binario compilado en una distribución reciente anota las etiquetas más nuevas que encontró, de modo que no arranca en un sistema más antiguo aunque el soname coincida: el cargador comprueba que cada versión requerida exista y aborta con un mensaje sobre una versión no encontrada. Es la manifestación exacta de la cuarta tarjeta anterior, la compatibilidad que solo va hacia adelante, y explica por qué quien distribuye binarios los construye contra la versión más antigua que quiere admitir en lugar de la más nueva que tiene a mano.

Verificar la compatibilidad en lugar de recordarla

Confiar en que el equipo recuerde el catálogo de la sección anterior no es una política; es una esperanza. La disciplina consiste en convertir la interfaz binaria en un artefacto versionado que la integración continua compara en cada cambio, del mismo modo que se comparan los resultados de las pruebas.

abidw --out-file api-v1.abi libutil.so.1.4.2       # captura la ABI actual
abidiff api-v1.abi libutil.so.1.5.0                # compara y clasifica

nm -D --defined-only libutil.so.1.5.0 | sort > simbolos.txt
diff simbolos.esperados simbolos.txt               # la version pobre pero util

Las herramientas de la familia libabigail leen la información de depuración del binario, reconstruyen los tipos que atraviesan la interfaz y distinguen los cambios inocuos de los que rompen. Detectan precisamente lo que la revisión humana pasa por alto: la struct que creció tres bytes porque alguien añadió un campo en medio, la constante de enum insertada antes del final, el parámetro que pasó de int a long en una plataforma donde eso importa. Cuando no puedas instalarlas, la versión pobre —un fichero con la lista ordenada de símbolos exportados, guardado junto al código y comparado en cada construcción— ya detecta las desapariciones, que son la mitad de los desastres.

La segunda mitad de la disciplina es documental y no cuesta nada: escribir en la cabecera pública qué forma parte del contrato y qué no. Un tipo que aparece en un struct público está en el contrato aunque nunca lo hayas prometido, porque su tamaño se propagó al binario del usuario. Enunciarlo por escrito no impide romperlo, pero convierte la rotura en una decisión consciente en lugar de un descuido, y ese es exactamente el cambio de categoría que persigue todo este nivel.

Publicar una biblioteca es asumir una obligación asimétrica

Merece la pena nombrar con precisión lo que ocurre cuando publicas un soname, porque cambia la naturaleza de tu trabajo. Hasta ese momento tu código era tuyo: podías reorganizarlo, renombrar, cambiar de opinión, y el único coste era recompilar. Desde ese momento existe, en máquinas que no conoces, una población de binarios que contienen tu nombre grabado y ninguna forma de rehacerse. La relación se vuelve asimétrica de manera irreversible: tú puedes romperlos a ellos, ellos no pueden defenderse de ti, y ninguna cantidad de notas de versión reconstruye un binario cuyo código fuente se perdió. De ahí que la interfaz binaria sea, mucho antes que un asunto técnico, un compromiso institucional. Y por eso el catálogo de esta lección —el tamaño de una struct, el valor de una constante de enum, la firma de una función— importa tanto: no es una lista de tecnicismos, es el inventario exacto de las cosas que quedan congeladas en el momento de la publicación, y la mayoría de ellas se congelan sin que nadie lo declare, simplemente porque estaban en una cabecera. Ahí está el criterio que trasciende a C y reaparece en el diseño de protocolos, de formatos de fichero y de interfaces web: la compatibilidad no se conserva teniendo cuidado, sino no exponiendo aquello sobre lo que no quieres comprometerte. Un tipo opaco, una constante añadida al final y un guion de versión con local: *; no son detalles de implementación: son la forma de decidir, deliberadamente y por adelantado, cuánta de tu libertad futura estás vendiendo a cambio de que alguien te use.

⚔️ Publica dos versiones que convivan
  1. Construye libutil.so.1.0.0 con soname grabado, crea la cadena de enlaces y verifica con readelf -d que el programa cliente anota el soname y no el nombre de enlace.
  2. Cambia la implementación sin tocar la interfaz, publica 1.0.1 reapuntando el enlace y comprueba que el binario antiguo recoge el cambio sin recompilar.
  3. Amplía una struct pública que el cliente reserva por valor, ejecuta el binario viejo contra la biblioteca nueva y describe el fallo bajo un depurador.
  4. Repite el experimento con la struct convertida en tipo opaco y explica por qué ahora la ampliación es inocua.
  5. Escribe un guion de versión con dos etiquetas, exporta un símbolo con dos implementaciones mediante .symver y verifica con objdump -T que ambas conviven en el mismo fichero.