Operating Systems

ELF Dynamic Linking: How the PLT and GOT Resolve Symbols at Runtime

Run puts("hello") in a dynamically linked Linux program and the very first call does not jump to libc. It jumps to a tiny 16-byte stub, pushes an integer, and hands control to the dynamic linker, which walks a hash table, patches one 8-byte pointer, and only then calls the real function. Every call after that is a single indirect jump costing one extra memory load. That machinery is the Procedure Linkage Table (PLT) and the Global Offset Table (GOT).

The PLT is a table of executable code stubs, one per imported function; the GOT is a table of writable data pointers. Together they let position-independent ELF binaries call functions and read globals whose addresses are unknown until the loader maps shared objects (ASLR-randomized) into the process. The PLT/GOT split enables lazy binding: symbols are resolved on first use, amortizing startup cost to O(1) per resolved symbol.

  • TypeRuntime symbol-resolution mechanism (ELF ABI)
  • ComponentsPLT (code stubs) + GOT (data pointers) + ld.so
  • Time complexityFirst call O(1) amortized hash lookup; subsequent calls O(1) indirect jump
  • StandardizedSystem V ABI / ELF (SVR4, ~1988); GNU/Linux glibc ld.so
  • Used inEvery Linux/BSD/Solaris shared-library program; Android bionic
  • Key ideaOne writable pointer per symbol, patched once, called through forever

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The problem: calling code whose address you don't know yet

A shared library like libc.so.6 is loaded at a different virtual address in every process, chosen by ASLR when the dynamic linker (ld.so) mmaps it. So when the compiler emits call puts, it cannot bake in an absolute address, and it cannot even use a fixed relative offset, because the gap between your code and libc isn't known until load time. Rewriting every call site once the address is known (text relocations) would defeat page sharing and mark code pages writable, a security disaster.

  • The indirection trick: route every external call through a pointer that lives in writable data, not in the read-only code. That pointer table is the GOT.
  • The GOT (.got / .got.plt): one 8-byte slot per imported symbol. Code reads the slot PC-relative and jumps to whatever it holds. The loader only has to patch data, keeping .text read-only and shareable.
  • The PLT: a parallel table of small code trampolines that perform the GOT read and jump, and (for functions) trigger resolution on the first call.

This split, standardized in the System V ELF ABI, is what makes position-independent code (PIC) and shared libraries practical.

How lazy binding works, step by step

The first three GOT slots are reserved: GOT[0] = address of the .dynamic section, GOT[1] = pointer to this module's link_map (the linker's bookkeeping struct), GOT[2] = address of _dl_runtime_resolve, the linker's resolver stub. Each function's GOT slot is lazily initialized to point back into its own PLT stub.

; First call to puts@plt
puts@plt:
  jmp   *puts@got(%rip)   ; GOT slot initially points to next line
  push  $reloc_index      ; index into .rela.plt
  jmp   PLT[0]            ; common trampoline

PLT[0]:
  push  GOT[1]            ; link_map for this object
  jmp   *GOT[2]          ; _dl_runtime_resolve

On the first call, the GOT slot still points at the push instruction, so the jump falls through: the reloc index and link_map get pushed and control enters _dl_runtime_resolve. That calls _dl_fixup, which looks up the symbol in the dependency chain's hash table, finds puts's real address, writes it into the GOT slot, and tail-calls it. On every subsequent call, jmp *puts@got lands directly on libc's puts. Resolution happens once.

Complexity and a worked step-trace

Let n be the number of imported symbols. Steady state: every PLT call is one extra memory load plus an indirect jump, i.e. O(1) with a tiny constant (a possible cache miss on the GOT line and a branch-predictor entry). First call: one symbol lookup. glibc uses the GNU hash section (.gnu.hash), a bloom-filter-fronted hash table, so a single lookup is O(1) expected across the L libraries in scope; the bloom filter rejects most non-matching libraries without touching their chains.

  • Lazy total: only the symbols actually called are resolved, sum ≤ n, spread across execution.
  • Eager (BIND_NOW): all n resolved at startup — O(n) lookups up front, worse tail latency but predictable and safer.
call puts        ; step 1: enter puts@plt
jmp *GOT[puts]   ; step 2: GOT holds &push  -> fall through
push 0           ; step 3: reloc_index = 0
jmp PLT[0]       ; step 4
push GOT[1]      ; step 5: link_map
jmp *GOT[2]      ; step 6: _dl_runtime_resolve -> _dl_fixup
                 ; step 7: gnu_hash lookup -> &libc_puts
                 ; step 8: GOT[puts] = &libc_puts ; then jump there
; --- second call ---
call puts        ; jmp *GOT[puts] lands on libc_puts directly (O(1))

Where this runs in real systems

