C++ Module 1 – Start here: toolchains, compiling and first programs
How C++ compiles: preprocess, compile and link
Take hello.cpp through the preprocessor, compiler, assembler and linker with two compilers, then read and fix undefined and duplicate symbol errors.
What you will learn
- Explain the stages that turn a .cpp file into an executable
- Inspect each stage with the -E, -S and -c options
- Diagnose undefined symbol and duplicate symbol errors from the linker
Before you start
What you’ll learn · Before you start
What you will learn
- Explain the stages that turn a .cpp file into an executable
- Inspect each stage with the -E, -S and -c options
- Diagnose undefined symbol and duplicate symbol errors from the linker
Before you start
On this page
A C++ program is built in four stages. The preprocessor pastes in the text of every header a file includes and expands macros. The compiler checks the result and translates it into assembly code for your processor. The assembler encodes that as machine code in an object file. Finally the linker joins the object files of the program and the libraries it uses into one executable.
The first three stages run on each .cpp file separately; the C++ standard calls one source file together with
everything it includes a translation unit. Only the linker sees the whole program. One command such as
clang++ hello.cpp -o hello runs all four stages, so you rarely notice them, but they explain most build errors: a
missing header stops the preprocessor, a type error stops the compiler, and a function that is declared but never
defined compiles without complaint and then stops the linker. This lesson takes a five-line program through every
stage with both compilers, then reproduces the two most common linker errors.
The four stages at a glance
The four stages that turn hello.cpp into a program
Text description of the diagram
The diagram shows the path from the source file hello.cpp to the program hello, from top to bottom.
- The preprocessor reads hello.cpp, pastes in the text of every header it includes and expands macros. The result is the translation unit, hello.ii. The option -E stops here.
- The compiler checks the translation unit and writes assembly code for the processor, hello.s. The option -S stops here.
- The assembler encodes the assembly as machine code in an object file, hello.o. The option -c stops here.
- The linker takes hello.o together with the other object files of the program, such as greet.o, and the standard library (libc++ for Apple clang, libstdc++ for GCC). It matches every use of a function to exactly one definition and writes the executable, hello.
Steps 1 to 3 happen separately for each .cpp file; only the linker sees the whole program.
GCC and Clang both let you stop after any stage:
-Estops after preprocessing and prints the translation unit, or writes it to a file named with-o.-Sstops after compiling and writes an assembly file,.s.-cstops after assembling and writes an object file,.o.- With none of them, the compiler driver runs every stage and then the linker, and writes the executable.
The standard itself describes nine finer-grained phases of translation, from reading characters to linking; the four
stages are how compilers group them. The program called clang++ or g++ is a driver: it runs the stages one after
another and hands each one the previous one’s output.
Stage 1: the preprocessor pastes text
The program for this lesson is the shortest useful one:
#include <iostream>
int main() {
std::cout << "Hello from C++\n";
} Here it goes through the stages one at a time. Watch the sizes:
from session import run
# hello.cpp taken through the four stages one at a time, first with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -E hello.cpp -o hello.ii
wc -l hello.cpp hello.ii
clang++ -std=c++23 -S hello.cpp -o hello.s
wc -l hello.s
clang++ -std=c++23 -c hello.cpp -o hello.o
file hello.o
wc -c hello.o
nm -C hello.o | grep -c " T "
clang++ -std=c++23 -O2 -c hello.cpp -o hello-O2.o && nm -C hello-O2.o | grep -c " T "
clang++ hello.o -o hello
wc -c hello
./hello
g++-16 -std=c++23 -E hello.cpp | wc -l
g++-16 -std=c++23 -S hello.cpp -o hello-gcc.s && wc -l hello-gcc.s
g++-16 -std=c++23 -c hello.cpp -o hello-gcc.o && wc -c hello-gcc.o
nm -C hello-gcc.o | grep -c " T "
g++-16 hello-gcc.o -o hello-gcc && wc -c hello-gcc
./hello-gcc
""") Output
$ clang++ -std=c++23 -E hello.cpp -o hello.ii
$ wc -l hello.cpp hello.ii
5 hello.cpp
56969 hello.ii
56974 total
$ clang++ -std=c++23 -S hello.cpp -o hello.s
$ wc -l hello.s
1276 hello.s
$ clang++ -std=c++23 -c hello.cpp -o hello.o
$ file hello.o
hello.o: Mach-O 64-bit object arm64
$ wc -c hello.o
9336 hello.o
$ nm -C hello.o | grep -c " T "
36
$ clang++ -std=c++23 -O2 -c hello.cpp -o hello-O2.o && nm -C hello-O2.o | grep -c " T "
7
$ clang++ hello.o -o hello
$ wc -c hello
38184 hello
$ ./hello
Hello from C++
$ g++-16 -std=c++23 -E hello.cpp | wc -l
68000
$ g++-16 -std=c++23 -S hello.cpp -o hello-gcc.s && wc -l hello-gcc.s
186 hello-gcc.s
$ g++-16 -std=c++23 -c hello.cpp -o hello-gcc.o && wc -c hello-gcc.o
1848 hello-gcc.o
$ nm -C hello-gcc.o | grep -c " T "
1
$ g++-16 hello-gcc.o -o hello-gcc && wc -c hello-gcc
33928 hello-gcc
$ ./hello-gcc
Hello from C++
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stages_session.py
The five lines of hello.cpp become 56,969 lines after preprocessing with Apple clang, and 68,000 with GCC. The
directive #include <iostream> copies the text of that header into the file, and that header includes many others
in turn. Each standard library writes its headers differently, which is why the two counts differ. The
compiler has to read every one of those lines each time it compiles hello.cpp, which is why large projects pay
attention to what their headers include.
The preprocessor does not understand C++. It handles lines that start with #: #include pastes a file, #define
creates a macro, and #if keeps or drops lines, all before any C++ rule is checked.
Stages 2 and 3: assembly code and object files
-S shows what the compiler produced. For hello.cpp it is long, so here is a function small enough to read,
compiled with -O2, which asks the compiler to optimise (a later lesson compares the optimisation levels):
// One small function, to see what the compiler turns it into.
int square(int n) {
return n * n;
} from session import run
# -O2 asks for optimised code, which is short enough to read.
run("""
clang++ -std=c++23 -O2 -S square.cpp -o square.s
cat square.s
c++filt __Z6squarei
""") Output
$ clang++ -std=c++23 -O2 -S square.cpp -o square.s
$ cat square.s
.build_version macos, 26, 0 sdk_version 27, 0
.section __TEXT,__text,regular,pure_instructions
.globl __Z6squarei ; -- Begin function _Z6squarei
.p2align 2
__Z6squarei: ; @_Z6squarei
.cfi_startproc
; %bb.0:
mul w0, w0, w0
ret
.cfi_endproc
; -- End function
.subsections_via_symbols
$ c++filt __Z6squarei
square(int)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 assembly_session.py
Most lines starting with a dot are directives for the assembler. The work is two instructions for the arm64 processor
of this Mac: mul w0, w0, w0 multiplies the argument by itself, and ret returns. The label __Z6squarei is the
function’s mangled name: the compiler writes the parameter types into the name (i stands for int), so that
overloads such as square(int) and square(double) get different names. c++filt turns a mangled name back into
C++. On macOS every name gets one extra leading underscore, which is why it starts with two.
The assembler then writes the object file, hello.o, which holds machine code plus a table of symbols: the names
it defines and the names it still needs. In the stages session, Apple clang’s hello.o is 9,336 bytes and defines 36
functions (the lines marked T), most of them small helper functions from libc++’s headers; with -O2 they are
inlined, and only 7 remain. GCC’s hello-gcc.o is 1,848 bytes and defines only main, because it calls code that is
already compiled inside libstdc++. Both programs print the same line.
Stage 4: the linker joins translation units
Real programs have many .cpp files. A function is declared in a header, so that every file can call it, and
defined in exactly one .cpp file:
main.cpp
// main.cpp: uses greeting() but does not define it.
#include <iostream>
#include "greet.h"
int main() {
std::cout << greeting("Asha") << '\n';
} greet.h
// greet.h: the declaration. Every file that calls greeting() includes this header.
#pragma once
#include <string>
std::string greeting(const std::string& name); greet.cpp
// greet.cpp: the definition, compiled once into greet.o.
#include "greet.h"
std::string greeting(const std::string& name) {
return "Namaste, " + name + "!";
} Each .cpp file is compiled on its own with -c, and nm lists the symbols of each object file:
from session import run
# Compile each .cpp file on its own, look at the symbols, then link the two object files.
run("""
clang++ -std=c++23 -c main.cpp -o main.o
clang++ -std=c++23 -c greet.cpp -o greet.o
nm -C main.o | grep greeting
nm -C greet.o | grep greeting
clang++ main.o greet.o -o greet_app
./greet_app
""") Output
$ clang++ -std=c++23 -c main.cpp -o main.o
$ clang++ -std=c++23 -c greet.cpp -o greet.o
$ nm -C main.o | grep greeting
U greeting(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&)
$ nm -C greet.o | grep greeting
0000000000000000 T greeting(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&)
$ clang++ main.o greet.o -o greet_app
$ ./greet_app
Namaste, Asha!
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 separate_session.py
In main.o, greeting is marked U: used, but undefined here. In greet.o it is marked T: defined, in the
section that holds code. The linker’s job is to match every U to exactly one T and to write their addresses into
the program. The std::__1 inside the name is libc++’s own namespace; it becomes important below.
Building in pieces also saves time: when you change greet.cpp, only greet.o has to be compiled again before the
link. Build tools such as CMake, covered later in this track, keep track of that for you.
Two linker errors you will meet
“Undefined symbols”: a definition is missing
Leave greet.o out of the link, and the linker has a U with no matching T:
from session import run
# Link main.o alone: greeting() is declared but its definition is missing.
run("""
clang++ -std=c++23 -c main.cpp -o main.o
clang++ main.o -o greet_app
g++-16 -std=c++23 -c main.cpp -o main-gcc.o
g++-16 main-gcc.o -o greet_app
""") Output
$ clang++ -std=c++23 -c main.cpp -o main.o
$ clang++ main.o -o greet_app
Undefined symbols for architecture arm64:
"greeting(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&)", referenced from:
_main in main.o
ld: symbol(s) not found for architecture arm64
clang++: error: linker command failed with exit code 1 (use -v to see invocation)
[exit status 1]
$ g++-16 -std=c++23 -c main.cpp -o main-gcc.o
$ g++-16 main-gcc.o -o greet_app
Undefined symbols for architecture arm64:
"greeting(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> const&)", referenced from:
_main in main-gcc.o
ld: symbol(s) not found for architecture arm64
collect2: error: ld returned 1 exit status
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 missing_session.py
Read the message from the top. It names the symbol with its full parameter list, then the object file that needs it
(_main in main.o), then says that the link failed. The last line differs between the compilers: Clang’s driver says
linker command failed, while GCC’s says collect2: error: ld returned 1 exit status. On this Mac both drivers call
Apple’s linker; the linkers on Linux and Windows word the same error in their own ways. The usual causes:
- The file with the definition was not compiled or not given to the linker. Add it, here
greet.o. - The declaration and the definition do not match. A definition
greeting(std::string)is a different function from the declaredgreeting(const std::string&), so the declared one stays undefined. - The definition is in a library that was not linked; a later lesson shows how to link libraries.
“Duplicate symbol”: a definition appears twice
The opposite mistake is to put a function’s definition, not only its declaration, in a header:
twice.h
// twice.h: a function DEFINED in a header. Every .cpp file that includes it gets its own copy.
#pragma once
int twice(int n) {
return 2 * n;
} part_a.cpp
#include "twice.h"
int from_a() {
return twice(10);
} part_b.cpp
#include <iostream>
#include "twice.h"
int from_a();
int main() {
std::cout << from_a() + twice(1) << '\n';
} from session import run
# Both object files contain a definition of twice(int), so the linker refuses to choose.
run("""
clang++ -std=c++23 -c part_a.cpp -o part_a.o
clang++ -std=c++23 -c part_b.cpp -o part_b.o
clang++ part_a.o part_b.o -o parts
""") Output
$ clang++ -std=c++23 -c part_a.cpp -o part_a.o
$ clang++ -std=c++23 -c part_b.cpp -o part_b.o
$ clang++ part_a.o part_b.o -o parts
duplicate symbol 'twice(int)' in:
part_a.o
part_b.o
ld: 1 duplicate symbols
clang++: error: linker command failed with exit code 1 (use -v to see invocation)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duplicate_session.py
Both .cpp files include twice.h, so both object files contain a definition of twice(int), and the linker refuses
to choose between them. This is the one-definition rule of the standard: a program contains exactly one
definition of each ordinary function it uses. There are two clean fixes:
- Keep only the declaration,
int twice(int n);, in the header and move the definition into one.cppfile. - Mark the definition
inline. The rule allows an inline function to be defined in every translation unit that uses it, as long as all the definitions are identical, and the linker keeps one copy.
#pragma once does not help here. It stops a header from being pasted twice into the same translation unit, but
each .cpp file is a separate translation unit and gets its own copy.
Object files from two toolchains do not mix
Every object file of a program must come from the same toolchain. Here main.cpp is compiled by Apple clang and
greet.cpp by GCC:
from session import run
# main.cpp compiled by Apple clang (libc++), greet.cpp by GCC (libstdc++): the two do not link.
run("""
clang++ -std=c++23 -c main.cpp -o main.o
g++-16 -std=c++23 -c greet.cpp -o greet-gcc.o
clang++ main.o greet-gcc.o -o mixed
""") Output
$ clang++ -std=c++23 -c main.cpp -o main.o
$ g++-16 -std=c++23 -c greet.cpp -o greet-gcc.o
$ clang++ main.o greet-gcc.o -o mixed
Undefined symbols for architecture arm64:
"greeting(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&)", referenced from:
_main in main.o
"std::__throw_length_error(char const*)", referenced from:
std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>>::_M_create(unsigned long&, unsigned long) in greet-gcc.o
std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>>::_M_check_length(unsigned long, unsigned long, char const*) const in greet-gcc.o
ld: symbol(s) not found for architecture arm64
clang++: error: linker command failed with exit code 1 (use -v to see invocation)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mixed_session.py
Both files compiled, and the link still failed. In libc++ the string type is std::__1::basic_string, and in
libstdc++ it is std::__cxx11::basic_string: two different types, so greeting taking one is a different symbol
from greeting taking the other. GCC’s object also needs internal functions of libstdc++, such as
std::__throw_length_error, which Apple clang does not link. The standard does not define how compilers lay out their
types or name their symbols, so build every file of a program, and every library it links, with one toolchain.
What main has to look like
Every program has exactly one main function, and it must return int: int main() or
int main(int argc, char* argv[]). The value it returns becomes the program’s exit status, which the shell keeps
in $?. Zero means success, and reaching the end of main without a return is the same as return 0, a rule that
holds for main only:
// main returns an int: 0 means success; any other value reports a failure to whoever started the program.
#include <iostream>
int main() {
std::cout << "Checking the input file...\n";
return 3;
} from session import run
# The shell keeps the exit status of the last command in $?.
run("""
clang++ -std=c++23 hello.cpp -o hello && ./hello; echo "exit status: $?"
clang++ -std=c++23 exit_status.cpp -o exit_status && ./exit_status; echo "exit status: $?"
""") Output
$ clang++ -std=c++23 hello.cpp -o hello && ./hello; echo "exit status: $?" Hello from C++ exit status: 0 $ clang++ -std=c++23 exit_status.cpp -o exit_status && ./exit_status; echo "exit status: $?" Checking the input file... exit status: 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exit_status_session.py
hello.cpp has no return and reports 0; exit_status.cpp returns 3, and scripts and build tools that run it see
the failure. void main(), which some old books use, is not valid C++, and modern compilers reject it.
Your turn: build a program from three files
Here is a small program in three files: a header with two declarations, a .cpp file with their definitions, and
the program that uses them.
stats.h
// stats.h: declarations of two small statistics functions. Both expect a non-empty vector.
#pragma once
#include <vector>
double mean(const std::vector<int>& values);
int largest(const std::vector<int>& values); stats.cpp
// stats.cpp: the definitions.
#include "stats.h"
#include <algorithm>
#include <numeric>
double mean(const std::vector<int>& values) {
const int total = std::accumulate(values.begin(), values.end(), 0);
return static_cast<double>(total) / static_cast<double>(values.size());
}
int largest(const std::vector<int>& values) {
return *std::max_element(values.begin(), values.end());
} stats_main.cpp
// stats_main.cpp: the program, which only sees the declarations in stats.h.
#include <iostream>
#include <vector>
#include "stats.h"
int main() {
const std::vector<int> marks{72, 85, 64, 91, 80};
std::cout << "mean: " << mean(marks) << '\n';
std::cout << "largest: " << largest(marks) << '\n';
} - Save the three files in one folder.
- Compile
stats.cppandstats_main.cppseparately with-std=c++23 -c, then link the two object files intostatsand run it. - Compare your output with the session below.
from session import run
run("""
clang++ -std=c++23 -c stats.cpp -o stats.o
clang++ -std=c++23 -c stats_main.cpp -o stats_main.o
clang++ stats_main.o stats.o -o stats
./stats
g++-16 -std=c++23 -c stats.cpp -o stats-gcc.o
g++-16 -std=c++23 -c stats_main.cpp -o stats_main-gcc.o
g++-16 stats_main-gcc.o stats-gcc.o -o stats-gcc
./stats-gcc
""") Output
$ clang++ -std=c++23 -c stats.cpp -o stats.o $ clang++ -std=c++23 -c stats_main.cpp -o stats_main.o $ clang++ stats_main.o stats.o -o stats $ ./stats mean: 78.4 largest: 91 $ g++-16 -std=c++23 -c stats.cpp -o stats-gcc.o $ g++-16 -std=c++23 -c stats_main.cpp -o stats_main-gcc.o $ g++-16 stats_main-gcc.o stats-gcc.o -o stats-gcc $ ./stats-gcc mean: 78.4 largest: 91
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stats_session.py
Then break the build on purpose, and predict the message before you run each command:
- Link
stats_main.oalone, withoutstats.o. - Copy the definition of
largestintostats_main.cpp, compile it again and link both files. - Change the declaration in
stats.htodouble mean(std::vector<int> values);without changingstats.cpp, recompile both files and link.
The first two give the errors of this lesson. The third shows why the declaration and the definition must match: the
program now asks for a mean that takes the vector by value, which nothing defines.
Interview questions
Fresher · What is a translation unit, and why does it matter when you build a program?
A translation unit is one source file after preprocessing: the .cpp file together with every header it includes,
minus the lines that #if removed. The compiler works on one translation unit at a time and turns it into one object
file, without seeing the other files of the program; only the linker sees them all. That explains the common build
errors. A function that is declared but defined in no translation unit is reported by the linker as an undefined
symbol, and an ordinary function defined in a header that two .cpp files include is defined in two translation
units, which the linker reports as a duplicate symbol. It also explains incremental builds: changing one .cpp file
recompiles only its translation unit.
Key takeaways
- A build has four stages: the preprocessor pastes headers and expands macros, the compiler writes assembly, the assembler writes an object file, and the linker joins object files and libraries into an executable.
-E,-Sand-cstop after the first, second and third stage, so you can look at each result.- Each
.cppfile is compiled alone, as one translation unit;#include <iostream>turned five lines into tens of thousands of lines with both compilers. - “Undefined symbols” means a declared function has no definition in the link; “duplicate symbol” means it has more
than one. Declarations go in headers, definitions in one
.cppfile, unless the function isinline. - Build every file of a program with one toolchain: libc++ and libstdc++ name their types differently, so their object files do not link together.
mainreturnsint, the program’s exit status, and falling off its end returns 0.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Options Controlling the Kind of Output (Using the GNU Compiler Collection) (GNU Project)
- clang (the Clang command guide) (LLVM Project)
- The C Preprocessor (GNU Project)
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress