Compilers · Linkers and Loaders
Symbol Interposition and the Global Namespace
The search order that decides which definition of a name wins, why a shared library cannot rely on its own globals, and how preloading and interposition actually take effect.
Every global symbol in a dynamically linked process lives in one namespace, and the first definition loaded wins. That single rule, applied to a list of objects ordered by the loader, explains why LD_PRELOAD works, why a library’s internal call to its own function may not reach its own implementation, and why so much of the cost of dynamic linking is paid to preserve the possibility of being overridden.
The rule is not an implementation accident. It is the definition of the global symbol scope in the ELF dynamic linking model, and it exists because interposition is a feature: a debugger, a tracing tool, or a test harness can replace a function for an entire process without recompiling anything. The price is that every reference to a global in a shared object must remain indirect.
The scope list and the order within it
When the dynamic linker resolves a symbol, it searches a scope: an ordered list of loaded objects. For a program’s own references the order begins with the executable, then the executable’s DT_NEEDED dependencies in the order they appear, then their dependencies breadth-first, and so on. A preloaded object is inserted near the front, before the executable’s normal dependencies, which is why it wins against a library’s own definition.
The executable’s position first is deliberate and slightly asymmetric. The executable’s global definitions take precedence over those of the libraries it links, even though those libraries were loaded afterwards. This makes it possible to link a program with its own definition of a library function, which the static linker resolves at link time, and the dynamic linker confirms at load time by placing the executable first. Without this, a program that provided a strong definition of a symbol also defined in a library would resolve to the library’s version, contradicting what the static link had decided.
The asymmetry goes further: the executable does not export its symbols to libraries unless it was linked with the option that puts them in the dynamic symbol table. On ELF platforms that option is -rdynamic (or --export-dynamic), which causes the static linker to add global definitions to .dynsym. Without it, a library cannot see the executable’s functions, and a callback registered by name — the classic case being a plugin that looks up a function the host program provides — fails with an undefined symbol. The static symbol table is unaffected; only the dynamic one matters for runtime resolution.
Why a library’s own calls go through the PLT
The consequence of the global scope is that a shared object’s call to a function it defines itself is still subject to interposition. If the compiler emitted a direct relative call, the call would reach the local definition and no preloaded object could replace it. To preserve the feature, the compiler emits a call through the PLT, and the loader resolves the symbol in the global scope, which may find another module’s definition first.
This is the semantic interposition model: the definition in the calling module is one candidate among many, and it loses if an earlier object defines the same name. The cost is that even a purely internal call pays an indirect jump and, under lazy binding, a resolver visit. For a library that makes many internal calls to its own functions, the overhead is systematic.
The escape hatches are visibility and the linker’s knowledge of the local definition. Marking a function static makes it local and removes it from the dynamic symbol table entirely, so calls to it are direct. Marking it with hidden visibility achieves the same for a symbol that must remain global within the link unit but should not be exported: the static linker sees the definition in the same output, resolves the reference directly, and does not create a dynamic relocation for it. Protected visibility is the third option: the symbol is exported, but references within the defining module bind to the local definition rather than to an earlier one in the global scope. It gives interposition to external callers while denying it to the module itself, which is what a library wants when its internal invariants depend on its own implementation.
The compiler-side equivalent is -fno-semantic-interposition, which tells the compiler to assume that definitions within the translation unit are not overridden, enabling inlining and direct calls. It is a promise the program must keep: if something does interpose the symbol, the optimised calls bypass it and the interposition silently does not apply to those call sites.
How preloading actually takes effect
LD_PRELOAD names a list of objects to load before the program’s own dependencies. They are mapped, their constructors are queued, and they are inserted into the global scope ahead of everything the program asked for. A function defined in a preloaded object therefore wins every lookup for that name, including lookups made from inside the libraries that also define it.
Three conditions must hold for this to work, and each is a common source of confusion.
The symbol must be dynamically resolvable. If the target library’s definition is hidden, or if the reference was resolved at static link time to a local definition because of protected visibility or -fno-semantic-interposition, preloading has no effect on that call site. Preloading replaces the definition found by the dynamic lookup; it cannot rewrite an already direct call.
The preloaded object must export the symbol with the same version, if the original is versioned. As described in SONAME, DT_NEEDED, and Symbol Versioning, a versioned reference matches only a definition carrying the same version. A preloaded object that defines the plain name without the version will not satisfy a reference to foo@GLIBC_2.2.5, and the original definition wins.
The preloaded definition must be compatible with the original’s ABI. The loader does not check signatures. A preloaded function with a different parameter list is called with the caller’s registers and whatever it does with them is undefined behaviour, not a load error.
The classic use is to interpose an allocator or a system-call wrapper to observe or change behaviour without recompiling the program. The classic failure is interposing a function that the target library calls internally through a direct call because of -fno-semantic-interposition, leaving the interception incomplete in a way that is invisible until behaviour is compared in detail.
/* A preloadable interposer for a libc function. The original definition is
found with dlsym using RTLD_NEXT, which searches after this object. */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <stdarg.h>
#include <string.h>
#include <unistd.h>
static int (*real_open)(const char *, int, ...);
int open(const char *path, int flags, ...) {
if (!real_open)
real_open = (int (*)(const char *, int, ...))dlsym(RTLD_NEXT, "open");
/* Diagnostic output goes through write, not stdio: stdio itself routes
through interposable functions and could re-enter this one. */
write(2, path, strlen(path));
/* mode is only present when the call site passed it. */
int mode = 0;
if (flags & (O_CREAT | O_TMPFILE)) {
va_list ap;
va_start(ap, flags);
mode = va_arg(ap, int);
va_end(ap);
}
return real_open(path, flags, mode);
}
RTLD_NEXT is the mechanism that makes an interposer able to call the original. It tells the lookup to begin after the current object in the scope order, so the interposer does not find itself. Without it, a naive dlsym(RTLD_DEFAULT, "open") returns the interposer’s own address and the call recurses until the stack is exhausted.
Reading a variadic argument that the call site never passed is undefined behaviour rather than a wrong value, which is why the mode fetch above is guarded by the flags that make it present. The same mistake appears in most naive wrappers around variadic libc functions, and it is invisible until a call site omits the argument.
Namespaces and the local scope
The single global scope is the default, but it is not the only one. dlmopen creates a separate namespace with its own scope list and its own copy of the libraries in it. A symbol lookup in that namespace searches only its own objects, so two namespaces can hold two versions of the same library with independent state, and interposition does not cross between them.
The cost is duplication. Each namespace maps its own copies of the libraries, so a process with several namespaces has several copies of libc’s data, including its allocator state and its locale tables. Objects do not share state across namespaces, which is the point and also the hazard: a pointer to an object allocated in one namespace cannot be safely passed to code in another if that code will free it.
A more common variant is the local scope. When a library is loaded with RTLD_LOCAL, its symbols are not added to the global scope, so later lookups do not see them and it cannot interpose anything. Only the object that loaded it, and objects it loads in turn, resolve against it. This is the default for dlopen on most systems, and it is why a plugin loaded with RTLD_LOCAL cannot accidentally shadow a symbol for the whole process. RTLD_GLOBAL opts into the opposite, adding the object to the global scope so subsequent loads can use it — necessary when one plugin provides symbols another plugin needs, and dangerous for the same reason.
Resolution depth and the copy that is not shared
Because the first definition wins, a symbol’s address is decided once, and every module’s reference to it is patched to that address. The copy relocations described in Position-Independent Code and the Global Offset Table are the exception that proves the rule: a non-PIE executable that references a library’s variable gets its own copy, and the library’s GOT entry is redirected to that copy so both sides agree. The variable now lives in the executable’s data segment, and the library’s initialisation of it happens by copying at load time rather than by construction.
That redirection is itself a form of interposition, applied in the opposite direction: the executable’s storage wins, and the library is made to use it. The consequence for initialisation is subtle. The copy is made at load time from the library’s initialised image, so a variable with a static initialiser arrives with the right value. A variable that a constructor initialises is different: the constructor runs later and writes through the library’s GOT entry, which has already been redirected to the copy, so the write lands in the executable’s storage. The value is correct, but only after the constructor runs, and any code in another module’s constructor that read the variable earlier saw the static initialiser instead.
Limits and failure modes
The global scope makes it impossible for a library to guarantee that its own functions are the ones being called. Code that depends on this, such as a library implementing a security check that calls a comparison routine it defines, is relying on a property the linker does not provide. Hidden visibility and protected visibility are the tools that restore the guarantee, and applying them systematically is the difference between a library whose internal behaviour is predictable and one whose behaviour depends on load order.
Deep dependency chains make the scope order expensive to search. A process with a hundred loaded objects resolves a name by walking a list that may be a hundred entries long, comparing it against each object’s hash table in turn. The search runs once per reference, because its result is written into the slot that reference uses: a data slot is filled at load time, a jump slot at load time or on first call. Interposition therefore has to be settled by the time a slot is filled, and nothing reconsiders it afterwards, which is why an object loaded later cannot change the meaning of an already-patched reference and why lazy binding only moves the decision from load time to first call.
Diagnosing a wrong binding is a matter of reading the scope order. A symbol resolving to an unexpected definition usually means an object earlier in the list defines it — a preloaded library, a plugin loaded with RTLD_GLOBAL, or the executable itself with -rdynamic. The symbol tables of each candidate show the definitions; the load order, which can be observed from the loader’s trace output, gives the ranking.
References
- elf(5) — Linux manual page — Linux man-pages project
- ld.so(8) — 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