19 min read #compilers #Runtime
On this page

Linking and Loading

After the compiler outputs .o files, 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:

GOT + PLT Lazy Binding: First call is slow, subsequent calls jump directly (lazy binding)

First call to printf (slow path: goes through dynamic linker resolution) call printf@plt (PLT stub) → Check GOT[printf] (unresolved) jmp _dl_runtime_resolve Dynamic linker searches symbol table → finds address Update GOT[printf] = Real address → jmp printf

Nth call (hit path: GOT already has real address) call printf@plt Same PLT stub GOT[printf] already has real address → jmp printf, no extra overhead

GOT acts as a cache: the first call must go through the full resolution of the dynamic linker and write back to GOT; subsequent calls directly read GOT to hit the real address and jump—that's the time-saving aspect of lazy binding.

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 .sodlopendlsym 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).

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:

LTO: Gather IR at link time for whole-program optimization, rather than independent per-unit optimization at compile time

Normal Compilation: Each compilation unit optimizes independently; the linker only concatenates .c opt Intra-unit optimization .o Machine code Linker Concatenate

LTO: .c produces IR instead of machine code; the linker merges them for whole-program optimization .c IR (not machine code) Stored in .o (bitcode) Linker collects IR from all .o files Merge into one module opt→codegen→link

LTO defers optimization to link time: the linker can see all IR and perform cross-module inlining/DCE that normal compilation cannot; the cost is longer link times (possibly doubling for large projects), in exchange for a 5–15% performance improvement.
  • 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