Back to course

Versioned Symbols

26 minadvancedModule 4: Versions and Invented SymbolsContinues from sym-copy-reloc
26 min · Module 4: Versions and Invented Symbols · Advanced

Why This Matters

In 2012 glibc replaced its malloc with a thread-safe implementation that used more memory and was slower on some workloads. Every program on every distribution broke. The fix is the subject of this concept, and it is a solution you can look up in any binary on your machine:

$ readelf --dyn-syms -W /lib/x86_64-linux-gnu/libc.so.6 | grep -E ' (malloc|free)@'
  1391: ... FUNC GLOBAL DEFAULT UND malloc@GLIBC_2.2.5 (5)
  1937: ... FUNC GLOBAL DEFAULT UND free@GLIBC_2.2.5 (5)

malloc@GLIBC_2.2.5 is not the name of a function. It is a name plus a requirement. The program is saying “give me the malloc from the 2.2.5 era”, and the library is free to provide an entirely different implementation as long as it provides that one too. Both versions live in the same library at once, and a program from 2004 keeps getting the behaviour it was compiled against.

There is no way to do that with a plain name. A name is one thing; this needs one name to be several things, addressable independently, while remaining one name to the source language.

A Simple Model

Three tables, and it is worth being precise about which side each belongs to, because “versioning” covers both directions and they are not symmetric.

.gnu.version 2 bytes per .dynsym entry: an INDEX. This is the per-symbol annotation. It is what a reference points at. .gnu.version_d verdef: the versions THIS FILE DEFINES. A tree, linked by parent pointers. .gnu.version_r verneed: the versions this file REQUIRES, grouped by the file they come from. This is a list of demands, not a table.

The index in .gnu.version is a version node number, and it means different things depending on which side of the file you are on. For a defined symbol it names the version this definition belongs to. For an undefined symbol it names a required version, resolved through .gnu.version_r into a requirement on a named file.

So the two structures are genuinely different in kind. verdef is a tree: version 3 inherits from version 2, which inherits from version 1, and the chain is what makes “anything that works with V1 also works with V3” a checkable statement. verneed is a list: this file needs these versions of these names from these libraries. There is no inheritance in a requirement, because a requirement is a fact about one specific reference.

Does It Actually Work?

Decode .gnu.version_d from a real versioned library:

$ python3 symtables.py ver/libv.so
-- .gnu.version_d: what this file DEFINES --
    ndx=1  flags=BASE  hash=0x02f995ef  cnt=1
        verdaux[0] = 'libv.so'         own name
        elf_hash('libv.so') = 0x02f995ef  MATCHES vd_hash
    ndx=2  flags=none  hash=0x00000591  cnt=1
        verdaux[0] = 'V1'              own name
        elf_hash('V1') = 0x00000591     MATCHES vd_hash
    ndx=3  flags=none  hash=0x00000592  cnt=2
        verdaux[0] = 'V2'              own name
        verdaux[1] = 'V1'              PARENT
    ndx=4  flags=none  hash=0x00000593  cnt=2
        verdaux[0] = 'V3'              own name
        verdaux[1] = 'V2'              PARENT

Finding 11: vd_cnt is not a name count. The cnt for V2 and V3 is 2, and a reader who takes it as “this version has two names” will be wrong. The last verdaux of a node is its parent version name, and that entry is counted. A node with a parent therefore always has cnt >= 2, and V1 — the root — has cnt = 1.

And Finding 12: vd_hash is the plain ELF hash. The same function .hash and .gnu.hash use, verified four times over including the SONAME. This is not a coincidence and it is load-bearing: the loader can reject a version mismatch with a 32-bit comparison instead of a string comparison, before doing any lookup. Note that the consecutive values 0x591, 0x592, 0x593 prove nothing by themselves — V1, V2 and V3 differ in one character. The proof is the SONAME hash, 0x02f995ef, which is not near its neighbours.

Now the requirement side, from the same library:

-- .gnu.version_r: what this file REQUIRES --
    from libc.so.6
      name=GLIBC_2.2.5   hash=0x09691a75   flags=0   other=5

One requirement, from one file, naming one version. Compare its structure to the definition tree above: no parent, no chain, no hierarchy. A requirement is a flat assertion, and the “other=5” is the index that .gnu.version uses to point at it. The two tables are joined by a number, which is why they must be read together.

A Real Example

Building a versioned library, and the three traps. First, the default version and its @@ marker:

$ cat ver/vmap
V1 { global: compute; local: *; };
V2 { global: added_in_v2; } V1;
V3 { global: compute; added_in_v2; removed_in_v3; vtag; } V2;

$ clang -fPIC -shared -Wl,--version-script=ver/vmap -o libv.so ver/vlib.c
$ llvm-nm -D --defined-only libv.so
0000000000000000 A V1@@V1
0000000000000000 A V2@@V2
0000000000000000 A V3@@V3
0000000000001110 T added_in_v2@@V2
0000000000001100 T compute@@V1
0000000000001120 T removed_in_v3@@V3
0000000000001130 T vtag@@V3

