Symbol Versioning

Symbol versioning is the ELF mechanism that lets a single shared library export several mutually-incompatible definitions of the same symbol name at once, tagging each with a version label like foo@LIBFOO_1.0 versus foo@@LIBFOO_2.0. When a program is linked, the linker records not just that it needs foo but which version node of foo it was built against; at run time the dynamic linker binds the reference to exactly that versioned definition. The payoff is enormous: a library can change the behaviour or signature of an existing function — normally an ABI break requiring a new SONAME and a forced recompile of the world — while keeping old binaries bound to the old definition, so both the twenty-year-old binary and today’s build run correctly against one libfoo.so.1. This note explains the argument for the mechanism and the mechanism itself: why the two obvious alternatives (change it in place; bump the SONAME) fail, each demonstrated on a real build; the three ELF sections down to the single bit that separates @ from @@; version scripts and every form of the .symver directive; and the exact two-stage matching ld.so performs. The concrete glibc application — the GLIBC_2.xx node set, the famous memcpy split, the portability wall — lives in Versioned Symbols in glibc. One point of orientation before starting: despite appearances, none of this is in the System V gABI; it is a GNU/Solaris extension standardized only by the Linux Standard Base.

What This Note Owns

Versioned Symbols in glibc owns the concrete GLIBC_2.xx node set and the specific historical incidents. Symbol Resolution and Lookup Scope owns the lookup algorithm — scope order, first-definition-wins, visibility. The ELF Symbol Table owns Elf64_Sym. This note owns the generic versioning mechanism and the argument for it: why an soname bump is not an acceptable substitute, the byte layout of the three .gnu.version* sections, what @ and @@ mean down to the single bit that distinguishes them, the version-script and .symver syntax with every modifier, the exact matching rules the loader applies — including the four reference/definition combinations the LSB enumerates — and the several ways versioning fails.

Not Actually in the gABI

One thing worth pinning before going further, because it explains why the authoritative text is harder to find than it should be: symbol versioning is not part of the base System V gABI. Fetching the gABI’s symbol-table chapter and searching it for “version”, “verdef”, “verneed” or “gnu.version” returns zero hits (gABI ch.4, Symbol Table); the gABI’s dynamic-linking chapter likewise describes .dynsym, relocation and DT_* tags with no versioning machinery at all. The mechanism originated at Sun for Solaris, was adopted and extended by GNU, and is standardized — as far as it is standardized anywhere — by the Linux Standard Base, §10.7. The DT_VERSYM/DT_VERDEF/DT_VERNEED tags live in the DT_LOOS operating-system-reserved range precisely because the base ABI left that space for extensions like this one. Treat the LSB plus the binutils and glibc documentation as the specification; there is no higher authority to appeal to.

Why It Exists: the Alternative Is a Flag Day

A shared library that has shipped is a contract. Once libfoo.so.1 is on a million machines and a million binaries are linked against it, the set of things you may do to foo shrinks to almost nothing — and “almost nothing” is not sustainable across twenty years of bug fixes, standards changes and security work. The question symbol versioning answers is precise: how do you change the behaviour of an existing function without breaking any binary already compiled against it? The three possible answers are worth building and running, because the difference is not theoretical.

Answer 1: change it in place, and hope. (Silent wrong answers.)

Here is an unversioned library whose foo returns x + 1, an application built against it, and then the library upgraded in place to a foo that returns x + 1000:

$ ./app_un                        # with the original library
foo(10) = 11  (this binary was built when foo(10) was 11)
 
$ ln -sf libun.so.1.new libun.so.1     # upgrade the library, same soname
$ ./app_un                        # the SAME binary, not recompiled
foo(10) = 1010  (this binary was built when foo(10) was 11)

No error. No warning. No non-zero exit. The program computes a different, wrong answer and carries on. This is the failure mode that makes ABI breaks so expensive: they do not announce themselves. The binary carries no record of what it expected, so nothing in the system is in a position to notice the mismatch — readelf -V on it shows no requirement on libun.so.1 at all, only the version requirements glibc itself imposed.

Answer 2: bump the soname. (Two libraries in one process, sharing one variable.)

The traditional ELF answer is to make it a different library: libfoo.so.1 → libfoo.so.2. Old binaries keep their old file, new binaries get the new one, nothing is silently wrong. What that costs is a flag day — every dependent package must be rebuilt and every dependency chain migrated in lockstep — and, if the migration is not complete, a process can end up with both files mapped at once. Drepper’s warning is that the two copies “do not know of each other, [and] the results can be catastrophic” (Drepper §3.3). That is measurable. Given libsplit.so.1 and libsplit.so.2, each with its own int shared_counter, an application linked against .so.1 and a middle library linked against .so.2:

$ gcc -o app_split s.c -L. -l:libsplit.so.1 -l:libmid.so.1
/usr/bin/ld.bfd: warning: libsplit.so.2, needed by ./libmid.so.1,
                 may conflict with libsplit.so.1
 
$ ldd ./app_split | grep libsplit
	libsplit.so.1 => .../libsplit.so.1 (0x00007f39388b3000)
	libsplit.so.2 => .../libsplit.so.2 (0x00007f3938691000)   <- both mapped
 
$ ./app_split
app calls the OLD soname:
  libsplit.so.1: shared_counter now 1 @ 0x7f6a5f684014
app calls through libmid (NEW soname):
  libsplit.so.2: shared_counter now 2 @ 0x7f6a5f684014        <- SAME address!
app calls the OLD soname again:
  libsplit.so.1: shared_counter now 3 @ 0x7f6a5f684014

Both libraries are loaded, each defines its own shared_counter — and both are using the same four bytes. LD_DEBUG=bindings says why:

