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
PUBLICvsPRIVATEhere. Sinceadd.his included bymain.cpp, we usePUBLIC. Ifadd.cppdidn’t includeadd.h(it could, since it defines the function), we could useINTERFACEorPUBLIC.
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(removingaddfrommain’starget_link_librarieswould 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
cmakeconfigure 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