Compilers · Linkers and Loaders
Position-Independent Code and the Global Offset Table
Why shared code cannot contain absolute addresses, how the GOT turns one pointer indirection into position independence, and what the PLT adds for functions.
A shared object is mapped at whatever address the loader finds free, and the same file may be mapped at different addresses in different processes. Any instruction containing an absolute address would therefore be wrong for all but one mapping. Making the code work everywhere means removing every absolute address from it, and the two mechanisms that do this — the global offset table for data and the procedure linkage table for calls — are the subject here.
The constraint is not merely that addresses vary. A shared object’s text segment is shared between processes, mapped from the same page cache pages, so writing to it would be both a correctness problem and a memory-sharing problem. The loader can patch relocations at startup, but only into writable pages, and only into pages that are not shared. Position independence means never needing that patch at all for the common cases.
The two ways to be position independent
Some architectures solved this with a dedicated register. Where an ABI can spare one, it designates a register to hold the GOT base, the loader sets it before any shared code runs, and every access to an external object becomes a load at a fixed offset from that register. The offset is resolved at link time, because the relative arrangement of GOT entries is fixed even though the GOT’s own address is not. Architectures that cannot spare a register, or that reserve none by convention, must instead compute the GOT address in each function’s prologue and keep it in a general-purpose register for the body.
x86-64 falls into the second group, but it also has instruction-relative addressing, which removes the need for a GOT in most cases. The instruction lea rax, [rip + offset] computes the address of anything within two gigabytes of the instruction, and since the displacement is between two things in the same module, it is the same for every mapping. The compiler can reference its own read-only data and its own functions with no table at all. What it cannot do this way is reference data in another module, whose relative position is not known until load time.
For that case the GOT is still required. A reference to an external variable becomes a load of a pointer from the GOT, and the loader fills that pointer at startup. The cost is one extra memory access per external access, and that is the price of position independence for data.
What actually lives in the GOT
The GOT is an array of addresses in a writable section, .got. Each entry corresponds to one symbol that needs an address at run time. Entries for data objects hold the object’s final address; entries for functions may hold the function’s address when the function’s address is taken or when a call must go through a pointer.
The linker cannot fill these entries at static link time because the symbol may live in another module, so it emits a relocation for each entry and lets the dynamic linker do it. For a local symbol the linker can fill the entry itself, since the address is known relative to the module base; but if the symbol is preemptible — meaning another module could define it first — the entry must stay dynamic.
A detail that matters for correctness: the GOT is split by when the loader finishes writing to it. Slots for data addresses and for taken function addresses are filled at load time and live in .got, which can be made read-only once relocation is complete. The jump slots used by lazy binding live in .got.plt, which must stay writable for the life of the process unless binding is eager. The fewer entries in the writable part, the less memory is dirtied at startup. On x86-64 the psABI reserves the first three GOT slots: the address of the dynamic section, the address of the object’s link map, and the resolver’s entry point. Those three exist in every module whether or not they are used.
Copy relocations and the object that could not move
An executable that references a variable defined in a shared object presents an awkward case. The variable’s address is not known at link time, so the executable cannot embed it in its code; but the executable is not position independent either, so it cannot use the GOT for a reference without changing the instruction.
The historical solution is the copy relocation. The linker allocates space for the variable in the executable’s .bss, emits a dynamic relocation that the loader fills by copying the initial value from the shared object, and then the executable’s references point at its own copy. The shared object’s references, which were compiled to go through its GOT, are then redirected to the copy, so both sides see the same bytes.
The cost is that the variable now exists twice, and its initialisation is no longer under the shared object’s control: a constructor in the library that would have initialised it runs after the copy, and writes to the library’s own GOT-reached address only because the loader redirected that entry to the copy. A further constraint is size: the linker must know how many bytes to reserve, so the object’s size must be visible in the dynamic symbol table, and a library that later changes the size of an object involved in a copy relocation breaks the executables that copied it. Position-independent executables avoid the mechanism entirely, since they can address the variable through their own GOT.
/* Two modules, one defining a global and one referencing it. With
-fPIC the reference compiles to a load through the GOT; with
-fno-pic on an executable it compiles to an absolute address, which
forces a copy relocation when the definition is in a shared object. */
/* library side: compiled with -fPIC */
int counter = 0;
int bump(void) { return ++counter; }
/* program side */
extern int counter;
extern int bump(void);
int main(void) { return bump() + counter; }
Building the program above as a non-PIE executable against a shared library produces a copy relocation for counter. Building it as a PIE produces a GOT-relative reference and no copy. The observable difference is in the dynamic symbol table of the executable: the copy relocation appears as a distinct relocation type naming the size of the copied object.
The PLT and the call that cannot be direct
Calls to functions in another module have the same problem, with an extra twist: the call instruction’s displacement is computed by the linker, but the callee’s address is not known, so the displacement cannot be filled. The procedure linkage table is the answer. Each external function gets a small stub in .plt, and the call site is patched to jump to the stub, which is within range because it is in the same module.
The stub’s body is an indirect jump through a GOT entry dedicated to that function. On the first call the entry does not contain the function’s address; it contains the address of the next instruction in the stub, which pushes an index and jumps to the resolver. The resolver finds the function, stores its address in the GOT entry, and jumps to it. Every subsequent call goes through the now-filled entry and costs one indirect jump.
The initial state of the GOT entries is what makes this work: the loader does not need to fill them at startup, so startup is faster, and the cost is paid on first use. The scheme is called lazy binding, and it is described in Lazy Binding and the Procedure Linkage Table.
The PLT exists even when lazy binding is disabled. The stub is still needed to reach a callee outside the module’s two-gigabyte window, and the indirect jump through a GOT entry is the only way to express that. With eager binding the loader fills all the entries at startup and the resolver is never called, but the extra jump remains.
Where the model costs
The overhead of position independence is threefold. Every external data access gains a load, which on a modern core costs a few cycles when the GOT entry is in cache and considerably more when it is not. Every external call gains an indirect jump, which the branch predictor handles well after warm-up but which is not free. And the text segment grows, because the address computations occupy bytes that a direct reference would not need.
The measurable part of startup is the relocation work. A shared object with many dynamic relocations requires the loader to walk its relocation sections, compute addresses, and write them into the GOT, all before any code runs. A library that references hundreds of external symbols pays for all of them at load time even if the program never calls the functions involved, unless the entries are lazily bound. The cost of loading a shared object therefore tracks its dynamic relocation count rather than its file size, which is why two libraries of similar size can differ widely in how long they take to become ready.
There is a further cost specific to preemption. A symbol defined in a shared object is by default preemptible: another module loaded earlier could define the same name, and the dynamic linker must let the first definition win. This forces every reference to a global inside the same shared object to go through the GOT and the PLT, even though the definition is right there. Marking symbols as protected visibility, or compiling with -fno-semantic-interposition, tells the compiler and linker that the local definition wins, allowing direct references and removing both the load and the indirect jump. The trade is that the symbol can no longer be overridden, which is exactly what symbol interposition depends on, as described in Symbol Interposition and the Global Namespace.
Limits of the model
The two-gigabyte window is the hard boundary. A module larger than that cannot reach its own data with RIP-relative addressing, and the compiler must fall back to a large code model where every address is materialised with a 64-bit immediate and every call is indirect. In practice, modules that large are rare but not unheard of in generated code and in statically linked binaries containing whole language runtimes.
The GOT itself is a per-module table, which means that n modules referencing the same symbol each carry an entry pointing to the same address. There is no cross-module sharing of GOT entries, because each module’s entries must be at a fixed offset from that module’s base. A process with a hundred shared libraries referencing malloc has a hundred GOT entries for it, which is negligible in absolute terms but illustrates that the model duplicates rather than centralises.
Finally, position independence does not by itself make a binary relocatable at arbitrary addresses. The dynamic linker must still apply the relocations that remain, and it must do so before transferring control. A module with a large number of data relocations has a correspondingly large fixed startup cost that no amount of lazy binding removes, since data relocations cannot be deferred: reading the variable would otherwise read an unfilled pointer.
References
- System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI working group
- How to Write Shared Libraries — Ulrich Drepper
- elf(5) — Linux manual page — Linux man-pages project
- Linkers and Loaders — John R. Levine, Morgan Kaufmann, 1999