Compiling, Linking & Warnings
So far you have built every program with one command such as g++ -std=c++23 app.cpp -o app. Real projects have tens or thousands of source files, and that one command hides four separate steps. When a build fails at work, the error message tells you which step failed, and knowing the steps is the fastest way to fix it.
In this lesson you will split a program into a header and two source files, build it step by step, read the two most common linker errors, and choose warning and optimisation flags like a professional.
The four steps of a build
Each .cpp file, together with everything it includes, is called a translation unit. The compiler handles each translation unit on its own, and only the last step brings them together.
| Step | What happens | GCC flag to stop here | Output |
|---|---|---|---|
| 1. Preprocess | #include text is pasted in, macros are replaced, #if blocks are kept or removed | -E | Pure C++ text |
| 2. Compile | C++ is checked and turned into assembly code | -S | .s file |
| 3. Assemble | Assembly becomes machine code | -c | Object file .o (.obj on MSVC) |
| 4. Link | All object files and libraries are joined, and calls are connected to the functions they call | (default) | Program or library |
Syntax and type errors come from step 2. Errors that mention "undefined reference" or "unresolved external symbol" come from step 4, the linker. The two need different fixes, so always check which tool is complaining.
Splitting a program into files
A header (.hpp or .h) holds declarations: it tells other files that a function or class exists and what its signature is. A source file (.cpp) holds definitions: the actual function bodies. Here is a small GST calculator for a shop in Jaipur split into three files.
// gst.hpp: declarations only
#pragma once
#include <string>
namespace gst {
double with_gst(double amount, double rate);
std::string receipt_line(const std::string& item, double amount);
}// gst.cpp: definitions
#include "gst.hpp"
#include <format>
namespace gst {
double with_gst(double amount, double rate) {
return amount * (1.0 + rate);
}
std::string receipt_line(const std::string& item, double amount) {
return std::format("{:<12} Rs. {:>8.2f}", item, with_gst(amount, 0.18));
}
}// main.cpp
#include "gst.hpp"
#include <print>
int main() {
std::println("{}", gst::receipt_line("Kurta", 1299));
std::println("{}", gst::receipt_line("Dupatta", 450));
}#pragma once stops a header from being pasted twice into the same translation unit. It is supported by GCC, Clang and MSVC, although it is not part of the standard. You will also see the standard include guard pattern, which does the same job:
#ifndef GST_HPP
#define GST_HPP
// ... declarations ...
#endifNow build the files one step at a time. -c compiles each source file into an object file without linking, and the last command links the two object files into one program.
g++ -std=c++23 -Wall -Wextra -c gst.cpp -o gst.o
g++ -std=c++23 -Wall -Wextra -c main.cpp -o main.o
g++ gst.o main.o -o shop
./shopKurta Rs. 1532.82 Dupatta Rs. 531.00
Why bother with separate steps? If you change only main.cpp, only main.o must be rebuilt; gst.o is reused. In a big project this turns a twenty-minute build into a few seconds. Build tools such as CMake and Ninja do exactly this tracking for you, as you will see in Build Projects with CMake.
Reading linker errors
Undefined reference. If you forget to link gst.o, the compiler is happy, because main.cpp saw the declarations in the header. The linker, however, cannot find the function bodies.
g++ -std=c++23 main.cpp -o shop/usr/bin/ld: /tmp/ccA1b2C3.o: in function `main': main.cpp:(.text+0x5c): undefined reference to `gst::receipt_line(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, double)' collect2: error: ld returned 1 exit status
The temporary file name and offsets differ on your machine. The fix is to add the missing source or object file, or the missing library, to the link command. With MSVC the same problem appears as error LNK2019: unresolved external symbol. Other causes are a typo between the declaration and the definition, or a function declared in the header but never written.
Multiple definition. If you put a normal function definition in a header and include that header in two .cpp files, both object files contain the function, and the linker reports multiple definition of 'gst::with_gst(double, double)'. This breaks the one definition rule (ODR): a normal function or global variable must be defined exactly once in the whole program. Functions that are defined inside headers must be inline, constexpr or templates; these may appear in several translation units as long as every copy is identical.
Warnings: a free code review
A warning means "this is legal C++, but it is probably a bug". Professional teams turn on many warnings and treat them as errors in their automatic builds. Look at this program:
#include <print>
#include <vector>
int to_paise(double rupees) {
int paise = rupees * 100;
return paise;
}
int main() {
std::vector<int> marks = {78, 92, 65};
for (int i = 0; i < marks.size(); ++i) {
std::println("{}", marks[i]);
}
int unused = to_paise(12.5);
}g++ -std=c++23 -Wall -Wextra -Wconversion warn.cpp -o warnwarn.cpp: In function 'int to_paise(double)':
warn.cpp:5:24: warning: conversion from 'double' to 'int' may change value [-Wfloat-conversion]
warn.cpp: In function 'int main()':
warn.cpp:11:23: warning: comparison of integer expressions of different signedness: 'int' and 'std::vector<int>::size_type' {aka 'long unsigned int'} [-Wsign-compare]
warn.cpp:14:9: warning: unused variable 'unused' [-Wunused-variable]The exact columns and type names vary between compiler versions and systems, but each warning names the flag that enabled it in square brackets. All three are real problems: the conversion silently drops fractions of a paisa, the signed and unsigned comparison breaks when sizes are large, and the unused variable usually means you forgot something.
| GCC and Clang | MSVC | What it does |
|---|---|---|
-Wall -Wextra | /W4 | The common useful warnings (despite its name, -Wall is not all of them) |
-Wpedantic | /permissive- | Reject compiler-specific extensions and stick to standard C++ |
-Wshadow | (part of /W4) | A local name hides another name, a common source of confusion |
-Wconversion | (part of /W4) | Implicit conversions that can lose data |
-Werror | /WX | Turn every warning into an error |
Debug and release builds
The same source can be built in different ways. During development you want a debug build: no optimisation, so the program follows your code line by line in a debugger, plus debugging information. For users you ship a release build: optimised and usually without assert checks.
| Flag | Meaning |
|---|---|
-O0 | No optimisation (the default); fastest to compile, easiest to debug |
-Og | Light optimisation that keeps debugging pleasant |
-O2 | The usual optimisation level for release builds |
-O3 | More aggressive optimisation; sometimes faster, sometimes only bigger |
-g | Add debugging information (file names, line numbers, variable names) |
-DNDEBUG | Define the macro NDEBUG, which turns assert into nothing |
g++ -std=c++23 -Wall -Wextra -O0 -g main.cpp gst.cpp -o shop-debug
g++ -std=c++23 -Wall -Wextra -O2 -DNDEBUG main.cpp gst.cpp -o shopOn MSVC the equivalents are /Od /Zi for debugging and /O2 /DNDEBUG for release.
Libraries: static and shared
A library is a bundle of compiled code that many programs can use. A static library (.a, or .lib on Windows) is copied into your program at link time. A shared library (.so on Linux, .dylib on macOS, .dll on Windows) stays a separate file that is loaded when the program starts, so several programs can share one copy and it can be updated without relinking.
ar rcs libgst.a gst.o # create a static library
g++ main.o -L. -lgst -o shop # -L: where to look, -l: which library-lgst means "find libgst.a or libgst.so". -I folder tells the compiler where to search for headers, just as -L folder tells the linker where to search for libraries. On Linux, libraries listed with -l should come after the object files that use them.
Common mistakes
- Including
.cppfiles.#include "gst.cpp"seems to fix an undefined reference, but it breaks the ODR as soon as two files do it. Link the object file instead. - Defining non-inline functions in headers. This causes multiple definition errors. Put the body in a
.cppfile, or mark itinline. - Ignoring warnings. Each ignored warning hides the next new one. Keep the count at zero.
- Debugging an optimised build. At
-O2variables can disappear and lines run out of order in the debugger. Debug with-O0 -gor-Og -g. - Wrong library order. On Linux,
g++ -lgst main.ocan fail whereg++ main.o -lgstworks.
Key points
- A build has four steps: preprocess, compile, assemble and link; each
.cppfile is compiled separately. - Headers declare, source files define; the one definition rule allows only one definition of each normal function.
- "Undefined reference" and "unresolved external symbol" are linker errors: add the missing object file or library.
- Turn on
-Wall -Wextra(or/W4) and more, and treat warnings as bugs. - Use
-O0 -gwhile debugging and-O2 -DNDEBUGfor release builds.
Exercise
Create a header marks.hpp that declares double average(const std::vector<int>& marks);, a file marks.cpp that defines it, and a main.cpp that prints the average of 78, 92 and 65. Build each source file with -c, then link them. Finally, try linking only main.o and note which tool reports the error.
Show solution
// marks.hpp
#pragma once
#include <vector>
double average(const std::vector<int>& marks);// marks.cpp
#include "marks.hpp"
double average(const std::vector<int>& marks) {
double total = 0;
for (int m : marks) {
total += m;
}
return marks.empty() ? 0.0 : total / static_cast<double>(marks.size());
}// main.cpp
#include "marks.hpp"
#include <print>
int main() {
std::println("Average: {:.2f}", average({78, 92, 65}));
}g++ -std=c++23 -Wall -Wextra -c marks.cpp -o marks.o
g++ -std=c++23 -Wall -Wextra -c main.cpp -o main.o
g++ marks.o main.o -o marks
./marksAverage: 78.33
Linking only main.o fails with an "undefined reference to average(...)" message from ld, the linker, because the compiler step for main.cpp succeeded.
Quick Quiz
1. Which step produces an "undefined reference" error?
The linker connects calls to function bodies; it reports names it cannot find.
2. Which flag makes GCC compile a file into an object file without linking?
-c stops after creating the .o file; -E stops after preprocessing.
3. A normal function body is written in a header that two .cpp files include. What happens?
#pragma once only works inside one translation unit; across files the one definition rule is broken unless the function is inline.
4. Which set of flags suits a debug build?
No optimisation plus debugging information lets a debugger follow your code exactly.
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
