The Compile Pipeline & Linker
preprocess → compile → assemble → link
- › Break 'compiling' into its four real stages and say what each produces
- › Explain what the preprocessor does before any real compilation happens
- › Describe object files, symbols, and how the linker resolves references between them
- › Run each stage by hand and inspect the intermediate outputs
"Compiling" is four steps, not one
gcc hello.c -o hello looks like one action, but it's a pipeline of four distinct stages, each with
its own input and output. Most bugs and mysteries ("undefined reference," "multiple definition,"
weird macro behaviour) live at the boundaries between these stages. Take the black box apart and they
stop being mysterious. Here's the whole pipeline:
hello.c
│ (1) PREPROCESS cpp: expand #include, #define, #if
▼
hello.i (pure C, no directives)
│ (2) COMPILE cc1: C -> assembly
▼
hello.s (assembly text, the stuff you've been reading)
│ (3) ASSEMBLE as: assembly -> machine code
▼
hello.o (object file: machine code + a symbol table, not yet runnable)
│ (4) LINK ld: combine .o files + libraries -> executable
▼
hello (an executable the OS can load and run)(1) The preprocessor: text surgery before compiling
Before the compiler sees any real C, the preprocessor does pure text manipulation: it pastes in
the contents of every #include file, replaces every #define macro, and includes/excludes code by
#if/#ifdef. It doesn't understand C — it moves text. That's why a macro like
#define SQ(x) x*x bites you: SQ(1+2) expands to 1+2*1+2 = 5, not 9, because it's blind text
substitution. Run gcc -E to see the fully-expanded source and demystify any macro.
(2) Compile and (3) assemble
The compiler proper turns that expanded C into assembly (gcc -S stops here — this is the
.s file you've been reading all along in Compiler Explorer). The assembler then turns assembly
into machine code packaged in an object file (.o, via gcc -c). An object file has real
machine code but is *not runnable yet*: it has holes. If your code calls printf, the object file
just records "I need something called printf" — a symbol reference it can't resolve on its own.
(4) The linker: connecting the references
The linker is the stage people understand least, and it's simple in concept. Each object file
has a symbol table: symbols it *defines* (functions/variables it provides) and symbols it *needs*
(references to things elsewhere). The linker's job is to match every reference to a definition —
across all your .o files and the libraries you include — patch the addresses, and produce one
runnable executable.
1gcc -E hello.c -o hello.i # (1) preprocess: see the expanded source2gcc -S hello.i -o hello.s # (2) compile: C -> assembly3gcc -c hello.s -o hello.o # (3) assemble: -> object file (machine code)4gcc hello.o -o hello # (4) link: -> runnable executable5nm hello.o # inspect the object's symbol table6# 'U printf' = undefined (needed); 'T main' = defined here (text section)Running the stages separately makes the pipeline concrete: you can open hello.i and see your tiny
file ballooned by the expanded headers, read hello.s as plain assembly, and use nm to see that
hello.o *needs* printf (marked U) and *defines* main (marked T). The linker is what turns
that U printf into a real address by pulling in the C library.
a.c defines int helper(void){...} and main.c calls helper(). You run
gcc main.c -o prog (forgetting a.c). What error, from which stage, and why?.a, copied into your
executable at link time) and dynamic/shared (.so, resolved at load time by the dynamic linker,
so many programs share one copy). ldd ./prog lists the shared libraries a program depends on. This
split is why some binaries are self-contained and huge, and others are small but need their .sos
present to run.Walk the pipeline
Take a small C program that calls a function defined in a SEPARATE file (util.c defines it, main.c uses it). (1) Run all four stages by hand on main.c (-E, -S, -c) and look at each output: find something the preprocessor pasted in, read the assembly, and use `nm main.o` to see the defined and undefined (U) symbols. (2) Try to link main.o alone and read the 'undefined reference' error; then compile+link util.c too and watch it resolve. Uses only this lesson.
$ gcc -E main.c -o main.i && gcc -S main.i -o main.s && gcc -c main.s -o main.o$ nm main.o # note 'U' (needed) and 'T' (defined) symbols$ gcc main.o -o prog # observe the undefined-reference error$ gcc main.c util.c -o prog # now it links- › You identified content the preprocessor inserted (from an #include or #define)
- › You read the .s assembly and saw it's what you've been studying
- › nm showed main.o needs a symbol (U) that util.o defines (T)
- › You reproduced 'undefined reference' and fixed it by linking the defining object