This article was originally published on Valerius Petrini's Programming Blog on 08/05/2025
When starting up a new C++
project, you might run a command using your compiler like g++ main.cpp -o main
to compile your program into an executable. However, as your project grows and includes more source files, libraries, and dependencies, managing compilation from the command line can become tedious and prone to errors.
This is where CMake
comes in. CMake
is a cross-platform build system generator that helps automate and organize the compilation of your C++
projects. Instead of writing shell commands by hand, you can write a CMakeLists.txt
file, and CMake
will generate build scripts for you. After that, you can use a tool like Ninja
or Make
to run the generated build scripts and produce your executable.
Here are some reasons why CMake
is a great tool for working with C++
:
- Incremental Builds - Only changed files are recompiled, saving development time.
-
Dependency Management - Easily include external libraries using
find_package
orFetchContent
. - Out-of-Source Builds - Keeps the source directory clean by placing build files in a separate directory.
- IDE Support - Editors like VSCode and IDEs like Visual Studio support CMake and offer features like IntelliSense, code navigation, and integrated debugging
-
Multi-Language Support Although this article will go over
C++
,CMake
supports multiple different languages, likeC
,C#
, andCUDA
Note: This guide assumes you're already familiar with basic C++ syntax (e.g., headers,
main()
, and compilation withg++
). If you're completely new toC++
, I recommend starting with a beginner course like Learncpp.com (Chapters 0 through 2 should teach you the fundamentals you need to follow along with this guide).Once you're comfortable with the basics, learning
CMake
is a great next step to save time and avoid frustration down the road. It’s also an excellent way to get started on your first large-scaleC++
project.
Creating a CMakeLists.txt template
To start with your project, you'll need to do a few things:
- Install
cmake
andNinja
.- On
Windows
, download the binaries from the cmake and Ninja websites. After that, add the executables to yourPATH
. - On
Linux
, install them by running the corresponding command through your distro's package manager. For example, on Ubuntu-based distribution the command issudo apt install cmake ninja-build
- On
- Create a new project directory and set up the following structure:
- You should have a
main.cpp
file insrc
, along with atest.cpp
file intest
. - Add an
include
directory that will hold allhpp
files. - Add two more files, a
CMakeLists.txt
and aMakefile
. - You'll include a simple
Makefile
that wrapsNinja
commands (e.g.,make build
runsninja
). This is optional and not a traditional buildMakefile
, but just a convenience wrapper.
- You should have a
Once you're done with those steps, your directory tree should look like this:
your_project/
├── CMakeLists.txt
├── include/
│ └── your_headers.hpp
├── src/
│ └── main.cpp
├── test/
│ └── test.cpp
└── build/ <-- created when you run cmake to configure your project
If everything looks good, we can begin creating our CMakeLists.txt
file.
Basic CMakeLists.txt header
Open your CMakeLists.txt
and start with these few lines:
cmake_minimum_required(VERSION 3.10)
project(MyProject VERSION 0.1 LANGUAGES CXX)
set(EXEC_NAME "EXECUTABLE")
set(EXEC_NAME_TEST "EXECUTABLE_TEST")
In your project, rename MyProject and EXECUTABLE to your project name
Additionally, C++ is written asCXX
inCMake
The first two lines are metadata on the project. The first sets what version of CMake
we need, and the second gives info about our project, like the name, version, and languages it uses.
The next two variables define the names for the main and test executables.
After that, we have a few more variable assignments:
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
CMAKE_BINARY_DIR
corresponds tobuild
in this case, as that is where all the build files go. Alongside that,CMAKE_CURRENT_SOURCE_DIR
is the absolute path of the directory that theCMakeLists.txt
file is in.
The first two lines have to deal with the C++
version we are using, specifically forcing C++ 17
be used. After that, we make it so that all of our executables are put inside build/bin
by overriding CMAKE_RUNTIME_OUTPUT_DIRECTORY
, to keep them separate from the other build files.
Next, the final two lines of our setup are:
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp")
file(GLOB_RECURSE SOURCES_TEST CONFIGURE_DEPENDS "src/*.cpp" "test/*.cpp")
GLOB
will take all of the files provided and put them all into one variable, here named SOURCES
and SOURCES_TEST
. This is helpful as instead of having to declare all of our files individually, we group the all together using the *
wildcard.
Additionally, since we use CONFIGURE_DEPENDS
, every time we recompile our project, it will detect new files added.
You might also be wondering why we are including all of our source files in our test sources. That's because our test files depend on the compiled implementations of our main code defined in src/
.
Creating the executable
The next section is where we actually create the executable.
For simplicity’s sake, I will be excluding all code relating to the test directory, although it is identical to the main executable, except with all the names changed.
Here’s the short snippet:
add_executable(${EXEC_NAME} ${SOURCES})
target_include_directories(${EXEC_NAME} PRIVATE include/)
target_compile_options(${EXEC_NAME} PRIVATE -Wall -Wextra -fdiagnostics-color=always -g)
The first line adds all of the source files into our executable, while the second tells the compiler where to find all the header files. target_include_directories
will tell our compiler that all the hpp
files in our project are located in the include/
directory.
The last line is more of a configuration line. target_compile_options
allows us to specify what special things we want the compiler to do, and in this case we do 4 things:
-
-Wall
and-Wextra
: These enable more warnings, making sure our codebase is clean and organized. -
-fdiagnostics-color=always
: Ensures our errors and warnings have colors assigned to them (red for errors and purple for warnings) so they are easy to differentiate at a glance. -
-g
: This adds debug information to our code, like memory addresses of variables, so that we can use debuggers likegdb
.
After that last line, we have a functional CMakeLists.txt
, but we can still do some more optimizations.
Optimizing target_compile_options
When we actually finish our program and plan to publish it, all of the compiler options we specified are unnecessary. Warnings and colorized output are helpful during development, but unnecessary in the final executable. Additionally, using -g
will bloat our executable with data the end user will never see.
On top of that, certain compiler options should be added if we are building to publish, like the -O3
flag. This enables more compiler optimizations, so our executable will run much faster, but we don't want to add that flag while debugging because it would slow down our development process.
To counter this, we can use CMake
conditional statements:
target_compile_options(${EXEC_NAME} PRIVATE
$<$<CONFIG:Debug>:-Wall -Wextra -fdiagnostics-color=always -g>
$<$<CONFIG:Release>:-O3>)
This makes it so that if we are in debug mode, we activate all the debugging options, but if we are in release mode, we enable the compiler's highest optimization level to improve performance. Later on, we'll go over how to switch between the two.
Cutting out repetition
If you've been following along, you should have executable code that looks something like this:
add_executable(${EXEC_NAME} ${SOURCES})
target_include_directories(${EXEC_NAME} PRIVATE include/)
target_compile_options(${EXEC_NAME} PRIVATE
$<$<CONFIG:Debug>:-Wall -Wextra -fdiagnostics-color=always -g>
$<$<CONFIG:Release>:-O3>)
add_executable(${EXEC_NAME_TEST} ${SOURCES})
target_include_directories(${EXEC_NAME_TEST} PRIVATE include/)
target_compile_options(${EXEC_NAME_TEST} PRIVATE
$<$<CONFIG:Debug>:-Wall -Wextra -fdiagnostics-color=always -g>
$<$<CONFIG:Release>:-O3>)
As you can probably tell, our target_compile_options
takes up a large amount of space, for what amounts to the same exact code.
Thankfully, CMake
gives us a way to deal with this by extracting that code into its own library.
Insert this code after defining your source files (e.g., after file globbing), but before the executables:
add_library(common_flags INTERFACE)
target_compile_options(common_flags INTERFACE
$<$<CONFIG:Debug>:-Wall -Wextra -fdiagnostics-color=always -g>
$<$<CONFIG:Release>:-O3>)
This defines a new library, common_flags
, which contains all of the flags. With this, we can replace the target_compile_options
in our executable generation code with this:
target_link_libraries(${EXEC_NAME} PRIVATE common_flags)
target_link_libraries(${EXEC_NAME_TEST} PRIVATE common_flags)
Importing Libraries
This topic is a bit more advanced and totally optional, but if you ever want to use other libraries, CMake
can do this too. If you're not interested in this or just want to see how to actually run your file, you can skip to the Makefile chapter.
Boost
is a popular library that defines a lot of helper functions alongside helpful tools for users. For this example, let's say we need to check if a file exists, so we decide to use Boost
Filesystem module.
The first step is to download Boost
. On Linux
, you can install the package libboost-all-dev
using your package manager. On Windows
(assuming you are using MSYS2
), you will need to follow these steps:
- Launch
MSYS2 MinGW UCRT64
which comes withMSYS2
. - Run
pacman -S mingw-w64-ucrt-x86_64-boost
in the terminal.
Once that is done, you'll need to add this code somewhere in the setup section:
find_package(Boost REQUIRED COMPONENTS filesystem)
This will find the package from MSYS2
and register it in CMake
.
At this point, all we need to do is link against the library:
target_link_libraries(${EXEC_NAME} PRIVATE common_flags Boost::filesystem)
target_link_libraries(${EXEC_NAME_TEST} PRIVATE common_flags Boost::filesystem)
The
find_package()
andtarget_link_libraries()
works for many other libraries, not justBoost
.
Here's a small snippet showing what a main.cpp
file would look like:
#include <boost/filesystem.hpp>
#include <iostream>
int main() {
boost::filesystem::path p("example.txt");
if (boost::filesystem::exists(p))
std::cout << p << " exists.\n";
else
std::cout << p << " does not exist.\n";
return 0;
}
After that, the filesystem code should be ready for you to use.
Makefile
With our CMakeLists.txt
file finished, its finally time to run our code.
For those unfamiliar, with Makefile
, we can use it to simplify commands. Take this basic Makefile
:
.ONESHELL:
ifeq ($(OS),Windows_NT)
EXE := .exe
SEP := \\
EXEC_PREFIX :=
else
EXE :=
SEP := /
EXEC_PREFIX := ./
endif
setup:
cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Debug
build:
cmake --build build --config Debug -- -j4
run: build
@echo Running main executable...
"${EXEC_PREFIX}build$(SEP)bin$(SEP)EXECUTABLE$(EXE)"
Be sure to change
EXECUTABLE
to the name of your executable file
The first line, .ONESHELL:
is to make sure all the commands run in one confined shell instead of each command in it's own. This is a Make
feature that prevents issues with environment changes (like the current working directory) between lines. The ifeq
statement is then used to define some of the differences between Linux
and Windows
, like file separators and whether or not executable files end in .exe
.
After that, we have three commands, setup
, build
, and run
.
-
setup
creates all of the build files. It should be run once whenCMake
is first initialized. Additionally, we can changeDebug
toRelease
to change the flags we defined earlier. -
build
will compile our code into the executable. The-j
at the very end specifies to split the command into 4 separate jobs, so that it can execute the build faster. The number can be changed depending on how many cores your CPU has. -
run
will compile the code by first runningbuild
, then running the executable found inbuild/bin
In order to actually run these commands, you would write these commands, depending on your platform:
Linux:
make setup
make run
Windows:
mingw32-make setup
mingw32-make run
Note that
mingw32-make
should come withMSYS2
, but if it's not preinstalled you'll need to install it, preferably by runningpacman -S mingw-w64-x86_64-make
Once you run make run
, your program's output should appear in the terminal.
Final thoughts and Gist
By now, you should have a general understanding of CMakeLists.txt
and Makefile
. Now that you've done all the setup, you're free to code as much as you want without having to worry anymore about configuration.
Additionally, you can find a GitHub Gist
with a CMakeLists.txt
template alongside a much more complete Makefile
here.
Top comments (3)
Oh! I didn't know that
CONFIGURE_DEPENDS
existed! I usedGLOB_RECURSE
when I started using CMake, in 2017, but the lack ofCONFIGURE_DEPENDS
was making it highly uncomfortable. Other reasons make me stay away fromGLOB_RECURSE
, but that interesting to know.I think this is not the right way to do it.
First, you always want to compile with
-Wall -Wextra
, no matter the build type. So they should be outside any generator expression.Second,
-g
shouldn't be added manually.CMAKE_BUILD_TYPE
will add the appropriate options, so you should rely on, it. For instance,Debug
andRelWithDebInfo
will add-g
. This remark may apply to-O3
: except if you really want-O3
, just let CMake add the appropriate options. See this discussions stackoverflow.com/questions/487546...Thank you for the detailed feedback! And yes, you are right about the compile options being moved outside the generator expression. I appreciate you pointing that out!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.