5.1.1 · D2C Programming

Visual walkthrough — C compilation — preprocessor, compiler, assembler, linker

2,344 words11 min readBack to topic

We will trace exactly this two-file program the whole way down:

// hello.c
#include <stdio.h>
#define GREET "hi"
int main(void) { print_it(); return 0; }
// helper.c
#include <stdio.h>
void print_it(void) { printf("%s\n", "hi"); }

Two source files. One calls a function defined in the other, and both borrow printf from the system. Watch what each stage does with those borrowings.


Step 1 — Where we start: pure text a CPU cannot read

WHAT. A .c file is just characters in a text file. Nothing more. The letters m, a, i, n are stored as bytes, but they mean nothing to a CPU — a CPU only executes numeric opcodes (an opcode is a number that names one hardware action, like "add these two registers").

WHY show this first. To feel the size of the gap. The whole pipeline exists only to close the distance between "text a human likes" and "numbers a chip obeys". If you don't feel the gap, the four stages look like bureaucracy instead of translation.

PICTURE. On the left, human conveniences (#include, the name main, the macro GREET). On the right, the only thing the CPU accepts: a stream of raw numbers. The red bar is the gap we must cross.

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

Step 2 — The preprocessor: paste and replace, nothing more

WHAT. The preprocessor reads every line that starts with # and does text surgery, then throws the #-lines away. #include <stdio.h> is replaced by the entire text of that header pasted in place. #define GREET "hi" makes it hunt down every later token GREET and swap in "hi". Comments are deleted.

WHY this tool, and why first. The other stages want to reason about C grammar and types. But #include and #define aren't C grammar — they're instructions about what text the compiler should even see. So they must be resolved before the real compiler runs. The preprocessor answers one narrow question: "What is the full, expanded text?" — with zero understanding of meaning. (See C Preprocessor Directives for the full directive set.)

PICTURE. The header's hundreds of lines flow in where the one #include line sat; the token GREET is physically overwritten. Output (.i) is still C — just bigger and directive-free.

Figure — C compilation — preprocessor, compiler, assembler, linker
  • :: what you typed, still holding #include / #define.
  • cpp :: the tool; gcc -E hello.c stops here so you can read the result.
  • :: same language (C), but every directive already carried out.

Step 3 — The compiler: understand the C, emit assembly

WHAT. Now a real language processor reads the expanded C. It parses the grammar, checks types (does print_it() take no arguments? does return 0; return an int?), optionally optimizes, and writes out assembly: short text mnemonics like call, mov, ret, one line per CPU-level action.

WHY assembly and not final bytes right away. Two reasons. First, separation: the compiler's hard job is understanding C for your specific CPU; turning nice mnemonics into exact bytes is a mechanical afterthought better left to a tiny separate tool. Second, inspectability: assembly is human-readable, so you can look at it and tune it. (See Assembly Language Basics; the amount of rearranging here depends on Compiler Optimization Levels (-O0..-O3).)

PICTURE. The C statement print_it(); becomes the assembly line call print_it. Notice the red word: print_it is written as a name, not an address — the compiler does not know where that function will live in memory. It leaves the name and moves on.

Figure — C compilation — preprocessor, compiler, assembler, linker
  • :: the mnemonic for "jump to a routine, remember where to come back".
  • :: still a symbolic name — a labelled blank the compiler cannot fill.
  • cc1 :: gcc -S hello.c stops here and hands you hello.s.

Step 4 — The assembler: mnemonics become bytes, names become a to-do list

WHAT. The assembler is a near-mechanical translator: each assembly mnemonic maps to its numeric opcode. mov → a byte, ret → a byte, and so on. The output is an object file (.o): real machine code at last. But it is not runnable.

WHY not runnable. Because call print_it couldn't become a real jump — the assembler still doesn't know print_it's address either. So instead of a number, it writes a placeholder and records, in a symbol table, two lists:

  • Defined here: names this file provides (e.g. hello.o defines main).
  • Undefined: names this file needs from elsewhere (e.g. print_it, printf), each marked with the exact byte position that must later be patched.

PICTURE. The object file as a box: a block of finished machine code, plus a side ledger. The red row is an undefined entry — a hole with a note "someone please fill address of print_it here." (More in Object Files and Symbol Tables.)

Figure — C compilation — preprocessor, compiler, assembler, linker
  • machine code :: bytes the CPU would run — except for the unfilled holes.
  • (defined) :: this file promises the name main to others.
  • (undefined) :: this file demands them from others.
  • as :: gcc -c hello.c stops here, giving hello.o.

Step 5 — The linker: match every demand to a supply

WHAT. The linker takes all the object files (hello.o, helper.o) plus libraries (the C library libc, which supplies printf). It plays matchmaker: for every undefined symbol in any file, it finds a defined one somewhere in the pile.

WHY this is a separate, final stage. Each object file was compiled alone and knew nothing of the others. Only now, with everyone in the room, can hello.o's demand for print_it meet helper.o's supply of print_it, and both files' demand for printf meet libc's supply. This is why splitting the build pays off: change one file, re-assemble only it, re-run this matchmaking — the whole point of Makefiles and Incremental Builds.

PICTURE. Arrows from each red "undefined" slot to the green "defined" slot that satisfies it. Every arrow that finds a home = success. An arrow with no target = the famous error.

Figure — C compilation — preprocessor, compiler, assembler, linker
  • left side :: the undefined slot recorded back in Step 4.
  • :: the linker binds them — the demand is satisfied.
  • right side :: the definition living in another object file (or a library).

Step 6 — Relocation: turning names into real addresses

WHAT. Matching a name isn't enough — the linker must now decide where everything sits in memory, then go back and patch every placeholder with the actual numeric address. This patching is called relocation.

WHY it must come after matching. You cannot write down print_it's address until you've decided the final memory layout of the merged program — and you can't do the layout until you know which pieces (including library code) are even coming along. So: gather everyone → lay them out → patch the holes. Only after every hole is a real number does a jump become a legal jump.

PICTURE. The two object files' code blocks stacked into one address space; each function gets a concrete address; the once-red placeholder call ???? is overwritten with call 0x4011a6 (the address where print_it landed). The linker also staples on startup code _start whose job is to call main.

Figure — C compilation — preprocessor, compiler, assembler, linker
  • :: the hole recorded by the assembler in Step 4.
  • :: a concrete address chosen once layout is fixed.
  • ld :: the linker; its output is the executable. (Bringing library code in at build time vs. at run time is Static vs Dynamic Linking.)

Step 7 — Edge & degenerate cases (never hit an unshown scenario)

WHAT / WHY. Real builds throw curveballs the happy path didn't show. Here is each one, mapped to the exact stage that owns it — so you always know which tool to argue with.

PICTURE. A vertical strip, one row per stage; each row lists what goes wrong there and nowhere else.

Figure — C compilation — preprocessor, compiler, assembler, linker
Situation Which stage What happens
SQUARE(3+1) with #define SQUARE(x) ((x)*(x)) preprocessor pure text swap → ((3+1)*(3+1)) = 16; without the inner parens it'd be 3+1*3+1 = 7
#ifdef DEBUG where DEBUG isn't defined preprocessor the guarded code is deleted before compilation — the compiler never sees it
int x = "hi"; compiler type error; the pipeline stops here, no .o is made
print_it() called, defined nowhere linker undefined reference to 'print_it'
print_it defined in two .o files linker multiple definition — a demand met by two supplies is ambiguous
Empty program int main(void){return 0;} all four still runs all four stages; the assembler emits an object with main defined and no undefined refs; the linker still adds _start

The one-picture summary

Everything above, compressed: text falls through four funnels; at each funnel one kind of human convenience is stripped and the file gets one level closer to raw executable bytes. The red thread is the single symbol print_it — born as a name in Step 3, recorded as an undefined demand in Step 4, matched in Step 5, and finally turned into a real address in Step 6.

Figure — C compilation — preprocessor, compiler, assembler, linker
Recall Feynman retelling — the whole walkthrough in plain words

You wrote a note in English that borrows from other notes and uses your own shorthand.

  1. An assistant (preprocessor) pastes in every borrowed page and spells out your shorthand — now it's one long English note, no references to elsewhere.
  2. A translator (compiler) rewrites it into the robot's step language. Where you said "then do Grandma's frosting," it just writes the words "do Grandma's frosting" — it doesn't own that recipe, so it leaves the name.
  3. A transcriber (assembler) turns each robot instruction into exact button-codes, and keeps a to-do list: "these named steps (Grandma's frosting, print) are borrowed — someone must supply them." That's the object file: real codes plus a list of holes.
  4. A binder (linker) gathers all the transcribed booklets and the library, finds who supplies each borrowed step, staples them together, decides the page numbers, and rewrites every "see Grandma's frosting" into "turn to page 47." Now the robot can run start to finish. If nobody supplies a borrowed step, the binder stops: undefined reference.
Recall Quick self-test

Which stage strips comments? ::: preprocessor call print_it still shows a name, not an address — which stage produced that? ::: compiler (Step 3) Which stage records print_it as undefined? ::: assembler, into the object file's symbol table Which stage turns ???? into 0x4011a6? ::: linker (relocation) A .o has real machine code — why can't you run it? ::: it still has unresolved holes and no entry-point (_start) undefined reference blames which tool? ::: the linker