Foundations · Module 1 · Lesson 1 of 9
From Source Text to a Running Program
Follow a five-line program through the preprocessor, compiler and linker, and learn which stage produces which file and which kind of error.
By the end you can
- Describe what the preprocessor, compiler and linker each take in and produce
- Explain why a declaration lets the compiler accept a call to code it has not seen
- Classify an error message as a preprocessor, compiler or linker error
- Predict which stages must run again after a source or header edit
01 · A question first
You write five lines of text, press Run, and the word Hello appears. But the processor cannot read text, and nothing in the example says how to print anything. Where does the code behind std::cout come from, and how can a compiler declare your program fine while the build still fails?
Make a guess, then step through the trace below to check it.
02 · Watch it run
#include <iostream> int main() { std::cout << "Hello\n";}- stage 0the example(looked at in this step)
- stage 1—
- stage 2—
- stage 3—
We start with one plain-text file, the example. The four cells stand for the four things a build produces in turn: the source, the expanded text, the object file and the finished program. Only the first exists.
Machine work: nothing has run yet; the example is just text on disk
03 · The idea
Start with the problem
You type five lines into a file called the example:
#include <iostream>
int main() {
std::cout << "Hello\n";
}A processor cannot run that file. It understands only machine code: numeric instructions for one family of chips, such as x86-64 or ARM64. None of those instructions says "print", and none of them knows what std::cout is. Somewhere between your keystrokes and the letters on screen, the text has to become instructions, and the code that actually writes to the screen, which you never wrote, has to join your program.
Python solves this by shipping an interpreter that reads your text while the program runs. C++ takes the other road: translate everything ahead of time into a file the machine can run directly. That is a large part of why C++ programs start quickly and run fast. The price is that the translation is yours to understand, because most early errors and surprises come from it.
And the translation is not a single step. It is a pipeline of three tools followed by the operating system. Knowing which one is speaking to you is the difference between fixing an error in a minute and staring at it for an hour.
How the idea works
The trace above follows the example through the pipeline. Its four cells are the four things that exist along the way: the source, the expanded text, the object file and the finished program.
Stage 1: the preprocessor. This tool handles only lines that start with #. For #include <iostream> it finds the header file and pastes its full text in place of the line. The output is still plain text: one large file called a translation unit, which is the unit the compiler works on. On a typical installation your five lines grow to tens of thousands, because <iostream> includes many other headers in turn.
Stage 2: the compiler. The compiler reads the translation unit, checks it against the rules of C++ and translates each function into machine code. The output is an object file, here hello.o. Most compilers produce assembly text first and then assemble it; the trace folds those two steps into one.
The step in the trace that matters most is what happens at line 4. The pasted header contains a declaration of std::cout: a statement that an object with this name and type exists somewhere. It does not contain the definition, which is the actual storage and code. A declaration is all the compiler needs. It checks line 4 against the declared type, emits instructions that call the printing code, and leaves a named gap where that code's address belongs. Such a name is a symbol, and a gap still waiting for its definition is an unresolved symbol. That is why the trace marks the object file with a warning: it holds real machine code, but it is incomplete.
Stage 3: the linker. The linker takes your object files and the libraries they need and joins them into an executable. For every unresolved symbol it searches for exactly one definition. The definition behind std::cout lives in the C++ standard library, which ships with your compiler as precompiled code. With static linking the linker copies that code into your executable. With dynamic linking, the usual default for the standard library, it records only "this program needs the library" and the operating system fills in the final addresses when the program starts. Either way, the linker also adds startup code that runs before main and calls it.
Stage 4: running. The executable is a file holding instructions, data and a list of what it still needs. When you launch it, the operating system's loader copies the parts into memory, connects any dynamic libraries and jumps to the startup code, which calls main. Only now does line 4 actually execute.
The everyday command g++ hello.cpp -o hello runs all of these stages at once, which hides them. You can run them one at a time:
g++ -E hello.cpp -o hello.ii # stage 1: preprocess only, text comes out
g++ -S hello.ii -o hello.s # stage 2a: compile to assembly text
g++ -c hello.s -o hello.o # stage 2b: assemble into an object file
g++ hello.o -o hello # stage 3: link into an executable
./hello # stage 4: the OS loads and runs itAfter the third command, nm -C hello.o lists the symbols inside the object file. The letter T marks something defined here, and U marks something still unresolved:
0000000000000000 T main
U std::cout
...Why it is correct
The pipeline works because checking is split between two tools with different jobs. The compiler needs to know only what something looks like, not what it does. A header is a bundle of declarations, and with them the compiler can verify every use in the example: the right number of arguments, the right types, a return value used correctly. It never needs to see the code behind those names.
The linker checks the other half of the bargain. For every name that was used, it verifies that exactly one definition exists somewhere in the program. C++ calls this the one definition rule: each function or object is defined once in the whole program, however many files declare it.
This split is what lets programs be written in many files. Each .cpp file can be compiled on its own, in any order, even in parallel, without seeing the others' function bodies. The compiler trusts the declarations; the linker audits whether that trust was justified. It also explains a confusing experience: a file compiles without a single error and the build still fails. The compiler proved the code consistent with its declarations. It never promised that the definitions exist.
What it costs
Header text. Your five lines expanded to tens of thousands. The compiler parses all of it every time it compiles the file, even though you use one name from it. A hello world typically compiles in a few hundred milliseconds, and almost all of that time goes to headers. In a project of 500 source files that all include the same large headers, the same text is parsed 500 times, and header parsing is often the largest share of build time in big C++ codebases.
File size. On a typical Linux GCC setup, the dynamically linked hello is on the order of 16 to 20 KB. Link it statically and it grows to roughly 2 MB, because the standard library code it needs is copied in. Dynamic linking keeps files small but means the program depends on those libraries being present on the machine that runs it.
Rebuilds. The last step of the trace shows the cost of an edit: everything to the right of the change is stale. If you change a function body in one .cpp file, only that file is recompiled, and then the program must be relinked. If you change a header, every .cpp file that includes it, directly or through another header, must be recompiled. Build tools such as Make, Ninja and CMake track these dependencies using file timestamps, which is why touching one widely included header can trigger a very long build.
Optimization. Compile time also buys run-time speed. With -O2 the compiler works longer to produce faster code, while -O0 compiles quickly and keeps the machine code close to your source, which helps debugging. You will switch between them often.
Pitfalls and variations
Reading the message to find the stage. Each tool has its own style of error, and each needs a different fix:
fatal error: utils.h: No such file or directorycomes from the preprocessor, which could not find a file to paste. Check the include path and the spelling.error: 'count' was not declared in this scopeorexpected ';' before '}' tokencomes from the compiler: the text breaks the rules of C++. Fix the code.undefined reference to 'parse(std::string const&)'comes from the linker. MSVC saysunresolved external symboland numbers it LNK2019. The function was declared but no definition reached the link, because a source file is missing from the build or a library was not linked. Editing the code will not help; fix the build.
Mistaking "it compiled" for "it works". Compiling proves only that your code agrees with its declarations. A call to a function that is declared but never defined compiles cleanly, as you can check with -c, and fails only at link time.
Defining instead of declaring in a header. If a header holds a function's definition and two .cpp files include it, both object files contain a definition, and the linker reports multiple definition. A later module covers the right way to share code through headers.
Variations. MSVC on Windows uses cl and link and produces .obj and .exe files, but the stages are the same. Libraries come in two shapes: static ones (.a, .lib) are bundles of object files copied in at link time, while shared ones (.so, .dylib, .dll) are loaded at run time. C++20 modules replace the textual pasting of headers with a compiled interface that is read rather than reparsed, which attacks the header cost directly, though build tool support is still uneven. Precompiled headers and compiler caches such as ccache are older tricks aimed at the same cost.
Where it is used
Every C++ project you will meet. CMake does not compile anything itself; it generates Make or Ninja files that run exactly these stages for each source file and then a final link. Compiler Explorer shows the output of stage 2 next to your code, and the Run button in an IDE runs the whole pipeline behind a single click. Other ahead-of-time compiled languages such as C, Rust and Go share the compile-then-link shape, with different details, so this model transfers.
The next lesson takes the five lines of the example apart one at a time: what main is, what the headers and the std:: prefix mean, and what the number that main returns is for.
04 · Prove it
Question 1 of 4 · answer from memory
the example contains `#include <iostream>` and one line that uses `std::cout`. What does the compiler (not the preprocessor) actually read?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem