The problem with flat projects
Most C++ projects start with everything in one directory:
project/
├── main.cpp
├── sensor.cpp
├── sensor.h
├── logger.cpp
├── logger.h
├── uart.cpp
...
CMakeLists.txt ← one file, 300 lines
It works until the project grows. Then you get:
- a monolithic
CMakeLists.txtthat nobody wants to touch - headers with
#include "../../../sensor.h"relative paths - no way to reuse modules in another project
- tests that include half the codebase to test one function
The fix is a layered directory layout with one CMakeLists.txt per module.
The layout
project/
├── CMakeLists.txt ← top-level: orchestrates, sets policy
├── cmake/
│ └── CompilerFlags.cmake ← shared compiler options
├── src/
│ ├── sensor/
│ │ ├── CMakeLists.txt
│ │ ├── sensor.cpp
│ │ └── sensor.h
│ ├── logger/
│ │ ├── CMakeLists.txt
│ │ ├── logger.cpp
│ │ └── logger.h
│ └── app/
│ ├── CMakeLists.txt
│ └── main.cpp
├── lib/
│ └── etl/ ← third-party, vendored
│ └── CMakeLists.txt
└── test/
├── CMakeLists.txt
├── test_sensor.cpp
└── test_logger.cpp
Each directory under src/ is a CMake library target — a self-contained
module with its own include paths, sources, and dependencies.
Top-level CMakeLists.txt
1cmake_minimum_required(VERSION 3.21)
2project(MyFirmware CXX)
3
4set(CMAKE_CXX_STANDARD 17)
5set(CMAKE_CXX_STANDARD_REQUIRED ON)
6set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # for clangd, clang-tidy
7
8include(cmake/CompilerFlags.cmake)
9
10add_subdirectory(lib/etl)
11add_subdirectory(src/sensor)
12add_subdirectory(src/logger)
13add_subdirectory(src/app)
14
15option(BUILD_TESTS "Build unit tests" ON)
16if(BUILD_TESTS)
17 enable_testing()
18 add_subdirectory(test)
19endif()
No source files listed here. The top level only sets policy and composes modules.
Module CMakeLists.txt
1# src/sensor/CMakeLists.txt
2add_library(sensor STATIC
3 sensor.cpp
4)
5
6target_include_directories(sensor
7 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} # consumers get this path
8 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} # internal headers
9)
10
11target_link_libraries(sensor
12 PRIVATE etl::etl # implementation detail, not exposed
13)
14
15target_compile_options(sensor
16 PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-Wall -Wextra>
17)
PUBLIC vs PRIVATE vs INTERFACE on target_include_directories is the key:
| Scope | Meaning |
|---|---|
PRIVATE |
Only this target uses this include path |
PUBLIC |
This target and consumers use this path |
INTERFACE |
Only consumers use this path (header-only library) |
When sensor is PUBLIC, any target that target_link_libraries(... sensor) gets
sensor’s include path automatically — no manual path wrangling.
App target
1# src/app/CMakeLists.txt
2add_executable(firmware
3 main.cpp
4)
5
6target_link_libraries(firmware
7 PRIVATE
8 sensor
9 logger
10)
main.cpp just does #include "sensor.h" — CMake handles the path because
sensor declared its include directory as PUBLIC.
Enforcing layer boundaries
CMake’s target model allows you to enforce boundaries — but only if you’re
deliberate. A flat include_directories() call leaks everything to everyone.
Use target_include_directories with PRIVATE wherever the path shouldn’t
propagate to consumers.
For stricter enforcement, add a cmake/LayerCheck.cmake that asserts no
application-layer target is linked as a dependency of a lower-layer module:
1# Conceptually: app can depend on sensor, but sensor must not depend on app
2# Enforce by code review or a custom check target, not CMake itself
In practice, the layout itself enforces this: if sensor/CMakeLists.txt tries to
link against app, the dependency cycle will fail the build.
Compiler flags module
Centralise all compiler options so modules don’t repeat them:
1# cmake/CompilerFlags.cmake
2add_library(compiler_flags INTERFACE)
3
4target_compile_options(compiler_flags INTERFACE
5 $<$<COMPILE_LANGUAGE:CXX>:
6 -Wall
7 -Wextra
8 -Wpedantic
9 -Wconversion
10 -Wshadow
11 -fno-exceptions # for embedded targets
12 -fno-rtti
13 >
14 $<$<CONFIG:Debug>:-g3 -O0>
15 $<$<CONFIG:Release>:-O2 -DNDEBUG>
16)
17
18target_compile_features(compiler_flags INTERFACE cxx_std_17)
Then link every module against it:
1target_link_libraries(sensor PRIVATE compiler_flags)
INTERFACE libraries have no source files — they’re just a bag of properties
that propagate to consumers. This is the idiomatic CMake way to share settings.
Test CMakeLists.txt
1# test/CMakeLists.txt
2find_package(GTest REQUIRED)
3
4add_executable(test_sensor
5 test_sensor.cpp
6)
7
8target_link_libraries(test_sensor
9 PRIVATE
10 sensor
11 GTest::gtest_main
12)
13
14add_test(NAME SensorTests COMMAND test_sensor)
Tests link against the module library directly — no need to include source files again, no duplicate compilation.
Third-party libraries
Vendored libraries (ETL, FreeRTOS, Eigen) go under lib/ and get their own
CMakeLists.txt that wraps them as an imported or interface target:
1# lib/etl/CMakeLists.txt
2add_library(etl INTERFACE)
3target_include_directories(etl INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)
4add_library(etl::etl ALIAS etl)
The :: alias is a CMake convention for “real imported target” — it produces a
better error message if the target is missing, compared to a plain string name.
Build types
Configure debug and release from the command line, not from CMakeLists:
1# Debug build
2cmake -B build/debug -DCMAKE_BUILD_TYPE=Debug
3cmake --build build/debug
4
5# Release (minified firmware)
6cmake -B build/release -DCMAKE_BUILD_TYPE=Release
7cmake --build build/release
8
9# Cross-compile for STM32
10cmake -B build/stm32 \
11 -DCMAKE_TOOLCHAIN_FILE=cmake/stm32_toolchain.cmake \
12 -DCMAKE_BUILD_TYPE=Release \
13 -DBUILD_TESTS=OFF
14cmake --build build/stm32
Multiple build directories coexist — one for unit tests on the host, one for the target firmware, no cleaning required between them.
Common mistakes
1. Using include_directories() instead of target_include_directories()
include_directories() is directory-scoped — it leaks to every target in the
subtree. One call can break include isolation across the whole project.
2. Globbing sources
1# DO NOT do this:
2file(GLOB SOURCES "*.cpp")
3add_library(sensor ${SOURCES})
CMake won’t re-run when you add a new .cpp file — the build silently misses it.
List sources explicitly or use target_sources() in subdirectories.
3. Setting properties without targets
1# Wrong: affects everything
2set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
3
4# Right: scoped to a target
5target_compile_options(sensor PRIVATE -Wall)
4. Monolithic top-level file
If your root CMakeLists.txt lists source files directly, you’ve already lost.
The moment a module needs reuse, you’ll have to refactor. Start with modules from
day one.
The payoff
With this layout you can:
- Build just the
sensormodule and its tests:cmake --build build --target test_sensor - Swap the HAL by changing one
add_subdirectoryline - Reuse
loggerin another project by copying the directory and linking to it - Onboard a new team member by pointing them to
src/<module>/— self-contained
The structure scales from a 5-file hobby project to a 500-file production firmware without restructuring. Set it up correctly at the start and it mostly manages itself.
What’s next
- Interface-based design — designing the module interfaces CMake is wiring together
- SOLID in practice — applying SRP so each module has one reason to change
- Dependency injection without a framework — connecting modules at the composition root