Linker Version Scripts & Symbol Versioning Controlling Visibility and Versioning with .map Files

 

Linker Version Scripts & Symbol Versioning
TLPI Chapter 42.3 — Controlling Visibility and Versioning with .map Files
42.3
Section
3
Code Examples
6
Interview Q&A

Key Terms
Version Script (.map) –version-script Version Node Version Tag global / local Symbol Versioning Wildcard * glibc libc.so.6 readelf Anonymous Version Tag

What is a Version Script?

A version script is a plain text file (usually with a .map extension) that you pass to the GNU linker (ld) when building a shared library. It gives you two powerful capabilities:

  1. Symbol visibility control — Precisely declare which symbols are public (global) and which are hidden (local), using wildcards if needed.
  2. Symbol versioning — Allow a single shared library to simultaneously provide multiple versions of the same function, so old and new programs can both run against the same library file without conflicts.

You activate a version script with the linker option:

gcc -Wl,--version-script,yourscript.map ...

Section 42.3.1 — Controlling Symbol Visibility with Version Scripts
The Problem It Solves

Even when you carefully write your library, the C compiler makes all non-static functions globally visible by default. When you build a shared library from multiple .c files, all their functions end up exported — even the ones you only intended as internal helpers that are called between your own source files.

A version script lets you say: “only these specific functions are public, hide everything else” — in one place, without modifying every source file.

Anatomy of a Version Script
Structure of a .map file
VER_1 {
global:
vis_f1;  /* exported — part of public ABI */
vis_f2;  /* exported */
local:
*;       /* hide EVERYTHING else */
};

Breaking down each part:

Element What It Means
VER_1 { ... }; A version node. Contains declarations for one version. The name (VER_1) is the version tag. In modern ld it can be omitted (anonymous version tag).
global: Symbols listed here are exported — visible to any program linking against this library.
local: Symbols listed here are hidden — invisible to the outside world.
*; A wildcard — matches all symbols not explicitly listed. Using local: *; means “hide everything that wasn’t explicitly declared global.”
Important: Without the local: *; line, all symbols not mentioned in global: remain visible by default — you must explicitly hide the rest.

Code Example 1 — Hiding Internal Symbols with a Version Script
Library with a shared internal function that must not be exported

We have three source files: vis_comm.c (internal helper), vis_f1.c and vis_f2.c (public API). Both f1 and f2 call the internal comm function.

/* vis_comm.c — internal helper, NOT for external use */
#include <stdio.h>

void vis_comm(void) {
    printf("vis_comm: shared internal logic\n");
}
/* vis_f1.c — public API function 1 */
#include <stdio.h>

void vis_comm(void);   /* use internal helper */

void vis_f1(void) {
    printf("vis_f1: called\n");
    vis_comm();
}
/* vis_f2.c — public API function 2 */
#include <stdio.h>

void vis_comm(void);

void vis_f2(void) {
    printf("vis_f2: called\n");
    vis_comm();
}

Now the version script vis.map:

# vis.map — export only vis_f1 and vis_f2, hide vis_comm
VER_1 {
    global:
        vis_f1;
        vis_f2;
    local:
        *;    # hide everything else (including vis_comm)
};

Build without the version script first, then with it:

# Build WITHOUT version script — vis_comm is accidentally exported
gcc -g -fPIC -c -Wall vis_comm.c vis_f1.c vis_f2.c
gcc -g -shared -o vis_bad.so vis_comm.o vis_f1.o vis_f2.o
readelf --dyn-syms vis_bad.so | grep vis_
# Output shows: vis_f1, vis_f2, AND vis_comm — all exported!

# Build WITH version script — vis_comm is hidden
gcc -g -shared -o vis_good.so vis_comm.o vis_f1.o vis_f2.o \
    -Wl,--version-script,vis.map
readelf --dyn-syms vis_good.so | grep vis_
# Output shows: ONLY vis_f1 and vis_f2 — vis_comm is hidden!
Verify with readelf: Symbols showing GLOBAL DEFAULT are exported. Symbols not listed (or showing LOCAL) are hidden. The version script causes vis_comm to disappear from the dynamic symbol table entirely.

Section 42.3.2 — Symbol Versioning
The Problem: Incompatible Library Changes

Normally, if you change the behavior or signature of a function in your shared library, you must bump the major version number (e.g., libfoo.so.1 → libfoo.so.2). All existing programs linked against the old version must be recompiled or they break.

