Search topics...
Build Systems
intermediate
Weight: 3/10

Make & CMake for Embedded

Compare Make and CMake for embedded projects: rules, targets, toolchain files, and when to use each.

build-systems
make
cmake
build-tools
toolchain-file
Loading quiz status...

Quick Cap

A build system decides what to compile, in what order, with what flags, and when to skip a step that hasn't changed. Make is the venerable Unix tool: a Makefile lists rules of the form "target depends on these inputs; here's the recipe to rebuild it," and make does the dependency tracking. CMake is a higher-level meta-tool that generates Makefiles (or Ninja build files, or IDE projects) from a portable description. For embedded, both are common; the choice depends on whether you need cross-platform IDE generation (CMake) or a simple, transparent recipe (Make). Interviewers test whether you can read both and write a basic toolchain file for cross-compilation.

Key Facts:

  • Make rule = target + dependencies + recipe; rebuild target only if any dependency is newer
  • Automatic variables in Make: $@ = target, $^ = all deps, $< = first dep
  • CMake = meta-build: CMakeLists.txt describes targets and dependencies; CMake generates the actual build files
  • Generators: CMake can output Make, Ninja, Visual Studio, Xcode — same CMakeLists.txt, different builds
  • Toolchain file (-DCMAKE_TOOLCHAIN_FILE=...) tells CMake to cross-compile for embedded
  • Modern CMake style: target-based — target_link_libraries, target_compile_options, target_include_directories

Deep Dive

At a Glance

MakeCMake
What it isBuild executorBuild-file generator
InputMakefileCMakeLists.txt
OutputCompiled targetsGenerated Makefile / Ninja / IDE project + compiled targets
Cross-compileSet CC/CFLAGS variablesToolchain file (-DCMAKE_TOOLCHAIN_FILE=...)
IDE integrationManual or via project importNative — generates CLion, VS Code, Visual Studio, Xcode projects
Learning curveSteep early; transparent laterSteeper; abstractions hide details
Best forSmall projects, scripts, transparent controlLarger projects, multi-platform, IDE-friendly

Make: The Basics

A Makefile is a list of rules:

make
target: prerequisite1 prerequisite2
recipe-line-1
recipe-line-2

Make rebuilds target if any prerequisite is newer (file mtime comparison). The recipe lines must be tab-indented (literal tab character — a classic gotcha).

A minimal embedded Makefile:

make
CROSS = arm-none-eabi
CC = $(CROSS)-gcc
LD = $(CROSS)-gcc
OBJCOPY = $(CROSS)-objcopy
CFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
-Os -Wall -Werror -ffunction-sections -fdata-sections
LDFLAGS = -Tlinker.ld -Wl,--gc-sections -Wl,-Map=firmware.map \
--specs=nano.specs
SRCS = main.c startup.c system.c
OBJS = $(SRCS:.c=.o)
firmware.bin: firmware.elf
$(OBJCOPY) -O binary $< $@
firmware.elf: $(OBJS)
$(LD) $(CFLAGS) $(LDFLAGS) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) firmware.elf firmware.bin firmware.map
.PHONY: clean

A few key features visible here:

  • Variables (CC, CFLAGS) factored out for readability and reuse
  • Pattern rule %.o: %.c matches any .o target with corresponding .c
  • Automatic variables $< (first prerequisite), $@ (target), $^ (all prerequisites)
  • Phony targets like clean declared with .PHONY so Make doesn't look for a file named clean

Automatic Variables Cheat Sheet

VariableMeaningExample use
$@Target namegcc -o $@ ...
$<First prerequisitegcc -c $< -o $@
$^All prerequisitesgcc $^ -o $@
$?Prerequisites newer than targetUseful in incremental rebuilds
$*Stem of pattern ruleFor %.o: %.c, $* = base name

Make's Dependency Pitfall

By default, Make tracks only the file-level dependencies you declare. If main.c includes config.h and config.h changes, Make has no idea — it sees main.c is newer than config.h's mtime is irrelevant unless you tell Make to track headers.

The standard fix is auto-generated dependencies with gcc's -MMD -MP:

make
CFLAGS += -MMD -MP
# Include the .d files Make generates
-include $(SRCS:.c=.d)

