Symbol Resolution and Lookup Scope

When code in one ELF (Executable and Linkable Format) object references a function or variable defined in another — printf, malloc, a symbol from your own shared library — the dynamic linker ld.so must decide which definition wins among all the loaded objects. It does this by searching the loaded objects in a precisely defined order called the lookup scope, and the first definition it finds wins (per How To Write Shared Libraries). That single rule — first-match, not best-match — is the foundation of symbol interposition, the mechanism that makes LD_PRELOAD work. This note walks the lookup scope’s ordering (executable first, then DT_NEEDED dependencies breadth-first, with LD_PRELOAD objects spliced near the front), the rules for STB_GLOBAL versus STB_WEAK bindings and STV_DEFAULT/STV_PROTECTED/STV_HIDDEN visibilities, how dlopen’s RTLD_LOCAL/RTLD_GLOBAL carve out a local scope, what -Bsymbolic changes, and how LD_DEBUG lets you watch the whole thing.

Mental Model

Picture every loaded object — the executable and each shared library — as a desk with a name-card index (its dynamic symbol table, .dynsym). To resolve a reference, ld.so walks the desks in a fixed order and stops at the first desk that has a card for the name. It does not collect candidates and pick the “best” one; it takes the first. So the order of the desks is the policy. The order is mostly fixed at link time (the executable’s DT_NEEDED list determines the dependency walk), but two things deliberately splice objects toward the front of the walk: LD_PRELOAD (run-time injected libraries) and the object that owns a reference under -Bsymbolic. Because earlier desks shadow later ones, putting a desk first lets it interpose — override — any definition that would otherwise have been found deeper in the list.

flowchart TB
  REF["undefined symbol 'foo'<br/>referenced in some object"]
  REF --> SCOPE["build/consult lookup scope"]
  SCOPE --> P["LD_PRELOAD objects<br/>(spliced near front)"]
  P --> EXE["the executable itself"]
  EXE --> D1["DT_NEEDED deps of exe<br/>(in listed order)"]
  D1 --> D2["deps-of-deps<br/>(breadth-first, dups skipped)"]
  D2 --> LOCAL["dlopen local scope<br/>(only if reference is in a dlopen'd object<br/>loaded RTLD_LOCAL)"]
  P -.->|"first match<br/>wins"| WIN["first STB_GLOBAL or STB_WEAK<br/>STV_DEFAULT definition found<br/>= the binding"]
  EXE -.-> WIN
  D1 -.-> WIN
  D2 -.-> WIN
  LOCAL -.-> WIN

What it shows: the global lookup scope as an ordered list, searched front-to-back, with LD_PRELOAD near the head and the dlopen local scope appended only for references inside dynamically loaded objects. The insight to take: position in this list is destiny — the first matching definition binds, so anything spliced earlier (a preload, a -Bsymbolic self-reference) silently shadows everything later.

The Symbol Table Entry Being Matched

What ld.so matches against is the dynamic symbol table .dynsym, an array of Elf64_Sym records (per elf(5)):

typedef struct {
    uint32_t      st_name;   // offset into .dynstr for the symbol's name
    unsigned char st_info;   // packed: binding (STB_*) high nibble + type (STT_*) low nibble
    unsigned char st_other;  // visibility (STV_*) in the low 2 bits
    uint16_t      st_shndx;  // section index; SHN_UNDEF (0) means "undefined here — import it"
    Elf64_Addr    st_value;  // the symbol's value (its address, once relocated)
    uint64_t      st_size;   // size in bytes
} Elf64_Sym;

A reference is a symbol with st_shndx == SHN_UNDEF; a definition is one with a real section index and a non-trivial binding. ld.so is resolving each undefined reference to some defining entry elsewhere. The match has two parts: the name (st_name → string in .dynstr, a NUL-terminated C string compared byte-by-byte) and, if both reference and definition are versioned, the version name too (see Symbol Versioning). To make name matching fast, the search is hash-driven: ld.so hashes the name and probes the object’s .gnu.hash (or legacy .hash) bucket, walking the chain and doing a full string compare only on hash collisions (per dsohowto, §1.5.2). This is implemented in glibc’s do_lookup_x in elf/dl-lookup.c.

The Lookup Scope — Exact Ordering

Drepper’s canonical description: “The lookup scope consists in fact of up to three parts. The main part is the global lookup scope. It initially consists of the executable itself and all its dependencies. The dependencies are added in breadth-first order. That means first the dependencies of the executable are added in the order of their DT_NEEDED entries in the executable’s dynamic section. Then the dependencies of the first dependency are added in the same fashion. DSOs already loaded are skipped; they do not appear more than once on the list” (per dsohowto §1.5.4). Walking that concretely:

  1. LD_PRELOAD objects, if any — ld.so(8) describes them as “loaded before all others … used to selectively override functions in other shared objects” (per ld.so(8)). They are spliced in right after the executable, ahead of the program’s own dependencies, which is precisely what lets them interpose.
  2. The executable itself — its own definitions are consulted before any library’s. A function defined in main.c shadows a same-named library function.
  3. The executable’s DT_NEEDED libraries, in the order listed in its dynamic section.
  4. Their dependencies, then their dependencies — breadth-first, level by level, each object added at most once.

