The World of Linkers/ Theory/ 17 articles
31 min readPublic

[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_H
struct 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_H
void 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 ./app
sizeof(struct point) = 8
sum0 = 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 ./app
sizeof(struct point) = 8
sum0 = 13, sum1 = 30

The 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_new
sizeof(struct point) = 12
sum0 = 3, sum1 = 30

Conflicting structure layouts

The 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_sum

The 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:

PartExamples of assumptions frozen into a client
Calling conventionArgument registers, stack slots, return values, preserved registers
Data representationStructure size/alignment, field offsets, enum values
Symbol identityExported names and required versions
Documented semanticsReturn 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”:

ChangeCompatibility consequence
Add an exported functionUsually preserves old clients; new clients still cannot use an older library lacking it
Remove an exportBreaks clients that need it
Change a C function's argumentsCan bind successfully but use the wrong calling convention
Append a structure fieldDepends on who allocates, copies, sizes, or embeds the object
Insert an enum valueCan renumber old constants unless their values are fixed
Append an enum valueAlso requires representation and protocol assumptions to remain valid
Change an inline function or macroOld machine code retains the old implementation
Change a C++ default argumentExisting callers retain the old compiled-in default
Change virtual functionsMay alter slots, derived-class contracts, and object layout
Change a C++ parameter typeOften 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_app
GREEN -> green
$ LD_LIBRARY_PATH=v2 ./color_app
GREEN -> yellow

The 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 8
static 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_app
apple -> slot 2 -> 7
$ LD_LIBRARY_PATH=v2 ./tab_app
apple -> slot 2 -> 0
$ nm -D tab_app | grep tab_
U tab_put
U tab_slot

The 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.h
struct 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_app
area = 9
$ LD_LIBRARY_PATH=v2 ./shape_app
area = 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 order
0x3dd8 -> _ZNK5Shape4areaEv
0x3de0 -> _ZN5ShapeD1Ev
0x3de8 -> _ZN5ShapeD0Ev
# v2
0x3dd0 -> _ZNK5Shape9perimeterEv
0x3dd8 -> _ZNK5Shape4areaEv
0x3de0 -> _ZN5ShapeD1Ev
0x3de8 -> _ZN5ShapeD0Ev

Under 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_leak
area = 9, operator new 1, operator delete 1
$ LD_LIBRARY_PATH=v2 ./shape_leak
area = 12, operator new 1, operator delete 0

Keeping 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.

NameWhere it existsMain role
libpoint.so.1.0.0Actual file on diskStores one implementation
libpoint.so.1Library's DT_SONAME; usually also a disk linkNames the client's runtime dependency
libpoint.soUsually a development link on diskSelects 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 lib
lib: (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 lib
lrwxr-xr-x libpoint.so -> libpoint.so.2
lrwxr-xr-x libpoint.so.1 -> libpoint.so.1.0.0
-rwxr-xr-x libpoint.so.1.0.0
lrwxr-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; ./app2
sizeof(struct point) = 8
sum0 = 3, sum1 = 30
sizeof(struct point) = 12
sum0 = 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.2

Both 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.so
libdemo.so.1
libdemo.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.1

A 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-dev
dpkg -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.

RecordQuestion answeredHow its index is used
.dynsymSymbol name and defined/undefined statusSymbol ordinal j within this file
.gnu.versionVersion associated with each dynamic symbolTwo-byte entry j corresponds to .dynsym[j]
.gnu.version_dVersion nodes defined by this fileFind the definition record with the matching vd_ndx and name
.gnu.version_rVersions this file requests from other librariesFind the corresponding requirement number, name, and library

Three index layers in ELF symbol versioning

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_1

VERS_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: 2

An 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-old
explicit old: 0

parse_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_old
parse_num("42") = 42, parse_num("abc") = 0
$ LD_LIBRARY_PATH=v2 ./app_new
parse_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.5
0x0c22f0 FUNC memcpy@GLIBC_2.2.5
0x0b8c80 IFUNC memcpy@@GLIBC_2.14
0x097fe0 FUNC fmemopen@@GLIBC_2.22
0x0983f0 FUNC fmemopen@GLIBC_2.2.5
0x0a4050 FUNC pthread_create@GLIBC_2.2.5
0x0a4050 FUNC pthread_create@@GLIBC_2.34
0x02a690 FUNC __libc_start_main@GLIBC_2.2.5
0x02a690 FUNC __libc_start_main@@GLIBC_2.34

The 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");
#endif
int 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_old
fmemopen(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_main
GLIBC_2.34 pthread_create
GLIBC_2.34 pthread_join

Pinning 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" ./th

The 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_el7

The 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 functions
Variables 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=4

It 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=4

The 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 functions
Variables 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=12

Twelve 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=0

See 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.0
WARNING: incompatible build option detected: -O1 (required -Og for better analysis)
$ abi-dumper v2/libpoint.so.1 -o v2.dump -lver 1.1
WARNING: incompatible build option detected: -O1 (required -Og for better analysis)
$ abi-compliance-checker -l libpoint -old v1.dump -new v2.dump -report-path report.html
Preparing, please wait ...
Comparing ABIs ...
Comparing APIs ...
Creating compatibility report ...
Binary compatibility: 100%
Source compatibility: 100%
Total binary compatibility problems: 0, warnings: 2
Total source compatibility problems: 0, warnings: 1
Report: report.html

For 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: 0
Total source compatibility problems: 1, warnings: 0
Report: report-strict.html
$ echo $?
1

Rebuilding 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 V2
int fmt_pad(int n, char c) { return c == ' ' ? n : 0; }
#define PAD(n) fmt_pad(n, ' ')
#else
int 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
#endif
FMT_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 function
Variables 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=4

Default-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=0

A version script can enforce the export list too:

$ cat fmt.map
FMT_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=0

Visibility 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.

  1. Inspect the installed ABI baseline. Scan non-symlink ELF files in /usr/bin and /usr/lib/x86_64-linux-gnu. Count each file by its highest required GLIBC_* version, preserving full dotted versions such as 2.2.5. Why does simply grepping all version strings miscount providers as consumers? Which references explain the common maxima? Then inspect libstdc++'s GLIBCXX_* and CXXABI_* nodes, the parent of GLIBCXX_3.4.30, and old/new time_get symbol pairs on that node. Do not assume that .30 is the newest node on the installed system.

  2. 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 unversioned parse1 library, then run it against the two-version library. Does the reference acquire a version retroactively? Which implementation is selected, and does LD_DEBUG=bindings show a bracketed version?

  3. 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.5

Seven 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 dladdr

Of 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.30

The 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.3
4:3:1: libfoo.so.3.1.3 soname=libfoo.so.3
5:0:2: libfoo.so.3.2.0 soname=libfoo.so.3
6:0:0: libfoo.so.6.0.0 soname=libfoo.so.6

The 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_unver
parse_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_2

Deleting 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_start
readelf -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_start
main 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_old
parse_num("42") = 42, parse_num("abc") = 0

If 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

  1. 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 reference linker error is a different concept. C11 draft. ↩

  2. 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. ↩

  3. 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. ↩

  4. 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. ↩