Visual walkthrough — Compilation stages — preprocessing, compilation, assembly, linking
Step 1 — Start: two piles of raw text
WHAT. Before anything runs, all we have is characters in two files. A file called main.c and a file called helper.c. The computer does not understand either. To it, int is just the bytes i, n, t — no meaning yet.
WHY show this. Every later stage removes one layer of "human-ness". To appreciate what each stage does, you must first see the fully-human starting point: text a person typed, with #-lines, comments, and English-like keywords.
PICTURE. Two peach cards, each holding plain text. Notice main.c contains only a declaration of helper (a promise), not its body. The body lives in the other file. That separation is the whole reason the linker will exist.

Step 2 — Preprocessing: text gets pasted and substituted
WHAT. Each .c file is fed to the preprocessor. It obeys every line starting with #, and it strips comments. For our files there are no #include or #define lines, so the main visible change is: comments vanish and each file becomes a clean translation unit — pure C with zero # lines.
Reading the arrow term by term: the thing on the left is what you typed; the label -E over the arrow is the flag that says "stop right after preprocessing"; the thing on the right, ending in .i, is the translation unit — the real input the compiler will see.
WHY this stage first. Text edits (pasting headers, expanding macros) have nothing to do with the C language — they are pure find-and-replace on characters. Doing them first means the compiler never has to think about # lines at all. One job per stage.
PICTURE. Watch the comment line dissolve and the #-machinery (shown as a gear) chew the text. If we had a #define, this is exactly where SQ(3+1) would blindly become ((3+1)*(3+1)) — see Preprocessor macros and include guards for that pure-text-paste behaviour.

Step 3 — Compilation: C becomes assembly
WHAT. The compiler takes each translation unit (.i) and produces assembly (.s) — a human-readable list of CPU instructions for one specific chip family (here x86-64). Along the way it checks types and optimizes.
For main.i, roughly:
main:
mov edi, 2 ; put the argument 2 into the "first-argument" register
call helper ; jump to helper (address UNKNOWN for now)
ret ; hand back whatever helper returnedRead it symbol by symbol:
main:— a label, a human name for a memory spot.mov edi, 2— copy the number2into registeredi, the slot the ABI reserves for a function's first argument.call helper— go runhelper. Crucial: the compiler writes the namehelper, not an address. It cannot know wherehelperwill end up, becausehelperlives in a different file being compiled separately.ret— return to whoever calledmain.
WHY assembly and not straight to bytes? Because a readable intermediate lets us inspect, optimize, and target different CPUs from the same front-end. Splitting "understand C" from "emit bytes" keeps each half simple.
WHY the name, not an address? This is the seed of the whole linking idea: main.c and helper.c are compiled in total isolation. Neither knows the other's memory layout. So helper stays as an unresolved name — a promise to be kept later.
PICTURE. The compiler is shown as a translator turning C into the shorthand .s. Highlight in magenta the call helper line with a big question mark on the address — that "?" is the debt the linker must repay in Step 5.

Step 4 — Assembly: mnemonics become an object file
WHAT. The assembler turns the human-readable .s into raw machine code — the actual opcode bytes the CPU executes — packaged as an object file (.o). Each of our two .s files becomes its own .o.
Both arrows do the same job on different inputs: turn instruction text into instruction bytes.
WHY still not runnable? Because main.o contains that call helper whose target address is still unknown. The assembler leaves a note — a relocation entry — that says: "at this byte position I wrote a placeholder; whoever knows helper's real address, patch it in here."
PICTURE. Two object boxes. main.o carries a symbol table: defines main, needs helper (undefined, in magenta) — plus a relocation arrow pointing at the placeholder bytes. helper.o carries: defines helper (in green). Notice they are mirror images: one needs exactly what the other defines.

Step 5 — Linking: promises kept, addresses patched
WHAT. The linker takes all the .o files (plus any libraries), lays them out in one memory map, then resolves symbols: for every "needs helper", it hunts for a matching "defines helper", and writes that real address into the placeholder.
Term by term: the + means pool all object files together; the linker matches the need on the left to the define in the middle; the result on the right has every placeholder filled — nothing unresolved remains, so the CPU can run it start to finish.
WHY this is a separate stage. Because you often compile files at different times (that's what Make and incremental builds exploits — recompile only what changed). Symbol resolution must wait until all pieces exist. Whether the library code is copied in or merely referenced is the Static vs Dynamic Linking choice.
PICTURE. The magenta placeholder from main.o gets an orange arrow drawn to the real address of helper inside the merged layout. The question mark from Step 3 is finally replaced by a concrete address.

Step 6 — Edge case: the promise nobody keeps
WHAT. Suppose you compile only main.c and forget helper.c. Every earlier stage still succeeds: preprocessing, compiling, and assembling main.c never needed helper's body — only its declaration (the promise). The failure appears only at link time:
undefined reference to `helper'
WHY here and nowhere earlier. Trace the debt: the compiler wrote the name helper (Step 3), the assembler left a relocation note (Step 4). Now the linker searches every object file and library for a define of helper, finds none, and cannot fill the placeholder. An unfillable placeholder = an unrunnable file = an error — but a link error, never a compile error.
PICTURE. main.o reaches out with its "needs helper" arrow into empty space — no helper.o on the table. The dangling arrow is the error.

The one-picture summary
WHAT. One factory line: two text files enter on the left; at each station a layer of human-ness is stripped and a bit more machine-ness is added; on the right a single file emerges that returns 42. The undefined-helper arrow shows exactly where a broken build snaps.

Recall Feynman retelling — the whole walkthrough in plain words
You wrote two half-recipes. One says "make the sauce (I promise a sauce exists)"; the other actually is the sauce recipe. Preprocess: a helper tidies each page, pasting in referenced notes and erasing your margin scribbles — now they're clean pages. Compile: a translator rewrites each clean page into the chef's shorthand, but where your page says "use the sauce", the translator just writes the word "sauce", because it can't see the other kitchen. Assemble: someone stamps each shorthand page into exact robot button-presses, leaving a sticky-note "fill in the sauce's location later". Link: the head chef gathers every page and every sauce, figures out where each lives, and writes the real locations onto the sticky-notes — producing ONE cookbook the robot runs start to finish, returning 42. If the sauce recipe was never handed in, the head chef stops and shouts "undefined reference to sauce!" — and only then, never earlier, because up to that point nobody actually needed the sauce's body, only your promise that it existed.
Connections
- Build Systems & Toolchain
- Object files and symbol tables
- Static vs Dynamic Linking
- Make and incremental builds
- ABI and calling conventions
- Preprocessor macros and include guards