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.

The scope is a list of lists, not one flat list

Drepper’s prose describes the scope as an ordered sequence of objects, and for a normal program that is exactly what it behaves like. The implementation is one level more structured, and the structure is what makes dlopen comprehensible. Each loaded object’s struct link_map carries an l_scope field: a NULL-terminated array of pointers to struct r_scope_elem. Each r_scope_elem is itself a list — r_list, an array of struct link_map *, with r_nlist giving its length. So the scope is a list of lists, searched outer-then-inner.

The two nested loops are literally that. The outer one lives in _dl_lookup_symbol_x, the entry point every relocation calls (glibc-2.43, elf/dl-lookup.c:786):

/* elf/dl-lookup.c, glibc-2.43, lines 786-790 — the OUTER loop over scope elements */
  for (size_t start = i; *scope != NULL; start = 0, ++scope)
    if (do_lookup_x (undef_name, new_hash, &old_hash, *ref,
		     &current_value, *scope, start, version, flags,
		     skip_map, type_class, undef_map) != 0)
      break;

Read it symbol by symbol: scope starts at symbol_scope, the caller-supplied struct r_scope_elem **; *scope != NULL walks it until the terminating NULL; ++scope advances to the next element; and break stops the moment do_lookup_x returns non-zero, i.e. the moment a definition is found. start is an index-within-the-first-element optimisation used only by _dl_lookup_symbol_skip-style callers; for the ordinary case it is 0.

The inner loop is do_lookup_x itself, which walks a single element’s r_list linearly (elf/dl-lookup.c:350-355):

