Table of Contents

  1. Single Source Executable
  2. Multiple Source Files
  3. Header Files
  4. Generators
  5. Build Types
  6. Compiler & Linker Flags
  7. Build Configuration
  8. Object Files & Intermediate Output
  9. Building in Practice
  10. Common Mistakes
  11. Exercises
  12. Conclusion & Next Steps
Back to CMake Mastery Series

Part 3: From Source Files to Executables

June 4, 2026 Wasil Zafar 35 min read

Compile single and multiple source files into executables. Master generators, build types, compiler flags, and the full compilation pipeline from source to binary.

Single Source Executable

The simplest possible CMake project turns a single .cpp file into an executable. This is the starting point for every C++ project and demonstrates CMake's core command: add_executable().

The Minimum Project

A complete CMake project requires exactly two files: a CMakeLists.txt and at least one source file.

# Project structure
my_project/
├── CMakeLists.txt
└── main.cpp

The source file:

// main.cpp
#include <iostream>

int main() {
    std::cout << "Hello from CMake!\n";
    return 0;
}

The CMakeLists.txt:

# CMakeLists.txt - Minimum viable project
cmake_minimum_required(VERSION 3.21)
project(HelloWorld LANGUAGES CXX)

add_executable(hello main.cpp)

Let's break down each line:

  • cmake_minimum_required(VERSION 3.21) — Sets the minimum CMake version. This enables modern behavior and policies. See cmake_minimum_required.
  • project(HelloWorld LANGUAGES CXX) — Declares the project name and languages. CXX means C++. This triggers compiler detection. See project().
  • add_executable(hello main.cpp) — Creates a build target called hello from main.cpp. The output binary is named hello (or hello.exe on Windows).

Build and run:

# Configure (generate build files)
cmake -S . -B build

# Build (compile and link)
cmake --build build

# Run the result
./build/hello          # Linux/macOS
.\build\Debug\hello.exe  # Windows (MSVC)
Key Insight: add_executable() creates a target. Everything in modern CMake revolves around targets — they carry properties like compile flags, include paths, and link dependencies. We'll explore targets deeply in Part 5.

Setting the C++ Standard

Always specify the C++ standard explicitly rather than relying on compiler defaults:

