Why code has to stop hard-coding addresses
From guide 3 you know a shared library is one copy on disk that many running programs map into their own address spaces at the same time. That single goal has a sharp consequence the moment you ask: where, exactly, does the library land? It cannot land at one fixed address, because two libraries might both want 0x400000, and because ASLR deliberately shuffles the base address on every run to make attacks harder. So the loader gets to choose the load address late, and it may differ in your process from mine for the very same .so file. Any instruction inside that library that bakes in an absolute address like "call the function at 0x4011a0" is therefore wrong as soon as the library is placed anywhere else.
The clean answer is position-independent code (PIC): code compiled so that not a single instruction depends on the absolute address at which the code is loaded. You build it with gcc -fPIC, and from guide 1 you have already met its self-relocating cousin for executables, the position-independent executable (PIE), which is now the default on most Linux distributions. The trick that makes PIC possible is that x86-64 can address data relative to the instruction pointer: an instruction can say "the byte 0x2f3d ahead of wherever rip currently is" rather than "the byte at 0x404050." Because the offset from one instruction to a nearby spot in the same loaded image never changes no matter where the image lands, rip-relative addressing is naturally position-independent. The whole library can slide as one rigid block and every internal reference still points at the right place.
Rip-relative addressing solves references within one image, where the distance is a fixed compile-time constant. It does not solve the two hard cases: a call to a function in a different shared library, whose distance from here is unknown until load time and differs every run; and a reference to a global variable that might be defined in some other module, or even interposed by a third one. For those, PIC needs a level of indirection — a small table of pointers that the loader fills in once the real addresses are known. There are two such tables, one for data and one for functions, and the rest of this guide is about them.
The GOT: one table of real addresses, filled in at load time
The data half is the global offset table (GOT). Picture it as a plain array of pointer-sized slots living in a writable section of each loaded module, one slot per external symbol the module needs to reach. When the compiler generates PIC and hits a reference to, say, the global errno or the symbol stdout that lives in libc, it cannot emit the address — it does not know it. Instead it emits rip-relative code that says: "load the pointer out of my GOT slot for this symbol, then use that." The GOT slot itself sits at a fixed offset from the code (so rip-relative reaches it), but its contents are left blank in the file and written by the loader once libc has been placed.
How does the loader know which slots to fill, and with what? That is exactly what the relocations from guide 2 are for. Each GOT slot that needs a runtime address carries a relocation entry naming its symbol; the dynamic linker walks that list at startup, looks each symbol up using the symbol-resolution order from guide 3, and stores the resolved address into the slot. So the GOT is the meeting point of three earlier ideas at once: PIC creates the indirection, relocations describe what to patch, and symbol resolution decides the value. After the loader is done, reading a global variable through the GOT is just one extra pointer load — you fetch its address from the slot, then dereference. That single indirection is the standing cost PIC pays for data.
The PLT: calling a function you cannot yet locate
Functions get their own table, the procedure linkage table (PLT), and at first it looks like needless duplication of the GOT. The reason it is separate is an optimization we will meet in a moment: a function's real address can be discovered the very first time it is actually called, rather than all at once at startup. To make that possible, the compiler never emits a direct call to an external function. When your PIC code calls printf(), the assembler instead emits a call to a tiny local stub named printf@plt, which lives in the read-only PLT inside your own module. Every external function you call gets one such stub.
Each PLT stub is glued to one GOT slot. The stub's job is simple: jump to whatever address currently sits in its GOT slot. So when you call printf@plt, control reaches a tiny piece of code that does, in effect, "jmp *(the GOT slot for printf)." If the loader has already stored printf's real address in that slot, the jump lands directly in libc's printf() and the cost is one indirect jump through memory — cheap, and exactly the same machinery as the data GOT, just used to reach code. The interesting question is what sits in that GOT slot before anyone has resolved printf, and the answer is the heart of the next section.
your code printf@plt (PLT stub) GOT slot for printf
---------- -------------------- -------------------
call printf@plt --> jmp *GOT[printf] ----------> [ ?? ]
-- before first call: GOT[printf] points BACK into the PLT resolver stub
-- resolver runs once: dynamic linker looks up printf, writes its real
address into GOT[printf]
-- after first call: GOT[printf] = &printf in libc
call printf@plt --> jmp *GOT[printf] ----------> &printf (direct, fast)Lazy binding: the first call patches itself
A big program may link against thousands of library functions yet call only a handful on any given run. Resolving every one at startup would mean a slow launch spent looking up symbols that never get used. Lazy binding avoids that by resolving each function on demand, at its first call, and never paying again. The mechanism is a clever bit of self-modifying data. Initially the loader does not fill printf's GOT slot with printf's address. Instead it points that slot back at a small resolver stub at the head of the PLT — so the very first "jmp *GOT[printf]" jumps not to libc but into the dynamic linker's helper.
- Your code calls printf@plt. The stub does jmp *GOT[printf]. On this very first call, that slot still points back into the PLT's resolver stub, so the jump lands there — carrying along a small index that says "this was printf."
- The resolver stub jumps into the dynamic linker (the routine often named _dl_runtime_resolve), handing it that index and a pointer to your module's linking info.
- The dynamic linker looks up printf using the normal symbol-resolution search order, finds its real address inside libc, and writes that address into GOT[printf] — overwriting the pointer-back-to-the-resolver. The slot is now correct forever.
- The resolver then jumps to printf itself, so this first call still completes normally — the detour is invisible to your program except as a one-time cost.
- Every later call to printf@plt does jmp *GOT[printf] and now lands straight in libc's printf(). The resolver is never touched again for that function.
That is the whole trick: the GOT slot starts as a trampoline into the resolver and is rewritten, on first use, into a direct pointer to the function. The call site never changes — it always just jumps through the slot — so the patching is entirely in mutable data, not in code. You can watch it happen: run a dynamically linked program under a debugger, set a watchpoint on a function's GOT entry, and you will see it flip from the resolver address to the libc address at the exact instant of the first call.
What it costs, and the security catch that RELRO fixes
Be honest about the price tag, because none of this is free. Position-independent code on x86-64 is cheap but not zero: a few registers are tied up, some references pay an extra GOT load, and the compiler has slightly less freedom — on the older 32-bit x86 this was painful because there was no rip-relative addressing, so PIC had to dedicate a whole register to a GOT pointer, which is one reason 32-bit PIC had a real reputation for slowness. On x86-64 the rip-relative trick makes it nearly negligible, which is why PIE could become the default. Lazy binding adds a one-time resolver cost on each function's first call and nothing thereafter; if you would rather pay it all up front — for predictable latency, or for the security hardening below — you set the environment variable LD_BIND_NOW=1, or link with -z now, and the loader resolves every GOT slot at startup instead.
Resolving everything early is not only about speed — it is a real security lever, and here is the catch it fixes. For lazy binding to work, the GOT must stay writable the entire time the program runs, because the resolver patches slots on demand. But a writable table full of function pointers, sitting at a known offset, is a gift to an attacker: a single out-of-bounds write that lands in the GOT can redirect printf to point at malicious code, and your next perfectly innocent printf() call jumps straight to it. This is a classic control-flow hijack target. The defense is RELRO — relocation read-only. With full RELRO (link with -z relro -z now), the loader resolves the whole GOT eagerly at startup and then asks the kernel to mark those pages read-only, so by the time any of your code runs, the GOT can no longer be tampered with.