Symbol versioning solves this by allowing a single shared library to provide multiple implementations of the same function — one for old programs, one for new programs. Each program is “locked in” to the version that existed when it was compiled.

Real-world example: This is exactly how glibc works. libc.so.6 is a single file that contains multiple versioned implementations of functions like memcpy(). Programs compiled 10 years ago use the old version, while new programs use the current version — from the same .so file.

How Symbol Versioning Works — The Mechanism
Timeline of Library Evolution with Symbol Versioning
v1
xyz@@VER_1 — original implementation. Programs compiled now will link to VER_1 of xyz.
▼ library updated, xyz behavior changes
v2
xyz@@VER_2 (new default) + xyz@VER_1 (old compat version). New programs use VER_2. Old programs still use their VER_1.
Both old and new programs run against the same single .so file simultaneously.

When a program is linked against the library, the linker records the version tag of each symbol it used (e.g., xyz@@VER_1). At runtime, the dynamic linker matches that specific versioned symbol — even if a newer version exists in the same .so file.

Code Example 2 — Symbol Versioning Step by Step
Building Library V1 and linking a program against it
/* sv_lib_v1.c — version 1 of the library */
#include <stdio.h>

void xyz(void) {
    printf("v1 xyz: original implementation\n");
}
# sv_v1.map — version script for V1
VER_1 {
    global: xyz;
    local: *;    # hide everything else
};
# Build V1 library
gcc -g -fPIC -c -Wall sv_lib_v1.c
gcc -g -shared -o libsv.so sv_lib_v1.o -Wl,--version-script,sv_v1.map
/* sv_prog.c — program that uses the library */
#include <stdlib.h>

int main(int argc, char *argv[]) {
    void xyz(void);   /* forward declaration */
    xyz();
    return 0;
}
# Link program p1 against the library
gcc -g -o p1 sv_prog.c libsv.so

# Run it
LD_LIBRARY_PATH=. ./p1
# Output: v1 xyz: original implementation

# Check which version of xyz p1 is bound to
readelf --syms p1 | grep xyz
# Shows: xyz@@VER_1  (bound to VER_1)

Code Example 3 — Upgrading the Library While Keeping Backward Compatibility
Adding VER_2 while keeping VER_1 for old programs
/* sv_lib_v2.c — version 2 with a new xyz AND the old xyz for compatibility */
#include <stdio.h>

/* Old v1 implementation kept for backward compatibility */
__asm__(".symver xyz_old, xyz@VER_1");
void xyz_old(void) {
    printf("v1 xyz: original implementation (compat)\n");
}

/* New v2 implementation — default for new programs */
__asm__(".symver xyz_new, xyz@@VER_2");
void xyz_new(void) {
    printf("v2 xyz: improved new implementation!\n");
}
# sv_v2.map — version script for V2
VER_1 {
    global: xyz;
    local: *;
};

VER_2 {
    global: xyz;
} VER_1;    # VER_2 depends on VER_1 (inheritance)
# Build V2 library — replaces libsv.so
gcc -g -fPIC -c -Wall sv_lib_v2.c
gcc -g -shared -o libsv.so sv_lib_v2.o -Wl,--version-script,sv_v2.map

# Link new program p2 against V2 library
gcc -g -o p2 sv_prog.c libsv.so
# Test — the old program p1 still uses v1 xyz
LD_LIBRARY_PATH=. ./p1
# Output: v1 xyz: original implementation (compat)

# New program p2 gets v2 xyz
LD_LIBRARY_PATH=. ./p2
# Output: v2 xyz: improved new implementation!
Key insight: p1 was compiled when only VER_1 existed. Its ELF file contains the requirement xyz@VER_1. Even after the library is updated, the dynamic linker routes p1‘s call to the VER_1 implementation. p2, compiled after the upgrade, gets xyz@@VER_2.

Version Script Syntax Details
Version Tags, Dependencies, and Wildcards
Syntax Element Explanation Example
Version tag Name of the version node. Can be any identifier. Older ld versions required it; modern ld allows anonymous tags (no name). VER_1 { ... };
Anonymous tag Version node with no name. Only one can exist in the script. { global: foo; local: *; };
global: Starts list of exported symbols (semicolon-separated) global: func_a; func_b;
local: Starts list of hidden symbols local: *;
Wildcard * Matches all remaining symbols. Commonly used as local: *; to hide everything not explicitly exported. local: *;
Wildcard ? Matches a single character, like shell globbing global: get_?;
Version dependency Declares that one version inherits all symbols of another VER_2 { ... } VER_1;
# comment Everything after # is ignored # This is a comment
@@ default version Symbol with @@ is the default version for new links xyz@@VER_2
@ compat version Symbol with single @ is a compatibility version only, not the default xyz@VER_1

