Loading learning content...
When you compile a modern program, the resulting executable doesn't know where it will load in memory. It might load at address 0x400000 on one run and 0x7F3A4B000000 on the next (thanks to ASLR). Yet the same binary works perfectly in both cases.
How is this possible? The answer is relocatable code—code specifically designed to function correctly regardless of its load address. This isn't just convenient; it's essential for shared libraries, security features, and modern operating system architecture.
Relocatable code comes in different forms, from code that can be adjusted once at load time to position-independent code (PIC) that requires no adjustment at all. Understanding these techniques reveals how compilers, linkers, and loaders collaborate to create flexible, secure software.
By the end of this page, you will understand how relocatable code is structured, the difference between static relocation and position-independent code, how PIC accesses global data and calls functions, the role of the GOT and PLT, and why modern systems prefer position-independent executables (PIE).
Absolute code—code with hardcoded addresses—creates fundamental problems in modern systems:
Problem 1: Shared libraries
A shared library (like libc.so) is loaded by many processes. Each process has a different memory layout. If the library contained absolute addresses, it would only work in one process. With relocatable code, the same library binary serves all processes.
Problem 2: Address Space Layout Randomization (ASLR)
For security, modern systems randomize where programs load. A binary must work at any address. Absolute code would need patching on every execution—expensive and defeats the purpose of randomization.
Problem 3: Memory efficiency
Shared libraries mapped at different virtual addresses in each process can share the same physical pages (text sharing). This only works if the code pages are identical—no per-process patching. Position-independent code enables this sharing.
Problem 4: Hot patching and dynamic loading
Plugins, dynamic libraries, and runtime code loading require code that works wherever the OS places it. Absolute addresses prevent flexible loading strategies.
Not all relocatable code is created equal. There's a spectrum from "relocatable with patching" to "fully position-independent":
| Type | Requires Patching | When Patched | Code Sharing Possible |
|---|---|---|---|
| Absolute code | Only at predetermined address | Never relocatable | Only if same address |
| Load-time relocatable | Yes | At load time (once) | No (each process has patched copy) |
| Position-Independent Code (PIC) | No | Never | Yes (identical copies shared) |
| Position-Independent Executable (PIE) | Minimal (GOT/PLT) | At load time | Mostly (code shared, data per-process) |
Load-time relocatable code:
This traditional approach uses relocation tables (discussed in the load-time binding page). The code contains addresses relative to an assumed base. At load time, the loader patches all addresses by adding the actual load address.
Position-Independent Code (PIC):
PIC takes a fundamentally different approach: the code never uses absolute addresses at all. Instead, it calculates addresses at runtime using the program counter (PC) as a reference. Since the program counter always contains the current instruction's address, all calculations relative to PC remain correct regardless of load location.
Position-Independent Executable (PIE):
A PIE is an executable (not just a library) compiled as position-independent. This enables full ASLR for the main program, not just libraries. Most modern Linux distributions and macOS require PIE by default.
The key insight behind PIC is that while absolute addresses change based on load location, relative distances remain constant. If a data item is 1000 bytes ahead of an instruction, it's always 1000 bytes ahead—regardless of where in memory both are placed. PIC exploits this invariance.
PC-relative addressing is the foundation of position-independent code. Instead of encoding absolute addresses, instructions encode offsets from the current program counter (instruction pointer).
How it works:
; ABSOLUTE ADDRESSING (Position-Dependent); ══════════════════════════════════════════════════════════════════════ ; Assume: code at 0x1000, data at 0x20000x1000: MOV EAX, [0x2000] ; Load from ABSOLUTE address 0x2000 ; Encoded bytes: A1 00 20 00 00 ; The address 0x2000 is in the instruction! ; If the program loads at 0x5000 instead:0x5000: MOV EAX, [0x2000] ; Still tries to access 0x2000 (WRONG!) ; Data is actually at 0x6000 now! ; Program crashes or reads garbage. ; ══════════════════════════════════════════════════════════════════════; PC-RELATIVE ADDRESSING (Position-Independent); ══════════════════════════════════════════════════════════════════════ ; Assume: code at 0x1000, data at 0x2000 (distance = 0x1000 = 4096 bytes); Using x86-64 RIP-relative addressing: 0x1000: MOV EAX, [RIP + 0xFF6] ; Load from (current address + offset) ; RIP at this instruction = 0x1006 (after fetch) ; 0x1006 + 0xFF6 = 0x2000 ✓ (after adjustment) ; Note: actual offset calculation accounts for instruction length ; If the program loads at 0x5000 instead (data at 0x6000):0x5000: MOV EAX, [RIP + 0xFF6] ; RIP = 0x5006 ; 0x5006 + 0xFF6 = 0x6000 ✓ ; Same instruction bytes work at new location! ; The offset (0xFF6) is a RELATIVE distance, not an absolute address.; This distance is constant regardless of where the code+data load.x86-64 RIP-relative addressing:
The x86-64 architecture introduced RIP-relative addressing specifically to enable PIC. In 64-bit mode, most instructions that reference memory can encode addresses as offsets from the instruction pointer (RIP):
MOV RAX, [RIP + offset] ; Load from address (RIP + offset)
LEA RBX, [RIP + offset] ; Load the address itself into RBX
CALL [RIP + offset] ; Call function at (RIP + offset)
This was a deliberate design choice. The 32-bit x86 lacked good PC-relative data access, making 32-bit PIC more complex (requiring the GOT for most data access).
In 64-bit mode, absolute addresses would require 8 bytes each. RIP-relative offsets need only 4 bytes (±2 GB range), reducing code size. Combined with the PIC benefits, RIP-relative addressing was an obvious win for the x86-64 design.
Even with PC-relative addressing, there's a challenge: external symbols. A shared library doesn't know where other libraries or the main program are located. Their addresses are determined at load time.
The Global Offset Table (GOT) solves this. It's a data structure containing pointers to external data and functions. The code accesses external symbols indirectly through the GOT:
GLOBAL OFFSET TABLE (GOT) MECHANISM═══════════════════════════════════════════════════════════════════════════════ PROBLEM: How does library code access 'extern_var' defined in another module? The address of extern_var is unknown until load time! SOLUTION: The GOT (a table of addresses filled by the loader) ┌────────────────────────────────────────────────────────────────────┐ │ SHARED LIBRARY CODE │ │ │ │ function_in_lib: │ ┌─────│ ... │ │ │ ; Want to access extern_var (defined elsewhere) │ │ │ LEA R11, [RIP + got_offset] ; R11 = address of GOT │ │ │ MOV RAX, [R11 + extern_var@GOT]; Load pointer from GOT entry │ │ │ MOV EBX, [RAX] ; Dereference to get value │ │ │ ... │ │ │ │ │ └────────────────────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────────────────────┐ │ │ GLOBAL OFFSET TABLE (.got section) │ └────>│ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Entry 0 (dynamic): [pointer to dynamic linker] │ │ │ │ Entry 1 (link_map): [pointer to link map structure] │ │ │ │ Entry 2 (resolver): [pointer to lazy resolver] │ │ │ │ ... │ │ │ │ extern_var@GOT: [0x00007F1234567890] ← Filled by loader│ │ │ │ another_var@GOT: [0x00007F12DEADBEEF] ← Filled by loader│ │ │ │ ... │ │ │ └─────────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────────┘ │ │ Points to actual variable ▼ ┌────────────────────────────────────────────────────────────────────┐ │ ANOTHER MODULE (libc, main, etc.) │ │ │ │ .data section: │ │ ... │ │ extern_var: 42 ← The actual variable at 0x7F1234567890 │ │ ... │ │ │ └────────────────────────────────────────────────────────────────────┘ ═══════════════════════════════════════════════════════════════════════════════THE CODE IS POSITION-INDEPENDENT:═══════════════════════════════════════════════════════════════════════════════ 1. Code finds GOT using PC-relative offset (fixed at compile time)2. GOT contains actual addresses (filled by loader at runtime)3. Code uses indirect access through GOT entries The code section never changes → can be shared between processesThe GOT is per-process (each process has its own addresses)GOT characteristics:
| Property | Description |
|---|---|
| Location | Typically at a fixed offset from code (immediate after .text) |
| Content | Pointers to data symbols and function addresses |
| Writability | Writable (at least at load time for initialization) |
| Per-process | Each process has its own GOT with its own addresses |
| Security concern | GOT overwrites are a classic exploitation technique |
While the GOT handles data access, the Procedure Linkage Table (PLT) handles function calls to external code. The PLT enables lazy binding—delaying function address resolution until the function is actually called.
Why lazy binding?
A program might link against hundreds of library functions but only call a few during typical execution. Resolving all addresses at load time wastes time. Lazy binding resolves each function on first call.
PROCEDURE LINKAGE TABLE (PLT) - LAZY BINDING MECHANISM═══════════════════════════════════════════════════════════════════════════════ When your code calls an external function (e.g., printf): YOUR CODE: call printf@PLT ; Doesn't call printf directly! ; Calls the PLT entry for printf ═══════════════════════════════════════════════════════════════════════════════PLT STRUCTURE (x86-64):═══════════════════════════════════════════════════════════════════════════════ .plt section: PLT[0]: (common resolver entry) push [GOT + 8] ; Push link_map pointer jmp [GOT + 16] ; Jump to dynamic linker resolver printf@PLT (PLT entry for printf): jmp [printf@GOT] ; Jump through GOT entry push 0 ; Index of printf in relocation table jmp PLT[0] ; Jump to resolver ╔═══════════════════════════════════════════════════════════════════════════════╗║ FIRST CALL TO printf (lazy resolution): ║╠═══════════════════════════════════════════════════════════════════════════════╣║ ║║ 1. call printf@PLT ║║ │ ║║ ▼ ║║ 2. jmp [printf@GOT] → GOT entry initially points back to PLT (push instr) ║║ │ ║║ ▼ ║║ 3. push 0 ; Push relocation index ║║ jmp PLT[0] ; Go to resolver ║║ │ ║║ ▼ ║║ 4. Dynamic linker resolver: ║║ - Looks up printf address ║║ - WRITES actual address to printf@GOT ║║ - Jumps to printf ║║ ║║ printf@GOT: BEFORE: 0x400506 (points to push instruction in PLT) ║║ AFTER: 0x7F1234567890 (actual printf address) ║║ ║╠═══════════════════════════════════════════════════════════════════════════════╣║ SUBSEQUENT CALLS TO printf (fast path): ║╠═══════════════════════════════════════════════════════════════════════════════╣║ ║║ 1. call printf@PLT ║║ │ ║║ ▼ ║║ 2. jmp [printf@GOT] → GOT now contains actual printf address ║║ │ ║║ ▼ ║║ 3. printf() executes directly! (One extra indirect jump, that's all) ║║ ║╚═══════════════════════════════════════════════════════════════════════════════╝Binding modes:
| Mode | Description | When to Use |
|---|---|---|
| Lazy binding (default) | Resolve on first call | Most applications (faster startup) |
Immediate binding (LD_BIND_NOW=1) | Resolve all at load time | Security-sensitive, real-time systems |
| GNU_RELRO | Make GOT read-only after relocation | Security (prevents GOT overwrites) |
Since the GOT contains function pointers and is writable, attackers who can write to arbitrary memory often target GOT entries. Overwriting a GOT entry like printf with system address turns the next printf call into system call execution. RELRO (Relocation Read-Only) mitigates this by marking GOT read-only after relocation.
Modern compilers generate position-independent code using specific flags and techniques. Let's examine the compilation process:
GCC/Clang flags:
123456789101112131415161718192021
# Compile position-independent code (for shared libraries)gcc -fPIC -c mylib.c -o mylib.o # Create a shared library (implicitly uses PIC)gcc -shared -o libmylib.so mylib.o # Compile position-independent executable (for ASLR)gcc -fPIE -pie -o myprogram main.c # Flag explanations:# -fPIC: Generate position-independent code (large model, for shared libs)# -fpic: Generate position-independent code (small model, limited GOT size)# -fPIE: Generate position-independent code for executables# -pie: Link as a position-independent executable# -no-pie: Explicitly disable PIE (not recommended for security) # View relocation entries to verify PICreadelf --relocs libmylib.so # View GOT entriesobjdump -d -j .got libmylib.soCode generation differences:
Let's compare the assembly generated for position-dependent vs position-independent code:
; C SOURCE CODE:; extern int global_var;; int get_global() { return global_var; } ; ═══════════════════════════════════════════════════════════════════════════; NON-PIC (Position Dependent) - compiled without -fPIC; ═══════════════════════════════════════════════════════════════════════════ get_global: mov eax, DWORD PTR global_var ; Direct absolute address reference ; The address of global_var is embedded in instruction ret ; The instruction bytes contain the absolute address of global_var.; Requires relocation entry; cannot share code pages if address differs. ; ═══════════════════════════════════════════════════════════════════════════; PIC (Position Independent) - compiled with -fPIC; ═══════════════════════════════════════════════════════════════════════════ ; For global defined in SAME shared object on x86-64:get_global: mov eax, DWORD PTR [rip+global_var] ; RIP-relative access ; Accesses global_var at (current instruction address + offset) ret ; For global defined in DIFFERENT shared object:get_global: mov rax, QWORD PTR global_var@GOTPCREL[rip] ; Load GOT entry address ; GOT entry contains pointer to actual global_var mov eax, DWORD PTR [rax] ; Dereference ret ; The code uses only PC-relative addresses; no absolute addresses encoded.; Code section is identical for all processes → sharable!| Access Type | x86-64 PIC Technique | Overhead |
|---|---|---|
| Local (same object) data | RIP-relative addressing | None |
| Local (same object) function | RIP-relative call | None |
| External (other object) data | GOT (RIP-relative to GOT, then dereference) | 1 extra memory load |
| External (other object) function | PLT (indirect through GOT) | 1-2 extra jumps |
Historically, only shared libraries were position-independent. Main executables used fixed addresses. This created a security gap: the main program had predictable addresses even with ASLR for libraries.
Position-Independent Executables (PIE) apply the same techniques to the main program, enabling full ASLR:
PIE benefits:
12345678910111213141516
# Check if an executable is PIEfile /bin/ls# Output: ELF 64-bit LSB pie executable, x86-64, ...# ^^^# PIE = position-independent executable # OR using readelfreadelf -h /bin/ls | grep Type# Output: Type: DYN (Position-Independent Executable file)# ^^^# DYN = dynamically linked, PIE# (Non-PIE would show "EXEC") # Check with checksec (security tool)checksec --file=/bin/ls# Output shows: PIE: enabledPIE performance overhead:
| Architecture | PIE Overhead | Notes |
|---|---|---|
| x86-64 | ~0-2% | RIP-relative addressing is efficient |
| x86 (32-bit) | ~5-10% | Requires extra register for GOT base |
| ARM64 | ~0-1% | Good PC-relative support |
| ARM32 | ~3-5% | Moderate overhead |
The overhead on x86-64 is minimal because RIP-relative addressing was designed with PIC in mind. Most modern systems enable PIE by default.
As of 2020+, most major Linux distributions (Debian, Ubuntu, Fedora, Arch) compile packages with PIE by default. macOS has required PIE since 10.7 (2011). Android requires PIE for all APKs targeting API 21+ (2014). PIE is the modern default.
Relocatable code relies on relocation entries that describe how to patch or compute addresses. Different relocation types handle different scenarios:
COMMON x86-64 ELF RELOCATION TYPES═══════════════════════════════════════════════════════════════════════════════ R_X86_64_64 Formula: S + A Description: Absolute 64-bit address Usage: Rare in PIC; used in data sections for pointers R_X86_64_PC32 Formula: S + A - P Description: PC-relative 32-bit signed offset Usage: Function calls, RIP-relative data access (within ±2GB) R_X86_64_GOT32 Formula: G + A Description: 32-bit GOT entry offset Usage: Access GOT entry (relative to GOT base) R_X86_64_PLT32 Formula: L + A - P Description: PLT entry offset (PC-relative) Usage: Calling external functions through PLT R_X86_64_GOTPCREL Formula: G + GOT + A - P Description: 32-bit PC-relative offset to GOT entry Usage: Loading address of external symbol from GOT R_X86_64_GOTPCRELX / REX_GOTPCRELX Formula: G + GOT + A - P (with optimization) Description: Like GOTPCREL but linker may relax to direct access Usage: Optimized external variable access R_X86_64_GLOB_DAT Formula: S Description: Absolute address for GOT entry Usage: Linker fills GOT entries with symbol addresses R_X86_64_JUMP_SLOT Formula: S Description: PLT GOT entry for function Usage: Lazy binding target address in GOT Where: S = Symbol value (final address) A = Addend (constant offset from relocation entry) P = Place (address of location being relocated) G = GOT entry offset L = PLT entry addressViewing relocations:
12345678910111213141516
# View compile-time relocations in object file$ readelf -r myfile.o # View dynamic relocations in shared library$ readelf -r libmylib.so Relocation section '.rela.dyn' at offset 0x5a0 contains 8 entries: Offset Info Type Sym. Value Sym. Name + Addend0000000000200fd8 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterT...0000000000200fe0 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 00000000000201008 0000000800000005 R_X86_64_COPY 0000000000201008 global_var + 0 Relocation section '.rela.plt' at offset 0x628 contains 2 entries: Offset Info Type Sym. Value Sym. Name + Addend0000000000200ff0 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 printf@GLIBC_2.2.50000000000200ff8 0000000400000007 R_X86_64_JUMP_SLOT 0000000000000000 puts@GLIBC_2.2.5We've explored the techniques that enable code to run at any memory address. Let's consolidate the key concepts:
What's Next:
With relocatable code understood, we'll complete our address binding exploration with dynamic loading—the ability to load code at runtime, on demand. You'll learn about dlopen/LoadLibrary, runtime linking, plugin architectures, and how all these binding concepts come together in real systems.
You now understand relocatable code—from PC-relative addressing to the GOT and PLT mechanisms. You can explain why PIC enables code sharing, how lazy binding optimizes startup, and why PIE is essential for security. This knowledge is fundamental for understanding shared libraries, linkers, and secure system design.