The Procedure Linkage Table

The Procedure Linkage Table (PLT) is an array of tiny machine-code stubs that lets position-independent code call functions whose addresses are not known until the program is loaded. Because a shared library can be mapped at any virtual address, a call printf cannot embed printf’s address — that address only exists after the dynamic linker (ld.so) maps libc. So the compiler emits the call as call printf@plt, which targets a fixed-offset stub inside the program. The stub performs an indirect jump through a Global Offset Table (GOT) slot. On the first call that slot still points back into the PLT, which routes the call into the resolver _dl_runtime_resolve; the resolver finds the real printf, patches the GOT slot, and tail-calls it. Every subsequent call jumps straight through the now-patched GOT to printf at near-native cost. This indirection-plus-self-patching is lazy binding, and the PLT is the machinery that implements it on the x86-64 System V ABI (per the x86-64 psABI / Oracle Linker Guide; mechanics confirmed in MaskRay 2021).

Mental Model — A Call Forwarding Desk That Learns

Think of every external function as someone you can only reach through a forwarding desk. The first time you ask for printf, the desk doesn’t have the number — it phones the directory service (_dl_runtime_resolve), gets the number, writes it into its own address book (the GOT), and connects you. From then on the desk reads the number straight out of the book and connects you instantly; it never calls the directory again for printf. The PLT stub is the desk’s fixed phone extension (a stable address your code can hardcode), and the GOT slot is the one line in the address book it consults. The trick that makes the first call route to the resolver is that the GOT slot is pre-loaded to point back into the PLT stub itself, one instruction past the indirect jump — so the “address book entry” initially says “go ask the directory.”

flowchart TB
  CALL["main: call puts@plt"]
  subgraph PLTSEC[".plt.sec puts@plt (CET build)"]
    EB["endbr64<br/>(IBT landing pad)"]
    IJ["jmp *GOT[puts]"]
    EB --> IJ
  end
  subgraph PLT[".plt lazy entry for puts"]
    PUSH["push 0  (reloc index)"]
    JPLT0["jmp PLT0"]
    PUSH --> JPLT0
  end
  subgraph PLT0[".plt PLT0 header"]
    P8["push GOT[1]  (link_map)"]
    J16["jmp *GOT[2]  (_dl_runtime_resolve)"]
    P8 --> J16
  end
  GOT["GOT[puts] slot<br/>1st call: points to .plt lazy entry+? <br/>after: points to real puts"]
  RESOLVER["_dl_runtime_resolve<br/>→ _dl_fixup: look up puts,<br/>write addr into GOT[puts],<br/>tail-call puts"]
  LIBC["libc puts"]

  CALL --> EB
  IJ -->|reads| GOT
  GOT -.->|"1st call"| PUSH
  JPLT0 --> P8
  J16 --> RESOLVER
  RESOLVER -->|patches| GOT
  RESOLVER --> LIBC
  GOT -.->|"2nd+ call"| LIBC

The PLT call path on a modern Intel-CET x86-64 build. What it shows: the call lands on the .plt.sec stub (whose endbr64 is a Control-flow Enforcement Technology landing pad), which jumps indirectly through the GOT slot. On the first call that slot routes via the lazy .plt entry → PLT0_dl_runtime_resolve, which resolves puts, writes its address into the GOT slot, and calls it; later calls jump straight to puts. The insight: the PLT never changes — it is read-only shared text — yet behaviour changes between the first and later calls because the data it reads (the GOT slot) is what gets patched.

Mechanical Walk-through

Why the PLT must exist

A position-independent executable or shared object is compiled so its .text contains no absolute addresses — only PC-relative references — so the kernel and ld.so can map it anywhere (the basis of Address Space Layout Randomization and of sharing one physical copy of libc across processes; see Position-Independent Code and PIE). A direct call printf would need printf’s absolute address baked into .text, which is impossible at compile time and would also make .text non-shareable. The classic solution is a layer of indirection. Function calls go through the PLT (code) which reads the GOT (writable, per-process data) for the actual address. The code stays read-only and shared; only the small GOT, which is private to each process, is written.

The classic (pre-CET) PLT layout

On the System V x86-64 ABI the linker builds two cooperating tables: a .plt section of 16-byte stubs and a .got.plt section of 8-byte pointer slots. The very first stub, PLT0 (the header), is special. The empirically-confirmed disassembly of a lazy, non-PIE binary built with gcc -no-pie -z lazy (gcc 15.2.1, glibc 2.42) is:

; PLT0 — the resolver trampoline (one per object)
400360:  ff 35 8a 2c 00 00   push  0x2c8a(%rip)   ; push GOT[1]  (= .got.plt+8, the link_map)
400366:  ff 25 8c 2c 00 00   jmp   *0x2c8c(%rip)  ; jmp *GOT[2]  (= .got.plt+16, _dl_runtime_resolve)
40036c:  0f 1f 40 00         nopl  0x0(%rax)      ; padding to 16 bytes
 
