glibc Symbol Versioning: what it is, how to control it, and how to verify it
glibc Symbol Versioning
Introduction
You've cross-compiled your binary, shipped it to a server, and now you get:
./myapp: /lib64/libc.so.6: version `GLIBC_2.38' not found (required by ./myapp)
The binary is the right architecture. The target has glibc. But it's the wrong version. The runtime linker refuses to load the binary because it references symbols tagged with a glibc version newer than what's installed.
ELF symbol versioning explains why the binary fails to load and how to control its version requirements.
The following sections expand on Issue 10: Binary Compatibility in Cross-Compiling Rust for aarch64 on Fedora.
What is Symbol Versioning?
Traditional shared libraries have a single namespace: when you call memcpy, the dynamic linker finds memcpy in libc.so.6 and you're done. But what happens when the semantics of memcpy change between library versions?
glibc solved this with ELF symbol versioning (introduced in glibc 2.1, ~1999). Every exported symbol carries a version tag. When you compile against glibc 2.38, your binary records that it needs, say, memcpy@GLIBC_2.14, the version of memcpy whose ABI was introduced in glibc 2.14.
The mechanism uses three ELF sections:
.gnu.version: per-symbol version index table (maps each dynamic symbol to a version).gnu.version_r: version requirements (lists which versioned interfaces this binary needs).gnu.version_d: version definitions (used by libraries to define versions they provide)
When the dynamic linker (ld-linux.so) loads your binary, it checks .gnu.version_r against what the loaded libc.so.6 defines in .gnu.version_d. If any required version is missing, you get the error above.
How Symbol Versions Relate to sonames
Sonames (libc.so.6) tell you the major ABI version. Symbol versioning is finer-grained: it tracks individual function interfaces within the same soname. glibc has kept the libc.so.6 soname since glibc 2.0 while adding and changing function interfaces. Without symbol versioning, glibc couldn't change any function signature or semantics without bumping to libc.so.7 and breaking every binary on the system.
Symbol versioning lets multiple implementations of the same function coexist in a single library. Old binaries call memcpy@GLIBC_2.2.5; new binaries call memcpy@GLIBC_2.14. Both implementations live in the same library file and are dispatched by version tag.
Inspecting Symbol Versions
Finding the Maximum Required glibc Version
The maximum glibc version tag in your binary determines the minimum glibc your target system must have.
# one-liner: extract the highest required GLIBC version
| | |
Listing All Required Version Tags
# show every versioned symbol import
|
Example output:
0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.2.5) write
0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.34) __libc_start_main
0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.38) strlcpy
Here, strlcpy@GLIBC_2.38 is what pulls the minimum glibc version up to 2.38.
Using readelf for the Full Picture
# version requirements section — summarizes which libraries and versions are needed
Output contains two sections:
- Version symbols (
.gnu.version): index per dynamic symbol - Version needs (
.gnu.version_r): aggregated list of required versions per shared library
The "Version needs" section is the most useful:
Version needs section '.gnu.version_r' contains 1 entry:
Addr: 0x00000000000004b0 Offset: 0x000004b0 Link: 7 (.dynstr)
000000: Version: 1 File: libc.so.6 Cnt: 4
0x0010: Name: GLIBC_2.38 Flags: none Version: 5
0x0020: Name: GLIBC_2.34 Flags: none Version: 4
0x0030: Name: GLIBC_2.17 Flags: none Version: 3
0x0040: Name: GLIBC_2.2.5 Flags: none Version: 2
Hunting Down a Specific Version Tag
When you see GLIBC_2.38 and want to know which function caused it:
|
The output identifies which symbol(s) pull in the newest version requirement.
How Binaries Acquire Version Tags
A binary's glibc version requirements are recorded during compile-time linking. The source code names the function; when the linker resolves strlcpy against the system's libc.so.6, it records the default version of that symbol as it exists in the library at link time.
glibc marks exactly one version of each symbol as the default (shown with @@ in objdump -T output on the library itself):
# on the library: @@ means "default version"
|
00000000000a1f40 w DF .text 0000000000000032 (GLIBC_2.14) memcpy
If your code (or any C library call made by your dependencies) references a function whose default version is GLIBC_2.38, your binary will require glibc 2.38.
Where Do the Versions Come From in Rust?
Rust binaries link to glibc for:
- Rust's
stdruntime:pthread_*,malloc,mmap,write, etc. - C dependencies via
-syscrates: anything usingcc-rs,pkg-config, orcmaketo link native libraries - The CRT startup code:
Scrt1.o,crti.oreference__libc_start_mainand friends
The version tag on __libc_start_main alone often determines your minimum glibc. On Fedora 42 (glibc 2.41), this symbol defaults to GLIBC_2.34.
Strategies to Lower the glibc Requirement
Strategy 1: Build Against an Older Sysroot
The simplest approach: if you link against an older glibc, you naturally get older version tags.
# install a Fedora 40 (glibc 2.39) sysroot
Or use a container image based on the oldest distro you need to support:
# Debian 11 "bullseye" ships glibc 2.31
Start with an older sysroot when your dependencies support the available library and tool versions. The glibc headers and stubs at compile time determine the version tags. Build old → link old → run everywhere that's ≥ old. There are trade-offs, though. The toolchains in these containers also need to be cross-compiled, and that's where things tend to start to break: the availability of C++/Rust compiler versions, each with its own slew of bugs/missing fixes, and libraries that ship in older versions with missing APIs - this is all extra work and can become non-trivial quickly.
Strategy 2: The .symver Directive (C/C++ Only)
When you're building against a new glibc but need to pin specific symbols to older versions, the .symver assembler directive overrides version resolution at link time:
// force the linker to use the GLIBC_2.17 version of clock_gettime
;
Place this before any code that calls clock_gettime. The linker will bind to the GLIBC_2.17 version instead of whatever the default is on your build system. However, this is a rather surgical approach, and I would not recommend it beyond very specific use cases
where a destination machine has a specific issue with a specific ABI. There is also no direct way to use this for Rust std symbols.
Since Rust doesn't have inline assembly that can emit .symver, the workaround is a small C shim to scale this up:
/* compat.c - pin problematic symbols to older glibc versions */
/* pin fcntl to GLIBC_2.2.5 (glibc 2.28+ defaults to GLIBC_2.28) */
;
/* pin reallocarray to GLIBC_2.26 if it exists, or provide a fallback */
;
Then in your build.rs:
The pinned versions from the C translation unit affect the final binary's symbol table.
A Practical Example
The demo repository contains a complete, working example of a C program that:
- Uses
strlcpy(introduced in glibc 2.38) andclock_gettime(interface changed in glibc 2.17 → 2.34) - Uses
.symverto pin symbols to older versions - Provides a comparison: build with and without version pinning
Building the Demo
# build without version pinning (links to whatever your system glibc provides)
# inspect: will show your system's default (e.g. GLIBC_2.38 for strlcpy)
# build WITH version pinning
# inspect: strlcpy replaced with manual impl, clock_gettime pinned to 2.17
Verification Checklist
After building, always verify:
1. Maximum glibc Version Required
| | |
2. No Unexpected Version Tags
# list all unique GLIBC versions required
| |
Compare against the glibc version on your target system:
# on the target system
|
# or: ldd --version | head -1
3. Full Version Requirements Summary
|
4. Test on the Target
The ultimate verification. If possible, run on a system (or container) with the exact glibc version you're targeting:
# example: test against glibc 2.31 using Debian Bullseye
Quick Reference Script
#!/bin/bash
# check-glibc-compat.sh
#
# Verify glibc version requirements of a binary.
#
# Requires: Symbol names to be present.
BINARY=""
| |
for; do
count=
done
Common Pitfalls
__libc_start_main@GLIBC_2.34
On glibc ≥ 2.34, the CRT startup code (Scrt1.o) calls __libc_start_main, and the linker resolves that reference to glibc's default __libc_start_main@@GLIBC_2.34. The version requirement is intentional: glibc 2.34 changed the startup contract so newly linked binaries record a 2.34 requirement instead of accidentally running against older libcs that are not prepared for it.
The startup object's requirement can mask the effect of other compatibility work. You might successfully pin or replace every symbol your own code uses, but objdump will still report a maximum requirement of GLIBC_2.34 because the startup object introduced it. A .symver directive in your own C code will not rewrite the reference coming from the prebuilt startup object, and a linker version script will not cap it either. The fix is to use startup objects and libc from an older sysroot (Strategy 1). Needing an older glibc ABI should be the exception, though.
Transitive Dependencies
Even if your code uses no new symbols, your dependencies might. A C library you link against may have been compiled on a newer system. Use objdump -T on individual .so files to trace which library introduces the offending version.
strlcpy / strlcat (glibc 2.38)
These were added to glibc in 2.38 after decades of debate. Some C code started using them immediately. If you link against a library that calls strlcpy, your binary inherits the GLIBC_2.38 requirement. Options:
- Provide your own
strlcpyimplementation that the linker picks up first - Build the offending library from source against an older sysroot
- Use
.symverto point at a compat shim
Rust std Baseline
Rust's tier-1 x86_64-unknown-linux-gnu target requires glibc ≥ 2.17 as of Rust 1.64+. However, if you build on a system with glibc 2.41, the linked binary may require 2.34+ due to __libc_start_main. Check the actual binary's version requirements alongside the Rust docs.
tl;dr
| Goal | Method |
|---|---|
| Know your binary's glibc requirement | objdump -T binary | grep GLIBC |
| Lower the requirement broadly | Build against an older sysroot / container |
| Pin specific C symbols to old versions | .symver directive in C code |
| Pin symbols from Rust | C shim with .symver via cc-rs |
| Control your own shared-library exports | Linker version script |
| Verify on target | Run in a container with the target glibc |
Build against the oldest glibc you need to support to keep the required symbol versions compatible with that baseline. Use .symver for specific symbols when you can't change your entire build environment.
A working demo with all techniques is at https://github.com/konifay/blogpost-demos/tree/main/2026-0002-glibc-symbol-versioning.