Проблема плоских проектов

Большинство C++-проектов начинаются с размещения всего в одной директории:

project/
├── main.cpp
├── sensor.cpp
├── sensor.h
├── logger.cpp
├── logger.h
├── uart.cpp
...
CMakeLists.txt   ← один файл, 300 строк

Работает, пока проект не растёт. Затем появляется:

  • монолитный CMakeLists.txt, которого никто не хочет трогать
  • заголовки с относительными путями #include "../../../sensor.h"
  • нет возможности повторно использовать модули в другом проекте
  • тесты, включающие половину кодовой базы для тестирования одной функции

Исправление — многоуровневое расположение директорий с одним CMakeLists.txt на модуль.


Структура директорий

project/
├── CMakeLists.txt          ← верхний уровень: оркестрация, установка политик
├── cmake/
│   └── CompilerFlags.cmake ← общие опции компилятора
├── src/
│   ├── sensor/
│   │   ├── CMakeLists.txt
│   │   ├── sensor.cpp
│   │   └── sensor.h
│   ├── logger/
│   │   ├── CMakeLists.txt
│   │   ├── logger.cpp
│   │   └── logger.h
│   └── app/
│       ├── CMakeLists.txt
│       └── main.cpp
├── lib/
│   └── etl/                ← стороннее, вендорное
│       └── CMakeLists.txt
└── test/
    ├── CMakeLists.txt
    ├── test_sensor.cpp
    └── test_logger.cpp

Каждая директория под src/ — это цель-библиотека CMake — самодостаточный модуль со своими путями включения, исходниками и зависимостями.


Верхний 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)   # для 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 "Собирать юнит-тесты" ON)
16if(BUILD_TESTS)
17    enable_testing()
18    add_subdirectory(test)
19endif()

Здесь не перечисляются исходные файлы. Верхний уровень только устанавливает политики и компонует модули.


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}   # потребители получают этот путь
 8    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}   # внутренние заголовки
 9)
10
11target_link_libraries(sensor
12    PRIVATE etl::etl                      # деталь реализации, не экспортируется
13)
14
15target_compile_options(sensor
16    PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-Wall -Wextra>
17)

PUBLIC vs PRIVATE vs INTERFACE в target_include_directories — ключевое различие:

Область Значение
PRIVATE Только эта цель использует этот путь включения
PUBLIC Эта цель и потребители используют этот путь
INTERFACE Только потребители используют этот путь (библиотека только из заголовков)

Когда sensor объявляет путь как PUBLIC, любая цель, использующая target_link_libraries(... sensor), автоматически получает путь включения sensor — никакого ручного указания путей.


Цель приложения

 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 просто делает #include "sensor.h" — CMake управляет путём, потому что sensor объявил свою директорию включения как PUBLIC.


Соблюдение границ слоёв

Модельная система CMake позволяет соблюдать границы — но только при осознанном подходе. Глобальный вызов include_directories() утечёт везде. Используйте target_include_directories с PRIVATE везде, где путь не должен распространяться на потребителей.

Для более строгого контроля добавьте cmake/LayerCheck.cmake, утверждающий, что ни одна цель слоя приложения не является зависимостью модуля нижнего слоя:

1# Концептуально: app может зависеть от sensor, но sensor не должен зависеть от app
2# Применяйте через код-ревью или кастомную цель проверки, а не через сам CMake

На практике сама структура директорий это обеспечивает: если sensor/CMakeLists.txt попытается слинковаться с app, цикл зависимостей сломает сборку.


Модуль флагов компилятора

Централизуйте все опции компилятора, чтобы модули не повторяли их:

 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      # для встраиваемых платформ
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)

Затем линкуйте каждый модуль с ним:

1target_link_libraries(sensor PRIVATE compiler_flags)

INTERFACE-библиотеки не имеют исходных файлов — это просто набор свойств, распространяемых на потребителей. Это идиоматический способ CMake для разделения настроек.


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)

Тесты линкуются напрямую с библиотекой модуля — не нужно снова включать исходные файлы, никакой дублирующей компиляции.


Сторонние библиотеки

Вендорные библиотеки (ETL, FreeRTOS, Eigen) идут под lib/ и получают свой CMakeLists.txt, оборачивающий их как импортированную или интерфейсную цель:

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)

Псевдоним :: — соглашение CMake для «настоящей импортированной цели» — выдаёт лучшее сообщение об ошибке при отсутствии цели по сравнению с обычным строковым именем.


Типы сборки

Настраивайте отладочную и релизную сборки из командной строки, а не из CMakeLists:

 1# Отладочная сборка
 2cmake -B build/debug -DCMAKE_BUILD_TYPE=Debug
 3cmake --build build/debug
 4
 5# Релизная (минимизированная прошивка)
 6cmake -B build/release -DCMAKE_BUILD_TYPE=Release
 7cmake --build build/release
 8
 9# Кросс-компиляция для 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

Несколько директорий сборки сосуществуют — одна для юнит-тестов на хосте, одна для прошивки платформы, никакой очистки между ними.


Типичные ошибки

1. Использование include_directories() вместо target_include_directories()

include_directories() имеет область видимости директории — утекает во все цели в поддереве. Один вызов может сломать изоляцию включений во всём проекте.

2. Сбор источников через glob

1# НЕ делайте так:
2file(GLOB SOURCES "*.cpp")
3add_library(sensor ${SOURCES})

CMake не перезапустится при добавлении нового .cpp-файла — сборка молча пропустит его. Перечисляйте исходники явно или используйте target_sources() в поддиректориях.

3. Установка свойств без целей

1# Неправильно: влияет на всё
2set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
3
4# Правильно: ограничено целью
5target_compile_options(sensor PRIVATE -Wall)

4. Монолитный файл верхнего уровня

Если в корневом CMakeLists.txt перечислены исходные файлы напрямую — вы уже проиграли. При первой необходимости повторного использования модуля придётся рефакторить. Начинайте с модулей с первого дня.


Выигрыш

С такой структурой вы можете:

  • Собрать только модуль sensor и его тесты: cmake --build build --target test_sensor
  • Заменить HAL, изменив одну строку add_subdirectory
  • Повторно использовать logger в другом проекте, скопировав директорию и слинковав с ней
  • Ввести нового члена команды, указав ему на src/<модуль>/ — самодостаточно

Структура масштабируется от хобби-проекта из 5 файлов до производственной прошивки из 500 файлов без реструктуризации. Настройте правильно с самого начала — и она в основном управляет собой сама.

Что дальше