binding file .../libsplit.so.2 [0] to .../libsplit.so.1 [0]: normal symbol `shared_counter'

libsplit.so.2’s reference to its own global was interposed by libsplit.so.1’s definition, because ELF’s dynamic symbol namespace is flat and first-definition-wins does not care which file a symbol came from (see Symbol Resolution and Lookup Scope). The soname bump separated the files and did nothing whatever to separate the symbols. In this toy the shared counter merely produces a confusing sequence; when the two versions disagree about a global’s size or layout — int in v1, long long in v2, which is exactly the kind of change an soname bump is supposed to license — one library writes eight bytes into the other’s four-byte object.

Uncertain

Verify: whether the size mismatch reliably corrupts adjacent data. Reason: in the size-mismatch variant built here (int shared_counter in v1, long long in v2), the v2 code’s 8-byte write into v1’s 4-byte object did not damage the neighbouring guard variable — readelf --dyn-syms shows guard at 0x3010 and shared_counter at 0x3018, i.e. guard sits before the counter and shared_counter starts a section, so the overrun landed in alignment padding. The hazard is real but the observable outcome depends entirely on the linker’s data layout, and I did not observe corruption. To resolve: force adjacency with an explicit section and ordering, or check with a sanitizer that traps out-of-bounds global writes.

Answer 3: symbol versioning. (Same file, both answers, no rebuild.)

Now the same change with versioning. libfoo.so.1 keeps its soname forever; the old foo becomes a compatibility symbol and the new one becomes the default:

int foo_v1_impl(int x) { return x + 1;   }   /* the OLD behaviour, preserved */
int foo_v2_impl(int x) { return x + 1000; }  /* the NEW, incompatible behaviour */
 
__asm__(".symver foo_v1_impl, foo@LIBFOO_1.0");    /* single @  : compat only  */
__asm__(".symver foo_v2_impl, foo@@LIBFOO_2.0");   /* double @@ : new default  */

and then, with one library file on disk and no recompilation of the old binary:

$ ./app_old     # built in the LIBFOO_1.0 era, not touched since
foo(10) = 11
$ ./app_new     # built today against the same libfoo.so.1
foo(10) = 1010
$ LD_DEBUG=bindings ./app_old 2>&1 | grep "symbol \`foo'"
binding file ./app_old [0] to .../libfoo.so.1 [0]: normal symbol `foo' [LIBFOO_1.0]
$ LD_DEBUG=bindings ./app_new 2>&1 | grep "symbol \`foo'"
binding file ./app_new [0] to .../libfoo.so.1 [0]: normal symbol `foo' [LIBFOO_2.0]

Two binaries, one library file, two different implementations of one symbol name, each getting the one it was built against, decided by data frozen into each binary at link time. No flag day, no duplicated library, no shared-state hazard, and no silent wrong answer.

Unversioned, changed in placeSONAME bumpSymbol versioning
Old binary’s result after the changesilently wrong (1010, expected 11)correct — keeps the old filecorrect (11)
Old binary must be rebuilt?no (but is now broken)no, if the old .so is kept foreverno
Whole dependency graph must be rebuilt?noyes — the flag dayno
Files shipped for one library12 (and growing, one per break)1
Both versions coexisting in one processimpossiblepossible and hazardous — globals interpose across the split (measured above)routine and safe; each object binds what it recorded
Failure signal when the requirement cannot be metnonelibfoo.so.2: cannot open shared object fileversion 'LIBFOO_2.0' not found (required by ./app_new)
Cost to the library authorzero until it breaksa coordinated migrationone map-file edit and a .symver pair per change

The limiting case: for libc, answer 2 does not even work

For most libraries the soname bump is merely expensive. For the C library it is not available at all, and that is the strongest form of the argument for versioning. Carlos O’Donell tried it and reported the result on libc-alpha in July 2014, in the thread about the s390 ABI change:

I don’t think that bumping the glibc SO name would help either since it’s not possible to load libc.so twice given the implementation. I’m pretty sure this breaks TLS usage in the second loaded glibc. I just tried bumping the SO name for x86-64 to test this and it’s crashing at startup when an application needs both libcs. (libc-alpha, 2014-07-15)

The reason is structural: two copies of libc in one process would each want to own the thread pointer, the TLS layout, malloc’s arena state and the process’s global constructors. The interposition effect demonstrated with shared_counter above is the toy version of exactly that failure, and for libc it is not survivable. His conclusion in the same thread:

Therefore I think for libc we can’t SONAME bump and we should instead be aiming to detect the incompatible ABI change and simply refuse to run the application. (libc-alpha, 2014-07-15)

and the project’s standing position, recorded on the glibc wiki’s own “Potential projects for glibc 3.0 and beyond” page: “Note: We do not plan to change the soname.” (glibc wiki).

So libc.so.6 is permanent, and every incompatible change glibc has made since that number was fixed has gone through the mechanism in this note. glibc’s own FAQ states the purpose in one paragraph:

Symbol versioning solves problems that are related to interface changes. One version of an interface might have been introduced in a previous version of the GNU C library but the interface or the semantics of the function has been changed in the meantime. For binary compatibility with the old library, a newer library needs to still have the old interface for old programs. On the other hand, new programs should use the new interface. Symbol versioning is the solution for this problem. (glibc FAQ)

and is equally blunt about opting out:

We don’t advise building without symbol versioning, since you lose binary compatibility — forever! The binary compatibility you lose is not only against the previous version of the GNU libc but also against all future versions. (ibid.)

The concrete GLIBC_2.xx node set and the individual incidents are in Versioned Symbols in glibc; the shapes those incidents take are tabulated later in this note.

Mental Model

Without versioning, a shared object’s public face is a flat namespace: one name, one definition, and the only way to make an incompatible change is to bump the SONAME (libfoo.so.1 → libfoo.so.2), which duplicates the entire library and forces every dependent to recompile. Drepper’s How To Write Shared Libraries walks through why this coarse “filename versioning” is wasteful and dangerous — two SONAME-versioned copies of a library loaded into one process “do not know of each other, [and] the results can be catastrophic” (Drepper §3.3). Symbol versioning replaces the flat namespace with a versioned one: each exported name may carry a version label, and one library file holds a small tree of version nodes, each node grouping the symbols that were current at some release.

flowchart LR
  subgraph SO["one libfoo.so.1 file"]
    v1["node LIBFOO_1.0<br/>foo@LIBFOO_1.0  (old impl)"]
    v2["node LIBFOO_2.0  → parent LIBFOO_1.0<br/>foo@@LIBFOO_2.0 (new default)<br/>bar@@LIBFOO_2.0"]
  end
  old["old app<br/>linked in 2010"] -->|verneed: foo@LIBFOO_1.0| v1
  new["new app<br/>linked today"] -->|verneed: foo@LIBFOO_2.0| v2

What it shows: one library file exports two definitions of foo under two version nodes; an old binary recorded a need for foo@LIBFOO_1.0 and binds to the old code, a new binary recorded foo@LIBFOO_2.0 and binds to the new code — from the same .so. The insight: the version each binary needs is frozen into it at link time (in the .gnu.version_r “verneed” data), so the run-time binding is deterministic and no recompile is ever forced by a compatible change.

The Three ELF Sections

Symbol versioning adds no fields to Elf64_Sym. It is implemented entirely as three side tables layered on top of the ordinary dynamic symbol table .dynsym (see The ELF Symbol Table), specified in the Linux Standard Base’s symbol-versioning chapter, which codifies the original Sun/GNU design (LSB §10.7):

Sectionsh_typeCounted byPresent inHolds
.gnu.versionSHT_GNU_versym(same as .dynsym)any object with versioned dynamic symbolsone 16-bit index per .dynsym entry
.gnu.version_dSHT_GNU_verdefDT_VERDEFNUMan object that defines version nodesthe node names, indices and parent links this object provides
.gnu.version_rSHT_GNU_verneedDT_VERNEEDNUMan object that uses versioned symbolsper dependency file, the node names it requires

A common simplification that is wrong

It is often said that “libraries carry verdef, executables carry verneed.” Objects routinely carry both. The demonstration library built for this note defines LIBFOO_1.0 (so it has a verdef) and calls puts from libc (so it has a verneed naming GLIBC_2.2.5) — readelf -V on it prints all three sections. The correct statement is that verdef describes what an object provides and verneed what it requires, and any shared library that is not entirely self-contained does both.

.gnu.version (Versym) is a parallel array of Elf64_Half with exactly one entry per .dynsym entry. Two values are reserved (LSB §10.7.2): 0 means *local* — “the symbol is local, not available outside the object” — and 1 means *global*, “defined in this object and is globally available”, i.e. present but unversioned. glibc’s elf.h names these VER_NDX_LOCAL and VER_NDX_GLOBAL. Every other value is an index into this object’s own verdef (for definitions) or into its verneed auxiliary entries (for references). The spec is careful that “the value itself is not the version associated with the symbol. The string identified by the value defines the version” — indices are file-local, and two objects using index 2 are almost certainly talking about different version nodes.

The 16th bit is separate from the index. Bit 15 (0x8000) is the hidden flag, and the LSB gives it its meaning: “If the high order bit (bit number 15) of the version symbol is set, the object cannot be used and the static linker shall ignore the symbol’s presence in the object.” This single bit is the entire difference between @ and @@, and readelf renders it as a trailing h:

$ readelf -V -W libfoo.so.1        # the two-version library
Version symbols section '.gnu.version' contains 11 entries:
  000:   0 (*local*)       0 (*local*)       0 (*local*)       0 (*local*)
  004:   4 (GLIBC_2.2.5)   2 (LIBFOO_1.0)    3 (LIBFOO_2.0)    2h(LIBFOO_1.0)
  008:   3 (LIBFOO_2.0)    2 (LIBFOO_1.0)    3 (LIBFOO_2.0)

Entry 6 is foo@@LIBFOO_2.0 and carries a plain 3. Entry 7 is foo@LIBFOO_1.0 and carries 2h — index 2 with 0x8000 set. That h is @ versus @@, in the file.

.gnu.version_d (verdef) is a chain of Elf64_Verdef records, each followed by vd_cnt Elf64_Verdaux name records (glibc 2.43 elf/elf.h):

typedef struct {
  Elf64_Half  vd_version;  /* revision; must be 1 */
  Elf64_Half  vd_flags;    /* VER_FLG_BASE (0x1) | VER_FLG_WEAK (0x2) */
  Elf64_Half  vd_ndx;      /* the index .gnu.version entries carry */
  Elf64_Half  vd_cnt;      /* number of verdaux records that follow */
  Elf64_Word  vd_hash;     /* ELF hash of the node name -- the fast path */
  Elf64_Word  vd_aux;      /* byte offset to the first Elf64_Verdaux   */
  Elf64_Word  vd_next;     /* byte offset to the next Verdef, 0 = end  */
} Elf64_Verdef;
 
typedef struct {
  Elf64_Word  vda_name;    /* .dynstr offset of the name */
  Elf64_Word  vda_next;    /* byte offset to the next Verdaux, 0 = end */
} Elf64_Verdaux;

The first Verdaux of a record is the node’s own name; any further ones are its parents. That is why a node with a predecessor has Cnt: 2:

Version definition section '.gnu.version_d' contains 3 entries:
  000000: Rev: 1  Flags: BASE  Index: 1  Cnt: 1  Name: libfoo.so.1
  0x001c: Rev: 1  Flags: none  Index: 2  Cnt: 1  Name: LIBFOO_1.0
  0x0038: Rev: 1  Flags: none  Index: 3  Cnt: 2  Name: LIBFOO_2.0
  0x0054: Parent 1: LIBFOO_1.0

The BASE entry (vd_flags = VER_FLG_BASE) takes index 1 — the same reserved “global, unversioned” index — and its name is the library’s own SONAME. It is a placeholder for “symbols this object exports without a version”, not a node anything can request. Note also that vd_hash exists purely so the loader can reject a non-matching node with an integer compare before touching a string; the hash is the classic ELF hash function, not GNU hash.

.gnu.version_r (verneed) mirrors the structure, grouped per dependency file:

typedef struct {
  Elf64_Half  vn_version;  /* must be 1 */
  Elf64_Half  vn_cnt;      /* number of Vernaux records for this file */
  Elf64_Word  vn_file;     /* .dynstr offset of the dependency's SONAME */
  Elf64_Word  vn_aux;      /* byte offset to the first Elf64_Vernaux */
  Elf64_Word  vn_next;     /* byte offset to the next Verneed, 0 = end */
} Elf64_Verneed;
 
typedef struct {
  Elf64_Word  vna_hash;    /* ELF hash of the required node name */
  Elf64_Half  vna_flags;   /* VER_FLG_WEAK makes a missing node non-fatal */
  Elf64_Half  vna_other;   /* the index .gnu.version entries carry */
  Elf64_Word  vna_name;    /* .dynstr offset of the node name */
  Elf64_Word  vna_next;
} Elf64_Vernaux;

glibc's own header mislabels a field

elf/elf.h at glibc 2.43 comments Elf64_Vernaux.vna_other as /* Unused */. It is not unused: the LSB defines it as the “object file version identifier used in the .gnu.version symbol version array”, and readelf -V prints it as the trailing Version: number. In the dump below, LIBFOO_1.0 has Version: 2, and index 2 is exactly what .gnu.version entry 1 carries for the undefined foo. Treat the header comment as stale and the LSB as authoritative. The LSB also states that bit 15 of vna_other carries the same hidden meaning as in a versym entry.

$ readelf -V -W app_old
Version symbols section '.gnu.version' contains 6 entries:
  000:   0 (*local*)       2 (LIBFOO_1.0)    3 (GLIBC_2.34)    4 (GLIBC_2.2.5)
  004:   0 (*local*)       2 (LIBFOO_1.0)
 
Version needs section '.gnu.version_r' contains 2 entries:
  000000: Version: 1  File: libc.so.6  Cnt: 2
  0x0010:   Name: GLIBC_2.2.5  Flags: none  Version: 4
  0x0020:   Name: GLIBC_2.34  Flags: none  Version: 3
  0x0030: Version: 1  File: libfoo.so.1  Cnt: 1
  0x0040:   Name: LIBFOO_1.0  Flags: none  Version: 2

Read across: .dynsym[1] is the undefined foo; .gnu.version[1] is 2; the verneed aux entry with vna_other == 2 is LIBFOO_1.0 under file libfoo.so.1. That chain — symbol index → versym index → vernaux → node name → dependency file — is the entire “frozen-in requirement.” It is written once at link time and read once per reference at run time, and it is what makes the binding deterministic years later.

flowchart LR
    subgraph APP["app_old (the consumer)"]
        direction TB
        DS["<b>.dynsym</b><br/>[1] foo &nbsp;(UND)"]
        VS["<b>.gnu.version</b><br/>[1] = 2"]
        VR["<b>.gnu.version_r</b><br/>Verneed vn_file = 'libfoo.so.1'<br/>└ Vernaux vna_other = 2<br/>&nbsp;&nbsp;&nbsp;vna_name = 'LIBFOO_1.0'<br/>&nbsp;&nbsp;&nbsp;vna_hash = ELF hash"]
        DS -- "same index" --> VS
        VS -- "index 2 selects" --> VR
    end
    subgraph LIB["libfoo.so.1 (the provider)"]
        direction TB
        DS2["<b>.dynsym</b><br/>[6] foo = 0x393<br/>[7] foo = 0x38f"]
        VS2["<b>.gnu.version</b><br/>[6] = 3 &nbsp;(bit 15 clear)<br/>[7] = 2 | 0x8000 &nbsp;(readelf: 2h)"]
        VD["<b>.gnu.version_d</b><br/>idx 1 BASE 'libfoo.so.1'<br/>idx 2 'LIBFOO_1.0'<br/>idx 3 'LIBFOO_2.0' → parent 1.0"]
        DS2 -- "same index" --> VS2
        VS2 -- "index selects" --> VD
    end
    VR == "match on node NAME<br/>(vd_hash first, strcmp to confirm)" ==> VD
    VD -. "node 'LIBFOO_1.0' = idx 2<br/>→ .dynsym[7] = foo@LIBFOO_1.0" .-> DS2

What it shows: the two halves of one binding, and the single string that joins them. Inside each object, .dynsym and .gnu.version are index-parallel arrays; the version entry is an index into that object’s own verneed (consumer side) or verdef (provider side). Across objects, nothing is compared but the node name string — indices are file-local and mean nothing outside their own file. The insight: the only durable identifier in the whole scheme is the node name, which is why it must never change and why a node, once published, is permanent. Note also which side carries the hidden bit: 0x8000 appears on definition entry [7], marking foo@LIBFOO_1.0 as non-default, and is masked off (& 0x7fff) whenever a reference index is read.

The dynamic section wires the tables in with five tags so ld.so can find everything from PT_DYNAMIC alone: DT_VERSYM → .gnu.version; DT_VERDEF and DT_VERDEFNUM → the verdef chain and its length; DT_VERNEED and DT_VERNEEDNUM → the verneed chain and its length. All five sit in the DT_LOOS range, which is why they appear in readelf -d output with 0x6fffff..-style tag numbers.

The @ versus @@ Convention

A versioned symbol is written name@version or name@@version, and the doubling is load-bearing (binutils ld VERSION, Drepper §3.5). Mechanically the distinction is the 0x8000 bit shown above; behaviourally it is the difference between “what a new link picks up” and “what an old binary can still find”.

name@@version — defaultname@version — non-default / compat
.gnu.version entryindex, bit 15 clearindex, bit 15 set (readelf prints 2h)
How many per nameat most one per libraryany number
A new link naming name binds to it?yes — this is what -lfoo resolves foo tono — the static linker ignores it for an unqualified reference
Reachable from a new link at all?yesonly via an explicit .symver in the consumer (shown below)
dlsym(h, "name") returns it?yesno
dlvsym(h, "name", "version") returns it?yesyes
Purposethe current interfacekeeping already-linked binaries working

Drepper puts the first half precisely: “No symbol defined using @ are ever considered by the linker. These are the compatibility symbols which are considered only by the dynamic linker” (Drepper §3.5).

That sentence is true of unqualified references, but it is worth being exact about the exception, because the exception is how you deliberately pin old behaviour in new code. A consumer can name the compat symbol explicitly, and binutils documents the mechanism: “If the symbol name is not defined within the file being assembled, all references to name will be changed to name2@nodename” (binutils as, .symver). Built and run on this host:

int foo(int);
__asm__(".symver foo, foo@LIBFOO_1.0");    /* pin the OLD version in NEW code */
int main(void){ printf("explicitly-requested old foo(10) = %d\n", foo(10)); }
$ gcc -O1 -o app_pin wantold.c -L. -lfoo && ./app_pin
explicitly-requested old foo(10) = 11
$ readelf -V -W app_pin | grep -A1 libfoo
  0x0020: Version: 1  File: libfoo.so.1  Cnt: 1
  0x0030:   Name: LIBFOO_1.0  Flags: none  Version: 4

The new binary’s verneed records LIBFOO_1.0, not LIBFOO_2.0, and it gets 11. So “you cannot link against a compat symbol” should be read as “you cannot accidentally link against one” — which is the property that actually matters.

There is also a third form, @@@, which glibc’s own build system leans on heavily. Per the assembler manual: “When name is not defined within the file being assembled, it is treated as name2@nodename. When name is defined within the file being assembled, the symbol name … will be changed to name2@@nodename.” In other words @@@ means “default if I am defining this, compat-reference if I am using it” — one directive that does the right thing in both a library and its consumers, which is what lets glibc write a single macro used from either side.

Finally, the lifecycle of a compatible-but-incompatible change, stated once: the old code becomes foo@OLD_1.0 (single @, compat-only), the new code becomes foo@@NEW_2.0 (double @, the new default), and both live in the same file. Old binaries reach the old code through their frozen verneed; new links reach the new code because it is the only default.

Version Scripts and the .symver Directive

Two toolchain features drive the mechanism from opposite ends: the version script decides which names are exported and under which node, and .symver decides which internal function is behind a given versioned name.

The version script

A version script (or “map file”) is passed with -Wl,--version-script=libfoo.map. It declares version nodes and, inside each, which symbols are global: (exported) and which are local: (hidden):

LIBFOO_1.0 {
  global:
    foo;
    bar;
  local:
    *;                 /* catch-all: everything not named above is hidden */
};

LIBFOO_2.0 {
  global:
    foo;               /* a new, incompatible foo */
    baz;               /* and a brand-new function */
} LIBFOO_1.0;          /* LIBFOO_2.0 has LIBFOO_1.0 as its predecessor */

Line by line. LIBFOO_1.0 { … } declares a version node; the binutils manual is blunt that “the names of the version nodes have no specific meaning other than what they might suggest to the person reading them” (binutils VERSION) — the PACKAGE_x.y convention is entirely for humans, and nothing in the toolchain parses or orders those numbers. global: lists exported names. local: *; hides everything else. The trailing } LIBFOO_1.0; names the predecessor node.

Version-script constructWhat it doesNotes
NODE { … };declares a version nodethe name is arbitrary; nothing compares or orders node names
global:the listed symbols are exported and stamped with this nodea name listed in two nodes needs .symver to say which code is which
local:the listed symbols are removed from .dynsymthe export-control half of the feature
local: *;catch-all — hide everything not explicitly exportedDrepper: “always use the catch-all case” (§2.2.5)
} PARENT;records PARENT as a predecessor (a Verdaux after the node’s own name)Drepper: the relation “is not really important in symbol versioning” but should be written for readers and Solaris compatibility
extern "C++" { … };matches demangled C++ names, so you can write Foo::bar*without it you must list mangled names
{ global: …; }; (anonymous)export control with no version node at allthe common choice for a library that wants local: * but not versioning

The local: half is the same problem -fvisibility=hidden solves, from the other direction — see Weak Symbols and Symbol Visibility. The difference is that a version script acts at link time on the whole output, so it also covers symbols coming from static archives you did not compile.

Its effect is directly visible. The demonstration source defines three global C functions; the map file names two:

$ nm foo_v1.o | grep ' T '            # what C produced
000000000000000a T bar
0000000000000006 T foo
0000000000000000 T internal_helper
 
$ readelf --dyn-syms -W libfoo.so.1.v1 | grep -E 'foo|bar|helper'
     5: ... FUNC GLOBAL DEFAULT 4 bar@@LIBFOO_1.0
     6: ... FUNC GLOBAL DEFAULT 4 foo@@LIBFOO_1.0
                                                  # internal_helper: gone

internal_helper was GLOBAL in the object file and is absent from .dynsym in the shared library. One local: *; line turned it from part of the ABI into an implementation detail.

The .symver directive

C has no syntax for “this function is foo@@2.0 and that one is foo@1.0”, so the binding is made in inline assembly. The full ELF form is (binutils as, .symver):

.symver name , name2@nodename [ , visibility ]

where name is the internal C identifier, name2@nodename is the externally visible versioned name, and the optional third argument adjusts what happens to the internal name. binutils documents three values, and they are not merely cosmetic:

ModifierEffect on the original internal symbol
(omitted)left unchanged — the internal name stays in the symbol table
localmade a local symbol (as if .local)
hiddenvisibility set to STV_HIDDEN
removedeleted from the symbol table entirely

Measured with binutils 2.46.1, the difference between omitting the modifier and passing remove:

# without a modifier
$ nm -a libfoo.so.1.v2 | grep 'foo_v._impl'
000000000000038f t foo_v1_impl
0000000000000393 t foo_v2_impl
 
# with ", remove"
$ nm -a libfoo_rm.so | grep -c 'foo_old\|foo_new'
0
$ readelf --dyn-syms -W libfoo_rm.so | grep foo
     6: ... FUNC GLOBAL DEFAULT 4 foo@@LIBFOO_2.0
     7: ... FUNC GLOBAL DEFAULT 4 foo@LIBFOO_1.0

Both builds export exactly the same two versioned symbols; remove additionally scrubs the internal foo_old/foo_new names from .symtab. That matters for a library that does not want its versioning scaffolding visible to anyone reading nm output, and it is why the modifier exists.

One rule about precedence: a .symver binding wins over what the version script alone would have assigned (binutils VERSION). The version script says which names are exported and establishes the nodes; .symver says which code sits behind each versioned name. Listing foo under two nodes without a matching pair of .symver directives is meaningless — there is only one foo to assign.

Worked Example: One Library Gaining a Second Version

Everything above appears in one build, done twice. Toolchain: Fedora 44, glibc 2.43, GCC 16.2.1-2, binutils 2.46.1-1.fc44, measured 2026-09-11.

Release 1. foo returns x + 1; bar returns x * 2; internal_helper exists in C but is not in the map.

$ gcc -O1 -fPIC -shared -Wl,--version-script=v1.map \
      -Wl,-soname,libfoo.so.1 -o libfoo.so.1.v1 foo_v1.c
 
$ readelf -V -W libfoo.so.1.v1
Version definition section '.gnu.version_d' contains 2 entries:
  000000: Rev: 1  Flags: BASE  Index: 1  Cnt: 1  Name: libfoo.so.1
  0x001c: Rev: 1  Flags: none  Index: 2  Cnt: 1  Name: LIBFOO_1.0
Version needs section '.gnu.version_r' contains 1 entry:
  000000: Version: 1  File: libc.so.6  Cnt: 1
  0x0010:   Name: GLIBC_2.2.5  Flags: none  Version: 3

An application is built against it and never recompiled again; its verneed freezes LIBFOO_1.0 (full dump in the verneed discussion above), and it prints foo(10) = 11.

Release 2. foo must now return x + 1000. The old implementation is kept, both are bound with .symver, and the map file gains a second node. The resulting .dynsym holds both definitions of one name:

$ readelf --dyn-syms -W libfoo.so.1.v2 | grep -E 'foo|bar|baz|helper'
     5: ... FUNC GLOBAL DEFAULT 4 bar@@LIBFOO_1.0     <- unchanged, still on the 1.0 node
     6: ... FUNC GLOBAL DEFAULT 4 foo@@LIBFOO_2.0     <- the new default
     7: ... FUNC GLOBAL DEFAULT 4 foo@LIBFOO_1.0      <- the compat definition
     8: ... FUNC GLOBAL DEFAULT 4 baz@@LIBFOO_2.0     <- new in 2.0
                                                       internal_helper still absent

Four facts in one dump. bar did not move to LIBFOO_2.0 — a symbol keeps the node it was introduced under, forever, and only symbols that actually changed (or are new) touch a new node. foo appears twice under two nodes. baz is new and therefore defaults to the newest node. internal_helper is still hidden by local: *.

The result. With libfoo.so.1 pointing at the v2 file:

$ ./app_old      # built in the 1.0 era, untouched
foo(10) = 11
bar(10) = 20
$ ./app_new      # built today
foo(10) = 1010
bar(10) = 20
timeline
    title One symbol name, two definitions, one file
    Release 1.0 : libfoo.so.1 defines foo@@LIBFOO_1.0 and bar@@LIBFOO_1.0
                : app_old is linked and freezes "LIBFOO_1.0" into its .gnu.version_r
                : app_old prints foo(10) = 11
    foo must change : the old implementation is renamed foo_v1_impl and kept
                    : .symver foo_v1_impl, foo@LIBFOO_1.0   (compat, hidden bit set)
                    : .symver foo_v2_impl, foo@@LIBFOO_2.0  (new default)
                    : the map file gains a LIBFOO_2.0 node with LIBFOO_1.0 as predecessor
    Release 2.0 : one file, same SONAME, .dynsym holds foo@LIBFOO_1.0 AND foo@@LIBFOO_2.0
                : app_old is NOT rebuilt and still prints foo(10) = 11
                : app_new is linked today and prints foo(10) = 1010
                : bar stays on LIBFOO_1.0 because bar did not change

What it shows: the life of one symbol across a behaviour change that would otherwise be an ABI break. The insight to take: nothing is ever removed and nothing is ever renamed — the old definition is demoted from default to compat by setting one bit, and the new one takes the default slot. The library file grows by one function per incompatible change, which is the entire price of never having a flag day.

How the Loader Binds a Versioned Reference

There are two distinct checks, they happen at different times, and confusing them is the source of most misdiagnosed versioning failures.

Check 1, at load time: does the library define the nodes I need?

Before any symbol is looked up, ld.so walks every dependency’s verneed and confirms the corresponding library actually defines those nodes. glibc’s elf/dl-version.c:match_symbol is the whole of it — a linear scan of the verdef chain, integer hash first, string second:

  def = (ElfW(Verdef) *) ((char *) map->l_addr + def_offset);
  while (1)
    {
      ...
      /* Compare the hash values.  */
      if (hash == def->vd_hash)
	{
	  ElfW(Verdaux) *aux = (ElfW(Verdaux) *) ((char *) def + def->vd_aux);
	  /* To be safe, compare the string as well.  */
	  if (__builtin_expect (strcmp (string, strtab + aux->vda_name), 0) == 0)
	    /* Bingo!  */
	    return 0;
	}
      if (def->vd_next == 0)
	break;
      def = (ElfW(Verdef) *) ((char *) def + def->vd_next);
    }

Two things are worth reading carefully here.

There is no parent-chain walk. match_symbol compares the required name against each node’s own name (aux->vda_name, the first Verdaux) and nothing else. A requirement on LIBFOO_1.0 is satisfied only by a node literally named LIBFOO_1.0; it is not satisfied by LIBFOO_2.0 merely because LIBFOO_2.0 lists LIBFOO_1.0 as a predecessor. This is the mechanical backing for Drepper’s remark that the predecessor relationship “is not really important in symbol versioning” — it is documentation, ordering information for tools, and a Solaris compatibility gesture, not something the loader consults. Any statement that the loader accepts “that node or an ancestor” is wrong.

A missing node is fatal, but a missing verdef is not. If the node is absent the loader raises version 'X' not found (required by Y); if the library has no DT_VERDEF at all the check returns success with only a warning, on the reasoning stated in the source: “The file has no symbol versioning. I.e., the dependent object was linked against another version of this file.” Both behaviours, measured:

# the required node is missing from a library that DOES use versioning -> fatal
$ ./app_new                              # needs LIBFOO_2.0, library downgraded to v1
./app_new: .../libfoo.so.1: version `LIBFOO_2.0' not found (required by ./app_new)
exit=1
 
