1. Introduction

I’ve been working on a C++ project recently and had to learn CMake. Honestly, CMake’s quirky syntax can be quite intimidating at first. But once you get hands-on, you’ll find that only a small subset is needed day-to-day, and it generally follows predictable patterns. Master this subset and you can likely organize a fairly large project. That’s exactly what this tutorial aims to cover.

Of course, you’ll need to understand compilation and linking before reading this. For compilation-related commands, see my article on Command-Line Compilation.

All code for this article is in the practical-cmake repository. Stars welcome :).

Install CMake (apt):

sudo apt-get install build-essential
sudo apt-get install cmake

2. First Steps

For a first program, we must of course bring out the classic “hello, world”:

#include <stdio.h>

int main()
{
    printf("hello, world\n");
    return 0;
}

I’ll first show the gcc command, then the CMake equivalent. For step one, gcc is simple:

gcc main.cpp -o main

CMake is similar. We need a CMakeLists.txt configuration file:

cmake_minimum_required(VERSION 3.10)

project(main)

add_executable(main main.cpp)

The first line specifies the CMake version requirement, the second names the project, and add_executable does what gcc did: specifies the source file main.cpp and output name main, producing an executable.

To build, first run cmake to generate build files (including a Makefile). Then run make. To keep source directories clean, create a separate build directory:

mkdir build
cd build
cmake ..

Then compile and run:

make
./main

At this stage, CMake seems no better than gcc — even more steps. But as the project grows more complex, CMake’s advantages become clear.

3. Including Headers

Most real projects have more than one file. Multiple source files can be compiled into a single .o file, with symbols shared through headers. Consider this project:

.
|-- CMakeLists.txt
|-- include
|   `-- add.h
`-- src
    |-- add.cpp
    `-- main.cpp

add.h declares int add(int a, int b), implemented in add.cpp. main.cpp calls this function and needs to include add.h.

With gcc:

gcc src/add.cpp src/main.cpp -I ./include -o main

With CMake:

cmake_minimum_required(VERSION 3.10)

project(main)

add_executable(main src/main.cpp src/add.cpp)
target_include_directories(main PRIVATE include)

target_include_directories adds ./include to the header search path for target main. The PRIVATE keyword means the headers are only used by the current target and not exposed to dependents. Other options: PUBLIC (exposed to dependents) and INTERFACE (not used by the target itself, but exposed). Don’t worry if this isn’t clear yet — we’ll revisit it.

4. Building and Linking Libraries

Sometimes you want to compile a module into a library for reuse across programs. Let’s reorganize the previous example — since main depends on add, we compile add as a library and link main against it:

.
|-- add
|   |-- include
|   |   `-- add
|   |       `-- add.h
|   `-- src
|       `-- add.cpp
|-- CMakeLists.txt
`-- main.cpp

A useful convention: nest headers under a library-named directory inside include/. Then you use #include <add/add.h> instead of #include <add.h>, avoiding name collisions.

With gcc, building a static library:

gcc -c add/src/add.cpp -I add/include
ar cr libadd.a add.o
gcc main.cpp -I add/include -L . -l add -o main

For a shared library:

gcc -c -fPIC add/src/add.cpp -I add/include -o add.o
gcc -shared -fPIC add.o -o libadd.so
gcc main.cpp libadd.so -I add/include -o main
LD_LIBRARY_PATH=. ./main

As projects get more complex, the build commands become more tedious. Shell scripts handle simple cases; Makefiles manage complex dependencies; for truly large projects, higher-level tools like CMake are essential.

With CMake, library building and linking remain simple. Two new commands:

cmake_minimum_required(VERSION 3.10)

project(main)

add_library(add add/src/add.cpp)
target_include_directories(add PUBLIC add/include)

add_executable(main main.cpp)
target_link_libraries(main PRIVATE add)

add_library replaces add_executable for the add module. By default this creates a shared library (add STATIC for static: add_library(add STATIC add/src/add.cpp)). Then target_link_libraries links main against add. CMake handles static and shared libraries uniformly, simplifying the workflow.

There’s also header-only libraries:

add_library(headerlib INTERFACE)
target_include_directories(headerlib INTERFACE headerlib/include)

Note PUBLIC vs PRIVATE here. Since add.h is included by main.cpp, we use PUBLIC. If add.cpp didn’t include add.h (it could, since it defines the function), we could use INTERFACE or PUBLIC.

5. Project Organization — Subdirectories

For large projects, dumping all source files together is a bad idea. Split code into modules, each in its own directory, compiled as a library. But managing all modules in a single CMakeLists.txt becomes bloated. The solution: place a CMakeLists.txt in each subdirectory and aggregate them.

CMake needs just one command: add_subdirectory. Example project:

