DEV Community

Valerius Petrini
Valerius Petrini

Posted on • Originally published at valerius-petrini.vercel.app

CMake Made Simple: A Reusable Template for Your First C++ Project

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 or FetchContent.
  • 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, like C, C#, and CUDA

Note: This guide assumes you're already familiar with basic C++ syntax (e.g., headers, main(), and compilation with g++). If you're completely new to C++, 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-scale C++ project.

Creating a CMakeLists.txt template

To start with your project, you'll need to do a few things:

  • Install cmake and Ninja.
    • On Windows, download the binaries from the cmake and Ninja websites. After that, add the executables to your PATH.
    • On Linux, install them by running the corresponding command through your distro's package manager. For example, on Ubuntu-based distribution the command is sudo apt install cmake ninja-build
  • Create a new project directory and set up the following structure:
    • You should have a main.cpp file in src, along with a test.cpp file in test.
    • Add an include directory that will hold all hpp files.
    • Add two more files, a CMakeLists.txt and a Makefile.
    • You'll include a simple Makefile that wraps Ninja commands (e.g., make build runs ninja). This is optional and not a traditional build Makefile, but just a convenience wrapper.

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

In your project, rename MyProject and EXECUTABLE to your project name
Additionally, C++ is written as CXX in CMake

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)
Enter fullscreen mode Exit fullscreen mode

CMAKE_BINARY_DIR corresponds to build 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 the CMakeLists.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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 like gdb.

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>)
Enter fullscreen mode Exit fullscreen mode

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>)
Enter fullscreen mode Exit fullscreen mode

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>)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 with MSYS2.
  • 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The find_package() and target_link_libraries() works for many other libraries, not just Boost.

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;
}
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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 when CMake is first initialized. Additionally, we can change Debug to Release 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 running build, then running the executable found in build/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
Enter fullscreen mode Exit fullscreen mode

Note that mingw32-make should come with MSYS2, but if it's not preinstalled you'll need to install it, preferably by running pacman -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)

Collapse
 
pgradot profile image
Pierre Gradot • Edited
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp")
file(GLOB_RECURSE SOURCES_TEST CONFIGURE_DEPENDS "src/*.cpp" "test/*.cpp")
Enter fullscreen mode Exit fullscreen mode

Oh! I didn't know that CONFIGURE_DEPENDS existed! I used GLOB_RECURSE when I started using CMake, in 2017, but the lack of CONFIGURE_DEPENDS was making it highly uncomfortable. Other reasons make me stay away from GLOB_RECURSE, but that interesting to know.

target_compile_options(common_flags INTERFACE
    $<$<CONFIG:Debug>:-Wall -Wextra -fdiagnostics-color=always -g>
    $<$<CONFIG:Release>:-O3>)
Enter fullscreen mode Exit fullscreen mode

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 and RelWithDebInfo 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...

Collapse
 
villyp profile image
Valerius Petrini

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.