Проблема монолита

Прошивка, начатая как концепт, в итоге выглядит так:

 1// main.cpp — 1200 строк
 2int main() {
 3    HAL_Init();
 4    MX_GPIO_Init();
 5    MX_I2C1_Init();
 6
 7    // Обращение к регистрам BMP280 вперемешку с логикой дисплея
 8    uint8_t id;
 9    HAL_I2C_Mem_Read(&hi2c1, 0x76<<1, 0xD0, 1, &id, 1, 10);
10
11    // Байты команд SSD1306 вперемешку с математикой сенсора
12    HAL_I2C_Mem_Write(&hi2c1, 0x3C<<1, 0x00, 1, initCmds, sizeof(initCmds), 10);
13
14    float temp = (id == 0x60) ? readBmp() : 25.0f;
15    char buf[32];
16    sprintf(buf, "T: %.1fC", temp);
17    drawString(0, 0, buf);  // drawString обращается к HAL внутри
18}

Детали железа (адреса регистров, дескрипторы I2C, последовательности командных байт) переплетены с бизнес-логикой. Замена BMP280 на SHT31 требует поиска по 1200 строкам. Написание юнит-теста для логики сигнализации требует реального МК.

Многоуровневая архитектура разделяет эти concerns.


Четыре слоя

┌─────────────────────────────────────────────────────┐
│  Слой приложения                                     │
│  — бизнес-правила, автоматы состояний, переключение │
│    режимов                                           │
│  — знает: доменные типы (Temperature, AlarmLevel)   │
│  — не знает: HAL, адреса I2C, регистры              │
├─────────────────────────────────────────────────────┤
│  Слой сервисов                                       │
│  — агрегирует драйверы в функциональные возможности │
│  — знает: интерфейсы драйверов (ISensor, IDisplay)  │
│  — не знает: Bmp280, SSD1306, карты регистров       │
├─────────────────────────────────────────────────────┤
│  Слой драйверов                                      │
│  — оборачивает одну периферию, владеет её дескриптором HAL │
│  — реализует: ISensor, IDisplay, IStorage           │
│  — знает: функции HAL, адреса регистров             │
│  — не знает: бизнес-логику, другие драйверы         │
├─────────────────────────────────────────────────────┤
│  Слой HAL / CMSIS (сгенерирован CubeMX)             │
│  — доступ к регистрам периферии, инициализация тактирования │
│  — не редактируется вручную                         │
└─────────────────────────────────────────────────────┘

Каждый слой зависит только от слоя ниже — никогда от слоёв выше. Слой сервисов вызывает интерфейсы драйверов — не конкретные классы. Слой приложения вызывает интерфейсы сервисов — не их реализации.


Слой 1: HAL (сгенерированный)

CubeMX генерирует функции MX_*_Init() и структуры дескрипторов HAL. Они не редактируются вручную. Живут в Core/Src/ и Core/Inc/.

1// Сгенерировано — не трогать
2extern I2C_HandleTypeDef hi2c1;
3extern SPI_HandleTypeDef hspi1;
4
5void MX_I2C1_Init(void);
6void MX_SPI1_Init(void);

Слой 2: Драйверы

Один класс на периферию. Реализует доменный интерфейс. Знает карты регистров и вызовы HAL; больше ничего.

 1// include/drivers/IBmp280.h — абстрактный интерфейс
 2class ITemperatureSensor {
 3public:
 4    virtual float readCelsius() = 0;
 5    virtual bool  isReady()     = 0;
 6    virtual ~ITemperatureSensor() = default;
 7};
 8
 9// src/drivers/Bmp280Driver.h — конкретный драйвер
10class Bmp280Driver : public ITemperatureSensor {
11    I2C_HandleTypeDef* hi2c_;
12    uint8_t            addr_;
13    // коэффициенты компенсации
14    int32_t t_fine_ = 0;
15
16public:
17    Bmp280Driver(I2C_HandleTypeDef* hi2c, uint8_t addr = 0x76)
18        : hi2c_(hi2c), addr_(addr << 1) {}
19
20    bool init();                             // читает ID чипа, загружает калибровку
21    float readCelsius() override;            // запускает измерение, ждёт, компенсирует
22    bool  isReady()     override;            // проверяет регистр статуса
23
24private:
25    uint8_t readReg(uint8_t reg);
26    void    writeReg(uint8_t reg, uint8_t val);
27    float   compensateTemp(int32_t raw);
28};

Драйвер знает hi2c1, регистр 0xF3, формулу компенсации. Слой сервисов выше знает только ITemperatureSensor.

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

firmware/
├── Core/           ← сгенерировано CubeMX
├── Drivers/        ← ваш слой драйверов
│   ├── include/
│   │   ├── ITemperatureSensor.h
│   │   ├── IDisplay.h
│   │   └── IStorage.h
│   └── src/
│       ├── Bmp280Driver.cpp
│       ├── Ssd1306Driver.cpp
│       └── W25qFlashDriver.cpp
├── Services/       ← слой сервисов
├── App/            ← слой приложения
└── CMakeLists.txt

Слой 3: Сервисы