; puts@plt — a per-function stub (PLTn)
400370:  ff 25 8a 2c 00 00   jmp   *0x2c8a(%rip)  ; jmp *GOT[puts]  (= .got.plt+24)
400376:  68 00 00 00 00      push  $0x0           ; push relocation index 0
40037b:  e9 e0 ff ff ff      jmp   400360 <PLT0>  ; jmp back to the header

Walking it symbol by symbol: the per-function stub puts@plt begins with an indirect jump, jmp *GOT[puts], that reads the 8-byte GOT slot belonging to puts and jumps to whatever address it holds. The whole lazy-binding trick is the initial content of that slot. The linker pre-fills the slot not with garbage but with the address of puts@plt+6 — the instruction immediately after the jmp, i.e. the push $0x0. I verified this directly: the .got.plt slot for puts contained the bytes 76 03 40 00, which is little-endian 0x400376 = puts@plt + 6. So on the first call, jmp *GOT[puts] jumps to push $0x0 (the next instruction), which pushes the relocation index 0 — the index of this function’s entry in the .rela.plt relocation table — and then jmp 400360 falls into PLT0. PLT0 pushes a second word, GOT[1] (a per-object identifier the dynamic linker filled in at load time, the link_map), and does jmp *GOT[2], an indirect jump to _dl_runtime_resolve, whose address ld.so also wrote into GOT[2] at load time. (See The Global Offset Table for why GOT[0..2] are reserved.)

What the resolver does

_dl_runtime_resolve is a small architecture-specific assembly trampoline in glibc (sysdeps/x86_64/dl-trampoline.S). It saves the argument registers (because we are in the middle of a real function call and %rdi, %rsi, etc. hold puts’s arguments), then calls the C function _dl_fixup with the two values the PLT pushed: the link_map and the relocation index. _dl_fixup (glibc source) locates the relocation by adding the index to the DT_JMPREL table base — reloc = JMPREL_table + reloc_offset(reloc_arg) — reads the symbol name (puts@GLIBC_2.2.5), runs the full symbol search across the lookup scope via _dl_lookup_symbol_x, computes the resolved address, and — crucially — writes that address back into the GOT slot via the macro elf_machine_fixup_plt (which on x86-64 simply stores value at reloc_addr). It returns the resolved address; the trampoline restores the saved registers and tail-jumps to puts so the call completes as if it had gone there directly.

After this, the GOT slot for puts no longer points to puts@plt+6 — it points to the real puts in libc. Every later call puts@plt runs jmp *GOT[puts] and lands directly in libc: one indirect jump, no resolver, no register save/restore. That is why lazy binding’s cost is paid once per symbol, on first use, and is essentially free thereafter.

The relocation index is literally a table index

I confirmed the relocation index pushed by each stub is the entry’s position in .rela.plt. In a CET build, puts@plt pushed $0x0 and __cxa_finalize@plt pushed $0x1, and readelf -r showed .rela.plt[0] = puts and .rela.plt[1] = __cxa_finalize, both of type R_X86_64_JUMP_SLOT. The JUMP_SLOT relocation type is precisely “this .got.plt entry should hold the address of this function,” and it is the relocation _dl_fixup consumes for each lazy bind. (See ELF Relocation Entries and Relocation Processing.)

The CET-era split: .plt and .plt.sec

Modern x86 CPUs support Control-flow Enforcement Technology (CET), whose Indirect Branch Tracking (IBT) feature requires that the target of every indirect branch be an endbr64 instruction; landing anywhere else faults. The original PLT stub’s indirect jump (jmp *GOT[n]) is itself reached by an indirect call (call puts@plt), so the stub must begin with endbr64 to be a legal landing pad. But the classic 16-byte stub had no room to add endbr64 (4 bytes) on top of its three instructions, and — more subtly — you do not want the resolver path and the fast path to share the same landing pad. The binutils/glibc solution, which I observed on this Fedora 43 toolchain (gcc 15.2.1, -fcf-protection=full, the default for Fedora’s hardened builds), is to split the PLT in two:

; .plt  — the lazy/resolver path, one entry per function
370:  f3 0f 1e fa          endbr64
374:  68 00 00 00 00       push  $0x0          ; reloc index for puts
379:  e9 e2 ff ff ff       jmp   360 <PLT0>    ; into the header → resolver
 
; .plt.sec — the *call target*, the fast path, one entry per function
390:  f3 0f 1e fa          endbr64              ; IBT landing pad
394:  ff 25 66 2c 00 00    jmp   *0x2c66(%rip) ; jmp *GOT[puts]
39a:  66 0f 1f 44 00 00    nopw                 ; padding

Now a call puts@plt from main targets the .plt.sec entry, whose endbr64 is the IBT landing pad, followed immediately by the indirect jump through the GOT. The .plt entry no longer contains the indirect jump at all — it has become purely the lazy stub: endbr64; push index; jmp PLT0. The initial GOT value for a function now points at its .plt lazy entry (so the first indirect jump from .plt.sec routes into the resolver path), and after resolution the GOT points at the real function (so .plt.sec’s jump goes straight there). I confirmed the .got.plt slot for puts initially held 0x370 — the address of its .plt lazy entry. This is the layout you will see on essentially every distro binary in 2026.

Uncertain

