Estabilidad de ABI: qué rompe y qué no
El catálogo de cambios que invalidan los binarios ya compilados frente a los que son inocuos, por qué la compatibilidad de código fuente y la binaria son contratos independientes, y las técnicas con las que una biblioteca seria evoluciona sin romper a nadie.
Publicas la versión 1.1 de tu biblioteca. Compila igual, pasa los tests, la API no ha cambiado ni una coma: solo has añadido un campo a una estructura pública. Al día siguiente, los programas que enlazaban con la 1.0 y no se han recompilado empiezan a corromper memoria. No hay bug en tu código ni en el suyo: hay un contrato binario que rompiste sin darte cuenta de que existía. La estabilidad de ABI es la disciplina de saber exactamente dónde está ese contrato, qué cláusulas tiene y cómo se enmienda sin invalidar lo ya firmado.
- Separar compatibilidad de código fuente de compatibilidad binaria y ver que son independientes.
- Enumerar los cambios que invalidan un binario ya enlazado y los que no.
- Aplicar tipos opacos, versionado de símbolos y visibilidad para poder evolucionar.
- Verificar la compatibilidad con herramientas en lugar de razonarla de memoria.
Dos contratos independientes
La API es lo que ve el compilador: nombres, tipos, firmas. La ABI es lo que ve el enlazador dinámico: tamaños, desplazamientos, registros, símbolos. La intuición dice que quien conserva la primera conserva la segunda, y esa intuición es sencillamente falsa. Los cuatro cuadrantes existen todos y cada uno es real.
Compatible en ambas
Añadir una función nueva. El código antiguo compila igual y los binarios antiguos siguen encontrando lo que buscaban.
Rompe API, conserva ABI
Renombrar un parámetro en la cabecera, o marcar un puntero como const. El código ajeno puede dejar de compilar; el binario ya enlazado no se entera.
Conserva API, rompe ABI
Añadir un campo a una estructura pública. Todo el código fuente del mundo sigue compilando, y todos los binarios existentes quedan corruptos.
Rompe ambas
Eliminar una función exportada o cambiar su firma. El caso obvio, y por eso el menos peligroso: se detecta el primer día.
El cuadrante temible es el tercero, porque es silencioso. Una struct pública no es una descripción: es un mapa de desplazamientos que quedó grabado en el código máquina de cada programa que la usó. Si el llamador escribe en el desplazamiento 8 porque ahí estaba el campo estado cuando lo compilaste, seguirá escribiendo en el 8 aunque tú lo hayas movido al 16.
/* Version 1.0 de la cabecera publica */
struct Conexion { int fd; int estado; }; /* estado en el offset 4 */
/* Version 1.1: parece inocuo, es catastrofico */
struct Conexion { int fd; int reintentos; int estado; }; /* estado en el 8 */
El programa antiguo, además, reserva sizeof bytes con el tamaño viejo. Si tu biblioteca escribe ahora en el campo nuevo, escribe fuera del objeto que el llamador reservó. No es un valor incorrecto: es corrupción de montículo o de pila a distancia.
El catálogo de rupturas
Vale la pena memorizar la frontera, porque razonarla caso a caso durante una release es cómo se cometen los errores.
Rompen la compatibilidad binaria: añadir, quitar o reordenar campos de una estructura pública; cambiar el tipo de un campo por otro de distinto tamaño o alineación; alterar la anchura de un campo de bits; cambiar el tipo subyacente de un enum público o el valor de un enumerador ya publicado; modificar la firma de una función exportada, incluido su tipo de retorno; eliminar un símbolo exportado; cambiar los atributos de empaquetado o alineación de un tipo público; y —el más traicionero— modificar el cuerpo de una función inline o de una macro definida en la cabecera, porque su código ya fue copiado dentro de cada binario cliente.
Hay dos casos fronterizos que merecen su propio párrafo porque casi todo el mundo los juzga mal. El primero son los enum: en C el tipo subyacente lo elige la implementación según los valores presentes, de modo que añadir un enumerador con un valor grande puede ensanchar el tipo entero y cambiar el tamaño de cualquier estructura que lo contenga. C23 permite fijarlo explícitamente, y hacerlo en una cabecera pública es una de las mejores inversiones de estabilidad que existen. El segundo son los campos de bits, cuyo empaquetado depende de reglas de la ABI que la mayoría de los programadores nunca ha leído: cambiar una anchura de tres a cuatro bits puede desplazar todo lo que viene detrás.
enum Estado : uint32_t { LIBRE = 0, OCUPADO = 1 }; /* C23: ancho fijado */
No rompen nada: añadir funciones nuevas; añadir campos a una estructura que el cliente nunca declara, reserva ni recorre; cambiar el cuerpo de una función no inline conservando su semántica; renombrar parámetros; añadir o quitar const a un parámetro por valor; convertir una función pública en static si nadie la exportaba; y reordenar declaraciones en la cabecera.
Todo lo que pones en una cabecera pública —una macro con lógica, una función inline, el layout de una estructura, un enum— no es documentación: es código y datos que el compilador del cliente incrusta en su binario. A partir de ese momento no puedes cambiarlo, porque ya no vive en tu biblioteca. La regla operativa es brutal en su simplicidad: cuanto menos haya en tu cabecera pública, más libertad conservas para evolucionar. Cada detalle que expones es una promesa binaria que tendrás que sostener durante años.
Cómo se evoluciona sin romper
Las bibliotecas que llevan décadas en producción no evitan el problema por disciplina: lo evitan por diseño. Cuatro técnicas cubren casi todos los casos.
El tipo opaco es la primera y la más eficaz. Declaras la estructura sin definirla, entregas al cliente únicamente punteros y reservas tú toda la memoria. Como nadie fuera conoce el tamaño ni los desplazamientos, puedes reorganizar el interior en cada versión sin consecuencias.
/* cabecera publica: el cliente no sabe nada del interior */
typedef struct Conexion Conexion;
Conexion *conexion_abrir(const char *host, int puerto);
int conexion_estado(const Conexion *c);
void conexion_cerrar(Conexion *c);
La segunda es el control de visibilidad. Compilando con -fvisibility=hidden y marcando explícitamente lo que exportas, reduces la superficie del contrato a lo que decidiste ofrecer en vez de a todo lo que resultó no ser static. Un fichero de versión del enlazador da además nombre a cada conjunto de símbolos.
#define API __attribute__((visibility("default")))
API int conexion_estado(const Conexion *c);
La tercera es el soname. Una biblioteca compartida lleva grabado en su cabecera dinámica el nombre con el que los binarios la buscan: libred.so.1. Mientras la ABI se conserve, ese número no cambia y las actualizaciones son transparentes. Cuando se rompe de forma inevitable, se incrementa a libred.so.2 y ambas versiones conviven en el sistema: los binarios antiguos siguen cargando la 1 y los nuevos la 2. La ruptura no desaparece, pero deja de ser destructiva.
Un fichero de versión hace las dos cosas a la vez: agrupa los símbolos bajo una etiqueta y oculta todo lo demás sin depender de atributos repartidos por el código.
LIBRED_1.0 {
global: conexion_abrir; conexion_estado; conexion_cerrar;
local: *;
};
Cuando el tipo opaco no es viable —porque el cliente debe reservar el objeto él mismo, como ocurre con las estructuras que se pasan al kernel— queda una tercera vía intermedia: publicar el tamaño. La estructura incluye como primer campo su propia longitud, el cliente la rellena y la biblioteca decide qué campos puede leer según el valor recibido. Es exactamente el patrón que usan clone3 u openat2, y permite crecer indefinidamente sin romper a nadie. Su variante pobre, reservar campos de relleno para el futuro, funciona pero envejece mal: o sobran o faltan.
La cuarta, para cuando hay que romper una sola función, es el versionado de símbolos: el mismo nombre convive en varias encarnaciones dentro del mismo fichero, cada una etiquetada con la versión en que apareció. Es la maquinaria que ha permitido a glibc mantener binarios de los años noventa funcionando sobre sistemas actuales.
flowchart TD A[Quiero cambiar algo de la biblioteca] --> B[Solo anado funciones nuevas: sin ruptura] A --> C[Toco el interior de un tipo opaco: sin ruptura] A --> D[Toco un tipo publico o una firma: hay ruptura] D --> E[Ruptura aislada: versionar el simbolo] D --> F[Ruptura general: subir el soname] style B fill:#a6e3a1,color:#11111b style C fill:#a6e3a1,color:#11111b style F fill:#f38ba8,color:#11111b
Verificarlo, no suponerlo
La compatibilidad binaria es demasiado sutil para confiarla al criterio humano en una tarde de release. Existen herramientas que comparan dos binarios y enumeran las diferencias que importan.
# construir con informacion de tipos para poder compararla
gcc -std=c23 -g -O2 -fPIC -shared -Wl,-soname,libred.so.1 red.c -o libred.so.1.0.0
# comparar dos versiones y obtener el veredicto
abidiff libred.so.1.0.0 libred.so.1.1.0
# inspeccionar el contrato real de un binario
readelf -d libred.so.1.1.0 | grep SONAME
nm -D --defined-only libred.so.1.1.0 | grep ' T '
Vale la pena entender por qué la comprobación es posible. abidiff no adivina: lee la información de depuración DWARF incrustada por -g y reconstruye a partir de ella el tamaño y el desplazamiento de cada campo de cada tipo alcanzable desde un símbolo exportado. Comparando dos reconstrucciones detecta justo lo que el compilador grabó en los clientes. De ahí una consecuencia práctica: si distribuyes binarios sin información de tipos, nadie —tú incluido— puede verificar tu ABI de forma automática.
Meter abidiff en la integración continua convierte una clase entera de desastres en un fallo de compilación. El coste es una hora de configuración; el beneficio es que nadie vuelve a publicar una ruptura silenciosa por descuido.
Hay una diferencia cualitativa entre un programa y una pieza de infraestructura, y esa diferencia se mide exactamente aquí. Un programa puede reescribirse; una infraestructura tiene encima cosas que no controla y que no puede recompilar. Cuando publicas una biblioteca con ABI estable estás firmando algo más ambicioso que una API: estás prometiendo que el código máquina que otros generaron hoy contra tu descripción de la memoria seguirá siendo válido dentro de diez años. Esa promesa es la razón de que exista un ecosistema de software compartido en lugar de un mundo donde cada actualización obligue a reconstruir el planeta entero. El precio, y hay que verlo con los ojos abiertos, es la pérdida permanente de libertad de diseño: cada campo que expusiste, cada enum que publicaste, cada función inline que dejaste en una cabecera, es una decisión que ya no puedes revisar. Por eso los ingenieros con más experiencia diseñan las cabeceras públicas con una mentalidad casi paranoica de mínima exposición, y por eso el patrón del tipo opaco con funciones de acceso, que en un curso introductorio parece burocracia inútil, es en realidad la técnica que compra el derecho a seguir mejorando tu implementación después de haberla publicado. La verdadera pregunta al diseñar una interfaz no es qué necesita el usuario hoy, sino qué me estoy prohibiendo cambiar mañana.
- Escribe una biblioteca compartida con una estructura pública y un programa que la use. Compila ambos, añade un campo al principio de la estructura, recompila solo la biblioteca y observa la corrupción.
- Repite el experimento con la estructura convertida en tipo opaco y comprueba que ahora el cambio es inocuo.
- Ejecuta
abidiffentre las dos versiones de cada variante y compara los informes. - Compila con
-fvisibility=hiddeny lista connm -Dcuántos símbolos exportabas antes y cuántos después. - Crea dos versiones con soname distinto, instálalas a la vez y verifica con
lddque cada binario carga la suya.