|$ curl https://forge-ai.dev/api/markdown?path=docs/c/cmake
$cat docs/c-—-build-systems-&-cmake.md
updated Recently·45 min read·published

C — Build Systems & CMake

CCMakeBuild SystemsMakeAdvancedAdvanced🎯Free Tools
Makefile Review

Before diving into CMake, a quick refresher on Make fundamentals. Make is the classic Unix build tool — it reads a Makefile, determines what needs rebuilding based on file timestamps, and executes the necessary recipes. Understanding Make helps you appreciate why CMake exists and what problems it solves.

A Makefile consists of targets, dependencies, and recipes. Each target depends on prerequisite files. When a prerequisite is newer than the target, Make runs the recipe to rebuild it.

Makefile
MAKEFILE
1# Basic Makefile structure
2CC = gcc
3CFLAGS = -Wall -Wextra -std=c17
4
5# Target: dependencies
6# recipe (must start with tab)
7all: server client
8
9server: server.o network.o utils.o
10 $(CC) $(CFLAGS) -o $@ $^
11
12client: client.o network.o utils.o
13 $(CC) $(CFLAGS) -o $@ $^
14
15# Pattern rule: compile .c to .o
16%.o: %.c
17 $(CC) $(CFLAGS) -c $< -o $@
18
19clean:
20 rm -f *.o server client
21
22.PHONY: all clean

Make provides automatic variables that simplify recipes. $@ expands to the target name, $< is the first dependency, and $^ is all dependencies. Pattern rules with %: allow generic compilation rules.

For larger projects, recursive make invokes sub-makefiles in subdirectories. Non-recursive make keeps everything in one file but can become unwieldy. Both approaches have trade-offs that CMake attempts to resolve.

