Systems Programming · Binary Formats
The PLT and GOT Protocol That Defers Symbol Resolution
How a call to an external function becomes a jump through a writable table, what the resolver stub pushes, and what is given up when binding happens eagerly instead.
A shared library cannot be patched at link time, because its final load address is unknown and its symbols may be overridden by another object in the process. Every call from one object to another must therefore go through something the loader can redirect. The mechanism ELF settled on is a pair of tables: a read-only stub section containing one small trampoline per imported function, and a writable table holding the addresses those trampolines load. The first call to an imported function runs a resolver; every subsequent call goes through a slot that the resolver has already filled.
The design earns its complexity by deferring work. A process that links against a large library but calls three of its functions pays resolution cost for three functions rather than for all of them, and a function that is never called is never resolved at all. The price is a writable table inside an otherwise read-only address space, an indirect branch on every call, and a resolution path that is much slower than a direct call but runs at most once per symbol per process.
Why a direct call is impossible
Consider a call instruction in a shared object. The compiler emits call with a 32-bit displacement, which the linker would normally fill with the distance from the call site to the target. If the target is defined in the same object, that works: the displacement is fixed at link time and the CPU reaches it directly. If the target is defined in another object, the linker has no address to use. It could emit a full 64-bit indirect call through a slot, but that costs an extra instruction and an extra memory load for every call site, and it would still need a place to put the address.
The second problem is interposition. ELF’s default symbol visibility means a definition in the executable overrides a definition in a shared library, and a library’s internal calls to its own exported functions can be redirected to the executable’s version. Even if the linker knew the library’s internal address, binding the call directly would violate that rule. The indirection is not only a workaround for unknown addresses; it is the mechanism that makes interposition possible.
A third consideration is lazy resolution. Resolving every imported symbol at startup means walking the dependency graph, hashing each name, and comparing versions before the first instruction of user code runs. For a program importing hundreds of symbols from several libraries, that work is measurable in process startup time, and most of it is wasted on functions that the run never calls.
The layout of the two tables
On x86-64 the classic procedure linkage table is an array of 16-byte entries. Entry zero, often called PLT0, is the resolver trampoline shared by all others. Each subsequent entry corresponds to one imported function and has the form:
foo@plt:
jmp *foo@GOT(%rip) # 6 bytes, indirect through the GOT slot
push $index # relocation index for this symbol
jmp PLT0 # 5 bytes, enter the shared resolver
The GOT slot for foo is initialised by the linker to point not at foo but at the push instruction of foo@plt, which is the second instruction of the same entry. The first call therefore jumps straight into the push, pushes its relocation index, and jumps to PLT0. PLT0 pushes the address of the link map, an internal structure describing the object, and jumps to the resolver whose address is held in the third GOT slot. The resolver looks up the symbol named by the relocation entry, writes its address into the slot, and jumps to it. The second call to foo finds the slot already holding the real address and goes directly there.
The three reserved GOT slots hold, in order, the address of the dynamic section, the link map pointer, and the resolver entry. These are populated by the dynamic linker when it maps the object. A reader that expects every GOT slot to hold a function address will misread the first three, and a reader that computes a slot index without accounting for them will be off by three entries.
/* The shape of the patch a lazy resolver performs: the address comes from
the symbol lookup, and the slot arithmetic belongs to the caller. */
#include <stdint.h>
struct rela { uint64_t offset, info; int64_t addend; };
static uint64_t *jump_slot(uint64_t *got, const struct rela *jmprel, uint32_t idx) {
uint32_t type = (uint32_t)(jmprel[idx].info & 0xffffffffu);
if (type != 7) /* R_X86_64_JUMP_SLOT */
return 0;
return &got[3 + idx]; /* skip dynamic, link_map, resolver */
}
void bind(uint64_t *got, const struct rela *jmprel, uint32_t idx, uint64_t addr) {
uint64_t *slot = jump_slot(got, jmprel, idx);
if (slot) *slot = addr;
}
Two costs are visible in the encoding. The indirect jmp is a memory-indirect branch, which the branch predictor can handle well only if it learns the target; on a call-heavy workload the pattern is stable after the first call and prediction succeeds. And the entry is 16 bytes, so a binary importing 500 functions spends 8 KiB on trampolines and another 4 KiB on GOT slots, a modest but non-zero addition to the text footprint.
The resolver’s side of the contract
When control reaches the resolver, the stack contains the return address, the relocation index pushed by the PLT entry, and the link map pointer pushed by PLT0. The resolver reads the relocation entry at that index from the table named by DT_JMPREL, whose size is DT_PLTRELSZ and whose entry size is DT_PLTREL. It extracts the symbol index, looks the name up in the string table, and searches the loaded objects in the order the dynamic section’s DT_NEEDED entries established, with the executable’s own definitions checked first.
The search itself is a hash table lookup, typically through the DT_HASH table or the newer DT_GNU_HASH, whose format is the subject of Symbol Versioning and the Two Hash Tables Behind Every Lookup. The name is hashed, the bucket is located, and the chain is walked comparing names byte by byte. Once found, the resolver applies the versioning rules, computes the symbol’s address from the object’s base plus its symbol value, and writes it to the slot.
If the symbol is not found, the process terminates with an error naming the symbol, unless the relocation is weak. That distinction is why a program can reference a symbol from a library it does not link against, as long as the reference is weak and the code checks for null before calling. The check is a property of the source, not of the linker: a weak undefined function is bound to zero, and calling zero faults.
Eager binding and what it buys
Setting the environment variable LD_BIND_NOW to a non-empty value, or linking with -z now, instructs the dynamic linker to resolve every JUMP_SLOT relocation before transferring control to the program. The PLT entries remain, but their slots are filled immediately, so the second instruction of each entry is never executed.
Eager binding removes the need to write to the GOT after startup, which allows the whole GOT, together with the rest of the data the loader will never modify again, to be marked read-only once relocation finishes. That range is described by PT_GNU_RELRO, and the protection is applied by the loader after processing relocations. The security benefit is concrete: an attacker who gains a write primitive cannot redirect a lazily bound call by overwriting a GOT slot that is still writable, because there are no such slots.
The cost is startup work proportional to the number of imported symbols. It also changes failure timing: with lazy binding, a missing symbol surfaces at the first call, which may be inside a rarely executed error path; with eager binding, it surfaces at startup. For a process that must not fail late, the second behaviour is preferable, and it is why build systems increasingly pass -z now for binaries that run unattended.
There is a third option that avoids the PLT entirely. Compiling with -fno-plt makes each call site perform its own indirect call through the GOT slot, so the call goes through one memory load rather than a jump to a stub and a second load. The resolver still runs on first use, but the trampoline disappears, saving the 16 bytes per import and removing one branch. The trade is that the indirect call is at the call site, where the branch predictor sees a different instruction stream, and that every call pays the GOT load rather than only the first.
Interaction with control-flow integrity
Modern processors and toolchains add constraints that the classic PLT layout did not anticipate. Intel’s indirect branch tracking requires that every indirect branch target begin with an endbr64 instruction, so a PLT entry reached indirectly must start with it, which changes the entry layout and its size. The response in current toolchains is to split the table: a .plt section retains the entries that are reached by direct jumps, and a .plt.sec section holds entries beginning with the landing instruction, reached indirectly. Both refer to the same GOT slots, so the resolver contract is unchanged.
Position-independent executables add another wrinkle. A PIE’s own calls to its own functions may still go through the PLT if those functions are exported and therefore interposable, which is why a binary can contain PLT entries for symbols it defines itself. Marking such functions hidden, or compiling with -fno-semantic-interposition, lets the linker bind them directly and removes the entries. The count of self-referential PLT entries in a binary is a rough indicator of how much interposability the build retained, and the mechanics of that addressing are covered in Position-Independent Executables and the Cost of PC-relative Addressing.
What can go wrong at the slot
The GOT slot is the weak point of the whole scheme. A slot that is never resolved holds a pointer into the PLT, so a write to it before resolution redirects the next call into an arbitrary PLT entry, whose pushed index then names a different symbol. That is why full RELRO is specified as covering both the GOT and the PLT’s own slots, and why partial RELRO, which protects only the non-PLT portion, leaves the jump slots writable for the lifetime of the process.
A second failure mode is a slot patched by two different objects. When two libraries import the same symbol, they have separate GOT slots but the resolver must write the same address to both. If one object is loaded after the other has already been bound, the newly loaded object’s slots are resolved against the current symbol table, which may include the new object’s own definition if it appears earlier in the search order. Symbol lookup order and lazy binding therefore interact: the moment at which a slot is resolved can determine which definition it receives, which is one reason lazy binding makes interposition behaviour harder to reason about than eager binding.
A third is the resolver’s own recursion. If the resolver itself calls a function through the PLT while resolving, and that function is not yet bound, the recursion is entered again. Implementations guard against this by marking the resolution in progress, but the guard is internal to the dynamic linker and invisible in the file format, which is why the format alone cannot answer questions about reentrancy during startup.
Reading a binary’s binding policy
The policy is recoverable from the file without running it. A DT_BIND_NOW entry in the dynamic section, or the DF_BIND_NOW bit in DT_FLAGS, indicates eager binding. The presence of DT_PLTREL and a non-empty DT_JMPREL indicates that lazy binding is at least structurally possible. The presence of PT_GNU_RELRO indicates that some range will be made read-only after relocation; combined with eager binding, that range covers the jump slots as well as the rest of the GOT, which is what the distinction between partial and full RELRO describes.
Those flags are set by the linker from command-line options, so a binary’s binding behaviour is a build decision recorded in the file. Nothing in the ELF specification requires a particular choice, and the same source compiled twice with different linker flags produces binaries with materially different startup cost and materially different exposure to a write primitive.
References
- System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification — TIS Committee
- elf(5) — Linux man-pages — Linux man-pages project