On this page
Linking and Loading
After the compiler outputs
.ofiles, linking and loading are still needed before execution—relocation turns symbol references into addresses, PLT/GOT lazy binding resolves dynamic libraries only on the first call, and LTO defers cross-module optimization to link time for whole-program analysis.
Overview
After the compiler outputs .o (object files), two more steps are needed to produce a "runnable executable": linking (resolving symbol references from multiple .o files and libraries into addresses) and loading (mapping the executable into the process address space and jumping to the entry point after initialization). The linker does more than just "concatenating"—it performs relocation, garbage-collects unreferenced sections, and performs LTO (whole-program optimization across compilation units). This article follows the chain from "compiler output → static linking → dynamic linking → dynamic loading" to explain every layer of linking and loading mechanisms.
Compiler Output: Object Files (ELF)
A .o file (in ELF format) contains:
- .text: Machine code (code segment)
- .data: Initialized global variables
- .bss: Uninitialized (or zero-initialized) global variables — does not occupy file space; the OS maps zero pages during loading
- .rodata: Read-only data (string literals, floating-point constants)
- .symtab: Symbol table (symbols exported/imported by this file)
- .rela.text: Relocation table for the text section (which instructions reference external symbols and need patching by the linker)
- .rela.data: Relocation table for the data section
- .debug_info, .debug_line, ...: DWARF debug information
The final step of the compiler (the assembler) outputs .o. The linker reads the symbol tables of all .o files and libraries, performs relocations, and produces the executable.
Relocation: From "Symbol References" to "Addresses"
In the machine code of .o files, references to external symbols are filled with 0 or placeholders. .rela.text records these locations:
Typical relocation entry (R_X86_64_PC32):
Offset: 0x42 ← Location in .text that needs patching
Symbol: printf ← Which symbol is referenced
Type: R_X86_64_PC32 ← Relative jump (PC + offset)
Addend: -4 ← Compensation (PC in the instruction points to the next instruction, a difference of 4 bytes)
After assigning load addresses to all sections, the linker iterates through all relocation entries, calculates the final address of the target symbol, and rewrites the bytes at the target location according to the type-specific formula:
R_X86_64_PC32 patching formula: *(target) = sym_addr - (target_addr + 4) + addend
R_X86_64_64 patching formula: *(target) = sym_addr + addend (absolute address)
Static Linking: .a (archive)
A static library is a bundle of .o files (in ar format), extracted on demand during linking:
gcc main.o -L. -lmylib → The linker does:
1. Collect unresolved symbols from main.o
2. Search for each unresolved symbol in libmylib.a
3. If found → extract the corresponding .o from .a and add it to the link set
4. Extracted .o may introduce new unresolved symbols → recursive (or multi-pass scan)
5. All symbols resolved → perform relocation → output executable
The linker's symbol resolution is order-sensitive—traditional Unix linkers process .o and .a files from left to right. If libfoo.a is placed before the .o that references it, it might not be scanned (because there are no unresolved symbols requiring its symbols at that point). Modern linkers (LLD) provide --start-group / --end-group for iterative scanning to eliminate order issues.
Linker GC (dead section elimination)
Compilers generate .text section fragments on a per-function basis (gold and LLD support --gc-sections). The linker starts from the entry point (_start), traces call/jump references, marks unreferenced function sections as dead, and discards them. This reduces the final binary size—similar to DCE but across .o units.
Dynamic Linking: .so (shared object)
Dynamic libraries are not copied into the executable at link time; instead, dependencies are recorded at link time and loaded and dynamically resolved at runtime.
GOT and PLT: Two Layers of Lazy Binding
Target Code and ABI mentioned that PIC requires GOT. The complete picture for dynamic linking is GOT + PLT:
This is lazy binding: symbols in dynamic libraries are resolved only when they are first called, with GOT acting as a cache. The LD_BIND_NOW=1 environment variable forces all symbols to be resolved immediately at load time (not lazily)—this slows down startup but eliminates PLT overhead during execution.
Symbol Interposition
Dynamic linking supports symbol interposition: if multiple .so files define the same malloc, the definition from the first loaded one takes effect. Symbols defined in the executable override those in .so (default behavior), and LD_PRELOAD explicitly inserts symbols. This is the mechanism used by jemalloc/tcmalloc to replace the system malloc—but it is also a performance pitfall: interposition prevents the compiler from inlining malloc, causing every call to go through PLT.
dlopen: Runtime Loading
Programs can dynamically load arbitrary libraries at runtime:
void *handle = dlopen("libplugin.so", RTLD_NOW);
void (*fn)(int) = dlsym(handle, "plugin_init");
fn(42);
dlclose(handle);
JIT compilers commonly use dlopen to load compiled .so files—compiled code is written out as .o → linked into .so → dlopen → dlsym to call specific functions. V8 and Julia both use this approach—wrapping JIT-compiled machine code as .so and dynamically loading it. This is safer than directly writing to executable memory (the OS ensures NX/write protection for .text segments, eliminating the need for manual mprotect).
LTO (Link-Time Optimization): Whole-Program Optimization at Link Time
Traditionally, each .c (compilation unit) is compiled independently into .o, with optimization performed only within the unit—cross-unit function inlining, dead code elimination, and constant propagation cannot see other units. LTO defers optimization between compilation units to link time:
- ThinLTO: A scalable approach to whole-program optimization—instead of merging all IR into a single module (which would consume too much memory), it creates cross-module summaries based on "call relationships," optimizes each module independently, and performs inlining only for hot cross-module calls. ThinLTO is the default LTO mode for gold/LLD.
LTO has a significant impact on compilation time—the entire program's optimization is re-run at link time, potentially doubling link times for large projects. The benefits are also substantial: cross-module inlining eliminates call boundaries, typically yielding a 5–15% performance improvement.
References
- Levine: "Linkers and Loaders" — the classic textbook on linking and loading
- Ian Lance Taylor: "Linkers" series (https://lwn.net/Articles/276782/) — the author of the gold linker writes about linker principles
- System V AMD64 ABI: Dynamic linking section (the formal specification for GOT/PLT)
Keywords: static linking, dynamic linking, archive, .a, shared object, .so, ELF, .text, .data, .bss, .symtab, relocation, R_X86_64_PC32, GOT, PLT, lazy binding, _dl_runtime_resolve, symbol interposition, LD_PRELOAD, dlopen, dlsym, linker script, gc-sections, LTO, ThinLTO, link-time optimization, gold, LLD