Reading the Version Information with readelf
# Show dynamic symbol table with versions
readelf --dyn-syms libsv.so

# Show version definitions in the library
readelf --version-info libsv.so

# Check which symbol versions a program requires
readelf --version-info p1

After building with a version script, readelf --dyn-syms will show something like:

Symbol table '.dynsym' contains N entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     5: 00001234    45 FUNC    GLOBAL DEFAULT   12 xyz@@VER_2
     6: 00001200    45 FUNC    GLOBAL DEFAULT   12 xyz@VER_1
The @@ means this is the default version (what new programs link to). A single @ means compatibility-only (old programs already linked to this version will use it, but new links won’t).

Real World: How glibc Uses Symbol Versioning

The GNU C Library is the most famous example of symbol versioning in practice. All versions of glibc from 2.0 onward ship as a single major library file: libc.so.6.

Inside this single file, there are versioned implementations of many functions. For example, memcpy has different versions for different glibc releases. A program compiled against glibc 2.14 gets memcpy@@GLIBC_2.14. A program compiled against glibc 2.2.5 uses the older memcpy@GLIBC_2.2.5.

# See all versioned symbols in libc.so.6
readelf --version-info /lib/x86_64-linux-gnu/libc.so.6 | head -30

# See what versions a compiled binary depends on
readelf --version-info /bin/ls
This is why you can run binaries compiled years ago on a modern Linux system. The libc.so.6 file they were linked against still exists and still contains the exact symbol version they need.

Interview Questions & Answers
Q1. What is a linker version script and what file extension does it typically use?
A linker version script is a plain text file containing instructions for the GNU linker (ld) that control which symbols are exported from a shared library and optionally define symbol version tags. It is typically given the .map extension, though this is a convention and not required. It is activated with the linker option -Wl,--version-script,script.map.
Q2. Explain the global and local keywords in a version script.
Inside a version node, global: begins a semicolon-separated list of symbols that will be exported from the shared library — these are visible to programs linking against it. local: begins a list of symbols that will be hidden from the outside world. The wildcard * can be used in either section. The common pattern local: *; means “hide everything not explicitly declared global,” which is the safest default.
Q3. What is symbol versioning and what problem does it solve?
Symbol versioning allows a single shared library file to simultaneously provide multiple implementations of the same function, identified by version tags. It solves the backward compatibility problem: normally, if you change the behavior of a library function, programs compiled against the old version break. With symbol versioning, old programs continue to use the old version (which is preserved in the library), while new programs use the new version — from the same .so file. This avoids the need to increment the library’s major version number for every incompatible change.
Q4. What is the difference between xyz@@VER_2 and xyz@VER_1 in a versioned symbol table?
xyz@@VER_2 (double @) marks the default version — when a new program links against the library and references xyz, the linker binds it to this version. xyz@VER_1 (single @) marks a compatibility version — it exists in the library for old programs that were already linked against VER_1, but new links will not use it by default. Only one version can be the default (@@); others are compat-only (@).
Q5. What is the .symver assembly directive used for?
The .symver GNU assembly directive is used in C source code to associate a function implementation with a specific version tag. The syntax is: __asm__(".symver actual_fn_name, exported_name@VERSION_TAG");. This allows you to have two C functions with different names (e.g., xyz_v1 and xyz_v2) that are both exported under the same public name (xyz) but with different version tags. New programs calling xyz get xyz_v2; old programs that recorded xyz@VER_1 at link time get xyz_v1.
Q6. Name two ways to control symbol visibility in a shared library and compare them.
Two common approaches: (1) GCC visibility attributes — use __attribute__((visibility("hidden"))) on individual functions and compile with -fvisibility=hidden. This is source-code level and affects one function at a time. Advantage: explicit in the source; Disadvantage: requires modifying every source file. (2) Version scripts — write a .map file with global: and local: *; entries and pass it to the linker. This is linker-level and can control many symbols at once using wildcards without touching source files. Version scripts also enable symbol versioning, which visibility attributes cannot do.

Chapter 42 — Advanced Shared Library Features
You have completed sections 42.1.6, 42.2, and 42.3

← Prev: Symbol Visibility EmbeddedPathashala Home

Leave a Reply

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