5.1.1 · D4C Programming

Exercises — C compilation — preprocessor, compiler, assembler, linker

2,920 words13 min readBack to topic

Before we start, one shared picture of the whole pipeline — every exercise lives somewhere on this line.

Figure — C compilation — preprocessor, compiler, assembler, linker

Level 1 — Recognition

Goal: name the tool, name the file, no reasoning chains yet.

L1.1 — Match tool to job

For each job, name the single tool responsible: (a) pastes a header file's text in place; (b) turns mov/add mnemonics into raw opcode bytes; (c) resolves printf to its real code in libc; (d) type-checks your C and emits .s.

Recall Solution

(a) Preprocessor (cpp) — text substitution of #include. (b) Assembler (as) — mnemonics → machine code. (c) Linker (ld) — matches undefined symbols to definitions. (d) Compiler (cc1) — parses, type-checks, emits assembly. What it looks like: point at each numbered arrow in the s01 figure. #include sits on arrow ① (cpp), mnemonics→bytes on arrow ③ (as), symbol resolution on arrow ④ (ld), type-checking on arrow ② (cc1).

L1.2 — File-form order

Put these file extensions in the order they are produced: .o, .i, .s, .c, executable.

Recall Solution

Why this order: each stage lowers the level by exactly one step. You cannot skip: the assembler needs .s, and .s only exists after the compiler ran. Mnemonic: "I Saw Our executable" — i, s, o, exe.

L1.3 — Flag to stopping point

Name the gcc flag that stops the pipeline right after each stage: preprocessing / compiling / assembling / (no flag).

Recall Solution

gcc -E ::: stop after preprocessing → .i gcc -S ::: stop after compiling → .s gcc -c ::: stop after assembling → .o (not runnable) gcc (no flag) ::: run the whole pipeline → executable Picture: each flag is a "STOP HERE" gate drawn under its file box in the s01 figure.


Level 2 — Application

Goal: run flags on concrete input and predict exact output.

L2.1 — Predict preprocessor output

Given:

#define PI 3.14
#define AREA(r) (PI*(r)*(r))
// a circle
double a = AREA(2);

Write the exact source after gcc -E (ignore the pasted stdio boilerplate; just the shown lines).

Recall Solution

The comment is stripped, and PI and AREA(r) are substituted as text only:

double a = (3.14*(2)*(2));

That is the entire output of gcc -E: a string of characters. The preprocessor does not multiply anything — the file still literally contains (3.14*(2)*(2)), not 12.56. (Only much later, at compile or run time, will something evaluate that expression to .) Why parentheses matter here: even with argument 2 there is no ambiguity, but the macro author wrapped PI*(r)*(r) defensively so that AREA(1+1) would also work. Text substitution has no idea what a "number" is.

L2.2 — Which flag for which file?

You currently have math.c. You want, in three separate commands: (a) only math.i, (b) only math.s, (c) only math.o. Write the commands.

Recall Solution
gcc -E math.c -o math.i    # (a) preprocessed source
gcc -S math.c              # (b) produces math.s automatically
gcc -c math.c              # (c) produces math.o automatically

Note: -S and -c name the output file for you (.s, .o); -E prints to stdout unless you redirect with -o.

L2.3 — Count the stages a command runs

How many of the 4 tools does gcc -c file.c actually invoke?

Recall Solution

Three: preprocessor → compiler → assembler. It skips the linker, which is exactly why the result (.o) is not runnable. Picture: on the s01 figure, -c walks arrows ①②③ and stops before arrow ④.


Level 3 — Analysis

Goal: read an error / behaviour and deduce which stage produced it and why.

L3.1 — Which tool complained?

Classify each message by the tool that emits it: (a) expected ';' before 'return' (b) undefined reference to 'sqrt' (c) 'printf' undeclared (first use in this function) (d) multiple definition of 'globalCounter'

Recall Solution

