Systems Programming · Binary Formats
Position-Independent Executables and the Cost of PC-relative Addressing
Why a PIE carries relocation tables that a fixed-address executable does not, how RIP-relative instructions encode the same reference, and what the GOT still has to mediate.
A fixed-address executable is mapped at the address its linker chose, and every absolute reference inside it is correct without adjustment. A position-independent executable is mapped wherever the kernel’s address-space layout randomisation selects, and every reference that depends on an absolute address must be fixed up at load time. The difference is not a flag that changes the instruction set; it is a difference in which relocations survive linking, and therefore in how much work the loader does before the first instruction runs.
The terminology conflates two things that are separate. Position-independent code is a property of the instruction encoding: code that computes addresses relative to the program counter rather than from absolute constants. A position-independent executable is a linking decision: an executable linked so that it contains no fixed load address and can be relocated. PIC can exist in a fixed-address executable, and a PIE can contain non-PIC code that is patched at load time.
What an absolute reference costs
On x86-64, an instruction that references a global variable by absolute address encodes a 32-bit displacement in the common case, which works only because the compiler assumes the whole image fits below 4 GiB. A mov that loads a pointer to a function similarly needs an absolute 64-bit immediate or a 32-bit displacement to a location holding the full address. Both encodings are shorter than a full 64-bit immediate, which is why they were chosen, and both break when the image is placed anywhere else.
RIP-relative addressing removes the assumption. The instruction encodes a 32-bit signed displacement that the CPU adds to the address of the next instruction, so the target is computed from the current position rather than from a constant. The encoding is the same length as the absolute form, no relocation survives, and the instruction is correct wherever the image lands. The limitation is reach: a signed 32-bit displacement spans about ±2 GiB, so a program whose code and data are separated by more than that cannot use the form.
That reach limit is why a PIE can still contain absolute relocations. A reference to a symbol defined in another shared object cannot be RIP-relative even in principle, because the target’s address is unknown until that object is mapped, and the distance to it is unknown at link time. The GOT solves this: the code uses a RIP-relative reference to a GOT slot, which is within reach because the GOT is part of the same image, and the slot holds the absolute address that the loader fills in. The instruction sequence costs an extra memory load compared with a direct reference, and that load hits a cache line that is shared by all references to the same symbol.
Which relocations survive
Linking a non-PIE executable resolves nearly everything. The linker knows every address, applies each relocation, and discards the relocation sections. What remains in the dynamic tables is only the set of references that could not be resolved: symbols imported from shared libraries, and symbols that are exported and therefore interposable.
A PIE adds a third category. Even for symbols defined within the executable and not exported, an absolute reference needs rebasing, because the image’s base is unknown. The linker records those as R_X86_64_RELATIVE entries with addends equal to the link-time offset. At load time the loader adds the base to each addend and writes the result. A PIE built with fully PC-relative code has almost no RELATIVE entries, because there are no absolute locations to fix. A PIE containing a table of function pointers has one per pointer, because each pointer is an absolute address in writable data.
This is the trade the design makes visible: PIC code has no load-time relocation cost for internal references but pays a small per-access cost through the GOT and a loss of some instruction encodings. Non-PIC code has no per-access cost but requires either a fixed base or a large relocation table. Neither dominates; the choice depends on how often the references execute versus how long startup takes.
/* An array of function pointers holds absolute addresses, so every element
needs a relocation before the image can run. */
#include <stddef.h>
extern int handler(void);
int (*const table[])(void) = { handler };
int dispatch(size_t i) {
return table[i](); /* relocated slot, then an indirect call */
}
/* A direct call to a function in the same image needs no relocation at all:
the call is encoded as a PC-relative displacement. */
int direct(void) { return handler(); }
The array costs one word of writable data and one relocation per element. The direct call costs nothing at load time beyond the instruction itself. The difference is invisible in source and decisive in the file.
The cost of a writable data page
Every RELATIVE relocation is applied to a writable location, and every such location must be writable at the moment of application. A PIE with a large function pointer table therefore needs a large writable data region, which the loader maps, writes, and then, under full RELRO, marks read-only. The sequence of operations is: map with write permission, apply relocations, then apply mprotect.
The cost is not only the page fault that follows the protection change. It is also the copy-on-write behaviour of the data: pages that the loader writes are private to the process from that point, so a shared library’s writable pages cannot be shared between processes that use it. A PIE’s own data pages are private regardless, since each process has its own image, but a shared library’s RELATIVE relocations make its data private in every process that maps it, which is a per-process memory cost proportional to the number of relocated pages.
Shared libraries reduce that cost by keeping relocatable data small: initialised pointers live in the GOT or in a dedicated section, and large constant tables are moved to read-only sections where possible. The compiler’s -fdata-sections and the linker’s ability to place read-only data in a non-writable segment both serve this goal. A library whose writable data is one page costs one private page per process; a library whose writable data is a hundred pages costs a hundred.
Thread-local storage is the awkward case
Thread-local variables are per-thread, so their addresses cannot be computed from a single base. The compiler emits one of several access models. The local-exec model assumes the variable is defined in the executable and computes its address as a fixed offset from the thread pointer, which is the cheapest form and is unavailable to shared libraries. The initial-exec model uses an offset from the thread pointer into a static block allocated at load time, which is available to libraries and still a single instruction. The general-dynamic model calls a function to obtain the offset, which is required for variables in dynamically loaded libraries and is much more expensive.
A PIE can use local-exec for its own thread-local variables, which is one reason executables and shared libraries have different code generation for identical source. A shared library cannot, because the variable’s offset in the static block depends on the order in which libraries were loaded. The __tls_get_addr call in the general-dynamic model is the point where a thread-local access becomes a function call, and a loop over thread-local data in a plugin pays that cost per iteration unless the compiler can hoist the offset computation out of the loop.
The static TLS block is a fixed resource. Initial-exec accesses require the library to be allocated a slot in that block at load time, and a program that loads many libraries using initial-exec TLS can exhaust it. The failure appears as a load error, not as a slow access, and it is one of the reasons the general-dynamic model exists despite its cost.
Interaction with the other mechanisms
A PIE’s dynamic section still names a PT_INTERP, still has a PLT for imported functions, and still uses the GOT for cross-object references. The difference is in quantity: a PIE’s own internal calls, when the callee is not interposable, become direct PC-relative calls and never touch the PLT. The PLT in a PIE therefore contains only genuinely imported symbols, plus any exported function called internally when interposition is permitted. The machinery is the same as described in The PLT and GOT Protocol That Defers Symbol Resolution; only the population differs.
The load bias reaches user code through the auxiliary vector on the initial stack, whose AT_PHDR, AT_PHENT, and AT_PHNUM entries locate the program headers in memory, and which the C library exposes for later queries. A static PIE, which has no interpreter to do the work, determines its own load address at startup from a PC-relative reference, then applies its relocation table before running any initialiser. That self-relocation is why a static PIE is larger than a static non-PIE: it must carry its own relocation table and the code to process it.
Debugging adds another layer. The addresses recorded in debug information are link-time addresses, so a debugger must compute the load bias and add it before looking up a line. The bias is recoverable from the auxiliary vector or from a symbol’s runtime address, and a debugger that omits the step reports wrong lines for a PIE while working correctly for a fixed-address executable. The mapping between those addresses and source positions is the subject of DWARF Debug Information: Abbreviation Tables and the Line Number State Machine.
When position independence is not a choice
Some platforms make PIE the default for all executables and some distributions build with it universally, which means the relocation cost is paid whether or not the program benefits from address randomisation. The reasons are security and consistency rather than performance: randomising the executable’s base removes a fixed address that an exploit can rely on, and building everything the same way avoids a class of bugs where a program works as a PIE and fails when linked fixed, or the reverse.
The cost is measurable in file size and startup work, and it scales with the number of absolute references the code contains. A program that stores thousands of function pointers in static tables pays a relocation per pointer, and a program that uses only PC-relative addressing and GOT-mediated calls pays almost nothing. The compiler’s output, not the linker’s flag, determines which case a program falls into, and the two are indistinguishable from the source.
A further consequence is that the same source compiled as a PIE and as a fixed-address executable can differ in instruction count. The fixed build may use a shorter addressing form, and the PIE build may need an extra lea to materialise an address into a register before a call. The difference is small per site and cumulative across a large program, and it is the concrete form of the abstract trade between flexibility and directness that position independence represents.
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
- GNU Compiler Collection Manual — Code Gen Options — GNU Project
- ELF Handling For Thread-Local Storage — Ulrich Drepper