Makefile
MAKEFILE
1# Recursive make: each subdir has its own Makefile
2SUBDIRS = src/network src/utils src/core
3
4all:
5 @for dir in $(SUBDIRS); do \
6 $(MAKE) -C $$dir; \
7 done
8
9clean:
10 @for dir in $(SUBDIRS); do \
11 $(MAKE) -C $$dir clean; \
12 done
13
14# Non-recursive: collect all sources in one file
15SRCS = $(wildcard src/network/*.c) \
16 $(wildcard src/utils/*.c) \
17 $(wildcard src/core/*.c)
18OBJS = $(SRCS:.c=.o)

warning

Recursive make has well-documented issues: it breaks dependency tracking across subdirectories and can cause incorrect rebuilds. The "non-recursive make" approach (e.g., Peter Miller's article "Recursive Make Considered Harmful") avoids this but sacrifices modularity. CMake sidesteps both problems entirely.
Why CMake

Makefiles are powerful but have significant limitations for modern cross-platform development. Writing platform-specific build rules, handling compiler differences, and managing complex dependency graphs becomes error-prone and repetitive. CMake is a meta-build system — it doesn't build your code directly. Instead, it generates the native build system for your platform: Makefiles on Linux, Ninja files for fast builds, Visual Studio solutions on Windows, or Xcode projects on macOS.

With a single CMakeLists.txt, you can target every major platform. CMake handles compiler detection, library discovery, cross-compilation, and installation. The build process becomes: write CMakeLists.txt, run cmake to generate build files, then use the generated build system to compile.

FeatureMakeCMakeAutotoolsMeson
Cross-platformNoYesPartialYes
Learning curveLowMediumHighLow
Dependency managementManualfind_package / FetchContentm4 macroswrap files
Out-of-source buildsManualDefaultYesDefault
Generator outputN/AMake, Ninja, VS, XcodeMakefilesNinja only
IDE integrationPoorExcellentPoorGood
Test integrationManualCTest built-inmake checkMeson test
Install rulesManualinstall() commandsmake installinstall()

Modern CMake (3.x+) emphasizes a target-based approach. Instead of setting global variables, you define targets and attach properties to them. This encapsulation makes large projects more maintainable and reduces unintended side effects.

info

CMake 3.15+ is recommended for new projects. It provides better support for modern C features, precompiled headers, and improved generator expressions. Always set cmake_minimum_required to a version you actually use.
CMake Basics

Every CMake project starts with a CMakeLists.txt in the root directory. This file is processed top-to-bottom by CMake during configuration. The key commands you need to know are listed below.

CMakeLists.txt
CMAKE
1cmake_minimum_required(VERSION 3.20)
2
3project(MyApp
4 VERSION 1.0.0
5 DESCRIPTION "A sample C project"
6 LANGUAGES C
7)
8
9# Set C standard
10set(CMAKE_C_STANDARD 17)
11set(CMAKE_C_STANDARD_REQUIRED ON)
12set(CMAKE_C_EXTENSIONS OFF)
13
14# Add an executable
15add_executable(myapp
16 src/main.c
17 src/utils.c
18 src/config.c
19)
20
21# Add include directories to the target
22target_include_directories(myapp PRIVATE
23 ${CMAKE_CURRENT_SOURCE_DIR}/include
24)
25
26# Add compile options
27target_compile_options(myapp PRIVATE
28 -Wall -Wextra -Wpedantic
29 -O2
30)
31
32# Link libraries
33target_link_libraries(myapp PRIVATE m pthread)

cmake_minimum_required() ensures the user has a compatible CMake version. project() declares the project name, version, and language. add_executable() creates a build target from source files. target_include_directories() and target_link_libraries() are the modern way to configure how a target is built and linked.

The visibility keywords PRIVATE, PUBLIC, and INTERFACE control how properties propagate. PRIVATE applies only to the target itself. PUBLIC applies to the target and anything that links against it. INTERFACE applies only to consumers (useful for header-only libraries).

CMakeLists.txt
CMAKE
1# Library types
2add_library(mylib STATIC
3 src/mylib.c
4 src/mylib_internal.c
5)
6
7add_library(myshared SHARED
8 src/mylib.c
9 src/mylib_internal.c
10)
11
12# Header-only (INTERFACE) library
13add_library(headerlib INTERFACE)
14target_include_directories(headerlib INTERFACE
15 ${CMAKE_CURRENT_SOURCE_DIR}/include
16)
17
18# Set library version
19set_target_properties(myshared PROPERTIES
20 VERSION 1.0.0
21 SOVERSION 1
22)
23
24# Alias for use in subdirectories or after install
25add_library(MyProject::mylib ALIAS mylib)

best practice

Always use target-based commands (target_include_directories, target_link_libraries) instead of global variables (INCLUDE_DIRECTORIES, LINK_LIBRARIES). The target-based approach is the modern CMake idiom and prevents properties from leaking between targets.
CMake Variables & Options

CMake variables control build behavior. The set() command assigns values, and option() creates boolean cache entries that users can toggle from the command line or GUI. CACHE variables persist across configuration runs and can be set via -D flags.

CMakeLists.txt
CMAKE
1# Basic variable assignment
2set(SERVER_PORT 8080)
3set(LOG_LEVEL "DEBUG")
4
5# CACHE variables (persistent, user-configurable)
6set(MAX_CONNECTIONS 100 CACHE STRING "Maximum concurrent connections")
7set(ENABLE_SSL ON CACHE BOOL "Enable SSL/TLS support")
8set(DATA_DIR "/var/data" CACHE PATH "Data storage directory")
9
10# Option: boolean toggle
11option(ENABLE_TESTS "Build test suite" ON)
12option(ENABLE_BENCHMARKS "Build benchmarks" OFF)
13option(USE_SYSTEM_DEPS "Use system-installed dependencies" OFF)
14
15# Environment variables
16message(STATUS "Home: $ENV{HOME}")
17message(STATUS "CC: $ENV{CC}")
18
19# Conditional logic
20if(ENABLE_SSL)
21 find_package(OpenSSL REQUIRED)
22 target_compile_definitions(myapp PRIVATE ENABLE_SSL)
23 target_link_libraries(myapp PRIVATE OpenSSL::SSL)
24endif()
25
26# Generator expressions (evaluated at build time, not configure time)
27target_compile_definitions(myapp PRIVATE
28 BUILD_TYPE=${CMAKE_BUILD_TYPE}
29 VERSION_MAJOR=${PROJECT_VERSION_MAJOR}
30)

Generator expressions are powerful but syntactically awkward. They use the dollar-brace syntax and are evaluated during build generation rather than during configuration. They are essential for conditional properties in modern CMake.

CMakeLists.txt
CMAKE
1# Common built-in variables
2message(STATUS "Source dir: ${CMAKE_CURRENT_SOURCE_DIR}")
3message(STATUS "Build dir: ${CMAKE_CURRENT_BINARY_DIR}")
4message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID}")
5message(STATUS "System: ${CMAKE_SYSTEM_NAME}")
6message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
7
8# Platform detection
9if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
10 target_compile_definitions(myapp PRIVATE PLATFORM_LINUX)
11elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
12 target_compile_definitions(myapp PRIVATE PLATFORM_MACOS)
13elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
14 target_compile_definitions(myapp PRIVATE PLATFORM_WINDOWS)
15endif()
16
17# Compiler detection
18if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
19 target_compile_options(myapp PRIVATE -Wall -Wextra)
20elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
21 target_compile_options(myapp PRIVATE -Wall -Wextra -Werror)
22elseif(CMAKE_C_COMPILER_ID STREQUAL "MSVC")
23 target_compile_options(myapp PRIVATE /W4)
24endif()
CMake Build Process

CMake uses an out-of-source build model. You create a separate build directory and run CMake from there. This keeps build artifacts separate from source code, making it easy to clean up or maintain multiple build configurations.

terminal
Bash
1# Standard build workflow
2mkdir build && cd build
3cmake .. # Configure (generate build system)
4cmake --build . # Build (compile all targets)
5cmake --install . # Install (copy files to prefix)
6
7# Specify build type (single-config generators like Make)
8cmake -DCMAKE_BUILD_TYPE=Release ..
9cmake -DCMAKE_BUILD_TYPE=Debug ..
10
11# Specify generator
12cmake -G Ninja .. # Use Ninja instead of Make
13cmake -G "Unix Makefiles" ..
14
15# Specify install prefix
16cmake -DCMAKE_INSTALL_PREFIX=/usr/local ..
17
18# Build specific target
19cmake --build . --target myapp
20
21# Clean build
22rm -rf build && mkdir build && cd build
23cmake -DCMAKE_BUILD_TYPE=Release .. 2>&1 | tee cmake.log
24cmake --build . -- -j$(nproc)

Build types control optimization and debug information. Debug includes symbols and no optimization. Release enables full optimization without debug symbols. RelWithDebInfo is a middle ground: optimized with debug symbols, useful for profiling. MinSizeRel optimizes for binary size.

Build TypeOptimizationDebug SymbolsTypical Use
Debug-O0Yes (-g)Development, debugging
Release-O3NoProduction deployment
RelWithDebInfo-O2Yes (-g)Profiling, testing
MinSizeRel-OsNoEmbedded, size-constrained
terminal
Bash
1# Multi-config generators (Visual Studio, Xcode, Ninja Multi-Config)
2# Build type is specified at build time, not configure time
3cmake -G "Ninja Multi-Config" ..
4cmake --build . --config Release
5cmake --build . --config Debug
6
7# Check compilation database (useful for IDEs and tools)
8cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
9# Produces compile_commands.json for clangd, etc.
10
11# Run tests
12cmake --build . --target test
13# Or directly:
14ctest --test-dir build --output-on-failure
Multi-Directory Projects

Real projects have multiple directories with separate concerns. CMake handles this with add_subdirectory(), which processes a CMakeLists.txt in a subdirectory. Targets defined in subdirectories are automatically visible to the parent.

CMakeLists.txt
CMAKE
1# Root CMakeLists.txt
2cmake_minimum_required(VERSION 3.20)
3project(Server VERSION 2.1.0 LANGUAGES C)
4
5set(CMAKE_C_STANDARD 17)
6set(CMAKE_C_STANDARD_REQUIRED ON)
7
8# Project-wide options
9option(ENABLE_TESTS "Build tests" ON)
10
11# Subdirectories
12add_subdirectory(src/core)
13add_subdirectory(src/network)
14add_subdirectory(src/utils)
15
16# Main executable
17add_executable(server src/main.c)
18target_link_libraries(server PRIVATE
19 core
20 network
21 utils
22)
23
24if(ENABLE_TESTS)
25 enable_testing()
26 add_subdirectory(tests)
27endif()
src/core/CMakeLists.txt
CMAKE
1# src/core/CMakeLists.txt
2add_library(core
3 connection.c
4 session.c
5 config.c
6)
7
8target_include_directories(core PUBLIC
9 ${CMAKE_CURRENT_SOURCE_DIR}/include
10)
11
12target_link_libraries(core PRIVATE utils)

When core exposes its headers as PUBLIC, anything that links against core automatically gets the include path. This is the power of target-based CMake: dependencies and their transitive properties propagate automatically.

CMakeLists.txt
CMAKE
1# INTERFACE library for header-only code
2add_library(headers INTERFACE)
3target_include_directories(headers INTERFACE
4 ${CMAKE_CURRENT_SOURCE_DIR}/include
5)
6
7# Usage: just link against headers
8target_link_libraries(myapp PRIVATE headers)
9
10# Finding external packages
11find_package(Threads REQUIRED)
12find_package(CURL 7.50.0 REQUIRED)
13find_package(PkgConfig REQUIRED)
14pkg_check_modules(JSON_C REQUIRED json-c)
15
16target_link_libraries(myapp PRIVATE
17 Threads::Threads
18 CURL::libcurl
19 ${JSON_C_LIBRARIES}
20)
21target_include_directories(myapp PRIVATE
22 ${JSON_C_INCLUDE_DIRS}
23)
📝

note

FetchContent (available since CMake 3.11) lets you download and build dependencies at configure time. It integrates external projects as if they were subdirectories. Use it when you want to vendor dependencies without git submodules.
CMakeLists.txt
CMAKE
1# FetchContent for downloading dependencies at configure time
2include(FetchContent)
3
4FetchContent_Declare(
5 unity
6 GIT_REPOSITORY https://github.com/ThrowTheSwitch/Unity.git
7 GIT_TAG v2.5.2
8)
9
10FetchContent_MakeAvailable(unity)
11
12# Now you can link against unity
13target_link_libraries(tests PRIVATE unity)
Complete CMake Examples

Here are complete, production-ready CMake configurations for common project structures. Each example demonstrates best practices and idiomatic CMake.

Example 1: Simple Single-File Project

CMakeLists.txt
CMAKE
1cmake_minimum_required(VERSION 3.20)
2project(hello VERSION 1.0.0 LANGUAGES C)
3
4set(CMAKE_C_STANDARD 17)
5set(CMAKE_C_STANDARD_REQUIRED ON)
6
7add_executable(hello src/hello.c)
8
9# Install rules
10install(TARGETS hello DESTINATION bin)

Example 2: Project with Static Library

CMakeLists.txt
CMAKE
1cmake_minimum_required(VERSION 3.20)
2project(parser VERSION 2.0.0 LANGUAGES C)
3
4set(CMAKE_C_STANDARD 17)
5set(CMAKE_C_STANDARD_REQUIRED ON)
6
7# Shared library
8add_library(parser SHARED
9 src/parser.c
10 src/tokenizer.c
11 src/ast.c
12)
13
14target_include_directories(parser PUBLIC
15 ${CMAKE_CURRENT_SOURCE_DIR}/include
16)
17
18target_compile_options(parser PRIVATE -Wall -Wextra)
19
20# Executable using the library
21add_executable(parse_cli src/main.c)
22target_link_libraries(parse_cli PRIVATE parser)
23
24# Install library and headers
25install(TARGETS parser
26 LIBRARY DESTINATION lib
27 ARCHIVE DESTINATION lib
28 RUNTIME DESTINATION bin
29)
30
31install(DIRECTORY include/ DESTINATION include)

Example 3: Project with Tests

tests/CMakeLists.txt
CMAKE
1# tests/CMakeLists.txt
2find_package(unity REQUIRED)
3
4# Collect test sources
5file(GLOB TEST_SOURCES test_*.c)
6
7# Create one test executable per test file
8foreach(test_source ${TEST_SOURCES})
9 get_filename_component(test_name ${test_source} NAME_WE)
10
11 add_executable(${test_name} ${test_source})
12 target_link_libraries(${test_name} PRIVATE parser unity)
13
14 add_test(
15 NAME ${test_name}
16 COMMAND ${test_name}
17 )
18endforeach()
19
20# Custom test with arguments
21add_test(
22 NAME integration_tests
23 COMMAND parse_cli ${CMAKE_CURRENT_SOURCE_DIR}/test_data/input.json
24)
25set_tests_properties(integration_tests PROPERTIES
26 PASS_REGULAR_EXPRESSION "OK"
27 TIMEOUT 30
28)

Example 4: Project with Precompiled Headers

CMakeLists.txt
CMAKE
1# Precompiled headers speed up compilation
2target_precompile_headers(myapp PRIVATE
3 src/pch.h
4)
5
6# Or using a target to export PCH to dependents
7add_library(pch INTERFACE)
8target_precompile_headers(pch INTERFACE
9 ${CMAKE_CURRENT_SOURCE_DIR}/include/pch.h
10)
11target_link_libraries(mylib PUBLIC pch)

Example 5: Custom CMake Module

cmake/FindMyLib.cmake
CMAKE
1# cmake/FindMyLib.cmake
2# Custom find module for a library not shipped with CMake
3
4find_path(MYLIB_INCLUDE_DIR
5 NAMES mylib.h
6 PATHS /usr/local/include /usr/include
7)
8
9find_library(MYLIB_LIBRARY
10 NAMES mylib
11 PATHS /usr/local/lib /usr/lib
12)
13
14include(FindPackageHandleStandardArgs)
15find_package_handle_standard_args(MyLib
16 REQUIRED_VARS MYLIB_LIBRARY MYLIB_INCLUDE_DIR
17)
18
19if(MyLib_FOUND AND NOT TARGET MyLib::MyLib)
20 add_library(MyLib::MyLib UNKNOWN IMPORTED)
21 set_target_properties(MyLib::MyLib PROPERTIES
22 IMPORTED_LOCATION "${MYLIB_LIBRARY}"
23 INTERFACE_INCLUDE_DIRECTORIES "${MYLIB_INCLUDE_DIR}"
24 )
25endif()
26
27mark_as_advanced(MYLIB_INCLUDE_DIR MYLIB_LIBRARY)
🔥

pro tip

Use CMAKE_MODULE_PATH to tell CMake where to find your custom find modules: set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_SOURCE_DIR} cmake). Then find_package(MyLib) will use your FindMyLib.cmake automatically.
Testing with CTest

CMake includes CTest, a built-in testing driver. After configuring your project, run ctest from the build directory to execute all registered tests. CTest supports parallel execution, output capture, and integration with CDash for continuous testing dashboards.

CMakeLists.txt
CMAKE
1# Enable testing in the project
2enable_testing()
3
4# Simple test
5add_test(NAME unit_tests COMMAND test_runner)
6
7# Test with arguments
8add_test(NAME parse_test
9 COMMAND parse_cli --test --file ${CMAKE_CURRENT_SOURCE_DIR}/test.json
10)
11
12# Set test properties
13set_tests_properties(parse_test PROPERTIES
14 TIMEOUT 10
15 PASS_REGULAR_EXPRESSION "All tests passed"
16 FAIL_REGULAR_EXPRESSION "FAIL"
17 ENVIRONMENT "TESTING=1;LOG_LEVEL=debug"
18)
19
20# Test with fixtures (CMake 3.7+)
21set_tests_properties(parse_test PROPERTIES
22 FIXTURES_SETUP test_data
23)
24set_tests_properties(cleanup_test PROPERTIES
25 FIXTURES_CLEANUP test_data
26)
27
28# Run tests
29# ctest --output-on-failure
30# ctest -N (list tests without running)
31# ctest -R "parse" (run matching tests)
32# ctest --parallel 4 (run 4 tests in parallel)
Installation & Packaging

CMake provides install() commands to specify where files should be placed during installation. You can also generate packaging configurations for CPack to create .deb, .rpm, .dmg, or .msi packages.

CMakeLists.txt
CMAKE
1# Install executable
2install(TARGETS myapp
3 RUNTIME DESTINATION bin
4)
5
6# Install library
7install(TARGETS mylib
8 LIBRARY DESTINATION lib
9 ARCHIVE DESTINATION lib
10 PUBLIC_HEADER DESTINATION include/mylib
11)
12
13# Install headers
14install(DIRECTORY include/mylib/
15 DESTINATION include/mylib
16 FILES_MATCHING PATTERN "*.h"
17)
18
19# Install config files
20install(FILES mylib.conf
21 DESTINATION etc/mylib
22)
23
24# Install with permissions
25install(PROGRAMS scripts/setup.sh
26 DESTINATION bin
27 PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE
28 GROUP_READ GROUP_EXECUTE
29)
30
31# Generate package config for downstream CMake projects
32install(EXPORT mylibTargets
33 FILE mylibTargets.cmake
34 NAMESPACE MyLib::
35 DESTINATION lib/cmake/mylib
36)
37
38include(CMakePackageConfigHelpers)
39write_basic_package_version_file(
40 ${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
41 VERSION ${PROJECT_VERSION}
42 COMPATIBILITY SameMajorVersion
43)
$Blueprint — Engineering Documentation·Section ID: C-CMAKE·Revision: 1.0