CMake is an open-source, cross-platform build system generator that produces native build files (such as Makefiles, Ninja build files, or IDE project files) from a higher-level, compiler-agnostic description of a project's sources, dependencies, and build options.
In practice
In embedded development, CMake is increasingly used to manage the build process for bare-metal and RTOS-based firmware projects, particularly those targeting ARM Cortex-M and Cortex-A devices. Rather than directly compiling code, CMake reads one or more `CMakeLists.txt` files and generates build scripts for a backend of your choice -- commonly GNU Make or Ninja. The actual compilation is then driven by that backend, using whatever cross-compiler toolchain (e.g., `arm-none-eabi-gcc`) is configured. Toolchain files, which tell CMake about the target CPU, ABI, and compiler flags, are a central concept when cross-compiling for embedded targets.
Several popular embedded ecosystems now use CMake as their primary build system. Zephyr RTOS relies on CMake directly. ESP-IDF adopted CMake as its build system starting with v4.0, replacing its earlier Make-based system, though the migration was gradual and some older projects still use the legacy system. Some STM32 workflows -- such as projects generated by STM32CubeIDE and then adapted for use with VS Code -- can be configured to use CMake, but this is not universally the case across all STM32 tooling. If you are starting a new project or integrating with one of these ecosystems, understanding at least the basics of `CMakeLists.txt` structure and CMake toolchain files is increasingly necessary.
A common point of friction for beginners is that CMake adds a layer of abstraction on top of the build system they may already know. Developers coming from hand-written Makefiles (as covered in "Coding Step 1 - Hello World and Makefiles") sometimes find CMake verbose for small projects, but its value grows as projects gain multiple targets, optional features, or third-party library dependencies. Out-of-source builds -- where generated files live in a separate directory from source files -- are a CMake convention that keeps repositories clean and makes it straightforward to maintain multiple build configurations (e.g., debug vs. release, or different target boards) side by side.
A frequent pitfall in embedded CMake projects is incomplete or incorrect toolchain file configuration. Forgetting to set `CMAKE_SYSTEM_NAME`, `CMAKE_C_COMPILER`, and related variables causes CMake to probe for a host compiler instead of the cross-compiler, leading to confusing errors early in the configure step. Another common issue is linker script handling: the linker script path must be passed explicitly through `target_link_options` or a similar mechanism, since CMake has no built-in concept of MCU linker scripts.
Frequently asked
What is the difference between CMake and Make?
Make is a build executor: it reads a
Makefile and runs the compiler and
linker commands described in it. CMake is a build system generator: it reads CMakeLists.txt files and outputs a Makefile (or Ninja build file, or other format) that Make or another tool then executes. In a typical embedded workflow you invoke CMake once to configure the project, then invoke Make or Ninja repeatedly to compile.
Do I need CMake for embedded development, or are Makefiles sufficient?
Makefiles remain entirely sufficient for many embedded projects, especially smaller or single-target ones. CMake becomes more attractive when a project has multiple build configurations, reusable library components, or needs to integrate with an ecosystem (Zephyr, ESP-IDF, STM32 tooling) that already uses CMake. The 'Embedded Developers, Ditch Your IDEs' perspective often points to CMake plus a command-line workflow as a maintainable alternative to proprietary IDE project files.
How do I tell CMake to use an embedded cross-compiler instead of the host compiler?
You supply a
toolchain file -- a CMake script that sets variables such as `CMAKE_SYSTEM_NAME` (typically set to `Generic` for bare-metal targets), `CMAKE_C_COMPILER`, `CMAKE_
ASM_COMPILER`, and `CMAKE_OBJCOPY`. Pass it to CMake at configure time with `-DCMAKE_TOOLCHAIN_FILE=path/to/toolchain.cmake`. Without this, CMake will attempt to use and test the host compiler, which will fail or produce host binaries instead of firmware.
What is an out-of-source build and why does CMake encourage it?
An out-of-source build places all generated files (
Makefiles, object files, binaries) in a separate directory from the source tree, commonly a subdirectory called `build/`. This keeps the repository clean, makes it trivial to delete and regenerate build artifacts (`rm -rf build/`), and allows multiple independent build directories to coexist -- for example, one for a debug build and one for a release build targeting a different board.
How does CMake handle linker scripts for bare-metal targets?
CMake has no built-in abstraction for
MCU linker scripts. The standard approach is to pass the linker script path as a compiler flag using `target_link_options(my_target PRIVATE -T${CMAKE_SOURCE_DIR}/linker/stm32f4.ld)` and to declare the linker script as an explicit link dependency -- for example, using `set_source_files_properties` or by listing it as a dependency with `add_custom_command` -- so that a change to the script triggers a relink. Forgetting the dependency means stale binaries can go undetected when the linker script changes.
Differentiators vs similar concepts
CMake is often compared to Make and to Ninja. Make and Ninja are build tools that execute build rules (which typically invoke compiler and
linker commands); CMake is one level above them, generating the files those tools consume. CMake is also sometimes confused with CPack (a packaging tool bundled with CMake) or CTest (its companion test runner). A separate common point of confusion is CMake vs. Autotools (Autoconf/Automake): both are build system generators targeting similar goals, but Autotools is Unix-centric and rarely used in embedded
cross-compilation workflows, while CMake has explicit cross-compilation support and broader IDE integration, though the quality of that integration varies across
toolchains and environments.