The Dynamic Linker

When you run a dynamically linked program, the kernel does not jump to your main, nor even to your program’s own entry point. It reads a special program header in your executable — PT_INTERP, “a null-terminated pathname to invoke as an interpreter” (elf(5)), almost always /lib64/ld-linux-x86-64.so.2 for glibc on x86-64 — maps that program (the dynamic linker, ld.so) into the address space, and transfers control to it. The dynamic linker, “ld.so … find[s] and load[s] the shared objects (shared libraries) needed by a program, prepare[s] the program to run, and then run[s] it” (ld.so(8)). It first relocates itself (a genuine chicken-and-egg problem: it must fix up its own address references using code that cannot yet rely on those references), then walks the program’s DT_NEEDED list to recursively load every dependency, builds a symbol-lookup scope, applies relocations to wire up all the cross-library references, runs each library’s initializers, and only then jumps to the executable’s real entry point e_entry. The toolchain-time counterpart — the static linker that resolves symbols and emits relocations at build time — is covered in The Go Linker; this note is about the runtime linker that finishes that job when the process actually starts.

Version and machine context

Everything in this note that quotes source was read from glibc 2.43 (released 2026, the version installed on the machine used for the worked examples: ld.so (GNU libc) stable release version 2.43, glibc-2.43-8.fc44.x86_64 on Fedora 44, kernel 7.1.8). Source is cited from the pinned glibc-2.43 tag on the sourceware git browser, not from the moving master branch. All command output reproduced below is real output captured on that machine, not reconstructed from memory. Details such as register conventions and PLT layout are x86-64-specific; aarch64 and s390x differ in the assembly while following the same architecture-neutral algorithm.

Mental Model

The dynamic linker is best thought of as a second program that the kernel runs in order to run your program. A statically linked binary is self-contained: the kernel maps its segments and jumps straight to its entry point, and nothing else is needed. A dynamically linked binary is deliberately incomplete on disk — it references functions (printf, malloc) and data that live in separate shared objects whose load addresses are not known until runtime. Something has to glue the pieces together after they are mapped, and that something is ld.so. The kernel bootstraps it by honoring the PT_INTERP request, so from the program’s perspective the dynamic linker is its “interpreter” exactly the way /bin/bash is the interpreter named in a shell script’s #! line.

The crucial mental anchor is the inversion of who runs first. The kernel maps two ELF objects for a dynamic executable: the program and its interpreter. It sets the CPU’s instruction pointer to the interpreter’s entry point (ld.so’s _start), not the program’s. The dynamic linker does all its preparation, and the very last thing it does is jmp to the program’s e_entry. So _start of your binary — and far below that, main — is reached only after a substantial relocation-and-initialization dance has already happened in a completely different ELF object.

The second anchor is self-bootstrapping. The dynamic linker is itself a shared object (ET_DYN) loaded at an unpredictable address, so it too needs its internal address references patched (relocated). But there is no other linker to do this for it — it must relocate itself, using carefully written code that avoids touching any not-yet-relocated global until the self-relocation is done. glibc’s source is explicit: “Before ld.so is relocated we must not access variables which need relocations. … Variables declared as static are fine” (elf/rtld.c, glibc 2.43).

flowchart TD
  K["kernel: execve()<br/>reads PT_INTERP from the ELF<br/>maps program + maps ld.so"]
  K --> S["ld.so _start / _dl_start<br/>(interpreter entry point)"]
  S --> R["self-relocate:<br/>ELF_DYNAMIC_RELOCATE(bootstrap_map)<br/>'Now life is sane'"]
  R --> N["read program's DT_NEEDED list<br/>_dl_map_object_deps()"]
  N --> L["recursively load each .so<br/>build the link_map list"]
  L --> SC["build symbol search scope<br/>(l_scope / l_searchlist)"]
  SC --> RL["apply relocations<br/>_dl_relocate_object() per object"]
  RL --> I["run initializers<br/>_dl_init: DT_INIT + DT_INIT_ARRAY"]
  I --> J["jmp to program's e_entry<br/>(_dl_start_user)"]
  J --> P["program _start --> __libc_start_main --> main"]

The runtime-linking pipeline from execve to your main. What it shows: the kernel hands control to the interpreter (ld.so), which self-relocates, loads dependencies into a link_map list, builds the symbol scope, relocates everything, runs constructors, and only then jumps to the program. The insight to take: every box left of “jmp to program’s e_entry” runs inside ld.so, in a different ELF object than your program — main is nowhere near the first code to execute.

The third anchor is what the address space actually contains. After execve returns to userspace there are two independently-relocated ELF objects mapped, plus the kernel-provided vDSO, and only later do the shared libraries join them:

high addresses
 ┌──────────────────────────────────────────────────────────┐
 │ [stack]        argc, argv[], envp[], auxv[]              │  <- %rsp points at argc
 ├──────────────────────────────────────────────────────────┤
 │ linux-vdso.so.1        (mapped by the kernel, no file)   │  auxv AT_SYSINFO_EHDR
 ├──────────────────────────────────────────────────────────┤
 │ /lib64/ld-linux-x86-64.so.2   <- PT_INTERP, ET_DYN       │  auxv AT_BASE
 │    .text  .rodata  .data.rel.ro  .data  .bss             │  entry = auxv-independent
 ├──────────────────────────────────────────────────────────┤
 │ /lib64/libc.so.6      (NOT yet mapped at handoff —       │
 │ /lib64/libm.so.6       ld.so maps these itself later)    │
 ├──────────────────────────────────────────────────────────┤
 │ [heap]                 (brk, created later by malloc)    │
 ├──────────────────────────────────────────────────────────┤
 │ ./t_lazy   the program itself, ET_DYN if PIE             │  auxv AT_PHDR / AT_ENTRY
 │    .text  .rodata  .data.rel.ro  .got  .got.plt  .data   │
 └──────────────────────────────────────────────────────────┘
low addresses

Process address space at the instant the kernel transfers control, drawn as an ASCII map because a vertical address-ordered layout is one of the few things mermaid cannot render honestly. What it shows: the kernel maps exactly three things — the program, the interpreter named in PT_INTERP, and the vDSO — and leaves a description of all of them on the stack in the auxiliary vector. The insight to take: libc.so.6 is conspicuously absent. Nothing in the kernel knows or cares that the program needs libc; that discovery is entirely ld.so’s job, driven by DT_NEEDED entries it reads out of the program’s own dynamic section.

Mechanical Walk-through

The kernel’s half. execve(2) parses the ELF header. If the file is dynamically linked it contains a PT_INTERP program header. The kernel maps the executable’s PT_LOAD segments, then maps the interpreter named in PT_INTERP, builds the initial process stack (argc, argv, envp, and the auxiliary vector — see Process Startup Sequence), and sets the entry point to the interpreter’s e_entry. For a static binary PT_INTERP is absent and the kernel jumps straight to the program’s e_entry, skipping ld.so entirely. The dynamic linker learns where the real program lives, its entry point, and its program-header table through the auxiliary vector entries AT_PHDR, AT_PHNUM, AT_ENTRY, and AT_BASE (the linker’s own load base).

Self-relocation: the chicken-and-egg. Control reaches ld.so’s assembly _start, which calls _dl_start (elf/rtld.c, glibc 2.43). The linker is position-independent code (PIC) loaded at a random base, so any absolute address baked into it — including the address of its own functions reached through the Global Offset Table — is wrong until relocated. But relocation normally uses global data (the symbol table, the relocation table) reached through exactly those broken references. The escape: _dl_start first determines its own runtime load address purely from PIC-safe means. On x86-64 it takes the address of the linker’s own ELF header symbol __ehdr_start (elf_machine_load_address) and computes the address of its _DYNAMIC array, both as PC-relative references that need no relocation (sysdeps/x86_64/dl-machine.h, glibc 2.43). It fills a temporary bootstrap_map describing itself from that dynamic section, then performs the self-relocation:

ELF_DYNAMIC_RELOCATE (&bootstrap_map, NULL, 0, 0, 0);
bootstrap_map.l_relocated = 1;

The comment right before it tells the whole story — glibc’s own _dl_start is preceded by “Relocate ourselves so we can do normal function calls and data access using the global offset table”, and immediately after the relocation completes the source declares “Now life is sane; we can call functions and access global data” (elf/rtld.c, glibc 2.43). Until that line, the code is hand-restricted to static locals and inlined helpers; after it, the linker can use ordinary C with global state.

Loading the program’s dependencies. Now relocated, ld.so runs dl_main, which builds the main map (a link_map describing the executable itself), then reads its DT_NEEDED entries. Each DT_NEEDED is a “String table offset to name of a needed library” (elf(5)) — typically a SONAME like libc.so.6. _dl_map_object_deps walks this list and, for each name, calls _dl_map_object to find and load that object, then recurses into its DT_NEEDED list, breadth-first, until the transitive closure of dependencies is loaded. The mechanics of finding and mmap-ing one .so are the subject of Shared Library Loading; the search order (RPATH, LD_LIBRARY_PATH, RUNPATH, the ldconfig cache) is Library Search Path and ldconfig.

The link_map list. Every loaded object — the executable, every shared library, and ld.so itself — is represented by a struct link_map, and they are threaded into a doubly linked list. Each node records the object’s load address (l_addr), its name (l_name, the real on-disk path), a parsed index of its dynamic section (l_info), and its place in symbol-resolution scopes. This list is the loader’s model of the running process’s shared-object graph; it is the same structure a debugger reads (via the _r_debug rendezvous protocol) to learn which libraries are loaded, and the same structure _dl_init later walks to run constructors.

