BUILD: CMake Introduction — Cross-Platform Build System

CMake is the industry-standard cross-platform build system generator for C and C++ projects. Rather than defining how to build, CMake describes what to build, and generates native build files for any target platform — from Unix Makefiles to Visual Studio solutions and Xcode projects.

Building C and C++ code correctly across different platforms, compilers, and project configurations is a notoriously complex problem. CMake was created specifically to solve it. Instead of writing platform-specific build scripts, you describe your project once in a CMakeLists.txt file, and CMake generates the appropriate native build files for whatever environment you are targeting.


What Is CMake?

CMake stands for Cross-platform Make. It is an open-source, cross-platform tool for building, testing, and packaging software. It was first developed in 2000 by Andy Cedilnik at Kitware and has since become the de facto standard for C and C++ projects.

text
CMake's Role in the Build Pipeline:
+---------------------+
| CMakeLists.txt      |  (your project description)
+---------------------+
          |
          v
+---------------------+
|    cmake command    |  (configure step)
+---------------------+
          |
     +---------+----------+----------+
     |         |          |          |
     v         v          v          v
+--------+ +--------+ +-------+ +--------+
|Makefile| |VS Proj | | Xcode | | Ninja  |
+--------+ +--------+ +-------+ +--------+
     |
     v
+-----------------------------------+
|  make / MSBuild / xcodebuild      |  (actual compilation)
+-----------------------------------+

CMake does not compile your code itself. It generates the files that your native build system (Make, Ninja, Visual Studio, Xcode) uses to compile.


Why CMake?

Before CMake, C/C++ projects maintained separate build scripts for each platform: Makefiles for Linux, project files for Visual Studio on Windows, and Xcode projects for macOS. Keeping these in sync was error-prone and time-consuming.

CMake solves this with a single source of truth:

Without CMakeWith CMake
Separate Makefile per platformOne CMakeLists.txt
Manual dependency trackingAutomatic dependency management
Hard to integrate external librariesfind_package() and FetchContent
No standardized testingctest integration
No package manager integrationCPack for packaging

CMake is used by projects of all sizes, from single-file programs to large systems like LLVM, KDE, and Qt.


The CMake Language

CMake scripts are written in the CMake language. The syntax is straightforward: a series of commands that take arguments. Commands are case-insensitive by convention but are usually written in lowercase:

cmake
command_name(argument1 argument2 argument3)

Comments use #:

cmake
# This is a CMake comment


The CMakeLists.txt File

Every CMake project starts with a CMakeLists.txt file in the root of the project. Here is a complete, annotated example:

cmake
# Minimum CMake version required to process this file
cmake_minimum_required(VERSION 3.12)

# Project name, version, and programming language(s)
project(My_project VERSION 1.0 LANGUAGES CXX)

# Build a static library from source files
add_library(my_lib STATIC my_lib_source.cpp)

# Tell the compiler where to find header files for the library
# PUBLIC means these include paths are also exposed to targets that link my_lib
target_include_directories(my_lib PUBLIC directory_has_headers)

# Build an executable from main.cpp
add_executable(my_program main.cpp)

# Tell the compiler where to find header files for the executable
# PRIVATE means these includes are only for my_program, not propagated further
target_include_directories(my_program PRIVATE
    first_dir/
    second_dir/)

# Link my_program against my_lib
# PRIVATE means this link dependency is not propagated to targets that link my_program
target_link_libraries(my_program PRIVATE my_lib)


Key CMake Commands

`cmake_minimum_required`

Specifies the minimum version of CMake needed to process the file. This protects users from cryptic errors if their CMake is too old:

cmake
cmake_minimum_required(VERSION 3.20)

`project`

Declares the project name and metadata:

cmake
project(MyProject
    VERSION 2.0.1
    DESCRIPTION "A sample CMake project"
    LANGUAGES CXX C)

`add_executable`

Creates a build target that produces an executable:

cmake
add_executable(my_app
    src/main.cpp
    src/utils.cpp
    src/config.cpp)

`add_library`

