Type a few lines of C into a file called hello.c, run cc hello.c -o hello, and half a second later there is a working program. Inside that half second, four separate tools read your text, rewrote it twice, translated it into machine instructions, and stitched the result together with code you never wrote. None of it is hidden. Every stage leaves a file behind that you can open, read, and disassemble. This is a walk through that pipeline, using only flags you already have on your machine.
One Command, Four Programs
What most people call "the compiler" is really a driver. GCC and Clang decide which tools to run and in what order; the actual work happens in separate executables. Pass -v and the driver prints every command it executes. Pass -save-temps and it keeps the intermediate files instead of deleting them:
gcc -save-temps hello.c -o hello
You now have hello.i, hello.s, and hello.o sitting next to your source. Those three files are the pipeline made visible.
- Preprocessor (cpp): pure text substitution. Headers get pasted in, macros expand, comments vanish.
- Compiler (cc1): parses C, checks types, optimizes, emits assembly for your target CPU.
- Assembler (as): turns assembly text into machine code inside an object file.
- Linker (ld, usually launched through a wrapper called collect2): combines object files and libraries into one executable.
- Loader: not part of the build at all. The kernel and the dynamic loader map the program into memory when you run it.
Preprocessing: Text In, Text Out
Ask for the preprocessed result directly with gcc -E hello.c -o hello.i. What comes out is still C, just fatter. Include stdio.h and a three-line program becomes a thousand-line file, because every declaration that header pulls in gets copied into place. Later stages keep track of your original code through line markers: lines that begin with a hash and look like # 1 "hello.c".
Two tricks are worth keeping. When a macro expands into something baffling, run the preprocessor and search the .i file for the call site; the line markers tell you which header is responsible. And gcc -E -dM on an empty input prints every macro the compiler predefines for your machine, which settles most arguments about what a translation unit actually sees. Note that the preprocessor does no type checking. Feed it nonsense and it will produce nonsense without complaining.
Compilation: Preprocessed C to Assembly
gcc -S hello.i -o hello.s is where real translation happens. The compiler parses your code into a syntax tree, checks types, lowers it to an intermediate representation, runs optimization passes over that representation, and only then selects instructions for your architecture.
Read the .s file and the abstractions fall away. You will find a .text section for code, .rodata for string literals, a .globl main directive, labels like .L2, and function calls written as names rather than addresses. With optimization on there are surprises: at -O2, a printf of a constant string ending in a newline often becomes a call to puts, because the compiler knows it produces the same output with less work.
Compare -O0 and -O2 on the same file. Unoptimized output keeps every variable in a stack slot and reloads it for each use, which is why stepping through it in a debugger follows your source so neatly. Optimized output keeps values in registers, inlines small functions, unrolls loops, and reorders code, which is why stepping through it feels like watching a card trick.
Assembly: Mnemonics to Machine Code
gcc -c hello.s -o hello.o runs the assembler. It encodes each instruction into its binary form, resolves labels that are local to the file into offsets, and writes everything into an object file, an ELF file on Linux. The .s file is human-readable and architecture-specific; the .o file is neither.
An object file is not a program. It holds code, data, a symbol table, and relocations: notes saying "this field contains an address that nobody knows yet." Two tools make it legible:
- nm hello.o lists symbols. T means defined in the code section, U means undefined and expected from elsewhere, D and B cover initialized and uninitialized data.
- objdump -d hello.o disassembles the instructions. Calls to outside functions show placeholder targets, backed by relocation entries you can view with readelf -r.
One caution: assembly output is not a strict transcript of instructions. Assemblers accept pseudo-instructions, choose between encodings of the same operation, and relax branch sizes. When the two disagree, the disassembly is the truth.
Linking: One Program From Many Objects
The linker takes your object files, the startup code, and the libraries you named, and produces a single executable. Its two jobs are symbol resolution and relocation. Every U in one file has to match a T in another, or the build stops.
This is the stage beginners blame on the compiler. "undefined reference to sqrt" is a linker error: the symbol exists, but you did not hand over the math library with -lm. "multiple definition of main" is also a linker error, meaning two files both define main and both were passed to
Photo: Lee Campbell / Pexels