.
|-- add
|   |-- CMakeLists.txt
|   |-- include
|   |   `-- add
|   |       `-- add.h
|   `-- src
|       `-- add.cpp
|-- CMakeLists.txt
|-- fibo
|   |-- CMakeLists.txt
|   |-- include
|   |   `-- fibo
|   |       `-- fibo.h
|   `-- src
|       `-- fibo.cpp
`-- main.cpp

We’ve added a fibo module for computing Fibonacci numbers, using the add function. main.cpp now loops through 1–10 Fibonacci numbers. Dependency graph:

fibo -> add
main -> add
main -> fibo

Each module has its own CMakeLists.txt:

# add/CMakeLists.txt
add_library(add src/add.cpp)
target_include_directories(add PUBLIC include)
# fibo/CMakeLists.txt
add_library(fibo src/fibo.cpp)
target_include_directories(fibo INTERFACE include)
target_link_libraries(fibo PRIVATE add)

The root CMakeLists.txt uses add_subdirectory:

cmake_minimum_required(VERSION 3.10)

project(main)

add_subdirectory(add)
add_subdirectory(fibo)

add_executable(main main.cpp)
target_link_libraries(main PRIVATE add fibo)

Again, note PUBLIC/INTERFACE/PRIVATE. fibo’s source doesn’t include fibo.h → INTERFACE. fibo depends on add but doesn’t expose it → PRIVATE (removing add from main’s target_link_libraries would cause an error).

With add_subdirectory, the project structure is beautifully clear!

6. I/O, Variables, and Control Flow

Sometimes you want to build a project in different ways — perhaps only certain modules, or conditionally include unit tests. CMake supports parameters, variables, and control flow, almost like a programming language. We’ll keep it brief and practical.

CMake’s I/O, variables, and control flow execute during the cmake configure step.

I/O

Use option for boolean input parameters (or set with CACHE STRING for string parameters — outside our scope). Use message for output:

cmake_minimum_required(VERSION 3.10)

project(io)

option(OPTION_VAR "this is help text" OFF)

message("the value of OPTION_VAR is ${OPTION_VAR}")

Variables

Use set(name "value") for variables. CMake also provides built-in variables like PROJECT_NAME, CMAKE_SOURCE_DIR, CMAKE_CURRENT_DIR, etc.:

cmake_minimum_required(VERSION 3.10)

project(variable)

set(USER_VAR "default")

message("the value of USER_VAR is ${USER_VAR}")
message("the name of project is ${PROJECT_NAME}")
message("the whole project's dir is ${CMAKE_SOURCE_DIR}")

Output:

the value of USER_VAR is default
the name of project is variable
the whole project's dir is /home/wokron/Code/Projects/practical-cmake/variable

With built-in variables, we can simplify earlier CMakeLists.txt files — e.g., replacing explicit target names with ${PROJECT_NAME}.

Control Flow

CMake supports branching:

cmake_minimum_required(VERSION 3.10)

project(controlflow)

option(OPTION_VAR "this is option" ON)

if(OPTION_VAR)
    message("OPTION_VAR is on")
else()
    message("OPTION_VAR is off")
endif()

set(STR_VAR "123")

if(STR_VAR MATCHES "123")
    message("is 123!")
elseif(STR_VAR MATCHES "456")
    message("is 456")
else()
    message("is other :(")
endif()

Parameters and branching make builds more flexible, adapting to complex requirements.

Subdirectory Behavior

What happens with add_subdirectory? Simply: CMake executes the subdirectory’s commands, then returns to the parent. It’s almost like a function call — subdirectory CMakeLists.txt may contain option declarations, and the parent can set those options before add_subdirectory to control the sub-build. This is especially useful with third-party libraries.

7. Testing

CMake provides simple testing support. First, enable it in the root CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)

project(main)

enable_testing() # here!!!

add_subdirectory(add)
add_subdirectory(fibo)
add_subdirectory(test)

add_executable(main main.cpp)
target_link_libraries(main PRIVATE add fibo)

Then create test executables and register them with add_test. Here we use a test subdirectory:

# test/CMakeLists.txt
add_executable(test_add test_add.cpp)
target_link_libraries(test_add add)

add_executable(test_fibo test_fibo.cpp)
target_link_libraries(test_fibo fibo)

add_test(NAME test_add COMMAND test_add)
add_test(NAME test_fibo COMMAND test_fibo)

Simple test files:

// test_add.cpp
#include "add/add.h"
#include <assert.h>

int main()
{
    assert(add(1, 2) == 3);
    assert(add(1, -1) == 0);
    assert(add(1, -2) == -1);
    assert(add(100, 100) == 200);
    return 0;
}
// test_fibo.cpp
#include "fibo/fibo.h"
#include <assert.h>

int main()
{
    assert(fibonacci(1) == 1);
    assert(fibonacci(2) == 1);
    assert(fibonacci(3) == 2);
    assert(fibonacci(4) == 3);
    assert(fibonacci(5) == 5);
    assert(fibonacci(6) == 8);
    assert(fibonacci(7) == 13);
    assert(fibonacci(8) == 21);
    assert(fibonacci(9) == 34);
    assert(fibonacci(10) == 55);
    return 0;
}

Build and test:

cmake ..
make
make test

Output — all tests pass!

Test project /home/wokron/Code/Projects/practical-cmake/build/test
    Start 1: test_add
1/2 Test #1: test_add .........................   Passed    0.00 sec
    Start 2: test_fibo
2/2 Test #2: test_fibo ........................   Passed    0.00 sec

100% tests passed, 0 tests failed out of 2

Total Test time (real) =   0.00 sec

Final project structure:

.
|-- add
|   |-- CMakeLists.txt
|   |-- include
|   |   `-- add
|   |       `-- add.h
|   `-- src
|       `-- add.cpp
|-- CMakeLists.txt
|-- fibo
|   |-- CMakeLists.txt
|   |-- include
|   |   `-- fibo
|   |       `-- fibo.h
|   `-- src
|       `-- fibo.cpp
|-- main.cpp
`-- test
    |-- CMakeLists.txt
    |-- test_add.cpp
    `-- test_fibo.cpp