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.txtdescribes 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
| Make | CMake | |
|---|---|---|
| What it is | Build executor | Build-file generator |
| Input | Makefile | CMakeLists.txt |
| Output | Compiled targets | Generated Makefile / Ninja / IDE project + compiled targets |
| Cross-compile | Set CC/CFLAGS variables | Toolchain file (-DCMAKE_TOOLCHAIN_FILE=...) |
| IDE integration | Manual or via project import | Native — generates CLion, VS Code, Visual Studio, Xcode projects |
| Learning curve | Steep early; transparent later | Steeper; abstractions hide details |
| Best for | Small projects, scripts, transparent control | Larger projects, multi-platform, IDE-friendly |
Make: The Basics
A Makefile is a list of rules:
target: prerequisite1 prerequisite2recipe-line-1recipe-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:
CROSS = arm-none-eabiCC = $(CROSS)-gccLD = $(CROSS)-gccOBJCOPY = $(CROSS)-objcopyCFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \-Os -Wall -Werror -ffunction-sections -fdata-sectionsLDFLAGS = -Tlinker.ld -Wl,--gc-sections -Wl,-Map=firmware.map \--specs=nano.specsSRCS = main.c startup.c system.cOBJS = $(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: %.cmatches any.otarget with corresponding.c - Automatic variables
$<(first prerequisite),$@(target),$^(all prerequisites) - Phony targets like
cleandeclared with.PHONYso Make doesn't look for a file namedclean
Automatic Variables Cheat Sheet
| Variable | Meaning | Example use |
|---|---|---|
$@ | Target name | gcc -o $@ ... |
$< | First prerequisite | gcc -c $< -o $@ |
$^ | All prerequisites | gcc $^ -o $@ |
$? | Prerequisites newer than target | Useful in incremental rebuilds |
$* | Stem of pattern rule | For %.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:
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_minimum_required(VERSION 3.20)project(firmware C ASM)# Common flags applied to all targetsadd_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}.elfmain.c startup.c system.c)# Convert .elf to .bin for flashingadd_custom_command(TARGET ${CMAKE_PROJECT_NAME}.elf POST_BUILDCOMMAND ${CMAKE_OBJCOPY} -O binary$<TARGET_FILE:${CMAKE_PROJECT_NAME}.elf>${CMAKE_PROJECT_NAME}.bin)
Build from the command line:
cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake -G Ninjacmake --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=...:
# arm-none-eabi.cmakeset(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 hostset(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)# Skip ABI checks that fail in cross-buildsset(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:
| Old | Modern 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_libsees this - PUBLIC —
my_liband anything that links tomy_libsee it - INTERFACE — only consumers of
my_libsee it (notmy_libitself)
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.
cmake -B build -S . -G Ninja -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmakeninja -C build
When to Use Make vs CMake vs IDE
| Situation | Pick |
|---|---|
| Small (one binary, ~10 source files), no IDE | Make — transparent, no extra layer |
| Project that should build from CLion / VS Code / IAR | CMake — 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 Makefile | Don't migrate unless there's a real pain point |
| Greenfield project, multiple developers | CMake + Ninja — modern default |
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
| Symptom | Cause | Fix |
|---|---|---|
| Header change doesn't trigger rebuild | Make doesn't track #include deps by default | Add -MMD -MP to CFLAGS, -include *.d to Makefile |
cmake --build always rebuilds everything | Source files in include path; CMake regenerates configure step constantly | Move sources out of include path; check CMakeCache.txt for stray paths |
| Wrong compiler used in cross-build | CMake fell back to host gcc | Toolchain file missing or -DCMAKE_TOOLCHAIN_FILE=... not passed |
| "Compiler is not able to compile a simple test" in CMake | CMake 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 stdin | Check for missing redirects; use < /dev/null if needed |
| Tabs vs spaces in Makefile | Recipe must use literal TAB characters | Configure 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
-jon a Pi?" - "What's
compile_commands.jsonand 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()andadd_executable()in CMake?" - "How do you express target-specific compile flags for one source file in CMake?"
- "What is
Bazeland where does it fit relative to Make/CMake?"
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.txt — if(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).
