Compilers · Linkers and Loaders
SONAME, DT_NEEDED, and Symbol Versioning in Shared Libraries
How a shared object records the name it wants to be found under, why DT_NEEDED names a soname rather than a file, and how version definitions keep old binaries working.
A shared library has three names that are easy to confuse. There is the file name, which is what the filesystem knows. There is the soname, which is what the library declares it wants to be recorded as. And there is the name the linker writes into a consumer’s dynamic section, which is the soname, not the file name. Separating them is what allows a library to be replaced by a newer build without relinking anything that uses it.
The mechanism is small: a DT_SONAME entry in the dynamic section, a DT_NEEDED entry per dependency, and a lookup order in the loader. The consequences are large, because the soname is effectively a promise about binary compatibility, and versioning is how that promise gets refined when the library needs to change.
The three names and where each is used
When a library is linked with a soname, the linker records it in a DT_SONAME string entry and writes the soname into the DT_NEEDED entry of every consumer. The file name may differ from the soname: libfoo.so.1.4.2 is the file, libfoo.so.1 is the soname, and libfoo.so is a link-time-only symlink used when building against the library. A consumer’s DT_NEEDED contains libfoo.so.1, and at load time the loader searches its paths for a file with that name.
This indirection is what makes upgrades work. Installing libfoo.so.1.4.3 and repointing the libfoo.so.1 symlink replaces the library for every existing consumer without touching any of them. The consumers never named 1.4.2, so they are unaffected by the patch.
The version number in the soname is a compatibility marker, not a release marker. It changes when the ABI changes: a new soname libfoo.so.2 is installed alongside libfoo.so.1, and old consumers keep working against the old one while new builds link against the new one. Distributions rely on this co-installation, and the packaging rules that require it are the practical enforcement of the convention.
The libfoo.so symlink without a version is normally provided only by the development package, since it is only needed when the linker resolves -lfoo to a file. A runtime-only installation that lacks it is correct, which is why a link can fail with a message about libfoo.so not being found while the program that uses libfoo.so.1 runs perfectly.
How the loader searches
The loader’s search order is fixed and each step is a decision point. A DT_NEEDED entry containing a slash is treated as a path and used directly, with no search. Otherwise the loader consults DT_RPATH of the loading object, but only when that object carries no DT_RUNPATH, since RUNPATH superseded it; then the environment variable LD_LIBRARY_PATH, then DT_RUNPATH, then the cache file maintained by the dynamic linker, then the default directories such as /lib and /usr/lib.
The distinction between RPATH and RUNPATH is more than a rename. RPATH of an object applies to that object’s dependencies and to their dependencies, transitively. RUNPATH applies only to the direct dependencies of the object that carries it. The transitive behaviour of RPATH made it a security problem: a library deep in the dependency tree could inherit a search path the application never intended, and a writable directory in that path becomes a way to substitute code. RUNPATH was introduced to remove the transitivity, and modern linkers emit it by default.
The cache file is an optimisation with a failure mode. It maps library names to the paths they were found at, so the loader can locate a library without walking the default directories; ldconfig builds it. A directory that has been removed, or a library moved between directories, can produce a stale entry that the loader then has to correct by falling through to its search paths. Rebuilding the cache is an explicit administrative action, and a library installed somewhere outside the default directories is invisible to the loader until the cache has been rebuilt.
DT_NEEDED entries are processed breadth-first. The executable’s direct dependencies are loaded first, then their dependencies, and so on. Each library is loaded at most once per process, identified by the file it resolves to rather than by the name that requested it — so two different sonames that resolve to the same file produce one mapping. That identity rule matters for symbol resolution, as described in Symbol Interposition and the Global Namespace.
Version definitions and version needs
Symbol versioning refines the soname promise. Instead of a library having one namespace of symbol names, it can have several, each tagged with a version label such as GLIBC_2.34 or LIBFOO_1.2. A symbol table entry with a version has its name suffixed in the string table with @ and the version, and the dynamic section carries a table mapping version indices to labels.
Two structures do the work. .gnu.version is an array parallel to the dynamic symbol table, giving each symbol a 16-bit version index. .gnu.version_d holds version definitions — the versions this object provides — and .gnu.version_r holds version needs, one entry per dependency naming the versions the object requires from it. The loader checks needs against definitions at load time and refuses to load a module whose requirements are unmet.
The point of the check is to convert a subtle runtime failure into a load-time error. Without versioning, a program built against a newer library and run against an older one fails when it calls a function that turns out to have a different signature or, worse, silently misbehaves. With versioning, the program’s reference is recorded as requiring a version the old library does not define, and the loader reports a missing version symbol before main runs.
The two ways to bind a reference are worth distinguishing. A reference bound to foo@VER_1 is a non-default version and can only be reached by an explicit .symver directive in assembly. A reference bound to foo@@VER_2 names a default version: the linker resolves an ordinary call to foo against the default definition, and records the version in the consumer’s need. This is how a library can keep an old implementation of foo for existing consumers while new builds get the new one — both definitions live in the table, distinguished only by version.
/* Two implementations of one name, selected by the version the consumer was
linked against. The .symver directives precede the definitions, and a
version script localises parse_v1/parse_v2 so they are not separate exports. */
__asm__(".symver parse_v1, parse@LIBPARSE_1.0");
__asm__(".symver parse_v2, parse@@LIBPARSE_2.0");
int parse_v1(const char *s) { return s[0] == 'x'; } /* old behaviour */
int parse_v2(const char *s) { return s[0] == 'x' || s[0] == 'y'; }
Building that object with a version script that names LIBPARSE_1.0 and LIBPARSE_2.0 produces a library where a consumer linked before the change keeps calling parse_v1 and one linked after calls parse_v2, with no source change on either side.
The version script also controls which symbols are exported at all. Its global and local patterns decide visibility, which is a separate concern from versioning but shares the mechanism: a symbol not matched by any global pattern is local and does not appear in the dynamic symbol table. Hiding everything not explicitly exported reduces the size of the table, speeds up relocation, and prevents accidental interposition of internal names.
The limits of the scheme
Versioning cannot express every compatibility relation. A version attaches to a name, not to a type or a calling convention, so a change in a struct layout is invisible to the mechanism. If a function’s signature changes while the symbol name and version stay the same, consumers built against the old signature call it with the old argument layout and get whatever the new implementation does with those registers. Only a new soname catches that, and the convention is that structural ABI changes require one.
The scheme also assumes that versions are added, not renumbered. Deleting a version definition breaks every consumer that needed it, and the failure is at load time with a message naming the version, which is at least diagnosable. Renumbering a version in place is worse: consumers continue to load but bind to different code than they were built against.
There is a cost in symbol table size and in relocation work. Each versioned symbol occupies a string table entry with the suffix, each object carries version need records per dependency, and the loader must check them. For a large library with tens of thousands of exported symbols the bookkeeping is measurable, which is one reason version scripts usually export a curated subset rather than everything.
A subtler problem is that versioning interacts with interposition in a way that is not obvious. If a symbol is versioned and another module defines an unversioned symbol of the same name, the unversioned definition does not satisfy a versioned reference. The loader matches on the full name including the version suffix, so an interposing library must export the same versions to be a drop-in replacement. That is a deliberate restriction: it prevents an unrelated library from accidentally overriding a versioned interface, and it makes LD_PRELOAD interposition of versioned functions require matching declarations. The mechanics of that matching are the subject of Symbol Interposition and the Global Namespace.
Finally, versioning does nothing about the ordering of initialisation. A library’s constructors run in the order the loader determined, which follows the dependency graph but not any version relationship, and a constructor that depends on a newer version’s behaviour may run before or after the library providing it. The ordering rules and their failure modes belong with the load-time work described in Lazy Binding and the Procedure Linkage Table, since both are consequences of the same load sequence.
References
- elf(5) — Linux manual page — Linux man-pages project
- How to Write Shared Libraries — Ulrich Drepper
- System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI working group
- ld.so(8) — Linux manual page — Linux man-pages project