Relocation and Linking

The compiler's job ends with an object file — machine code for one source file, riddled with holes. It cannot possibly be finished code: when your main.o calls printf, the compiler has no idea what address printf will live at, because printf is in a different file it never saw. So it leaves a blank where the address goes and writes a note to itself: "fill this in later, once you know where printf ended up." Linking is the phase that reads all those notes across every object file and library, decides the final memory layout, and relocates — patches every blank with a real address — to produce a running executable.

It is the same trick as backpatching, promoted from within one function to across an entire program: emit now, record the hole, fill it when the address is known. Understanding it explains a dozen everyday mysteries — "undefined reference" errors, why a static binary is huge and a dynamic one tiny, what a .so or DLL actually is, and why the same shared library can sit at a different address in every process.

What's in an object file

An object file (ELF on Linux, Mach-O on macOS, COFF/PE on Windows) is more than raw bytes. It carries three things the linker needs:

The linker's two jobs fall straight out of this. Symbol resolution: match every undefined reference to a definition somewhere in the other object files and libraries (fail, and you get the dreaded "undefined reference to printf"). Relocation: now that every symbol has a final address, walk the relocation entries and write those addresses into the holes.

From object files to an executable

Watch the pipeline. Two object files each define some symbols and reference others; the linker lays their sections end to end (assigning real addresses), resolves the cross-references, and patches the holes.

Once main.o's .text is placed at, say, address 0x400000 and util.o's at 0x400080, the linker knows helper lives at 0x400080 and can finally fill the blank in main's call helper. Every hole becomes a number; the result is an executable with no undefined symbols left.

Absolute vs relative relocations

Not all holes are patched the same way, and the difference is the crux of modern code. An absolute relocation writes the symbol's actual address into the hole: \textit{value} = S (plus an addend). Simple — but it bakes a fixed address into the code, so the code only works if loaded at exactly the address the linker assumed.

A relative (PC-relative) relocation instead writes the distance from the patch site to the target: \textit{value} = S + A - P, where S is the symbol's address, A an addend, and P the address of the hole itself. This is the magic ingredient of position-independent code (PIC): if the whole block moves, the site P and the target S move together, so the difference is unchanged and the same bytes work at any load address. That is why a shared library can be mapped wherever the loader likes.

Relocation entry: { offset: 0x12, symbol: "helper", type: R_X86_64_PC32, addend: -4 } absolute (R_X86_64_64): patch = S + A // the target's real address relative (R_X86_64_PC32): patch = S + A - P // signed distance from the hole to the target

Static vs dynamic linking

The final fork. Static linking copies the machine code of every library function you use into the executable at link time and relocates it there. The binary is self-contained and needs nothing at run time — but it is large, and every program that uses printf carries its own private copy, wasting disk and RAM.

Dynamic linking leaves the library out and records only that the program needs, say, libc.so. The loader maps the shared library into the process at run time and fixes up the references then. One copy of libc in memory is shared by every process — smaller binaries, and a security fix in the library helps everyone at once. The cost is a little start-up work and a layer of indirection, which has two famous names:

Static linkingDynamic linking
When resolvedat link timeat load / call time
Library codecopied into the binaryshared .so/DLL, one copy in RAM
Binary sizelargesmall
Indirectionnonevia the GOT and PLT
Library updaterelink requiredswap the .so

The indirection works through two tables. The GOT (Global Offset Table) is an array of pointers, filled in with real addresses at load time; code reaches a global or function through its GOT slot rather than by a baked-in address, so only the small GOT needs fixing up, not the code. The PLT (Procedure Linkage Table) adds lazy binding: the first call to a dynamic function jumps through a PLT stub that asks the loader to resolve the symbol and cache its address in the GOT; every later call flies straight through the now-filled GOT slot. Cheap start-up, one-time cost per function actually used.

Applying relocations, in code

Here is a miniature linker's relocation pass. We place two object files' sections at chosen base addresses, build a symbol table of final addresses, then walk each relocation entry and compute the patch value — absolute or PC-relative.

type Sym = { name: string; section: string; offset: number }; type Reloc = { section: string; offset: number; symbol: string; kind: "ABS" | "PC32"; addend: number }; // Final base address assigned to each section by the linker's layout. const sectionBase: Record<string, number> = { "main.text": 0x400000, "util.text": 0x400080, }; // Symbols defined across the object files (section-relative). const defs: Sym[] = [ { name: "main", section: "main.text", offset: 0x00 }, { name: "helper", section: "util.text", offset: 0x00 }, { name: "logmsg", section: "util.text", offset: 0x30 }, ]; const addrOf = (name: string) => { const s = defs.find((d) => d.name === name)!; return sectionBase[s.section] + s.offset; // resolve to a final absolute address }; // Holes to patch inside main.text. const relocs: Reloc[] = [ { section: "main.text", offset: 0x12, symbol: "helper", kind: "PC32", addend: -4 }, { section: "main.text", offset: 0x20, symbol: "logmsg", kind: "ABS", addend: 0 }, ]; for (const r of relocs) { const S = addrOf(r.symbol); // target address const P = sectionBase[r.section] + r.offset; // address of the hole itself const patch = r.kind === "ABS" ? S + r.addend : S + r.addend - P; const hex = (patch >>> 0).toString(16); console.log(`patch ${r.section}+0x${r.offset.toString(16)} (${r.symbol}, ${r.kind}) = 0x${hex}`); }

The helper call gets a small PC-relative displacement (target minus the site minus 4), exactly the encoding an x86-64 call rel32 wants; the logmsg reference gets the full absolute address 0x4000b0. Change a section base and the relative patch adjusts itself while staying correct — the whole point of position independence.

Two reasons, one old and one about security. The old reason: a shared library must coexist with whatever else a process has mapped, and different programs have different memory maps, so the loader picks whatever free region fits — which is only possible because the library is position-independent (all those PC-relative relocations and GOT indirections mean the code does not care where it lands). The modern reason is ASLR — Address Space Layout Randomisation. The loader deliberately randomises the base address of the executable, libraries, stack and heap on every run, so an attacker cannot predict where system() or a useful gadget will be. ASLR is only feasible because relocation already made code relocatable: the security win rides for free on machinery the linker built for sharing. Position-independence started as a memory-saving convenience and became a cornerstone of exploit mitigation.

The classic beginner confusion is to see undefined reference to 'sqrt' and hunt for a syntax mistake. There isn't one — your code compiled fine. The message comes from a later phase, the linker, and it means resolution failed: some object file referenced sqrt (left a relocation hole for it) but no object file or library on the link line defined it, so the hole cannot be filled. The fix is never in the source; it is telling the linker where the definition lives — link the maths library (-lm), add the missing .o, or fix library order (linkers scan left to right, so a library must come after the object that needs it). The mirror error, "multiple definition", is the opposite: two files defined the same symbol and the linker can't choose. Compilation checks one file's grammar; linking checks that the whole program's symbols line up.