Where we are: a pile of finished-but-unconnected pieces
From guide 1 you already know the linker is the fourth and final build stage, and from guide 3 you know what it is handed: a set of object files, each one real machine code that is complete but not connected. Each `.o` carries a symbol table — a list of the names it defines (functions and globals whose code or data it actually contains) and the names it merely uses but does not have. That second list is the set of undefined symbols: a hole that says, in effect, "I call something named `printf`, but I don't know where it is."
So the linker's input is genuinely a jigsaw: every piece is fully formed inside, but the edges have labelled tabs that must click into the labelled slots of other pieces. The linker is the only program in the whole separate-compilation flow that ever sees all the pieces at once. The compiler worked on each translation unit in total isolation, trusting that the holes would be filled by someone later. The linker is that someone. Its entire purpose is to turn many self-contained `.o` files into one whole, and it does this with exactly two operations: symbol resolution and relocation.
Job one — resolution: matching every name to its one definition
Symbol resolution is bookkeeping, and you can do it in your head. The linker reads every object file and builds one master ledger of symbols. For each name, it asks a simple question: who defines this, and who only uses it? Every undefined symbol in one `.o` must be matched to a defined symbol in some other `.o` or library. When your `main.o` says "I need `helper`" and your `helper.o` says "I define `helper`," the linker draws the line between them. That line is the resolution. Do it for every undefined name, across every input file, and the puzzle's tabs are all paired with slots.
Two failures fall straight out of this ledger, and they are the two most common linker errors. If a name is used but defined nowhere, resolution fails with `undefined reference to 'helper'` — you called it, but no piece supplies it (you forgot to compile `helper.c` in, or to link the library that holds it). If a name is defined in two places at once — say you accidentally put a non-`static` global in a header that two `.o` files both include — resolution fails the other way, with `multiple definition of 'count'`. Resolution insists on exactly one definition per name: not zero, not two.
Job two — relocation: every piece thought it lived at address zero
Resolution only paired names; it did not yet decide where anything lives. Here is the problem relocation solves. When the assembler made each `.o`, it had no idea what other files it would be joined with, so it wrote each one as if it would sit at address 0x0 — its functions start at offset 0, its globals at small offsets, all measured from a private zero. Now the linker has several such pieces, and they obviously cannot all occupy address 0 at once. So the linker first lays them out end to end, deciding the final layout: `main`'s code at one base address, `helper`'s code right after it, the globals in a data region of their own.
But moving a block of code changes every address baked inside it. If `main`'s code is shifted to start at 0x4000, then a `call helper` instruction that was encoded assuming a base of 0 is now pointing at the wrong place. This is what relocation fixes. Every `.o` carries, alongside its symbol table, a list of relocation entries: each one is a note that says "at byte offset such-and-such inside my code, there is an address that depends on the final layout — once you know where `helper` ended up, come back and patch this number." The linker walks that list and rewrites each of those slots with the now-known final address.
before linking -- main.o, written as if based at 0x0:
0x0000: ... call <helper> ; target = 0x0000, RELOC: "patch to helper"
0x0005: ...
the linker chooses the final layout:
main placed at base 0x4000
helper placed at base 0x4020
after relocation -- the call slot is rewritten:
0x4000: ... call 0x4020 ; hole filled in: helper's real address
0x4005: ...Putting it together: from .o files to one executable
So the linker's whole run, for the simplest case of static linking, is just these moves in order. Picture three inputs — your `main.o`, your `helper.o`, and the C library's object holding `printf` — flowing through the same little assembly line the linker repeats every time.
- Collect every input object file (and pull from libraries any object that defines a still-needed symbol) into one combined symbol ledger.
- Resolve: match each undefined symbol to exactly one definition; abort with 'undefined reference' if none exists, or 'multiple definition' if two do.
- Lay out: choose a final address for each block of code and data, packing the pieces into the executable's sections (code in one region, initialised data in another).
- Relocate: walk every relocation entry and patch each address slot with the real, now-known final address.
- Write out one file in the platform's executable format, recording where execution should begin (the entry point).
The file that comes out is not just a bag of bytes — it is structured in a defined executable file format (ELF on Linux, Mach-O on macOS, PE on Windows). That format records the separate regions the linker built (the code region, the read-only data, the writable globals) and, crucially, the single starting address. When you later run `$ ./a.out`, the loader from guide 1 reads exactly this format to know what to copy into memory and where to jump. Linking writes the map; loading follows it.
Static versus dynamic: link it in now, or find it at launch
Everything so far assumed static linking: the linker copies the library's actual code into your executable, so the finished file is self-contained and `printf`'s machine bytes physically live inside your `a.out`. Pulling code from a static library (a `.a` file, which is really just an archive of `.o` files) works exactly as described above — resolve, relocate, copy in. The upside is a single file that needs nothing else to run. The downside is size and staleness: every program that uses `printf` carries its own private copy, and a bug fixed in the library does not reach you until you relink.
Dynamic linking splits the job in two. At build time the linker does only the bookkeeping half: it confirms the symbol exists in a shared library and records a promise — "this program needs `libc`'s `printf`" — but copies no code in. The real resolution is deferred until the program launches, when a piece of the loader (the dynamic linker) finds the shared library already in memory, maps it in, and wires up the calls then. This is why one copy of the C library on disk can be shared by every running program at once, and why a security patch to that one file fixes everyone the moment they restart — no relinking needed.
Neither choice is free, and it is worth being honest about the trade. Static linking buys a self-contained, predictable binary at the cost of disk space and update friction. Dynamic linking buys shared memory, smaller files, and centralised updates at the cost of a new failure mode: the program can build perfectly yet refuse to start because the shared library it was promised isn't found at launch — the dreaded `error while loading shared libraries`. That error is the dynamic linker's version of `undefined reference`, just arriving at run time instead of build time. Same idea — a name with no definition — different moment.