C++ • GETTING STARTED AND TOOLING

CMake Basics — Configure and build a simple project with CMake (intro workflow)

Master the cross-platform build system that powers most modern C++ projects worldwide.

Historical Context & Motivation

In the early days of C and C++ development, building software was inextricably tied to the platform on which it was compiled. Unix developers relied on Makefiles — hand-crafted dependency graphs that told the make utility which source files to compile and in what order. On Windows, developers used Visual Studio project files or NMake scripts, while macOS introduced its own Xcode project format. The result was a fractured ecosystem: a project that compiled flawlessly on one platform required substantial rework to build on another. This platform lock-in was not merely inconvenient; it actively hindered open-source collaboration and cross-platform library development.

The core problem was that build configuration — specifying compiler flags, include paths, library dependencies, and output targets — was conflated with build execution. Every platform's native build tool used its own syntax and semantics, so porting a project meant rewriting its entire build logic. As C++ projects grew in complexity, with dozens of libraries and thousands of source files, manually maintaining parallel build configurations became untenable. The community needed a meta-build system — a tool that could generate platform-native build files from a single, unified project description.

1976
Make is born
Stuart Feldman creates make at Bell Labs, establishing the foundational concept of dependency-driven builds for C programs on Unix systems.
1991
GNU Autotools matures
The GNU Autotools suite (autoconf, automake, libtool) provides the first widely adopted cross-platform build configuration system, but its complexity and Unix-centrism limit its reach.
2000
CMake is created
Kitware develops CMake to support the cross-platform build needs of the Insight Toolkit (ITK) for the US National Library of Medicine, introducing the configure-then-build paradigm.
2014
Modern CMake emerges
CMake 3.0 introduces target-based dependency management and generator expressions, shifting the community toward the "Modern CMake" idiom that emphasizes targets over variables.
2023
Industry standard
CMake is the de facto standard for C++ projects, supported natively by CLion, Visual Studio, VS Code, and virtually every CI/CD pipeline. CMake presets and package management integration solidify its dominance.

CMake's central innovation was separating the description of a build from the execution of that build. By writing a single CMakeLists.txt file, developers declare what they want to build — executables, libraries, tests — and CMake generates the appropriate native build files for whatever platform and toolchain is in use. This two-phase workflow (configure, then build) is the fundamental concept this lesson explores.

Core Principles of CMake

CMake operates on a set of foundational principles that distinguish it from traditional build tools. Understanding these principles before writing any code will make the entire workflow feel coherent rather than arbitrary. At its heart, CMake is a build system generator — it does not compile your code itself, but rather produces the instructions that a native build tool (such as Make, Ninja, or MSBuild) will follow to perform the actual compilation and linking.

1

Declarative Configuration

You declare what to build (targets, dependencies, properties) rather than scripting how to invoke the compiler. CMake figures out the appropriate compiler invocations for your platform.
2

Two-Phase Workflow

The configure phase reads CMakeLists.txt and generates build files. The build phase invokes the native build tool to compile and link.
3

Out-of-Source Builds

CMake strongly encourages placing generated build artifacts in a separate directory (e.g., build/) to keep the source tree clean and allow multiple build configurations simultaneously.
4

Generator Abstraction

A single CMakeLists.txt can produce Makefiles on Linux, Ninja build files, Visual Studio solutions on Windows, or Xcode projects on macOS — all without modification.
5

Target-Centric Model

Modern CMake organizes builds around targets (executables, libraries). Properties like include directories and compile options are attached to targets, enabling clean dependency propagation.
✦ KEY TAKEAWAY
Think of CMake as a universal translator for build instructions. Just as a diplomatic interpreter converts a speech into whatever language the audience speaks, CMake converts your single project description into whatever build format your platform's native tools understand. You write the speech once; CMake handles the translation to Makefiles, Ninja, Visual Studio, or Xcode.

The CMake Workflow Visualized

The diagram below illustrates the complete CMake workflow from source files to a runnable executable. The flow proceeds in two distinct phases: the configure phase (where CMake reads your project description and generates native build files) and the build phase (where the native tool compiles and links your code). Notice how the developer only interacts with the top row — the CMakeLists.txt and source files — while CMake and the native build tool handle everything below.

