5.3.1 · D3Build Systems & Toolchain

Worked examples — Compilation stages — preprocessing, compilation, assembly, linking

3,516 words16 min readBack to topic

This page is the "do it by hand" companion to the parent stages note. There, we saw the pipeline in theory. Here we run real programs through it and stop at each failure point, so you can name which stage broke before you even read the error.


The scenario matrix

Each row is a case class this topic can throw at you. Columns: which pipeline stage owns it, what the trigger looks like, and the example number that lands on it.

# Case class Owning stage Trigger you'll see Worked ex.
A Pure text substitution, all fine Preprocess #define, #include expand cleanly Ex 1
B Text substitution bites you (degenerate macro) Preprocess wrong answer, no error Ex 2
C Conditional compilation (block kept vs deleted) Preprocess #ifdef on/off Ex 3
D Language error caught early Compile expected ';', type mismatch Ex 4
E Constant folding + code generation Compile 2*3*7 becomes 42 in .s Ex 5
F Bad hand-written assembly mnemonic Assemble no such instruction Ex 6
G Missing definition across files Link undefined reference to 'x' Ex 7
H Missing library (-lm) Link undefined reference to 'sqrt' Ex 8
I Duplicate definition (degenerate: too many, not too few) Link multiple definition of 'x' Ex 9
J Exam twist: succeeds separately, fails together Link classic trap Ex 10
K Real-world word problem (multi-file build) All 4 end-to-end trace Ex 11
Recall Why these are the only cells that exist

Every build error must be raised by some stage, and there are exactly four stages. Preprocess errors are textual (A–C), compile errors are per-file language errors (D–E), assembly errors are bad mnemonics (F), link errors are cross-file symbol errors (G–I). J and K just combine those. There is no fifth kind of failure — that completeness is the whole point.

The figure below places each error family on the pipeline so you can see that every stage owns exactly one column of the matrix — refer back to it after each example to confirm which coloured box you just landed in.

Figure — Compilation stages — preprocessing, compilation, assembly, linking

Figure 1 — Four coloured boxes sit in a row, left to right, connected by grey arrows: Preprocess (blue, .c → .i), Compile (orange, .i → .s), Assemble (green, .s → .o), Link (red, .o → exe). Beneath each box a same-coloured arrow points down to the error family that stage owns: under Preprocess "Cells A/B/C — wrong text, no error"; under Compile "Cells D/E — expected ';', type error, folding"; under Assemble "Cell F — no such instruction"; under Link "Cells G/H/I/J — undefined / multiple reference". A caption across the top reads "one job per stage → one error family per stage." The picture is: every stage is a separate box, and every error family hangs beneath exactly one box — so the assembly box (green) has its own failure mode and is not folded into "compile".


Worked examples

Ex 1 — Clean text substitution (Cell A)

Forecast: guess the translation unit before reading on.

  1. Replace every MAX name token with 100. Why this step? #define MAX 100 registers a token-substitution rule; the preprocessor scans the token stream and swaps each matching whole-name token. → int arr[100];
  2. Replace every GREET name token with "hi". Why this step? Same rule engine, second macro. → char *g = "hi";
  3. Drop the #define lines. Why this step? Directives (lines starting #) never reach the compiler; they are the instructions to the preprocessor.

Result: the translation unit is

int arr[100];
char *g = "hi";

Verify: arr has 100 elements — a number, not the word MAX. If you saw MAX survive, you forgot that macros substitute matching name tokens. ✓


Ex 2 — Degenerate macro (text substitution bites) (Cell B)

Forecast: everyone says 4 (because "half of 8"). Write your guess down.

  1. Paste the argument tokens 4+4 in place of x, verbatim. Why? A function-like macro substitutes the argument's tokens as-is, so HALF(4+4) becomes the token sequence 4 + 4 / 2 — no parentheses were added because you didn't write any.
  2. Now let the compiler apply C precedence. Why? Once tokens are pasted, ordinary C math takes over. / binds tighter than +, so it reads as 4 + (4/2).
  3. Evaluate: .