(a) Compiler (cc1) — a syntax error; the parser choked. (b) Linker (ld) — a definition was never found among your .o files and libraries. (c) Compiler (cc1) — a declaration is missing (you forgot #include <stdio.h>), caught during type-checking, not linking. (d) Linker (ld) — two object files each defined the same symbol; the linker cannot choose. Key distinction: undeclared (compiler: "I don't know this name's signature") vs undefined reference (linker: "I know the signature, but no code exists"). See Object Files and Symbol Tables.

L3.2 — The sqrt mystery

#include <math.h>
int main(){ double r = sqrt(2.0); return (int)r; }

This compiles but fails at link with undefined reference to 'sqrt'. Why does including math.h not fix it, and what single flag does?

Recall Solution

math.h supplies only the declaration (the promise "double sqrt(double) exists"). That satisfies the compiler. But the compiled code for sqrt lives in the math library libm, which is not linked by default. You must tell the linker to include it:

gcc prog.c -lm

Header = promise; library = delivery. This is the parent note's rule made concrete. Contrast with printf: its code is in libc, which is linked automatically.

L3.3 — Trace the double increment

#define SQ(x) ((x)*(x))
int i = 5;
int y = SQ(i++);

What is y, and what is i afterwards? Explain via the expanded text.

Recall Solution

Substitution gives int y = ((i++)*(i++));. The macro duplicated i++, so i would be incremented twice in one expression with no sequence point between them. That makes this undefined behaviour: the C standard guarantees nothing about the result — the value of y, the final value of i, and even whether the program crashes are all unspecified. Any number you happen to observe (some compilers print 30, others 36, others something else) is incidental, never something you may rely on. The only correct answer is: the behaviour is undefined; do not write this. The lesson: a macro is text, not a function. A function would evaluate i++ once into a temporary; the macro pastes it verbatim. This is why side-effecting arguments break macros — see C Preprocessor Directives.


Level 4 — Synthesis

Goal: assemble a full multi-file build and reason about incremental rebuilds.

L4.1 — Build a two-file program by hand

You have main.c (calls greet()) and greet.c (defines greet()). Write the commands to (a) produce both object files separately, then (b) link them into ./app.

Recall Solution
gcc -c main.c        # -> main.o  (greet is UNDEFINED here)
gcc -c greet.c       # -> greet.o (greet is DEFINED here)
gcc main.o greet.o -o app   # linker resolves greet, makes ./app

What happens at link: main.o's symbol table lists greet as undefined; greet.o lists it as defined. The linker matches them, performs relocation (assigns final addresses), and staples on the _start entry code that calls main.

L4.2 — Incremental rebuild cost

After building app above, you edit only greet.c. Which of the four stages must re-run, and on which files?

Recall Solution

Only greet.c changed, so:

  • Re-run preprocessor + compiler + assembler on greet.c → new greet.o.
  • main.o is untouched and reused.
  • Re-run the linker on main.o + new greet.o → new app.
gcc -c greet.c        # rebuild only this object
gcc main.o greet.o -o app   # relink

Why this is fast: we skipped recompiling main.c entirely. This is the whole reason object files exist and the engine behind Makefiles and Incremental Builds.

L4.3 — Where does optimization live?

You compile with gcc -O2 -c hot.c. Which stage does -O2 affect, and does it change the .o's symbol table?

Recall Solution

-O2 affects the compiler stage (cc1) — it rearranges/eliminates instructions to produce faster assembly. See Compiler Optimization Levels (-O0..-O3). It does not change which symbols are defined/undefined (the interface), so the symbol table's names are the same — the machine code behind them is just faster/smaller. That is why you can optimize one .o without re-linking logic changing.


Level 5 — Mastery

Goal: subtle, whole-picture cases — sign/degenerate/limiting analogues of "all cases".

L5.1 — The static symbol

// util.c
static int helper(){ return 42; }

main.c writes int x = helper();. Predict the exact stage and error.

Recall Solution

static at file scope gives the symbol internal linkagehelper is not exported in util.o's symbol table. So main.o has helper as undefined, and no .o provides it publicly. Result: linker error, undefined reference to 'helper'. The compiler is fine (it trusts the declaration in main.c). The subtlety: the code exists in util.o, but is deliberately hidden. This is the "definition present but invisible" degenerate case — different from "definition absent." See Object Files and Symbol Tables.

L5.2 — Static vs dynamic delivery

Two builds of the same program: gcc app.c -o app_dyn (default) and gcc app.c -static -o app_stat. In which build does printf's machine code physically sit inside the executable file, and which needs libc present at run time?

Recall Solution
  • app_stat: the linker copies printf's code from libc.a into the executable → it is self-contained, larger, no run-time library needed.
  • app_dyn: the executable stores only a reference ("find printf in libc.so at run time"); the loader resolves it when you launch → smaller file, but libc.so must exist on the machine. This is exactly the Static vs Dynamic Linking trade-off: size + self-containment vs sharing + smaller binaries.

L5.3 — Two mains, one program

You link a.o and b.o, and both define int main(). Which stage fails and why, and how is this the mirror image of an undefined reference?

Recall Solution

Linker fails: multiple definition of 'main'. Mirror image: an undefined reference = zero definitions found; a multiple definition = more than one found. The linker's job is to match each undefined symbol to exactly one definition. Both zero and two violate "exactly one." These are the two failure poles around the healthy case of one definition.

L5.4 — Predict the whole trace

For gcc SQ.c -o sq where

#define SQ(x) ((x)*(x))
int main(){ return SQ(3+1); }

state, stage by stage: (i) the preprocessed return line, (ii) whether it compiles, (iii) whether it links, (iv) the process exit code.

Recall Solution

(i) After cpp: return ((3+1)*(3+1)); — this is still just text; no arithmetic has happened yet. (ii) Compiles cleanly — valid C. During compilation the constant expression is folded. (iii) Links cleanly — main defined, no external symbols. (iv) The folded value is , so the program returns 16; the shell exit code is 16 (fits in the 0–255 range). Contrast the trap: had the macro been #define SQ(x) x*x, the pasted text would be 3+1*3+1, which C evaluates with * before + as 3 + (1*3) + 1 = 7. The parentheses in the good macro are what force the answer 16, not 7 — precedence is decided by the pasted text, not by the macro's "intent." This is why macro parameters are always wrapped in parentheses.


Wrap-up recall

Recall The one-line diagnostic rule

Which tool spoke ::: syntax/undeclared/type → compiler (cc1); undefined or multiple definition → linker (ld) Header changed, how many objects rebuild ::: every object whose .c includes that header static function called cross-file ::: linker undefined reference (code exists but not exported) -lm needed because ::: math.h is only the declaration; sqrt code lives in libm, not linked by default