/* elf/dl-lookup.c, glibc-2.43, lines 350-355 — the INNER loop over one element's objects */
  size_t n = atomic_load_acquire (&scope->r_nlist);
  struct link_map **list = scope->r_list;
 
  do
    {
      const struct link_map *map = list[i]->l_real;

The atomic_load_acquire on r_nlist is not decoration: dlopen can grow a scope list concurrently with another thread performing a lazy-binding lookup, and the acquire load pairs with the release store on the dlopen side so a reader never sees a new r_nlist against a stale r_list pointer. The comment above that line in the source says exactly this. For a program that never calls dlopen, l_scope has exactly one element — the global scope — and the outer loop runs once, which is why the nesting is invisible until a plugin shows up.

flowchart TB
  START["relocation needs symbol S<br/>_dl_lookup_symbol_x()"]
  START --> HASH["new_hash = _dl_new_hash(S)<br/>computed once, reused for every object"]
  HASH --> OUTER{"more scope<br/>elements?"}
  OUTER -->|"no"| NOTFOUND{"reference<br/>STB_WEAK?"}
  NOTFOUND -->|"yes"| NULLBIND["bind to 0 (NULL)<br/>legal, not an error"]
  NOTFOUND -->|"no"| FATAL["_dl_signal_error:<br/>'undefined symbol: S'"]
  OUTER -->|"yes"| INNER["do_lookup_x on this element:<br/>walk r_list[0 .. r_nlist-1] in order"]
  INNER --> PROBE["per object: probe .gnu.hash bucket,<br/>compare 32-bit hash, then full strcmp<br/>then version match if versioned"]
  PROBE -->|"no entry"| NEXTOBJ{"more objects<br/>in r_list?"}
  NEXTOBJ -->|"yes"| PROBE
  NEXTOBJ -->|"no"| OUTER
  PROBE -->|"name matches"| VIS{"STV_HIDDEN or<br/>STV_INTERNAL?"}
  VIS -->|"yes"| SKIP["goto skip — treat as local,<br/>keep searching"]
  SKIP --> NEXTOBJ
  VIS -->|"no"| BIND{"ST_BIND?"}
  BIND -->|"STB_GLOBAL"| WIN["result = this definition<br/>return 1 — search STOPS"]
  BIND -->|"STB_WEAK"| DW{"GLRO(dl_dynamic_weak)<br/>set? (LD_DYNAMIC_WEAK=1)"}
  DW -->|"no (default)"| WIN
  DW -->|"yes"| REMEMBER["remember as candidate<br/>if none yet, keep searching"]
  REMEMBER --> NEXTOBJ
  BIND -->|"STB_LOCAL"| SKIP

What it shows: the exact control flow of a glibc-2.43 symbol lookup, from _dl_lookup_symbol_x’s outer walk over scope elements down to the per-object hash probe and the binding/visibility decision in do_lookup_x. The insight to take: there is exactly one place the search terminates successfully — the return 1 on the first STB_GLOBAL (or, by default, STB_WEAK) STV_DEFAULT match — so everything about “which definition wins” reduces to “which object comes first in r_list”. Every knob in this note (LD_PRELOAD, -Bsymbolic, RTLD_LOCAL, hidden visibility) is a knob on that ordering or on whether a candidate is skipped.

Watching First-Definition-Wins Happen

The rule is easier to believe once you have made two libraries fight over a name and watched the loader pick a winner. Everything below was run on Fedora 44 / x86-64 with glibc 2.43-8.fc44, GCC 16.2.1 20260819, and GNU ld (binutils) 2.46.1 on 2026-09-11. Two shared objects each define whoami, and each also has an internal caller:

/* liba.c */                              /* libb.c */
#include <stdio.h>                        #include <stdio.h>
void whoami(void)                         void whoami(void)
{ printf("whoami: liba\n"); }             { printf("whoami: libb\n"); }
void a_calls_whoami(void)                 void b_calls_whoami(void)
{ printf("a_calls_whoami -> ");           { printf("b_calls_whoami -> ");
  whoami(); }                               whoami(); }
/* main.c — calls whoami directly, then asks each library to call its own */
void whoami(void); void a_calls_whoami(void); void b_calls_whoami(void);
int main(void) { printf("main direct  -> "); whoami();
                 a_calls_whoami(); b_calls_whoami(); return 0; }

Build both libraries, then link the program twice with the only difference being the order of -la and -lb, which is the order the linker writes the DT_NEEDED entries in:

$ gcc -shared -fPIC -o liba.so liba.c
$ gcc -shared -fPIC -o libb.so libb.c
$ gcc -o prog_ab main.c -L. -la -lb -Wl,-rpath,'$ORIGIN'   # liba listed first
$ gcc -o prog_ba main.c -L. -lb -la -Wl,-rpath,'$ORIGIN'   # libb listed first

readelf -d confirms the only difference is the DT_NEEDED ordering (both binaries are byte-identical in size and layout otherwise — the dynamic section starts at the same offset 0x1dd8 with the same 27 entries):

$ readelf -d prog_ab | head -4          $ readelf -d prog_ba | head -4
 0x...01 (NEEDED)  [liba.so]             0x...01 (NEEDED)  [libb.so]
 0x...01 (NEEDED)  [libb.so]             0x...01 (NEEDED)  [liba.so]
 0x...01 (NEEDED)  [libc.so.6]           0x...01 (NEEDED)  [libc.so.6]
 0x...1d (RUNPATH) [$ORIGIN]             0x...1d (RUNPATH) [$ORIGIN]

And the behaviour flips wholesale:

$ ./prog_ab                              $ ./prog_ba
main direct  -> whoami: liba             main direct  -> whoami: libb
a_calls_whoami -> whoami: liba           a_calls_whoami -> whoami: libb
b_calls_whoami -> whoami: liba           b_calls_whoami -> whoami: libb

Look carefully at the third line of each column. In prog_ab, b_calls_whoami — a function inside libb.so, calling a function defined in libb.so, in the same translation unit — ends up executing liba’s copy. libb.so has been interposed against itself. This is the single most surprising consequence of first-definition-wins and the reason a library can misbehave because of an unrelated library it has never heard of. LD_DEBUG=bindings names the culprit outright (paths shortened):

$ LD_DEBUG=bindings ./prog_ab 2>&1 | grep whoami
binding file ./prog_ab [0] to ./liba.so [0]: normal symbol `whoami'
binding file ./liba.so [0] to ./liba.so [0]: normal symbol `whoami'
binding file ./libb.so [0] to ./liba.so [0]: normal symbol `whoami'   <-- libb -> liba

The last line is the bug, stated by the loader itself: binding file libb.so to liba.so. Note also what is absent — there is no binding file ./prog_ab to ./libb.so: normal symbol 'whoami' line at all, because the lookup for whoami was satisfied before libb.so was ever probed.

LD_DEBUG=scopes prints the computed scope list, which is the r_list from the code above made visible:

$ LD_DEBUG=scopes ./prog_ab 2>&1 | head
Initial object scopes
object=./prog_ab [0]
 scope 0: ./prog_ab ./liba.so ./libb.so /lib64/libc.so.6 /lib64/ld-linux-x86-64.so.2

There it is, in order: the executable, then its DT_NEEDED libraries in listed order, then libc.so.6 (also a DT_NEEDED of the program, added after the explicitly-named ones), then ld.so itself. One scope element — scope 0 — because nothing was dlopened. Every object in the process shares this same scope 0, which is precisely why libb.so’s internal reference resolved through it rather than through libb.so alone.

Splicing an LD_PRELOAD object in front changes exactly one thing about that line — and therefore everything about the outcome:

$ LD_PRELOAD=./libpre.so LD_DEBUG=scopes ./prog_ab 2>&1 | grep -m1 'scope 0:'
 scope 0: ./prog_ab ./libpre.so ./liba.so ./libb.so /lib64/libc.so.6 /lib64/ld-linux-x86-64.so.2
                    ^^^^^^^^^^^ spliced after the executable, before DT_NEEDED deps
 
$ LD_PRELOAD=./libpre.so ./prog_ab
main direct  -> whoami: LD_PRELOAD
a_calls_whoami -> whoami: LD_PRELOAD
b_calls_whoami -> whoami: LD_PRELOAD

This measurement settles a detail the prose above states but rarely gets demonstrated: the preload goes after the executable, not before it. A definition in the main program still beats a preload. That is why LD_PRELOAD reliably overrides library functions and cannot override a function the executable itself defines.

The source says the same thing in one comment. _dl_map_object_deps builds the breadth-first list, and the preloads are injected at a single, explicit point (elf/dl-deps.c:157-161):

/* elf/dl-deps.c, glibc-2.43, lines 157-161 */
  /* First load MAP itself.  */
  preload (known, &nlist, map);
 
  /* Add the preloaded items after MAP but before any of its dependencies.  */
  for (i = 0; i < npreloads; ++i)
    preload (known, &nlist, preloads[i]);

map here is the executable. “After MAP but before any of its dependencies” is the whole LD_PRELOAD mechanism in one sentence, and it is exactly the ordering LD_DEBUG=scopes printed.

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.

Resolved: the old behaviour still exists, behind a switch

An earlier revision of this note flagged as uncertain whether current glibc really special-cases weak definitions at all. Reading the pinned source settles it, and the answer is more interesting than a flat “no”. glibc-2.43 do_lookup_x does carry a STB_WEAK branch — but it is gated on a runtime flag that is off by default (elf/dl-lookup.c:482-498):

/* elf/dl-lookup.c, glibc-2.43, lines 481-498 */
	  switch (ELFW(ST_BIND) (sym->st_info))
	    {
	    case STB_WEAK:
	      /* Weak definition.  Use this value if we don't find another.  */
	      if (__glibc_unlikely (GLRO(dl_dynamic_weak)))
		{
		  if (! result->s)
		    {
		      result->s = sym;
		      result->m = (struct link_map *) map;
		    }
		  break;              /* <-- keep searching for a strong one */
		}
	      [[fallthrough]];
	    case STB_GLOBAL:
	      /* Global definition.  Just what we need.  */
	      result->s = sym;
	      result->m = (struct link_map *) map;
	      return 1;             /* <-- search STOPS here */

Walk it: when GLRO(dl_dynamic_weak) is clear — the default — case STB_WEAK falls straight through into case STB_GLOBAL and hits return 1, ending the search. Weak and global are then genuinely indistinguishable, exactly as Drepper says. When the flag is set, the weak definition is merely stashed in result if nothing has been recorded yet, and break lets the loop continue to the next object, so a later strong definition can overwrite it. That is the resurrected pre-2.2 behaviour, and glibc exposes it as the environment variable LD_DYNAMIC_WEAK (listed among the variables ld.so ignores in secure-execution mode, per ld.so(8)).

It is directly observable. Give libweak.so a weak whoami and list it first, ahead of libb.so’s strong one:

/* libweak.c */
__attribute__((weak)) void whoami(void) { printf("whoami: libweak (WEAK definition)\n"); }
$ readelf --dyn-syms -W libweak.so | grep whoami
     6: 0000000000000399    22 FUNC    WEAK   DEFAULT    4 whoami
$ readelf -d prog_weak | grep NEEDED | head -2
 0x...01 (NEEDED)  Shared library: [libweak.so]     <-- weak definition, earlier in scope
 0x...01 (NEEDED)  Shared library: [libb.so]        <-- strong definition, later in scope
 
$ ./prog_weak                          # default: first-found wins, weakness ignored
main direct -> whoami: libweak (WEAK definition)
b_calls_whoami -> whoami: libweak (WEAK definition)
 
$ LD_DYNAMIC_WEAK=1 ./prog_weak        # legacy mode: strong definition wins from further away
main direct -> whoami: libb
b_calls_whoami -> whoami: libb

Same binary, same link order, opposite outcome — the only variable is one environment flag. LD_DEBUG=bindings shows the loader changing its mind about which object it is binding to:

$ LD_DEBUG=bindings ./prog_weak 2>&1 | grep whoami
binding file ./prog_weak [0] to ./libweak.so [0]: normal symbol `whoami'
binding file ./libb.so [0]  to ./libweak.so [0]: normal symbol `whoami'
 
$ LD_DYNAMIC_WEAK=1 LD_DEBUG=bindings ./prog_weak 2>&1 | grep whoami
binding file ./prog_weak [0] to ./libb.so [0]: normal symbol `whoami'
binding file ./libb.so [0]  to ./libb.so [0]: normal symbol `whoami'

The practical lesson is not “use LD_DYNAMIC_WEAK” — it is a compatibility escape hatch, it changes resolution for every symbol in the process, and nothing should depend on it. The lesson is that you cannot use STB_WEAK to express “this is a fallback, prefer someone else’s” across shared objects. If you need that, the correct tools are ordering (put the preferred provider earlier), visibility (make the fallback not-exported), or an explicit runtime dispatch such as IFUNC.

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.

There is a fourth value, STV_INTERNAL (value 1), which the generic ABI leaves to processor supplements: “The meaning of this visibility attribute may be defined by processor supplements to further constrain hidden symbols. A processor supplement’s definition should be such that generic tools can safely treat internal symbols as hidden” (per the gABI symbol table chapter). The x86-64 psABI adds no such constraint, so on x86-64 STV_INTERNAL is effectively a synonym for STV_HIDDEN, and glibc treats the two identically — do_lookup_x funnels both through one predicate, dl_symbol_visibility_binds_local_p, at elf/dl-lookup.c:473, whose comment is a one-line summary of the rule: “Hidden and internal symbols are local, ignore them.” A match on such a symbol does not end the search; it goto skips and the loop carries on.

The binding × visibility grid

Binding and visibility are two independent fields (st_info’s high nibble and st_other’s low two bits), and what a symbol does is determined by the pair, not by either alone. The combinations that can actually occur:

BindingVisibilityIn .dynsym?Callable from outside?Preemptible by an earlier object?Defining object’s own calls
STB_GLOBALSTV_DEFAULTyesyesyescan be hijacked (go through PLT/GOT)
STB_WEAKSTV_DEFAULTyesyesyes (identically — weakness is ignored by default)can be hijacked
STB_GLOBALSTV_PROTECTEDyesyesno, from insidealways bind to own copy
STB_WEAKSTV_PROTECTEDyesyesno, from insidealways bind to own copy
STB_GLOBALSTV_HIDDENno — linker demotes to STB_LOCALnon/adirect call, no PLT, inlinable
STB_GLOBALSTV_INTERNALno — same demotionnon/asame as hidden on x86-64
STB_LOCALSTV_DEFAULTnonon/adirect call
STB_LOCALSTV_PROTECTED———forbidden by the gABI: “A symbol with STB_LOCAL binding may not have STV_PROTECTED visibility.”

The two columns that matter operationally are the last two. “Preemptible” decides whether someone else’s LD_PRELOAD can change what your callers get; “own calls” decides whether it can change what you get. STV_DEFAULT says yes to both, STV_PROTECTED says yes to the first and no to the second, STV_HIDDEN says no to both by removing the symbol from the export table entirely.

The “linker demotes to STB_LOCAL” entry is not folklore; the gABI mandates it — “A hidden symbol contained in a relocatable object must be either removed or converted to STB_LOCAL binding by the link-editor when the relocatable object is included in an executable file or shared object” — and it is visible in the output. Compiling one library with all three visibilities:

/* libv.c */
__attribute__((visibility("default")))   void v_default(void)   { ... }
__attribute__((visibility("protected"))) void v_protected(void) { ... }
__attribute__((visibility("hidden")))    void v_hidden(void)    { ... }
$ readelf --dyn-syms -W libv.so | grep -E 'v_default|v_protected|v_hidden'
   Num:    Value            Size Type   Bind    Vis        Ndx Name
     6: 00000000000003a9     22 FUNC   GLOBAL  DEFAULT      4 v_default
     8: 00000000000003bf     22 FUNC   GLOBAL  PROTECTED    4 v_protected
                                    <-- v_hidden is simply ABSENT from .dynsym
 
$ readelf -s -W libv.so | grep v_hidden          # the full .symtab still has it:
    15: 00000000000003d5     22 FUNC   LOCAL   DEFAULT      4 v_hidden

Two things to read out of that. First, v_hidden never reaches .dynsym, so no amount of LD_PRELOAD or link ordering can interpose it — the loader will never see the name. Second, note the Bind/Vis columns on the surviving .symtab entry: GNU ld 2.46.1 did not keep it as GLOBAL/HIDDEN; it rewrote it to LOCAL/DEFAULT, exactly as the gABI requires. STV_HIDDEN is therefore best understood as a compile-and-link-time instruction to the linker, not a runtime attribute — by the time ld.so is involved the hiddenness has already been converted into ordinary locality.

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 — see Copy Relocations), which is why most code reaches for -Bsymbolic-style mechanisms or hidden visibility instead. glibc actively polices the dangerous case: when do_lookup_x matches a protected symbol it calls _dl_check_protected_symbol (elf/dl-lookup.c:476-478) before accepting it, which diagnoses precisely the copy-relocation-versus-protected-definition conflict.

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.

How glibc actually splices the new objects in

The “third part of the scope” is not a metaphor; it is the second element of the l_scope array introduced earlier, and the two flags take two entirely different code paths in elf/dl-open.c.

RTLD_GLOBAL extends the existing global list. add_to_global_update (elf/dl-open.c:172-190) walks the newly loaded object and its dependencies and appends each one to the single per-namespace global search list, flipping a per-object flag as it goes:

/* elf/dl-open.c, glibc-2.43, lines 178-188 */
  for (unsigned int cnt = 0; cnt < new->l_searchlist.r_nlist; ++cnt)
    {
      struct link_map *map = new->l_searchlist.r_list[cnt];
      if (map->l_global == 0)
	{
	  map->l_global = 1;
	  ...
	  ns->_ns_main_searchlist->r_list[new_nlist++] = map;

_ns_main_searchlist is scope 0 — the same list LD_DEBUG=scopes printed above. So RTLD_GLOBAL makes the plugin a permanent, first-class citizen of the one scope everybody shares. It is appended at the end, so it cannot interpose anything already loaded, but it can satisfy references from anything loaded later, and it will be found by every future lookup in the process.

RTLD_LOCAL instead adds a new scope element to each already-initialised object in the new tree. update_scopes (elf/dl-open.c:321-347) does this:

/* elf/dl-open.c, glibc-2.43, lines 337-345 */
	  size_t cnt = scope_size (imap);
	  ...
	  /* First terminate the extended list.  Otherwise a thread
	     might use the new last element and then use the garbage
	     at offset IDX+1.  */
	  imap->l_scope[cnt + 1] = NULL;
	  atomic_write_barrier ();
	  imap->l_scope[cnt] = &new->l_searchlist;

Note the ordering discipline in those three statements: the NULL terminator is written first, then a write barrier, then the new entry. This is the release half of the acquire load on r_nlist seen earlier in do_lookup_x — a concurrent lazy-binding lookup in another thread must never observe a non-NULL l_scope[cnt] before the array is properly terminated. Symbol lookup is lock-free on the fast path, and this is the memory-ordering protocol that makes that safe.

The upshot is the asymmetry the man page describes in behavioural terms: RTLD_GLOBAL grows scope 0 for everyone; RTLD_LOCAL gives only the objects inside the dlopened tree an extra scope 1 to fall back on, which nobody outside that tree ever consults.

sequenceDiagram
  autonumber
  participant App as main program
  participant DL as dlopen()
  participant NS as namespace scope 0<br/>(_ns_main_searchlist)
  participant P as plugin.so<br/>(l_scope array)
  App->>DL: dlopen("plugin.so", RTLD_NOW | flag)
  DL->>DL: _dl_map_object: search DT_RPATH,<br/>LD_LIBRARY_PATH, DT_RUNPATH, ld.so.cache
  DL->>DL: _dl_map_object_deps: load plugin's<br/>own DT_NEEDED tree, breadth-first
  alt flag == RTLD_GLOBAL
    DL->>NS: add_to_global_resize (allocate first,<br/>so the update cannot fail)
    DL->>NS: add_to_global_update:<br/>append plugin+deps, set l_global = 1
    Note over NS: scope 0 is now longer —<br/>every object in the process sees the plugin
  else flag == RTLD_LOCAL (default)
    DL->>P: resize_scopes (allocate first)
    DL->>P: update_scopes sets l_scope[cnt]<br/>to point at the plugin l_searchlist
    Note over P: plugin gets a private scope 1 —<br/>scope 0 is untouched, host cannot see plugin
  end
  DL->>DL: relocate — each lookup walks scope 0<br/>then scope 1 if present
  DL->>P: call DT_INIT / DT_INIT_ARRAY constructors
  DL-->>App: return handle

What it shows: the sequence dlopen runs, and the single branch where RTLD_GLOBAL and RTLD_LOCAL diverge. The insight to take: both modes allocate before they mutate (add_to_global_resize / resize_scopes run first precisely so the update step “cannot fail” — an out-of-memory error after the scope is half-updated would be unrecoverable), and the difference between the modes is not “can the plugin see the host” — it always can, via scope 0 — but “can the host and other plugins see the plugin”, which only RTLD_GLOBAL grants.

Measuring it

The asymmetry is easy to get backwards, so here it is measured. liba.so defines whoami; libb_ref.so is deliberately built with an undefined whoami (no -la at link time, which is legal for a shared object). A host program dlopens liba.so under one flag, then dlopens libb_ref.so and sees whether its reference can be satisfied:

    int flags = RTLD_NOW | (want_global ? RTLD_GLOBAL : RTLD_LOCAL);
    void *ha = dlopen("./liba.so", flags);
    void (*f)(void) = dlsym(ha, "whoami");   f();
    void *hb = dlopen("./libb_ref.so", RTLD_NOW);   /* references whoami */
$ ./host local
== dlopen liba.so with RTLD_LOCAL ==
dlsym(ha,"whoami") -> whoami: liba
dlopen libb_ref FAILED: ./libb_ref.so: undefined symbol: whoami
 
$ ./host global
== dlopen liba.so with RTLD_GLOBAL ==
dlsym(ha,"whoami") -> whoami: liba
libb_ref loaded OK; b_calls_whoami -> whoami: liba

Both runs prove the same two things at once. dlsym(ha, "whoami") succeeds in both modes — RTLD_LOCAL does not hide a symbol from the handle that owns it; dlsym on a specific handle searches that object’s own dependency tree regardless (elf/dl-sym.c). What RTLD_LOCAL withholds is participation in other objects’ lookups, which is exactly why the second dlopen fails with undefined symbol: whoami and succeeds under RTLD_GLOBAL. Those five words — undefined symbol at dlopen time, on a symbol you can plainly dlsym — are the signature of a scope problem rather than a missing-library problem.

Scenarioscope 0 (global) containsExtra scope elementsWho can resolve against the plugin
Program, no dlopenexe, LD_PRELOAD objects, DT_NEEDED tree (breadth-first)nonen/a
dlopen(RTLD_LOCAL)unchangedplugin tree gains scope 1 = plugin + its depsonly the plugin and its own dependencies
dlopen(RTLD_GLOBAL)grown: plugin + its deps appended at the endnone addedevery object, including ones loaded later
Two plugins, both RTLD_LOCALunchangedeach gets its own scope 1neither can see the other — deliberate isolation
Two plugins, both RTLD_GLOBALgrown twice, first-loaded earliernoneboth see each other; first one loaded wins any name collision
Executable linked -rdynamicexe’s .dynsym is populated, so exe definitions are visibleunchangedplugins can call back into the host
dlmopen into a new namespacea separate _ns_main_searchlist per namespaceper-namespacefull isolation, including a second copy of libc

Read the last two rows together: -rdynamic is the fix for “my plugin cannot find a host symbol” (it makes the executable export its symbols at all), while RTLD_GLOBAL is the fix for “my second plugin cannot find a first plugin’s symbol”. Reaching for RTLD_GLOBAL when the real problem was a non-exporting executable is a common and costly misdiagnosis, because it globally exposes every plugin symbol as a side effect.

-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).

Rebuilding libb.so with the flag is enough to see all of this. The tag really is set — and, as the gABI’s dynamic-section chapter notes, in two places at once, the obsolete standalone DT_SYMBOLIC (d_tag 0x10) and the modern DT_FLAGS bit:

$ gcc -shared -fPIC -Wl,-Bsymbolic -o libb.so libb.c
$ readelf -d libb.so | grep -Ei 'SYMBOLIC|FLAGS'
 0x0000000000000010 (SYMBOLIC)           0x0
 0x000000000000001e (FLAGS)              SYMBOLIC
 
$ readelf -d libb_plain.so | grep -Ei 'SYMBOLIC|FLAGS'
(no output — neither tag is present)

Relinking the earlier collision program against this libb.so, with liba.so still first in DT_NEEDED, changes exactly one of the three lines:

$ ./prog                                 (liba first; libb built -Bsymbolic)
main direct  -> whoami: liba             <- unchanged: the program still gets liba's
a_calls_whoami -> whoami: liba           <- unchanged
b_calls_whoami -> whoami: libb           <- CHANGED: libb now calls its own

The LD_DEBUG=bindings output shows why, and it is not what the “added in front of the scope” description alone would lead you to expect:

binding file ./prog     [0] to ./liba.so [0]: normal symbol `whoami'
binding file ./liba.so  [0] to ./liba.so [0]: normal symbol `whoami'
binding file ./prog     [0] to ./libb.so [0]: normal symbol `b_calls_whoami'
                                             <- no `whoami' binding for libb.so at all

Compare with the non--Bsymbolic run earlier, which had a third whoami line reading binding file ./libb.so to ./liba.so. Here that line is gone entirely. -Bsymbolic did not make the runtime lookup prefer libb.so; it made the static linker resolve the call at link time and emit no dynamic symbol relocation for it at all, so ld.so has nothing to look up. That is the mechanism behind the performance claim: fewer relocations to process at startup, and a call that can be a direct call instead of an indirect hop through the PLT. It is also the mechanism behind the danger — the decision is frozen at build time, and LD_PRELOAD can no longer reach those call sites at all, which quietly breaks interposition-based debugging, profiling, and sanitizer tooling for that library.

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.

Alternatives and When to Choose Them

“Which definition wins” is a policy question, and ELF offers half a dozen mechanisms to answer it, at different points in the build-and-run pipeline and with very different blast radii. They are frequently confused with one another, partly because several of them look like they do the same thing and differ only in when they take effect.

MechanismApplied atGranularityWhat it actually changesInterposition still possible?
Do nothing (STB_GLOBAL + STV_DEFAULT)—per symbolnothing; full first-definition-winsyes, fully
DT_NEEDED ordering (link-line order)static linkwhole libraryposition in scope 0yes — this is the mechanism
LD_PRELOADrun timewhole librarysplices an object after the exe, before depsyes — this is the mechanism
-fvisibility=hidden + explicit exportscompileper symbolremoves symbol from .dynsym; demoted to STB_LOCALno — loader never sees the name
STV_PROTECTEDcompileper symbolexported, but own references bind locallyexternally yes, internally no
-Bsymbolic-functionsstatic linkall functions in the libraryresolves intra-library function calls at link timeno, for that library’s own calls
-Bsymbolic / DF_SYMBOLICstatic linkall symbols in the libraryas above, plus data — Drepper: “never use DF_SYMBOLIC”no
-fno-semantic-interpositioncompileper translation unitlets the compiler assume no interposition (inline, constant-fold) without changing the dynamic tagsyes, dynamically — but the inlined copies are already frozen
Version script (--version-script)static linkper symbol, declarativecontrols exactly which names enter .dynsym and under which versionno, for names left out
dlopen(RTLD_LOCAL)run timewhole plugin treeprivate scope element instead of extending scope 0scoped away, not removed
dlmopen (new namespace)run timewhole subtreean independent scope 0, including its own libcfully isolated
Static linkingstatic linkwhole programno runtime lookup existsno — see Static vs Dynamic Linking

The decision procedure that follows from the table is short. If a symbol is an implementation detail, make it hidden — it is the only option that is both free at runtime and impossible to get wrong, and it shrinks .dynsym, which makes every other lookup in the process faster (a smaller hash table means fewer chain walks). If a symbol is part of your published API but your own calls to it must not be hijackable, use -Bsymbolic-functions rather than whole-object -Bsymbolic, and be aware you are giving up LD_PRELOAD-based tooling against that library. If you only want the compiler’s optimisation benefit without changing observable dynamic behaviour, use -fno-semantic-interposition — MaskRay reports a Clang build running “15% faster due to reduced relocation processing overhead” from combining it with symbolic linking (per maskray). If the problem is two plugins colliding, the answer is RTLD_LOCAL (the default) and resisting the urge to reach for RTLD_GLOBAL; if it is total isolation you need — two versions of the same library in one process — dlmopen is the only mechanism that genuinely delivers it, at the cost of a duplicated libc and no object sharing between namespaces.

One non-option deserves naming: STB_WEAK is not a priority mechanism. As measured above, a weak definition found first beats a strong definition found later. Designs that say “we’ll ship a weak default and let anyone override it by linking their own” only work if the override also comes earlier in the scope, which is a link-order property the library author does not control.

Production Notes

The first-definition-wins rule is load-bearing in production far more often than its obscurity suggests, and it shows up in three recurring shapes.

Deliberate interposition is a mainstream operational tool. Swapping a process’s allocator with LD_PRELOAD=/usr/lib64/libjemalloc.so or libtcmalloc.so works entirely because malloc, free, calloc and realloc are STB_GLOBAL/STV_DEFAULT in libc.so.6 and the preload is spliced ahead of it. The same mechanism underlies ltrace, LD_PROFILE, libfaketime, faketime-style test harnesses, and the interceptor layer of AddressSanitizer, ThreadSanitizer and Valgrind. Drepper lists losing exactly these — “tools and hacks like ltrace, LD_PRELOAD, LD_PROFILE, LD_AUDIT don’t work” — as one of his arguments against static linking (per Static Linking Considered Harmful). A library built with whole-object -Bsymbolic opts out of the same tooling for its own internal calls, which is a real and often unanticipated operational cost.

Accidental interposition is a genuine bug class. The failure signature is distinctive: a library misbehaves only when loaded alongside some unrelated other library, and only in a particular link order. The measured example above — libb.so executing liba.so’s whoami for a call written in libb.c — is the whole bug in miniature. Real instances cluster around short, generic, unnamespaced exports: init, log, error, hash, compress, read_config. The reason C libraries prefix everything (curl_easy_init, png_read_info, SSL_CTX_new) is not aesthetics; it is that the ELF namespace is flat and global and the first definition wins. The diagnostic is always the same two commands: LD_DEBUG=bindings ./prog 2>&1 | grep <symbol> to see who won, and readelf --dyn-syms -W lib*.so | grep <symbol> to see who else was exporting it.

Plugin hosts live and die by scope flags. The canonical case is CPython: extension modules are dlopened, and modules that must share C++ symbols or a common ABI layer with each other historically needed sys.setdlopenflags(os.RTLD_GLOBAL) precisely because RTLD_LOCAL gives each module its own private scope element. The same tension appears in every plugin architecture — Apache modules, PAM, NSS, GStreamer, browser plugins — and the same trap: RTLD_GLOBAL “fixes” the immediate symptom by dumping every plugin’s exports into scope 0, where they then interpose each other, and where the first plugin loaded silently wins any future collision. Drepper’s judgement that using RTLD_GLOBAL is “usually a very bad idea” is the accumulated experience of that pattern.

The cost is measurable and worth knowing. LD_DEBUG=statistics prints the loader’s own accounting, and on this Fedora 44 / glibc 2.43 host the numbers scale with how much symbol resolution there is to do:

$ LD_DEBUG=statistics ./hello 2>&1 | grep -E 'startup time|number of relocations:'
  total startup time in dynamic loader: 25353 cycles
                 number of relocations: 95
 
$ LD_DEBUG=statistics /usr/bin/ls --version 2>&1 | grep -E 'startup time|relocation'
  total startup time in dynamic loader: 76692 cycles
            time needed for relocation: 26024 cycles (33.9%)
                 number of relocations: 564
 
$ LD_DEBUG=statistics /usr/bin/python3 --version 2>&1 | grep -E 'startup time|relocation'
  total startup time in dynamic loader: 209019 cycles
            time needed for relocation: 157746 cycles (75.4%)
                 number of relocations: 935

Two readings. First, the absolute cost is small — tens of microseconds — so “dynamic linking is slow” is overstated for a long-lived process. Second, the proportion spent in relocation climbs steeply with the number of symbols (12% for a small program, 34% for ls, 75% for python3), and every one of those relocations is a run through the scope walk described in this note. That is the concrete payoff of hidden visibility and version scripts: symbols that never enter .dynsym are relocations that never happen. It is also why Lazy Binding exists at all, and why LD_BIND_NOW / full RELRO trade some of that startup time back for the security of a read-only GOT.

See Also