all_lessons/C++/01 · Source to running processlesson 2 / 20

Source to running process: the translation model

Lesson 00 promised a language with no runtime in the way — nothing between your code and the CPU. That was a slogan. This lesson makes it literal by following your source text through the machinery that turns it into a running process: preprocess → compile → assemble → link → load. The one new idea is that "compiled" is not a vague adjective but a concrete pipeline with concrete intermediate products — object files, symbols, an executable — and understanding that pipeline explains why C++ has a build step at all, what that build step buys you (the absence of an interpreter), and a whole category of errors that have nothing to do with your logic and everything to do with translation.

The thesis, here
The cost model starts before the program even runs. C++'s answer to "what does this compile to?" is literal — you can read the machine code it produced — because translation happens ahead of time, once, and ships as native instructions. A high-level language hides a runtime that re-translates or interprets your code as it executes; C++ does that work at build time and then gets out of the way. Knowing the pipeline is what lets you reason about the bill: there is no interpreter loop, no JIT warm-up, no bytecode dispatch standing between your add and the CPU's add.
Linear position
Prerequisite: Lesson 00 — the thesis, the zero-overhead principle, and the five questions. You should be comfortable reading code in some language and know what a function call is. No C++ syntax assumed; every term here is defined at first use.
New capability: trace a multi-file C++ program through preprocess/compile/assemble/link/load; distinguish a declaration from a definition; explain what an object file and a symbol are, what the linker does, and how to read a linker error — and say precisely what the build step buys over an interpreted language.
The plan
Five moves. (1) The von-Neumann picture: what a running program physically is — code and data in addressable memory. (2) The pipeline, stage by stage, naming each intermediate product. (3) The translation unit and the preprocessor: why #include is just textual paste. (4) Declaration vs definition, symbols, and the linker's one job — plus the one-definition rule. (5) A worked two-file-plus-header example traced through every stage, ending in a real linker error and its diagnosis.

1 · What a running program physically is

Before the pipeline, fix the destination in your head. Nearly every machine you will run on follows the von-Neumann model: there is one addressable memory — a giant array of bytes, each with a numeric address — and in that single memory live both the program's machine code (the CPU's native instructions, like "add these two registers", "load the bytes at this address") and its data (your variables, arrays, objects). The CPU does nothing but repeat a tiny loop: read the instruction at the address in its program counter, do what it says, advance. Code and data are the same kind of stuff — bytes at addresses — distinguished only by which ones the CPU is currently treating as instructions.

That picture matters because it tells you what the translation pipeline is for: its entire job is to turn the text you typed into exactly those two things — a blob of machine-code instructions and a description of the data they operate on — laid out so the operating system can drop them into memory and point the CPU at the first instruction. There is no third thing. In Python, a separate program (the interpreter) sits in memory too, reading your code as data and deciding what to do; in a compiled C++ program, your code is the instructions. That is the concrete meaning of "no runtime in the way."

Term: machine code vs the runtime gap
An interpreter is a program that, at runtime, reads your source (or a bytecode form of it) and performs the corresponding actions — so the CPU spends its cycles running the interpreter's instructions, and your logic is data being inspected. A compiler translates your source to the CPU's own instructions ahead of time; at runtime there is no inspector. The build step is the price you pay once, before shipping, to delete that per-instruction overhead forever.

2 · The pipeline: five stages, five products

Run g++ main.cpp -o app and a single command quietly drives a multi-stage assembly line. Each stage consumes the previous stage's output and produces a new, lower-level artifact. Knowing the artifacts is what lets you debug at the right stage.

StageToolConsumesProducesWhat it does
1 · Preprocesspreprocessor.cpp + headersone big .cpp (a translation unit)obeys #include, #define, #if — pure text manipulation
2 · Compilecompilerone translation unitassembly (.s)parses, type-checks, optimizes, emits target assembly
3 · Assembleassemblerassemblyobject file (.o)encodes assembly into binary machine code + a symbol table
4 · Linklinkerall .o files + librariesexecutableresolves cross-file symbols, lays out the final binary
5 · LoadOS loaderthe executablea running processmaps code+data into memory, jumps to the entry point

Stages 1–3 happen per source file, independently — that independence is central, and we return to it in §3. Stage 4 is the only one that sees the whole program at once. Stage 5 happens every time you launch; everything above it happens once, at build time. You can stop the pipeline at any stage to inspect the product: g++ -E stops after preprocessing, g++ -S after compiling (gives you the assembly), g++ -c after assembling (gives you a .o and goes no further).

