EduC++ / Understanding CMake Through Code

Understanding CMake Through Code

This file is a companion to CMakeLists.txt. It's a runnable program that demonstrates how CMake settings translate to compiler behavior. Read this alongside the CMakeLists.txt for a complete picture.

HOW TO BUILD THIS FILE:

Method 1: With CMake (recommended) mkdir build cd build cmake .. -G Ninja (or -G "Unix Makefiles" or omit -G) cmake --build . ./cmake_explained (Linux/Mac) cmake_explained.exe (Windows)

Method 2: Direct compilation (what CMake generates internally) g++ -std=c++20 -Wall -Wextra -o cmake_explained cmake_explained.cpp cl /std:c++20 /W4 /EHsc cmake_explained.cpp (MSVC)

Prereqs None (this is a standalone explanation file).

Frequently Asked Questions

QWhat is the difference between CMake and make?
Amake is a build tool that reads a Makefile and executes compiler commands. CMake is a build-system *generator*: it reads CMakeLists.txt and produces native build files (Makefiles, Ninja files, Visual Studio projects, Xcode projects, etc.). You run CMake once to generate, then use the native tool to build.
QWhy use CMake instead of calling g++ directly?
ADirect g++ invocations work for tiny programs, but they do not scale. CMake tracks file dependencies, rebuilds only what changed, compiles independent files in parallel, and generates the correct flags for any platform and compiler. It also integrates testing (CTest), packaging (CPack), and third-party dependency management.
QDoes the CMake version matter, and what version should I require?
AYes. Each CMake release adds new features and policies. Set cmake_minimum_required() to the oldest version that supports every feature you use. CMake 3.20+ is a safe modern baseline -- it supports C++20, presets, and modern target-based commands.
QWhat are the CMake build types and when should I use each?
ADebug (-g -O0): full debug info, no optimization -- use during development. Release (-O3 -DNDEBUG): maximum optimization, asserts disabled -- use for production. RelWithDebInfo (-O2 -g): optimized but debuggable -- use when profiling. MinSizeRel (-Os -DNDEBUG): smallest binary -- use for embedded or size-constrained deployments.
QHow do I add a third-party library with CMake?
AUse find_package() for system-installed libraries, or FetchContent (CMake 3.11+) to download and build dependencies automatically. FetchContent is preferred for reproducible builds because it pins a specific version in your CMakeLists.txt.
C++
#include <iostream>
#include <format>

WHAT CMAKE DOES BEHIND THE SCENES

When you write this in CMakeLists.txt:

cmake_minimum_required(VERSION 3.20) project(MyApp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF)

add_executable(my_app main.cpp utils.cpp) target_compile_options(my_app PRIVATE -Wall -Wextra) target_link_libraries(my_app PRIVATE pthread)

CMake generates roughly this compiler command:

g++ -std=c++20 -Wall -Wextra -o my_app main.cpp utils.cpp -lpthread

But CMake does much MORE than just generate the command:

1. Dependency tracking: if utils.cpp changes, only utils.o is recompiled, then the final link step reruns. main.o is reused.

2. Header dependency scanning: if main.cpp #includes "utils.h", and utils.h changes, main.o is rebuilt too. CMake tracks this automatically using compiler-generated dependency files (.d files).

3. Parallel builds: CMake tells the build tool (Ninja/Make) which files are independent, so main.o and utils.o compile in parallel.

4. Incremental builds: only changed files are rebuilt. On a large project (thousands of files), this saves minutes of build time.

5. Cross-platform: the same CMakeLists.txt generates Visual Studio projects on Windows and Makefiles on Linux.

PROJECT STRUCTURE BEST PRACTICES

A well-organized C++ project typically looks like:

MyProject/ ├── CMakeLists.txt <- Root build file ├── CMakePresets.json <- Build presets (optional, CMake 3.21+) ├── README.md ├── .gitignore <- Include "build/" here! │ ├── src/ <- Implementation files (.cpp) │ ├── CMakeLists.txt <- Subdirectory build rules │ ├── main.cpp │ └── utils.cpp │ ├── include/ <- Public headers (.h / .hpp) │ └── myproject/ │ └── utils.hpp │ ├── tests/ <- Test files │ ├── CMakeLists.txt │ └── test_utils.cpp │ ├── external/ <- Third-party dependencies │ └── ... │ └── build/ <- Build output (NOT in git) └── ...

Key conventions: - Headers in include/<project_name>/ so #include is unambiguous: #include <myproject/utils.hpp> - Source in src/, tests in tests/, each with their own CMakeLists.txt - build/ is always in .gitignore - Use add_subdirectory() to include sub-CMakeLists.txt files

COMMON CMAKE MISTAKES AND HOW TO AVOID THEM

