Systems Programming · C and the ABI

Position-Independent Code, the PLT, and the GOT

Why a shared object cannot know its load address, how the global offset table turns an absolute address into a load-time fixup, and what lazy binding defers.

A shared object is compiled once and mapped at whatever address the loader chooses, which may differ between two runs of the same program and between two processes mapping the same file. Any absolute address baked into its code would therefore be wrong. Position-independent code solves this by expressing every reference to a global as an offset from a register that holds the load address, and the global offset table supplies the values that cannot be computed until load time.

The machinery has three parts: a reserved register, a table of slots filled by the loader, and a stub for each external function call. Each part exists because of a specific constraint, and each has a cost that shows up in the generated instructions.

The program counter as the anchor

The core trick is that a reference to another location in the same object can be encoded as a displacement from the current instruction. x86-64 provides RIP-relative addressing: lea rax, [rip + offset] computes the address of a symbol as a fixed displacement from the instruction pointer. The displacement is decided by the linker and is independent of where the object is loaded, because both the instruction and the target move together.

For a global variable with hidden or internal visibility, this is the entire solution. The variable’s address is computed with one instruction, and no table is involved. The -fno-pic alternative is an absolute 32-bit or 64-bit address in the instruction stream, which requires a relocation at load time and, for a shared object, a writable text segment or a text relocation.

A text relocation is the case the loader refuses. Writing a corrected address into an instruction means writing to a page that is mapped read-only and shared between every process using the library. The loader can do it by temporarily making the page writable and giving each process a private copy, which defeats the sharing that made the library worth loading as a shared object. Modern linkers reject text relocations in shared objects by default, and the error message names the symbol that caused it.

The GOT as a table of addresses

An external variable’s address is not known until the loader resolves the symbol, so it cannot be encoded as a displacement. Instead, the linker allocates a slot in the global offset table, and the code loads the address from that slot.

The sequence for a data reference in position-independent code is two instructions on x86-64: a mov that loads the slot’s address relative to rip into a register, followed by an indirect load through that register. The first instruction’s displacement is fixed at link time and points into the GOT; the second reads the address the loader stored there. The cost is one extra load per access compared with a hidden symbol, and one extra register consumed.

The GOT slot is filled by the dynamic loader before any code runs, unless the symbol is resolved lazily. The slot’s content is an absolute address, which is fine because the table is in a writable segment; the text is not.

The distinction between hidden and default visibility determines whether the indirection is needed at all. A default-visibility symbol can be interposed by a definition in another object, so its address genuinely is unknown until load time. A hidden symbol cannot be interposed, so its address is fixed relative to the object’s load address and the RIP-relative form suffices. The performance difference is one load per access, which matters in a loop that touches a global every iteration. The full set of visibility levels and their export consequences is covered in linkage and symbol visibility.

The PLT and the call that goes through a stub

A call to an external function has the same problem, and the solution has an extra layer because a call needs to reach an address, not load from one.

The linker creates a procedure linkage table entry for each external function the object calls. The PLT entry is a small piece of code in the object’s text, at a known offset from the call site, so the call can be a direct RIP-relative call to the PLT. The entry’s first instruction is an indirect jump through a GOT slot dedicated to that function.

If binding is immediate, the loader fills that GOT slot with the function’s real address at load time, and the PLT entry is a jump that lands on the function. The call sequence is call-to-PLT, jump-through-GOT, and the extra cost is one indirect branch per call.

If binding is lazy, the GOT slot initially points back into the PLT entry itself, at the instruction after the jump, which pushes an index identifying the symbol and transfers control to the resolver in the dynamic linker. The resolver looks up the symbol, writes the real address into the GOT slot, and jumps to it. Every subsequent call through that PLT entry goes directly to the function, because the slot has been overwritten. The first call to each lazily bound function therefore pays the cost of a symbol lookup.