3 · The translation unit and the preprocessor

The compiler does not work on "your project". It works on one translation unit at a time: a single .cpp file after the preprocessor has expanded it. The compiler sees nothing else — not your other .cpp files, not your project layout. This is the single most important fact about C++ translation, and most build confusion dissolves once you internalize it.

The first stage, the preprocessor, is almost embarrassingly literal: it is a text-substitution engine that runs before the compiler understands any C++ at all. Its main directive, #include "foo.h", does exactly one thing — it deletes that line and pastes the entire contents of foo.h in its place. Nothing more. A header (.h) is therefore not a module or an import in the Python sense; it is just a file of text you arrange to have pasted into many translation units so they can agree on what exists.

// math_utils.h  — a header: it DECLARES what exists, defines almost nothing
int square(int x);          // declaration: "a function square(int)->int exists somewhere"

// main.cpp
#include "math_utils.h"     // preprocessor pastes the line above right here
int main() {
    return square(5);       // compiler is happy: it has seen the declaration
}

When main.cpp reaches the compiler, the #include is gone and the declaration of square sits literally above main. The compiler can now type-check the call square(5) — it knows the argument and return types — even though it has no idea where the actual code of square lives. It does not need to. It records "I need a thing named square" and moves on. Wiring that need to its supply is the linker's job, not the compiler's.

Why this is a footgun, not just trivia
Because #include is blind text paste, a header included by 50 translation units is compiled 50 times — slow builds are a layout consequence, not a mystery. And because the same paste happening twice in one translation unit would re-declare everything, headers carry an include guard (#pragma once, or #ifndef X / #define X / #endif) so the second paste expands to nothing. Forget it and you get a flood of "redefinition" errors — a preprocessor problem masquerading as a code problem.

4 · Declaration vs definition, symbols, and the linker

The pipeline forces a distinction high-level languages let you ignore. A declaration announces that a name exists and what its type is — enough for the compiler to check uses of it. A definition additionally provides the actual entity: a function's body, or storage for a variable. int square(int x); is a declaration; int square(int x) { return x * x; } is a definition. The rule of thumb: a declaration is a promise; a definition keeps it.

When the compiler finishes a translation unit it emits an object file (.o): the machine code for the definitions it did see, plus a symbol table. A symbol is just a name the linker can match on — the encoded name of a function or global variable. Each symbol in the table is either defined here (this .o provides it) or undefined / external (this .o uses it but expects someone else to provide it). main.cpp's object file defines main and references an undefined square.

The linker's one job
The linker takes all the .o files (and libraries) and plays matchmaker: for every undefined symbol in any object file, it finds the one object file that defines that symbol and patches the reference to point at the right address. Then it lays everything out into the final executable. That is essentially all it does — match undefined symbols to definitions, and arrange the result. It does not type-check (the compiler already did) and it does not understand C++; it understands names and addresses.

Two failure modes follow directly, and they are the two most common build errors in C++:

undefined reference / unresolved symbol
A symbol is referenced but no object file defines it. You declared square and called it, but never compiled+linked a file containing its definition. The compiler was fine (it saw the declaration); the linker fails, because it has a promise nobody kept.
multiple definition
Two object files both define the same symbol — usually a function body or a global variable placed in a header and pasted into several translation units. The linker has two answers to one name and refuses to choose.

The second error is the practical face of the one-definition rule (the ODR). In plain terms: across the whole program, every function and every variable may be declared as many times as you like, but must be defined exactly once. (Certain entities — inline functions, templates (Lesson 12), things defined inside a class — are allowed to appear in multiple translation units precisely because they are designed to be identical everywhere, so the linker is told to keep just one.) The ODR is why headers contain declarations and the .cpp files contain definitions: it keeps each definition in exactly one translation unit, so exactly one object file owns each symbol.

5 · Worked example: a two-file program, traced end to end

Here is the smallest program that exercises the whole pipeline: a header that declares, one .cpp that defines, and one .cpp that uses.

// ── math_utils.h ──────────────────────────────
#pragma once                 // include guard: paste this header at most once per TU
int square(int x);           // DECLARATION only — a promise

// ── math_utils.cpp ────────────────────────────
#include "math_utils.h"      // so the definition's signature matches the promise
int square(int x) {          // DEFINITION — keeps the promise
    return x * x;            // compiles to a couple of machine instructions, no runtime
}