MISTAKE 1: Using include_directories() instead of target_include_directories() BAD: include_directories(include/) <- affects ALL targets GOOD: target_include_directories(my_lib PUBLIC include/) <- affects only my_lib

MISTAKE 2: Not setting CXX_STANDARD_REQUIRED BAD: set(CMAKE_CXX_STANDARD 20) <- silently falls back if unsupported GOOD: set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) <- error if C++20 not available

MISTAKE 3: Building in the source directory BAD: cd MyProject && cmake . <- pollutes source with build files GOOD: cd MyProject && mkdir build && cd build && cmake ..

MISTAKE 4: Hardcoding compiler paths or flags BAD: set(CMAKE_CXX_COMPILER /usr/bin/g++-12) <- breaks on other machines GOOD: Let the user choose: cmake .. -DCMAKE_CXX_COMPILER=g++-12

MISTAKE 5: Not using generator expressions for cross-compiler flags BAD: target_compile_options(app PRIVATE -Wall) <- fails on MSVC GOOD: target_compile_options(app PRIVATE $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall> $<$<CXX_COMPILER_ID:MSVC>:/W4> )

MISTAKE 6: Forgetting to link threading on Linux The program compiles fine but crashes at runtime with threading. GOOD: find_package(Threads REQUIRED) target_link_libraries(app PRIVATE Threads::Threads)

C++
int main() {
    std::cout << "=== CMake Build System Explained ===\n\n";

    // --- Show what the compiler set for us ---
    std::cout << "--- Compiler information ---\n";

#if defined(_MSC_VER)
    std::cout << std::format("  Compiler: MSVC {}\n", _MSC_VER);
#elif defined(__clang__)
    std::cout << std::format("  Compiler: Clang {}.{}.{}\n",
                              __clang_major__, __clang_minor__, __clang_patchlevel__);
#elif defined(__GNUC__)
    std::cout << std::format("  Compiler: GCC {}.{}.{}\n",
                              __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__);
#else
    std::cout << "  Compiler: Unknown\n";
#endif

    // --- C++ standard version ---
    // __cplusplus is set by the compiler to indicate the standard version.
    // CMake's CMAKE_CXX_STANDARD = 20 translates to -std=c++20 or /std:c++20,
    // which makes the compiler set __cplusplus accordingly.
    std::cout << std::format("  __cplusplus = {}\n", __cplusplus);
    // Expected values:
    //   199711L = C++98/03
    //   201103L = C++11
    //   201402L = C++14
    //   201703L = C++17
    //   202002L = C++20
    //   202302L = C++23

    // Note: MSVC historically reported 199711L regardless of the actual
    // standard unless /Zc:__cplusplus was set. Modern MSVC with /std:c++20
    // should report correctly.

    // --- Build type ---
    // CMake has build types that control optimization:
    //   Debug:          -g -O0        (debuggable, slow)
    //   Release:        -O3 -DNDEBUG  (fast, no asserts)
    //   RelWithDebInfo: -O2 -g        (fast with debug info)
    //   MinSizeRel:     -Os -DNDEBUG  (small binary)
    //
    // Set with: cmake .. -DCMAKE_BUILD_TYPE=Release
    std::cout << "\n--- Build configuration ---\n";
#ifdef NDEBUG
    std::cout << "  Build type: Release (NDEBUG defined, asserts disabled)\n";
#else
    std::cout << "  Build type: Debug (asserts enabled)\n";
#endif

    // --- Platform detection ---
    std::cout << "\n--- Platform ---\n";
#if defined(_WIN32)
    std::cout << "  Platform: Windows\n";
#elif defined(__linux__)
    std::cout << "  Platform: Linux\n";
#elif defined(__APPLE__)
    std::cout << "  Platform: macOS\n";
#else
    std::cout << "  Platform: Unknown\n";
#endif

    // --- Quick build command reference ---
    std::cout << "\n--- Quick reference: building with CMake ---\n";
    std::cout << "  1. mkdir build && cd build\n";
    std::cout << "  2. cmake ..                     (generate build files)\n";
    std::cout << "  3. cmake --build .              (compile everything)\n";
    std::cout << "  4. cmake --build . --target X   (compile just target X)\n";
    std::cout << "  5. ctest                        (run tests)\n";
    std::cout << "  6. cmake --install .            (install to system)\n";

    std::cout << "\n--- Quick reference: useful CMake flags ---\n";
    std::cout << "  cmake .. -G Ninja                    (use Ninja generator)\n";
    std::cout << "  cmake .. -DCMAKE_BUILD_TYPE=Release  (optimize for speed)\n";
    std::cout << "  cmake .. -DCMAKE_CXX_COMPILER=clang++ (choose compiler)\n";
    std::cout << "  cmake .. -DCMAKE_EXPORT_COMPILE_COMMANDS=ON (for IDE/linters)\n";

    return 0;
}