symbol Versioning Multiple Versions of the Same Function

 

42.3.2 — Symbol Versioning
Multiple Versions of the Same Function · .symver · @ vs @@ | Chapter 42 · TLPI
EmbeddedPathashala.com — Free Embedded & Linux Tutorials

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.

Key Concepts

.symver directive @ notation @@ default version VER_1 / VER_2 tags Version dependency chain objdump -t readelf -s Backward Compatibility glibc versioning

How Symbol Versioning Works

Symbol versioning uses two mechanisms together:

  1. The .symver assembler directive — maps an internal function name to a versioned external symbol name
  2. 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
Only one @@ per symbol: Exactly one .symver directive for a given symbol name should use @@. 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.

VER_1: exports xyz
↓ VER_2 depends on VER_1
VER_2: exports pqr, inherits xyz from VER_1
↓ VER_3 depends on VER_2
VER_3: exports new_api, inherits everything above
Version tag names are arbitrary. VER_1 and VER_2 are just names we chose. In production libraries, use meaningful names like MYLIB_1.0, MYLIB_1.1, MYLIB_2.0. glibc uses GLIBC_2.0, GLIBC_2.1, GLIBC_2.17, etc. The names have no semantic meaning to the linker; only the dependency relationships matter.

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
Real-world relevance: Symbol versioning is why you can run a binary compiled 15 years ago on a modern Linux system. The old binary says “I need malloc@GLIBC_2.0” and glibc provides the old malloc in the same libc.so.6 that also has the modern malloc@GLIBC_2.2.5.

Interview Questions & Answers

Q1. What problem does symbol versioning solve that traditional major version numbering cannot?
Traditional major version numbering requires all programs to be recompiled when a library changes its ABI, and old and new major versions must both be installed on the system simultaneously. Symbol versioning allows multiple versions of the same function to coexist in a single shared library file. Old programs automatically use the old symbol version they were compiled against; new programs automatically use the new default version. No parallel library installations are needed.
Q2. What is the .symver assembler directive and what does it do?
The .symver assembler directive creates a mapping between an internal function name and a versioned external symbol name. The syntax is __asm__(“.symver internal_name, external_name@VERSION_TAG”). It tells the assembler to expose the function as a versioned symbol. The @@ notation (double at sign) marks the default version — the one that new programs linking against the library will bind to. The @ notation (single) marks a legacy version that old pre-compiled programs already depend on.
Q3. How does a program know which version of a symbol to use at runtime?
The version tag that a program depends on is recorded in the program’s ELF symbol table at static link time. You can see this with objdump -t or readelf -s. When the dynamic linker loads the program, it reads this version dependency and looks for the matching versioned symbol in the shared library. The library contains both the old and new versions, and the linker picks the correct one based on the stored version tag.
Q4. What does the } VER_1; syntax mean in a version script?
The closing brace followed by a version tag name (} VER_1;) means the current version node depends on the named version node. In a script like VER_2 { global: pqr; } VER_1;, it means VER_2 depends on VER_1 and inherits its global and local symbol specifications. If VER_1 had local: *; to hide all other symbols, VER_2 inherits that specification too. Version dependencies can be chained across multiple version nodes.
Q5. Why does glibc use symbol versioning instead of separate libcs for each version?
Without symbol versioning, glibc would need to ship and maintain separate libc.so.5, libc.so.6, libc.so.7, etc. — one for every ABI change. Using symbol versioning, all glibc versions from 2.0 onward are contained in a single libc.so.6. A program compiled against glibc 2.0 still works on a system running glibc 2.35 because libc.so.6 still contains the old versioned symbols (malloc@GLIBC_2.0, printf@GLIBC_2.0, etc.) alongside the modern ones. This dramatically simplifies Linux distribution packaging.

Leave a Reply

Your email address will not be published. Required fields are marked *