Understand How C++ Turns Source Code into a Running Program
Learn how the C++ compiler transforms your source files into an executable, and why that process shapes every decision you make as a C++ programmer.
By the end of this page, you will understand how C++ transforms plain text source files into a running executable, what a translation unit is, what the preprocessor and linker do, and why that knowledge helps you read error messages and structure your code confidently.
What and Why
Most scripting languages read your source file and run it directly. C++ is different. It was designed so that each source file can be compiled independently, without knowing anything about the others, and the resulting pieces are stitched together afterward. This design is decades old, and it has deep consequences for how you write and organize code.
Once you understand this model, questions that baffle newcomers suddenly make sense:
- Why do I need
#includeat the top of every file? - What is a "linker error," and how is it different from a "compiler error"?
- Why can I call a function before I've written its body?
The answer to all three lives in the three stages the compiler toolchain runs every time you build: preprocessing, compilation, and linking.
Step by Step
Stage 1: Preprocessing
Before the compiler ever sees your code, a separate tool—the preprocessor—runs over every source file. It handles lines that start with #, and its job is purely textual: copy, paste, and replace.
The most common directive is #include. When the preprocessor encounters this line:
#include <iostream>it opens the file named iostream and pastes its entire contents in place. The compiler never sees the #include directive itself—by the time compilation begins, the file is already one large block of expanded text.
Stage 2: Compilation
After preprocessing, each .cpp file (called a translation unit) is handed to the compiler. The compiler reads it independently, checks types and syntax, and produces an object file—a .o file on Linux and macOS, or a .obj file on Windows. Object files contain machine code, but they are not yet runnable; they may reference names that live in other translation units.
Here is the smallest possible translation unit:
#include <iostream>
int main() {
std::cout << "Hello, world!\n";
return 0;
}Save this as hello.cpp. You can compile it to an object file without linking by running g++ -c hello.cpp -o hello.o. The resulting hello.o is machine code that the computer cannot execute on its own yet.
Stage 3: Linking
The linker combines all the object files your program needs, plus any libraries, into a single executable. Its job is to resolve every reference: if hello.o calls std::cout, the linker finds the definition inside the standard library and wires them together.
Running g++ hello.cpp -o hello performs all three stages in one command. You can also do them separately:
// Stage 1+2: g++ -c hello.cpp -o hello.o
// Stage 3: g++ hello.o -o hello
// Run: ./helloA Two-File Example
Real programs span multiple files. Here is a minimal example that shows every stage interacting.
The header file declares that greet exists. It does not define what greet does:
// greet.h
#ifndef GREET_H
#define GREET_H
void greet(const char* name);
#endifThe implementation file defines what greet actually does:
// greet.cpp
#include "greet.h"
#include <iostream>
void greet(const char* name) {
std::cout << "Hello, " << name << "!\n";
}The entry point calls greet without knowing how it works—only that it exists:
// main.cpp
#include "greet.h"
int main() {
greet("Alice");
return 0;
}Build with: g++ main.cpp greet.cpp -o app. The compiler processes main.cpp and greet.cpp into separate object files, then the linker joins them. main.o has a placeholder saying "call something called greet"; greet.o has the actual code. The linker fills in the address.
Common Patterns
Pattern 1: Header Guards
Every header must be protected against being pasted twice into the same translation unit. Without a guard, if two of your .cpp files both include utils.h, the preprocessor would paste its contents twice, and the compiler would see duplicate declarations.
// math_utils.h — classic guard
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int square(int x);
#endifMany compilers also support #pragma once, which is shorter and equally effective in practice:
// math_utils.h — pragma once variant
#pragma once
int square(int x);Pattern 2: Declarations in Headers, Definitions in .cpp
Put declarations (what exists) in headers. Put definitions (the actual code) in .cpp files. A definition must appear exactly once across all translation units—the One Definition Rule (ODR). If two object files both define the same function, the linker rejects the program.
// counter.h
#pragma once
int add(int a, int b); // declaration only// counter.cpp
#include "counter.h"
int add(int a, int b) { // one and only definition
return a + b;
}// main.cpp
#include "counter.h"
#include <iostream>
int main() {
std::cout << add(3, 4) << "\n";
return 0;
}Pattern 3: Inline Functions in Headers
Short functions defined directly in a header must be marked inline. This tells the linker that identical copies will appear in every translation unit that included the header—that is expected and fine; keep just one.
// math_utils.h
#pragma once
inline int square(int x) {
return x * x;
}Without inline, including this header in two .cpp files would produce a "multiple definition" linker error.
What Can Go Wrong
Forgetting to include a header
// WRONG — <string> was never included
int main() {
std::string s = "hello"; // error: 'string' is not a member of 'std'
return 0;
}// CORRECT
#include <string>
int main() {
std::string s = "hello";
return 0;
}Do not rely on transitive includes. A header you include today might include <string> as a side effect, but that is an implementation detail that can change.
Defining a non-inline function in a header
// utils.h — WRONG
int double_it(int x) {
return x * 2; // definition in a header without inline
}If two .cpp files include this header, the linker sees two definitions of double_it and fails. Fix it by adding inline, or by moving the definition to utils.cpp.
Missing a source file from the build command
// WRONG — greet.cpp is missing from the build
// g++ main.cpp -o app
// linker error: undefined reference to `greet(char const*)`// CORRECT
// g++ main.cpp greet.cpp -o app"Undefined reference" is the linker saying it found a call to a name but no definition anywhere in the object files it was given. Either add the missing .cpp file, or link the library that contains the definition.
Quick Reference
| Stage | Input | Output | Typical error |
|---|---|---|---|
| Preprocessing | .cpp + headers | Expanded source text | Cannot open include file |
| Compilation | Expanded source text | Object file (.o) | Syntax error, type mismatch |
| Linking | Object files + libraries | Executable | Undefined reference, multiple definition |
Terms to know:
- Translation unit — one
.cppfile after preprocessing; the atom the compiler works on - Declaration — tells the compiler a name exists and its type; can appear many times
- Definition — provides the actual implementation; must appear exactly once (unless
inline) - Header guard —
#ifndef/#define/#endif(or#pragma once) that prevents double inclusion - One Definition Rule (ODR) — every non-inline entity must be defined in exactly one translation unit
What's Next
With the compilation model in hand, you are ready to explore how C++ manages memory and objects during a program's lifetime:
- Memory Model — the rules C++ defines for reading and writing memory safely across threads and compiler optimizations
- Object Model — what an "object" is in C++, how objects occupy storage, and how the compiler lays them out
Once you have written a few multi-file programs, look into build systems like CMake, which automate the compile and link commands so you do not have to list every .cpp file by hand.
Header Guards vs #pragma once
Both header guards and #pragma once solve the same problem: preventing a header's contents from being pasted into the same translation unit more than once. The preprocessor is a pure text engine — if two files both #include "utils.h", it will happily paste utils.h twice, causing duplicate declarations that the compiler rejects. Understanding why both solutions exist, and why the One Definition Rule is at the heart of the issue, makes you a more confident C++ programmer.
The classical solution is the include guard — three preprocessor directives that wrap the entire header:
// math_utils.h — classical include guard
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
double sqrt_approx(double x);
#endif // MATH_UTILS_HThe first time the preprocessor sees this file, MATH_UTILS_H is not yet defined, so it enters the block, defines the macro, and processes the content. The second time, MATH_UTILS_H is already defined, so it skips everything between #ifndef and #endif. The macro name is just a convention — it has no meaning beyond "has this file been seen before?"
#pragma once is a non-standard but universally supported shorthand that tells the preprocessor to include the file at most once, using the file's identity on disk rather than a macro name:
// math_utils.h — #pragma once variant
#pragma once
double sqrt_approx(double x);| Feature | #ifndef guard | #pragma once |
|---|---|---|
| Standard C++? | Yes | No (compiler extension) |
| Supported everywhere? | Yes | All major compilers (GCC, Clang, MSVC) |
| Fails on copies of the same file? | No — each copy gets its own path | Yes — two copies are treated as different files |
| Risk of macro name collision? | Yes, if names are poorly chosen | None |
| Lines of boilerplate | 3 | 1 |
The One Definition Rule (ODR) is why this matters at all. The ODR says every non-inline entity — function, variable, class — must be defined exactly once across the entire program. Headers usually contain only declarations, but without a guard those declarations can be pasted so many times that some definitions sneak in twice and the compiler or linker raises an error.
In new code written for mainstream compilers, #pragma once is the pragmatic choice. In portable, standards-only codebases, classical guards are safer.
The Preprocessor in Detail
The preprocessor is a macro language that runs before the compiler ever sees your code, and it operates entirely on text. It knows nothing about C++ types, scopes, or semantics — it finds patterns and performs textual substitutions. Most of what it does falls into three categories: file inclusion, macro expansion, and conditional compilation.
Macro expansion
#define creates a macro. Object-like macros substitute a name with a value:
#define MAX_RETRIES 5
int attempts = MAX_RETRIES; // becomes: int attempts = 5;Function-like macros accept arguments and expand them inline:
#define SQUARE(x) ((x) * (x))
int y = SQUARE(3 + 1); // expands to ((3 + 1) * (3 + 1)) == 16The extra parentheses around x are not optional. Without them, SQUARE(3 + 1) would expand to 3 + 1 * 3 + 1, which equals 7 — a classic macro pitfall. Modern C++ prefers inline functions and constexpr over macros for exactly this reason.
Predefined macros
The compiler defines several macros automatically. Two of the most useful in debugging are __FILE__ and __LINE__:
#include <iostream>
void log_location() {
std::cout << "Called from " << __FILE__
<< " line " << __LINE__ << "\n";
}Other useful predefined macros:
| Macro | Expands to |
|---|---|
__FILE__ | Current source file name (string literal) |
__LINE__ | Current line number (integer) |
__DATE__ | Compilation date as "Jul 15 2026" |
__TIME__ | Compilation time as "14:03:00" |
__cplusplus | C++ standard version: 201703L for C++17, 202002L for C++20 |
Conditional compilation
#if, #ifdef, #ifndef, #elif, #else, and #endif let you include or exclude code at the preprocessing stage, before the compiler processes anything:
#ifdef NDEBUG
// release build: assertions compiled out
#else
#define ASSERT(cond) \
if (!(cond)) { \
std::cerr << "ASSERT failed: " #cond \
<< " at " << __FILE__ << ":" << __LINE__ << "\n"; \
std::abort(); \
}
#endifYou can also test the C++ version to conditionally use newer features while remaining backward compatible:
#if __cplusplus >= 202002L
// C++20 available — use std::span
#include <span>
#else
// Older standard — use our own wrapper
#include "compat/span.h"
#endifStatic vs Dynamic Linking
When the linker combines your object files with libraries, it faces a fundamental choice: should the library code be embedded directly into your executable, or should it be loaded separately at runtime? Static linking bakes everything in; dynamic linking keeps library code in separate files that are loaded on demand. Each approach has real trade-offs that affect deployment, memory, and binary size.
Static libraries
A static library (.a on Linux/macOS, .lib on Windows) is an archive of object files. When you link against it statically, the linker copies only the object files it actually needs directly into your final executable.
# Create a static library from object files
g++ -c greet.cpp -o greet.o
ar rcs libgreet.a greet.o
# Link against the static library
g++ main.cpp -L. -lgreet -o app_staticThe resulting app_static binary is self-contained. You can copy it to another machine and it runs without any additional files. The downside is that if you have ten programs all using the same library, each executable carries its own private copy.
Dynamic (shared) libraries
A dynamic or shared library (.so on Linux, .dylib on macOS, .dll on Windows) is loaded at program startup — or even later — rather than being embedded in the executable.
# Build a shared library
g++ -fPIC -c greet.cpp -o greet.o
g++ -shared greet.o -o libgreet.so
# Link against the shared library
g++ main.cpp -L. -lgreet -o app_dynamicThe -fPIC flag ("Position Independent Code") is required: shared library code must be able to run at any address the operating system places it, not a fixed address.
The -rpath problem
When you run app_dynamic, the dynamic linker searches for libgreet.so in standard system directories. If you placed the library somewhere non-standard, the program fails to start:
./app_dynamic: error while loading shared libraries: libgreet.so: cannot open shared object fileThe clean fix for development is -rpath, which embeds the library search path into the executable:
g++ main.cpp -L. -lgreet -Wl,-rpath,'$ORIGIN' -o app_dynamic$ORIGIN means "the directory where the executable lives," so the dynamic linker looks next to the binary — a portable pattern for bundled deployments.
Static (.a) | Dynamic (.so/.dll) | |
|---|---|---|
| Binary size | Larger (includes library) | Smaller (library is separate) |
| Deployment | Self-contained | Library must be on target system |
| Memory sharing | Each process has its own copy | OS shares one copy between processes |
| Library updates | Requires recompiling the app | Replace the .so, app picks it up automatically |
| Startup time | Faster | Slightly slower (dynamic linking step) |
For small utilities or tools you ship to a single machine, static linking keeps deployment simple. For large applications or libraries used by many programs simultaneously, dynamic linking saves memory and allows independent updates.
Symbol Visibility
Every name that the compiler outputs in an object file — a function, a global variable, a class method — is called a symbol, and controlling which symbols are exported from a shared library is a critical skill for library authors. Exporting too many symbols bloats the ABI, slows dynamic linking, and creates accidental dependencies. Exporting too few means callers cannot use your library.
Inspecting symbols with nm
nm lists the symbols in an object file or library. After building greet.o from the earlier example:
nm greet.oTypical output:
0000000000000000 T _Z5greetPKc
U _ZSt4cout
U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKcThe letter in the middle column tells you the symbol type:
| Letter | Meaning |
|---|---|
T | Defined in the text (code) section |
U | Undefined — expected from another object or library |
D | Defined in the initialised data section |
B | Defined in the uninitialised data section (BSS) |
W | Weak symbol |
Controlling visibility with __attribute__((visibility))
On GCC and Clang, you can mark individual symbols as visible or hidden to callers outside the shared library:
// my_library.h
#ifdef _WIN32
#define MY_LIB_API __declspec(dllexport)
#else
#define MY_LIB_API __attribute__((visibility("default")))
#endif
// Only this function is part of the public API
MY_LIB_API void public_function();
// Internal helper — not exported
__attribute__((visibility("hidden")))
void internal_helper();Building with -fvisibility=hidden makes hidden the default for everything, so you only export what you explicitly mark as default. This is the recommended approach for any shared library you publish:
g++ -fPIC -fvisibility=hidden -shared my_library.cpp -o libmy.soAfter building, use nm -D libmy.so (or objdump -t libmy.so) to confirm only your intended public symbols appear.
Tracing a Program Through All Four Stages
Watching each intermediate output helps cement the mental model: preprocessing expands macros and includes, compilation turns C++ into assembly, the assembler turns assembly into object code, and the linker combines everything into a runnable program. GCC and Clang expose each stage individually so you can inspect the output at any point.
Consider this two-file program:
// add.h
#pragma once
int add(int a, int b);// add.cpp
#include "add.h"
int add(int a, int b) { return a + b; }// main.cpp
#include "add.h"
#include <iostream>
int main() {
std::cout << add(2, 3) << "\n";
}Stage 1 — Preprocessing (-E flag, output is .i):
g++ -E main.cpp -o main.iOpen main.i and you will find thousands of lines — all the content of <iostream> and add.h pasted in, with #include directives replaced by their expanded text. Comments are stripped and line markers (# linenum "filename") are inserted so the compiler can map back to original source positions.
Stage 2 — Compilation to assembly (-S flag, output is .s):
g++ -S -O0 main.i -o main.smain.s is human-readable x86-64 assembly. You can see how the compiler translated std::cout << add(2, 3) into call instructions. The symbol add appears as an external reference.
Stage 3 — Assembly (-c flag on a .s file, or pass -c directly):
g++ -c main.s -o main.o
# Equivalently from source: g++ -c main.cpp -o main.omain.o is machine code, but the add symbol is still unresolved. Running nm main.o will show U _Z3addii — undefined.
Stage 4 — Linking:
g++ main.o add.o -o appThe linker resolves _Z3addii by finding it in add.o, patches the call address, and writes the final app binary. Running nm app | grep add now shows T _Z3addii — defined in the text segment.
Common Compilation Errors by Stage
Most C++ error messages fall into one of three buckets depending on which stage they come from, and recognising the bucket tells you immediately where to look. Preprocessor errors appear before any code is compiled; compiler errors are about syntax and types; linker errors are about missing or duplicate definitions.
Preprocessor errors
fatal error: greet.h: No such file or directoryThe preprocessor could not find the file you #included. Common causes:
- A typo in the filename
- The header lives in a different directory and you have not added it with
-I - The library is not installed on the system
Fix: Check the path, and add -I/path/to/headers to your compile command (or the equivalent in your CMakeLists.txt).
Compiler errors
error: 'greet' was not declared in this scopeThe compiler has no declaration for greet in this translation unit. You probably forgot the #include "greet.h" at the top of your file.
error: no match for 'operator<<' (operand types are 'std::ostream' and 'MyType')You are trying to stream a type that has no operator<< defined. The fix is either to add operator<< for MyType, or to convert it to a type that already has one.
error: redefinition of 'double sqrt_approx(double)'The compiler sees two definitions of the same function in the same translation unit — almost always caused by a missing header guard and the header being included twice.
Linker errors
undefined reference to `greet(char const*)'The linker found a call to greet in one object file but no definition in any of the object files or libraries it was given. Possible fixes:
- Add the missing
.cppfile to the build command - Add the missing
-llibrary flag - Check for a typo in the function name or signature
multiple definition of `greet(char const*)'Two object files both define greet. This happens when a function is defined (not just declared) in a header that gets included in more than one .cpp file, and the function is not marked inline. Fix: move the definition to exactly one .cpp file, or add inline.
undefined reference to `vtable for MyClass'A class has at least one virtual function whose body was declared but never defined. The linker needs the vtable, which requires all virtual functions to have implementations. Check that every virtual function in the class has a body, including the destructor if it is virtual.