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
.so or DLL
actually is, and why the same shared library can sit at a different address in every process.
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:
.text (instructions),
.data (initialised globals), .bss (zero-initialised) — each still assuming
it starts at address 0.
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.
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 util.o's at helper lives at main's call helper. Every hole becomes a number; the result is an executable
with no undefined symbols left.
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:
A relative (PC-relative) relocation instead writes the distance from the patch
site to the target:
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 linking | Dynamic linking | |
|---|---|---|
| When resolved | at link time | at load / call time |
| Library code | copied into the binary | shared .so/DLL, one copy in RAM |
| Binary size | large | small |
| Indirection | none | via the GOT and PLT |
| Library update | relink required | swap 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.
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.
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
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.