Now compiling main.c produces both main.o and main.d (a Makefile snippet listing every header included). Future runs include those .d files and Make automatically rebuilds when any header changes. Without this, header changes silently produce stale builds.

CMake: The Meta-Build

CMake doesn't compile anything itself. Instead, it reads CMakeLists.txt files and generates native build files (Makefiles, Ninja files, Visual Studio projects, etc.). You then run the generated build.

A minimal embedded CMakeLists.txt:

cmake
cmake_minimum_required(VERSION 3.20)
project(firmware C ASM)
# Common flags applied to all targets
add_compile_options(
-mcpu=cortex-m4 -mthumb
-mfpu=fpv4-sp-d16 -mfloat-abi=hard
-Os -Wall -Werror
-ffunction-sections -fdata-sections
)
add_link_options(
-mcpu=cortex-m4 -mthumb
-mfpu=fpv4-sp-d16 -mfloat-abi=hard
-T${CMAKE_SOURCE_DIR}/linker.ld
-Wl,--gc-sections
-Wl,-Map=${CMAKE_PROJECT_NAME}.map
--specs=nano.specs
)
add_executable(${CMAKE_PROJECT_NAME}.elf
main.c startup.c system.c
)
# Convert .elf to .bin for flashing
add_custom_command(
TARGET ${CMAKE_PROJECT_NAME}.elf POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary
$<TARGET_FILE:${CMAKE_PROJECT_NAME}.elf>
${CMAKE_PROJECT_NAME}.bin
)

Build from the command line:

text
cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake -G Ninja
cmake --build build

The -G Ninja selects the generator (Ninja is much faster than Make for incremental builds; both are common).

CMake Toolchain Files

A toolchain file tells CMake "you're cross-compiling for this target." It's a tiny CMake script you point at via -DCMAKE_TOOLCHAIN_FILE=...:

cmake
# arm-none-eabi.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY arm-none-eabi-objcopy)
set(CMAKE_SIZE arm-none-eabi-size)
# Don't try to run target binaries on the host
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
# Skip ABI checks that fail in cross-builds
set(CMAKE_C_COMPILER_WORKS 1)
set(CMAKE_CXX_COMPILER_WORKS 1)

Two important lines:

  • CMAKE_SYSTEM_NAME Generic — tells CMake there's no OS on the target. This unlocks the bare-metal code paths.
  • CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY — by default, CMake tries to compile-and-link a small test program to check the toolchain. For bare metal that link fails (no startup code, no _start). Setting this to STATIC_LIBRARY makes CMake stop at the compile step.

Modern CMake: Target-Based Style

The "old" CMake style uses global variables (CMAKE_C_FLAGS, include_directories, link_libraries). The "modern" target-based style scopes everything to specific targets:

OldModern target-based
add_definitions(-DDEBUG)target_compile_definitions(my_lib PRIVATE DEBUG)
include_directories(inc)target_include_directories(my_lib PUBLIC inc)
link_libraries(foo)target_link_libraries(my_lib PRIVATE foo)
set(CMAKE_C_FLAGS "...")target_compile_options(my_lib PRIVATE -O2)

The PRIVATE/PUBLIC/INTERFACE keywords control whether the property propagates to consumers:

  • PRIVATE — only my_lib sees this
  • PUBLICmy_lib and anything that links to my_lib see it
  • INTERFACE — only consumers of my_lib see it (not my_lib itself)

Modern style scales much better in larger projects.

Ninja: The Fast Generator

By default cmake -B build generates Makefiles. Adding -G Ninja generates Ninja files instead. Ninja is significantly faster for incremental builds (lower per-file overhead, parallel by default), at the cost of being slightly less human-readable. For any non-trivial embedded project, Ninja is the recommended generator.

text
cmake -B build -S . -G Ninja -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake
ninja -C build

When to Use Make vs CMake vs IDE