// ── main.cpp ──────────────────────────────────
#include "math_utils.h"      // pastes the declaration; main.cpp never sees square's body
int main() {
    int n = square(5);       // a call to an as-yet-undefined symbol
    return n;                // process exit code = 25
}

Trace it. Build with g++ main.cpp math_utils.cpp -o app and watch the artifacts appear:

preprocess main.cpp → main.cpp + pasted "int square(int);" (one translation unit) preprocess math_utils.cpp → math_utils.cpp + same declaration (another TU) compile+asm main TU → main.o [ defines: main | undefined: square ] compile+asm math_utils TU → math_utils.o[ defines: square | undefined: (none) ] link main.o + math_utils.o → matches main.o's undefined `square` to math_utils.o's definition → patches the call address, lays out code+data → executable "app" load ./app → OS maps app's code+data into memory, jumps to main

Notice that main.o was produced without the compiler ever seeing square's body — only its declaration. The two translation units were compiled in complete isolation; they met for the first time at the linker. That isolation is the whole reason a header exists: it is the contract both sides compile against so the linker's later matchmaking succeeds.

The linker error, made on purpose
Now build only the file that uses square:
$ g++ main.cpp -o app
/usr/bin/ld: main.o: in function `main':
main.cpp:(.text+0x10): undefined reference to `square(int)'
collect2: error: ld returned 1 exit status
Read it like the pipeline. ld is the linker. It is processing main.o, in function main, and finds an undefined reference to the symbol square(int). The compiler never complained — it saw the declaration from the header and trusted the promise. The promise was never kept because we never compiled math_utils.cpp, so no object file defines square, so the linker has an undefined symbol with no supplier. The fix is not in your logic; it is to feed the definition into the link: g++ main.cpp math_utils.cpp -o app. "Undefined reference" almost always means a missing definition in the link, not a missing declaration.

6 · Common mistakes / failure modes

Putting a function body in a header
A non-inline definition in a header gets pasted — and thus defined — in every translation unit that includes it. Each .o defines the symbol; the linker reports multiple definition. Keep definitions in .cpp; keep declarations in .h.
"It compiles, so it's fine"
Compiling (per translation unit) and linking (whole program) are different stages with different errors. A clean compile only means each TU is internally consistent; cross-file mistakes surface only at link time. Distinguish the two when you read the error.
Forgetting the include guard
Without #pragma once, including a header twice in one TU re-pastes its declarations and triggers redefinition errors — a preprocessor-stage problem, fixable only at the preprocessor stage.
Declaration / definition signature drift
If the header says int square(int) but the .cpp defines long square(long), these are two different symbols. The call links against the declared one, which nothing defines → undefined reference, even though the file is right there.

Checkpoint exercise

Try it
Recreate the three files from §5. First run g++ -E main.cpp and confirm the #include line has been replaced by the bare declaration int square(int); — you have just watched the preprocessor do its only trick. Next run g++ -c main.cpp to get main.o, then nm main.o (the symbol-table viewer) and find square marked U (undefined) and main as a defined text symbol. Now run g++ main.cpp -o app and reproduce the "undefined reference to square(int)" error. Finally, fix it by adding math_utils.cpp to the command, run ./app; echo $?, and confirm the exit code is 25. You will have driven, stopped, and inspected the pipeline at every stage by hand.

Where this points next

We now know what a program is by the time it runs — machine code and data in one addressable memory, with no interpreter between it and the CPU — and how the text we typed became that. But we have been vague about the data half: "your variables live in memory." Where in memory, with what type, of what size, for how long? Lesson 02 makes the data side as concrete as the pipeline made the code side: it defines a C++ object precisely as a typed region of storage, measures the fundamental types with sizeof, and draws the stack frame a function call pushes — the first and cheapest of the three places your data can live. That is where "where does the memory live?" stops being a slogan.

Takeaway
"Compiled" names a concrete five-stage pipeline — preprocess → compile → assemble → link → load — whose job is to turn your source text into machine code and data in addressable memory (the von-Neumann picture) so the CPU runs your instructions directly, with no interpreter or runtime in the way: that absence is exactly what the build step buys. The compiler works on one translation unit at a time (a .cpp after the preprocessor blindly pastes its #included headers), so a declaration (a promise a name exists, type-checkable on its own) is fundamentally different from a definition (the actual body or storage). Each translation unit becomes an object file with a symbol table; the linker's one job is to match every undefined symbol to its single definition — which is why the one-definition rule demands exactly one definition program-wide, why headers hold declarations and .cpp files hold definitions, and why "undefined reference" is a missing-definition link error, not a compile error.

Interview prompts