One command, four programs
By now you have typed `gcc hello.c` and watched an `a.out` appear, then run `$ ./a.out` and seen your greeting. From the foundations rung you already know the deep split underneath this: your source is human-readable text, and a CPU only obeys machine code. Something must translate one into the other. What that command hides is that the translation is not a single leap — it is a short assembly line, and the whole assembly line together has a name: the toolchain.
Specifically, for a C program, four programs run back to back: the preprocessor, the compiler, the assembler, and the linker. Each takes the previous one's output, transforms it, and hands it on — text in at the top, a runnable file out at the bottom. `gcc` is really a friendly driver that calls all four for you and deletes the intermediate files so you never notice. This guide's whole job is to make you notice. Once you can name each stage and say what it does, the cryptic errors of later guides stop being mysterious — you will know exactly which of the four stages complained.
hello.c <- you write this (text)
| preprocessor (cpp)
hello.i <- text, still C, but expanded
| compiler (cc1)
hello.s <- assembly text (human-readable instructions)
| assembler (as)
hello.o <- object file (machine code, not yet runnable)
| linker (ld)
a.out <- executable (runnable)Stage 1 — the preprocessor: text in, text out
The very first thing that touches your file is not the compiler at all. It is the preprocessor, and it is almost shockingly dumb on purpose: it does not understand C. It is a text-shuffling robot that obeys the lines beginning with `#`. When it sees `#include <stdio.h>`, it literally finds that header file and pastes its entire contents in place of that one line. When it sees a macro like `#define MAX 100`, it walks the rest of the text and replaces every `MAX` with `100`. That is essentially all it does — copy, paste, and substitute.
The output is still C source text — just bigger and fully expanded, with every header pasted in and every macro resolved. You can see it for yourself: `gcc -E hello.c` stops after this stage and prints the result. Run it once and you will find your three-line `hello.c` has ballooned to hundreds of lines, because all of `stdio.h` (and whatever it includes) is now sitting at the top. Nothing has been compiled yet; not one machine instruction exists. The file is still just text, ready to be read by the next stage.
Stage 2 — the compiler: C in, assembly out
Now the real translation begins. The compiler takes that expanded text — what it calls one translation unit, a single source file plus everything pasted into it — and turns C into instructions. It does this in phases of its own: it first parses the text into a structured form (checking that your grammar and types are valid, which is where most of your `error:` messages come from), then it optimizes, then it emits the result. But here is the surprise: the compiler does not emit machine code. It emits assembly — still human-readable text, but now a near-direct list of CPU instructions.
Why stop at assembly rather than go all the way to raw bytes? Because assembly is the natural border between "my language" and "this machine." The compiler's hard work — understanding your C, choosing efficient instructions, deciding which values live in which registers — is the same regardless of how those instructions are finally encoded. You can peek at this output too: `gcc -S hello.c` halts here and leaves a `hello.s` file you can open and read. You will see lines like `mov` and `call` and register names; the assembly rung later in this ladder teaches you to read them fluently. For now, the point is just the shape of the data: text came in, slightly-less-friendly text went out.
Stage 3 — the assembler: assembly in, object code out
The assembler is the simplest stage of all to describe: it takes the assembly text and encodes each instruction into its exact numeric machine code — the actual bytes the CPU will fetch and execute. `mov` becomes a specific byte pattern; a register name becomes a few bits inside that pattern. There is almost no cleverness here, just a faithful, mechanical translation from one notation to another. The result is the first file in the whole pipeline that is genuinely binary rather than text — it is called an object file, and on most systems it ends in `.o`.
Here is the crucial twist, and the reason a `.o` file is not yet a program you can run. An object file is real machine code, but it has holes. When your code says `printf("hi")`, the assembler has no idea where `printf()` actually lives — its code is in some library file, not yours. So the object file records, in effect, "call printf, address to be filled in later." It carries a list of names it provides and a list of names it still needs, a bookkeeping structure called the symbol table. A `.o` is a finished but unconnected puzzle piece: complete machine code with labelled tabs where it must join to other pieces.
Stage 4 — the linker: joining the pieces, and why it differs from compiling
The linker takes one or more object files (yours, plus the system ones holding `printf()` and friends) and assembles them into a single, complete program. It does two jobs. First, resolution: it reads every "name I still need" hole and matches it against the "name I provide" entry in some other object file or library. Your `call printf` hole gets matched to the real `printf` in the C library. If a needed name is provided by nobody, this is where you get the famous `undefined reference to 'printf'` — a linker error, not a compiler error, because the compiler never doubted that `printf()` existed; it only trusted you that someone would supply it.
Second, relocation: each object file was written as if it would sit at address 0, but they cannot all live there at once. The linker decides the final layout — your code here, the library's code there — and then goes back and patches every address that depended on the layout, filling in the holes it left blank. After resolution and relocation, every name is matched and every address is fixed: the result is a single executable file. This is the heart of why compiling and linking are different jobs: compiling translates one translation unit in isolation, knowing nothing of the others; linking is the only stage that sees them all at once and stitches them together.
It helps to picture the boundary as a contract. A header file is the compiler's promise — "a function named `printf()` exists and takes these arguments" — which is all the compiler needs to translate your call. The matching machine code is the fulfillment of that promise, and supplying it is the linker's job. The compiler checks that you used the contract correctly; the linker checks that the contract was actually honoured by somebody. Two different questions, two different stages, two different error messages — which is why the same broken call can fail at either step depending on what is missing.
After the four stages: the loader runs it
The four stages produce a file on disk — but a file is not a running program. The final actor enters when you type `$ ./a.out`: the loader, a part of the operating system. It reads the executable, copies its code and data into fresh memory, sets up the stack you met in the memory rung, places the starting address into the CPU, and jumps. Only now does anything actually execute. The loader is not part of `gcc` and runs at a completely different time — once, every time you launch the program, long after compilation is over. Building a program and running it are separate events, and the loader is the bridge between them.
So now you can read `gcc hello.c` for what it truly is: a request to run the preprocessor, then the compiler, then the assembler, then the linker — four transformations turning text into a runnable executable — after which the loader, at a separate moment, brings it to life. You can step through any boundary yourself with `-E`, `-S`, and `-c`, and you now know which stage owns which kind of error. That map is the foundation for the rest of this rung: guide 2 zooms into the preprocessor, guide 3 into object files and symbols, guide 4 into the linker, and guide 5 teaches you to read the messages that each stage throws when something goes wrong.