SituationPick
Small (one binary, ~10 source files), no IDEMake — transparent, no extra layer
Project that should build from CLion / VS Code / IARCMake — generates IDE projects natively
Multi-platform (host tests + embedded firmware)CMake — toolchain files separate target concerns
Vendor-supplied IDE flow (STM32CubeIDE, MCUXpresso)IDE-generated Make — simplest for vendor support
Existing project with a working MakefileDon't migrate unless there's a real pain point
Greenfield project, multiple developersCMake + Ninja — modern default
💡Generate compile_commands.json

Both Make (with bear) and CMake (with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON) can produce compile_commands.json — a list of every command used to compile each file. Tools like clangd, clang-tidy, and IDE language servers use it for accurate code navigation and diagnostics. Always enable it.

Common Build-System Bugs

SymptomCauseFix
Header change doesn't trigger rebuildMake doesn't track #include deps by defaultAdd -MMD -MP to CFLAGS, -include *.d to Makefile
cmake --build always rebuilds everythingSource files in include path; CMake regenerates configure step constantlyMove sources out of include path; check CMakeCache.txt for stray paths
Wrong compiler used in cross-buildCMake fell back to host gccToolchain file missing or -DCMAKE_TOOLCHAIN_FILE=... not passed
"Compiler is not able to compile a simple test" in CMakeCMake try-compile failed (no startup code on bare metal)Set CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY in toolchain file
Make seems to "hang"Recipe line is awaiting input from stdinCheck for missing redirects; use < /dev/null if needed
Tabs vs spaces in MakefileRecipe must use literal TAB charactersConfigure editor to preserve tabs in Makefiles

Debugging Story: The Phantom Stale Build

A team kept hitting a bug where "I changed the header, but the bug is still there." After hours of confusion, a senior engineer ran make clean && make and the issue evaporated. The Makefile had no header dependency tracking — main.o was rebuilt only when main.c changed, not when config.h (which main.c includes) changed. The header change was effectively invisible to Make.

Fix: add -MMD -MP to CFLAGS and -include $(OBJS:.o=.d) to the Makefile. Now every main.c compile generates main.d listing every header main.c (transitively) includes. Subsequent builds correctly rebuild main.o whenever any of those headers change.

The lesson: Make's dependency model only tracks what you declare. Header dependencies require explicit auto-generation via the compiler's -MMD flag. Without it, you have a build system that lies to you.

What Interviewers Want to Hear

  • You can read a basic Makefile and explain rules, automatic variables, pattern rules
  • You know Make doesn't track header dependencies without help (-MMD -MP)
  • You understand CMake is a meta-build that generates Makefiles or Ninja files
  • You can describe the role of a toolchain file for cross-compilation
  • You can articulate when to use Make vs CMake (project size, IDE need, multi-platform)
  • You know modern CMake is target-based (target_link_libraries, etc.)

Interview Focus

Classic Interview Questions

Q1: "What's the difference between Make and CMake?"

Model Answer Starter: "Make is a build executor — it reads a Makefile that lists rules of the form target: dependencies; recipe, and runs the recipe whenever a dependency is newer than the target. CMake is a meta-build system — it doesn't compile anything itself. Instead, it reads CMakeLists.txt files (a portable description of targets, sources, dependencies) and generates native build files: Makefiles, Ninja build files, Visual Studio solutions, Xcode projects. You then run the generated build. The benefit is that the same CMakeLists.txt describes the project once and works across platforms and IDEs; the cost is more abstraction layers and a steeper learning curve."

Q2: "Walk me through writing a CMake toolchain file for arm-none-eabi."

Model Answer Starter: "It's a small CMake script that sets the toolchain variables. First, CMAKE_SYSTEM_NAME Generic tells CMake there's no OS on the target — this unlocks bare-metal code paths. Second, set the compiler executables: CMAKE_C_COMPILER arm-none-eabi-gcc, CMAKE_CXX_COMPILER arm-none-eabi-g++, CMAKE_ASM_COMPILER arm-none-eabi-gcc. Third, optionally set CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY because CMake's default try-compile creates an executable, which fails on bare metal without startup code. Then invoke CMake with -DCMAKE_TOOLCHAIN_FILE=path/to/file.cmake. The toolchain file should be in version control alongside the project."

Q3: "I changed a header, ran make, and the bug is still there. What happened?"

