Lazy binding is the dynamic linker’s optimization of deferring the resolution of a called function’s address until the very first time that function is actually called, rather than resolving every imported symbol at program startup. It works through a two-table indirection: a call to an external function jumps to a small stub in the Procedure Linkage Table (PLT), which on the first call traps into the dynamic linker’s resolver (_dl_runtime_resolve → _dl_fixup), looks the symbol up, writes the resolved address into the function’s Global Offset Table (GOT) slot, and tail-calls the target; every subsequent call reads the now-patched GOT slot and jumps straight to the function, paying only one indirect jump. The motivation is startup latency: a large program may import thousands of functions and call only a fraction of them, so resolving on demand avoids paying for symbols that are never used (man ld.so(8)). The cost is a writable GOT and a small first-call penalty — tradeoffs that hardening (full RELRO) and -z now deliberately reverse.
Three sibling notes describe the same first call from different angles, and this one deliberately does not repeat them. The Procedure Linkage Table owns the code — the stub formats, the CET-era .plt.sec split, the resolver trampoline’s register-save problem, -z mark-plt. The Global Offset Table owns the table — which section a slot lives in, the reserved entries, page permissions, the GOT-overwrite attack class. Relocation Processing owns the relocation pass as a whole.
This note owns the policy and the timing: who decides that binding is deferred, what the complete set of inputs to that decision is and how they compose, what deferral actually costs and saves measured on real builds, when the error for a broken symbol surfaces and why that is the property people actually care about, what is not deferred even though it lives in the same relocation section, and what happens when two threads race into the same unresolved slot. Where a mechanism belongs to a sibling, the sibling is linked rather than paraphrased.
The Reference Build — Everything Below Is Measured
Every number, byte dump and program transcript below was produced on one host on 2026-09-11:
Component
Version
Distribution
Fedora 44
Kernel
7.1.13-200.fc44.x86_64
glibc / ld.so
2.43
GCC
16.2.1 20260819 (Red Hat 16.2.1-2)
GNU ld (bfd)
2.46.1-1.fc44
Specification
System V gABI, chapter 5 “Dynamic Linking”; AMD64 psABI version 1.0, 12 March 2025
The test program is deliberately minimal — a shared library exporting three functions, and a main program that calls exactly one of them twice and never calls the other two:
/* lib.c — built as libdemo.so */#include <stdio.h>void alpha(void) { puts("alpha"); }void beta (void) { puts("beta"); }void gamma_(void){ puts("gamma"); }
/* main.c */#include <stdio.h>void alpha(void); void beta(void); void gamma_(void);int main(int argc, char **argv) { printf("--- before any call ---\n"); alpha(); /* first call: resolves */ alpha(); /* second call: cached */ if (argc > 99) { beta(); gamma_(); } /* never called */ return 0;}
The argc > 99 guard is there so that beta and gamma_ are genuinely referenced — the linker must emit relocations for them — while never being executed. That gap between “referenced” and “executed” is the entire economic case for lazy binding, and it is what the measurements below quantify.
The brief that commissioned this note was wrong about the toolchain, and the correction matters
A widely repeated claim is that on Fedora you cannot observe lazy binding at all without passing -Wl,-z,lazy, because the redhat-hardened-ld spec file forces -z now. Measured on this host, that is false in two separate ways. First, /usr/lib/rpm/redhat/redhat-hardened-ld contains exactly one line of spec — + %{!static:%{!shared:%{!r:-pie}}} — it supplies -pie, and says nothing about binding mode. -z now comes from a different place: the RPM macro %{build_ldflags}, which expands (checked with rpm --eval) to -Wl,-z,relro -Wl,--as-needed -Wl,-z,pack-relative-relocs -Wl,-z,now -specs=… — an explicit -Wl,-z,now in the package build flags. Second, neither the spec file nor those flags reach a plain gcc invocation: gcc -dumpspecs contains no reference to redhat-hardened-*, and no installed specs file exists under /usr/lib/gcc/x86_64-redhat-linux/. The consequence is directly observable:
$ gcc -O0 -o app_default main.c -L. -ldemo -Wl,-rpath,'$ORIGIN'$ readelf -h app_default | grep Type Type: EXEC (Executable file) <- not even PIE$ readelf -d app_default | grep -E 'FLAGS|BIND' (no output — no DT_FLAGS, no BIND_NOW, no DF_1_NOW)$ readelf -h /usr/bin/ls | grep Type # an actual Fedora package Type: DYN (Position-Independent Executable file)$ readelf -d /usr/bin/ls | grep -E 'FLAGS|BIND' 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
So: a hand-compiled binary on Fedora 44 is a non-PIE EXEC that binds lazily by default; a Fedora-packaged binary is a PIE with full RELRO and BIND_NOW. The hardening is a property of the packaging, not of the compiler. The same measurement independently refutes the claim (repeated in MOCs/Linux Userspace Runtime MOC.md) that this GCC is built --enable-default-pie: gcc -v lists no such flag, and the default output type is EXEC. This note therefore does not pass -Wl,-z,lazy to observe lazy binding — plain gcc suffices — and uses -Wl,-z,now only to build the eager comparison binary. Note also that -z must be handed to the linker through -Wl,-z,now; a bare gcc -z now is a compiler-driver argument and is silently useless for this purpose on some driver versions.
Mental Model
Picture each imported function as having a “mailbox” — its GOT slot. Initially the mailbox does not contain the function’s address; it contains a note that says “ask the dynamic linker.” The PLT stub is the little machine that reads the mailbox and jumps to whatever address is inside. On the first call the address inside is the linker’s resolver, so the first call detours through resolution; the resolver’s side effect is to replace the note in the mailbox with the real address. Every later call reads the mailbox, finds the real address, and goes directly. Lazy binding is thus a self-modifying dispatch table that “warms up” one entry per first-call.
sequenceDiagram
participant App as caller
participant PLT as foo@plt stub
participant GOT as GOT slot (.got.plt)
participant Res as ld.so resolver
Note over GOT: first call — slot points<br/>back into the PLT stub
App->>PLT: call foo@plt
PLT->>GOT: jmp *GOT[foo]
GOT-->>PLT: (address is next insn) fall through
PLT->>PLT: push reloc index
PLT->>Res: jmp PLT[0] -> _dl_runtime_resolve
Res->>Res: _dl_fixup: look up "foo", find address
Res->>GOT: write foo's real address into slot
Res->>App: tail-call foo (this call)
Note over GOT: slot now holds foo's address
App->>PLT: call foo@plt (later)
PLT->>GOT: jmp *GOT[foo]
GOT-->>App: jump straight to foo
What it shows: the first call falls through the stub into the resolver, which patches the GOT slot and jumps to the target; subsequent calls find the patched slot and jump directly. The insight to take: the “laziness” is entirely encoded in the initial contents of the GOT slot — pointing it back into the stub on the first call is what forces the one-time detour, and patching it is what makes every later call cheap.
Mechanical Walk-through
The stub. On x86-64 a lazy PLT stub is three instructions (MaskRay PLT):
foo@plt: jmpq *foo@GOTPLT(%rip) ; jump to whatever the GOT slot holds pushq $index ; (reached only on 1st call) push this reloc's index jmp .plt ; jump to the PLT header
Crucially, before resolution the GOT slot foo@GOTPLT is initialized not to the function but to the address of the pushq instruction — the second instruction of its own stub. So the first jmpq *slot “falls through” to pushq, which pushes the relocation index (identifying which symbol), then jumps to the PLT header.
The header and the reserved GOT slots. The PLT header (.plt[0]) bootstraps the resolver (MaskRay PLT):
.plt: pushq foo@GOTPLT+8(%rip) ; push GOT[1] = link_map descriptor for this object jmpq *foo@GOTPLT+16(%rip); jump to GOT[2] = _dl_runtime_resolve nop
This uses the two reserved entries of .got.plt. As Drepper describes, “the dynamic linker has to make sure that the third GOT slot … contains the address of the entry point in the dynamic linker” (Drepper §1.5.5) — the third slot being GOT[2] (Drepper’s paper numbers it “offset 8” for IA-32’s 4-byte slots; on x86-64 with 8-byte slots the same slot is at byte offset +16, as in the stub above). MaskRay states it as “ld.so puts a descriptor into .got.plt[1] and the address of the lazy PLT resolver into .got.plt[2]” (MaskRay GOT). (GOT[0] holds the link-time address of _DYNAMIC.) So the header hands the resolver two things: which object is asking (the descriptor in GOT[1]) and which symbol (the index pushed by the stub).
The resolver.GOT[2] points at an assembly trampoline, _dl_runtime_resolve, which saves the argument registers (the resolved function will be called with the original arguments), then “invokes elf/dl-runtime.c:_dl_fixup to resolve the .got.plt entry and then perform a tail call to the resolved address” (MaskRay PLT). Inside _dl_fixup (see The Dynamic Linker for the surrounding loader), per glibc’s elf/dl-runtime.c: it locates the PLT relocation from DT_JMPREL plus the pushed offset, asserts it is a JUMP_SLOT (assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT)), calls _dl_lookup_symbol_x to find the symbol in the lookup scope, builds the target with DL_FIXUP_MAKE_VALUE, handles STT_GNU_IFUNC via elf_ifunc_invoke (see GNU IFUNC and Indirect Functions), and finally calls elf_machine_fixup_plt “to update the GOT entry and returns the resolved address.”
The patch and the tail call.elf_machine_fixup_plt writes the resolved address into foo@GOTPLT. Then _dl_runtime_resolve restores the saved argument registers and jumps to the resolved function so the original call still completes normally. From then on the GOT slot holds the real address, so “all future uses of the PLT entry will not go through the dynamic linker, but will instead directly transfer to the function. The overhead for all but the first call is therefore ‘only’ one indirect jump” (Drepper §1.5.5).
The relocation type. Each lazily-bound function has an R_*_JUMP_SLOT relocation (on x86-64, R_X86_64_JUMP_SLOT, value 7, calculation S — just the symbol’s address) sitting in .rela.plt, and the corresponding .got.plt slot (Hubička psABI mirror). Under lazy binding these are not applied at startup — they are applied one at a time by _dl_fixup.
Who actually initialises the slot — a correction worth making explicit. It is tempting to assume ld.so writes the “point back at the stub” value into each GOT slot at load time. It does not. The static linker writes it into the file, and the dynamic linker’s entire lazy-mode work per slot is to add the load bias. glibc’s x86-64 elf_machine_runtime_setup says so in a comment and then touches only the two reserved words (glibc 2.43 sysdeps/x86_64/dl-machine.h):
if (l->l_info[DT_JMPREL] && lazy) { /* 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]. */ got = (Elf64_Addr *) D_PTR (l, l_info[DT_PLTGOT]); ... /* Identify this shared object. */ *(ElfW(Addr) *) (got + 1) = (ElfW(Addr)) l; /* GOT[1] = this link_map */ ... *(ElfW(Addr) *) (got + 2) = (ElfW(Addr)) GLRO(dl_x86_64_runtime_resolve); /* GOT[2] = trampoline */ }
and the per-slot lazy “relocation” is one line in elf_machine_lazy_rel:
if (__glibc_likely (r_type == R_X86_64_JUMP_SLOT)) { /* Prelink has been deprecated. */ if (__glibc_likely (map->l_mach.plt == 0)) *reloc_addr += l_addr; /* <- the whole of lazy binding, per slot */
*reloc_addr += l_addr — read the value the static linker put there, add the load bias, store it back. That is the complete load-time cost of one lazily-bound function.
Watching One GOT Slot Warm Up
The claim above is checkable to the byte. First, what the static linker wrote into .got.plt in the file (app_default is the non-PIE build, so readelf -x shows the final runtime values directly):
GOT[0] — the link-time address of _DYNAMIC (and readelf -S confirms .dynamic is at 0x402de8)
0x402ff0
00 …
0
GOT[1] — zero in the file; ld.so overwrites it with this object’s link_map
0x402ff8
00 …
0
GOT[2] — zero in the file; ld.so overwrites it with the resolver trampoline
0x403000
76 03 40 00
0x00400376
puts slot → puts@plt + 6, i.e. its own push $0x0
0x403008
86 03 40 00
0x00400386
alpha slot → alpha@plt + 6, i.e. its own push $0x1
0x403010
96 03 40 00
0x00400396
beta slot → beta@plt + 6
0x403018
a6 03 40 00
0x004003a6
gamma_ slot → gamma_@plt + 6
The whole trick, visible as four bytes: the slot a jmp * reads points six bytes further into the very stub that is doing the reading. The jmp “falls through” to the next instruction the long way round, through memory.
Now the same slots at run time, under gdb, with breakpoints immediately before the first alpha() call, between the two calls, and after both:
Breakpoint 1, 0x00000000004004af in main () # about to make the FIRST alpha() callAT 1st alpha() call: GOT[alpha]=0x400386 GOT[beta]=0x400396 GOT[puts]=0x7ffff7dfabc0 GOT[1]=0x7ffff7ffe2e0 GOT[2]=0x7ffff7fd7760(gdb) info symbol *(long*)0x402ff8_dl_runtime_resolve_xsavec in section .text of /lib64/ld-linux-x86-64.so.2Breakpoint 2, 0x00000000004004b4 in main () # between the two alpha() callsAFTER 1st alpha(): GOT[alpha]=0x7ffff7fb6399(gdb) info symbol *(long*)0x403008alpha in section .text of .../libdemo.soBreakpoint 3, 0x00000000004004c9 in main () # after both alpha() callsAFTER 2nd alpha(): GOT[alpha]=0x7ffff7fb6399 GOT[beta]=0x400396 (never called)(gdb) info symbol *(long*)0x403010beta@plt + 6 in section .plt of .../app_default
Four separate facts fall out of that transcript, and each of them is the note in miniature:
GOT[alpha] before the first call is 0x400386, byte-identical to the file. This is a non-PIE EXEC, so the load bias l_addr is zero and *reloc_addr += l_addr is a no-op — the file value is the runtime value.
GOT[2] resolves to _dl_runtime_resolve_xsavec, not a generic _dl_runtime_resolve. glibc picks a trampoline variant at load time according to the CPU’s register-save capability, because the trampoline must preserve every register the ABI lets an argument travel in; see The Procedure Linkage Table for why that is expensive.
GOT[puts] is already resolved before main runs a single line of user code — 0x7ffff7dfabc0, an address inside libc — while GOT[alpha] is not. GCC turned the printf with no format directives into a puts call, that call happened first, and its slot warmed up. Laziness is per slot, not per object: the same table holds cold and hot entries side by side at any instant.
GOT[beta] is still beta@plt + 6 when the program exits.beta was linked in, relocated, and never resolved. gdb prints the symbolic form itself, which is about as direct a confirmation as one can ask for.
The PIE build makes the arithmetic visible instead of degenerate. Linked as a DYN, the file records the link-time address:
and under gdb with ASLR disabled, where the bias came out as 0x555555554000:
runtime GOT[alpha] = 0x555555554386(gdb) info symbol *(long*)(0x555555554000+0x3008)alpha@plt + 6 in section .plt of .../app_piefile-recorded value = 0x386 ; load bias = 0x555555554000 ; sum = 0x555555554386
0x386 + 0x555555554000 = 0x555555554386. That single addition is elf_machine_lazy_rel, executed once per .rela.plt entry at load time, and it is the reason the next section insists that lazy binding is cheap but not free.
The Policy Lattice: Everything That Decides Lazy vs Eager
“Is this object bound lazily?” has more inputs than the usual two-item answer (LD_BIND_NOW, -z now) suggests, they are read at different times by different code, and two of them push in the unexpected direction. The gABI supplies the ground rule and it is permissive, not prescriptive: “The dynamic linker is permitted to evaluate procedure linkage table entries lazily, thus avoiding symbol resolution and relocation overhead for functions that are not called” (gABI ch.5, emphasis on permitted). Lazy binding is an implementation licence, never a guarantee; a conforming ld.so may bind everything eagerly and nothing observable breaks except startup time. DT_JMPREL exists precisely to make the licence exercisable — separating the PLT relocations into their own table “lets the dynamic linker ignore them during process initialization, if lazy binding is enabled” (ibid.).
In glibc 2.43 the decision is assembled in three stages.
Stage 1 — a process-wide default, from the environment.elf/rtld.c initialises GLRO(dl_lazy) to 1 and then lets LD_BIND_NOW clear it. The parse is a two-character test that explains the “non-empty string” semantics exactly:
if (memcmp (envline, "BIND_NOW", 8) == 0) GLRO(dl_lazy) = envline[9] == '\0'; /* lazy only if the value is EMPTY */ if (memcmp (envline, "BIND_NOT", 8) == 0) GLRO(dl_bind_not) = envline[9] != '\0';
envline is BIND_NOW=…, so envline[9] is the first character of the value. LD_BIND_NOW= (empty) leaves lazy binding on; LD_BIND_NOW=0, LD_BIND_NOW=off and LD_BIND_NOW=anything all turn it off — including the literal string off, which the gABI itself lists as an example of a value that enables eager binding (gABI ch.5). The variable is a presence test, not a boolean.
Stage 2 — a per-object override, from the object’s own dynamic section.elf/get-dynamic-info.h folds the modern flag words down onto the legacy tag, so that everything downstream only has to test one pointer:
This is why readelf -d on a -z now binary shows both(FLAGS) BIND_NOW and (FLAGS_1) Flags: NOW: ld emits the pair for maximum loader compatibility, and glibc collapses either one onto l_info[DT_BIND_NOW]. The gABI is explicit that this per-object flag beats the process-wide default in the eager direction only — its “presence … takes precedence over a directive to use lazy binding for this object when specified through the environment or via dlopen”. There is no DF_1_LAZY: an object can demand eager binding, but it can never demand lazy binding, so LD_BIND_NOW=1 is a global one-way ratchet.
Stage 3 — the actual test, in elf/dl-reloc.c._dl_relocate_object_no_relro starts from the caller’s mode and applies the per-object override:
int lazy = reloc_mode & RTLD_LAZY; ... /* If DT_BIND_NOW is set relocate all references in this object. We do not do this if we are profiling, of course. */ if (!consider_profiling && __builtin_expect (l->l_info[DT_BIND_NOW] != NULL, 0)) lazy = 0;
Note the guard: !consider_profiling. LD_PROFILE overrides -z now and forces lazy binding back on, and rtld.c sets the global default to match — GLRO(dl_lazy) |= consider_profiling; with the comment “If we are profiling we also must do lazy reloaction” (sic). Profiling works by leaving GOT[2] pointing at _dl_runtime_profile_* forever and never writing the resolved address into the slot, so that every call keeps trapping into the linker; that is structurally incompatible with having resolved the slot up front. The same section of elf_machine_runtime_setup spells it out: “In this case we don’t store the address in the GOT so that all future calls also end in this function.”
In the other direction, LD_DEBUG=unused silently forces eager binding for the whole process:
if (GLRO(dl_debug_mask) & DL_DEBUG_UNUSED) { /* In order to get an accurate picture of whether a particular DT_NEEDED entry is actually used we have to process both the PLT and non-PLT relocation entries. */ GLRO(dl_lazy) = 0; }
which is correct — you cannot report unused dependencies without resolving every symbol — but it means LD_DEBUG=unused measures a different program from the one that normally runs, and its startup cost is not comparable to a plain run.
flowchart TD
S["ld.so startup<br/>GLRO(dl_lazy) = 1"] --> E{"LD_BIND_NOW<br/>set to a non-empty value?"}
E -- yes --> E1["GLRO(dl_lazy) = 0"]
E -- no --> E2["GLRO(dl_lazy) stays 1"]
E1 --> U{"LD_DEBUG=unused?"}
E2 --> U
U -- yes --> U1["GLRO(dl_lazy) = 0<br/>(must resolve all to find unused DT_NEEDED)"]
U -- no --> U2[" "]
U1 --> P{"LD_PROFILE set?"}
U2 --> P
P -- yes --> P1["GLRO(dl_lazy) |= 1<br/>profiling REQUIRES lazy"]
P -- no --> P2[" "]
P1 --> O["per object:<br/>_dl_relocate_object(l, ..., dl_lazy ? RTLD_LAZY : 0)"]
P2 --> O
O --> B{"l_info[DT_BIND_NOW] != NULL ?<br/>(DT_BIND_NOW, DF_BIND_NOW or DF_1_NOW)"}
B -- "yes, and not profiling" --> B1["lazy = 0 for THIS object"]
B -- no --> B2["lazy keeps the process default"]
B1 --> R["ELF_DYNAMIC_RELOCATE(l, scope, lazy, ...)"]
B2 --> R
R --> D{"lazy?"}
D -- "0" --> DN["elf_machine_rela on every .rela.plt entry:<br/>full symbol lookup, GOT slot = final address"]
D -- "1" --> DL["elf_machine_lazy_rel on every .rela.plt entry:<br/>*slot += load_bias, nothing looked up"]
style P1 fill:#ffe8cc
style U1 fill:#ffe8cc
What it shows: the complete decision path in glibc 2.43, from rtld.c through get-dynamic-info.h to dl-reloc.c, ending in the two mutually exclusive per-entry handlers. The two shaded boxes are the counter-intuitive ones — LD_DEBUG=unused forces eager binding, and LD_PROFILE forces lazy binding even on a binary linked -z now. The insight: the decision is per-object, not per-process, and every override moves in one direction only; nothing anywhere in the lattice can turn lazy binding back on for an object that carries DF_1_NOW, except profiling.
The knobs, side by side
Knob
Where it lives
Effect
Scope
Notes
(nothing)
—
lazy
process
glibc’s default: GLRO(dl_lazy) = 1 in rtld.c
LD_BIND_NOW=<non-empty>
environment
eager
process
Presence test, not a boolean — LD_BIND_NOW=offenables eager binding
LD_BIND_NOW=
environment
lazy
process
Empty value is the same as unset
-Wl,-z,now
link flag → DF_BIND_NOW + DF_1_NOW
eager
that object
Persistent; the form distributions actually use
-Wl,-z,lazy
link flag
lazy
that object
The ld default; emits no tag — it only cancels an earlier -z now on the same command line
DT_BIND_NOW
dynamic tag
eager
that object
gABI “level 2”, superseded by DF_BIND_NOW; glibc still honours it
RTLD_NOW / RTLD_LAZY
dlopen(3) flag
either
that object + its new deps
Mandatory argument to dlopen; RTLD_NOW is the dlopen spelling of eager
LD_PROFILE=<soname>
environment
lazy, forced
process
Overrides -z now; `GLRO(dl_lazy)
LD_DEBUG=unused
environment
eager, forced
process
Needs every symbol resolved to report unused DT_NEEDED
LD_BIND_NOT=<non-empty>
environment
lazy, never cached
process
Resolution still happens; the GOT write is skipped (see below)
AT_SECURE (setuid)
kernel auxv
lazy
process
All of LD_BIND_NOW, LD_BIND_NOT, LD_DEBUG, LD_PROFILE are stripped
The AT_SECURE row deserves its own sentence, because it is a security property and not merely a footnote. process_envvars_secure in rtld.c unsets every variable on the unsecure list and then, if any of them had already taken effect, refuses to continue:
A set-user-ID binary that somehow arrived with dl_lazy != 1 exits with status 5 rather than running. So none of the environment knobs in the table above work on privileged binaries — if you need eager binding there, it must be linked in with -z now.
Observing It: LD_DEBUG=bindings and the One Store That Is the Cache
glibc’s ld.so exposes the binding decision directly through LD_DEBUG (ld.so(8)). Three categories matter here: bindings — “Display information about which definition each symbol is bound to”; reloc — “Display relocation processing”; and statistics, which prints the startup cost breakdown used in the next section.
The simplest possible experiment settles what “lazy” means in one line of output each. Both binaries below are the same source, differing only in -Wl,-z,now:
Three symbols are imported from libdemo.so; one is called. Lazy binding resolves 1, eager binding resolves 3. The two that eager binding resolved — beta and gamma_ — are work the lazy program never does, and in a real binary with several thousand imports that ratio is the entire argument.
The timing is equally visible, provided you defeat stdio buffering so that the program’s own stdout and ld.so’s stderr interleave truthfully:
$ LD_DEBUG=bindings stdbuf -o0 ./app_default 2>&1 | grep -E "alpha|before any call"--- before any call --- 1293119: binding file ./app_default [0] to .../libdemo.so [0]: normal symbol `alpha'alphaalpha
The binding message lands after the program has already printed its own first line and before the first alpha output — i.e. in the middle of main, exactly at the first call. Under -z now the same message appears among the startup noise, before any program output at all. (Without stdbuf -o0 the program’s stdout is block-buffered when redirected and flushes at exit, which makes the ordering look reversed; this is a very easy way to fool yourself with LD_DEBUG and worth remembering.)
LD_BIND_NOT: deleting the cache without deleting the resolution
LD_BIND_NOT is the cleanest possible experiment on the claim that the GOT write is the cache. Per ld.so(8), if set to a non-empty string it causes the linker to “not update the GOT and PLT after resolving a function symbol”. In glibc 2.43 that is two lines at the bottom of _dl_fixup (elf/dl-runtime.c):
/* Finally, fix up the plt itself. */ if (__glibc_unlikely (GLRO(dl_bind_not))) return value; return elf_machine_fixup_plt (l, result, refsym, sym, reloc, rel_addr, value);
and on x86-64 elf_machine_fixup_plt is, in its entirety:
One eight-byte store. Nothing else. LD_BIND_NOT skips that store while performing every other step of resolution, so the observable consequence is that resolution repeats on every call — which is exactly what happens, given a program that calls alpha twice:
Two calls, two resolutions. Add a third call and the count goes to three. The entire memoisation in lazy binding is *reloc_addr = value — there is no side table, no hash, no bookkeeping; the cache and the dispatch table are the same eight bytes, which is simultaneously why lazy binding is so cheap after the first call and why the GOT has to be writable, the tension RELRO Relocation Read-Only resolves.
One more control confirms the mechanism rather than a coincidence:
Under -z now the slot was written at load time by the eager path (elf_machine_rela), which LD_BIND_NOT does not touch — it only guards the write inside _dl_fixup. So LD_BIND_NOT has no effect on an eagerly bound binary, which is a good sanity check that you are measuring the lazy path and not something else.
What It Actually Costs and Saves
Drepper’s How To Write Shared Libraries motivates the whole design with a LD_DEBUG=statistics run whose headline is that “relocation processing dominates the startup costs with more than 50%” (Drepper §1.7). That claim is now well over a decade old, so it is worth re-measuring rather than repeating. On this host, a large modern C++ program — gdb, 64 shared-object dependencies, 765 PLT relocations of its own, linked -z now as all Fedora packages are:
$ LD_DEBUG=statistics gdb --version >/dev/null runtime linker statistics: total startup time in dynamic loader: 2294115 cycles time needed for relocation: 1798695 cycles (78.4%) number of relocations: 17919 number of relocations from cache: 9593 number of relative relocations: 2012 time needed to load objects: 403845 cycles (17.6%)
Drepper’s 50% has become 78.4%, over 17,919 relocations. The shape of the problem he described has not changed; it has intensified, because dependency graphs grew faster than anything else in the picture.
To isolate lazy binding’s contribution specifically, the same program has to be built both ways. A synthetic library exporting 2,000 functions, with a main that references all of them but calls exactly one, gives the cleanest signal:
Measurement
-Wl,-z,lazy
-Wl,-z,now
Ratio
R_X86_64_JUMP_SLOT relocations in .rela.plt
2,000
2,000
1.00×
number of relocations reported at startup
99
2,099
21.2×
time needed for relocation
2,868 cycles (5.9%)
59,630 cycles (58.0%)
20.8×
total startup time in dynamic loader
48,213 cycles
102,645 cycles
2.13×
wall clock, median of 200 runs
422 µs
506 µs
1.20×
Read the last two rows together, because they are the honest answer. Inside ld.so, eager binding costs 2.1× more startup time in the pathological case of 2,000 unused imports. Measured against the whole process lifetime — fork, execve, page faults, libc initialisation, main, exit — the difference is 84 µs, about 42 ns per eagerly resolved symbol. That is real, it is what a shell loop or a build system running tens of thousands of short-lived processes actually pays, and it is also small enough that for any process living longer than a few milliseconds it rounds to zero. Both halves of that sentence are why the argument about lazy binding is not settled by benchmarks alone.
Uncertain
Verify: whether number of relocations under lazy binding is a like-for-like count. Reason: reading elf/do-rel.h at glibc 2.43 shows the lazy path calls elf_machine_lazy_rel in a loop over every.rela.plt entry, and that function does not increment GL(dl_num_relocations) — only the eager handler does. So the “99 vs 2,099” row above almost certainly under-reports the lazy run’s true work: 2,000 slots were still visited and 2,000 additions still performed, they are simply not counted. The cycle counts, which come from a timer around the whole relocation phase rather than from the counter, are not affected by this and remain comparable. To resolve: instrument elf_machine_lazy_rel in a locally built glibc, or count the loop iterations with a uprobe.
Lazy binding is not “no work at load time”
The uncertainty above points at the substantive fact. Under lazy binding ld.so still walks the complete .rela.plt array and touches every slot; what it skips is the expensive part — the symbol lookup, which for each symbol means hashing the name and walking the lookup scope object by object (see Symbol Resolution and Lookup Scope). Per-slot cost drops from “a scoped symbol lookup” to “one load, one add, one store”, which is roughly a 20× reduction in this measurement, not an infinite one.
Eager mode also gets a structural optimisation that lazy mode cannot have. elf/dynamic-link.h builds up to two relocation ranges per object, and when binding eagerly it will merge them:
if (!(do_lazy) && (ranges[0].start + ranges[0].size) == start) { /* Combine processing the sections. */ ranges[0].size += size; }
When .rela.plt happens to be laid out immediately after .rela.dyn, eager binding processes both as one contiguous sweep with one handler; lazy binding must keep them as two ranges because they need different handlers. A small thing, but it is a genuine reason the eager path is not simply “the lazy path plus lookups”.
What is never lazy, even though it lives in .rela.plt
“Lazy” is a property of one relocation type, not of a section. The x86-64 elf_machine_lazy_rel dispatches on r_type and only R_X86_64_JUMP_SLOT gets the deferred treatment:
Relocation type
Where it lives
Lazy?
Why
R_X86_64_JUMP_SLOT
.rela.plt
yes
The one deferred type: *slot += l_addr now, resolve on first call
R_X86_64_IRELATIVE
.rela.plt
no
The IFUNC resolver is invoked at load time (in a second pass after the JUMP_SLOTs, so resolvers can call already-set-up PLT entries). See GNU IFUNC and Indirect Functions
R_X86_64_TLSDESC
.rela.plt
no, in glibc
glibc’s comment: “Always initialize TLS descriptors completely at load time, in case static TLS is allocated for it that requires locking.” Note the psABI permits lazy TLSDESC via DT_TLSDESC_PLT/DT_TLSDESC_GOT; glibc declines. This matters on Fedora, whose GCC is configured --with-tls=gnu2, making TLSDESC the default TLS model
R_X86_64_GLOB_DAT
.rela.dyn
no
A data GOT slot; nothing traps on a data load, so there is no place to hang a resolver
R_X86_64_RELATIVE
.rela.dyn
no
Pure base + addend, no symbol involved
R_X86_64_COPY
.rela.dyn
no
Copies bytes into the executable’s .bss at load time. See Copy Relocations
The rule that falls out of this table is the one most people half-remember correctly: you can defer a function call, because a call goes through an instruction you can redirect; you cannot defer a data reference, because a load has no hook. That asymmetry is also what makes the failure-timing difference in the next section.
The race two threads run into the same cold slot
The GOT store is a plain, non-atomic *reloc_addr = value. Two threads calling the same not-yet-resolved function at the same moment will both enter _dl_fixup, both perform the full lookup, and both store — and glibc considers this acceptable because both stores write the same value, and an aligned 8-byte store on x86-64 cannot tear. The resolver protects the scope rather than the slot: _dl_fixup takes THREAD_GSCOPE_SET_FLAG() before the lookup and resets it after, which keeps a concurrent dlclose from freeing the scope out from under the search, not two resolvers from colliding.
glibc’s own comment is refreshingly candid that this is a tolerated data race rather than a proof, though it is written about the auditing path where the stored object is a whole struct reloc_result rather than a single word (elf/dl-runtime.c):
This does not fix all of the data races that occur when two or more threads read reloc_result->reloc_init with a value of zero and read and write to that reloc_result concurrently. The expectation is generally that while this is a data race it works because the threads write the same values. Until the data races are fixed there is a potential for problems to arise from these data races. The reloc result updates should happen in parallel but there should be an atomic RMW which does the final update to the real result entry (see bug 23790).
The practical consequence for ordinary programs is nil — duplicated work on a rare first call — but it is worth knowing before writing anything that reads a GOT slot from another thread, and it is a second, quieter reason -z now is easier to reason about: with eager binding there is no window in which a slot’s value depends on who got there first.
Failure Modes and Common Misunderstandings
The one that actually bites: when a missing symbol kills you
This is the property that decides binding mode for most real deployments, and it is worth seeing rather than describing. Take a library with a proper SONAME, and a newer build of it that has quietly dropped a function — the shape of every accidental ABI regression:
/* main2.c — linked against the GOOD libswap.so.1 */int main(void){ puts("stage 1: started"); alpha(); puts("stage 2: alpha ok, about to call the missing beta"); beta(); puts("stage 3: unreachable");}
Run the same binary against the newer library that no longer defines beta:
$ LD_LIBRARY_PATH=bad stdbuf -o0 ./swapapp # lazy (default)stage 1: startedalphastage 2: alpha ok, about to call the missing beta./swapapp: symbol lookup error: ./swapapp: undefined symbol: betaexit=127$ LD_LIBRARY_PATH=bad LD_BIND_NOW=1 stdbuf -o0 ./swapapp./swapapp: symbol lookup error: ./swapapp: undefined symbol: betaexit=127
Same message, same exit status, completely different blast radius. Under lazy binding the program ran three stages of real work — which in a real service means it accepted connections, opened files, started transactions — and then died mid-flight at an arbitrary point that may be hours or months after deployment, on whatever code path happens to reach beta first. Under eager binding it did not execute a single instruction of main. Linking the binary -Wl,-z,now instead of setting the environment variable produces the identical early failure and makes it permanent:
(If you try this yourself, use stdbuf -o0. With stdout redirected and block-buffered, the lazy run appears to print nothing before dying, because the buffered stages are lost when ld.so terminates the process without flushing — which makes the lazy failure look like an early one and inverts the entire lesson.)
sequenceDiagram
autonumber
participant L as ld.so
participant M as main()
participant W as real work
Note over L,W: LAZY — the same binary, the same broken library
L->>M: relocations done (slot += bias only), transfer control
M->>W: "stage 1: started"
M->>W: alpha() resolves and runs
M->>W: "stage 2: alpha ok, about to call the missing beta"
M-->>L: first call to beta@plt traps into _dl_fixup
L--xM: symbol lookup error: undefined symbol: beta
Note over L,W: EAGER — LD_BIND_NOW=1 or linked -z now
L->>L: resolve every JUMP_SLOT before transferring control
L--xM: symbol lookup error: undefined symbol: beta
Note over M,W: main() never runs — no stage executes
What it shows: the same executable and the same missing symbol, under the two binding modes, on the time axis. The messages are byte-identical — symbol lookup error: undefined symbol: beta — and the exit status is 127 in both cases; only the position of the failure differs. The insight: choosing a binding mode is mostly choosing where in the program’s life an ABI break is allowed to surface, and “before main” is a far cheaper place to discover it than “in whichever request first reaches that call”.
Property
Lazy binding
Eager binding (-z now / LD_BIND_NOW)
When JUMP_SLOT relocations are processed
*slot += bias at load; symbol looked up at first call
All looked up and stored before main
Startup cost
Proportional to called imports
Proportional to all imports
Steady-state call cost
One indirect jump through a hot GOT slot
Identical — one indirect jump through the same slot
A missing function symbol
Fails at the first call, arbitrarily deep into the run
Fails before main, deterministically
A missing data symbol
Fails at load either way — data relocations are never deferred
Fails at load
.got.plt after startup
Must stay writable; partial RELRO at best
Can be mapped read-only: full RELRO
Compatible with LD_PROFILE
Yes — profiling requires it
No; profiling silently forces lazy back on
Thread behaviour on a cold slot
Benign duplicated resolution; both threads store the same value
No cold slots exist
Security posture
Writable function-pointer table for the process lifetime
GOT is read-only; the GOT-overwrite class is closed
Who defaults to it
Plain gcc; ld’s own default
Every mainstream distribution’s package build flags
The rest
“Lazy” is about function calls, not data. Only R_*_JUMP_SLOT relocations are deferred; see the table two sections above for the full list of what shares .rela.plt and is nonetheless eager. There is nowhere to put a trap on a data load, so a missing data symbol fails at load time no matter what the binding mode is.
Eager binding does not remove the PLT. A -z now binary still contains PLT stubs and still calls through them; what changes is that the GOT slot holds the final address from the start, so the stub’s jmp * goes straight to the target on the very first call. The Procedure Linkage Table covers why the stubs survive and what -z mark-plt does about it.
-z lazy emits no tag. There is no DF_1_LAZY. -Wl,-z,lazy only cancels a -z now appearing earlier on the same link command line — useful when a build system injects hardening flags you want to override for an experiment, and a no-op otherwise. If you readelf -d a -z lazy binary looking for confirmation, you will find nothing, and that absence is the confirmation.
The resolver runs with your arguments live._dl_runtime_resolve must preserve every register an argument could be sitting in across the resolution, which is why glibc selects a trampoline variant per CPU — the run above bound GOT[2] to _dl_runtime_resolve_xsavec. This is invisible to correct programs but explains the recurring class of “the resolver clobbered my AVX-512 register” bugs; The Procedure Linkage Table has the detail.
Lazy binding is incompatible with a read-only PLT GOT. You cannot have both: the resolver must write the slot. This is the tension resolved by choosing between partial RELRO (writable .got.plt, lazy permitted) and full RELRO (-z now, .got.plt read-only, lazy impossible) — see RELRO Relocation Read-Only.
Deferred errors are not caught by dlopen’s error reporting either.dlopen(…, RTLD_LAZY) succeeds for a library with unresolvable function references; dlerror() returns NULL; the failure arrives later as a process-terminating symbol lookup error at the first call, from a code path that has no way to handle it. If a plugin’s symbols must be validated at load, RTLD_NOW is the only mechanism that does it — see The Dynamic Linker.
Alternatives and When to Choose Them
Lazy binding is the right default when a program’s import surface is much larger than its call surface and its lifetime is short — a plugin host that loads every backend it might need, a GUI toolkit, a build-system helper invoked a hundred thousand times. The cost is a .got.plt that stays writable for the whole process lifetime, which is the hardening liability RELRO Relocation Read-Only exists to address.
Eager binding with -Wl,-z,now buys determinism more than speed: a read-only GOT after startup, a broken dependency that fails before main instead of mid-run, and no cold slots for concurrent callers to race into. It is the right default for anything long-lived, and it is what Fedora and Ubuntu package builds use.
-fno-plt is the interesting third option, because it does not choose between the two so much as delete the question. Compiling with -fno-plt makes GCC emit the call as a single GOT-indirect instruction with no stub at all, which on this host is directly visible:
Zero JUMP_SLOT relocations; the six imports became R_X86_64_GLOB_DAT entries in .rela.dyn. Since .rela.dyn is never processed lazily, -fno-plt implies eager binding by construction — there is no PLT stub left to trap into. What you gain is one fewer indirection and a branch the predictor handles better than a jmp through a stub; what you give up is the option of laziness, permanently and per translation unit. It composes naturally with full RELRO. MaskRay’s summary is the concise version: “we can just use one instruction … call *foo@GOTPLT(%rip)” (MaskRay, PLT).
-Wl,-z,mark-plt (accepted by ld 2.46.1 on this host) is a fourth path that keeps lazy binding’s shape while letting ld.so eventually remove the indirection: the linker emits DT_X86_64_PLT, DT_X86_64_PLTSZ and DT_X86_64_PLTENT, and with those “the dynamic linker may retrieve the indirect branch target address stored at the global offset table entry and rewrite the indirect branch in the procedure linkage table entry to a direct branch” (AMD64 psABI v1.0, 2025-03-12). The mechanism and its glibc switch belong to The Procedure Linkage Table.
Not linking dynamically at all is the limiting case and removes the entire question along with the flexibility that motivated it; see Static vs Dynamic Linking.
Production Notes
Who actually ships lazy binding in 2026 — and the answer is not “nobody”
The standard line is that every modern distribution links -z now, so lazy binding is a historical curiosity. Checked against the distributions’ own build-flag documentation, that is only two-thirds true, and the exception is the largest packaging ecosystem in the world.
Source of binaries
Binding mode
Evidence
Fedora 44 packages
eager
rpm --eval '%{build_ldflags}' on this host contains an explicit -Wl,-z,now (alongside -z relro and -z pack-relative-relocs); /usr/bin/ls carries FLAGS BIND_NOW and FLAGS_1 NOW PIE
Fedora 44, plain gcc
lazy
Measured above: no spec file is auto-loaded, output is a non-PIE EXEC with no BIND_NOW
Debian packages
lazy by default
dpkg-buildflags(1): “bindnow … adds -Wl,-z,now to LDFLAGS … Disabled by default. Supported since dpkg 1.16.1” (Debian manpage). relro is enabled by default; bindnow is not, so Debian’s baseline is partial RELRO
Ubuntu packages
eager
“-Wl,-z,now — First enabled in Ubuntu 16.10 … this enables immediate relocation table binding (BINDNOW) to gain full RELRO coverage” (Ubuntu ToolChain/CompilerFlags)
Anything you compile by hand on any of them
lazy
ld’s own default is -z lazy
So “lazy binding is dead” is a Fedora/Ubuntu-shaped generalisation. On a Debian system the default package is partial-RELRO and lazily bound, exactly as the mechanism in this note describes, and the LD_BIND_NOT and gdb experiments above will reproduce on stock binaries rather than needing a custom build.
The same Ubuntu page incidentally records where a common piece of Fedora folklore comes from: “Ubuntu’s GCC uses the --enable-default-pie configuration”. Ubuntu’s does; Fedora 44’s does not (verified with gcc -v above), and MOCs/Linux Userspace Runtime MOC.md attributes the Ubuntu behaviour to Fedora. Fedora gets PIE for packages from the -pie line in redhat-hardened-ld, which is a completely different mechanism with a completely different blast radius — it applies to RPM builds only.
Choosing a mode deliberately
A long-running daemon or service: -z now. The one-time startup cost is amortised over the process lifetime into nothing, full RELRO becomes available, and — the underrated part — a botched library upgrade fails at restart instead of at 03:00 on the one code path that calls the removed function.
A short-lived, fan-out-heavy tool (a compiler driver, a git subcommand, anything a build system invokes tens of thousands of times): measure before hardening. The 84 µs figure above is the right order of magnitude per process for a very large import surface; multiplied by 100,000 invocations it is eight seconds of wall clock, and multiplied by a typical import surface it is far less. Measure your binary, not this one.
A plugin host using dlopen: RTLD_NOW if you want a plugin’s missing symbols reported as a load failure you can handle; RTLD_LAZY if the plugin legitimately references symbols it will never call in your configuration. The choice here is about error handling, not speed.
Diagnosing “which library did this symbol come from?”: LD_DEBUG=bindings, and remember stdbuf -o0 if you need the ordering relative to program output to be meaningful. When two libraries export the same name, this is the fastest way to see which one won; Symbol Resolution and Lookup Scope explains why.
Profiling startup: LD_DEBUG=statistics. A run dominated by relocation time (the gdb run above was 78.4%) is the signature of a large dependency graph, and the fix is fewer DT_NEEDED entries, not a different binding mode. Note that LD_DEBUG=unused, the tool for finding those unneeded dependencies, forces eager binding and therefore cannot be used to measure startup at the same time.
Anything set-user-ID: none of the environment knobs apply. ld.so strips them under AT_SECURE and exits with status 5 if one had already taken effect. Bind mode for privileged binaries is a link-time decision or nothing.
The historical arc, briefly
Lazy binding was designed for a world where a symbol lookup was expensive relative to everything else: the original ELF hash table was a simple chained hash, dependency graphs were shallow, and resolving every import meant faulting in pages of symbol tables that a short-lived process would never otherwise touch. Three things eroded the payoff. The GNU hash table with its Bloom filter made a failed per-object lookup very cheap (see Symbol Resolution and Lookup Scope); DT_RELR compressed the relative relocations that dominate a PIE’s .rela.dyn; and the security argument for a read-only GOT turned the writable .got.plt from a neutral implementation detail into a liability with a named exploit class. Prelinking — the other historical answer to the same startup cost — was abandoned outright. What remains of lazy binding’s advantage is the -z now column of the table above: real, measurable, and for most workloads smaller than the cost of the thing it protects against.
See Also
The Procedure Linkage Table — the stub code lazy binding drives; the CET-era .plt.sec split, the resolver trampoline’s register-save cost, and -z mark-plt
The Global Offset Table — the slots _dl_fixup patches, the reserved GOT[0..2] entries, and the page permissions that make them writable
RELRO Relocation Read-Only — why full RELRO requires -z now, and what partial RELRO does and does not protect