# the library uses no versioning at all -> warning, and the program RUNS
$ LD_LIBRARY_PATH=none ./app_old         # app_old needs LIBFOO_1.0
./app_old: none/libfoo.so.1: no version information available (required by ./app_old)
foo(10) = 0
bar(10) = 0
exit=0

The Cnt: counts and the LD_DEBUG=versions trace show the check happening per dependency, not per symbol:

$ LD_DEBUG=versions ./app_old 2>&1 | grep -i libfoo
checking for version `LIBFOO_1.0' in file .../libfoo.so.1 [0] required by file ./app_old [0]
checking for version `GLIBC_2.2.5' in file /lib64/libc.so.6 [0] required by file .../libfoo.so.1 [0]

Two checks for a program with two versioned dependencies, regardless of how many symbols it imports from them. This is why Drepper can say “the version matching is a quick process” (§3.3): node counts are tiny and independent of symbol counts, and the per-symbol work in check 2 is one integer compare against a precomputed table.

Check 2, at symbol-resolution time: which definition matches?

Once the nodes exist, every symbol lookup carries a version. _dl_fixup — the lazy-binding resolver, see Lazy Binding — builds it before calling _dl_lookup_symbol_x, and the arithmetic is the versym chain from earlier in this note:

      if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
	{
	  const ElfW(Half) *vernum =
	    (const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]);
	  ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff;
	  version = &l->l_versions[ndx];
	  if (version->hash == 0)
	    version = NULL;              /* index 0 or 1: no version required */
	}