Сервис агрегирует один или несколько драйверов для реализации функциональности. Говорит доменными терминами (температура в °C, а не отсчёты АЦП или байты регистров).

 1// Services/include/EnvironmentMonitor.h
 2class EnvironmentMonitor {
 3    ITemperatureSensor& tempSensor_;
 4    IHumiditySensor&    humSensor_;
 5    float               lastTemp_  = 0.0f;
 6    float               lastHumid_ = 0.0f;
 7
 8public:
 9    struct Reading { float celsius; float relativeHumidity; };
10
11    EnvironmentMonitor(ITemperatureSensor& t, IHumiditySensor& h)
12        : tempSensor_(t), humSensor_(h) {}
13
14    Reading update() {
15        if (tempSensor_.isReady())  lastTemp_  = tempSensor_.readCelsius();
16        if (humSensor_.isReady())   lastHumid_ = humSensor_.readPercent();
17        return {lastTemp_, lastHumid_};
18    }
19};

EnvironmentMonitor не упоминает I2C, BMP280, SHT31 или HAL. Замена Bmp280Driver на SHT31Driver требует изменения одной строки в main.cpp (корень компоновки), не трогая сервис.


Слой 4: Приложение

Бизнес-правила, автоматы состояний, взаимодействие с пользователем. Говорит только доменными типами.

 1// App/src/ThermostatApp.cpp
 2class ThermostatApp {
 3    EnvironmentMonitor& monitor_;
 4    IDisplay&           display_;
 5    AlarmService&       alarm_;
 6    float               setPoint_ = 22.0f;
 7
 8public:
 9    ThermostatApp(EnvironmentMonitor& m, IDisplay& d, AlarmService& a)
10        : monitor_(m), display_(d), alarm_(a) {}
11
12    void tick() {
13        auto [temp, humid] = monitor_.update();
14        display_.showTemperature(temp);
15        display_.showHumidity(humid);
16
17        if (temp > setPoint_ + 2.0f) alarm_.trigger(AlarmLevel::Warning);
18        else                          alarm_.clear();
19    }
20};

В ThermostatApp нет #include <stm32f4xx_hal.h>. Полностью тестируем юнит-тестами с мок-реализациями EnvironmentMonitor, IDisplay и AlarmService.


Корень компоновки

Все конкретные типы создаются в одном месте — main.cpp или специальном CompositionRoot:

 1// main.cpp — единственное место, где появляются конкретные типы
 2int main() {
 3    HAL_Init();
 4    MX_I2C1_Init();
 5    MX_SPI1_Init();
 6
 7    // Слой 2: драйверы
 8    Bmp280Driver    tempSensor(&hi2c1);
 9    Ssd1306Driver   display(&hi2c1);
10    W25qFlashDriver storage(&hspi1);
11    tempSensor.init();
12    display.init();
13
14    // Слой 3: сервисы
15    EnvironmentMonitor monitor(tempSensor, tempSensor);  // или отдельные сенсоры
16    AlarmService       alarm(display);
17
18    // Слой 4: приложение
19    ThermostatApp app(monitor, display, alarm);
20
21    while (true) {
22        app.tick();
23        HAL_Delay(100);
24    }
25}

Каждая зависимость видна здесь. Чтобы заменить Bmp280Driver на SHT31Driver, измените две строки. Для тестирования с моками напишите тестовый main.cpp, создающий моки и передающий их.


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

Видимость целей CMake обеспечивает границы на уровне линковки:

 1# Библиотека драйверов — экспортирует интерфейсы, скрывает реализацию
 2add_library(drivers STATIC
 3    src/Bmp280Driver.cpp
 4    src/Ssd1306Driver.cpp
 5)
 6target_include_directories(drivers
 7    PUBLIC  include/    # интерфейсы: ITemperatureSensor.h и т.д.
 8    PRIVATE src/        # заголовки конкретных драйверов — внутренние
 9)
10target_link_libraries(drivers PUBLIC stm32_hal)
11
12# Сервисы — зависят от интерфейсов драйверов, не от реализаций
13add_library(services STATIC src/EnvironmentMonitor.cpp)
14target_include_directories(services PUBLIC include/)
15target_link_libraries(services PUBLIC drivers)
16
17# Приложение — зависит от интерфейсов сервисов
18add_library(app STATIC src/ThermostatApp.cpp)
19target_link_libraries(app PUBLIC services)

services не может случайно включить Bmp280Driver.h — его нет ни в одном PUBLIC пути включения. Граница структурная, а не просто соглашение.


Тестирование по слоям

Каждый слой тестируется в изоляции:

 1// Тест: ThermostatApp вызывает сигнализацию выше уставки
 2TEST(ThermostatApp, TriggersAlarmWhenTooHot) {
 3    MockEnvironmentMonitor monitor;
 4    MockDisplay            display;
 5    MockAlarmService       alarm;
 6
 7    monitor.setReading({28.0f, 50.0f});  // 28°C, уставка 22°C + 2 = сработает
 8
 9    ThermostatApp app(monitor, display, alarm);
10    app.tick();
11
12    EXPECT_TRUE(alarm.wasTriggered());
13    EXPECT_EQ(alarm.lastLevel(), AlarmLevel::Warning);
14}

Никакого железа, никаких заголовков HAL, никакого FreeRTOS. Тест компилируется и запускается на хосте. Многоуровневая архитектура сделала это возможным.


Итоги

  • Четыре слоя: HAL (сгенерированный) → Драйвер (обёртка периферии) → Сервис (функциональность) → Приложение (бизнес-логика)
  • Каждый слой зависит только от слоя ниже — через интерфейсы, не конкретные типы
  • Корень компоновки (main.cpp) — единственный файл, знающий конкретные типы
  • Пути включения PRIVATE в CMake обеспечивают границы на уровне сборки
  • Каждый слой независимо тестируется юнит-тестами с моками

Что дальше