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_packageorFetchContent. - 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++,CMakesupports 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
CMakeis 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
cmakeandNinja.- 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.cppfile insrc, along with atest.cppfile intest. - Add an
includedirectory that will hold allhppfiles. - Add two more files, a
CMakeLists.txtand aMakefile. - You'll include a simple
Makefilethat wrapsNinjacommands (e.g.,make buildrunsninja). 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 asCXXinCMake
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_DIRcorresponds tobuildin this case, as that is where all the build files go. Alongside that,CMAKE_CURRENT_SOURCE_DIRis the absolute path of the directory that theCMakeLists.txtfile 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:
-
-Walland-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 UCRT64which comes withMSYS2. - Run
pacman -S mingw-w64-ucrt-x86_64-boostin 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
EXECUTABLEto 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.
-
setupcreates all of the build files. It should be run once whenCMakeis first initialized. Additionally, we can changeDebugtoReleaseto change the flags we defined earlier. -
buildwill compile our code into the executable. The-jat 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. -
runwill 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-makeshould 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_DEPENDSexisted! I usedGLOB_RECURSEwhen I started using CMake, in 2017, but the lack ofCONFIGURE_DEPENDSwas 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,
-gshouldn't be added manually.CMAKE_BUILD_TYPEwill add the appropriate options, so you should rely on, it. For instance,DebugandRelWithDebInfowill 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.