Note & 0x7fff: the hidden bit is masked off on the reference side. A reference is never “hidden”; that flag only ever qualifies a definition.

The matcher is check_match in elf/dl-lookup.c, and its version logic is short enough to quote whole:

      if (__glibc_likely (verstab != NULL))
	{
	  /* We can match the version information or use the
	     default one if it is not hidden.  */
	  ElfW(Half) ndx = verstab[symidx] & 0x7fff;
	  if (map->l_versions[ndx].hash == version->hash
	      && strcmp (map->l_versions[ndx].name, version->name) == 0)
	    /* This is an exact version match.  Return the symbol below.  */
	    ;
	  else
	    {
	      if (!version->hidden
		  && map->l_versions[ndx].name[0] == '\0'
		  && (verstab[symidx] & 0x8000) == 0
		  && (*num_versions)++ == 0)
		/* This is the global default version.  Store it as a
		   fallback match.  */
		*versioned_sym = sym;
	      return NULL;
	    }
	}

An exact name match wins outright. Otherwise the only acceptable substitute is an unversioned definition (name[0] == '\0') that is not hidden, and even that is only remembered as a fallback in case nothing better turns up. A differently-versioned definition is never a match — which is what makes foo@LIBFOO_1.0 and foo@@LIBFOO_2.0 genuinely independent symbols that merely share a spelling.