Result: y == 6, not 4.

Verify: integer arithmetic . ✓ The fix is #define HALF(x) ((x)/2), which would give .


Ex 3 — Conditional compilation (Cell C)

Forecast: guess both outputs before the steps.

  1. #define DEBUG makes the name "defined" (even with no value). Why? #ifdef asks a yes/no question: "is this name defined?" — the value is irrelevant.
  2. #ifdef DEBUG is true → keep the first block, physically delete the #else block. Why? Conditional directives don't leave if-code in the program; they decide at paste time which text reaches the compiler.

Result with #define: the compiler only ever sees int mode = 1;. Result without #define: it only sees int mode = 0; — the other block is gone entirely, as if you never typed it.

Verify: the surviving value equals 1 when DEBUG is defined, 0 otherwise. Two mutually exclusive worlds, exactly one reaches the compiler. ✓ (See Preprocessor macros and include guards for how this powers include guards.)


Ex 4 — Language error, caught at compile (Cell D)

Forecast: name the stage before reading.

  1. Preprocessing runs fine. Why? There are no # lines and the text is well-formed as text. The preprocessor has no idea what a missing semicolon is.
  2. Compiler's parsing step fails. Why? Parsing builds the syntax tree; C grammar requires ; to end a statement. The parser reaches return while still expecting ;error: expected ';'.

Result: a compile-time error (stage 2), file-local, no other file involved.

Verify: by the 80/20 rule from the parent — expected ';' ⇒ compile stage. It cannot be a link error because linking never happens; compilation stopped first. ✓


Ex 5 — Constant folding + code generation (optimization visible in .s) (Cell E)

Forecast: guess how many multiply instructions the CPU actually performs.

  1. Semantic analysis sees all operands are literal constants. Why? No variables, no runtime input — the value is fully known at compile time.
  2. The optimizer folds the constant. Why this tool? "Constant folding" answers the question "can I compute this now instead of at runtime?" If yes, we save CPU cycles by doing the math once, in the compiler.
  3. Code generation emits the single precomputed value → mov eax, 42. Why this step? Code generation is the last part of the compile stage; it must produce assembly text that obeys the ABI. Since the ABI says the return value lives in eax, it writes the folded constant straight into eax — no multiply needed.

Result: the .s holds the literal 42, zero multiply instructions. (The assembler, stage 3, will later turn this mov eax, 42 text into bytes.)

Verify: , and the parent note showed the return value lands in eax by ABI. ✓


Ex 6 — Bad assembly mnemonic (assembler rejects it) (Cell F)

Forecast: it isn't a C file at all — so who complains, the compiler or the assembler?

  1. No preprocessing or C compilation happens. Why? The input is already a .s (assembly) file. gcc sees the extension and skips straight to the assembler — stages 1 and 2 are already done.
  2. The assembler tries to look up each mnemonic in its instruction table. Why? Its one job is to translate mnemonics into machine-code bytes; to do that it must recognise the mnemonic. movv is not in the table.
  3. It aborts: Error: no such instruction: 'movv eax,42'. Why this stage? Only the assembler validates that mnemonics are real CPU instructions — the compiler never checked, because it trusted whatever .s text it was handed.

Result: an assembly-time error (stage 3). No .o is produced, so there is nothing to link.

Verify: the error is neither expected ';' (compile) nor undefined reference (link); no such instruction is unique to the assembler. Fix the mnemonic to the real mov and the byte for that instruction is emitted. This is the matrix cell F that "compile-only" thinking misses. ✓


Ex 7 — Missing definition across files (Cell G)

Forecast: each gcc -c main.c succeeds. So which stage fails and why?

  1. Compile main.c alone: success. Why? The compiler only needs the declaration int helper(void); to type-check the call. It trusts that helper exists somewhere and emits a placeholder + a symbol-table entry "I need helper".
  2. Linking: failure. Why? The linker scans every object file and library for a matching "I define helper". It finds none → undefined reference to 'helper'.

Result: a link-time error (stage 4). The word "reference" is your clue — see Object files and symbol tables for the underlying symbol table.