Creates a build target that produces a library:

cmake
# Static library (linked at compile time)
add_library(my_lib STATIC src/mylib.cpp)

# Shared library (dynamic, linked at runtime)
add_library(my_lib SHARED src/mylib.cpp)

# Interface library (header-only, no compilation)
add_library(my_headers INTERFACE)

`target_include_directories`

Tells the compiler where to find header files for a specific target:

cmake
target_include_directories(my_lib PUBLIC include/)

The visibility specifier controls how the include paths propagate:

text
Visibility Keywords:
+------------------+--------------------------------------------------+
| PUBLIC           | Used by my_lib AND propagated to dependents     |
| PRIVATE          | Used only by my_lib, not propagated             |
| INTERFACE        | Not used by my_lib, only propagated to deps     |
+------------------+--------------------------------------------------+

Links a target against a library:

cmake
target_link_libraries(my_app
    PRIVATE my_lib          # our own library
    PRIVATE pthread         # system library
    PUBLIC  some_other_lib) # propagated dependency


Building a CMake Project

The standard two-step workflow:

bash
# Step 1: Configure — generate native build files
# -S specifies source dir, -B specifies build dir
cmake -S . -B build

# Step 2: Build — compile using the generated files
cmake --build build

# Optional: Install
cmake --install build

Using a separate build directory (build/) keeps generated files out of the source tree — this is the recommended practice.

Build Types

bash
# Debug build (no optimization, debug symbols)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug

# Release build (full optimization)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release

# Release with debug info
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo


Project Directory Structure

A typical well-organized CMake project looks like:

text
my_project/
├── CMakeLists.txt          # Root CMakeLists
├── src/
│   ├── CMakeLists.txt      # Source subdirectory
│   ├── main.cpp
│   └── utils.cpp
├── include/
│   └── my_project/
│       └── utils.h
├── libs/
│   └── my_lib/
│       ├── CMakeLists.txt  # Library subdirectory
│       ├── my_lib.cpp
│       └── my_lib.h
└── tests/
    ├── CMakeLists.txt      # Test subdirectory
    └── test_main.cpp

Root CMakeLists.txt includes subdirectories:

cmake
cmake_minimum_required(VERSION 3.20)
project(MyProject LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_subdirectory(libs/my_lib)
add_subdirectory(src)
add_subdirectory(tests)


Setting the C++ Standard

cmake
# Method 1: Global setting
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # Disable compiler-specific extensions

# Method 2: Per-target setting (preferred)
set_target_properties(my_target PROPERTIES
    CXX_STANDARD 17
    CXX_STANDARD_REQUIRED ON
)

# Method 3: Modern CMake (3.20+)
target_compile_features(my_target PRIVATE cxx_std_17)


CMake Best Practices

text
Modern CMake Principles:
1. Think in targets, not variables.
2. Use target_* commands (not global include_directories).
3. Separate build directory from source directory.
4. Use find_package() for external dependencies.
5. Set CXX_STANDARD per target, not globally.
6. Avoid absolute paths in CMakeLists.txt.


A Complete Working Example

Here is a complete CMake project for a simple library and executable:

cmake
cmake_minimum_required(VERSION 3.20)
project(Calculator VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Build the math library
add_library(mathlib STATIC
    src/math/add.cpp
    src/math/multiply.cpp)

target_include_directories(mathlib
    PUBLIC include/)

# Build the main executable
add_executable(calculator
    src/main.cpp)

target_link_libraries(calculator
    PRIVATE mathlib)

# Enable testing
enable_testing()
add_subdirectory(tests)


Conclusion

CMake is the lingua franca of C and C++ build systems. By describing your project in terms of targets (executables and libraries) and their dependencies (headers, links, compile options), CMake generates the optimal build script for each platform and build system.

Learning CMake properly — starting with the add_executable, add_library, target_include_directories, and target_link_libraries commands — gives you a foundation that scales from small embedded projects to multi-million-line codebases.

The next posts in this series cover CMake variables, flow control, and functions, which unlock the full power of CMake's scripting capabilities.