Building the symbol search scope. Loading objects is not enough — the linker must decide, for any given undefined symbol, which loaded object’s definition wins. It computes a search list per object: l_searchlist is the breadth-first ordering of an object plus its dependencies, and l_scope is the array of scope elements consulted during lookup. By default the main executable and its direct/indirect dependencies form the global scope searched in load order, which is why symbol definitions earlier in the dependency order win (the basis of interposition; see Symbol Resolution and Lookup Scope and Symbol Interposition and LD_PRELOAD).

Applying relocations. With every object mapped and the scope built, the linker walks each object and calls _dl_relocate_object, which processes that object’s relocation entries: each entry says “patch location L so it holds the runtime address of symbol S” (or ”+ load bias B”). Data relocations are applied eagerly; PLT (function) relocations are by default deferred to first call — lazy binding — unless LD_BIND_NOW/-z now forces them up front. The full mechanism is Relocation Processing, and the PLT/GOT indirection that makes laziness possible is The Procedure Linkage Table / The Global Offset Table.

Running initializers, then jumping to the program. Finally _dl_init runs constructors. It walks the initializer list and calls each object’s DT_INIT function and DT_INIT_ARRAY entries, in reverse dependency order so that “the constructors for all dependencies of an object must run before the constructor for the object itself” (elf/dl-init.c, glibc 2.43). Then ld.so transfers control to the program. On x86-64 the RTLD_START assembly saves the application entry point that _dl_start returned, restores the stack pointer to point at argc, passes the linker’s finalizer _dl_fini to the program in %rdx “as per ELF ABI”, and executes “Jump to the user’s entry point”jmp *%r12 (sysdeps/x86_64/dl-machine.h, glibc 2.43). From here the program’s own _start runs __libc_start_main and ultimately main (_start and __libc_start_main).

The whole handoff, with the data that flows across each boundary, looks like this:

sequenceDiagram
    autonumber
    participant K as Kernel (fs/binfmt_elf.c)
    participant LD as ld.so (the interpreter)
    participant FS as Filesystem / ld.so.cache
    participant P as Your program

    K->>K: parse ELF header, find PT_INTERP
    K->>K: mmap program PT_LOAD segments
    K->>K: mmap /lib64/ld-linux-x86-64.so.2
    K->>K: push argc/argv/envp + auxv onto the new stack
    K->>LD: set RIP = interpreter e_entry (NOT the program's)
    Note over LD: _start: mov %rsp,%rdi; call _dl_start
    LD->>LD: elf_machine_load_address() — where am I?
    LD->>LD: ELF_DYNAMIC_RELOCATE(bootstrap_map) — self-relocate
    Note over LD: "Now life is sane" — globals usable
    LD->>LD: read AT_PHDR / AT_ENTRY / AT_BASE from auxv
    loop for each DT_NEEDED, breadth-first
        LD->>FS: search RPATH / LD_LIBRARY_PATH / RUNPATH / cache / default
        FS-->>LD: fd
        LD->>LD: mmap PT_LOAD segments, append to link_map list
    end
    LD->>LD: build l_searchlist / l_scope (the global scope)
    LD->>LD: _dl_relocate_object() for each object, reverse order
    LD->>LD: _dl_init(): DT_PREINIT_ARRAY, then constructors
    LD->>P: lea _dl_fini,%rdx ; jmp *%r12
    Note over P: _start → __libc_start_main → main

The complete execve-to-main exchange. What it shows: the ordering constraint that makes everything else make sense — self-relocation must precede any use of globals, loading must precede scope construction, scope construction must precede relocation (because relocation needs symbol lookup), and relocation must precede constructors (because constructors are ordinary code that calls through the GOT). The insight to take: each arrow is a dependency, not a stylistic choice. You cannot reorder any two adjacent steps without breaking the linker.

The auxiliary vector is the only channel the kernel has for telling ld.so about the program it is supposed to run, since ld.so was started with the program’s argv, not with a pointer to it. The entries that matter to the loader:

auxv entryMeaningWhy ld.so needs it
AT_PHDRAddress of the program’s program-header tableTo find the program’s PT_DYNAMIC, and hence its DT_NEEDED list
AT_PHNUMNumber of program headersBound for the scan of AT_PHDR
AT_PHENTSize of one program headerStride for that scan
AT_ENTRYThe program’s e_entryThe address finally reached by jmp *%r12
AT_BASELoad base of the interpreter itselfCross-check on the linker’s self-computed load address
AT_SECURENon-zero ⇒ secure-execution modeGates LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, …
AT_SYSINFO_EHDRBase of the vDSOLets the loader add linux-vdso.so.1 to the link map with no file behind it
AT_HWCAP / AT_HWCAP2CPU feature bitmapsFeeds IFUNC resolvers and hardware-capability library selection
AT_PLATFORMe.g. "x86_64"Expansion of the $PLATFORM dynamic string token

Note the oddity in the third row of the address-space map above: linux-vdso.so.1 appears in every ldd listing but exists nowhere on disk. It is a shared object the kernel synthesizes in memory and advertises through AT_SYSINFO_EHDR; ld.so parses its ELF headers straight out of that mapping and gives it a link_map entry like any other library. That is why ldd /bin/ls shows it without a => path.

Finding the Library: the Exact Search Precedence

For every DT_NEEDED string, ld.so must turn a SONAME like libc.so.6 into an open file descriptor. The rule has a fast escape hatch and then a five-step ladder. The escape hatch first: “the dynamic linker first inspects each dependency string to see if it contains a slash … If a slash is found, then the dependency string is interpreted as a (relative or absolute) pathname” (ld.so(8)) — no searching at all. Otherwise _dl_map_object in elf/dl-load.c, glibc 2.43 runs the ladder, and the source is the authority on the precise order:

flowchart TD
  START["DT_NEEDED string, e.g. libfoo.so.1"] --> SLASH{"contains<br/>a slash?"}
  SLASH -->|yes| DIRECT["treat as a pathname<br/>open it directly, no search"]
  SLASH -->|no| RUNQ{"does the <b>loading object</b><br/>have DT_RUNPATH?"}
  RUNQ -->|"yes — RPATH is<br/>skipped entirely"| LLP
  RUNQ -->|no| RPATH["1. DT_RPATH of the loading object,<br/>then its loader, then its loader's loader …<br/>(for l = loader; l; l = l-&gt;l_loader)<br/>then DT_RPATH of the main executable"]
  RPATH -->|miss| LLP["2. LD_LIBRARY_PATH<br/>(stripped if AT_SECURE)"]
  LLP -->|miss| RUNPATH["3. DT_RUNPATH of the loading object <b>only</b><br/>— never inherited from its loader"]
  RUNPATH -->|miss| CACHE["4. /etc/ld.so.cache<br/>_dl_load_cache_lookup()<br/>skipped if DF_1_NODEFLIB or LD_LIBRARY_PATH<br/>inhibited by --inhibit-cache"]
  CACHE -->|miss| DEFAULT["5. built-in default dirs<br/>/lib64, /usr/lib64<br/>skipped if DF_1_NODEFLIB"]
  DEFAULT -->|miss| FAIL["error while loading shared libraries:<br/>libfoo.so.1: cannot open shared object file"]

The DT_NEEDED resolution ladder, transcribed from _dl_map_object. What it shows: five ordered chances plus one bypass, and the single most consequential branch at the top — the mere presence of DT_RUNPATH on the loading object switches off the entire DT_RPATH arm. The insight to take: RPATH and RUNPATH are not two spellings of one feature. They sit on opposite sides of LD_LIBRARY_PATH, which is the whole point of the redesign.

That single positional difference is the reason DT_RPATH was deprecated. Because RPATH is consulted before LD_LIBRARY_PATH, a binary with an RPATH cannot be redirected to a different copy of a library by an operator setting an environment variable — the baked-in path always wins, and the only fix is to patch or relink the binary. RUNPATH sits after LD_LIBRARY_PATH, so it behaves as a default that the environment can override, which is what almost everyone actually wanted. There is a second, subtler difference, and the manual page states it precisely: RUNPATH directories “are searched only to find those objects required by DT_NEEDED (direct dependencies) entries and do not apply to those objects’ children, which must themselves have their own DT_RUNPATH entries. This is unlike DT_RPATH, which is applied to searches for all children in the dependency tree” (ld.so(8)). The glibc source makes that non-inheritance mechanical rather than merely documented: the RPATH arm walks the whole loader chain with for (l = loader; l; l = l->l_loader), while the RUNPATH arm indexes only loader->l_runpath_dirs — one object, no walk.

DT_RPATH (tag 15)DT_RUNPATH (tag 29)
Position in the search orderBefore LD_LIBRARY_PATHAfter LD_LIBRARY_PATH
Overridable by the environmentNo — the binary always winsYes
Applies to transitive dependenciesYes — inherited down the whole dependency treeNo — direct DT_NEEDED only
Emitted byld --disable-new-dtags -rpath DIRld --enable-new-dtags -rpath DIR (the default on modern GNU ld)
StatusDeprecated; ignored outright whenever DT_RUNPATH is also presentThe supported mechanism
$ORIGIN / $LIB / $PLATFORM expansionYesYes

The practical corollary of the “no transitive inheritance” row is a real deployment trap. A vendored application tree that sets RUNPATH=$ORIGIN/../lib on the executable will find its own direct dependencies; but if libapp.so in turn needs libhelper.so from that same private directory, the executable’s RUNPATH does not help — libapp.so needs its own RUNPATH. Under the old RPATH this worked by accident, which is exactly why people are surprised when a toolchain switches to new-style dtags and a previously working bundle stops resolving. The other two lines of defence for a relocatable bundle are $ORIGIN“this expands to the directory containing the program or shared object”, so gcc -Wl,-rpath,'$ORIGIN/../lib' makes a tree work “no matter where somedir is located in the directory hierarchy” (ld.so(8)) — and giving every private library its own RUNPATH.

Step 4 is where nearly all real lookups actually terminate, because the cache is a pre-computed index of every library ldconfig has ever seen. On the machine used for these examples it holds 2,619 entries in 130 KiB, and LD_DEBUG=libs shows the resolution stopping there on the first try:

$ LD_DEBUG=libs ./t_lazy
   3263521:	find library=libm.so.6 [0]; searching
   3263521:	 search cache=/etc/ld.so.cache
   3263521:	  trying file=/lib64/libm.so.6
   3263521:	find library=libc.so.6 [0]; searching
   3263521:	 search cache=/etc/ld.so.cache
   3263521:	  trying file=/lib64/libc.so.6

Two libraries, two cache hits, zero directory scans — no stat storm at all. Contrast that with a binary carrying an LD_LIBRARY_PATH of six directories, where the loader must try each directory for each library before falling through to the cache; this is the mechanism behind the folklore that a long LD_LIBRARY_PATH slows down process startup. The cache’s own format, ldconfig’s maintenance of it, and the hardware-capability subdirectories are covered in Library Search Path and ldconfig.

One security-relevant asymmetry hides in step 4: the cache lookup is skipped when the process is in secure-execution mode and __RTLD_SECURE is set, and the default-directory step is skipped when the object carries DF_1_NODEFLIB (the -z nodefaultlib link option). Both exist so that a hardened or fully self-contained deployment can guarantee it never picks up a system library by accident.

Relocation: What Actually Gets Patched

A relocation is a note the static linker leaves for the runtime linker, and it has exactly three parts. On x86-64 the on-disk form is Elf64_Rela, 24 bytes:

packet-beta
0-31: "r_offset (low 32 bits) — where to patch, as an offset from the object's load base"
32-63: "r_offset (high 32 bits)"
64-95: "r_info: relocation TYPE — ELF64_R_TYPE(r_info), the low 32 bits"
96-127: "r_info: SYMBOL INDEX — ELF64_R_SYM(r_info), the high 32 bits, indexes DT_SYMTAB"
128-159: "r_addend (low 32 bits) — constant A folded into the computation"
160-191: "r_addend (high 32 bits)"

The Elf64_Rela relocation entry, 24 bytes, drawn at bit accuracy. Field order follows memory layout, so the little-endian low half of each 64-bit word comes first. What it shows: a relocation is a triple — an address to patch (r_offset), a rule for computing the value (the type), and whose address to compute (the symbol index), plus a constant. The insight to take: r_offset is a link-time offset, not a runtime address; the loader adds the object’s load bias l_addr to it, which is why the same .so file works at any load address.

The psABI’s notation for the computation is worth walking symbol by symbol, because the whole relocation table is expressed in it (AMD64 psABI v1.0, March 2025, Table 4.9): A is the addend from r_addend; S is the runtime address of the symbol named by the symbol index; B is the base address the object was actually loaded at (glibc’s l_addr); P is the address of the place being patched; GOT is the address of the global offset table; G is the offset of the symbol’s GOT slot within that table; L is the address of the symbol’s PLT entry. The handful of types the dynamic linker actually processes at load time:

TypeValueComputationWhat it patches, and when
R_X86_64_RELATIVE8B + AA pointer inside the object to another location inside the same object (a vtable slot, a string-literal pointer in a static initializer). No symbol lookup at all — pure arithmetic, and by far the most common relocation in a PIE. Always eager.
R_X86_64_GLOB_DAT6SA GOT slot holding a data address, or a function address taken as a value. Requires a symbol lookup. Always eager.
R_X86_64_JUMP_SLOT7SA .got.plt slot for a function called through the PLT. This is the one type that can be deferred — the entire lazy-binding mechanism exists to postpone exactly these.
R_X86_64_COPY5noneCopies the bytes of a data object out of a shared library into the executable’s .bss, so the executable can refer to it with a link-time-fixed address. A legacy of non-PIE executables; the source of “copy relocation” ABI hazards.
R_X86_64_IRELATIVE37indirect (B + A)Calls a resolver function at B + A and stores its return value. This is the STT_GNU_IFUNC mechanism that picks, e.g., the AVX-512 memcpy at startup.
R_X86_64_DTPMOD64 / R_X86_64_DTPOFF64 / R_X86_64_TPOFF6416 / 17 / 18(TLS)Thread-local storage: module ID, offset within that module’s TLS block, and offset from the thread pointer. Never lazy.

Two structural facts follow from that table. First, relative relocations dominate, and because they need no symbol lookup they are cheap per entry but numerous — a large PIE can have hundreds of thousands. That numerosity motivated DT_RELR, a compressed encoding in which one 64-bit word is either an address or a bitmap of up to 63 following word-sized slots that also need B + A applied. glibc gained support in 2.36: “Support for DT_RELR relative relocation format has been added to glibc. This is a new ELF dynamic tag that improves the size of relative relocations in shared object files and position independent executables (PIE). DT_RELR generation requires linker support for -z pack-relative-relocsLazy binding doesn’t apply to DT_RELR (glibc NEWS, 2.43 tree). The effect is visible on this machine: /bin/ls, built with -z pack-relative-relocs, carries DT_RELR and reports only 4 classic relative relocations because the rest were folded into the compressed section.

Second, only R_X86_64_JUMP_SLOT is a candidate for laziness, and Drepper states the rule generally: “on many architectures it is possible to delay the processing of some relocations until the references in question are actually used. This is on many architectures the case for calls to functions. All other kinds of relocations always have to be processed before the object can be used (How To Write Shared Libraries §1.5.2). A data address must be correct the moment any code might read it; a function address only has to be correct at the moment of the call, and a call goes through an indirection you can trap.

Here is the real relocation table of a two-call test program built on this machine, which shows the split concretely:

$ readelf -rW t_lazy
Relocation section '.rela.dyn' at offset 0x1150 contains 2 entries:
    Offset             Info             Type               Symbol's Name + Addend
0000000000402fd8  0000000100000006 R_X86_64_GLOB_DAT      __libc_start_main@GLIBC_2.34 + 0
0000000000402fe0  0000000400000006 R_X86_64_GLOB_DAT      __gmon_start__ + 0

Relocation section '.rela.plt' at offset 0x1180 contains 2 entries:
0000000000403000  0000000200000007 R_X86_64_JUMP_SLOT     puts@GLIBC_2.2.5 + 0
0000000000403008  0000000300000007 R_X86_64_JUMP_SLOT     printf@GLIBC_2.2.5 + 0

Read the Info column as the packed r_info: 0000000200000007 is symbol index 2, type 7 (R_X86_64_JUMP_SLOT). Note also that the symbol names carry @GLIBC_2.2.5 and @GLIBC_2.34 — the version requirements discussed below. The two sections are addressed by different dynamic tags (DT_RELA for .rela.dyn, DT_JMPREL for .rela.plt) precisely so the loader can process one eagerly and the other on demand. The mechanics of applying each entry are the subject of Relocation Processing.

Lazy Binding: the PLT/GOT Trampoline

This is the single mechanism most worth drawing, because it is the one place in the whole system where running code rewrites its own dispatch table, and prose renders it nearly incomprehensible.

Every call to a function that might live in another object is compiled as a call to a local stub in the Procedure Linkage Table (.plt), and every PLT stub begins with an indirect jump through a slot in the Global Offset Table (.got.plt). The static linker cannot know the target address, so it does something clever: it initialises the GOT slot to point back into the PLT stub itself, at the instruction immediately after the jump. The first call therefore “falls through” its own indirection into a two-instruction preamble that identifies which symbol was wanted and calls the resolver; the resolver overwrites the GOT slot with the real address; every subsequent call takes the fast path and never touches the loader again.

Both states, from the real binary:

flowchart TB
  subgraph BEFORE["BEFORE the first call — as ld.so left it"]
    direction TB
    C1["call puts@plt<br/>(in main)"]
    P1["<b>puts@plt</b> 0x400370<br/>jmp *0x2c8a(%rip)  → GOT[3]"]
    G1["<b>GOT[3]</b> 0x403000<br/>= <b>0x400376</b>"]
    P1b["0x400376: push $0x0     ← reloc index<br/>0x40037b: jmp 0x400360   ← PLT0"]
    P0["<b>PLT0</b> 0x400360<br/>push 0x2c8a(%rip) → GOT[1]<br/>jmp *0x2c8c(%rip) → GOT[2]"]
    G12["<b>GOT[1]</b> = struct link_map *<br/><b>GOT[2]</b> = _dl_runtime_resolve_xsavec"]
    RES["_dl_fixup(l, reloc_arg)<br/>_dl_lookup_symbol_x → libc puts<br/><b>*rel_addr = 0x7ffff7ce7bc0</b>"]
    C1 --> P1 --> G1 -->|"points back<br/>into the stub"| P1b --> P0 --> G12 --> RES
    RES -.->|"overwrites"| G1
  end
  subgraph AFTER["AFTER the first call — self-modified"]
    direction TB
    C2["call puts@plt"] --> P2["<b>puts@plt</b> 0x400370<br/>jmp *0x2c8a(%rip) → GOT[3]"]
    P2 --> G2["<b>GOT[3]</b> 0x403000<br/>= <b>0x7ffff7ce7bc0</b><br/>(puts in libc.so.6)"]
    G2 --> LIBC["libc puts()"]
    DEAD["0x400376: push $0x0<br/>0x40037b: jmp PLT0<br/><i>now unreachable — dead code</i>"]
  end

Lazy binding, before and after the first call to puts. Addresses are real, captured on the machine described above. What it shows: the GOT slot starts holding 0x400376, which is puts@plt + 6 — the push instruction inside its own stub — and ends holding 0x7ffff7ce7bc0, the real puts in libc.so.6. The resolver reaches itself through GOT[2] and identifies the calling object through GOT[1]. The insight to take: the “trampoline” is not a separate piece of code; it is the tail of the PLT stub that the first jump deliberately does not skip. After resolution that tail is unreachable, and the cost of every later call is a single indirect jump.

The GOT dump from gdb shows both states verbatim. The three reserved slots and the two function slots:

(gdb) break main ; run
(gdb) x/5gx 0x402fe8                     # .got.plt, BEFORE the first call
0x402fe8:  0x0000000000402df8   <- GOT[0] = &_DYNAMIC (0x402df8, matches readelf -S)
0x402ff0:  0x00007ffff7ffe2e0   <- GOT[1] = struct link_map * for this object
0x402ff8:  0x00007ffff7fd7760   <- GOT[2] = resolver trampoline
0x403000:  0x0000000000400376   <- GOT[3] = puts@plt+6   *** points into the PLT ***
0x403008:  0x0000000000400386   <- GOT[4] = printf@plt+6 *** points into the PLT ***

(gdb) info symbol *(long*)0x402ff8
_dl_runtime_resolve_xsavec in section .text of /lib64/ld-linux-x86-64.so.2

(gdb) break puts ; continue ; finish
(gdb) x/5gx 0x402fe8                     # AFTER
0x402fe8:  0x0000000000402df8
0x402ff0:  0x00007ffff7ffe2e0
0x402ff8:  0x00007ffff7fd7760
0x403000:  0x00007ffff7ce7bc0   <- GOT[3] rewritten
0x403008:  0x00007ffff7cbf990   <- GOT[4] rewritten
(gdb) info symbol *(long*)0x403000
puts in section .text of /lib64/libc.so.6

Every number in that dump is predicted by the specification. The psABI describes the sequence in eight steps; steps 4–8 are the ones the dump confirms: “The first instruction jumps to the address in the global offset table entry for name1. Initially the global offset table holds the address of the following pushq instruction, not the real address of name1. … the program then jumps to .PLT0 … The pushq instruction places the value of the second global offset table entry (GOT+8) on the stack, thus giving the dynamic linker one word of identifying information. The program then jumps to the address in the third global offset table entry (GOT+16), which transfers control to the dynamic linker. … it … finds the symbol’s value, stores the ‘real’ address for name1 in its global offset table entry, and transfers control to the desired destination” (AMD64 psABI §B.1).

The three reserved GOT slots are filled by the loader, not the static linker, and glibc’s elf_machine_runtime_setup does it explicitly — the comment states the contract: “The GOT entries for functions in the PLT have not yet been filled in. Their initial contents will arrange when called to push an offset into the .rel.plt section, push _GLOBAL_OFFSET_TABLE_[1], and then jump to _GLOBAL_OFFSET_TABLE_[2].” The code then writes *(ElfW(Addr) *) (got + 1) = (ElfW(Addr)) l; — “Identify this shared object” — and *(ElfW(Addr) *) (got + 2) = (ElfW(Addr)) GLRO(dl_x86_64_runtime_resolve); (sysdeps/x86_64/dl-machine.h, glibc 2.43).

SlotContentsWritten by
GOT[0]Address of the object’s _DYNAMIC arrayStatic linker (as a link-time constant, adjusted by an R_X86_64_RELATIVE-style bias)
GOT[1]struct link_map * for this object — how the resolver knows whose PLT faultedld.so, in elf_machine_runtime_setup
GOT[2]Address of the resolver trampoline (_dl_runtime_resolve*)ld.so, same function
GOT[3…]One slot per PLT function; initially PLT stub + 6Static linker; overwritten by _dl_fixup

Why the resolver on this machine is named _dl_runtime_resolve_**xsavec** is itself instructive. The trampoline runs in the middle of a function call, after the caller has already loaded arguments into registers — including vector registers, since the x86-64 calling convention passes floating-point arguments in %xmm0%xmm7. The resolver is ordinary C code that will clobber them. So glibc has several trampoline variants — _fxsave, _xsave, _xsavec — and selects at startup based on CPU features, saving the entire extended register state before entering _dl_fixup and restoring it before jumping to the resolved target. That save/restore is the hidden per-first-call cost of lazy binding, and it grows with the width of the vector unit.

What the resolver actually does is the second half of the story. _dl_fixup is deliberately small (elf/dl-runtime.c, glibc 2.43):

sequenceDiagram
    autonumber
    participant A as Application code
    participant PLT as puts@plt
    participant T as _dl_runtime_resolve_xsavec
    participant F as _dl_fixup
    participant L as _dl_lookup_symbol_x

    A->>PLT: call puts@plt
    PLT->>PLT: jmp *GOT[3] — lands on its own push
    PLT->>PLT: push $0 (reloc index) ; jmp PLT0
    PLT->>T: push GOT[1] (link_map*) ; jmp *GOT[2]
    T->>T: xsavec — preserve %xmm/%ymm/%zmm argument registers
    T->>F: _dl_fixup(link_map *l, ElfW(Word) reloc_arg)
    F->>F: reloc = DT_JMPREL + reloc_offset(pltgot, reloc_arg)
    F->>F: assert ELFW(R_TYPE) of reloc.r_info == ELF_MACHINE_JMP_SLOT
    F->>F: sym = symtab[ ELFW(R_SYM) of reloc.r_info ]
    F->>F: version = l.l_versions[ vernum[R_SYM] AND 0x7fff ]
    F->>L: _dl_lookup_symbol_x(name, l, sym, l.l_scope, version, ELF_RTYPE_CLASS_PLT, …)
    L-->>F: defining link_map + symbol
    F->>F: if STT_GNU_IFUNC: value = elf_ifunc_invoke(value)
    F->>F: elf_machine_fixup_plt() — *rel_addr = value
    F-->>T: resolved address
    T->>T: xrstor — restore argument registers
    T->>A: jmp *resolved — the original call finally happens

One lazy resolution, end to end. What it shows: the reloc index pushed by the stub is the only piece of information distinguishing one PLT slot from another, and GOT[1] is the only thing identifying which object asked; from those two integers _dl_fixup recovers the relocation, the symbol, and the symbol’s version, then runs a full scope search. The insight to take: a “lazy” call is not free — it is a symbol-table lookup across the entire global scope, wrapped in a full extended-register save/restore. It is cheaper than doing that work for functions you never call, and strictly more expensive for functions you do.

Two details in that flow matter and are easy to miss. The version lookup (l->l_versions[ndx]) means lazy binding is fully version-aware — a lazily bound call resolves to printf@GLIBC_2.2.5, not merely to “some printf”. And ELF_RTYPE_CLASS_PLT tells _dl_lookup_symbol_x that this is a function lookup, which changes how copy relocations and protected visibility are handled.

LD_DEBUG=bindings makes the laziness observable directly. Run against the same test binary, the loader logs the two PLT bindings, and they appear during execution rather than at startup:

$ LD_DEBUG=bindings ./t_lazy
  binding file /lib64/libm.so.6 [0] to /lib64/libc.so.6 [0]: normal symbol `fputs' [GLIBC_2.2.5]
  binding file ./t_lazy [0] to /lib64/libc.so.6 [0]: normal symbol `printf' [GLIBC_2.2.5]
  binding file ./t_lazy [0] to /lib64/libc.so.6 [0]: normal symbol `puts' [GLIBC_2.2.5]

The full description of the PLT and GOT as data structures — including .plt.sec, the Control-flow Enforcement Technology (CET) split described below — is in The Procedure Linkage Table and The Global Offset Table.

BIND_NOW and RELRO: the Hardening That Turns Lazy Binding Off

Lazy binding is a performance optimisation with a security cost, and on a modern distribution the security side has won. Understanding why requires seeing what lazy binding requires the memory map to look like.

For the trampoline to work, .got.plt must stay writable for the entire life of the process — that is the whole point; the resolver writes into it on every first call. A writable, executable-adjacent table of function pointers that the program dereferences on every library call is an attacker’s ideal target: overwrite one .got.plt slot and the next call to printf jumps wherever you like, with no stack corruption and no ROP chain needed. This “GOT overwrite” was for years the standard second stage of a heap-overflow exploit.

RELRO (RELocation Read-Only) is the mitigation, and it comes in two strengths that are exactly the two answers to “can the GOT be made read-only?”.

flowchart TB
  subgraph PART["Partial RELRO — <b>-z relro</b> alone (lazy binding still on)"]
    direction TB
    PA["<b>GNU_RELRO segment</b><br/>.init_array .fini_array<br/>.data.rel.ro .dynamic .got"]
    PB["<b>NOT covered:</b> .got.plt<br/>stays <b>RW</b> forever —<br/>the resolver must write it"]
    PC["mprotect(PROT_READ) after<br/>eager relocations, before _dl_init"]
    PA --> PC
    PB -.->|"writable target"| ATK["GOT-overwrite<br/>attack surface"]
  end
  subgraph FULL["Full RELRO — <b>-z relro -z now</b> (lazy binding off)"]
    direction TB
    FA["DT_FLAGS |= DF_BIND_NOW<br/>DT_FLAGS_1 |= DF_1_NOW"]
    FB["ld.so resolves <b>every</b><br/>R_X86_64_JUMP_SLOT at startup"]
    FC["linker merges .got.plt into .got,<br/>inside the GNU_RELRO segment"]
    FD["mprotect(PROT_READ) covers<br/>the entire GOT"]
    FA --> FB --> FC --> FD --> SAFE["no writable function-pointer table<br/>after startup"]
  end

The two RELRO strengths, and why full RELRO and lazy binding are mutually exclusive. What it shows: partial RELRO write-protects the eagerly relocated data but must leave .got.plt writable, because the lazy resolver’s job is to write it; full RELRO buys the write-protection by paying for all PLT relocations up front. The insight to take: “full RELRO” and “BIND_NOW” are not two separate hardening flags you can mix and match — one is the precondition for the other. You cannot have a read-only GOT and a lazy resolver.

The transition is visible in the ELF metadata. Two binaries built from the same source on this machine, differing only in the link flag:

$ gcc -O0 -o t_lazy t.c -lm -Wl,-z,lazy -Wl,-z,relro
$ gcc -O0 -o t_now  t.c -lm -Wl,-z,now  -Wl,-z,relro

$ readelf -d t_lazy | grep FLAGS          # (no output — no FLAGS entry at all)
$ readelf -d t_now  | grep FLAGS
 0x000000000000001e (FLAGS)               BIND_NOW
 0x000000006ffffffb (FLAGS_1)             Flags: NOW

$ readelf -lW t_lazy | grep GNU_RELRO
  GNU_RELRO  0x001de8 0x402de8 0x402de8 0x000218 0x000218 R
$ readelf -lW t_now  | grep GNU_RELRO
  GNU_RELRO  0x002db8 0x403db8 0x403db8 0x000248 0x000248 R

The RELRO region grew by 0x248 - 0x218 = 0x30 bytes — precisely the five 8-byte .got.plt slots plus alignment that the now build pulled inside the protected region. Drepper notes the one-way nature of the flag: “The linker will set the DF_BIND_NOW flag in the DT_FLAGS entry of the dynamic section to mark the DSO. This setting cannot be undone without relinking the DSOs or editing the binary” (How To Write Shared Libraries §1.5.2) — LD_BIND_NOW=1 can force eagerness on a lazy binary, but nothing in the environment can restore laziness to a -z now binary.

Fedora, like most distributions since roughly the mid-2010s, builds everything this way. The system /bin/ls on this machine shows the full picture:

$ readelf -d /bin/ls
 0x000000000000001e (FLAGS)               BIND_NOW
 0x000000006ffffffb (FLAGS_1)             Flags: NOW PIE
 0x0000000000000024 (RELR)                0x1b028      <- DT_RELR, compressed relatives
 0x0000000000000023 (RELRSZ)              80 (bytes)

$ readelf -SW /bin/ls | grep -E '\.plt|\.got'
  [ 3] .plt       PROGBITS  0x360   0x6a0  AX
  [ 4] .plt.sec   PROGBITS  0xa00   0x690  AX
  [26] .got       PROGBITS  0x24c08 0x3f0  WA      <- and NO .got.plt at all

$ readelf -lW /bin/ls | sed -n '/Section to Segment/,$p' | tail -1
   12     .init_array .fini_array .data.rel.ro .dynamic .got

Three things to read out of that. First, there is no .got.plt section — under -z now the linker folds it into .got, which segment 12 (the GNU_RELRO segment) covers, so the entire table is mprotected read-only before main runs. Second, DT_RELR is present, which is why LD_DEBUG=statistics reports only four classic relative relocations for a binary of this size. Third, the .plt.sec section: with -z now the classic PLT stub is no longer needed for dispatch, so the toolchain emits a second, minimal table whose entries are just endbr64; jmp *GOT[n]

$ objdump -d -j .plt.sec /bin/ls | head -8
0000000000000a00 <__ctype_toupper_loc@plt>:
     a00:	f3 0f 1e fa          	endbr64
     a04:	ff 25 16 42 02 00    	jmp    *0x24216(%rip)   # 24c20 <__ctype_toupper_loc@GLIBC_2.3>

$ readelf -n /bin/ls | grep -A1 Properties
      Properties: x86 feature: IBT, SHSTK

The endbr64 is the landing pad required by Indirect Branch Tracking, one half of Intel’s Control-flow Enforcement Technology; the note property confirms the binary opts into both IBT and Shadow Stack. Splitting the PLT was necessary because the classic stub’s second instruction is a push, not a valid indirect-branch target.

So the performance-versus-hardening tension resolves like this. Lazy binding buys you the symbol lookups for functions you never call — Drepper’s measurement of OpenOffice.org 1.0 gives the scale of the prize: 144 separate DSOs, roughly 20,000 relocations at startup, and by his estimate around 1.7 million string comparisons during symbol resolution, with “the percentage spent on relocations of the time the dynamic linker uses during startup … around 50–70% if the binary is already in the file system cache, and about 20–30% if the file has to be loaded from disk” (How To Write Shared Libraries §1.5.2–1.5.3). Against that, three things have changed since 2011 and each shifts the balance toward BIND_NOW:

  1. The GNU-style hash table (DT_GNU_HASH, described below) cut the cost of the average failed lookup by roughly an order of magnitude, so the 50–70% figure is not what a modern binary sees.
  2. DT_RELR removed most of the volume of relative relocations from the file entirely.
  3. GOT overwrite became a standard exploit primitive, so the writable-GOT cost is no longer theoretical.

The measured startup cost on this machine is now genuinely small. LD_DEBUG=statistics on the fully hardened, BIND_NOW, DT_RELR system /bin/ls:

$ LD_DEBUG=statistics /bin/ls /dev/null
  runtime linker statistics:
    total startup time in dynamic loader: 92775 cycles
              time needed for relocation: 30366 cycles (32.7%)
                   number of relocations: 564
        number of relocations from cache: 7
          number of relative relocations: 4
             time needed to load objects: 34404 cycles (37.0%)

564 relocations, all resolved eagerly, in about 30,000 cycles — on the order of ten microseconds on a 3 GHz core. For a utility that is the dominant part of loader time but a rounding error against execve itself. For a 144-DSO desktop application it would not be, which is why the trade-off is still argued about for large C++ applications and why LD_BIND_NOW=1 remains a useful diagnostic (it surfaces every unresolved symbol at startup instead of at an unpredictable later call) even where it is not the shipping configuration.

Uncertain

Verify: the claim that Fedora builds all packages with -z now full RELRO by default, and the release in which that became policy. Reason: it is directly observable that the Fedora 44 /bin/ls on this machine carries DF_BIND_NOW, DF_1_NOW, DF_1_PIE, DT_RELR, .plt.sec and a CET note — but a single binary is evidence about that binary, not about distribution-wide policy, and the Fedora packaging-guidelines page that would settle it sits behind the same Anubis proof-of-work challenge that blocked src.fedoraproject.org during this research (see the prelinking section). To resolve: read the Fedora “Changes/Harden All Packages” change page and the current redhat-rpm-config hardening macros, or sample readelf -d across a large set of installed binaries. uncertain

Symbol Lookup: Scope Order and the GNU Hash Bloom Filter

Loading objects does not by itself decide anything. When a relocation says “patch this slot with the address of printf”, the loader must choose which loaded object’s printf wins. That decision is made by walking a scope: an ordered array of objects, searched front to back, first definition wins.

The global scope is the breadth-first dependency order of the main executable. On this machine, LD_DEBUG=scopes prints it directly:

$ LD_DEBUG=scopes ./t_lazy
  Initial object scopes
  object=./t_lazy [0]
   scope 0: ./t_lazy /lib64/libm.so.6 /lib64/libc.so.6 /lib64/ld-linux-x86-64.so.2
  object=linux-vdso.so.1 [0]
   scope 0: ./t_lazy /lib64/libm.so.6 /lib64/libc.so.6 /lib64/ld-linux-x86-64.so.2
   scope 1: linux-vdso.so.1
  object=/lib64/libm.so.6 [0]
   scope 0: ./t_lazy /lib64/libm.so.6 /lib64/libc.so.6 /lib64/ld-linux-x86-64.so.2
  object=/lib64/ld-linux-x86-64.so.2 [0]
   no scope

Every object shares the same scope 0 — the global scope — and the executable is at its head. That single fact is the mechanism behind symbol interposition: a definition in the executable, or in a library loaded earlier, shadows the same name in a library loaded later, for every object in the process, including calls made from deep inside libc. LD_PRELOAD works by splicing its libraries into that list immediately after the executable and before the DT_NEEDED dependencies; the full treatment is in Symbol Interposition and LD_PRELOAD and Symbol Resolution and Lookup Scope.

flowchart TD
  START["_dl_lookup_symbol_x(name, undef_map, scope)"] --> H["compute GNU hash of name<br/>(new_hash), once for all objects"]
  H --> LOOP["for each map in scope order:<br/>executable → LD_PRELOAD → deps (BFS) → ld.so"]
  LOOP --> EMPTY{"map-&gt;l_nbuckets == 0?"}
  EMPTY -->|yes| NEXT["next object"]
  EMPTY -->|no| BLOOM{"<b>Bloom filter</b><br/>bitmask_word = l_gnu_bitmask[…]<br/>(w &gt;&gt; hashbit1) &amp; (w &gt;&gt; hashbit2) &amp; 1"}
  BLOOM -->|"0 — definitely absent"| NEXT
  BLOOM -->|"1 — maybe present"| BUCKET["bucket = l_gnu_buckets[new_hash % l_nbuckets]"]
  BUCKET --> ZERO{"bucket == 0?"}
  ZERO -->|yes| NEXT
  ZERO -->|no| CHAIN["walk l_gnu_chain_zero[bucket…]<br/>compare ((*hasharr ^ new_hash) &gt;&gt; 1) == 0<br/>stop when (*hasharr++ &amp; 1) != 0"]
  CHAIN --> MATCH{"check_match():<br/>strcmp + st_shndx != SHN_UNDEF<br/>+ version match + visibility"}
  MATCH -->|no| CHAIN
  MATCH -->|yes| FOUND["first definition wins —<br/>return this map + symbol"]
  NEXT --> LOOP

Symbol lookup in do_lookup_x, transcribed from elf/dl-lookup.c, glibc 2.43. What it shows: three successively cheaper rejection tests before any string comparison happens — an empty hash table, then a two-bit Bloom filter, then a zero bucket. Only if all three pass does the loader touch the symbol table. The insight to take: most lookups in most objects are failures, and the GNU hash table is engineered to make failure nearly free. The Bloom filter in particular rejects an entire shared library without a single cache miss on its symbol table.

The Bloom filter is worth reading in the original, because it is a two-hash filter compressed into one word (elf/dl-lookup.c, glibc 2.43):

ElfW(Addr) bitmask_word
  = bitmask[(new_hash / __ELF_NATIVE_CLASS) & map->l_gnu_bitmask_idxbits];
 
unsigned int hashbit1 = new_hash & (__ELF_NATIVE_CLASS - 1);
unsigned int hashbit2 = ((new_hash >> map->l_gnu_shift)
                         & (__ELF_NATIVE_CLASS - 1));
 
if (__glibc_unlikely ((bitmask_word >> hashbit1)
                      & (bitmask_word >> hashbit2) & 1))
  { /* … only now consult the buckets … */ }

Line by line: __ELF_NATIVE_CLASS is 64 on x86-64, so new_hash / 64 picks a word from the bitmask array and & l_gnu_bitmask_idxbits wraps it to the array’s size (a power of two, so a mask suffices). hashbit1 is the low 6 bits of the hash; hashbit2 is 6 bits taken from higher up, at a shift the linker chose when it built the table. The test shifts the word right by each and ANDs — both bits must be set. If either is clear the symbol is definitely not in this object and the loader moves on having read exactly one 64-bit word. If both are set the symbol is probably here, and the loader pays for the bucket walk. Like every Bloom filter this has false positives and no false negatives, which is exactly the guarantee a linker needs.

The chain walk has its own trick. l_gnu_chain_zero[bucket] is an array of 32-bit hashes rather than of symbol indices, and the low bit is stolen as an end-of-chain marker — hence the comparison ((*hasharr ^ new_hash) >> 1) == 0, which ignores bit 0, and the loop condition while ((*hasharr++ & 1u) == 0). Symbols in the table are sorted so that all symbols hashing to the same bucket are adjacent, so a chain is a contiguous run of 4-byte words: cache-friendly in a way the old SysV table, a linked list of arbitrary symbol indices, never was. glibc still keeps the SysV path (_dl_elf_hash, l_buckets/l_chain) as the else branch for objects built without DT_GNU_HASH — the /bin/ls on this machine has only GNU_HASH, no DT_HASH at all.

Symbol Versioning: Why Linux Does Not Have DLL Hell

The @GLIBC_2.2.5 suffixes in the relocation dump above are not cosmetic. Symbol versioning lets a single libc.so.6 export several incompatible definitions of the same function name, and lets each binary record which one it was built against. Drepper describes the design and its two departures from the Sun mechanism it extends: “it is possible to have more than one definition of a given symbol (the associated version must differ) and the application or DSO linked with the versioned DSO contains not only a list of the required version, but also records for each symbol which symbol version was used and from which DSO the definition came. At runtime this information can then be used to pick the right version from all the different versions of the same interface” (How To Write Shared Libraries §3.3).

The consequence he draws is the important one: “ABIs can normally be kept stable for as long as wanted … An API (not ABI) can also vanish completely: this is a way to deprecate APIs without affecting binary compatibility.” Systems without this mechanism have only one lever for an incompatible change — bump the library’s filename (SONAME) — which forces every consumer to be rebuilt simultaneously and produces the parallel-incompatible-copies problem colloquially called DLL hell. Linux has kept libc.so.6 as the SONAME since 1997 while changing the ABI of individual functions many times.

Three dynamic-section tags carry the machinery, and all three are visible on any Fedora binary:

TagSectionRole
DT_VERDEF.gnu.version_dDefinitions: the versions this object provides (GLIBC_2.2.5, GLIBC_2.34, …), with predecessor links forming a chain
DT_VERNEED.gnu.version_rRequirements: for each DT_NEEDED library, the exact version names this object needs from it
DT_VERSYM.gnu.versionA parallel array, one 16-bit index per .dynsym entry, binding each symbol to a version

/bin/ls on this machine carries VERNEED 0x1a3b0, VERNEEDNUM 2 and VERSYM 0x1a2a8 — two libraries with version requirements. The runtime check happens in _dl_check_all_versions before relocation begins: for each needed version the loader searches the providing object’s DT_VERDEF chain and, if the version is absent, produces the error everyone recognises, version 'GLIBC_2.34' not found (required by ./myprog). Because the check runs up front, a binary built against a newer glibc fails loudly and immediately on an older system rather than crashing later inside a subtly changed function. The same 16-bit DT_VERSYM index is what _dl_fixup consults during lazy binding (version = &l->l_versions[ndx]), so versioning applies to deferred PLT resolutions as well as to eager ones. The full mechanism, including how a project writes its own version script, is Versioned Symbols in glibc.

Thread-Local Storage: Four Access Models

Thread-local variables (__thread int x; / C11 _Thread_local) are a loader problem, because “the address of x” is not a single address — it is a different address in every thread, and for a library loaded by dlopen it may not exist yet at all. The ELF solution gives each module a TLS block template in its PT_TLS segment, and gives each thread a dynamic thread vector (dtv) indexed by module ID that points at that thread’s copy of each module’s block.

   thread pointer (%fs on x86-64)
            │
            ▼
 ┌──────────────────────┐
 │ TCB / pthread struct │──► dtv ──► ┌───────┬───────┬───────┬───────┐
 │  (thread descriptor) │            │ gen   │ dtv[1]│ dtv[2]│ dtv[3]│
 └──────────────────────┘            └───────┴───┬───┴───┬───┴───┬───┘
    ▲   (negative offsets on x86-64)             │       │       │
    │                                            ▼       ▼       ▼
 ┌──┴────────────┬─────────────┬──────────┐  module 1  module 2  module 3
 │ exe TLS block │ libA block  │ libB blk │  (the exe) (libA)    (dlopen'd,
 └───────────────┴─────────────┴──────────┘                       allocated
   contiguous "static TLS" — allocated                            lazily)
   at thread creation for every module
   present at program start

The per-thread TLS layout. Drawn as ASCII because it is a two-level pointer picture with a vertical address ordering that mermaid renders poorly. What it shows: modules loaded at program start get blocks in one contiguous region at a fixed offset from the thread pointer; modules arriving later via dlopen get separately allocated blocks reachable only through the dtv. The insight to take: the four access models below are entirely determined by two questions — is the module’s ID known at link time, and is its block guaranteed to be in the contiguous static region?

Drepper’s ELF TLS specification defines four models (ELF Handling For Thread-Local Storage v0.21, §4), and states the constraint that matters most to the loader up front: “All models have in common that the dynamic linker at startup-time or when a module gets loaded dynamically has to process all the relocations related to thread-local storage. Processing of none of these relocations can be deferred; just as any other relocation for variables (instead of function calls) they must be processed right away.” TLS is never lazy.

ModelCompiler flagApplicable whenRuntime costRelocations
General Dynamic-ftls-model=global-dynamic (default)Always — “code compiled with it can be used everywhere and it can access variables defined anywhere else”A call to __tls_get_addr per variable; allows the block to be allocated on first useR_X86_64_TLSGDDTPMOD64 + DTPOFF64 pair in the GOT
Local Dynamic-ftls-model=local-dynamicThe variable is defined in the same module that references it (file-scope, hidden, or protected)One __tls_get_addr call for the module’s block base, then plain offset arithmetic for every variable in itR_X86_64_TLSLD + DTPOFF32 per variable
Initial Exec-ftls-model=initial-execThe module is guaranteed present at program start (the executable or a DT_NEEDED dependency)One GOT load plus a %fs-relative access — no function callR_X86_64_GOTTPOFFTPOFF64 in the GOT
Local Exec-ftls-model=local-execThe variable is in the executable itself and accessed from the executableA single %fs-relative instruction; the offset is a link-time constantR_X86_64_TPOFF32, resolved by the static linker

The models form a strict optimisation ladder, and Drepper is blunt about the top of it: “The size of the code to implement this model and the time needed at run-time for relocation and in the code to compute the address makes it necessary to avoid this [general dynamic] model whenever possible.” The linker performs TLS relaxation — rewriting general-dynamic instruction sequences into initial-exec or local-exec ones when it can prove the stronger precondition holds — which is why a -fPIC shared library compiled with the default model can still end up with %fs-relative accesses in the final executable.

The operationally important consequence is the failure mode at the boundary. Initial-exec assumes the module’s TLS block sits in the contiguous static region, whose size is fixed when the first thread is created. A library compiled -ftls-model=initial-exec and then loaded with dlopen after threads already exist has no reserved space there, and glibc must either steal from a small surplus reservation or fail with cannot allocate memory in static TLS block. This is the single most common TLS deployment error, and it is why plugins are conventionally built with the default general-dynamic model despite its cost. The full data structures and the __tls_get_addr implementation live in Thread-Local Storage; the per-model instruction sequences and their relocations are in TLS Models and Access.

dlopen: the Same Machinery, Later

dlopen(3) is not a separate subsystem — it re-enters the exact code path described above, mid-flight, on a running process: _dl_map_object for the search, _dl_map_object_deps for the transitive closure, _dl_relocate_object for the fix-ups, and _dl_init for the constructors. What differs is scoping and lifetime, and those are what the flags control.

FlagEffect
RTLD_LAZYResolve function symbols on first use (only meaningful if the object was not linked -z now)
RTLD_NOWResolve everything before dlopen returns — the same eagerness as LD_BIND_NOW
RTLD_GLOBALAppend this object to the global scope, so its symbols become available to every subsequently loaded object and can interpose
RTLD_LOCAL(Default) The object’s symbols are visible only to it and to objects that name it — it gets its own local scope
RTLD_NODELETENever unmap on dlclose; destructors do not run at close
RTLD_NOLOADDo not load; return a handle only if already loaded — the standard way to test for presence
RTLD_DEEPBINDSearch the object’s own dependencies before the global scope, deliberately inverting interposition

RTLD_GLOBAL versus RTLD_LOCAL is the flag that surprises people, because it changes the symbol-resolution answers for objects loaded later. RTLD_DEEPBIND is the flag that surprises people most, because it deliberately breaks the interposition guarantee the rest of the system depends on: a plugin loaded RTLD_DEEPBIND will call its own bundled malloc rather than the process-wide one, which is occasionally what you want and frequently a source of allocator-mismatch crashes when a pointer allocated by one malloc is freed by the other.

Initialisation and finalisation order is where dlopen meets the ELF specification’s least-loved rule. _dl_init walks the main map’s search list backwards, and the glibc comment carries a full paragraph of editorial:

/* Stupid users forced the ELF specification to be changed.  It now
   says that the dynamic loader is responsible for determining the
   order in which the constructors have to run.  The constructors
   for all dependencies of an object must run before the constructor
   for the object itself.  Circular dependencies are left unspecified.
   ...  Stupidity rules!  */
 
i = main_map->l_searchlist.r_nlist;
while (i-- > 0)
  call_init (main_map->l_initfini[i], argc, argv, env);

(elf/dl-init.c, glibc 2.43)

Note the phrase “Circular dependencies are left unspecified” — that is not glibc hedging, it is the specification declining to define an answer, and it is why a constructor cycle between two libraries produces order that varies with link order and toolchain version. glibc’s only defence is the l_init_called guard a few lines above, which prevents infinite recursion but does not make the order meaningful.

Real output on this machine confirms both the ordering and its exact mirror at exit:

$ LD_DEBUG=libs ./t_lazy
  calling init: /lib64/ld-linux-x86-64.so.2
  calling init: /lib64/libc.so.6
  calling init: /lib64/libm.so.6
  initialize program: ./t_lazy
  transferring control: ./t_lazy
  calling fini:  [0]                     <- the program itself
  calling fini: /lib64/libm.so.6 [0]

ld.so first (it is at the tail of the search list), then libc before libm — because libm depends on libc, and a dependency’s constructor must precede its dependent’s. Destructors run in exactly the reverse order, driven by _dl_fini, whose address RTLD_START handed to the program in %rdx at startup. One consequence worth internalising: dlclose runs destructors in the middle of your program, and a destructor that touches state another still-loaded library owns is a well-known source of shutdown crashes. RTLD_NODELETE — and, in practice, glibc’s habit of refusing to unload objects with TLS or with unique symbols — exists partly to blunt this. The full API surface, including dlsym’s RTLD_NEXT/RTLD_DEFAULT pseudo-handles and dlmopen’s separate namespaces, is dlopen and Runtime Loading.

Prelinking: Rise and Abandonment

If relocation is the dominant startup cost, the obvious optimisation is to do it once, offline, and cache the answer. That was prelink, written by Jakub Jelinek at Red Hat: it assigned each shared library a fixed, non-conflicting load address system-wide, pre-computed all the relocations against those addresses, and stored the results in extra ELF sections. At runtime ld.so would check whether the assumptions still held — libraries at their assigned addresses, no dependency changed since prelinking — and if so skip relocation almost entirely.

timeline
    title Prelinking on Red Hat / Fedora
    2003 : Jelinek's prelink paper published (cited by Drepper as reference 7)
    2006 : GNU-style hash tables land in glibc and binutils
         : the symbol-lookup cost prelink was mostly avoiding drops sharply
    2009 : a prelink bug in Fedora Rawhide leaves systems unbootable
         : LWN asks in public whether pre-linking is worth it
    2015 : the Fedora prelink package is retired
    2016 : PIE-by-default and full ASLR become the norm
         : prelink's fixed addresses now conflict with the hardening baseline
    2022 : glibc 2.36 adds DT_RELR
         : relative relocations get compressed rather than precomputed

The rise and fall of prelinking. What it shows: prelink was not defeated by a single decision but squeezed from both sides — the benefit shrank as symbol lookup got cheaper, while the cost rose as address-space randomisation became mandatory. The insight to take: an optimisation that depends on predictable addresses is on a collision course with a security model built on unpredictable addresses, and the security model won.

The measured benefit was real but narrow. Jelinek posted results for OpenOffice.org Writer “showing an order of magnitude difference in the amount of time spent doing relocations between pre-linked and regular binaries”, and Drepper defended the default on both relocation time and a memory argument — “memory pages that do not require changes for relocations will not be copied (due to copy-on-write) and can thus be shared between multiple processes running the same executable” — while conceding that “the relatively new symbol table hashing feature … reduces the gain for pre-linking” (LWN, Is pre-linking worth it?, Jake Edge, 15 July 2009).

The objections were structural, not performance-based. Matthew Miller’s, in the same article: “I see [prelink] as adding unnecessary complexity and fragility, and it makes forensic verification difficult. Binaries can’t be verified without being modified, which is far from ideal.” That is the killer for any system doing package integrity checking — prelinking rewrites installed files in place, so rpm -V and every file-hash-based intrusion-detection scheme sees the whole system as modified. And the ASLR conflict was direct: on Fedora and RHEL, “prelink is run every two weeks with a parameter to request random addresses to alleviate this problem, but they do stay fixed over that time period” — a fortnight of predictable library addresses is not meaningful randomisation. Jelinek’s own concession is the epitaph: “security-sensitive programs should be position-independent executables (PIE) that are not pre-linked, and thus have ASLR done for every execution.” Once PIE became the default for everything rather than for a few hardened daemons, prelinking had no remaining constituency.

The successor idea is DT_RELR, and the contrast is instructive: prelink tried to eliminate relocations by fixing addresses; DT_RELR accepts that addresses vary and instead compresses the relocation list, turning runs of consecutive relative relocations into bitmaps. It gets a large fraction of the file-size and startup benefit with none of the address-predictability cost, and it composes with ASLR rather than fighting it.

Uncertain

Verify: the dates on the timeline above other than the two that are directly sourced (the 2009 LWN debate, and glibc 2.36’s DT_RELR from the glibc NEWS file) — in particular the exact date prelink was retired from Fedora (a search result attributed it to 2015-07-25, for having failed to build for two releases) and the precise Fedora release in which it disappeared. Reason: directly checkable facts are that dnf list --available prelink returns “No matching packages to list” on Fedora 44 as of 2026-09-04, and that the LWN debate is from 2009 — but the retirement commit itself could not be read, because src.fedoraproject.org is behind an Anubis proof-of-work challenge: curl with a browser User-Agent returned HTTP 200 with a 4,475-byte body titled “Making sure you’re not a bot!”, and the /api/0/ JSON endpoint returned the same challenge. This is the same block now known to affect lore.kernel.org and git.kernel.org. To resolve: read the rpms/prelink dead.package commit through a route that clears the challenge, or check the Fedora release notes for the release that dropped it. uncertain

Inspecting the Dynamic Linker

The dynamic linker is observable from the command line, which is the fastest way to build intuition. ld.so honors two environment variables that turn the loader into a debugging instrument.

# Set PT_INTERP-driven loading and print every resolution step:
$ LD_DEBUG=libs,reloc,bindings ./myprog

LD_DEBUG “output[s] verbose debugging information about operation of the dynamic linker” and accepts a colon/comma/space-separated set of categories. Per ld.so(8) the categories are exactly: help, all, bindings, files, libs, reloc, scopes, statistics, symbols, unused, versions. LD_DEBUG=libs shows the library search and the order objects are loaded; reloc shows relocation processing; bindings shows each symbol as it binds to a definition; scopes dumps the search scopes; statistics prints relocation counts and timing — invaluable for understanding why a large program’s startup is dominated by relocation work.

# What does ldd actually do? It sets one env var and runs the program:
$ LD_TRACE_LOADED_OBJECTS=1 ./myprog
        linux-vdso.so.1 (0x00007fff...)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
        /lib64/ld-linux-x86-64.so.2 (0x00007f...)

LD_TRACE_LOADED_OBJECTS, “if set (to any value), causes the program to list its dynamic dependencies, as if run by ldd(1), instead of running normally” (ld.so(8)). This is what ldd does under the hood — and it is exactly why running ldd on an untrusted binary is dangerous: it can cause the loader to actually execute the program’s interpreter logic. The safe alternative, also documented, is to run the loader directly.

# ld.so is itself a runnable program — invoke it directly:
$ /lib64/ld-linux-x86-64.so.2 --list ./myprog        # like ldd, without trusting the binary
$ /lib64/ld-linux-x86-64.so.2 --verify ./myprog      # check it is a valid dynamic exe
$ /lib64/ld-linux-x86-64.so.2 ./myprog arg1 arg2     # run the program via an explicit loader

This last form is the conceptual payoff: the dynamic linker is a normal ELF program (/lib64/ld-linux-x86-64.so.2) that takes a program path and arguments and runs it — which is precisely what the kernel does implicitly when it honors PT_INTERP. The --list option “list[s] all dependencies and how they are resolved” and --verify checks the file is a compatible dynamic executable (ld.so(8)). Running a program through an explicitly chosen loader is how you test a binary against a different glibc than the system one (e.g. /path/to/new/ld-linux.so --library-path /path/to/new/lib ./myprog).

Common Misunderstandings

main runs first.” It does not. Constructors (__attribute__((constructor)), C++ static-object constructors, DT_INIT_ARRAY) all run during _dl_init, before the program’s entry point — and the program’s own _start/__libc_start_main run before main. A crash in a library constructor produces a backtrace with no main frame at all, which surprises people.

“The dynamic linker is part of libc.” No. ld.so is a separate ELF object with its own entry point, and it must function before libc exists in the address space — that is exactly why it self-relocates and cannot call into libc during bootstrap. glibc ships them together and they share some source, but the loader is logically prior to libc.

“Relocation is cheap.” For a program linking dozens of large C++ libraries, relocation processing can dominate startup. Each relocation is a symbol lookup plus a memory write; a binary with hundreds of thousands of relocations spends measurable milliseconds in _dl_relocate_object before main. LD_DEBUG=statistics quantifies it. This is the cost that lazy binding (deferring PLT relocations) and prelinking historically tried to claw back.

“Static binaries use ld.so too.” They do not — a fully static binary has no PT_INTERP, so the kernel never maps the dynamic linker. (A subtlety: glibc’s NSS and dlopen historically still pulled in dynamic-linking machinery even in “static” binaries, which is part of why fully static glibc binaries are discouraged; musl behaves more cleanly here — see glibc vs musl.)

“Lazy binding is the default, so my binaries use it.” On a modern distribution, almost certainly not. Every binary on the Fedora 44 machine used here is linked -z now; readelf -d /bin/ls shows FLAGS: BIND_NOW. Lazy binding is still the toolchain default for a bare gcc foo.c, but distribution build systems override it globally. If you are reasoning about startup cost or about LD_DEBUG=bindings output on a packaged binary, check the flag first.

ldd is a safe way to inspect an unknown binary.” ldd is a shell script that, in the common case, sets LD_TRACE_LOADED_OBJECTS=1 and runs the program — with the program’s own PT_INTERP as the loader. A hostile binary can name a hostile interpreter. Use /lib64/ld-linux-x86-64.so.2 --list ./prog or readelf -d ./prog | grep NEEDED instead.

DT_NEEDED names a file path.” It names a SONAME, a versioned identifier like libc.so.6, and the mapping from SONAME to path is precisely what the five-step search resolves. This is why moving a .so breaks nothing as long as ldconfig re-runs, and why two libraries with the same SONAME in different directories produce shadowing bugs.

RPATH and RUNPATH are the same thing spelled differently.” They sit on opposite sides of LD_LIBRARY_PATH in the search order and differ in whether they are inherited by transitive dependencies. See the comparison table above; this is the single most common source of “it works on my machine” library-resolution bugs.

Alternatives and When to Choose Them

The glibc ld.so is the default on most Linux distributions. The principal alternative is musl’s dynamic linker (/lib/ld-musl-x86_64.so.1), which is smaller, simpler, and used on Alpine and other minimalist or fully-static-leaning systems; it implements the same ELF interpreter contract but with a leaner feature set (no $ORIGIN-heavy RPATH gymnastics, simpler symbol versioning support). The comparison is glibc vs musl.

A different axis of choice is static vs dynamic linking (Static vs Dynamic Linking): a statically linked program sidesteps ld.so entirely, trading the memory savings of shared text and the ability to ship security updates as a single .so swap for a self-contained binary with predictable startup and no runtime dependency resolution. This is why container and Go (Static and Dynamic Linking in Go) ecosystems favor static binaries.

The deeper contrast is with the static linker (The Go Linker, or the system ld/lld/gold): it does the same conceptual job — resolve symbols, compute addresses, emit fix-ups — but at build time, with all inputs known, producing relocation entries that the runtime linker later applies. The static linker decides “this call refers to symbol printf in libc.so.6” and records a relocation; ld.so decides “and libc.so.6 is mapped at 0x7f…, so printf is at this exact address” and writes it in. The division of labor is the heart of dynamic linking: defer the address-binding decisions that cannot be made until load time.

Production Notes

The dynamic linker is a frequent source of operational pain, almost always around finding the right library version. The classic symptom — error while loading shared libraries: libfoo.so.2: cannot open shared object file — means ld.so walked the entire search path and found no matching SONAME; the fix is in Library Search Path and ldconfig (add the directory to /etc/ld.so.conf.d/ and rerun ldconfig, or set LD_LIBRARY_PATH for a one-off). The subtler symptom — version 'GLIBC_2.34' not found — means the loader found the library but it does not export the versioned symbol the binary needs (Versioned Symbols in glibc); this is the wall people hit shipping a binary built on a new distro to an older one.

LD_DEBUG=all is the universal debugging tool for “why did it load that one?” — it prints, in order, every directory searched and every candidate rejected, making library-shadowing bugs (two copies of a .so on the path, the wrong one winning) immediately visible. For security-sensitive deployments, note that the loader deliberately ignores LD_LIBRARY_PATH, LD_PRELOAD, and friends in secure-execution mode (set-user-ID/set-group-ID programs and those with capabilities), per ld.so(8) — otherwise an attacker could inject a malicious library into a privileged process via the environment. This is also why LD_PRELOAD-based instrumentation silently “doesn’t work” on sudo and other setuid binaries.

Resolved (2026-09-04)

The x86-64 RTLD_START register usage and the _dl_start call chain, previously flagged as uncertain because they had been read from the moving master branch, are now confirmed against the pinned glibc-2.43 tag — the exact version installed on the machine these examples were run on. RTLD_START reads, verbatim: _dl_start_user:# Save the user entry point address in %r12. / mov %RAX_LP, %R12_LPcall _dl_init# Pass our finalizer function to the user in %rdx, as per ELF ABI. / lea _dl_fini(%rip), %RDX_LP# Jump to the user's entry point. / jmp *%r12 (sysdeps/x86_64/dl-machine.h, glibc 2.43). The chain is likewise confirmed: _dl_start ends with return _dl_start_final (arg);, and _dl_start_final contains start_addr = _dl_sysdep_start (arg, &dl_main); (elf/rtld.c, glibc 2.43). The architecture caveat stands: aarch64, s390x and riscv64 define their own RTLD_START with different registers, though the same six steps.

A practical decision table for the flags that matter most, since they interact:

GoalFlagsWhat you give up
Maximum hardening (distribution default)-z relro -z now -pie -Wl,-z,pack-relative-relocsAll PLT symbols resolved at startup; a large plugin host pays for symbols it may never call
Fastest startup for a huge C++ application-z relro only (partial RELRO, lazy PLT)A writable GOT for the process lifetime — a known exploit primitive
Fail loudly on a missing symbolLD_BIND_NOW=1 at run time, or -z now at link timeNothing, for a diagnostic run; this is the right first move when a dlopen plugin dies deep in a call
Relocatable self-contained bundle-Wl,-rpath,'$ORIGIN/../lib' on every object, not just the executableNothing, provided you remember RUNPATH is not inherited
Guarantee no system library is picked up-z nodefaultlib (sets DF_1_NODEFLIB)Steps 4 and 5 of the search; you must supply every dependency yourself

Finally, a note on introspection from inside the process rather than from the shell. glibc exposes dl_iterate_phdr(3) to walk the link map programmatically — this is how unwinders find .eh_frame and how profilers map addresses to modules — and dlinfo(3) to query a loaded object’s RTLD_DI_LINKMAP, RTLD_DI_ORIGIN, and search paths. Since glibc 2.35 there is also _dl_find_object, an async-signal-safe lookup designed so that a profiler interrupting arbitrary code can identify which object an instruction pointer belongs to without taking the loader lock (glibc manual, Dynamic Linker Introspection). The _r_debug rendezvous structure, whose address ld.so publishes through DT_DEBUG (visible in the /bin/ls dynamic section dump above as DEBUG 0x0, filled in at runtime), is the same mechanism from the outside: a debugger reads it to enumerate the link map and sets a breakpoint on _dl_debug_state to be notified whenever an object is loaded or unloaded.

See Also