The decisive rule, again from Drepper: “The symbol lookup algorithm simply picks up the first definition it finds.” There is no notion of a “better” definition; whoever is earlier in this ordered list wins. He spells out the consequence: “Assume DSO ‘A’ defines and references an interface and DSO ‘B’ defines the same interface. If now ‘B’ precedes ‘A’ in the scope, the reference in ‘A’ will be satisfied by the definition in ‘B’. It is said that the definition in ‘B’ interposes the definition in ‘A’.” That is the engine behind Symbol Interposition and LD_PRELOAD — and behind accidental bugs when two unrelated libraries happen to export the same name.

STB_GLOBAL vs STB_WEAK at Run Time

A symbol’s binding (STB_* in st_info) is STB_LOCAL, STB_GLOBAL, or STB_WEAK. Per elf(5): local symbols “are not visible outside the object file”; global symbols “are visible to all object files being combined”; weak symbols “resemble global symbols, but their definitions have lower precedence.” STB_LOCAL symbols never participate in dynamic resolution at all — they are private to their object.

The crucial — and widely misunderstood — point is what weakness means at dynamic-link time. The intuitive expectation, inherited from static linking, is that a strong definition should override a weak one even if the weak one is found first. That is not how modern ld.so behaves. Drepper is blunt: “a definition in a DSO being weak has no effect. Weak definitions only play a role in static linking” (per dsohowto). The first definition found in scope wins, full stop, regardless of whether it is weak or global. This was a deliberate change: pre-2.2 glibc would remember a weak definition and keep searching for a strong one, but glibc 2.2 dropped that and adopted first-definition-wins, with the generic-ABI mailing-list discussion recording that “the distinction between weak and strong symbols should have effect only at static link time” and that Roland McGrath confirmed the old behaviour was a bug being fixed. ld.so(8) itself documents the historical pre-2.2 behaviour and notes the current first-found semantics.

Uncertain

Verify: that current glibc (2.42, 2026) still implements strict first-definition-wins with zero special-casing of weak definitions during dynamic lookup. Reason: the generic-ABI thread is old, and do_lookup_x in elf/dl-lookup.c does carry a SYMBOL_HASH/weak-handling path whose exact effect on definition selection (as opposed to undefined-weak references) is worth re-reading against the 2026 source. To resolve: read the ST_BIND checks in glibc 2.42 elf/dl-lookup.c do_lookup_x directly. uncertain

Weakness still matters in one direction at run time: a weak undefined reference that no object in scope defines is allowed to resolve to address 0 (NULL) instead of being a fatal “undefined symbol” error. That is the standard idiom for “call this optional hook only if it exists”: if (optional_func) optional_func(); where optional_func is declared __attribute__((weak)). A weak undefined that goes unresolved becomes 0; a strong undefined that goes unresolved is a hard error. See Weak Symbols and Symbol Visibility for the compiler-side declarations.

Visibility — STV_DEFAULT, STV_PROTECTED, STV_HIDDEN

Visibility (st_other, the STV_* field) controls whether a definition is exported into the lookup scope at all, and whether the defining object’s own references to it can be interposed. Per elf(5):

  • STV_DEFAULT — “Global and weak symbols are available to other modules.” This is the normal, exported, preemptible case: the symbol is visible in the lookup scope and can be interposed by an earlier object. MaskRay’s interposition write-up makes the link explicit: “external linkage functions with STB_GLOBAL binding and STV_DEFAULT visibility are preemptible.”
  • STV_HIDDEN — “Symbol is unavailable to other modules; references in the local module always resolve to the local symbol.” A hidden symbol is not placed in .dynsym at all; it cannot be referenced from outside and cannot be interposed. This is the granular tool (__attribute__((visibility("hidden"))), or -fvisibility=hidden) for keeping a library’s internals private and letting the compiler optimize calls to them (no PLT, inlinable).
  • STV_PROTECTED — “Symbol is available to other modules, but references in the local module always resolve to the local symbol.” So a protected symbol is exported (other objects can call it) yet not preemptible from inside — the defining object’s own calls always bind to its own copy, never to an interposing definition. This is the precise tool for a library that wants to publish an API but guarantee its internal calls are not hijacked.

The STV_PROTECTED case is subtle and has historically been buggy in practice (a protected symbol whose address is also taken can create an inconsistency between the in-object value and an external copy-relocated value), which is why most code reaches for -Bsymbolic-style mechanisms or hidden visibility instead.

dlopen and the Local Scope — RTLD_LOCAL vs RTLD_GLOBAL

