Symbol Versioning
3 Code Demos
Intermediate
TLPI Ch 42
When you ship a shared library (like libmylib.so), multiple programs may link against it over time. Imagine program p1 was compiled when xyz() worked one way. Later you changed xyz() โ now p2 uses the new behavior. But p1 is already deployed and must continue to work as before.
Symbol versioning solves this: the same library can export two versions of the same function at the same time. Each program binds to exactly the version it was compiled against โ no recompilation needed, no breakage.
| Program p1 compiled vs VER_1 |
โ | libsv.so Contains both xyz@VER_1 and xyz@@VER_2 |
โ | xyz@VER_1v1 xyz |
| Program p2 compiled vs VER_2 |
โ | โ | xyz@@VER_2v2 xyz |
A version script is a plain text file you pass to the linker. It defines which symbols belong to which version tag, and which symbols are public vs hidden.
The version script for the first release of a library looks like this:
/* sv_v1.map - version script for libsv version 1 */
VER_1 {
global: xyz; /* xyz() is visible outside the library */
local: *; /* everything else is hidden */
};
global: โ symbols listed here are exported (visible to programs linking the library).
local: *; โ the wildcard hides all other symbols. This is good practice โ you only expose what you intend to.
MYLIB_1.0, GLIBC_2.1, etc. โ something that includes the library name and version number./* sv_lib_v1.c โ original library with one version of xyz() */
#include <stdio.h>
void xyz(void) { printf("v1 xyz\n"); }
void pqr(void) { printf("v1 pqr\n"); }
# Compile to position-independent object
gcc -g -c -fPIC -Wall sv_lib_v1.c
# Link as shared library, applying the version script
gcc -g -shared -o libsv.so sv_lib_v1.o -Wl,--version-script,sv_v1.map
/* sv_prog.c โ program that calls xyz() */
#include <stdio.h>
void xyz(void);
int main(void) {
xyz();
return 0;
}
# Build program p1 against the library
gcc -g -o p1 sv_prog.c libsv.so
# Run it
LD_LIBRARY_PATH=. ./p1
# Output: v1 xyz
At this point p1 is linked against xyz@VER_1. This binding is permanently recorded in the executable’s symbol table.
Now we want to change xyz() but keep backward compatibility for p1. We write a new source file that contains both implementations and uses the .symver assembler directive to tag each one.
/* sv_lib_v2.c โ library v2 with two versions of xyz() */
#include <stdio.h>
/*
* .symver syntax:
* __asm__(".symver real_fn_name, exported_name@VERSION_TAG");
*
* Single @ = non-default binding (used by old programs)
* Double @@ = DEFAULT binding (used by new programs linking fresh)
*/
__asm__(".symver xyz_old, xyz@VER_1"); /* old programs use this */
__asm__(".symver xyz_new, xyz@@VER_2"); /* new programs use this */
void xyz_old(void) { printf("v1 xyz\n"); } /* old behavior */
void xyz_new(void) { printf("v2 xyz\n"); } /* new behavior */
void pqr(void) { printf("v2 pqr\n"); }
The updated version script adds VER_2 and declares that VER_2 depends on VER_1:
/* sv_v2.map โ version script for libsv v2 */
VER_1 {
global: xyz;
local: *; /* hides xyz_old, xyz_new, etc. */
};
VER_2 {
global: pqr; /* new function exported in v2 */
} VER_1; /* VER_2 inherits rules from VER_1 */
local: *; in VER_1 is critical. Because VER_2 inherits from VER_1, the wildcard also hides xyz_old and xyz_new from being exported directly. Only the versioned aliases xyz@VER_1 and xyz@@VER_2 are exported.# Rebuild the shared library with the new version script
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
# Build program p2 (will bind to VER_2 โ the @@ default)
gcc -g -o p2 sv_prog.c libsv.so
# Verify both programs work correctly
LD_LIBRARY_PATH=. ./p1 # Output: v1 xyz (uses xyz@VER_1)
LD_LIBRARY_PATH=. ./p2 # Output: v2 xyz (uses xyz@@VER_2)
We can confirm which version each program is bound to by inspecting its symbol table:
# Show version tag recorded in p1
objdump -t p1 | grep xyz
# 08048380 F *UND* 0000002e xyz@@VER_1
# Show version tag recorded in p2
objdump -t p2 | grep xyz
# 080483a0 F *UND* 0000002e xyz@@VER_2
# Alternative: use readelf
readelf -s p1 | grep xyz
readelf -s p2 | grep xyz
| Syntax | Meaning | Use case |
|---|---|---|
| xyz@VER_1 | Non-default binding | Old programs compiled against VER_1 |
| xyz@@VER_2 | Default binding (@@) | New programs linking fresh to the library |
.symver directive for a given symbol must use @@. This is the version new programs will bind to by default. All other older versions use single @.Version dependencies can be chained across multiple releases:
/* sv_v3.map โ three-generation version script */
VER_1 {
global: xyz;
local: *;
};
VER_2 {
global: pqr;
} VER_1; /* VER_2 depends on VER_1 */
VER_3 {
global: newapi;
} VER_2; /* VER_3 depends on VER_2 (which depends on VER_1) */
/* Library source with three generations of xyz */
#include <stdio.h>
__asm__(".symver xyz_v1, xyz@VER_1");
__asm__(".symver xyz_v2, xyz@VER_2");
__asm__(".symver xyz_v3, xyz@@VER_3"); /* VER_3 is the current default */
void xyz_v1(void) { printf("xyz: original behavior\n"); }
void xyz_v2(void) { printf("xyz: version 2 behavior\n"); }
void xyz_v3(void) { printf("xyz: version 3 behavior\n"); }
void pqr(void) { printf("pqr: introduced in VER_2\n"); }
void newapi(void) { printf("newapi: introduced in VER_3\n"); }
This means programs compiled at any of these three generations will continue to get the right behavior โ all served from one .so file.
MYLIB_1.0, MYLIB_2.0, MYLIB_2.1 โ include the package name so version tags remain globally unique when inspecting a binary.@ marks a non-default version (used by programs that were already compiled against that version). Double @@ marks the default version โ this is what new programs will bind to when they link against the library. Exactly one .symver directive per symbol must use @@.*.map) that defines which symbols belong to which version tags, and visibility rules (global/local). It is passed to the linker using: gcc -shared -o libfoo.so foo.o -Wl,--version-script,foo.mapglobal:. This means internal implementation details (like xyz_old, xyz_new) are not visible outside the library โ only the versioned aliases are exported.objdump -t executable | grep symbol_name or readelf -s executable | grep symbol_name. The output will show the version tag (e.g., xyz@@VER_1 or xyz@@VER_2) recorded at link time.global: and local: specifications from VER_1. On Linux, this is mostly semantic โ it documents the evolution chain. The practical effect is that a local: *; in VER_1 will also hide symbols not explicitly declared in VER_2.