Verify: the exact instruction encoding of the lazy .plt jump. MaskRay’s 2021 write-up shows bnd jmp (a Multi-Byte-No-op-prefixed branch from older binutils, for MPX/bnd-tracking compatibility), but the gcc 15.2.1 / binutils on this Fedora 43 box emitted a plain jmp (e9 ...) with no bnd (f2) prefix. Reason: the bnd prefix in PLT stubs was a binutils-version-dependent detail tied to Intel MPX, which has since been deprecated/removed. To resolve: this is a cosmetic encoding difference, not a semantic one — both forms jump to PLT0; treat the presence/absence of bnd as toolchain-version-specific. uncertain

How to see it yourself

# Build a lazy, classic-PLT binary and a CET PIE one
gcc -O0 -no-pie -z lazy hello.c -o hello_lazy        # classic .plt only
gcc -O0 -fpie -pie -fcf-protection=full hello.c -o h_cet  # .plt + .plt.sec
 
objdump -d -j .plt h_cet          # the lazy/resolver stubs + PLT0 header
objdump -d -j .plt.sec h_cet      # the endbr64 fast-path call targets
objdump -d -j .text h_cet | grep -A2 'call.*@plt'   # see main call printf@plt
objdump -R h_cet                  # R_X86_64_JUMP_SLOT entries = PLT functions
readelf -r h_cet                  # .rela.plt index order = push immediate
 
# Watch lazy binding actually happen, symbol by symbol, at runtime:
LD_DEBUG=bindings ./hello_lazy 2>&1 | grep puts
# Force eager binding (skip the PLT-resolver dance entirely):
LD_BIND_NOW=1 ./hello_lazy

LD_DEBUG=bindings prints a line each time the resolver binds a symbol — you will see puts bound the first time it is called, not at startup. LD_BIND_NOW=1 (documented in ld.so(8)) resolves every PLT entry at load time, so the PLT stubs are never actually run as resolver trampolines — the GOT is fully populated before main.

Failure Modes and Common Misunderstandings

“The PLT gets patched.” No — the GOT gets patched; the PLT is read-only .text. The whole point is that the code stays immutable and shareable while only the per-process GOT data changes. Confusing the two is the single most common PLT misconception.

Symbol interposition surprises. Because the first call runs a full symbol search, a function can be interposed: an LD_PRELOADed library, or even an earlier object in the lookup scope, can define malloc and the PLT will bind to that, not libc’s. This is how LD_PRELOAD hooks work (see Symbol Interposition and LD_PRELOAD). It also means the PLT call is not free of policy: the binding depends on the entire lookup scope, not just “the obvious library.”

-fno-plt removes the stub. With gcc -fno-plt, the compiler does not emit call printf@plt; it emits call *printf@GOTPCREL(%rip) — a direct indirect call through the GOT, skipping the PLT stub. This forces eager binding of that symbol (there is no lazy stub to route to the resolver) and trades a smaller, faster call for losing laziness. MaskRay notes it is a win when the target is genuinely a preemptible external symbol but a pessimization for non-preemptible ones (you pay for indirection you did not need).

Stale GOT after a crash. If a process is in the middle of _dl_runtime_resolve and is examined in a core dump, the GOT slot may still point into the PLT — making the backtrace look like it goes through _dl_runtime_resolve. This is normal, not corruption.

Alternatives and When to Choose Them

The PLT exists to make lazy dynamic binding cheap. If you do not want laziness — for hardening or determinism — you bind eagerly with -z now (or LD_BIND_NOW), which lets full RELRO make the GOT read-only after relocation and closes the GOT-overwrite attack surface; the PLT stubs then exist but are run only as plain indirect-jump trampolines. If you want to avoid the PLT stub’s two extra jumps entirely, -fno-plt inlines the GOT indirection at each call site. And statically-linked binaries (see Static and Dynamic Linking in Go for the language-runtime view, and Static vs Dynamic Linking) have no PLT for internal calls at all — every call is a direct call to a known offset, because there is nothing to resolve at load time. Go in particular emits almost entirely static binaries and its own direct calls, sidestepping the PLT for Go-to-Go calls (the linker is link).

Production Notes

On a 2026 Fedora 43 system (gcc 15.2.1, glibc 2.42, binutils), the default toolchain emits .plt + .plt.sec with endbr64 IBT landing pads and, for distro packages, links with full RELRO + -z now — I confirmed /usr/bin/ls is a PIE with FLAGS BIND_NOW, FLAGS_1 NOW PIE, a .plt.sec, and no .got.plt section (merged under full RELRO). So on a stock 2026 distro, the lazy PLT path is largely a teaching artifact: the binaries you actually run resolve eagerly at startup and protect the GOT. Lazy binding still matters for programs with thousands of rarely-called symbols (it defers and avoids work for symbols never used in a given run), and it is still the default when you link without -z now. The performance cost of the first-call resolver is real but tiny — a register save/restore plus a symbol-table search — and is the reason large desktop apps historically defaulted to lazy: deferring the search for hundreds of unused symbols shaves startup time. The hardening community’s verdict, reflected in distro defaults, is that eager binding’s predictability and the GOT-protection it unlocks outweigh that startup saving for most software.

See Also