Worked examples — C compilation — preprocessor, compiler, assembler, linker
The whole page hangs on one picture — which stage owns what. Here is what the figure below shows, in words, so you can follow it even without the image: across the top are the five files of the pipeline as labelled boxes, in order — .c, .i, .s, .o, exe — joined left-to-right by white arrows. Between each pair of files sits the coloured stage box that does that transformation: blue cpp (Preprocessor) turns .c into .i, yellow cc1 (Compiler) turns .i into .s, green as (Assembler) turns .s into .o, red ld (Linker) turns .o into exe. Dashed coloured vertical lines drop from each joint showing the stop-early flag that "snips" the pipeline there (-E, -S, -c, and none for the full build). Under each stage box is a short label naming the kind of error that stage owns ("missing paste", "grammar & types", "bad mnemonic", "empty/doubled box"). The italic caption spells the file trail I S O exe.

The scenario matrix
Every problem about C compilation is really the question: "At which stage does something happen (or break)?" Here is the full grid of case classes. Each cell is covered by a worked example below.
| # | Case class | What triggers it | Which stage owns it |
|---|---|---|---|
| A | Clean full build | valid, self-contained program | all 4 run, no error |
| B | Macro text-substitution trap | #define used naively |
Preprocessor |
| C | Conditional compilation | #ifdef toggles code |
Preprocessor |
| K | Missing header file | #include target not found |
Preprocessor |
| D | Syntax / type error | bad C grammar or types | Compiler |
| L | Malformed assembly | broken .s fed to as |
Assembler |
| E | Undefined reference | symbol used, never defined | Linker |
| F | Duplicate definition | same symbol defined twice | Linker |
| G | Missing library flag | library function used, -l absent |
Linker |
| H | Degenerate input: empty file | .c with nothing runnable |
Compiler/Linker boundary |
| I | Stop-early flags | -E, -S, -c |
pick your exit stage |
| J | Real-world multi-file build | split project + separate compile | Compiler then Linker |
We now walk them one at a time. Signs of trouble = the stage that complains. That mapping is the whole skill.
Case A — the clean full build
Step 1 — Preprocessor. What: #include <stdio.h> is replaced by the entire text of stdio.h, which contains the declaration int printf(const char*, ...);. The result — one .c plus all pasted headers — is the translation unit the compiler will read. Why this step? The compiler needs to know printf's shape (return type, argument types) to type-check the call — the header is that promise.
Step 2 — Compiler. What: real C → assembly. The call becomes a call printf instruction, but the address of printf is left blank — a hole marked "fill me later". Why: the compiler doesn't know where printf will live in memory; that's not its job.
Step 3 — Assembler. What: assembly → object file hello.o with a symbol table: main = defined, printf = undefined. Why: raw bytes are what the CPU eats, and the table records which boxes are still empty.
Step 4 — Linker. What: finds printf inside the C library (libc, linked automatically), fills the hole (this hole-filling is relocation — patching the real address into every blank), and adds startup code that calls main. Why: one runnable file needs every box filled.
Verify: main executes return 0;, so the exit status is 0 (success). Run ./hello; echo $? → prints hi then 0.
Case B — the macro substitution trap
Step 1 — Preprocessor expands the macro as pure text. What: every SQUARE(x) → x*x, and x → the literal text 3+1. So SQUARE(3+1) becomes the characters 3+1*3+1. Why this step? The preprocessor has no idea about arithmetic or types — it only swaps tokens. This is the single most important fact in the chapter.
Step 2 — Compiler now sees ordinary C. What: it evaluates 3+1*3+1 using normal precedence: * before +, so it is 3 + (1*3) + 1. Why: by the time the compiler runs, the macro is long gone — it only sees the expanded text.
Step 3 — Arithmetic. 3 + 3 + 1 = 7. Not 16.
Verify: 3+1*3+1 = 3+3+1 = 7. The fix is #define SQUARE(x) ((x)*(x)), which expands to ((3+1)*(3+1)) = 16. Both values are checked below.
Case C — conditional compilation
Step 1 — Preprocessor checks whether DEBUG is defined. What: -DDEBUG on the command line is exactly like writing #define DEBUG at the top. Why this step? #ifdef is a compile-time switch resolved by the preprocessor — the branch it rejects is deleted from the text entirely before the compiler ever sees it.
Step 2 — Two different source texts result.
- Without
-DDEBUG: the compiler sees onlyreturn 7;. - With
-DDEBUG: the compiler sees onlyreturn 42;.
Why: this is how you ship one source file but produce a debug build and a release build — nothing is decided at runtime.
Verify: plain build returns 7; -DDEBUG build returns 42. Both checked below.
Case K — missing header file (preprocessor error)
Step 1 — Preprocessor tries to paste the header and fails immediately. What: #include "helpers.h" is a command to find that file and paste its text here. The preprocessor searches the current directory and include paths, finds nothing, and stops with fatal error: helpers.h: No such file or directory. Why this step? Pasting headers is the preprocessor's first job; it cannot produce a translation unit with a piece missing, so it aborts before the compiler ever runs.
Step 2 — Nothing downstream executes. What: because there is no completed translation unit, the compiler, assembler, and linker never even start. Why: the pipeline is strictly ordered — a fatal error at stage 1 halts everything.
Verify (structural): the message is a preprocessor fatal error about a missing file, and the perfectly-valid main is never reached. Owner = preprocessor. Checked below.
Case D — a compiler-stage error
Step 1 — Preprocessor. What: no # directives → passes through untouched, producing a valid translation unit. Why: nothing for it to do; it never checks meaning.
Step 2 — Compiler catches BOTH issues. What:
int x = "hello";— a string literal has type "pointer to char", notint. Type mismatch.return x— missing semicolon;. Syntax error.
Why this step? The compiler is the only stage that understands grammar and types. These never reach the assembler or linker — the build stops here.
Verify (conceptual): the error message names the compiler (error:), not undefined reference. Different diagnostic text ⇒ different owning stage. This is the diagnostic-reading skill, checked structurally below.
Case L — malformed assembly (assembler error)
Step 1 — Preprocessor and compiler are bypassed. What: a .s file is already assembly, so gcc sends it straight to the assembler as. Why: there is no C text to expand or translate — those stages have nothing to do.
Step 2 — Assembler rejects the bad mnemonic. What: as reads each line and looks up its opcode in the instruction table. movqq is not a real instruction, so it reports Error: no such instruction: 'movqq $0,%rax' and produces no object file. Why this step? Encoding mnemonics into machine-code bytes is the assembler's sole job; an unknown mnemonic has no byte pattern to emit.
Step 3 — Linker never runs. What: with no .o produced, there is nothing to link. Why: the halted pipeline again — a stage-3 failure stops stage 4.
Verify (structural): the diagnostic is an assembler Error: no such instruction, distinct from a compiler error: and from a linker undefined reference. Owner = assembler. Checked below.
Case E — undefined reference (linker)
Step 1 — Compiler is satisfied. What: the prototype void foo(void); is a promise that foo will exist; the compiler type-checks the call foo() against it and emits a call foo with an empty address. Why: declarations exist precisely so the compiler can compile a call before seeing the definition.
Step 2 — Assembler. What: object file records foo = undefined in the symbol table. Why: the box is still empty.
Step 3 — Linker fails. What: it searches all object files and libraries for a defined foo, finds none, and reports undefined reference to 'foo'. Why this step? Resolving symbols is the linker's sole job; an unfillable box is fatal here.
Verify (structural): the failure text contains undefined reference and the exit is non-zero, marking the linker as owner. Checked below.
Case F — duplicate definition (linker)
Step 1 — Each file compiles cleanly on its own. What: gcc -c a.c and gcc -c b.c both succeed; each object file has counter = defined. Why: a single file sees no conflict — the other file doesn't exist to it yet.
Step 2 — Linker sees the collision. What: two objects both claim to define counter. The linker cannot pick a memory slot for a symbol defined twice → multiple definition of 'counter'. Why this step? Merging symbol tables is the linker's job; a duplicate strong definition is a contradiction.
Verify (structural): error text says multiple definition, owner = linker. The fix: define once, and use extern int counter; in the other file (a declaration, an empty box the linker fills from the one definition).
Case G — missing library flag (linker)
Step 1 — Header ≠ code. What: #include <math.h> supplies only the declaration of sqrt. The compiler is happy. Why: headers are promises, not deliveries (see the parent note's steel-manned mistake).
Step 2 — Linker needs the actual sqrt code. What: the compiled sqrt lives in the math library libm, which — unlike libc — is not linked automatically. -lm says "also search libm". Why this step? Without it, sqrt stays an undefined symbol and the linker reports undefined reference to 'sqrt' — a linker error, not a compiler one.
Step 3 — When linked correctly, evaluate the arithmetic. What: the square root of 16.0 is 4.0; the cast (int) truncates it to 4, which becomes the exit status. Why: only after -lm fills the sqrt box does the program run to compute a value.
Verify: (int)sqrt(16.0) = 4. Without -lm the failure text is a linker undefined reference to 'sqrt'; with -lm the program's exit status is 4. Both facts checked below.
Case H — degenerate input: the near-empty file
Step 1 — Preprocessor strips the comment. What: the resulting translation unit is empty. Why: comments never survive preprocessing.
Step 2 — Compiler + Assembler. What: an empty translation unit is legal C. gcc -c empty.c happily produces an empty.o that defines no symbols. Why: nothing to translate is not an error — just an empty object file.
Step 3 — Linker (full build only). What: linking needs an entry point main. There is none → undefined reference to 'main'. Why this step? The startup code always calls main; with main undefined, the box the linker must fill is empty.
Verify (structural): -c succeeds (exit 0), full build fails with undefined reference to 'main', owner = linker. This is Case E in disguise — the missing symbol is main itself. Checked below.
Case I — stop-early flags (choose your exit stage)
Step 1 — Map flags to exits. What:
| flag | stops after | file produced |
|---|---|---|
-E |
Preprocessor | .i (expanded C on stdout) |
-S |
Compiler | .s (assembly) |
-c |
Assembler | .o (object, not runnable) |
| (none) | Linker | executable |
Why this step? Each flag is a valve cutting the pipeline at one joint — invaluable for debugging which stage misbehaves. This is exactly the picture at the top of the page: each dashed "scissors" line is one of these flags snipping the pipeline after its stage.
Step 2 — Sanity mnemonic. The file trail spells I S O exe — "I Saw Our executable" (from the parent's mnemonic). The flag that stops at .o is -c, and .o is the last non-runnable file.
Verify (structural): the four flags map to {i, s, o, exe} in pipeline order. Checked below.
Case J — the real-world multi-file build
Step 1 — Compile each .o independently. What:
main.o:maindefined,addundefined (empty box).mathutil.o:adddefined (full box).
Why this step? Separate compilation means editing main.c later only needs main.c recompiled — the incremental build idea, and why make is fast.
Step 2 — Linker matches empty box to full box. What: add undefined in main.o is resolved to add defined in mathutil.o. Relocation patches the call address. Why: symbol resolution across objects is exactly the linker's purpose.
Step 3 — Runtime. main returns add(20, 22).
Verify: 20 + 22 = 42; exit status 42. Checked below.
Recall
Recall Which stage owns each error message?
fatal error: helpers.h: No such file or directory ::: Preprocessor (can't paste a missing header)
error: expected ';' ::: Compiler (syntax)
Error: no such instruction: 'movqq' ::: Assembler (unknown mnemonic, no byte pattern)
undefined reference to 'foo' ::: Linker (empty box, no definition)
multiple definition of 'counter' ::: Linker (two full boxes, same label)
undefined reference to 'sqrt' after including math.h ::: Linker — missing -lm library flag
undefined reference to 'main' from an empty file ::: Linker — no entry point defined
See also: Object Files and Symbol Tables, Static vs Dynamic Linking, C Preprocessor Directives, Assembly Language Basics, Compiler Optimization Levels (-O0..-O3).