Model Answer Starter: "Almost certainly missing header dependency tracking. Make only rebuilds when an explicitly declared dependency is newer than the target. Headers included via #include are not tracked unless you add them yourself or use the compiler's -MMD -MP flags to auto-generate dependency files. The fix is to add -MMD -MP to CFLAGS, then -include $(OBJS:.o=.d) in the Makefile to pull in the generated .d files. After that, any header change triggers rebuild of every .c that includes it (transitively). Without this, you have to make clean to be sure of a clean rebuild — every developer's least favorite workaround."

Q4: "What are CMake's PRIVATE / PUBLIC / INTERFACE keywords for?"

Model Answer Starter: "They control whether a target's properties propagate to consumers. PRIVATE means only this target uses the property — its consumers don't see it. PUBLIC means this target uses it AND anything linking to this target also gets it. INTERFACE means only consumers see it, not this target itself (used for header-only libraries). Example: target_include_directories(my_lib PUBLIC inc) lets my_lib see inc/ AND any executable that does target_link_libraries(app PRIVATE my_lib) also gets inc/ on its include path. Without these scopes, you'd have to manually duplicate include paths everywhere — the modern CMake style is much more maintainable."

Q5: "Why is Ninja often preferred over Make for the build step?"

Model Answer Starter: "Two reasons. First, Ninja was designed specifically as a build executor, not as a general scripting tool — it has lower per-file overhead and parallel execution by default, so incremental builds are typically 2-5x faster than Make. Second, Ninja files are designed to be machine-generated, not hand-written, so they expose less footgun. The trade-off is human readability: a Makefile is editable; a Ninja file is barely readable. With CMake generating the build files, you don't write Ninja by hand — you just get the speed benefit. cmake -G Ninja is the modern default."

Trap Alerts

  • Don't say: "Make and CMake do the same thing" — they're at different layers; lumping them shows confusion
  • Don't forget: Header dependency tracking in Make (-MMD -MP) — silent stale builds otherwise
  • Don't ignore: Toolchain file's role — without it CMake builds for the host architecture, not the target

Follow-up Questions

  • "How do you parallelize a Make build, and what's the gotcha with -j on a Pi?"
  • "What's compile_commands.json and how do you generate it?"
  • "How would you organize a CMake project with multiple binaries sharing common code?"
  • "What's the difference between add_subdirectory() and add_executable() in CMake?"
  • "How do you express target-specific compile flags for one source file in CMake?"
  • "What is Bazel and where does it fit relative to Make/CMake?"
💡Practice Build Systems Interview Questions

Ready to test yourself? Head over to the Build Systems Interview Questions page for a full set of Q&A with collapsible answers — perfect for self-study and mock interview practice.

Practice

In a Makefile, what does `$<` represent?

What is the role of a CMake toolchain file?

Why do Make builds sometimes miss header changes, producing stale binaries?

What does `target_link_libraries(my_app PUBLIC my_lib)` do compared to `PRIVATE`?

Why is Ninja often preferred over Make as the CMake generator?

Real-World Tie-In

Cross-Compile + Host Tests Same Project — A team's CMake project builds firmware (cross-compiled with arm-none-eabi.cmake toolchain) and host-runnable unit tests (built natively with the default toolchain). Two CMake build directories: build-firmware/ with the toolchain file, build-tests/ without. Same CMakeLists.txtif(CMAKE_SYSTEM_NAME STREQUAL "Generic") branches between firmware-specific and host-specific configuration.

clangd / VS Code Setup via compile_commands.json — Enabling -DCMAKE_EXPORT_COMPILE_COMMANDS=ON produces compile_commands.json in the build directory. Symlinking it to the project root lets clangd (the LSP) provide accurate go-to-definition, completion, and diagnostics with full knowledge of cross-compile flags. Single-line addition; transformative for developer experience.

Replacing 800-line Makefile with 90-line CMakeLists — A team migrating from a hand-written Makefile to CMake reduced build description from ~800 lines to ~90 lines. Most reduction came from CMake's automatic source-file globbing, target-based property propagation eliminating duplicated flag lists, and built-in handling of .d dependency files (no explicit -MMD -MP boilerplate).

Was this helpful?