Software Consulting & Engineering Requirements to Production

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

Understanding why this happens — and what you can do about it — requires understanding ELF symbol versioning.

This post picks up where Cross-Compiling Rust for aarch64 on Fedora left off (specifically Issue 10: Binary Compatibility) and goes deep on the versioning mechanism itself.

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.

Why Not Just Use 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 been libc.so.6 since glibc 2.0, yet the library has evolved enormously. Without symbol versioning, glibc couldn't change any function signature or semantic 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 — same library file, different implementations 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
objdump -T ./myapp | grep -oP 'GLIBC_\K[0-9.]+' | sort -Vu | tail -1

Listing All Required Version Tags

# show every versioned symbol import
objdump -T ./myapp | grep 'GLIBC_'

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
readelf -V ./myapp

Output contains two sections:

  1. Version symbols (.gnu.version) — index per dynamic symbol
  2. 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:

objdump -T ./myapp | grep 'GLIBC_2.38'

This tells you exactly which symbol(s) pull in the newest version requirement.

How Binaries Acquire Version Tags

A binary's glibc version requirements come from compile-time linking, not from the code you write. 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"
objdump -T /lib64/libc.so.6 | grep ' memcpy'
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:

  1. Rust's std runtimepthread_*, malloc, mmap, write, etc.
  2. C dependencies via -sys crates — anything using cc-rs, pkg-config, or cmake to link native libraries
  3. The CRT startup codeScrt1.o, crti.o reference __libc_start_main and 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
sudo dnf --installroot=/usr/aarch64-redhat-linux/sys-root/fc40 \
         --releasever=40 --forcearch=aarch64 --use-host-config \
         install glibc-devel gcc libgcc

Or use a container image based on the oldest distro you need to support:

# Debian 11 "bullseye" ships glibc 2.31
FROM debian:bullseye

This is the recommended approach. The glibc headers and stubs at compile time determine the version tags. Build old → link old → run everywhere that's ≥ old.

Strategy 2: The .symver Directive (C/C++ Only)

When building against a new glibc but you 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
__asm__(".symver clock_gettime,clock_gettime@GLIBC_2.17");

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.

Caveats:

  • Only works for symbols that actually existed in that older version
  • If glibc changed the function's semantics between versions, you get the old behavior — make sure that's what you want
  • Must be applied per symbol
  • Doesn't work directly from Rust (but can be used in C shims linked via cc-rs)

A working example is in the demo repository.

Strategy 3: Linker Version Script

Instead of pinning individual symbols, you can use a linker version script to restrict all symbol resolutions:

/* glibc_2_17.ver — restrict to glibc ≤ 2.17 */
GLIBC_2.17 {
    *;
};

Pass it to the linker:

gcc -o myapp myapp.c -Wl,--version-script=glibc_2_17.ver

Or in .cargo/config.toml:

[build]
rustflags = ["-C", "link-arg=-Wl,--version-script=glibc_2_17.ver"]

Limitations: this can cause linker errors if your binary uses symbols that didn't exist in the specified version. You may need to combine this with .symver overrides or remove the offending code.

Strategy 4: Use .symver via a C Wrapper (for Rust)

Since Rust doesn't have inline assembly that can emit .symver, the workaround is a small C shim:

/* compat.c — pin problematic symbols to older glibc versions */
#include <features.h>

/* pin fcntl to GLIBC_2.2.5 (glibc 2.28+ defaults to GLIBC_2.28) */
__asm__(".symver fcntl,fcntl@GLIBC_2.2.5");

/* pin reallocarray to GLIBC_2.26 if it exists, or provide a fallback */
__asm__(".symver reallocarray,reallocarray@GLIBC_2.26");

Then in your build.rs:

fn main() {
    cc::Build::new()
        .file("compat.c")
        .compile("compat");
}

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:

  1. Uses strlcpy (introduced in glibc 2.38) and clock_gettime (interface changed in glibc 2.17 → 2.34)
  2. Uses .symver to pin symbols to older versions
  3. Provides a comparison: build with and without version pinning

Building the Demo

git clone https://github.com/konifay/blogpost-demos.git
cd blogpost-demos/2026-0002-glibc-symbol-versioning

# build without version pinning (links to whatever your system glibc provides)
make unpinned

# inspect: will show your system's default (e.g. GLIBC_2.38 for strlcpy)
make check-unpinned

# build WITH version pinning
make pinned

# inspect: strlcpy replaced with manual impl, clock_gettime pinned to 2.17
make check-pinned

Verification Checklist

After building, always verify:

1. Maximum glibc Version Required

objdump -T ./myapp | grep -oP 'GLIBC_\K[0-9.]+' | sort -Vu | tail -1

2. No Unexpected Version Tags

# list all unique GLIBC versions required
objdump -T ./myapp | grep -oP 'GLIBC_\K[0-9.]+' | sort -Vu

Compare against the glibc version on your target system:

# on the target system
/lib64/libc.so.6 | head -1
# or: ldd --version | head -1

3. Full Version Requirements Summary

readelf -V ./myapp 2>/dev/null | grep -A100 'Version needs'

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
podman run --rm -v ./myapp:/myapp:z debian:bullseye /myapp

Quick Reference Script

#!/bin/bash
# check-glibc-compat.sh — verify glibc version requirements of a binary
BINARY="${1:?Usage: $0 <binary>}"

echo "=== glibc version requirements for: $BINARY ==="
echo ""
echo "Required GLIBC versions (ascending):"
objdump -T "$BINARY" 2>/dev/null | grep -oP 'GLIBC_\K[0-9.]+' | sort -Vu
echo ""
echo "Maximum required: GLIBC_$(objdump -T "$BINARY" 2>/dev/null | grep -oP 'GLIBC_\K[0-9.]+' | sort -Vu | tail -1)"
echo ""
echo "Symbols by version:"
for ver in $(objdump -T "$BINARY" 2>/dev/null | grep -oP 'GLIBC_[0-9.]+' | sort -Vu); do
    count=$(objdump -T "$BINARY" 2>/dev/null | grep -c "$ver")
    echo "  $ver: $count symbol(s)"
done

Common Pitfalls

__libc_start_main@GLIBC_2.34

On glibc ≥ 2.34, the CRT startup code (Scrt1.o) references __libc_start_main@GLIBC_2.34. There's no way to .symver this away — it's baked into the object file the linker pulls from the sysroot. The fix is to use a sysroot from an older glibc (Strategy 1).

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 strlcpy implementation that the linker picks up first
  • Build the offending library from source against an older sysroot
  • Use .symver to 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. Always check the actual binary, not just the Rust docs.

tl;dr

GoalMethod
Know your binary's glibc requirementobjdump -T binary | grep GLIBC
Lower the requirement broadlyBuild against an older sysroot / container
Pin specific C symbols to old versions.symver directive in C code
Pin symbols from RustC shim with .symver via cc-rs
Restrict all symbols via linker--version-script with version cap
Verify on targetRun in a container with the target glibc

The safest, most predictable approach is building against the oldest glibc you need to support. The .symver trick is useful for surgical fixes 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.

Resources