The two-phase CMake workflow. The configure phase (top box) translates your CMakeLists.txt into platform-native build files. The build phase (bottom box) invokes the native compiler to produce the final binary.

Several important details emerge from this diagram. First, notice that the source files (.cpp and .h) flow directly into the build phase — CMake itself never reads your C++ source code during configuration. It only reads CMakeLists.txt to understand the project structure. Second, the CMake cache (stored as CMakeCache.txt in the build directory) persists configuration settings between runs, so reconfiguring after a small change is fast. Third, the generated build files and all compiled artifacts reside entirely within the build/ directory, leaving your source tree pristine — you can simply delete the build directory to start fresh.

The CMakeLists.txt — Anatomy and Commands

Every CMake project begins with a CMakeLists.txt file at the project root. This file uses CMake's own scripting language — a sequence of commands with parenthesized arguments — to describe the project. While CMake's language is Turing-complete and supports variables, conditionals, loops, and functions, the introductory workflow requires only a handful of commands. Let us examine each command in the minimal CMakeLists.txt for a single-file "Hello, World" project, understanding not just what each line does, but why it is necessary.

The Minimum Viable CMakeLists.txt

Consider a project directory containing two files: CMakeLists.txt and main.cpp. The CMake file contains exactly three essential commands:

COMMAND 1: CMAKE_MINIMUM_REQUIRED
cmake_minimum_required(VERSION 3.20)
Declares the minimum version of CMake required. This ensures that anyone building your project has a CMake version that supports the features you use. CMake will emit a fatal error if the installed version is older than 3.20. Always set this to the oldest version that supports all features in your CMakeLists.txt.
COMMAND 2: PROJECT
project(HelloWorld VERSION 1.0 LANGUAGES CXX)
Names the project and declares its language. CXX is CMake's identifier for C++. This command also sets several variables: PROJECT_NAME, PROJECT_VERSION, PROJECT_SOURCE_DIR, and PROJECT_BINARY_DIR, which can be referenced later in the script.
COMMAND 3: ADD_EXECUTABLE
add_executable(hello main.cpp)
Creates a target named hello that will be built as an executable from main.cpp. Multiple source files can be listed, separated by spaces. The target name is how you refer to this binary throughout the rest of the CMake script.

Additional Useful Commands

Beyond the three essential commands, two additional commands are commonly used in introductory projects. The set command assigns values to variables, and set(CMAKE_CXX_STANDARD 17) specifies that the project should be compiled with C++17. Pairing this with set(CMAKE_CXX_STANDARD_REQUIRED ON) ensures that CMake will fail rather than silently falling back to an older standard if the compiler does not support C++17. The modern alternative is the target-specific command target_compile_features(hello PRIVATE cxx_std_17), which attaches the requirement directly to the target, following the Modern CMake principle of target-centric configuration.

💡 Why CXX and not CPP?
CMake uses CXX to denote C++ because this convention predates CMake itself. On Unix systems, the C++ compiler was historically invoked as c++, but since + is problematic in filenames and environment variables, the letter pair XX was adopted as a safe substitute (C → CXX, just as .cpp files are sometimes named .cxx).

Project Structure and Build Directory Layout

Understanding the directory layout of a CMake project before and after configuration is essential for navigating your project confidently. A well-organized project separates source code from build artifacts, making version control straightforward and allowing multiple build configurations (debug, release, sanitized) to coexist. The diagram below shows the file system state at three stages: the initial source tree, the tree after the configure phase, and the tree after the build phase.

Directory layout at three stages. The source tree (①) is version-controlled. After configure (②), CMake populates build/ with generated build files. After build (③), the compiled executable appears inside build/. Deleting build/ resets everything.

The key insight from this layout is the strict separation between source files (which you write and commit) and build artifacts (which are generated and disposable). Your .gitignore should contain build/ to ensure that no generated files pollute your repository. If your build ever enters a strange state, you can simply remove the entire build/ directory and reconfigure from scratch — this is one of the great practical benefits of out-of-source builds.