# CMakeLists.txt - With C++ standard
cmake_minimum_required(VERSION 3.21)
project(HelloWorld LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_executable(hello main.cpp)
  • CMAKE_CXX_STANDARD 17 — Request C++17
  • CMAKE_CXX_STANDARD_REQUIRED ON — Fail if compiler doesn't support C++17 (instead of silently downgrading)
  • CMAKE_CXX_EXTENSIONS OFF — Use -std=c++17 instead of -std=gnu++17 (portable, no GNU extensions)

Multiple Source Files

Real projects have dozens or hundreds of source files. CMake provides several ways to specify them.

Listing Sources Directly

The most straightforward and recommended approach is to list every source file explicitly:

# CMakeLists.txt - Multiple sources listed directly
cmake_minimum_required(VERSION 3.21)
project(Calculator LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_executable(calculator
    src/main.cpp
    src/parser.cpp
    src/evaluator.cpp
    src/tokenizer.cpp
)

This approach is explicit, version-control friendly (diffs show exactly what changed), and guarantees CMake reconfigures when a source is added or removed.

Grouping Sources with Variables

For larger projects, you can group related sources into variables using set():

# CMakeLists.txt - Grouped source variables
cmake_minimum_required(VERSION 3.21)
project(GameEngine LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# Group by component
set(CORE_SOURCES
    src/core/engine.cpp
    src/core/window.cpp
    src/core/input.cpp
    src/core/timer.cpp
)

set(RENDERER_SOURCES
    src/renderer/renderer.cpp
    src/renderer/shader.cpp
    src/renderer/texture.cpp
    src/renderer/mesh.cpp
)

set(AUDIO_SOURCES
    src/audio/audio_system.cpp
    src/audio/sound.cpp
)

add_executable(game
    src/main.cpp
    ${CORE_SOURCES}
    ${RENDERER_SOURCES}
    ${AUDIO_SOURCES}
)

You can also append to lists with list(APPEND ...):

# CMakeLists.txt - Using list(APPEND)
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(SOURCES src/main.cpp)

list(APPEND SOURCES
    src/config.cpp
    src/logger.cpp
)

# Conditionally add platform-specific sources
if(WIN32)
    list(APPEND SOURCES src/platform_win32.cpp)
elseif(UNIX)
    list(APPEND SOURCES src/platform_unix.cpp)
endif()

add_executable(app ${SOURCES})

file(GLOB) and Why to Avoid It

CMake provides file(GLOB) to automatically collect source files matching a pattern:

# CMakeLists.txt - GLOB example (NOT RECOMMENDED)
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

# Collects ALL .cpp files in src/ into SOURCES variable
file(GLOB SOURCES "src/*.cpp")

add_executable(app ${SOURCES})
Warning — Avoid file(GLOB) for source files: CMake's own documentation discourages this. When you add or remove a .cpp file, the build system has no way to know the file list changed — CMake won't automatically re-configure. You must manually re-run cmake every time. This breaks incremental workflows and causes mysterious build failures in CI.

If you insist on GLOB, use CONFIGURE_DEPENDS (CMake 3.12+) which asks the generator to check for file changes:

# CMakeLists.txt - GLOB with CONFIGURE_DEPENDS (still not ideal)
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp")

add_executable(app ${SOURCES})

However, CONFIGURE_DEPENDS adds overhead to every build (the generator must scan the filesystem), is not supported by all generators, and still has edge cases. The CMake documentation explicitly states:

"We do not recommend using GLOB to collect a list of source files from your source tree." — CMake file() documentation

Bottom line: List your sources explicitly. Modern IDEs and tools make this easy.

Header Files

Headers (.h, .hpp) are not compiled directly — they're included by source files during preprocessing. However, CMake needs to know where to find them.

Include Directories

Consider this project structure:

# Project with separate include directory
my_project/
├── CMakeLists.txt
├── include/
│   └── myproject/
│       ├── math_utils.h
│       └── string_utils.h
└── src/
    ├── main.cpp
    ├── math_utils.cpp
    └── string_utils.cpp

The target_include_directories() command tells CMake where to search for headers:

# CMakeLists.txt - Include directories
cmake_minimum_required(VERSION 3.21)
project(MyProject LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_executable(myapp
    src/main.cpp
    src/math_utils.cpp
    src/string_utils.cpp
)

target_include_directories(myapp PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

Now source files can include headers with:

// src/main.cpp
#include <myproject/math_utils.h>
#include <myproject/string_utils.h>
#include <iostream>

int main() {
    std::cout << "Sum: " << add(3, 4) << "\n";
    std::cout << "Upper: " << to_upper("hello") << "\n";
    return 0;
}
Best Practice: Use include/projectname/header.h structure. The extra directory level prevents header name collisions between projects and makes includes self-documenting: #include <myproject/math_utils.h> clearly identifies where the header comes from.

PUBLIC, PRIVATE, and INTERFACE

The visibility keyword in target_include_directories() controls who sees the include path:

KeywordUsed by TargetPropagated to DependentsUse Case
PRIVATE✅ Yes❌ NoInternal implementation headers
PUBLIC✅ Yes✅ YesHeaders included in your public API
INTERFACE❌ No✅ YesHeader-only libraries
# CMakeLists.txt - Visibility example
cmake_minimum_required(VERSION 3.21)
project(VisibilityDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(demo
    src/main.cpp
    src/engine.cpp
)

# Headers in include/ are part of the public interface
target_include_directories(demo PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# Headers in src/ are implementation details
target_include_directories(demo PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src
)

For executables, the distinction between PUBLIC and PRIVATE is less critical (executables are endpoints). But establishing this habit now prepares you for libraries in Part 4, where visibility propagation is essential.

Should Headers Be Listed in add_executable()?

Headers don't need to be listed since they're not compiled. However, listing them helps IDEs discover and display them in the project tree:

# CMakeLists.txt - Listing headers for IDE support
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app
    src/main.cpp
    src/parser.cpp
    include/app/parser.h    # For IDE awareness only
    include/app/types.h     # Not compiled
)

Generators

A generator determines what native build system CMake outputs. Different generators produce different build files, each with trade-offs in speed, features, and platform support.

The -G Flag

Specify a generator during configuration with -G:

# List all available generators on your system
cmake --help

# Use specific generators
cmake -S . -B build-make -G "Unix Makefiles"
cmake -S . -B build-ninja -G "Ninja"
cmake -S . -B build-vs -G "Visual Studio 17 2022"
cmake -S . -B build-xcode -G "Xcode"
cmake -S . -B build-ninja-mc -G "Ninja Multi-Config"
Tip: Each build directory is bound to one generator. You cannot change the generator after configuration — create a new build directory instead. This is why we use descriptive names like build-ninja.

Comparing Generators

Generator Selection Flowchart
        flowchart TD
            A[Choose Generator] --> B{Platform?}
            B -->|Windows| C{Need IDE?}
            B -->|Linux/macOS| D{Need Speed?}
            C -->|Yes| E["Visual Studio 17 2022"]
            C -->|No| F{Multi-config?}
            F -->|Yes| G["Ninja Multi-Config"]
            F -->|No| H["Ninja"]
            D -->|Maximum| H
            D -->|Default OK| I["Unix Makefiles"]
            D -->|Multi-config| G
    
GeneratorSpeedMulti-ConfigPlatformNotes
Ninja⚡ Fastest❌ SingleAllRecommended for CLI builds
Ninja Multi-Config⚡ Fastest✅ YesAllBest of both worlds (CMake 3.17+)
Unix Makefiles🔄 Moderate❌ SingleLinux/macOSUniversal default on Unix
Visual Studio 17 2022🔄 Moderate✅ YesWindowsFull IDE integration
Xcode🔄 Moderate✅ YesmacOSFull Xcode IDE integration
# Compare build times with Ninja vs Make (same project)
# First, configure with both generators
cmake -S . -B build-ninja -G Ninja
cmake -S . -B build-make -G "Unix Makefiles"

# Time the builds
time cmake --build build-ninja
time cmake --build build-make

# Ninja is typically 10-40% faster on incremental builds
# due to better dependency tracking and parallelism

Ninja Multi-Config

Ninja Multi-Config (CMake 3.17+) combines Ninja's speed with multi-configuration support. You configure once and build any configuration without re-running CMake:

# Configure once
cmake -S . -B build -G "Ninja Multi-Config"

# Build Debug
cmake --build build --config Debug

# Build Release (no reconfiguration needed!)
cmake --build build --config Release

# Build RelWithDebInfo
cmake --build build --config RelWithDebInfo

This is especially valuable for CI pipelines that test both Debug and Release from a single configure step.

Build Types

Build types control optimization level, debug information, and assertions. They fundamentally affect the compiler flags passed during compilation.

CMAKE_BUILD_TYPE

For single-configuration generators (Ninja, Unix Makefiles), set the build type at configure time via CMAKE_BUILD_TYPE:

# Configure with specific build type
cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake -S . -B build-relwithdebinfo -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake -S . -B build-minsizerel -DCMAKE_BUILD_TYPE=MinSizeRel

# Build (type is already baked into the build directory)
cmake --build build-release
Common Pitfall: If you don't set CMAKE_BUILD_TYPE with single-config generators, you get an empty build type with no optimization and no debug info — the worst of both worlds. Always set it explicitly!

What Each Build Type Enables

Build TypeGCC/Clang FlagsMSVC FlagsPurpose
Debug-g -O0/MDd /Zi /Ob0 /OdFull debug info, no optimization
Release-O3 -DNDEBUG/MD /O2 /Ob2 /DNDEBUGFull optimization, no debug info
RelWithDebInfo-O2 -g -DNDEBUG/MD /Zi /O2 /Ob1 /DNDEBUGOptimized with debug info (for profiling)
MinSizeRel-Os -DNDEBUG/MD /O1 /Ob1 /DNDEBUGOptimize for size (embedded, WASM)

Key differences:

  • -DNDEBUG disables assert() macro — present in Release, RelWithDebInfo, MinSizeRel but NOT in Debug
  • -g includes DWARF debug symbols for GDB/LLDB — present in Debug and RelWithDebInfo
  • -O0 means zero optimization (code maps directly to source) vs -O3 (aggressive inlining, vectorization, etc.)

You can set a default build type in CMakeLists.txt if none is specified:

# CMakeLists.txt - Default build type
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

# Set a default build type if not specified
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
    message(STATUS "Setting build type to 'RelWithDebInfo' (default)")
    set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "Build type" FORCE)
    set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
        "Debug" "Release" "MinSizeRel" "RelWithDebInfo")
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app src/main.cpp)

Compiler and Linker Flags

Beyond build types, you often need custom compiler warnings, sanitizers, or platform-specific flags.

Global Flags (Avoid for New Code)

The legacy approach sets flags globally via cache variables:

# CMakeLists.txt - Global flags (legacy approach)
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

# These affect ALL targets in the project
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra")
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -fsanitize=address")
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -march=native")

add_executable(app src/main.cpp)
Warning: Global flags (CMAKE_CXX_FLAGS) apply to everything — including third-party libraries fetched via FetchContent or add_subdirectory(). This causes build failures when your strict warnings break someone else's code. Prefer per-target options instead.

Per-Target Options (Modern Approach)

Modern CMake uses target_compile_options() and target_link_options() for precise control:

# CMakeLists.txt - Per-target compile options
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_executable(app
    src/main.cpp
    src/engine.cpp
)

# Warnings only for OUR code
target_compile_options(app PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic -Wshadow>
    $<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->
)

# Debug-only sanitizers
target_compile_options(app PRIVATE
    $<$<AND:$<CONFIG:Debug>,$<CXX_COMPILER_ID:GNU,Clang>>:
        -fsanitize=address,undefined -fno-omit-frame-pointer>
)

target_link_options(app PRIVATE
    $<$<AND:$<CONFIG:Debug>,$<CXX_COMPILER_ID:GNU,Clang>>:
        -fsanitize=address,undefined>
)

The $<...> syntax is a generator expression — evaluated at generation time, not configure time. We'll cover these in depth in Part 7. For now, know that:

  • $<CXX_COMPILER_ID:GNU,Clang> — True when using GCC or Clang
  • $<CONFIG:Debug> — True when building Debug configuration
  • $<AND:...,...> — Logical AND of conditions

Common Warning Flags Reference

GCC/ClangMSVCPurpose
-Wall/W3Common warnings
-Wextra/W4Extra warnings
-Wpedantic/permissive-Strict standard conformance
-Werror/WXTreat warnings as errors
-WshadowWarn on variable shadowing
-WconversionWarn on implicit type conversions

Build Configuration

Understanding the difference between single-config and multi-config generators is crucial for writing portable CMakeLists.txt files.

Single-Config vs Multi-Config Generators

AspectSingle-ConfigMulti-Config
ExamplesUnix Makefiles, NinjaVisual Studio, Xcode, Ninja Multi-Config
Config set atConfigure time (-DCMAKE_BUILD_TYPE=Release)Build time (--config Release)
Build dirsOne per configurationOne for all configurations
Output locationbuild/myappbuild/Release/myapp
VariablesCMAKE_BUILD_TYPECMAKE_CONFIGURATION_TYPES
# Single-config workflow (separate build dirs)
cmake -S . -B build-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-debug
cmake --build build-release

# Multi-config workflow (one build dir, switch at build time)
cmake -S . -B build -G "Ninja Multi-Config"
cmake --build build --config Debug
cmake --build build --config Release

CMAKE_CONFIGURATION_TYPES

For multi-config generators, CMAKE_CONFIGURATION_TYPES lists the available configurations:

# CMakeLists.txt - Custom configuration types
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

# Only for multi-config generators
if(CMAKE_CONFIGURATION_TYPES)
    set(CMAKE_CONFIGURATION_TYPES "Debug;Release;RelWithDebInfo" CACHE STRING "" FORCE)
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app src/main.cpp)

You can also define entirely custom configurations (e.g., Profile, Sanitize) by adding them to CMAKE_CONFIGURATION_TYPES and defining their associated flags.

Object Files and Intermediate Output

Understanding what happens between source and binary helps debug compilation errors and optimize build times.

C++ Compilation Pipeline
        flowchart LR
            A[".cpp + .h
Source Files"] -->|Preprocessor
cpp| B[".i
Translation Unit"] B -->|Compiler
cc1plus| C[".s
Assembly"] C -->|Assembler
as| D[".o / .obj
Object File"] D -->|Linker
ld / link.exe| E["Executable
or Library"] F[".o (other)"] --> E G["Libraries
.a / .so / .lib"] --> E

Each .cpp file becomes one object file (.o on Unix, .obj on Windows). The linker then combines all object files plus libraries into the final binary.

# See intermediate object files (after building)
find build -name "*.o" -o -name "*.obj"

# On a Ninja build, objects are typically in:
# build/CMakeFiles/app.dir/src/main.cpp.o
# build/CMakeFiles/app.dir/src/engine.cpp.o

Object Libraries

CMake provides object libraries — collections of object files that aren't archived into a .a or linked into a .so. They're useful for compiling sources once and using them in multiple targets:

# CMakeLists.txt - Object library
cmake_minimum_required(VERSION 3.21)
project(App LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Compile these sources once into object files
add_library(common_objs OBJECT
    src/logger.cpp
    src/config.cpp
    src/utils.cpp
)

target_include_directories(common_objs PUBLIC include)

# Use the object files in multiple targets
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE common_objs)

add_executable(tests tests/test_main.cpp)
target_link_libraries(tests PRIVATE common_objs)

We'll cover libraries in detail in Part 4. For now, understand that object libraries let you avoid compiling the same source twice.

Building in Practice

Once configured, cmake --build wraps the native build tool. These techniques help you work efficiently.

Verbose Builds

See the exact compiler commands CMake generates:

# Method 1: --verbose flag (CMake 3.14+)
cmake --build build --verbose

# Method 2: Environment variable
CMAKE_VERBOSE_MAKEFILE=ON cmake --build build

# Method 3: Set in CMakeLists.txt (permanent)
# set(CMAKE_VERBOSE_MAKEFILE ON)

# Method 4: For Makefiles specifically
cmake --build build -- VERBOSE=1

# Method 5: For Ninja specifically
cmake --build build -- -v

Verbose output shows the full compiler invocation, which is invaluable for debugging flag issues:

# Example verbose output (Ninja)
[1/3] /usr/bin/g++ -DNDEBUG -O3 -std=c++17 -Wall -Wextra \
  -I/home/user/project/include \
  -MD -MT CMakeFiles/app.dir/src/main.cpp.o \
  -MF CMakeFiles/app.dir/src/main.cpp.o.d \
  -o CMakeFiles/app.dir/src/main.cpp.o \
  -c /home/user/project/src/main.cpp

Parallel Jobs

Speed up builds by compiling multiple files simultaneously:

# Use all available cores
cmake --build build --parallel

# Use specific number of cores
cmake --build build --parallel 8
cmake --build build -j 8

# Or pass through to native tool
cmake --build build -- -j$(nproc)      # Make/Ninja on Linux
cmake --build build -- -j$(sysctl -n hw.ncpu)  # macOS
cmake --build build -- /maxcpucount:8  # MSBuild
Tip: Ninja automatically parallelizes to nproc + 2 by default. You rarely need to specify -j with Ninja — it's already optimal out of the box.

Selective Rebuilds and Clean Targets

# Build only a specific target (when multiple targets exist)
cmake --build build --target app
cmake --build build --target tests

# Clean all build artifacts
cmake --build build --target clean

# Clean and rebuild
cmake --build build --clean-first

# Remove entire build directory and start fresh
rm -rf build
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build

CMake's incremental builds are dependency-aware: modifying one .cpp file recompiles only that object file and re-links. Modifying a header recompiles all source files that include it (directly or transitively).

Common Mistakes

These errors trip up nearly every CMake beginner. Learn them once and save hours of debugging.

1. In-Source Builds

# BAD: Building in the source tree
cd my_project
cmake .        # Pollutes source directory with CMakeCache.txt, CMakeFiles/, etc.
make

# GOOD: Out-of-source build
cd my_project
cmake -S . -B build
cmake --build build

In-source builds scatter generated files throughout your source tree, making git status unusable and .gitignore painful. Always use a separate build directory.

# CMakeLists.txt - Prevent in-source builds
cmake_minimum_required(VERSION 3.21)

# Guard against in-source builds
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR)
    message(FATAL_ERROR
        "In-source builds are not allowed.\n"
        "Create a build directory:\n"
        "  cmake -S . -B build\n"
        "Remove CMakeCache.txt and CMakeFiles/ if they were created."
    )
endif()

project(App LANGUAGES CXX)

2. Using GLOB Without Understanding the Consequences

# BAD: Silently breaks when files are added/removed
file(GLOB SOURCES "src/*.cpp")
add_executable(app ${SOURCES})

# GOOD: Explicit list — CMake detects changes to CMakeLists.txt
add_executable(app
    src/main.cpp
    src/parser.cpp
    src/evaluator.cpp
)

3. Forgetting to Add New Source Files

After creating a new .cpp file, you must add it to add_executable() or add_library(). Otherwise you get linker errors like "undefined reference to...".

# Symptom: Linker error after adding a new class
# /usr/bin/ld: CMakeFiles/app.dir/src/main.cpp.o: undefined reference to `Database::connect()'
# collect2: error: ld returned 1 exit status

# Fix: Add the new source to CMakeLists.txt
# add_executable(app src/main.cpp src/database.cpp)  # Added database.cpp

4. Not Setting CMAKE_BUILD_TYPE

# BAD: No build type = no optimization AND no debug info
cmake -S . -B build -G Ninja
cmake --build build  # Compiles with... nothing useful

# GOOD: Always specify the build type
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release

5. Mixing Up Source and Build Paths

# BAD: Confusing -S and -B
cmake -B . -S build  # BACKWARDS! Creates build files in source dir

# GOOD: -S = Source, -B = Build
cmake -S . -B build  # Source is current dir, build in ./build/

6. Stale Build Directory After Generator Change

# BAD: Switching generator in existing build directory
cmake -S . -B build -G Ninja          # First time: Ninja
cmake -S . -B build -G "Unix Makefiles"  # ERROR! Can't switch generators

# GOOD: Use a fresh directory
rm -rf build
cmake -S . -B build -G "Unix Makefiles"
Rule of Thumb: When in doubt, delete the build directory and reconfigure. CMake configure is fast (usually under 5 seconds). Don't waste 30 minutes debugging a stale cache when rm -rf build && cmake -S . -B build takes 10 seconds.

Exercises

Exercise 1 Beginner
Multi-File Calculator

Create a project with the following structure and build it with CMake:

  • main.cpp — CLI interface that reads two numbers and an operation
  • calculator.cpp / calculator.h — add, subtract, multiply, divide functions
  • validator.cpp / validator.h — input validation (division by zero check)

Requirements: Use target_include_directories() with a separate include/ directory. Set C++17. Add -Wall -Wextra warnings.

add_executable target_include_directories target_compile_options
Exercise 2 Intermediate
Generator Comparison

Take any project with 10+ source files and build it with three different generators. Compare:

  • Initial (cold) build time with time cmake --build build
  • Incremental build time after modifying one source file
  • Build directory size
  • Output structure (where binaries end up)

Try: Ninja, Unix Makefiles, and Ninja Multi-Config.

generators benchmarking Ninja
Exercise 3 Intermediate
Build Type Impact

Create a project that performs heavy computation (e.g., matrix multiplication with nested loops). Build with all four build types and compare:

  • Binary size (ls -la or size command)
  • Execution time
  • Whether assert() fires in each build type

Verify by including #include <cassert> and adding assert(false); — it should crash in Debug but be silently removed in Release.

CMAKE_BUILD_TYPE optimization NDEBUG

Conclusion & Next Steps

You now understand the full pipeline from source files to executables in CMake:

  • add_executable() creates a target from source files
  • Explicit source lists are preferred over GLOB
  • target_include_directories() controls header search paths with PUBLIC/PRIVATE/INTERFACE visibility
  • Generators determine the native build system — Ninja is fastest for CLI workflows
  • Build types control optimization and debug info — always set CMAKE_BUILD_TYPE explicitly
  • Per-target flags via target_compile_options() are preferred over global CMAKE_CXX_FLAGS
  • Multi-config generators let you switch Debug/Release without reconfiguring

Next in the Series

In Part 4: Building and Linking Libraries, we'll create static, shared, and object libraries. You'll learn add_library(), target_link_libraries(), symbol visibility, version SONAME management, and how library dependencies propagate through the target graph.