flowchart TD
    A["relocation for undefined symbol S in object O"] --> B{"does O have DT_VERSYM?"}
    B -- no --> N["version = NULL<br/>(unversioned reference)"]
    B -- yes --> C["ndx = versym[symidx] &amp; 0x7fff<br/>version = O-&gt;l_versions[ndx]"]
    C --> D{"version-&gt;hash == 0 ?<br/>(ndx was 0 *local* or 1 *global*)"}
    D -- yes --> N
    D -- no --> V["versioned reference:<br/>need node named version-&gt;name"]
    V --> E["for each candidate definition D in the lookup scope"]
    E --> F{"l_versions[ versym[D] &amp; 0x7fff ].name<br/>== version-&gt;name ?"}
    F -- yes --> HIT["EXACT MATCH — bind here, stop"]
    F -- no --> G{"definition is unversioned<br/>AND bit 0x8000 clear<br/>AND reference not hidden?"}
    G -- yes --> FB["remember as FALLBACK, keep searching"]
    G -- no --> SKIP["not a match, keep searching"]
    N --> P{"versym[D] &amp; 0x7fff &gt;= threshold?<br/>threshold = 2 for dlsym, 3 otherwise"}
    P -- "below threshold" --> HIT2["bind here, stop<br/>(index 2 = the OLDEST defined node)"]
    P -- "at or above" --> Q{"bit 0x8000 clear?"}
    Q -- yes --> FB
    Q -- no --> SKIP
    FB --> R["scope exhausted:<br/>use the remembered fallback, else fail"]
    SKIP --> R
    style HIT fill:#d3f9d8
    style HIT2 fill:#d3f9d8