⚠️ In-source builds: just don't
Running cmake . in the source directory (an in-source build) scatters generated files throughout your project tree. This makes it difficult to distinguish source from artifact, complicates version control, and can cause subtle caching bugs. Always use cmake -S . -B build to enforce an out-of-source build.

Worked Example — Building a Hello World Project

Let us walk through the complete process of creating, configuring, and building a minimal C++ project with CMake. We will work from the command line, which is the most universal approach and the one used by CI/CD systems. Every IDE that supports CMake (CLion, VS Code with the CMake Tools extension, Visual Studio) ultimately executes the same commands under the hood.

Building Hello World with CMake
1
Step 1 — Create the project directory and source fileCreate a directory called hello_cmake and add a file main.cpp with the following content: #include <iostream> int main() { std::cout << "Hello, CMake!" << std::endl; return 0; } This is a standard C++ program. Nothing about it is CMake-specific.
2
Step 2 — Write CMakeLists.txtIn the same directory, create CMakeLists.txt (the filename is case-sensitive on Linux/macOS): cmake_minimum_required(VERSION 3.20) project(HelloCMake VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(hello main.cpp) These five lines represent a complete, production-quality CMake configuration for a single-file project. The set commands ensure we compile with C++17.
3
Step 3 — Configure (generate build files)From the project root, run: cmake -S . -B build The -S . flag tells CMake that the source directory is the current directory, and -B build specifies the build directory. CMake will detect your compiler (g++, clang++, or MSVC), verify the minimum version requirement, and generate native build files in build/. You will see output confirming the compiler identification and the creation of Makefiles (or Ninja files, depending on your platform's default generator).
Output: -- Build files have been written to: /path/to/hello_cmake/build
4
Step 4 — Build (compile and link)Now invoke the build: cmake --build build This command delegates to the native build tool (e.g., make or ninja) inside the build/ directory. Using cmake --build instead of calling make directly is portable — it works regardless of which generator was used during configuration.
Output: [100%] Built target hello
5
Step 5 — Run the executableThe compiled binary resides in the build directory. Execute it: ./build/hello On Windows, the path would be .\build\Debug\hello.exe if using the MSVC generator, since multi-configuration generators place outputs in configuration-named subdirectories.
Output: Hello, CMake!
💡 Rebuild after editing
After modifying main.cpp, you only need to re-run cmake --build build. CMake's dependency tracking detects which files changed and recompiles only what is necessary. You need to re-run the configure step (cmake -S . -B build) only when you modify CMakeLists.txt — and even then, many generators auto-reconfigure when they detect CMakeLists.txt changes.

CMake vs. Other Build Systems

CMake is not the only build system available for C++ projects, and understanding where it excels and where it falls short will help you make informed tooling decisions. The table below compares CMake with several alternatives you may encounter in industry or open-source development. Each tool makes different trade-offs between simplicity, cross-platform support, and ecosystem integration.

Comparison of major C++ build systems
FeatureCMakeMakeMesonBazel
Cross-platformExcellent — Windows, Linux, macOS, embeddedUnix-focused; NMake on WindowsGood — all major platformsGood — all major platforms
LanguageCustom scripting languageTab-sensitive Makefile syntaxPython-like DSLStarlark (Python-like)
IDE integrationUbiquitous (CLion, VS, VS Code)MinimalGrowingLimited outside Google
Learning curveModerate — quirky syntaxSteep for complex projectsGentle — clean syntaxSteep — complex concepts
Ecosystem adoptionDominant — most C++ librariesLegacy projectsGrowing (GNOME, systemd)Google-centric; monorepos
Package managementfind_package, FetchContent, vcpkg/ConanManualWrapDB, subprojectsBuilt-in external deps
✦ KEY TAKEAWAY
CMake's greatest strength is not its syntax (which many find verbose) but its ubiquity. Learning CMake is like learning English for international business — it may not be the most elegant language, but it is the one that virtually every C++ library, employer, and CI/CD system expects. When you encounter a new open-source C++ project, there is an overwhelming probability that it uses CMake, and that knowledge transfers directly.

Connection to Advanced CMake

The three-line CMakeLists.txt we have studied is the foundation upon which substantial projects are built, but real-world CMake usage involves several layers of additional functionality. The table below maps the introductory concepts you have learned to their advanced counterparts, giving you a roadmap for continued study. As your projects grow to include multiple source files, external libraries, and testing frameworks, you will progressively adopt these more sophisticated features.

Mapping intro concepts to advanced CMake features
Intro ConceptAdvanced ExtensionWhen You Need It
add_executable()add_library() — STATIC, SHARED, INTERFACEWhen your project produces reusable libraries for other targets or projects
Single CMakeLists.txtadd_subdirectory() — multi-directory projectsWhen your codebase has logically separate modules (e.g., src/, tests/, libs/)
Listing source files manuallytarget_sources(), generator expressionsWhen source lists become large or platform-dependent
No external dependenciesfind_package(), FetchContentWhen your project depends on third-party libraries like Boost, fmt, or GoogleTest
Global set(CMAKE_CXX_STANDARD)target_compile_features() per targetWhen different targets in the same project require different C++ standards
Default build typeCMake Presets (CMakePresets.json)When you need reproducible, shareable configure/build/test configurations

A particularly important next step is mastering target-based dependency management. In Modern CMake, instead of setting global include directories or compiler flags, you attach properties to individual targets using commands like target_include_directories(), target_link_libraries(), and target_compile_options(). The PUBLIC, PRIVATE, and INTERFACE keywords control whether properties propagate to dependent targets, creating a clean and composable dependency graph that scales to large codebases with dozens of interconnected libraries.

Practice Problems

PROBLEM 1 — CONCEPTUAL
Explain why CMake is called a "meta-build system" or "build system generator" rather than a build system. What is the distinction between what CMake does and what make or ninja does?
PROBLEM 2 — BASIC
Write a minimal CMakeLists.txt that builds an executable called calculator from two source files: main.cpp and math_utils.cpp. The project should require C++17 and target CMake 3.20 or later.
PROBLEM 3 — INTERMEDIATE
A colleague cloned your repository and ran cmake . followed by make in the source directory. The project compiled successfully. Identify two problems with this approach and provide the correct commands they should have used instead.
PROBLEM 4 — APPLIED
You are setting up a CI/CD pipeline (GitHub Actions) for a C++ project that must build on Ubuntu with GCC, macOS with Clang, and Windows with MSVC. Explain how CMake's two-phase workflow simplifies this multi-platform CI configuration. Specifically, do you need to write different build scripts for each platform? What changes, if anything, between platforms?
PROBLEM 5 — CRITICAL THINKING
CMake's custom scripting language is often criticized as verbose and unintuitive compared to Meson's Python-like syntax or Bazel's Starlark. Despite this, CMake remains the dominant C++ build system. Analyze why ecosystem effects and switching costs can outweigh language ergonomics when evaluating build tools. Under what circumstances might it be rational for a new project to choose a non-CMake build system?

Lesson Summary

CMake is a build system generator that reads a single CMakeLists.txt project description and produces platform-native build files — Makefiles on Linux, Ninja files, Visual Studio solutions, or Xcode projects — without requiring any changes to the project configuration. The workflow is strictly two-phase: the configure phase (cmake -S . -B build) detects the compiler, processes the project description, and generates build files, while the build phase (cmake --build build) invokes the native tool to compile and link.

A minimal CMakeLists.txt requires only three commands: cmake_minimum_required() to declare version compatibility, project() to name the project, and add_executable() to define a build target. All generated artifacts belong in a separate build directory (out-of-source build), keeping the source tree clean and version-control friendly. As projects grow, Modern CMake's target-centric model — with target_link_libraries(), find_package(), and add_subdirectory() — scales the same foundational workflow to large, multi-library codebases.

Varsity Tutors • C++ • CMake Basics — Configure and build a simple project with CMake (intro workflow)