Verify: compile stage passed (declaration was enough), link stage failed (no definition) — precisely the parent's Forecast-then-Verify scenario. ✓


Ex 8 — Missing library (Cell H)

Forecast: true or false — you must edit the #include?

  1. #include <math.h> supplies only the declaration of sqrt. Why? Headers are text with function signatures, not the compiled body. Compilation succeeds.
  2. The definition of sqrt lives in the math library libm. Why the error? By default gcc links libc but not libm. The linker never searched it → symbol unresolved.
  3. Fix at the link stage: gcc prog.c -o prog -lm. Why -lm? -l names a library; m means libm. Now the linker finds sqrt's definition.

Result: the header was correct; the missing piece was a library at link time, not a syntax fix.

Verify: the intended runtime value is , cast to int4. The bug was purely link-stage; the math itself is fine. ✓ (Static vs dynamic choice here: Static vs Dynamic Linking.)


Ex 9 — Duplicate definition (degenerate: too many) (Cell I)

Forecast: Ex 7 was too few definitions. Guess the stage for too many.

  1. Each file compiles fine. Why? Each .c in isolation defines counter exactly once — no conflict is visible file-locally.
  2. Linking fails. Why? When the linker merges symbol tables, it finds two "I define counter" entries for the same name. It cannot choose one address → multiple definition.

Result: a link-time error (stage 4), the mirror image of Ex 7. Fix: define it once (in a.c) and put extern int counter; in b.c.

Verify: Ex 7 = zero definitions (error), this = two definitions (error). The valid count is exactly one. Both are the linker's symbol-resolution job. ✓


Ex 10 — Exam twist: separate success, combined failure (Cell J)

Forecast: which command owns the failure?

  1. gcc -c main.c = preprocess + compile + assemble, then STOP. Why? The -c flag deliberately halts before linking. main.o is produced with an unresolved compute symbol — and that is completely legal for an object file.
  2. gcc main.o -o app = link only. Why? Now we demand a runnable file, so every symbol must be resolved. compute was never defined in any provided file/library → error.

Result: compilation genuinely succeeded; the link step failed because the definition of compute was never supplied. "It compiled" ≠ "it links".

Verify: with -c, an object file with unresolved externals is a valid output (Ex 7 logic). The error only fires at link time. So the student's premise is right and their conclusion is wrong: syntax was fine, a definition was missing. ✓


Ex 11 — Real-world multi-file build, end to end (Cell K)

Forecast: compute the exit value before tracing.

  1. Preprocess both files. Why? Expand #define N 5 in main.c → the call becomes square(5). math_utils.c has no directives; unchanged.
  2. Compile each translation unit → .s. Why? Each file separately type-checks. main.c trusts the declaration int square(int);; math_utils.c emits the real body (code generation writes the multiply and the ABI-mandated return in eax).
  3. Assemble each → .o. Why? The assembler turns each .s into bytes. main.o has "I need square"; math_utils.o has "I define square". Neither is runnable alone.
  4. Link. Why? The linker matches the need in main.o to the definition in math_utils.o, patches the address, produces app.

Result: ./app returns square(5) = 25.

Verify: . Every stage had exactly its one job (see Make and incremental builds for how a build tool automates re-running only the changed stages). ✓


Recap of the matrix

Recall Which stage owns each symptom?

Wrong value, no error, macro involved ::: Preprocessor (text substitution) — Cells A/B/C expected ';' or type mismatch ::: Compiler — Cell D Literal computed in the .s file ::: Compiler optimizer + code generation — Cell E no such instruction on a .s file ::: Assembler — Cell F undefined reference to 'x' (you forgot to define/link it) ::: Linker — Cells G and H multiple definition of 'x' ::: Linker, duplicate definition — Cell I Compiles per-file but fails when combined ::: Linker — Cell J

Note that Cells G and H are the same linker symptom (undefined reference) from two causes: G forgot a source-file definition, H forgot a library (-lm). Same message, same stage — you just supply the missing piece from a different place.


Connections