Compilers · Linkers and Loaders
Lazy Binding and the Procedure Linkage Table
How the first call to an external function lands in the dynamic linker, what the resolver writes into the GOT, and why eager binding and full RELRO remove the writable page.
A program that imports a thousand functions from shared libraries and calls twelve of them pays, under eager binding, for all one thousand resolutions before main runs. Under lazy binding it pays for twelve, each at the moment of first call. The mechanism that makes this possible is the procedure linkage table, and its design is an unusually direct example of trading startup latency against first-call latency by leaving the work in a state that can be completed later.
The trick is that the GOT entry for an unresolved function does not contain an address that must be filled before use. It contains the address of a small piece of code that will fill it. The first call goes there, the resolver runs, and the entry is rewritten so the second call goes straight to the function. Nothing about the call site changes; only the memory the call site jumps through.
The stub and its two states
Each imported function gets an entry in .plt and a slot in .got.plt. The call site is patched at static link time to jump to the stub, and the stub’s body is an indirect jump through the GOT slot. That much is true whether binding is lazy or eager, and it is the reason the PLT exists at all: the call site needs to reach code within its own module’s addressing range, and the stub is in the same module.
In the lazy state, the GOT slot points back into the stub, at the instruction after the jump. That instruction pushes an index onto the stack — the index of the relocation for this function — and jumps to the common resolver entry at the start of .plt. The resolver reads the index, finds the relocation, looks up the symbol, writes the resolved address into the GOT slot, and jumps to it.
The next call executes the same indirect jump, but now the slot contains the function’s address, so control goes directly to the function and the stub’s push sequence is never reached. The stub has become a single indirect jump with no resolver involvement, and it stays that way for the life of the process.
The index pushed onto the stack is not a symbol index. It is an index into the module’s .rela.plt section, which holds one relocation per PLT entry. Using the section index rather than a symbol index lets the resolver find both the symbol and the GOT slot from one number, since the relocation record contains the slot’s offset. On x86-64 the relocation type is R_X86_64_JUMP_SLOT, and the resolver’s job is to compute the symbol’s address and store it at the slot.
Why the first three GOT slots are special
The GOT begins with three reserved entries. The first holds the address of the dynamic section, so the resolver can find the relocation tables and the string table without being told where they are. The second is a link-time value that the loader may set. The third is the resolver’s own entry point, and the initial value of every lazy PLT slot points at the push sequence, which jumps here.
This arrangement means the resolver needs no arguments at entry. It finds the dynamic section through the first slot, the relocation index on the stack, and the saved register state that the architecture’s calling convention requires it to preserve. The design predates any notion of passing context explicitly, and it is why the resolver must save and restore every caller-saved register: it is entered through a jump, not a call, so it has no idea what the caller expected.
The stack discipline matters too. The push of the relocation index modifies the stack, and the resolver must unwind that push before jumping to the real function, otherwise the function’s return address would be wrong. On x86-64 the resolver adjusts the stack pointer by eight bytes before the final jump, and the function it jumps to returns directly to the original caller. The push and the adjustment are a matched pair, and a resolver that forgets the adjustment produces a crash on return from the first call — a failure that only appears once and then disappears, which makes it memorable.
Eager binding and the switch that selects it
The environment variable LD_BIND_NOW causes the loader to resolve all jump slots before transferring control, and the -z now linker option marks the object as requiring the same. With eager binding the GOT slots are filled at startup, the resolver is never entered through the lazy path, and the stub is a plain indirect jump from the first call.
The trade is exactly what the names suggest. Lazy binding minimises startup work but makes the first call to each function slower by the cost of a symbol lookup, and that cost can be significant when it happens inside a timing-sensitive region. Eager binding moves all of it to startup, where it is predictable, and removes the possibility of a first-call stall. For a program that calls most of what it imports, eager binding is a net win because the work is the same but happens in one pass with better locality; for a program that calls a small fraction, lazy binding is much cheaper.
Eager binding also makes the failure mode deterministic. Under lazy binding, a missing symbol is discovered when the function is first called, which may be in a rarely executed error path, long after startup, and in a context where the diagnostic is confusing. Under eager binding the same problem is a load-time error naming the symbol. This is the main argument for -z now in programs where startup time is not critical.
RELRO and the page that must be given up
Lazy binding requires .got.plt to be writable for the life of the process, because the resolver writes to it on first call. A writable table of function pointers is a target: an attacker who can write to it can redirect any imported call. The mitigation is to make the table read-only once the loader has finished with it.
Partial RELRO does this for .got but not .got.plt, since the latter must stay writable for lazy binding. Full RELRO combines eager binding with a read-only .got.plt: all slots are resolved at startup, the section is made read-only once relocation is complete, along with the rest of the region RELRO covers, and no resolver can write to it afterwards. Full RELRO therefore requires -z now; the two are not independent options.
The cost of full RELRO is the startup time of eager binding plus the memory cost of making the pages private before they can be protected. Since the loader must write to them, they cannot remain shared with other processes running the same binary, so the pages that were previously shared copy-on-write become private. For a large binary with many imported functions, that is a measurable increase in resident memory per process.
/* The resolver's essential contract, in outline. The real one is
architecture-specific assembly; this shows the data flow. */
extern Elf64_Rela *rela_plt; /* .rela.plt */
extern uint64_t got[]; /* .got.plt, slot 0 = dynamic section */
extern Elf64_Sym *dynsym;
extern const char *dynstr;
static void report_missing(const char *name); /* defined elsewhere */
void resolve(unsigned long reloc_index) {
Elf64_Rela *r = &rela_plt[reloc_index];
unsigned long sym = ELF64_R_SYM(r->r_info);
const char *name = dynstr + dynsym[sym].st_name;
uint64_t addr = lookup_in_loaded_objects(name); /* search order */
if (addr == 0) {
report_missing(name);
return;
}
got[r->r_offset / sizeof(uint64_t)] = addr; /* patch the slot */
}
The real resolver must also handle symbol versions, weak symbols, and the case where the symbol is defined in the same object that references it. The outline above is enough to see why the operation is idempotent: running it twice writes the same address twice, so a race between two threads resolving the same slot is harmless. That property is what allows lazy binding to work in a multithreaded program without a lock on the fast path — two threads may both enter the resolver for the same symbol, both look it up, both write the same value, and both proceed correctly.
What lazy binding cannot defer
Data relocations are never lazy. A reference to an external variable is compiled as a load from a GOT slot, and the program may read that slot at any time, including before any function call. The slot must therefore contain the correct address from the start, which means the loader resolves all data relocations at load time. A library with many imported data objects pays that cost at startup regardless of LD_BIND_NOW.
This asymmetry is why the PLT/GOT split exists at all: functions are reached by a jump, which can be redirected through a slot that is initially a resolver address, while data is reached by a load, which cannot be redirected through a slot containing a lie. The design accepts the constraint rather than trying to defer data. The two kinds of slot also carry different relocation types, as described in Relocation Arithmetic: a jump slot resolves to a function address, a data slot to an object address, and the loader treats them as different classes of work.
Initialisation order interacts with lazy binding in a way that occasionally surprises. A constructor in one library may call a function in another, and under lazy binding that call triggers the resolver during the load sequence, before all libraries are fully initialised. The resolver must therefore work correctly while the process is only partially set up, which is why it avoids depending on any library state beyond the tables it needs. A constructor that calls a function whose own constructor has not run yet gets the function, not the initialisation.
When the mechanism is removed entirely
Static linking removes the PLT for internal calls: a call within the executable is a direct relative call, resolved at link time, with no indirection. That is one of the performance arguments for static binaries, along with the absence of load-time relocation work.
-fno-plt achieves part of the same effect for dynamic linking on x86-64. Instead of a call to a PLT stub, the compiler emits an indirect call through the GOT slot directly, which saves the stub’s jump but requires the slot to be resolved eagerly, since an indirect call through an unfilled slot would jump to the resolver address with no relocation index on the stack. The option therefore pairs with -z now, and the combination removes the PLT entirely while keeping dynamic linking.
The remaining cost is the indirect branch itself. Modern processors predict it well when the target is stable, and poorly when a function pointer alternates between targets, which is the case for virtual dispatch and for callbacks. That is a property of indirect calls in general, not of the PLT, but it is the reason the PLT’s extra jump is rarely the dominant cost in a program that also uses function pointers heavily. The way a symbol’s address is chosen in the first place, including the search order that decides which definition a slot receives, is the subject of Symbol Interposition and the Global Namespace.
References
- System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI working group
- How to Write Shared Libraries — Ulrich Drepper
- ld.so(8) — Linux manual page — Linux man-pages project
- elf(5) — Linux manual page — Linux man-pages project