What it shows: the complete version-index path taken for a single symbol reference in glibc 2.43, from _dl_fixup building the r_found_version through check_match accepting or rejecting each candidate definition. The insight to take: there are two entirely separate rule sets — one for a reference that carries a version and one for a reference that does not — and the unversioned path is governed by a numeric index threshold rather than by any name comparison, which is why a plain link and a dlsym call can legitimately return different functions for the same name.

The four combinations, and the one that surprises people

The LSB enumerates every case of versioned/unversioned reference against versioned/unversioned definition (LSB §10.7.6), and glibc implements all four:

ReferenceDefinitionRuleMeasured on this host
versionedversionedexact node-name match required; a missing node is a fatal errorapp_new against v1: version 'LIBFOO_2.0' not found
versionedunversioned“A matching symbol shall be accepted”app_old against the unversioned stub: warns no version information available, then runs
unversionedversioned“only the definitions with index numbers 1 and 2 will be used” — i.e. the oldest defined nodea binary with no verneed for libfoo got foo(10) = 11, the LIBFOO_1.0 definition
unversionedunversionedpre-versioning rulesordinary first-definition-wins

The third row is the one worth stopping on, because it is counter-intuitive and it is where the >= 3 threshold in check_match comes from. Index 0 is *local*, index 1 is the BASE/*global* node, so index 2 is the first version node the library ever defined. A reference that carries no version requirement — an ancient binary, or one linked against a version-stripped stub — is therefore bound to the oldest definition, not the newest. glibc’s comment states the intent directly:

In the case of the old unversioned application the oldest (default) version should be used. In case of a dlsym() call the latest and public interface should be returned.

and the code implements the distinction with a single ternary — >= ((flags & DL_LOOKUP_RETURN_NEWEST) ? 2 : 3). Both halves are observable in one program:

$ ./app_unver          # linked against a version-stripped stub, run against the real v2 library
foo(10) = 11           # bound to foo@LIBFOO_1.0 -- the OLDEST node
 
$ ./dlapp              # dlopen + dlsym/dlvsym against the same v2 library
dlsym (foo)               -> 0x7f2a80854393  foo(10)=1010    # the DEFAULT (@@) = newest
dlvsym(foo, LIBFOO_1.0)   -> 0x7f2a8085438f  foo(10)=11
dlvsym(foo, LIBFOO_2.0)   -> 0x7f2a80854393  foo(10)=1010
dlvsym(foo, LIBFOO_9.0)   -> (nil)  err: ./libfoo.so.1: undefined symbol: foo, version LIBFOO_9.0

The same symbol name, in the same library, resolved in the same process, gives 11 to an unversioned static link and 1010 to dlsym. That is not a bug; it is the deliberate consequence of the two rules above, and it is the single most surprising thing in this whole mechanism.

dlvsym: asking for a specific version at run time

The transcript above also shows the run-time counterpart to a link-time .symver. Plain dlsym(handle, "foo") can only ever return the default (@@) definition — the threshold rule guarantees it. glibc’s dlvsym(handle, "foo", "LIBFOO_1.0") takes the node name explicitly and will return a non-default, compat definition. It is how an interposition library or compatibility shim reaches the exact historical implementation of a symbol; the canonical example is dlvsym(RTLD_NEXT, "memcpy", "GLIBC_2.2.5") to call the overlap-tolerant memcpy deliberately (see Versioned Symbols in glibc). A nonexistent node gives a clean undefined symbol: foo, version LIBFOO_9.0 rather than silently falling back — which, given everything else in this section, is a mercy.

The Soname in a verneed Entry Is Advisory Now

A Verneed record names a file (vn_file, a SONAME) as well as a set of nodes. For most of ELF’s history glibc enforced that pairing: a reference to pthread_cond_wait@GLIBC_2.3.2 recorded under libpthread.so.0 had to be satisfied by a definition in a library whose SONAME was libpthread.so.0. That check was deliberately removed in glibc commit f0b2132b35248c1f4a80f62a2c38cddcc802aa8c, “ld.so: Support moving versioned symbols between sonames [BZ #24741]” (Florian Weimer, 28 June 2019), whose message argues the relaxation is safe:

This change should be fully backwards-compatible because the old code aborted the load if a soname mismatch was encountered (instead of searching further for a matching symbol). This means that no different symbols are found.

At glibc 2.43 the consequence is visible by absence: elf/dl-lookup.c contains no reference to the filename field of struct r_found_version at all. The version name is matched; the file it was supposed to come from is not.

This matters far beyond glibc’s own plumbing, because it is what makes symbol migration between shared objects possible without breaking installed binaries. It is the mechanism behind glibc 2.34’s consolidation, where libpthread, libdl, libutil and libanl were folded into libc.so.6 and the old shared objects became empty stubs. As the 2.34 release notes put it, “Applications which have been linked against glibc 2.33 or earlier continue to load the corresponding shared objects (which are now empty)” — they still carry verneed entries naming libpthread.so.0, and those are satisfied from libc.so.6.

The complementary half of that trick is worth knowing because it looks bizarre otherwise. Load-time check 1 still walks each dependency’s verneed and demands that the named library define the node, so the now-empty libpthread.so.0 must keep its verdef populated even though it contains no code. glibc does this with deliberately useless symbols — __libpthread_version_placeholder entries in nptl/Versions, one per historical node. The commit that introduced them states the reason directly (glibc 41d6f74e6cb6, Florian Weimer, 2 July 2019):

On older architectures, a placeholder symbol is required, to make sure that the GLIBC_2.1.2 symbol version does not go away, or is turned in to a weak symbol definition by the link editor. (The symbol version needs to be preserved so that the symbol coverage check in elf/dl-version.c does not fail for old binaries.)

A version node, once published, is thus a permanent obligation independent of whether any symbol still lives in it — which is the sharpest possible statement of what “ABI” means under this scheme.

What the Mechanism Is Used For: Four Real Version Bumps

The generic machinery above exists to serve a small number of recurring situations. Each of the following is a real glibc version bump; the detail and consequences live in Versioned Symbols in glibc, but the shapes belong here because they are what a library author is actually choosing between.

ChangeVersion bumpThe shape of the problemWhat the maintainers said
memcpy began copying backwards on some CPUs, exposing callers that passed overlapping buffersmemcpy@GLIBC_2.2.5 (compat) + memcpy@@GLIBC_2.14The function’s documented contract did not change; its observable behaviour on undefined input did, and real binaries depended on the old behaviourDrepper, on BZ 12518: “We can have the existing memcpy@GLIBC_2.2.5 implementation work around the issue by avoiding the backward copying code. A new memcpy@GLIBC_2.14 could use the code currently in use.” Jakub Jelinek’s refinement, which is what shipped: “wouldn’t it be better to just alias the old memcpy@GLIBC_2.2.5 to memmove and have the new memcpy@@GLIBC_2.14 be the only memcpy implementation?”
realpath(name, NULL) became legal instead of an errorrealpath@GLIBC_2.2.5 (compat) + realpath@@GLIBC_2.3An error case became a valid case — strictly more permissive, and still an ABI change, because a program may depend on the EINVALThe comment Drepper left in stdlib/Versions is two words: # Silent change in SUS. The compat entry point is a four-line wrapper that restores the old EINVAL
pthread_cond_t changed size and layout for NPTLpthread_cond_*@GLIBC_2.0 (compat) + @@GLIBC_2.3.2 for init/destroy/wait/timedwait/signal/broadcastA caller-allocated type changed. Every binary that declared a pthread_cond_t baked the old size into its own stack frames and structsThe comment in nptl/Versions is again one line: # Changed pthread_cond_t. The compat pthread_cond_wait treats the caller’s object as a pointer to a real condvar and lazily allocates one behind it
libpthread/libdl/libutil/libanl merged into libc.so.6no new node — every moved symbol kept its historical versionThe symbol did not change at all; only the file it lives in didglibc’s scripts/move-symbol-to-libc.py: “This script moves ABI symbols from non-libc abilists in to libc.abilist. Symbol versions are preserved.”

Two general lessons fall out of putting them side by side. First, the commonest reason to add a version is not a signature change; three of the four are semantic or layout changes that a C prototype cannot express, which is exactly why the compiler cannot catch them and why a mechanism at the link/load layer is needed. Second, the third row is the one that hurts most: when the changed thing is a caller-allocated type, symbol versioning alone is not sufficient, because static linking has no versions. The glibc wiki is explicit about this limit:

Unfortunately, when the new interface change involves a structure size change, using only symbol versioning to provide symbols for the old interface does not guarantee full compatibility since the static archive of GLIBC (libc.a) doesn’t provide symbol versioning.

That is the boundary of what this note’s mechanism can do, and it is worth internalising before designing an ABI around it — see Static vs Dynamic Linking.

A claim in circulation that I checked and found false

It is sometimes said that glibc’s 2016 condition-variable rewrite (Torvald Riegel, BZ 13165, commit ed19993b5b0d) required new symbol versions. It did not: that commit touches no Versions file and no *.abilist. The rewrite reshuffled the interior of pthread_cond_t while keeping __SIZEOF_PTHREAD_COND_T unchanged — which is precisely the kind of change symbol versioning is not needed for, because nothing observable to a compiled caller moved. The condvar functions did eventually gain new versions, but in 2.34 as part of the libpthread merge above, for the unrelated reason that they changed which DSO they live in.

Failure Modes and Common Misunderstandings

Confusing SONAME versioning with symbol versioning. They are orthogonal and solve different halves of the same problem. libfoo.so.1 → libfoo.so.2 means “a different, incompatible library; load both if you must” — with the interposition hazard measured at the top of this note. Symbol versioning keeps one libfoo.so.1 and evolves it internally. Distributions use SONAME bumps for wholesale rewrites and symbol versioning for incremental evolution within a major version; glibc uses only the latter, because for libc the former does not work.

Building the version into the node name and expecting the loader to compare them. LIBFOO_2.0 is a string, not a number. Nothing in ld or ld.so parses it, orders it, or infers that 2.0 supersedes 1.0. The binutils manual says so outright: node names have “no specific meaning other than what they might suggest to the person reading them”. A node named PURPLE works exactly as well, and a requirement on LIBFOO_1.0 is not satisfied by LIBFOO_2.0 even with the predecessor link recorded — as match_symbol above shows, the predecessor chain is never walked.

Expecting to link against a single-@ symbol by accident, or by design without saying so. An unqualified reference always resolves to the @@ default. To reach a compat definition you must write .symver in the consumer, which works (demonstrated above) but is a deliberate act with a maintenance cost: Florian Weimer’s position on libc-alpha is that “the general expectation is that the .symver backdoor to get symbols at non-default versions doesn’t really exist” (libc-alpha, 2017-04-20). Treating it as a supported interface is a bet on a library author’s tolerance, not a contract.

Assuming a deprecated symbol can eventually be deleted. It cannot, not while the version node exists. The supported deprecation move is to remove the default and keep the compat definition, as Weimer describes in the same thread:

They way this usually works is that deprecation involves removing the default, but keeping the version itself around. This means that existing binaries will continue to work, but new programs won’t be able to use the symbol anymore.

Yes. Removing the entire version still requires a soname bump (at which point you can remove all non-default versions because there aren’t any legacy binaries anymore), which is why I believe you typically do not want to do that.

So versioning does not let you shrink an ABI. It lets you stop growing the parts of it that new code can reach.

Omitting local: * and leaking internals. Without a catch-all local:, every global C symbol becomes part of the exported, versioned ABI — including helpers users will then start depending on, at which point they are in the ABI forever by the previous paragraph. Drepper’s advice is to “always use the catch-all case” (§2.2.5), and the nm-versus-readelf comparison earlier shows what it removes.

Forward-compatibility is not a thing. Versioning buys backward compatibility: a new library satisfies an old binary. It does nothing in the other direction. A binary built against GLIBC_2.34 on a modern machine and copied to an older one fails at load with version 'GLIBC_2.34' not found, and there is no runtime fallback, because the definition simply does not exist there. This is the wall covered in Versioned Symbols in glibc; the only fixes are building against the oldest glibc you intend to support, or shipping your own.

Reading a .gnu.version index as if it were portable. Indices are file-local. 2 in one object and 2 in another almost certainly name different nodes — the LSB is explicit that “the value itself is not the version associated with the symbol”. Always resolve an index through that object’s own verdef/verneed before comparing anything.

Assuming dlsym and a static link agree. They do not, and the measured transcript above shows a case where one returns 11 and the other 1010 for the same name in the same library. dlsym is documented by its implementation: it passes version == NULL plus DL_LOOKUP_RETURN_NEWEST, which lowers the accepted index threshold and yields the default (newest) definition, while an unversioned static reference takes the threshold-3 path and gets the oldest. If a program’s behaviour depends on which definition it gets, dlvsym is the only way to say so.

Assuming dlvsym falls back like an ordinary lookup. It does not. glibc’s _dl_vsym sets vers.hidden = 1 before the lookup, and check_match’s fallback branch is guarded by !version->hidden — so dlvsym demands an exact node-name match and will never silently return the default instead. That is why dlvsym(h, "foo", "LIBFOO_9.0") returned NULL with a clean error above rather than quietly handing back foo@@LIBFOO_2.0.

Two versions of one symbol live in one process simultaneously — and that is fine, provided you designed for it. If the application binds foo@LIBFOO_1.0 and a dependency binds foo@LIBFOO_2.0, both implementations are resident and both are called. This is legal and is the point of the mechanism, but it means the two implementations must be able to coexist over shared state — typically by both delegating to a common core rather than each keeping their own copy of anything.

VER_FLG_WEAK makes a missing node non-fatal. The LSB specifies that if the vna_flags bit for VER_FLG_WEAK is set, “the loader shall issue a warning and continue operation” instead of failing, and glibc’s match_symbol implements exactly that (if (__glibc_likely (weak)) { … return 0; }). Weak version references are rare in hand-written code but appear in generated compatibility shims; if you are debugging a library that seems to tolerate a missing version node, check the flag before concluding the check is broken.

Alternatives and When to Choose Them

For a library whose ABI genuinely never breaks, versioning is optional: an anonymous version script with nothing but { global: …; local: *; }; gives you the export control without any nodes, and that is the configuration most projects should start from. The moment the library needs to change the behaviour of a shipped function, though, the alternatives are the three columns of the comparison table at the top of this note, and only one of them avoids both a flag day and a silent wrong answer. Drepper recommends adopting versioning “from day one” (§3.3) precisely because retrofitting nodes onto an already-shipped unversioned library means the first node you add cannot protect anything that shipped before it.

The purely additive discipline — never change a function, only ever add new ones with new names — also works, costs nothing, and is what most libraries actually do. It fails the first time a bug in a shipped function’s semantics has to be fixed, which on a long enough timeline is every library.

musl deliberately does not implement symbol versioning, betting on an ABI stable enough never to need it. That is a coherent position with real consequences in both directions — binaries linked against glibc will not run against musl, and vice versa — and the trade-off is covered in glibc vs musl.

Two things are not alternatives and are worth naming so they are not reached for by mistake. Static linking removes the loader from the picture and therefore removes versioning entirely — which is why the pthread_cond_t row in the case-study table is a hard limit: libc.a has no version information and a statically linked program gets whatever was current when it was built, permanently (Static vs Dynamic Linking). And -fvisibility=hidden overlaps with a version script’s local: for export control but has nothing to say about versions; the two are complementary, not substitutes (Weak Symbols and Symbol Visibility).

Production Notes

The commands worth knowing, all used above on binutils 2.46.1 / glibc 2.43:

TaskCommand
See all three versioning sections of an objectreadelf -V -W <file>
See which node each exported symbol is onreadelf --dyn-syms -W <file> — the name@@node / name@node suffix
See what an object requiresreadelf -V -W <file> → the “Version needs” section, or readelf -d for VERNEED/VERNEEDNUM
Watch the load-time node checkLD_DEBUG=versions ./prog
See which node a symbol actually bound toLD_DEBUG=bindings ./prog — the trailing [NODE]
Find the internal name behind a versioned symbolnm -a <file>, unless the author used .symver … , remove
Reach a compat definition from new code__asm__(".symver foo, foo@OLD_1.0");
Reach a compat definition at run timedlvsym(handle, "foo", "OLD_1.0")

For a library author, the practical checklist is short and each item has a failure mode demonstrated somewhere above. Ship a version script from the first release, with local: *; in it. Give a node a name, not a number the toolchain will pretend to understand. Only move a symbol to a new node when its behaviour actually changed — bar in the worked example stayed on LIBFOO_1.0 through the 2.0 release and should have. When you do add a version, keep the old implementation compiling and tested, because it is now permanent: a node, once published, cannot be withdrawn without the soname bump you adopted versioning to avoid. And remember that the mechanism covers function behaviour, not caller-allocated types — if the thing that changed is a struct your callers declare on their own stack, symbol versioning will not save you, and the static-archive gap means it cannot.

For someone debugging a system rather than building a library, the three symptoms map cleanly onto the two checks. version 'X' not found (required by Y) is check 1 failing: the library is older than the binary, at the node level. no version information available (required by Y) is check 1 finding a library with no verdef at all — usually a hand-built or stripped replacement shadowing a system one, and the program will still run. And an unexpected but successful binding — the program runs and computes the wrong thing — is check 2 doing exactly what it was told, at which point LD_DEBUG=bindings and its trailing [NODE] annotation will tell you which definition won in one line.

See Also