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.0versusfoo@@LIBFOO_2.0. When a program is linked, the linker records not just that it needsfoobut which version node offooit 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 onelibfoo.so.1. This note explains the generic mechanism — the three ELF sections, the@/@@convention, version scripts, and the.symverdirective; the concrete glibc application (theGLIBC_2.xxnode set, the famousmemcpysplit) lives in Versioned Symbols in glibc.
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 is implemented entirely with data in three special ELF sections layered on top of the ordinary dynamic symbol table .dynsym (see The ELF Symbol Table). Their formats are specified in the Linux Standard Base’s symbol-versioning chapter, which codifies the original Sun/GNU design (LSB symbol versioning):
.gnu.version— the Version Symbol table (Versym). A parallel array with exactly one 16-bit entry per.dynsymentry, giving the version index of that symbol. Special values: index0means*local*(this symbol is hidden/local), index1means the base/global version (*global*— an unversioned symbol). The high bit is a “hidden” flag distinguishing a non-default versioned definition from the default one. This is how a.dynsymsymbol gets its version stamp without changing the fixed-sizeElf64_Symrecord..gnu.version_d— the Version Definition table (verdef). Present in a library that defines versions. It lists each version node this object provides — its name string (LIBFOO_1.0), its index, and any parent node it inherits from. The first entry is conventionally theBASEversion, whose name is the library’s own SONAME..gnu.version_r— the Version Requirement table (verneed). Present in an object that uses versioned symbols from its dependencies. Grouped per dependency file, it records “fromlibc.so.6I need version nodesGLIBC_2.2.5andGLIBC_2.34.” This is the frozen-in demand list the dynamic linker checks at startup.
The division is clean: a library that provides versions carries verdef; a binary that consumes them carries verneed; every object with versioned dynamic symbols carries the Versym array. You can see all three with readelf -V.
The @ versus @@ Convention
A versioned symbol is written name@version or name@@version, and the doubling is load-bearing (binutils ld VERSION docs, Drepper §3.5):
name@@version— the default definition. There may be at most one default per symbol name in a library. It is the definition the static linker uses when a new program simply referencesname. In other words,@@is what “linking againstfoo” resolves to today.name@version(single@) — a non-default, compatibility definition. Drepper states it 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 is, single-@symbols exist purely so that already-linked old binaries that recorded a need for that exact old version can still find their definition at run time. You cannot newly link against a single-@symbol; it is a fossil kept alive for backwards compatibility.
So the lifecycle of a compatible-but-incompatible change is: 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 → old code; new links → new code.
Version Scripts and the .symver Directive
Two toolchain features drive the mechanism.
The version script (a.k.a. map file) is a text file passed to the linker with -Wl,--version-script=libfoo.map. It declares version nodes and, within each, which symbols are global: (exported) and which are local: (hidden). Drepper’s canonical example (Drepper §3.4–3.5):
LIBFOO_1.0 {
global:
bar;
foo;
local:
*; /* catch-all: everything not named above is hidden */
};
LIBFOO_2.0 {
global:
foo; /* a new, incompatible foo */
} LIBFOO_1.0; /* LIBFOO_2.0 inherits from (has predecessor) LIBFOO_1.0 */
Line-by-line: LIBFOO_1.0 { ... } defines a version node named LIBFOO_1.0 — 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 convention of “package name + version” is purely for humans. global: lists exported symbols. local: *; is the wildcard that hides every symbol not explicitly exported — the same export-control tool discussed in Weak Symbols and Symbol Visibility (a version script’s local: and -fvisibility=hidden solve the same problem). The trailing } LIBFOO_1.0; on the second node declares that LIBFOO_2.0 depends on / inherits from LIBFOO_1.0, forming the non-cyclical version graph; Drepper notes this predecessor relationship “is not really important in symbol versioning [but] should always be mentioned” for the human reader and Solaris compatibility (Drepper §3.5).
The .symver assembler directive is what lets two C functions become two versions of one exported name. Because C has no syntax for “this function is foo@@2.0 and that other one is foo@1.0”, you write two distinct C functions and bind each to a versioned alias with inline assembly (binutils as Symver):
int foo_v1(void) { return 1; } /* old behaviour */
int foo_v2(void) { return 2; } /* new behaviour */
__asm__(".symver foo_v1, foo@LIBFOO_1.0"); /* single @ : compat-only */
__asm__(".symver foo_v2, foo@@LIBFOO_2.0"); /* double @@ : new default */The directive “renames” the internal symbol to be an alias of the official name bound to the named version node, and a .symver binding takes precedence over what the version script alone would assign (binutils VERSION).
Worked Example: Verified on a Real Build
Building exactly the library above on this Fedora 44 system (glibc 2.43, GCC 16.1.1, binutils 2.46) and inspecting it confirms every claim empirically:
$ gcc -O2 -fPIC -shared -Wl,--version-script=libfoo.map -o libfoo.so.1 libfoo.c
$ readelf --dyn-syms libfoo.so.1 | grep -E 'foo|bar|secret'
5: ... FUNC GLOBAL DEFAULT 4 bar@@LIBFOO_1.0
6: ... FUNC GLOBAL DEFAULT 4 foo@@LIBFOO_2.0 <- default (@@)
7: ... FUNC GLOBAL DEFAULT 4 foo@LIBFOO_1.0 <- compat (single @)
# secret_helper: ABSENT from .dynsym -> hidden by "local: *"
$ readelf -V libfoo.so.1 # the .gnu.version_d (verdef) tree
... Name: libfoo.so.1 <- BASE = the SONAME
... Name: LIBFOO_1.0
... Name: LIBFOO_2.0
Parent 1: LIBFOO_1.0 <- inheritance recorded
Both foo@LIBFOO_1.0 and foo@@LIBFOO_2.0 coexist in the same .so; bar (unchanged since 1.0) stays at LIBFOO_1.0; the verdef tree shows the BASE node named after the SONAME plus the two named nodes with the parent link. A new program linking -lfoo would resolve foo to foo@@LIBFOO_2.0 and record foo@LIBFOO_2.0 in its verneed; a program linked before the 2.0 change recorded foo@LIBFOO_1.0 and still binds to the compat definition. The secret_helper function (not listed in global:) is pruned from .dynsym entirely by the local: * line — proving that a version script doubles as an export-control tool.
How the Loader Binds a Versioned Reference
At run time the dynamic linker satisfies each undefined symbol of a binary by consulting the binary’s verneed (.gnu.version_r): for printf it knows it needs printf@GLIBC_2.2.5, so it searches libc.so.6’s verdef for a symbol named printf whose version node is GLIBC_2.2.5 (or an ancestor). If the library provides that version node, binding succeeds; if the library is too old to define the required node, the loader aborts with a “version not found” error — the portability wall detailed in Versioned Symbols in glibc. Crucially, the number of version nodes is tiny compared to the number of symbols and independent of it, so, as Drepper notes, “the version matching is a quick process” (Drepper §3.3) — it is a per-dependency check, not a per-symbol one.
The dynamic section wires all of this together with a small set of tags: DT_VERSYM points to the .gnu.version array, DT_VERDEF/DT_VERDEFNUM to the verdef table and its entry count, and DT_VERNEED/DT_VERNEEDNUM to the verneed table — so ld.so can locate every versioning structure from the dynamic segment alone.
Requesting a specific version at run time: dlvsym
Ordinary dlsym(handle, "foo") returns the default (@@) definition of foo. To reach a non-default (compat, single-@) version at run time, glibc provides dlvsym(handle, "foo", "LIBFOO_1.0"), which looks up foo bound specifically to the named version node. This is the runtime analogue of the link-time .symver: it is how, for example, interposition libraries or compatibility shims grab the exact historical implementation of a symbol (e.g. dlvsym(RTLD_NEXT, "memcpy", "GLIBC_2.2.5") to call the overlap-tolerant memcpy explicitly). Without dlvsym, a plain dlsym can only ever see the default, so a program that must pin an old version at run time has no other portable route.
Failure Modes and Common Misunderstandings
- Confusing SONAME versioning with symbol versioning. They are orthogonal.
libfoo.so.1→libfoo.so.2(SONAME bump) means “totally separate, incompatible library, load both if needed.” Symbol versioning keeps onelibfoo.so.1and evolves it internally. Distributions use SONAME bumps for wholesale breaks and symbol versioning for incremental ABI evolution within a major version. - Expecting to link against a single-
@symbol. You cannot; the static linker ignores compat symbols. If you need the old behaviour in a new build you must use.symveryourself to request it (__asm__(".symver realfoo, foo@OLD_1.0")), as shown in Versioned Symbols in glibc. - Omitting
local: *and leaking internals. Without a catch-alllocal:, every global C symbol becomes part of the exported, versioned ABI — including internal helpers users will then depend on, defeating the entire purpose. Drepper advises “always use the catch-all case” (Drepper §2.2.5). - Two versions used in one process from different objects. If application code and a dependency each bound to different versions of the same symbol, both are live simultaneously; this is legal and each object gets what it recorded, but it means the two versions’ implementations must be able to coexist (typically both wrap a shared core).
Alternatives and When to Choose Them
For a library with a stable, never-breaking ABI, versioning is optional overhead — a simple local: * export map to control the interface is enough. For any library that will evolve its ABI over years (a system library, a widely-deployed shared dependency), symbol versioning is the only way to change behaviour without a flag day; Drepper recommends adopting it “from day one” precisely because retrofitting versions onto an already-shipped unversioned library is painful (Drepper §3.3). The alternative of never changing behaviour and only ever adding symbols works too, but real libraries eventually need to fix a broken function’s semantics — and then versioning is what saves the installed base. musl deliberately does not use symbol versioning, betting on a stable ABI instead — a design contrast covered in glibc vs musl.
See Also
- Versioned Symbols in glibc — the concrete
GLIBC_2.xxnode set, thememcpy@GLIBC_2.14saga, and the “version not found” wall - The ELF Symbol Table — the
.dynsymtable that.gnu.versionstamps with version indices - The Dynamic Section — where
DT_VERNEED/DT_VERDEFtags point to these tables - Symbol Resolution and Lookup Scope — the lookup algorithm versioning constrains
- Weak Symbols and Symbol Visibility — version scripts’
local:overlaps with-fvisibility=hiddenfor export control - Static vs Dynamic Linking — only dynamically linked objects carry version requirements
- Linux Userspace Runtime MOC — parent map