Mastering CMake: A Practical Guide for DevOps Engineers and Developers

CMake is the backbone of modern C++ build systems. Whether you're compiling a simple executable or orchestrating large-scale multi-module projects with external libraries, mastering CMake can make your builds cleaner, faster, and more maintainable. This guide captures everything you need to know to write robust CMakeLists.txt files with real-world examples and explanations. Table of Contents Basic CMake Commands Project Structure Commonly Used Variables Best Practices Compiler Warnings Configuring files Unit Testing with Catch2 Compile Features and Definitions Sanitizers IPO & LTO Generator Expressions External Libraries (Git Submodules & FetchContent) Useful CMake CLI Flags Basic CMake Commands cmake_minimum_required(VERSION 3.10) This command specifies the minimum CMake version required for your project. It should be the first line in your top-level CMakeLists.txt. project(MyApp VERSION 1.0 LANGUAGES CXX) Defines the project name, version, and language. add_executable(MyApp main.cpp helper.cpp) This tells CMake to compile main.cpp and helper.cpp into an executable named MyApp. You can list as many source files as needed. The target name (MyApp) is used in other commands, like target_link_libraries(MyApp PRIVATE SomeLib) add_library(MyLib STATIC mylib.cpp) Defines a library named MyLib. STATIC: Creates a static library (.lib or .a). Linked at compile time. SHARED: Creates a shared (dynamic) library (.dll or .so). Loaded at runtime. MODULE: Creates a library that is not linked but loaded at runtime (e.g., for plugins). INTERFACE: No output file is built. Used for header-only libraries. target_include_directories(MyLib PUBLIC include) Specifies include directories. Types: PUBLIC: Used by both the library and users PRIVATE: Used only by the library INTERFACE: Used only by users add_subdirectory(utils) Includes utils/CMakeLists.txt in the build. target_link_libraries(MyApp PRIVATE MyLib) Links MyLib with MyApp. set(MY_VAR "value") Defines a variable. option(BUILD_TESTS "Build test binaries" ON) Defines a boolean option with description. if(USE_MY_FEATURE) message(STATUS "Building with my feature") else() message(STATUS "Building without my feature") endif() Conditional logic in CMake. Project Structure project-root/ ├── CMakeLists.txt ├── main.cpp ├── include/ │ └── mylib.hpp ├── src/ │ └── mylib.cpp ├── tests/ │ ├── CMakeLists.txt │ └── test_main.cpp Commonly Used Variables CMAKE_SOURCE_DIR: Top-level source directory CMAKE_PROJECT_NAME: Name set in project() CMAKE_BINARY_DIR: Top-level build directory CMAKE_CURRENT_SOURCE_DIR: Source dir of the current CMakeLists.txt CMAKE_CURRENT_LIST_DIR: Directory containing the currently processed CMake file CMAKE_MODULE_PATH: List of additional module directories Best Practices ✅ Always list files manually instead of using file(GLOB ...) ✅ Modularize using add_subdirectory() ✅ Use target_* commands (not global ones) ✅ Separate headers and sources by folder ❌ Don’t pollute global scope with variables Compiler Warnings Use portable warnings with: target_compile_options(MyApp PRIVATE $ $ ) Configuring Files with configure_file CMake’s configure_file() command lets you copy and process a template file at configure time, replacing variable placeholders. For example, if you have a header template file.h.in: // file.h.in #define VERSION @PROJECT_VERSION@ and in your CMakeLists.txt you do: project(MyApp VERSION 1.2.3) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/file.h.in ${CMAKE_CURRENT_BINARY_DIR}/file.h @ONLY) CMake will produce file.h in the build directory with @PROJECT_VERSION@ replaced by the actual version string (e.g. #define VERSION 1.2.3). In general, configure_file() copies a “.in” file to the build tree and substitutes any ${VAR} with the current CMake variable values cliutils.gitlab.io . This is very useful for generating headers (or even .cmake files) that convey build-time settings like version, feature toggles, or compile options. Remember to add the build directory to your include path (e.g. via target_include_directories) so that code can #include "file.h" from the generated location. Unit Testing with Catch2 Setup git submodule add https://github.com/catchorg/Catch2.git external/Catch2 CMakeLists.txt add_subdirectory(external/Catch2) add_executable(tests test_main.cpp) target_link_libraries(tests PRIVATE Catch2::Catch2WithMain) include(CTest) include(Catch) catch_discover_tests(tests) Compile Features and Definitions Enforce Features target_compile_features(MyApp PRIVATE cxx_std_17) Add Macros target_compile_definitions(MyApp PRIVATE VERSION=1.0) Sanitizers target_compile_options(MyApp PRIVATE -fsanitize=address) target_link_options(MyApp PRIVATE -fsanitize=address) Apply similar for undefined san

May 10, 2025 - 05:33
 0
Mastering CMake: A Practical Guide for DevOps Engineers and Developers

CMake is the backbone of modern C++ build systems. Whether you're compiling a simple executable or orchestrating large-scale multi-module projects with external libraries, mastering CMake can make your builds cleaner, faster, and more maintainable.

This guide captures everything you need to know to write robust CMakeLists.txt files with real-world examples and explanations.

Table of Contents

  1. Basic CMake Commands

  2. Project Structure

  3. Commonly Used Variables

  4. Best Practices

  5. Compiler Warnings

  6. Configuring files

  7. Unit Testing with Catch2

  8. Compile Features and Definitions

  9. Sanitizers

  10. IPO & LTO

  11. Generator Expressions

  12. External Libraries (Git Submodules & FetchContent)

  13. Useful CMake CLI Flags

