Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 22 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 22 minutes

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

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

hello.cpp passes through the preprocessor, compiler and assembler to hello.o; the linker joins it with other objects and the library.hello.cppsource filePreprocessorpastes headers, expands macros(-E stops here)hello.iitranslation unitCompilerchecks the code, writes assembly(-S stops here)hello.sassembly codeAssemblerencodes machine code(-c stops here)hello.oobject fileOther object filesand the standard libraryLinkermatches each use to one definitionhelloexecutable

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.

  1. 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.
  2. The compiler checks the translation unit and writes assembly code for the processor, hello.s. The option -S stops here.
  3. The assembler encodes the assembly as machine code in an object file, hello.o. The option -c stops here.
  4. 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:

  • -E stops after preprocessing and prints the translation unit, or writes it to a file named with -o.
  • -S stops after compiling and writes an assembly file, .s.
  • -c stops 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:

hello.cpp C++ · hello.cpp
#include <iostream>

int main() {
    std::cout << "Hello from C++\n";
}

Here it goes through the stages one at a time. Watch the sizes:

Every stage, with Apple clang and with GCC Python · stages_session.py
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):

square.cpp C++ · square.cpp
// One small function, to see what the compiler turns it into.
int square(int n) {
    return n * n;
}
The assembly for square() Python · assembly_session.py
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.

Hex Viewer & File Inspector Open hello.o or hello in the hex viewer to see the object-file format and the CPU it is for, read from the file's first bytes.

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:

A program in three files

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:

Compile separately, then link Python · separate_session.py
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:

Linking main.o on its own Python · missing_session.py
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 declared greeting(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:

A function defined 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';
}
Both object files define twice(int) Python · duplicate_session.py
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 .cpp file.
  • 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:

One file from each compiler Python · mixed_session.py
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:

exit_status.cpp C++ · exit_status.cpp
// 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;
}
Exit statuses of two programs Python · exit_status_session.py
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.

A three-file statistics program

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';
}
  1. Save the three files in one folder.
  2. Compile stats.cpp and stats_main.cpp separately with -std=c++23 -c, then link the two object files into stats and run it.
  3. Compare your output with the session below.
The expected result Python · stats_session.py
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.o alone, without stats.o.
  • Copy the definition of largest into stats_main.cpp, compile it again and link both files.
  • Change the declaration in stats.h to double mean(std::vector<int> values); without changing stats.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, -S and -c stop after the first, second and third stage, so you can look at each result.
  • Each .cpp file 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 .cpp file, unless the function is inline.
  • Build every file of a program with one toolchain: libc++ and libstdc++ name their types differently, so their object files do not link together.
  • main returns int, 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.

  1. Question 1 of 5 Put the stages of a build in the order they run.

    Give each item its position, from 1 (first).

    Show the answer to question 1

    Answer:

    1. The preprocessor pastes in headers and expands macros
    2. The compiler checks the code and writes assembly
    3. The assembler writes an object file
    4. The linker joins object files and libraries into an executable

    Each .cpp file goes through the first three stages on its own; the linker comes last and is the only stage that sees the whole program.

  2. Question 2 of 5 Which option makes clang++ or g++ stop once the compiler has written the assembly file?

    Choose one answer.

    Show the answer to question 2

    Answer: -S

    -E stops after preprocessing, -S after compiling (it writes a .s file) and -c after assembling (a .o file). -o only names the output.

  3. Question 3 of 5 nm -C main.o shows U greeting(...). What does the U mean?

    Choose one answer.

    Show the answer to question 3

    Answer: main.o uses greeting, but its definition is in another object file or library

    U marks a symbol that this object file needs but does not define. In greet.o the same name is marked T, defined in its code section, and the linker matches the two.

  4. Question 4 of 5 A header defines int twice(int n) { return 2 * n; } (not inline), and two .cpp files of one program include it. What happens?

    Choose one answer.

    Show the answer to question 4

    Answer: Both files compile, and the link fails with a duplicate symbol

    Each translation unit gets its own definition, so two object files define twice(int). #pragma once works within one translation unit only. Move the definition into one .cpp file, or mark it inline.

  5. Question 5 of 5 exit_status.cpp returns 3 from main. What does echo $? print right after the program runs?

    Type a number.

    Show the answer to question 5

    Answer: 3

    The value main returns becomes the exit status, which the shell keeps in $?. A main that ends without a return, like hello.cpp's, reports 0.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.