What Problem Does Symbol Versioning Solve?
Imagine you ship a shared library libfoo.so with a function process(int x). Thousands of programs are compiled against it. Now you realize you need to change the function signature to process(int x, int flags) for better functionality.
In the traditional approach, you would increment the major version (libfoo.so.1 → libfoo.so.2), but then all existing programs that link against libfoo.so.1 must be recompiled or the old library must be kept on every system.
Symbol versioning solves this elegantly: a single shared library can contain multiple versions of the same function. Old programs automatically get the old version. New programs get the new version. Both coexist in one .so file.
This is how glibc itself works — all versions from glibc 2.0 onward are packaged in a single libc.so.6, using symbol versioning to maintain backward compatibility across decades.
How Symbol Versioning Works
Symbol versioning uses two mechanisms together:
- The .symver assembler directive — maps an internal function name to a versioned external symbol name
- Version nodes in the version script — define the version tags and their dependencies
The .symver directive syntax:
__asm__(".symver internal_name, external_name@VERSION_TAG");
/* real function name exported as bound to this version */
/* Use @@ (double @) for the DEFAULT version — the one new programs get */
__asm__(".symver xyz_new, xyz@@VER_2"); /* Default for new programs */
__asm__(".symver xyz_old, xyz@VER_1"); /* Legacy for old programs */
| Notation | Meaning | When Used |
|---|---|---|
name@VER_1 (single @) |
Symbol bound to VER_1 (legacy) | For old programs that linked against VER_1 |
name@@VER_2 (double @@) |
Default symbol — bound to VER_2 | For all new programs linking against the library |
@@. This marks the default version that new compilations bind to. If you have 3 versions of a symbol, two use @ and one (the newest) uses @@.Version Tag Dependencies
Version nodes in the script can depend on earlier versions. The syntax is:
VER_1 {
global: xyz;
local: *;
};
VER_2 {
global: pqr; /* New symbol added in version 2 */
} VER_1; /* VER_2 depends on VER_1 */
The dependency chain means VER_2 inherits the global/local specifications from VER_1. You can chain further: VER_3 depends on VER_2, and so on.
Code Example 1: Complete Symbol Versioning (TLPI Example)
This is the complete example from TLPI Section 42.3.2 — creating two versions of xyz() in one library:
Step 1 — Create version 1 of the library:
/* sv_lib_v1.c — first version of the library */
#include <stdio.h>
void xyz(void) { printf("v1 xyz\n"); }
# sv_v1.map — version script for v1
VER_1 {
global: xyz;
local: *; # Hide all other symbols
};
gcc -g -c -fPIC -Wall sv_lib_v1.c
gcc -g -shared -o libsv.so sv_lib_v1.o -Wl,--version-script,sv_v1.map
Step 2 — Build program p1 that uses v1:
/* sv_prog.c */
#include <stdlib.h>
int main(int argc, char *argv[]) {
void xyz(void);
xyz();
exit(EXIT_SUCCESS);
}
gcc -g -o p1 sv_prog.c libsv.so
LD_LIBRARY_PATH=. ./p1
# Output: v1 xyz
Step 3 — Create version 2 with BOTH versions of xyz():
/* sv_lib_v2.c — library with both v1 and v2 of xyz() */
#include <stdio.h>
/* .symver directive: xyz_old is exposed as xyz@VER_1 (legacy) */
__asm__(".symver xyz_old,xyz@VER_1");
/* .symver directive: xyz_new is exposed as xyz@@VER_2 (default, new) */
__asm__(".symver xyz_new,xyz@@VER_2");
void xyz_old(void) { printf("v1 xyz\n"); } /* For old programs */
void xyz_new(void) { printf("v2 xyz\n"); } /* For new programs */
void pqr(void) { printf("v2 pqr\n"); } /* New in v2 */
# sv_v2.map — version script for v2
VER_1 {
global: xyz;
local: *;
};
VER_2 {
global: pqr; /* New symbol in v2 */
} VER_1; /* VER_2 depends on VER_1 */
gcc -g -c -fPIC -Wall sv_lib_v2.c
gcc -g -shared -o libsv.so sv_lib_v2.o -Wl,--version-script,sv_v2.map
Step 4 — Test backward and forward compatibility:
/* Build p2 against the new library */
gcc -g -o p2 sv_prog.c libsv.so
/* p1 still works — gets xyz@VER_1 (xyz_old) */
LD_LIBRARY_PATH=. ./p1
# v1 xyz (uses old version — unchanged!)
/* p2 uses new version — gets xyz@@VER_2 (xyz_new) */
LD_LIBRARY_PATH=. ./p2
# v2 xyz (uses new version)
Step 5 — Verify version tags in executables:
objdump -t p1 | grep xyz
# 08048380 F *UND* 0000002e xyz@@VER_1 <-- p1 wants VER_1 version
objdump -t p2 | grep xyz
# 080483a0 F *UND* 0000002e xyz@@VER_2 <-- p2 wants VER_2 version
Code Example 2: Production-Style Symbol Versioning
Here is a more realistic example of a library that evolves its API across releases:
/* crypto_lib.c — a library that changed its encrypt() signature between releases */
#include <stdio.h>
#include <string.h>
/* ---- Version CRYPTOLIB_1.0 implementation ---- */
/* Old encrypt: takes key as plain string — weak but backward compatible */
__asm__(".symver encrypt_v1, encrypt@CRYPTOLIB_1.0");
void encrypt_v1(const char *data, const char *key) {
printf("[v1.0] Encrypting '%s' with key '%s' (weak)\n", data, key);
}
/* ---- Version CRYPTOLIB_2.0 implementation ---- */
/* New encrypt: takes key as bytes + length — stronger, safer */
__asm__(".symver encrypt_v2, encrypt@@CRYPTOLIB_2.0");
void encrypt_v2(const char *data, const unsigned char *key, int keylen) {
printf("[v2.0] Encrypting '%s' with %d-byte key (strong)\n", data, keylen);
}
/* New in v2.0 */
void decrypt(const char *data, const unsigned char *key, int keylen) {
printf("[v2.0] Decrypting '%s'\n", data);
}
# cryptolib.map
CRYPTOLIB_1.0 {
global: encrypt;
local: *;
};
CRYPTOLIB_2.0 {
global: decrypt;
# encrypt@@ VER_2 is declared via .symver — included automatically
} CRYPTOLIB_1.0;
gcc -fPIC -shared -o libcrypto_demo.so crypto_lib.c \
-Wl,--version-script,cryptolib.map
# Check what's exported:
readelf -s libcrypto_demo.so | grep -E "encrypt|decrypt"
# encrypt@CRYPTOLIB_1.0 (legacy version)
# encrypt@@CRYPTOLIB_2.0 (default version for new programs)
# decrypt (only in v2.0)
Code Example 3: Inspecting glibc Symbol Versions
You can see symbol versioning in action in the actual glibc on your system:
# List versioned symbols in glibc on your system:
readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep "@@GLIBC" | head -20
# Example output (actual output will vary by glibc version):
# ... FUNC GLOBAL DEFAULT ... memcpy@@GLIBC_2.14
# ... FUNC GLOBAL DEFAULT ... printf@@GLIBC_2.2.5
# ... FUNC GLOBAL DEFAULT ... malloc@@GLIBC_2.2.5
# ... FUNC GLOBAL DEFAULT ... pthread_create@@GLIBC_2.2.5
# Old versions of these functions with @ (single):
readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep "@GLIBC_2.0" | head -10
# Shows legacy versions still present for backward compatibility
# See which version of memcpy a program expects:
objdump -t /bin/ls | grep memcpy
# memcpy@@GLIBC_2.14 -- ls requires memcpy from GLIBC 2.14