dlopen complicates the picture by adding a third part to the scope. Per Drepper, “if a DSO is dynamically loaded it brings in its own set of dependencies … these objects, starting with the one which was requested in the dlopen call, are appended to the lookup scope if the object with the reference is among those objects which have been loaded by dlopen … they are not added to the global lookup scope and they are not searched for normal lookups. This third part … we will call local lookup scope.” So by default (RTLD_LOCAL) a dlopen’d plugin’s symbols are private: they satisfy references within the plugin and its own dependency tree, searched after the global scope, but they are invisible to the main program and to other plugins. The dlopen(3) man page states RTLD_LOCAL (the default) means “Symbols defined in this shared object are not made available to resolve references in subsequently loaded shared objects.”

RTLD_GLOBAL is the opposite: “The symbols defined by this shared object will be made available for symbol resolution of subsequently loaded shared objects” — the dlopen’d object and its dependencies are merged into the global scope, so later loads (and other plugins) can see them. Drepper warns this is “usually a very bad idea” because globally-visible plugin symbols can interpose unexpectedly and create hard-to-debug conflicts, and removing such an object later is dangerous. dlopen also takes the binding-mode flags RTLD_LAZY (resolve on first use) and RTLD_NOW (resolve everything before dlopen returns) — the per-dlopen analogue of lazy vs eager binding covered in Relocation Processing.

-Bsymbolic and DF_SYMBOLIC — A Library Preferring Itself

The linker flag -Bsymbolic (and the narrower -Bsymbolic-functions) sets the DF_SYMBOLIC flag in the DT_FLAGS dynamic entry. Per Drepper, with this flag “the object with the reference is added in front of the global lookup scope” — only the object itself, not its dependencies. The effect: when that library references one of its own exported symbols, it finds its own definition first, before any interposing definition elsewhere. MaskRay frames it as restricting interposition: -Bsymbolic flags “instruct the dynamic linker to prefer a shared library’s own symbol definitions over external ones,” and combining it with -fno-semantic-interposition let a Clang build run “15% faster due to reduced relocation processing overhead” (per maskray).

Drepper is sharply critical of the whole-object DF_SYMBOLIC form: “all interfaces are affected” (it has no per-symbol granularity), the compiler still emits PLT calls so the optimization opportunity is missed, and “the lookup scope is changed in a way which can lead to using unexpected dependencies.” His blunt advice: “never use DF_SYMBOLIC.” The modern, granular replacements are per-symbol hidden/protected visibility and -Bsymbolic-functions (functions only), or -fno-semantic-interposition to let the compiler assume non-interposition without changing the actual dynamic behaviour.

LD_DEBUG — The Lens

To watch resolution happen, set LD_DEBUG. The values that matter here (per ld.so(8)):

LD_DEBUG=symbols  ./prog   # show the search-path/scope walked for each symbol look-up
LD_DEBUG=bindings ./prog   # show which definition each symbol is bound to
LD_DEBUG=scopes   ./prog   # show the computed scope of each object
LD_DEBUG=all      ./prog   # everything (very verbose)
LD_DEBUG=help     ./prog   # list categories and exit

LD_DEBUG=bindings is the direct answer to “which library actually won this symbol?” — it prints lines like binding file ./prog to /lib64/libc.so.6: normal symbol 'printf'. Pair it with LD_DEBUG=symbols to see the order in which objects were probed before the binding was chosen, which makes an interposition (a preload shadowing libc) immediately visible. Force-resolving everything with LD_BIND_NOW=1 makes all bindings appear at startup rather than lazily, so the full picture prints up front.

Failure Modes and Diagnosis

  • Accidental interposition / “wrong function called.” Two libraries export the same global name; the one earlier in DT_NEEDED order silently wins for both, including the loser’s internal calls. Symptom: a function behaves as if from the wrong library. Diagnose with LD_DEBUG=bindings; fix with hidden visibility or -Bsymbolic-functions so each library binds its own.
  • undefined symbol at startup vs at first call. A strong undefined that no object in scope defines is fatal; under eager binding it fails at load, under lazy binding only when first called (see Relocation Processing). A weak undefined silently resolves to NULL — so a forgotten weak attribute can turn a link error into a NULL-pointer crash.
  • dlopen plugin can’t see the host’s symbols. A plugin loaded RTLD_LOCAL that expects host-provided symbols fails because the host’s later-loaded symbols aren’t visible to it; the fix is to load the host symbols into the global scope (link the executable with -rdynamic so its symbols enter .dynsym, or load the relevant library RTLD_GLOBAL), not to blanket-RTLD_GLOBAL every plugin.
  • STV_PROTECTED address inconsistency. Taking the address of a protected symbol that is also copy-relocated in an executable can yield two different addresses for “the same” symbol — a long-standing ELF wart. Prefer hidden visibility for internals.

See Also