Every dynamically linked ELF program on Linux, the BSDs, Solaris, and illumos uses PLT/GOT; on Android the bionic linker implements the same scheme. Because virtually all userspace binaries are dynamically linked, this is one of the most-executed pieces of glue code on the planet.

  • Interposition & profiling: LD_PRELOAD works precisely because calls go through the GOT — the linker resolves a symbol to the preloaded library instead of libc. Tools like ltrace, sanitizers, and Electric Fence exploit this.
  • IFUNC / STT_GNU_IFUNC: functions like memcpy resolve through the same machinery to a CPU-tuned variant (AVX2 vs SSE2) chosen at load time by a resolver function — a GOT patch selects the implementation.
  • Data GOT: imported global variables (e.g. errno's address, stdout) are read through GOT entries in .got, resolved eagerly.
  • Linkers: ld, gold, LLD, and mold synthesize the PLT/GOT and .rela.plt/.rela.dyn relocations at build time.

Alternatives and the tradeoff

Static linking resolves everything at build time: direct calls, no GOT indirection, no startup cost, and no missing-library failures — at the price of larger binaries, no shared code pages across processes, and no security updates without recompiling. Eager binding (Full RELRO, -z now) keeps the GOT but resolves all n symbols at startup, then mprotects the GOT read-only; you trade slightly slower launch for immunity to GOT-overwrite attacks.

  • Lazy vs eager: lazy wins for short-lived processes that touch few symbols (fast startup, pay-as-you-go); eager wins for long-running daemons where startup cost is amortized and security matters more.
  • dlopen/dlsym: explicit, programmatic resolution for plugins — maximal flexibility, but you manage the function pointer and error handling yourself.
  • Direct binding / -Bsymbolic: resolve intra-library calls to the defining library, skipping the global scope search and disabling interposition for those symbols — faster, but breaks LD_PRELOAD for them.

Pitfalls, failure modes, and why it matters

Security: because .got.plt stays writable under lazy binding, a memory-corruption bug that overwrites a GOT slot redirects a future call to attacker code — the classic GOT hijacking primitive (c0ntex, 2006), and ret2dlresolve abuses the resolver's trust in the pushed reloc index. The mitigation is RELRO: Partial RELRO reorders and protects .got; Full RELRO (-z relro -z now) forces eager binding and marks the whole GOT read-only after startup. Distros increasingly ship Full RELRO by default.

  • Lazy-binding + fork/thread hazards: a signal handler or a resolver that itself calls a PLT function can recurse; historically fragile. LD_BIND_NOW=1 forces eager binding to sidestep it.
  • Runtime failures: an unresolved symbol only errors on first call under lazy binding — a code path you never hit hides the bug until production. Eager binding surfaces it immediately at launch.
  • Performance: GOT slots and PLT stubs add I-cache/D-cache pressure; hot cross-library calls pay an indirect-jump penalty a static call would avoid.

The PLT/GOT design is the quiet compromise that lets one shared libc serve thousands of processes while keeping code pages read-only — a foundational trick of modern operating systems.

Symbol resolution strategies compared
StrategyWhen symbols resolvedFirst-call costSteady-state costGOT writable at runtime?
Lazy binding (default PLT/GOT)On first call to each symbolO(1) hash lookup + fixup1 indirect jump via GOTYes (.got.plt stays writable)
Eager / BIND_NOW (Full RELRO)All at load timePaid up front at startup1 indirect jump via GOTNo (GOT mprotect'd read-only)
Static linkingAt link time (build)None at runtimeDirect call (no GOT)N/A (no dynamic symbols)
dlopen / dlsymExplicit runtime callO(1) lookup per dlsymDirect call via returned ptrCaller-managed pointer

Frequently asked questions

What is the difference between the PLT and the GOT?

The PLT (Procedure Linkage Table) is executable code — a small trampoline stub per imported function that reads a pointer and jumps to it. The GOT (Global Offset Table) is writable data — a table of address slots the dynamic linker patches. In short: PLT is the code that does the indirect jump; GOT holds the actual target addresses. Functions go through the PLT into the GOT; imported data globals are read straight from the GOT.

What are GOT[0], GOT[1], and GOT[2] reserved for?

They are reserved entries the linker fills at startup. GOT[0] holds the address of the module's .dynamic section, GOT[1] holds a pointer to the module's link_map (the linker's per-object bookkeeping structure), and GOT[2] holds the address of _dl_runtime_resolve, the resolver stub. The PLT[0] trampoline pushes GOT[1] and jumps through GOT[2] to invoke resolution on a first call.

How much does lazy binding actually cost per call?

After the first call, essentially one extra memory load plus an indirect jump — amortized O(1) with a small constant. The overhead is a possible cache miss on the GOT cache line and consuming a branch-target-predictor slot. The expensive part is the one-time first-call resolution: a GNU-hash lookup that is O(1) expected because a bloom filter fronts the hash chains.

What is RELRO and how does it relate to the GOT?

RELRO (Relocation Read-Only) hardens the GOT against being overwritten. Partial RELRO reorders sections and marks .got read-only after startup. Full RELRO (linked with -z relro -z now) additionally forces eager binding — the linker resolves every symbol at load time — then mprotects the entire GOT read-only, blocking GOT-hijacking attacks at the cost of slower startup and no lazy binding.

How does LD_PRELOAD hijack functions using this mechanism?

Because external calls resolve through the GOT rather than a hardcoded address, the dynamic linker searches libraries in scope order when it resolves a symbol. LD_PRELOAD inserts your library first in that search order, so when _dl_fixup looks up, say, malloc, it finds your version and writes its address into the GOT slot. Every subsequent call jumps to your interposed function. Full RELRO plus -Bsymbolic can defeat this for specific symbols.

Why does an unresolved symbol sometimes crash only later, not at startup?

Under default lazy binding, a function's GOT slot is only resolved on its first call. If the missing symbol lives on a code path you never execute, the program starts and runs fine until that path is hit, at which point _dl_fixup fails and aborts. Setting LD_BIND_NOW=1 (or linking with -z now) forces eager resolution, surfacing missing-symbol errors immediately at launch instead.