Systems Programming · Cross Compilation
Symbol Versioning and the Minimum glibc a Binary Requires
Versioned symbols record the library release that first defined each function a binary calls. How that floor is written into the file, why it cannot be lowered after linking, and how to check it before shipping.
A program compiled and linked on a recent distribution fails to start on an older one with a message naming a version of the C library rather than a file. The binary is present, the loader is present, every shared object the program needs is in place. What is missing is a symbol version: libc.so.6 on the old system exports memcpy and memcpy@GLIBC_2.2.5, but the program asked for something newer, and the loader refuses the process before main runs.
This mechanism is what lets one C library serve binaries built over two decades without breaking the older ones. It is also what makes the build machine’s library version a hard floor under every binary produced against it, and the floor is set at link time, not at compile time.
One name, several definitions
ELF symbol versioning starts from a problem that plain symbol names cannot express: a library wants to change a function’s behaviour or its ABI while continuing to serve binaries linked against the old one. Renaming every function would break the source interface; keeping the old implementation forever would prevent the change.
The solution is to attach a version name to each defined symbol and to record, for each undefined symbol in a program, the version that was resolved. memcpy becomes two symbols with the same name and different versions, both exported from the same library. A program linked against the old one records memcpy@GLIBC_2.2.5; a program linked later, when the library began exporting a newer implementation, records the newer version. The loader looks up the name and version pair, so each binary gets the implementation that matches what it was linked against.
The version names in glibc are release numbers: GLIBC_2.2.5, GLIBC_2.4, GLIBC_2.17, GLIBC_2.28, GLIBC_2.34. A version name marks the release in which the symbol first appeared, or in which its ABI last changed. It is not a claim that the symbol is new; memcpy@GLIBC_2.14 exists because the implementation changed, not because memcpy was added then.
The three sections that carry it
The information lives in three sections, and all three are needed to reconstruct the requirement.
.gnu.version holds a 16-bit index per dynamic symbol, in the same order as .dynsym. Index 0 means local, 1 means global and unversioned, and any larger value is an index into one of the two other tables.
.gnu.version_d holds the definitions: the version names the library provides, each with a hash and a set of flags, and each linked to its parent version. The parent relationship exists because adding a symbol to an existing version is not allowed once released — new symbols go into a new version node whose parent is the previous one, forming a chain.
.gnu.version_r holds the requirements: for each shared object the binary depends on, the version names it needs from that object. The dynamic tags DT_VERDEF, DT_VERNEED, and DT_VERSYM point at these sections, and the loader walks them during startup.
At startup the loader builds the set of version names each loaded object defines, then walks the requirement list of the main binary and every library it loads. A requirement with no matching definition is fatal, and the message names both the object and the version. This check happens before any constructor runs, which is why the failure is a startup error rather than a crash in a function.
How the floor is computed
The floor is not the library’s version. It is the highest version among the symbols the program actually references.
A program linked against glibc 2.36 that calls only write, malloc, and exit records GLIBC_2.2.5 for all three on x86-64 and runs on any system whose libc is at least that old. A program that calls pthread_create on a distribution where that symbol moved into libc in 2.34 records GLIBC_2.34 and requires a libc at least that new, even though the call itself is decades old. The version attached to a symbol can change when the library reorganises where the symbol lives, not only when it is added.
Two properties follow. Linking is what sets the floor, because the linker resolves against the actual library present on the build machine and copies the version of the definition it found. Compiling against newer headers while linking against an older library gives the older floor, as long as the symbols referenced exist in that library. And the floor is a maximum over all referenced symbols, so a single call to a recently added function raises the requirement for the whole program, including the parts that would otherwise run on an ancient system.
Architecture matters for the baseline. AArch64 support entered glibc in 2.17, so GLIBC_2.17 is the oldest version an AArch64 binary can require, regardless of how few symbols it uses. RISC-V entered later still, at 2.27, and its baseline is correspondingly higher.
Reading the requirement out of a file
objdump -T prints the dynamic symbol table with the version attached to each entry, and readelf --version-info prints the requirement and definition tables directly. To find the floor, sort the version strings numerically and take the last:
objdump -T ./program \
| grep -o 'GLIBC_[0-9.]*' \
| sort -u -V \
| tail -n 1
The same check applied to every shared library a package ships is the only reliable way to know what a build will run on. The build machine’s libc version is an upper bound, not the answer, and neither is the --target triple: x86_64-linux-gnu covers every glibc release from 2.2.5 onwards.
readelf --version-info also reveals a subtler problem: requirements inherited from a library the program does not call directly. A DT_NEEDED entry pulls in the whole library’s version requirements only for the symbols it references, so an unused dependency adds nothing to the floor, but a library that internally calls a new function does raise it. When a vendor-supplied .so is in the link, its floor becomes the program’s floor.
Lowering the floor after the fact is not possible
There is no linker option that rewrites a recorded version to an older one, and hand-editing the version sections breaks the hash checks and the loader’s expectations. The floor is a property of the link, which leaves three ways to lower it.
Link against an older library. This is what a sysroot is for: a root populated from the oldest distribution the software must support, so the linker resolves every symbol against those definitions and records those versions. The compiler can be new, and the headers can come from the same old root; the linker is what decides. Building the root and pointing the toolchain at it is the mechanics described in what a sysroot actually restricts.
Avoid the symbols that carry a high version. Sometimes a newer version attaches to a function whose older name still exists as a separate symbol; calling the older one through a compatibility entry point keeps the floor down. This is fragile, because the compatibility symbol is exactly what the library is trying to retire, and it is invisible in the source.
Link statically. A fully static binary records no version requirements at all, because there is no dynamic linker to satisfy. The floor becomes the kernel’s system call interface instead of the C library’s, which is usually much lower but introduces its own constraints: dlopen, name service lookups, and locale data all depend on files that a static binary must still find at run time. Static linking moves the compatibility problem rather than removing it.
The other floor, which versioning does not address
Symbol versions describe the C library’s ABI. They say nothing about the kernel. A binary can satisfy every version requirement and still fail because a system call it uses was added after the target’s kernel was built; the call returns ENOSYS, and whether that is fatal depends on the call. A program that uses statx on a kernel older than 4.11 gets ENOSYS and, if it does not fall back to fstatat, fails at run time on a system that passed the version check.
The same distinction applies to CPU features. A binary built for a baseline instruction set runs anywhere that baseline exists; one built with extensions enabled at compile time faults with SIGILL on a core that lacks them, and neither the version tables nor the ELF header records the requirement. Kernel version, CPU feature level, and C library version are three independent constraints, and the triple names none of them.
A different C library has a different convention
musl does not use glibc’s versioning scheme, and binaries linked against it carry no GLIBC_* requirements. That makes a musl-linked binary immune to this particular failure and incompatible with glibc systems for a different reason: the symbols it expects are unversioned, and the interpreter path it records points at musl’s loader. The two libraries are not interchangeable at the binary level, which is one reason cross toolchains are usually published in both variants with different triples.
For a build that must support an old target, the version floor is a fact to be measured and recorded rather than assumed. A release gate that runs the extraction above on every shipped binary catches the case where a newly added call silently raises the requirement for a program that was supposed to keep running on the oldest supported system.
References
- GNU C Library manual: Versioning — GNU Project
- How To Write Shared Libraries — Ulrich Drepper
- System V Application Binary Interface: ELF — AT&T / SCO
- elf(5) — Linux manual page — man7.org