[The World of Linkers—Theory 13] The Library Changed. What Happens to the Old Program?
A shared-library upgrade must remain compatible with released machine code as well as source. Argument registers, structure allocation sizes, and field offsets were fixed when each client was compiled. Replacing its library does not regenerate those instructions.
Dynamic linking selects files and binds symbols at runtime; successful binding does not validate the binary interface. The ABI, Application Binary Interface, describes the machine-level contract between caller and callee. Even unchanged function names and declarations can hide a structure-layout change that makes a successfully loaded program behave incorrectly.
Shared-library loading and symbol lookup from Theory 07 provide the foundation. This chapter distinguishes changes that break old machine-code contracts, the compatibility boundaries expressed by SONAMEs and symbol versions, and the failures that analysis tools can detect. Interface design and semantic tests must address what those tools cannot prove. A structure upgrade first shows why finding a symbol is weaker than calling it correctly.
A successful load with the wrong answer
Version 1.0 of libpoint exposes a structure and two functions:
/* point1.h:libpoint 1.0 */struct point { int x; int y; };void point_init(struct point *p, int x, int y);int point_sum(const struct point *p);The client allocates an array itself:
/* app.c */#include <stdio.h>#include POINT_Hstruct point pts[3]; /* reserve the third element */int main(void) { point_init(&pts[0], 1, 2); point_init(&pts[1], 10, 20); printf("sizeof(struct point) = %zu\n", sizeof(struct point)); printf("sum0 = %d, sum1 = %d\n", point_sum(&pts[0]), point_sum(&pts[1])); return 0;}Version 1.1 adds a z field, initializes it to zero, and includes it in the sum. The source uses POINT_H to select a header and HAVE_Z for the new implementation:
/* point.c: shared source; version 1.1 defines HAVE_Z */#include POINT_Hvoid point_init(struct point *p, int x, int y) { p->x = x; p->y = y;#ifdef HAVE_Z p->z = 0; /* initialize the new field to zero */#endif}int point_sum(const struct point *p) {#ifdef HAVE_Z return p->x + p->y + p->z;#else return p->x + p->y;#endif}/* point2.h:libpoint 1.1 */struct point { int x; int y; int z; };void point_init(struct point *p, int x, int y);int point_sum(const struct point *p);Keep the same SONAME, compile the client against version 1, and replace only its runtime library:
$ mkdir -p v1 v2$ gcc -g -O1 -fPIC -shared -DPOINT_H='"point1.h"' -Wl,-soname,libpoint.so.1 point.c -o v1/libpoint.so.1$ ln -sf libpoint.so.1 v1/libpoint.so$ gcc -O1 -DPOINT_H='"point1.h"' app.c -Lv1 -lpoint -o app$ LD_LIBRARY_PATH=v1 ./appsizeof(struct point) = 8sum0 = 3, sum1 = 30$ gcc -g -O1 -fPIC -shared -DPOINT_H='"point2.h"' -DHAVE_Z -Wl,-soname,libpoint.so.1 point.c -o v2/libpoint.so.1$ LD_LIBRARY_PATH=v2 ./appsizeof(struct point) = 8sum0 = 13, sum1 = 30The program starts without a warning, but sum0 becomes 13. Recompiling it against the new header restores the expected result:
$ ln -sf libpoint.so.1 v2/libpoint.so$ gcc -O1 -DPOINT_H='"point2.h"' app.c -Lv2 -lpoint -o app_new$ LD_LIBRARY_PATH=v2 ./app_newsizeof(struct point) = 12sum0 = 3, sum1 = 30The old executable allocated eight bytes per point. The new library expects twelve. Its pts[0].z overlaps the old client's pts[1].x; the second initialization writes 10 there, so the first sum becomes 1 + 2 + 10. The spare third element absorbs another misplaced write in this fixture. Without it, the second initialization would also run beyond the intended array.
This mismatched layout violates the program's type and storage contract. The value 13 explains the observed machine code; it is not a portable result guaranteed for a C program with UB1.
Why did the loader accept it?
$ readelf -d app | grep NEEDED 0x0000000000000001 (NEEDED) Shared library: [libpoint.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]$ readelf --dyn-syms -W v2/libpoint.so.1 | grep point_ 5: 00000000000010f9 17 FUNC GLOBAL DEFAULT 9 point_init 6: 000000000000110a 13 FUNC GLOBAL DEFAULT 9 point_sumThe required libpoint.so.1 exists, as do point_init and point_sum. Dynamic symbols describe names, addresses, sizes, binding, and coarse kinds such as FUNC or OBJECT. They do not carry the C parameter types or the structure layout needed to reject this mismatch. The old sizeof is already a constant in the client's instructions.
API and ABI describe different boundaries
An API is the source-level interface and its documented behavior. An ABI2 is the contract expressed in compiled code. Source compatibility asks whether an old client can be rebuilt correctly; binary compatibility asks whether its existing executable can continue without rebuilding. Even source compatibility is more than “the compiler accepted it.”
There are at least four parts to that compiled contract:
| Part | Examples of assumptions frozen into a client |
|---|---|
| Calling convention | Argument registers, stack slots, return values, preserved registers |
| Data representation | Structure size/alignment, field offsets, enum values |
| Symbol identity | Exported names and required versions |
| Documented semantics | Return values, ownership, error handling, edge cases |
The loader directly checks only parts of this boundary. A C function can acquire a second integer parameter without changing its symbol name; an old caller still prepares only the first. A changed enum can remain the same machine integer type while assigning that integer a different meaning.
Some common changes therefore deserve more precise treatment than “adding things is safe”:
| Change | Compatibility consequence |
|---|---|
| Add an exported function | Usually preserves old clients; new clients still cannot use an older library lacking it |
| Remove an export | Breaks clients that need it |
| Change a C function's arguments | Can bind successfully but use the wrong calling convention |
| Append a structure field | Depends on who allocates, copies, sizes, or embeds the object |
| Insert an enum value | Can renumber old constants unless their values are fixed |
| Append an enum value | Also requires representation and protocol assumptions to remain valid |
| Change an inline function or macro | Old machine code retains the old implementation |
| Change a C++ default argument | Existing callers retain the old compiled-in default |
| Change virtual functions | May alter slots, derived-class contracts, and object layout |
| Change a C++ parameter type | Often changes the mangled symbol name, making the break visible earlier |
A truly opaque, library-allocated object can grow internally. A public structure that clients allocate cannot be treated that way merely because functions take pointers to it.
Three failures the loader cannot reinterpret
Insert YELLOW between RED and GREEN:
/* color.h */enum color { RED,#ifdef V2 YELLOW,#endif GREEN, BLUE };const char *color_name(enum color c);$ gcc -O1 color_app.c v1/libcolor.so.1 -o color_app$ LD_LIBRARY_PATH=v1 ./color_appGREEN -> green$ LD_LIBRARY_PATH=v2 ./color_appGREEN -> yellowThe old client's constant for GREEN is still 1; the new library interprets 1 as YELLOW.
Next change a hash function that the header inlines into callers while the library also uses it to place values:
/* tab.h */#define TAB_SIZE 8static inline unsigned tab_hash(const char *s) { unsigned h = 0;#ifdef V2 while (*s) h = (h * 33) ^ (unsigned char)*s++;#else while (*s) h = h * 31 + (unsigned char)*s++;#endif return h % TAB_SIZE;}void tab_put(const char *key, int val); /* the library selects a slot using tab_hash */int tab_slot(unsigned slot); /* read a slot */$ LD_LIBRARY_PATH=v1 ./tab_appapple -> slot 2 -> 7$ LD_LIBRARY_PATH=v2 ./tab_appapple -> slot 2 -> 0$ nm -D tab_app | grep tab_ U tab_put U tab_slotThe old caller looks in slot 2 using its old algorithm. The new library stored the value elsewhere. No dynamic tab_hash symbol exists to update: its instructions are inside the caller.
Finally insert perimeter() before a virtual area():
// shape.hstruct Shape {#ifdef V2 virtual int perimeter() const;#endif virtual int area() const; virtual ~Shape(); int side;};Shape *make_square(int side);$ g++ -O1 shape_app.cc v1/libshape.so.1 -o shape_app$ LD_LIBRARY_PATH=v1 ./shape_apparea = 9$ LD_LIBRARY_PATH=v2 ./shape_apparea = 12$ objdump -d --no-show-raw-insn -C shape_app | sed -n '/<main>:/,/ret/p' ... 1178: mov %rax,%rbx 117b: mov (%rax),%rax 117e: mov %rbx,%rdi 1181: call *(%rax) ... 11a0: mov (%rbx),%rax 11a3: mov %rbx,%rdi 11a6: call *0x10(%rax)The old call uses virtual-table slot zero. In the new object, that slot means perimeter, so a square of side three produces “area” 12. The loader does not resolve the method name again at each indirect call.
The destructor slots shift too:
# v1: R_X86_64_64 targets in slot order0x3dd8 -> _ZNK5Shape4areaEv0x3de0 -> _ZN5ShapeD1Ev0x3de8 -> _ZN5ShapeD0Ev# v20x3dd0 -> _ZNK5Shape9perimeterEv0x3dd8 -> _ZNK5Shape4areaEv0x3de0 -> _ZN5ShapeD1Ev0x3de8 -> _ZN5ShapeD0EvUnder this C++ ABI, D1 destroys the complete object and D0 also releases its storage. The old delete call selects what used to be D0, now D1. Counting global allocation/deallocation calls makes the leak visible:
$ g++ -O1 shape_leak.cc v1/libshape.so.1 -o shape_leak$ LD_LIBRARY_PATH=v1 ./shape_leakarea = 9, operator new 1, operator delete 1$ LD_LIBRARY_PATH=v2 ./shape_leakarea = 12, operator new 1, operator delete 0Keeping names is insufficient when the broken assumptions are constants, inline instructions, or slot numbers. The options are to preserve the original contract, provide a compatible old interface, or explicitly separate an incompatible generation.
SONAMEs separate library generations
Separate build-time selection from runtime lookup. -Llib -lpoint asks the static linker to find an input under lib. After opening the shared library, it reads DT_SONAME and records that name in the new program's DT_NEEDED. At startup, the loader reads the stored dependency; it does not run the original -lpoint selection again.
| Name | Where it exists | Main role |
|---|---|---|
| libpoint.so.1.0.0 | Actual file on disk | Stores one implementation |
| libpoint.so.1 | Library's DT_SONAME; usually also a disk link | Names the client's runtime dependency |
| libpoint.so | Usually a development link on disk | Selects an input generation for a new -lpoint build |
DT_SONAME is a string inside ELF; a symbolic link is a filesystem relationship. Keeping them consistent is an installation arrangement. Renaming a disk file does not rewrite its SONAME. Changing the development link does not rewrite old clients' DT_NEEDED entries.
The oldest versioning boundary is the library's runtime name. An incompatible generation can use libpoint.so.2 while existing programs continue to request .1.
Three filenames serve different jobs:
$ gcc -O1 -fPIC -shared -DPOINT_H='"point1.h"' -Wl,-soname,libpoint.so.1 point.c -o lib/libpoint.so.1.0.0$ gcc -O1 -fPIC -shared -DPOINT_H='"point2.h"' -DHAVE_Z -Wl,-soname,libpoint.so.2 point.c -o lib/libpoint.so.2.0.0$ ldconfig -n -v liblib: (from <cmdline>:0) libpoint.so.1 -> libpoint.so.1.0.0 (changed) libpoint.so.2 -> libpoint.so.2.0.0 (changed)The real file identifies an installed implementation. The SONAME link is the name runtime dependencies request. The unversioned libpoint.so is the development link used by -lpoint when building a new client.
$ ln -sf libpoint.so.1 lib/libpoint.so$ gcc -O1 -DPOINT_H='"point1.h"' app.c -Llib -lpoint -Wl,-rpath,$W/soname/lib -o app1$ ln -sf libpoint.so.2 lib/libpoint.so$ gcc -O1 -DPOINT_H='"point2.h"' app.c -Llib -lpoint -Wl,-rpath,$W/soname/lib -o app2$ ls -l liblrwxr-xr-x libpoint.so -> libpoint.so.2lrwxr-xr-x libpoint.so.1 -> libpoint.so.1.0.0-rwxr-xr-x libpoint.so.1.0.0lrwxr-xr-x libpoint.so.2 -> libpoint.so.2.0.0-rwxr-xr-x libpoint.so.2.0.0$ readelf -d app1 | grep NEEDED | head -1 0x0000000000000001 (NEEDED) Shared library: [libpoint.so.1]$ readelf -d app2 | grep NEEDED | head -1 0x0000000000000001 (NEEDED) Shared library: [libpoint.so.2]$ ./app1; ./app2sizeof(struct point) = 8sum0 = 3, sum1 = 30sizeof(struct point) = 12sum0 = 3, sum1 = 30$ LD_DEBUG=libs ./app1 2>&1 | grep 'trying file=.*libpoint' | tail -1 664: trying file=$W/soname/lib/libpoint.so.1$ LD_DEBUG=libs ./app2 2>&1 | grep 'trying file=.*libpoint' | tail -1 666: trying file=$W/soname/lib/libpoint.so.2Both programs now select the appropriate generation and produce correct results. ldconfig can manage SONAME links; changing the development link affects future links, not the DT_NEEDED strings already stored in old executables.
This separation is coarse. A process that loads two incompatible generations with colliding global symbols can still encounter interposition between them. Different filenames are not automatically independent symbol namespaces.
Libtool's arithmetic
GNU libtool's current:revision:age numbers describe an interface interval, not the application's marketing version. current is the newest interface generation, age counts how many preceding generations remain supported, and revision identifies an implementation revision without an interface change.
For the Linux convention shown here, the SONAME suffix is current - age; the real filename ends in (current-age).age.revision:
$ libtool --mode=compile gcc -O1 -c demo.c$ libtool --mode=link gcc -o libdemo.la demo.lo -rpath /usr/local/lib -version-info 3:1:2$ ls .libs | grep '^libdemo.so'libdemo.solibdemo.so.1libdemo.so.1.2.1$ readelf -d .libs/libdemo.so | grep SONAME 0x000000000000000e (SONAME) Library soname: [libdemo.so.1]-version-info 0:0:0 -> libdemo.so.0.0.0 soname libdemo.so.0-version-info 1:0:1 -> libdemo.so.0.1.0 soname libdemo.so.0-version-info 1:1:1 -> libdemo.so.0.1.1 soname libdemo.so.0-version-info 2:0:0 -> libdemo.so.2.0.0 soname libdemo.so.2-version-info 3:1:2 -> libdemo.so.1.2.1 soname libdemo.so.1A bug fix increments revision. An interface change increments current and resets revision. A compatible addition increments age; a removal or incompatible change resets age to zero. Consequently, a SONAME number can skip values. Follow the libtool update rules, rather than copying the package release number into these fields.
Runtime packages and development packages
Runtime package names often distinguish incompatible SONAME generations; development packages provide the unversioned link. A distribution can add its own migration suffixes, so inspect package contents rather than guess them:
apt-get download libssl3t64 libssl-devdpkg -c libssl3t64_*.deb | grep 'libssl\.so'dpkg -c libssl-dev_*.deb | grep 'libssl\.so'The recorded Ubuntu packages were libssl3t64 and libssl-dev, version 3.5.5-1ubuntu3.7. Their roles remain useful even as package versions change: one supplies libssl.so.3, the other the link-time name and matching dependency. Other observed owners were libstdc++6, libc6, and libabigail8 for their corresponding SONAMEs.
glibc's long-lived libc.so.6 does not imply an eternally unchanged implementation. Finer-grained compatibility lives in symbol versions.
One name, two promised behaviors
SONAME selects a library; version requirements constrain symbols within it. parse_num@VERS_1 and parse_num@@VERS_2 share a base name but represent different contracts. @@ selects the default for newly linked ordinary references; a compatibility definition with one @ remains available to explicit versioned references. These are publisher-defined labels. The loader does not infer structure layout or function signatures from their numbers.
| Record | Question answered | How its index is used |
|---|---|---|
| .dynsym | Symbol name and defined/undefined status | Symbol ordinal j within this file |
| .gnu.version | Version associated with each dynamic symbol | Two-byte entry j corresponds to .dynsym[j] |
| .gnu.version_d | Version nodes defined by this file | Find the definition record with the matching vd_ndx and name |
| .gnu.version_r | Versions this file requests from other libraries | Find the corresponding requirement number, name, and library |
Symbol ordinals and version numbers are different indices. Only the low 15 bits of each two-byte .gnu.version entry are its number; the high bit is a separate version flag. Version definitions are linked records, not an array addressed by number times a fixed stride. Matching between files uses version names and related metadata, not coincidentally equal local numbers. The old client later numbers VERS_1 as 4, while the library numbers it as 2; both describe the same version name. glibc's version check compares a name hash and then the name string.
The diagram uses the actual libparse ordinals shown below. Its parse_num@VERS_1 is .dynsym[10], so its version entry starts 10 × 2 = 20 bytes into .gnu.version. On little-endian x86-64, bytes 02 80 encode 0x8002; masking off 0x8000 leaves definition number 2, VERS_1. Definition .dynsym[9] has bytes 03 00, identifying number 3, the default VERS_2. This high bit belongs to the version entry, not the ELF symbol’s STV_HIDDEN visibility field.
The client follows a different relation. The old executable’s .dynsym[5] references parse_num; its version entry supplies local number 4, which .gnu.version_r describes as a VERS_1 requirement from libparse.so.1. The library uses number 2 for that same name. The loader does not compare 4 == 2: it decodes each file’s records and checks that the requested named version is supplied. The following trace concerns an explicitly versioned reference; unversioned references and dlsym follow additional compatibility rules discussed later.
ELF symbol versioning records per-symbol versions, the versions a library defines, and the versions a client requires. GNU's model allows multiple implementations of the same base name. @@ denotes the default for ordinary new references; a single @ commonly exposes a nondefault compatibility definition that can still be requested explicitly.
Start with a parser whose invalid input returns zero:
/* parse1.c: stop at the first nondigit; return zero if there were no digits */int parse_num(const char *s) { int v = 0; while (*s >= '0' && *s <= '9') v = v * 10 + (*s++ - '0'); return v;}Give the first release a version node immediately:
/* parse1.map */VERS_1 { global: parse_num; local: *;};Version 2 should distinguish invalid input by returning minus one and add a base-selecting function. Preserve the old semantics in a compatibility implementation:
/* parse2.c (excerpt) *//* Old implementation: associate its internal name with parse_num@VERS_1 */int parse_num_v1(const char *s) { int v; digits(s, 10, &v); return v; }__asm__(".symver parse_num_v1, parse_num@VERS_1");
/* New implementation: use the symver attribute to select the default version */__attribute__((symver("parse_num@@VERS_2")))int parse_num_v2(const char *s) { int v; return digits(s, 10, &v) ? v : -1; }
/* The new function belongs only to VERS_2 */int parse_num_base(const char *s, int base) { int v; return digits(s, base, &v) ? v : -1; }The excerpt assumes the shared internal digits helper from the supplied source. GCC's symver attribute expresses a compiler-visible association, unlike relying entirely on opaque assembly text during LTO. The version script declares the old and new nodes:
/* parse2.map */VERS_1 { global: parse_num; local: *;};VERS_2 { global: parse_num; parse_num_base;} VERS_1;$ gcc -O1 -fPIC -shared -Wl,-soname,libparse.so.1 -Wl,--version-script=parse2.map parse2.c -o v2/libparse.so.1$ readelf --dyn-syms -W v2/libparse.so.1 | grep parse_ 7: 000000000000124e 75 FUNC GLOBAL DEFAULT 14 parse_num_base@@VERS_2 10: 00000000000011b9 69 FUNC GLOBAL DEFAULT 14 parse_num@VERS_1 9: 00000000000011fe 80 FUNC GLOBAL DEFAULT 14 parse_num@@VERS_2$ readelf -V v2/libparse.so.1 | sed -n '/version_d/,$p'Version definition section '.gnu.version_d' contains 3 entries: Addr: 0x00000000000004e8 Offset: 0x000004e8 Link: 4 (.dynstr) 000000: Rev: 1 Flags: BASE Index: 1 Cnt: 1 Name: libparse.so.1 0x001c: Rev: 1 Flags: none Index: 2 Cnt: 1 Name: VERS_1 0x0038: Rev: 1 Flags: none Index: 3 Cnt: 2 Name: VERS_2 0x0054: Parent 1: VERS_1VERS_2 lists VERS_1 as its parent. That records the lineage but does not manufacture a missing old implementation. GNU's per-symbol version matching is more specific than simply accepting any symbol under a descendant node.
Link the same client source against each release:
$ gcc -O1 app.c -Lv1 -lparse -o app_old$ gcc -O1 app.c -Lv2 -lparse -o app_new$ readelf --dyn-syms -W app_old | grep parse_num 5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND parse_num@VERS_1 (4)$ readelf --dyn-syms -W app_new | grep parse_num 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND parse_num@VERS_2 (3)$ readelf -V app_new | sed -n '/version_r/,$p'Version needs section '.gnu.version_r' contains 2 entries: Addr: 0x0000000000000548 Offset: 0x00000548 Link: 5 (.dynstr) 000000: Version: 1 File: libparse.so.1 Cnt: 1 0x0010: Name: VERS_2 Flags: none Version: 3 0x0020: Version: 1 File: libc.so.6 Cnt: 3 0x0030: Name: GLIBC_2.2.5 Flags: none Version: 5 0x0040: Name: GLIBC_2.3.4 Flags: none Version: 4 0x0050: Name: GLIBC_2.34 Flags: none Version: 2An ordinary reference chooses the default version available at link time and records that requirement. A new client can explicitly request the old version too:
#include <stdio.h>extern int parse_old(const char *s);__asm__(".symver parse_old,parse_num@VERS_1");int main(void) { printf("explicit old: %d\n", parse_old("abc")); return 0;}$ gcc explicit-old.c -Lv2 -lparse -o explicit-old$ LD_LIBRARY_PATH=v2 ./explicit-oldexplicit old: 0parse_old is a source-level alias for a versioned undefined reference; the library need not export that spelling.
Both old and new clients then run against the version-2 library:
$ LD_LIBRARY_PATH=v2 ./app_oldparse_num("42") = 42, parse_num("abc") = 0$ LD_LIBRARY_PATH=v2 ./app_newparse_num("42") = 42, parse_num("abc") = -1$ LD_DEBUG=bindings LD_LIBRARY_PATH=v2 ./app_old 2>&1 | grep "symbol \`parse_num" 45: binding file ./app_old [0] to v2/libparse.so.1 [0]: normal symbol `parse_num' [VERS_1]$ LD_DEBUG=bindings LD_LIBRARY_PATH=v2 ./app_new 2>&1 | grep "symbol \`parse_num" 47: binding file ./app_new [0] to v2/libparse.so.1 [0]: normal symbol `parse_num' [VERS_2]The old one keeps zero for invalid input; the new one gets minus one. Reverse the compatibility direction and the requirement check fails before main:
$ LD_LIBRARY_PATH=v1 ./app_new./app_new: v1/libparse.so.1: version `VERS_2' not found (required by ./app_new)The loader can compare a small set of required version nodes at startup without eagerly resolving every function relocation. This catches an insufficient library version earlier than the first lazy call.
glibc's versions are identities, not necessarily distinct addresses
Inspect the installed x86-64 library:
readelf --dyn-syms -W /lib/x86_64-linux-gnu/libc.so.6 | grep -E ' (memcpy|memmove|fmemopen|pthread_create|__libc_start_main)@'0x0b9600 IFUNC memmove@@GLIBC_2.2.50x0c22f0 FUNC memcpy@GLIBC_2.2.50x0b8c80 IFUNC memcpy@@GLIBC_2.140x097fe0 FUNC fmemopen@@GLIBC_2.220x0983f0 FUNC fmemopen@GLIBC_2.2.50x0a4050 FUNC pthread_create@GLIBC_2.2.50x0a4050 FUNC pthread_create@@GLIBC_2.340x02a690 FUNC __libc_start_main@GLIBC_2.2.50x02a690 FUNC __libc_start_main@@GLIBC_2.34The old memcpy version preserves historical compatibility behavior while the default version changed at 2.14. This does not make overlapping memcpy a valid portable C operation; new code must use memmove for overlap.
pthread_create's old and new versions point to the same address in this library. Its new default accompanies glibc 2.34's integration of libpthread. __libc_start_main also has two versions at one address, reflecting a startup-interface transition. Different version identities need not imply different instruction addresses.
fmemopen does retain distinct implementations. The old one rejects length zero; the newer one accepts it:
#define _GNU_SOURCE#include <errno.h>#include <stdio.h>#include <string.h>#ifdef OLD__asm__(".symver fmemopen, fmemopen@GLIBC_2.2.5");#endifint main(void) { char buf[1]; FILE *f = fmemopen(buf, 0, "r"); printf("fmemopen(buf, 0, \"r\") = %s (%s)\n", f ? "FILE*" : "NULL", f ? "ok" : strerror(errno)); return 0;}$ gcc -O1 fm.c -o fm_new$ gcc -O1 -DOLD fm.c -o fm_old$ objdump -T fm_new fm_old | grep fmemopen... (GLIBC_2.22) fmemopen... (GLIBC_2.2.5) fmemopen$ ./fm_new; ./fm_oldfmemopen(buf, 0, "r") = FILE* (ok)fmemopen(buf, 0, "r") = NULL (Invalid argument)Here .symver is on the reference, choosing an existing library definition. Version strings are architecture-specific contracts: an AArch64 glibc port whose baseline is 2.17 cannot be tested by copying x86-64's GLIBC_2.2.5 labels indiscriminately.
Why a new build fails on an old system
The native thread example's important references are:
GLIBC_2.34 __libc_start_mainGLIBC_2.34 pthread_createGLIBC_2.34 pthread_joinPinning only pthread_create to an old version leaves the newer pthread_join and startup requirement. __libc_start_main comes from a startup object, not the application's C source.
Run the new executable with the separately unpacked glibc 2.17 loader and its libraries:
S="$W/sysroot-el7""$S/lib64/ld-linux-x86-64.so.2" \ --library-path "$S/lib64:$S/usr/lib64" ./thThe loader reports GLIBC_2.34 not found. The one-symbol-pinned variant fails too, as does the old-fmemopen variant because startup still requires the new interface. This tests an old userspace runtime on the same Linux kernel; it is not a full-system certification for CentOS 7.
Build with the old headers, startup files, and libraries together:
gcc -O1 --sysroot="$S" -B"$S/usr/lib64" \ -L"$S/usr/lib64" -L"$S/lib64" th.c -lpthread -o th_el7"$S/lib64/ld-linux-x86-64.so.2" \ --library-path "$S/lib64:$S/usr/lib64" ./th_el7The result prints thread returned 42 under the old loader and also runs on the current system. It requests the old thread/startup versions, plus earlier dependencies such as __stack_chk_fail@GLIBC_2.4.
Headers are part of this baseline. The current stat wrapper can request stat@GLIBC_2.33; the old headers instead use __xstat. A few hand-written .symver aliases cannot replace a coherent sysroot. Likewise, a manylinux baseline concerns the architecture and dependency set of the distributed package, not merely one executable that happened to start.
What ABI checkers can—and cannot—prove
The dynamic symbol table lacks source types, but DWARF3 can describe them. ABI tools compare reachable public types, functions, and variables across releases.
abidiff
Libabigail's abidiff reads available type information and constructs an ABI representation rooted at exported interfaces. Without usable DWARF, CTF, or BTF, its view can shrink to symbol changes.
$ abidiff v1/libpoint.so.1 v2/libpoint.so.1; echo "abidiff exit=$?"Functions changes summary: 0 Removed, 1 Changed (1 filtered out), 0 Added functionsVariables changes summary: 0 Removed, 0 Changed, 0 Added variable
1 function with some indirect sub-type change:
[C] 'function void point_init(point*, int, int)' at point.c:2:1 has some indirect sub-type changes: parameter 1 of type 'point*' has sub-type changes: in pointed to type 'struct point' at point2.h:2:1: type size changed from 64 to 96 (in bits) 1 data member insertion: 'int z', at offset 64 (in bits) at point2.h:2:1
abidiff exit=4It detects the point structure growing from 64 to 96 bits and the field inserted at bit offset 64. A second function using the same changed type is filtered as redundant; --redundant displays it too.
$ abidiff v1/libcolor.so.1 v2/libcolor.so.1; echo "abidiff exit=$?"... [C] 'function const char* color_name(color)' at color.c:2:1 has some indirect sub-type changes: parameter 1 of type 'enum color' has sub-type changes: type size hasn't changed 1 enumerator insertion: 'color::YELLOW' value '1' 2 enumerator changes: 'color::GREEN' from value '1' to '2' at color.h:2:1 'color::BLUE' from value '2' to '3' at color.h:2:1
abidiff exit=4The exit status is a bitmask. Bit 1 indicates an error; bit 2 denotes usage error and accompanies bit 1. Bit 4 means ABI change; bit 8 means a change classified as incompatible. A status containing only bit 4 requires interpretation—it is not a compatibility certificate. The virtual-table change is classified more strongly:
$ abidiff v1/libshape.so.1 v2/libshape.so.1; echo "abidiff exit=$?"Functions changes summary: 0 Removed, 1 Changed (3 filtered out), 1 Added functionsVariables changes summary: 0 Removed, 0 Changed, 0 Added variable
1 Added function:
[A] 'method virtual int Shape::perimeter() const' {_ZNK5Shape9perimeterEv} note that this adds a new entry to the vtable of struct Shape
1 function with some indirect sub-type change:
[C] 'method virtual int Shape::area() const' at shape.cc:5:1 has some indirect sub-type changes: the vtable offset of method virtual int Shape::area() const changed from 0 to 1 note that this is an ABI incompatible change to the vtable of struct Shape...abidiff exit=12Twelve is 4 | 8. A CI policy can reject explicit incompatibilities and route other changes for review, using a previous library or an abidw baseline. It must also supply the type data, including separate debug files when necessary. Stripping them weakens the evidence:
$ strip -o v1/libpoint.stripped.so v1/libpoint.so.1; strip -o v2/libpoint.stripped.so v2/libpoint.so.1$ abidiff v1/libpoint.stripped.so v2/libpoint.stripped.so; echo "abidiff exit=$?"abidiff exit=0See the abidiff manual for the status and input rules.
abi-compliance-checker
ABICC can consume headers or ABI dumps extracted from debug-bearing libraries:
$ abi-dumper v1/libpoint.so.1 -o v1.dump -lver 1.0WARNING: incompatible build option detected: -O1 (required -Og for better analysis)$ abi-dumper v2/libpoint.so.1 -o v2.dump -lver 1.1WARNING: incompatible build option detected: -O1 (required -Og for better analysis)$ abi-compliance-checker -l libpoint -old v1.dump -new v2.dump -report-path report.htmlPreparing, please wait ...Comparing ABIs ...Comparing APIs ...Creating compatibility report ...Binary compatibility: 100%Source compatibility: 100%Total binary compatibility problems: 0, warnings: 2Total source compatibility problems: 0, warnings: 1Report: report.htmlFor this pointer-based API, the default report gives 100% binary compatibility with low-severity warnings:
Field z has been added to this type.1) This field will not be initialized by old clients.2) Size of the inclusive type has been changed.NOTE: this field should be accessed only from the new library functions, otherwise it may result in crash or incorrect behavior of applications.Size of this type has been changed from 8 bytes to 12 bytes.The fields or parameters of such data type may be incorrectly initialized or accessed by old client applications.That classification reflects a real ambiguity. If the library exclusively allocated opaque points, appending a field could preserve clients. Our client allocates the public structure itself and falls into the warning's explicitly described failure case. Binary type reachability does not fully specify ownership and allocation policy.
Strict mode treats the warnings as problems:
$ abi-compliance-checker -l libpoint -old v1.dump -new v2.dump -strict -report-path report-strict.html...Binary compatibility: 75%Source compatibility: 75%Total binary compatibility problems: 2, warnings: 0Total source compatibility problems: 1, warnings: 0Report: report-strict.html$ echo $?1Rebuilding with the suggested -Og retained the same default/strict distinction. Decide what report levels mean before installing an automated release gate. A successful process exit can mean “no issue above the chosen threshold,” not “every old client is safe.”
Export less before promising more
Every public symbol is a potential compatibility obligation. Internal functions should not become public merely because the compiler emits global names.
Here the library promises only fmt_width; fmt_pad changes internally:
/* fmt.c */#ifdef V2int fmt_pad(int n, char c) { return c == ' ' ? n : 0; }#define PAD(n) fmt_pad(n, ' ')#elseint fmt_pad(int n) { return n; }#define PAD(n) fmt_pad(n)#endif#ifdef EXPORT_MACRO#define FMT_API __attribute__((visibility("default")))#else#define FMT_API#endifFMT_API int fmt_width(int len, int width) { return len < width ? PAD(width - len) : 0; }With unrestricted visibility, both are exported, and the checker correctly sees a changed public parameter list:
$ gcc -g -O1 -fPIC -shared fmt.c -o v1/libfmt.so$ gcc -g -O1 -fPIC -shared -DV2 fmt.c -o v2/libfmt.so$ abidiff v1/libfmt.so v2/libfmt.so; echo "abidiff exit=$?"Functions changes summary: 0 Removed, 1 Changed, 0 Added functionVariables changes summary: 0 Removed, 0 Changed, 0 Added variable
1 function with some indirect sub-type change:
[C] 'function int fmt_pad(int)' at fmt.c:6:1 has some sub-type changes: parameter 2 of type 'char' was added
abidiff exit=4Default-hidden compilation plus an explicit export macro narrows that boundary:
$ gcc -g -O1 -fPIC -shared -fvisibility=hidden -DEXPORT_MACRO fmt.c -o v1/libfmt_h.so$ gcc -g -O1 -fPIC -shared -fvisibility=hidden -DEXPORT_MACRO -DV2 fmt.c -o v2/libfmt_h.so$ readelf --dyn-syms -W v2/libfmt.so | grep fmt_ 5: 0000000000001119 17 FUNC GLOBAL DEFAULT 11 fmt_pad 6: 000000000000112a 39 FUNC GLOBAL DEFAULT 11 fmt_width$ readelf --dyn-syms -W v2/libfmt_h.so | grep fmt_ 5: 000000000000110a 19 FUNC GLOBAL DEFAULT 9 fmt_width$ abidiff v1/libfmt_h.so v2/libfmt_h.so; echo "abidiff exit=$?"abidiff exit=0A version script can enforce the export list too:
$ cat fmt.mapFMT_1 { global: fmt_width; local: *;};$ gcc -g -O1 -fPIC -shared -Wl,--version-script=fmt.map fmt.c -o v1/libfmt_m.so$ gcc -g -O1 -fPIC -shared -Wl,--version-script=fmt.map -DV2 fmt.c -o v2/libfmt_m.so$ readelf --dyn-syms -W v2/libfmt_m.so | grep fmt_ 6: 000000000000110a 39 FUNC GLOBAL DEFAULT 11 fmt_width@@FMT_1$ abidiff v1/libfmt_m.so v2/libfmt_m.so; echo "abidiff exit=$?"abidiff exit=0Visibility tells the compiler earlier which definitions cannot be replaced externally; the script controls the final exported set and version names. -Bsymbolic and -fno-semantic-interposition affect binding or optimization assumptions but are not substitutes for an export allowlist.
C++ makes the remaining boundary richer. Inline functions, templates, class layouts, defaults, and virtual slots can all live in callers. Different definitions of the same entity can violate the ODR4 whether their out-of-line copies are unified or their inlined copies execute independently.
libstdc++'s dual ABI illustrates a deliberately designed escape: newer std::string and std::list implementations use std::__cxx11 in their binary names, allowing old and new names to coexist in libstdc++.so.6. _GLIBCXX_USE_CXX11_ABI controls the choice. Rust's native ABI offers no comparable cross-compiler stability promise by default; stable boundaries require suitable extern "C" calls, repr(C) types, and explicit ownership/error conventions. Theory 14 examines those compiler/linker contracts.
Exercises
After the main fixtures, compare the version records using the fields described above.
-
Inspect the installed ABI baseline. Scan non-symlink ELF files in
/usr/binand/usr/lib/x86_64-linux-gnu. Count each file by its highest requiredGLIBC_*version, preserving full dotted versions such as2.2.5. Why does simply grepping all version strings miscount providers as consumers? Which references explain the common maxima? Then inspect libstdc++'sGLIBCXX_*andCXXABI_*nodes, the parent ofGLIBCXX_3.4.30, and old/newtime_getsymbol pairs on that node. Do not assume that.30is the newest node on the installed system. -
Predict version arithmetic and lookup. Starting at libtool
4:2:1, calculate names after a bug fix, a compatible function addition, and a function removal. Separately, link a client against an unversionedparse1library, then run it against the two-version library. Does the reference acquire a version retroactively? Which implementation is selected, and doesLD_DEBUG=bindingsshow a bracketed version? -
Delete compatibility in two different ways. Version 3 keeps only the new implementation. Compare these scripts:
/* parse3a.map: remove VERS_1 entirely */VERS_2 { global: parse_num; parse_num_base; local: *;};/* parse3b.map: keep the VERS_1 node but remove its implementation */VERS_1 { local: *;};VERS_2 { global: parse_num; parse_num_base;} VERS_1;Run the old versioned client. When does each failure occur, and what changes with LD_BIND_NOW=1? Restore a working old-client path.
Answers
1. Count requirements, not definitions
These are installed-system snapshots, so package changes alter the counts:
== Naive count: 1317 files 12 GLIBC_2.2.5 1 GLIBC_2.3.2 3 GLIBC_2.3.4 51 GLIBC_2.4 4 GLIBC_2.6 2 GLIBC_2.7 1 GLIBC_2.8 69 GLIBC_2.14 1 GLIBC_2.15 5 GLIBC_2.17 1 GLIBC_2.25 3 GLIBC_2.27 2 GLIBC_2.28 12 GLIBC_2.29 1 GLIBC_2.31 2 GLIBC_2.32 15 GLIBC_2.33 370 GLIBC_2.34 3 GLIBC_2.35 2 GLIBC_2.36 695 GLIBC_2.38 6 GLIBC_2.39 2 GLIBC_2.41 21 GLIBC_2.42 33 GLIBC_2.43== Count only *UND*: 1316 files 15 GLIBC_2.2.5 1 GLIBC_2.3.2 2 GLIBC_2.3.4 52 GLIBC_2.4 4 GLIBC_2.6 1 GLIBC_2.7 1 GLIBC_2.8 69 GLIBC_2.14 1 GLIBC_2.15 5 GLIBC_2.17 1 GLIBC_2.25 3 GLIBC_2.27 2 GLIBC_2.28 12 GLIBC_2.29 2 GLIBC_2.32 15 GLIBC_2.33 371 GLIBC_2.34 3 GLIBC_2.35 2 GLIBC_2.36 695 GLIBC_2.38 6 GLIBC_2.39 2 GLIBC_2.41 21 GLIBC_2.42 30 GLIBC_2.43== Files classified differently by the two methods/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 GLIBC_2.35 -/usr/lib/x86_64-linux-gnu/libc.so.6 GLIBC_2.43 GLIBC_2.35/usr/lib/x86_64-linux-gnu/libc_malloc_debug.so.0 GLIBC_2.43 GLIBC_2.34/usr/lib/x86_64-linux-gnu/libdl.so.2 GLIBC_2.3.4 GLIBC_2.2.5/usr/lib/x86_64-linux-gnu/libm.so.6 GLIBC_2.43 GLIBC_2.4/usr/lib/x86_64-linux-gnu/libpthread.so.0 GLIBC_2.31 GLIBC_2.2.5/usr/lib/x86_64-linux-gnu/librt.so.1 GLIBC_2.7 GLIBC_2.2.5Seven files differ between the two counting methods; they belong to glibc's loader/libraries and define version nodes themselves. The loader drops out when counting only undefined requirements; libc can still require loader interfaces.
In this snapshot 2.38 is the most common maximum, often from C23 entry points such as __isoc23_strtol, __isoc23_sscanf, and __isoc23_strtoul, or newer string functions. The 371 files whose maximum is 2.34 commonly depend on startup, threading, or dynamic-loading interfaces:
== Files with maximum requirement GLIBC_2.34, counted by referenced 2.34 symbol 322 bin __libc_start_main 20 lib dlsym 15 lib pthread_create 13 lib pthread_join 12 lib pthread_setspecific 12 lib pthread_key_create 12 lib dlopen 10 lib dlerror 9 lib dlclose 8 lib dladdrOf those, 322 executables reference __libc_start_main@GLIBC_2.34. The two files with a maximum of exactly 2.36 were systemd-run through open_tree, and libXdmcp.so.6.0.0 through arc4random_buf.
The installed libstdc++ has 36 GLIBCXX_* and 19 CXXABI_* nodes. The parent of .3.4.30 is .3.4.29; nine default exports use .30, including these four time_get instances:
985: 000000000018b9f0 5326 FUNC WEAK DEFAULT 14 _ZNKSt8time_getIwSt19istreambuf_iteratorIwSt11char_traitsIwEEE21_M_extract_via_formatES3_S3_RSt8ios_baseRSt12_Ios_IostateP2tmPKwRSt16__time_get_state@@GLIBCXX_3.4.30 3280: 000000000012f290 10095 FUNC WEAK DEFAULT 14 _ZNKSt7__cxx118time_getIwSt19istreambuf_iteratorIwSt11char_traitsIwEEE21_M_extract_via_formatES4_S4_RSt8ios_baseRSt12_Ios_IostateP2tmPKwRSt16__time_get_state@@GLIBCXX_3.4.30 3425: 000000000015bfe0 5612 FUNC WEAK DEFAULT 14 _ZNKSt8time_getIcSt19istreambuf_iteratorIcSt11char_traitsIcEEE21_M_extract_via_formatES3_S3_RSt8ios_baseRSt12_Ios_IostateP2tmPKcRSt16__time_get_state@@GLIBCXX_3.4.30 5769: 0000000000120bc0 11768 FUNC WEAK DEFAULT 14 _ZNKSt7__cxx118time_getIcSt19istreambuf_iteratorIcSt11char_traitsIcEEE21_M_extract_via_formatES4_S4_RSt8ios_baseRSt12_Ios_IostateP2tmPKcRSt16__time_get_state@@GLIBCXX_3.4.30The wchar_t pair and char pair differ by the __cxx11 namespace encoding. Sharing a version node does not imply sharing a name or object layout.
2. Compatibility intervals and unversioned clients
Initially 4:2:1 means SONAME .3 and file .3.1.2. A fix yields 4:3:1; an addition yields 5:0:2; a removal yields 6:0:0:
4:2:1: libfoo.so.3.1.2 soname=libfoo.so.34:3:1: libfoo.so.3.1.3 soname=libfoo.so.35:0:2: libfoo.so.3.2.0 soname=libfoo.so.36:0:0: libfoo.so.6.0.0 soname=libfoo.so.6The SONAME jumps from 3 to 6 because age resets. It counts an interface boundary, not consecutive package majors.
The unversioned client behaves as follows:
$ gcc -O1 -fPIC -shared -Wl,-soname,libparse.so.1 parse1.c -o v0/libparse.so.1$ gcc -O1 app.c v0/libparse.so.1 -o app_unver$ readelf --dyn-syms -W app_unver | grep parse_num 6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND parse_num$ readelf -V app_unver | grep -A1 'File: libparse' || echo "(no version requirement for libparse.so.1)"(no version requirement for libparse.so.1)$ LD_LIBRARY_PATH=v2 ./app_unverparse_num("42") = 42, parse_num("abc") = 0$ LD_DEBUG=bindings LD_LIBRARY_PATH=v2 ./app_unver 2>&1 | grep "symbol \`parse_num" 521: binding file ./app_unver [0] to v2/libparse.so.1 [0]: normal symbol `parse_num'It has no stored version requirement for this library and does not acquire one by changing the runtime file. In the tested glibc compatibility lookup, the ordinary old unversioned reference selects the earliest version, producing zero. Its binding trace lacks brackets. dlsym follows a different selection rule for the public/default interface. This is a loader compatibility rule, not permission to delete published version nodes.
3. Missing version versus missing symbol within a version
$ LD_LIBRARY_PATH=v3a ./app_old./app_old: v3a/libparse.so.1: version `VERS_1' not found (required by ./app_old)$ echo $?1$ LD_LIBRARY_PATH=v3b ./app_old./app_old: symbol lookup error: ./app_old: undefined symbol: parse_num, version VERS_1$ echo $?127$ readelf --dyn-syms -W v3b/libparse.so.1 | grep -E 'parse|VERS' 6: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_1 7: 0000000000001209 75 FUNC GLOBAL DEFAULT 14 parse_num_base@@VERS_2 8: 0000000000000000 0 OBJECT GLOBAL DEFAULT ABS VERS_2 9: 00000000000011b9 80 FUNC GLOBAL DEFAULT 14 parse_num@@VERS_2Deleting VERS_1 fails the startup version check before main. Retaining an empty node lets that coarse check pass, but the required parse_num@VERS_1 is still missing.
Build the timing probe with explicit lazy binding. A toolchain may enable immediate binding by default, so omitting flags does not establish lazy behavior. app_start.c flushes stdout after its first line, preventing buffering from hiding whether main started.
gcc -O1 -Wl,-z,lazy app_start.c v1/libparse.so.1 -o app_startreadelf -d app_start | grep -E 'FLAGS|BIND_NOW'This build has a PIE flag but no BIND_NOW or NOW. The following environment-variable comparison then requests immediate binding.
$ LD_LIBRARY_PATH=v3b ./app_startmain started./app_start: symbol lookup error: ./app_start: undefined symbol: parse_num, version VERS_1$ LD_BIND_NOW=1 LD_LIBRARY_PATH=v3b ./app_start./app_start: symbol lookup error: ./app_start: undefined symbol: parse_num, version VERS_1$ LD_LIBRARY_PATH=v3a ./app_start./app_start: v3a/libparse.so.1: version `VERS_1' not found (required by ./app_start)With lazy binding, the latter fails on the first call, after main started. LD_BIND_NOW=1 moves it to startup. The missing-node failure is already at startup in either mode.
Restore the compatibility implementation, its symbol-version association, and the version-2 script:
$ gcc -O1 -fPIC -shared -Wl,-soname,libparse.so.1 -Wl,--version-script=parse2.map parse2.c -o v3fix/libparse.so.1$ LD_LIBRARY_PATH=v3fix ./app_oldparse_num("42") = 42, parse_num("abc") = 0If the old implementation is intentionally retired, a new SONAME marks the incompatible generation while old programs keep access to the old library.
References
Ulrich Drepper's How To Write Shared Libraries, chapter 3, develops the lifetime compatibility argument. The GNU symbol-versioning documentation, Debian shared-library policy, and libstdc++ dual-ABI manual describe the corresponding mechanisms and conventions.
Appendix: terms and tools
-
UB, undefined behavior, means the language standard imposes no requirements on the execution in question. A binary's observed output may be explained without becoming a portable promise. An
undefined referencelinker error is a different concept. C11 draft. ↩ -
ABI, Application Binary Interface, specifies how compiled components cooperate, including calling conventions, data layout, and object-format rules. It governs the machine-level boundary rather than only the source API. System V ABI. ↩
-
DWARF is a debugging-information format describing source lines, types, variables, and machine locations. It can be carried in ELF, but is not the ELF symbol table. Specification. ↩
-
ODR — The One Definition Rule constrains definitions of C++ entities across translation units. A linker choosing one same-named instance does not prove that the source definitions satisfy the rule. C++ draft. ↩