Trap one, and it is silent.compute is listed in global: for both V1 and V3, and exactly one entry survives: compute@@V1. A symbol may belong to only one version node, and the first node that claims it wins. The later mention is discarded with no diagnostic. A library author who lists a symbol in every version expecting the latest to be the default gets the oldest one, silently.

$ cat ver/vmap_at
V1 { global: compute@V1; local: *; };
$ clang -fPIC -shared -Wl,--version-script=ver/vmap_at -o /dev/null ver/vlib.c
ver/vmap_at:1: ignoring invalid character `@' in script
syntax error in VERSION script

Trap two: name@version is not version-script syntax. That spelling is an assembly .symver directive. In a version script you list bare names and the linker decides the default. Anyone arriving from .symver will get this wrong, and the error message points at the character rather than the concept.

$ clang -fPIC -shared -fvisibility=hidden \
      -Wl,--version-script=ver/vmap -o libh.so ver/vlib.c
$ llvm-nm -D --defined-only libh.so
0000000000000000 A V1@@V1
0000000000000000 A V2@@V2
0000000000000000 A V3@@V3

Trap three, and this one connects straight back to Visibility Is Not Binding. The script says global: compute and compute is still not exported. A version script can only narrow visibility, never confer it. -fvisibility=hidden has already demoted the binding to LOCAL, and a filter over the exported set cannot bring back something that is not in the set.

That is three separate ways to write a version script and get a library that does not export what you expected, with two of them silent. The lesson is not about version scripts; it is that a version script is a filter with no diagnostic path, and a filter that silently drops entries is indistinguishable from a filter that worked.

Try It Yourself

$ cd courses/sym/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/Findings 11-15/,$p'
$ python3 symtables.py ver/libv.so
$ python3 crosscheck.py 2>&1 | sed -n '/\[11\]/,/^$/p'

Then break each structure deliberately, which is the only way to learn what a field is for:

  1. Decode .gnu.version_d by hand from the bytes. For
     V3, list every verdaux and say which is the parent.
     Then write the vd_cnt you would have PREDICTED and
     compare. Why is the real number one higher?

  2. elf_hash('V1')=0x591. What is elf_hash of a name
     one character longer? Does the +1 pattern in the
     file prove anything? (Hint: test the SONAME.)

  3. Write a version script where V3's parent is V1
     (skipping V2). Does the chain still work? What did
     you lose?

  4. Build the same library three ways -- default,
     -fvisibility=hidden, and a script with no global:
     clause -- and count the exports each time. Which
     filter has the widest effect?

Question 3 has the answer that matters most for design. The parent chain is not a convenience; it is how compatibility is expressed. A client that requires V1 is satisfied by a library that defines V3 as long as V3 descends from V1, because the requirement is checked against the chain rather than against the name. Break the chain and V3 no longer satisfies a V1 requirement — you have created a new, incompatible library that happens to reuse a name. That is the whole reason the field exists, and it is the same reason a struct you have extended with a new trailing field can be passed to old code and the reverse is not true.

Connect

This is the largest concept in the course and it has three distinct ancestors. Two Hash Tables, One Job supplied the hash function that Finding 12 reuses, and the base-name rule it established is what makes a versioned lookup a single chain walk followed by a comparison. Visibility Is Not Binding supplied trap three, and it is the concept that explains why a filter cannot re-export: the decision was made by demoting a binding, and filters operate downstream of that.

Forward, the connection to Who Owns the Storage is the pairing this module promised. That concept is a symbol whose storage must change without relinking its users; this is a symbol whose behaviour must change without relinking its users. Both are the same problem — you cannot force a rebuild of every binary in the field — and the platform has exactly two answers. Version the name when the behaviour changes, and do not export storage when the ownership is ambiguous. glibc needed the first in 2012 and the second even more, which is a large part of why modern glibc exports functions and hides its variables.

Back to the static half, versioning is the sharpest possible answer to the constraint that closed Module 2. Two Definitions and One Tentative observed that a format which lets each producer record a local decision needs those decisions to interoperate, or the decision has to move. COMMON moved it to the compiler and then broke every prebuilt library. Versioning moves it into the file and makes the compatibility relation explicit and checkable — the chain in verdefis the interoperability rule, written down where both sides can see it.

And one connection outside the course, because the shape recurs constantly. Versioning is ABI evolution, and ABI evolution is the practice of changing a contract without being able to change everyone who depends on it. The same problem is solved by parallel namespacing in Python 3, by std:: versioning in the C++ standard library, by Android’s API levels, and by keeping a libfoo.so.1 beside a libfoo.so.2 in /usr/lib. The ELF mechanism is interesting because it puts several versions inside one file rather than in parallel files, which saves a file descriptor per version and makes the compatibility check part of loading rather than part of packaging. The cost is the complexity you have just read, and the reason it is worth paying is that the alternative does not scale to a system with thousands of independently-built binaries.