The trade is startup time against first-call latency. A program that imports hundreds of symbols but calls few of them benefits from lazy binding, because the loader does no work for the unused ones. A program that must not fail late, such as a long-running service, or one where a missing symbol must be detected before any work begins, benefits from immediate binding, which moves every lookup to startup and turns a late failure into a startup failure. The choice is exposed as an environment setting and as a link-time option, and the two are not equivalent: the environment setting changes the behaviour of an already-linked program, which is why it is used for diagnostics.

# Typical x86-64 PLT entry, lazy binding, before first call
foo@plt:
    jmp    QWORD PTR [rip + foo@got]   # slot initially targets the push below
    push   index                       # relocation index for this symbol
    jmp    resolver                    # first entry of the PLT

The push-and-jump tail is what makes the entry work before resolution. On the first call, the jmp targets the instruction after it, so control falls through to the push. The resolver overwrites the slot with the real address, and the tail becomes unreachable for that symbol.

Relocations, relative and symbolic

The loader performs two kinds of fixup. A relative relocation adds the object’s load address to a value stored in the object, which is how a GOT slot that holds a pointer to an internal object is corrected. A symbolic relocation looks up a name in the global symbol scope and stores the resulting address. The first is cheap and local; the second requires the symbol lookup and is the expensive part of dynamic linking.

The number of symbolic relocations is a function of how many external symbols the object references, and it is the reason a large library’s load time scales with its exported interface rather than with its code size. A library that exports a thousand functions but calls only a few of them externally still pays for the relocations of the ones it references, and lazy binding defers only the function relocations, not the data ones.

Relocation processing also explains the distinction between R_X86_64_GLOB_DAT, which fills a GOT slot with a symbol address, and R_X86_64_JUMP_SLOT, which fills a PLT slot and can be resolved lazily. The linker emits the first for data and the second for functions.

Thread-local storage and the third addressing model

A thread-local variable cannot be addressed by a fixed offset from the object’s load address, because each thread has its own copy. The ABI defines general-dynamic, initial-exec, and local-exec models, each with its own sequence.

The general-dynamic model calls a resolver function that returns the current thread’s offset for the variable, then adds the variable’s offset within the block. The call is expensive and is hoisted out of loops by the compiler where possible. The initial-exec model assumes the variable is allocated in the static TLS block, so the offset is a constant relative to the thread pointer and the sequence is a single load from the thread pointer plus a displacement. The local-exec model applies when the variable is hidden and defined in the same object, reducing the sequence to one instruction on some targets.

The models are selected at link time and the choice constrains where the library can be loaded. Initial-exec TLS in a library loaded with dlopen may fail if the static TLS area is exhausted, which is why the model is chosen conservatively for libraries intended to be loaded at runtime. The offset computation for each model is a property of the ABI rather than of the language, and the language’s _Thread_local keyword says nothing about it.

What the indirection costs and when it can be removed

The cumulative cost of position independence is one extra load for each external data reference and one extra indirect branch for each external call, plus a table of slots per object. For code that is dominated by calls into its own functions, the cost is negligible because internal calls are direct. For code that calls a small external function in a tight loop, the indirect branch can be significant, and the compiler’s inability to inline across the boundary is usually a larger cost than the branch itself.

Two mechanisms reduce the cost without giving up sharing. Link-time optimisation can internalise symbols that are not exported from the final link unit, which converts their calls to direct calls and their data references to RIP-relative form; the direct call then follows the register convention described in function call ABI register assignment. And semantic interposition can be disabled at link time for a build that does not need to support load-time replacement of a symbol, which allows the linker to bind references locally. Both are whole-program decisions, and both break the ability to substitute a symbol at load time, which is the feature the indirection exists to provide.

References

  1. System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee
  2. ELF Handling For Thread-Local Storage — Ulrich Drepper
  3. ld.so(8) — Linux man-pages — Linux man-pages project
  4. How To Write Shared Libraries — Ulrich Drepper