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.

Programming braces with a command terminal and binary digits.
Inside this lesson 10 sections 5 questions 7 figures

SECTION 1

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.

SECTION 2

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.

SECTION 3

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.

CONFIGURE PHASEBUILD PHASECMakeLists.txtSource Files (.cpp/.h)CMake Cachecmake -S . -B build(Configure command)Generated Build FilesMakefile / build.ninja / .slnstored in build/ directorycmake --build build(Build command)Compile & Linkg++ / clang++ / MSVCExecutable / Library
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.

SECTION 4

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

cmakeminimumrequired(VERSION3.20)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(HelloWorldVERSION1.0LANGUAGESCXX)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

addexecutable(hellomain.cpp)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).

SECTION 5

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.

① SOURCE TREEmy_project/├── CMakeLists.txt├── main.cpp├── src/│ ├── utils.cpp│ └── utils.h└── .gitignore(no build/ yet)② AFTER CONFIGUREmy_project/├── CMakeLists.txt├── main.cpp├── src/ ...└── build/├── CMakeCache.txt├── CMakeFiles/├── cmake_install.cmake└── Makefile(or build.ninja, .sln, etc.)③ AFTER BUILDmy_project/├── CMakeLists.txt├── main.cpp├── src/ ...└── build/├── CMakeCache.txt├── CMakeFiles/├── cmake_install.cmake├── Makefile└── hello(the compiled executable)Add "build/" to .gitignore — generated files should never be committed to version control.
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.

SECTION 6

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. 1

    Step 1 — Create the project directory and source file

    Create 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. 2

    Step 2 — Write CMakeLists.txt

    In 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. 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. 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. 5

    Step 5 — Run the executable

    The 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.

SECTION 7

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.

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

Comparison of major C++ build systems

✦

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.

SECTION 8

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.

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

Mapping intro concepts to advanced CMake features

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.

SECTION 9

Practice Problems

Question 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?
Show answer
CMake itself never invokes a compiler or linker. Instead, it reads your CMakeLists.txt project description and generates native build files (Makefiles, Ninja files, Visual Studio solutions, etc.) that are consumed by a separate, platform-native build tool. The native build tool (make, ninja, MSBuild) is the actual build system — it reads dependency graphs and invokes compilers. CMake sits one level above, hence "meta." This separation is what enables cross-platform portability: one project description produces different native build files for each platform.

Question 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.
Show answer
The complete CMakeLists.txt would be: cmake_minimum_required(VERSION 3.20) project(Calculator VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(calculator main.cpp math_utils.cpp) The add_executable command accepts multiple source files separated by whitespace. CMake will compile each source file into an object file and link them together into the calculator executable. Header files (.h) do not need to be listed — they are resolved by the compiler through #include directives.

Question 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.
Show answer
First, running cmake . performs an in-source build, scattering generated files (CMakeCache.txt, CMakeFiles/, Makefile) throughout the source tree. This pollutes version control, makes it difficult to distinguish source from artifact, and prevents maintaining multiple build configurations simultaneously. Second, calling make directly is not portable — it assumes the Unix Makefiles generator was used, which fails on Windows with Visual Studio or when using the Ninja generator. The correct commands are: cmake -S . -B build followed by cmake --build build. The first command forces an out-of-source build, and the second is generator-agnostic.

Question 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?
Show answer
CMake's configure-then-build workflow dramatically simplifies multi-platform CI because the same two commands — cmake -S . -B build and cmake --build build — work identically on all three platforms. You do not need separate build scripts. During the configure phase, CMake automatically detects the available compiler (GCC on Ubuntu, Clang on macOS, MSVC on Windows) and selects the appropriate default generator (Unix Makefiles or Ninja on Linux/macOS, Visual Studio on Windows). The CMakeLists.txt file is unchanged across platforms. The only CI-level differences are installing dependencies and selecting the runner OS. You might optionally pass -G Ninja on all platforms for faster, consistent builds, or specify a build type with -DCMAKE_BUILD_TYPE=Release for single-configuration generators.

Question 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?
Show answer
CMake's dominance is a classic example of network effects in developer tooling. Since the majority of C++ libraries publish CMake find-modules or config files, using CMake means frictionless integration with the dependency ecosystem — find_package(Boost) simply works. Switching to Meson or Bazel requires wrapping or reimplementing dependency discovery for every library, which is a significant upfront cost. Additionally, IDE support (CLion, VS, VS Code) is deepest for CMake, and most C++ developers already know it, reducing onboarding friction. However, a non-CMake system may be rational when: (1) the project exists within a monorepo with an existing Bazel infrastructure (e.g., at Google); (2) the project targets only Linux and can leverage Meson's cleaner semantics without cross-platform concerns; (3) the team prioritizes developer experience and is willing to invest in dependency wrapping; or (4) the project is in a language ecosystem where CMake is not dominant (e.g., Rust's Cargo, Go's built-in build system). The key insight is that build system choice is as much a social and ecosystem decision as a technical one.

SUMMARY

Lesson Summary

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)