Basic CMake Commands

cmake_minimum_required(VERSION 3.10)
This command specifies the minimum CMake version required for your project. It should be the first line in your top-level CMakeLists.txt.

project(MyApp VERSION 1.0 LANGUAGES CXX)
Defines the project name, version, and language.

add_executable(MyApp main.cpp helper.cpp)
This tells CMake to compile main.cpp and helper.cpp into an executable named MyApp. You can list as many source files as needed. The target name (MyApp) is used in other commands, like target_link_libraries(MyApp PRIVATE SomeLib)

add_library(MyLib STATIC mylib.cpp)
Defines a library named MyLib.

STATIC: Creates a static library (.lib or .a). Linked at compile time.
SHARED: Creates a shared (dynamic) library (.dll or .so). Loaded at runtime.
MODULE: Creates a library that is not linked but loaded at runtime (e.g., for plugins).
INTERFACE: No output file is built. Used for header-only libraries.

target_include_directories(MyLib PUBLIC include)
Specifies include directories. Types:

PUBLIC: Used by both the library and users
PRIVATE: Used only by the library
INTERFACE: Used only by users

add_subdirectory(utils)
Includes utils/CMakeLists.txt in the build.

target_link_libraries(MyApp PRIVATE MyLib)
Links MyLib with MyApp.

set(MY_VAR "value")
Defines a variable.

option(BUILD_TESTS "Build test binaries" ON)
Defines a boolean option with description.

if(USE_MY_FEATURE)
    message(STATUS "Building with my feature")
else()
    message(STATUS "Building without my feature")
endif()

Conditional logic in CMake.

Project Structure

project-root/
├── CMakeLists.txt
├── main.cpp
├── include/
│   └── mylib.hpp
├── src/
│   └── mylib.cpp
├── tests/
│   ├── CMakeLists.txt
│   └── test_main.cpp

Commonly Used Variables

CMAKE_SOURCE_DIR: Top-level source directory
CMAKE_PROJECT_NAME: Name set in project()
CMAKE_BINARY_DIR: Top-level build directory
CMAKE_CURRENT_SOURCE_DIR: Source dir of the current CMakeLists.txt
CMAKE_CURRENT_LIST_DIR: Directory containing the currently processed CMake file
CMAKE_MODULE_PATH: List of additional module directories

Best Practices

✅ Always list files manually instead of using file(GLOB ...)
✅ Modularize using add_subdirectory()
✅ Use target_* commands (not global ones)
✅ Separate headers and sources by folder
❌ Don’t pollute global scope with variables

Compiler Warnings

Use portable warnings with:

target_compile_options(MyApp PRIVATE
  $<$:-Wall -Wextra -Wpedantic>
  $<$:/W4>
)

Configuring Files with configure_file

CMake’s configure_file() command lets you copy and process a template file at configure time, replacing variable placeholders. For example, if you have a header template file.h.in:

// file.h.in
#define VERSION @PROJECT_VERSION@

and in your CMakeLists.txt you do:

project(MyApp VERSION 1.2.3)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/file.h.in
               ${CMAKE_CURRENT_BINARY_DIR}/file.h
               @ONLY)

CMake will produce file.h in the build directory with @PROJECT_VERSION@ replaced by the actual version string (e.g. #define VERSION 1.2.3). In general, configure_file() copies a “.in” file to the build tree and substitutes any ${VAR} with the current CMake variable values
cliutils.gitlab.io
. This is very useful for generating headers (or even .cmake files) that convey build-time settings like version, feature toggles, or compile options. Remember to add the build directory to your include path (e.g. via target_include_directories) so that code can #include "file.h" from the generated location.

Unit Testing with Catch2

Setup
git submodule add https://github.com/catchorg/Catch2.git external/Catch2

CMakeLists.txt

add_subdirectory(external/Catch2)
add_executable(tests test_main.cpp)
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
include(CTest)
include(Catch)
catch_discover_tests(tests)

Compile Features and Definitions

Enforce Features
target_compile_features(MyApp PRIVATE cxx_std_17)

Add Macros
target_compile_definitions(MyApp PRIVATE VERSION=1.0)

Sanitizers

target_compile_options(MyApp PRIVATE -fsanitize=address)
target_link_options(MyApp PRIVATE -fsanitize=address)

Apply similar for undefined sanitizer.

IPO & LTO

Enable link-time optimization:
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)

Or per target:
set_property(TARGET MyApp PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)

Generator Expressions

Inline conditional logic:
$<$:-DDEBUG>

External Libraries

Git Submodules
Add submodule: git submodule add external/LibName
In root CMake:

add_subdirectory(external/LibName)
target_link_libraries(MyApp PRIVATE LibName)

FetchContent

include(FetchContent)
FetchContent_Declare(
  fmt
  GIT_REPOSITORY https://github.com/fmtlib/fmt.git
  GIT_TAG master
)
FetchContent_MakeAvailable(fmt)
target_link_libraries(MyApp PRIVATE fmt::fmt)

Useful CMake CLI Flags

cmake -S . -B build/ -G "Ninja"
-S: Source directory
-B: Build directory
-G: Generator

cmake --build build/ --target MyApp
Builds only MyApp

cmake --build . --target extLib
Build external target

Dependency Graph

Use Graphviz to visualize target dependencies:

cmake --graphviz=graph.dot .
dot -Tpng graph.dot -o graph.png

Conclusion

Mastering CMake is about understanding its modular philosophy. Start with small clean builds, add subdirectories and targets gradually, and leverage the power of target_ commands for scalability and control.

Useful links: