Проблема монолита
Прошивка, начатая как концепт, в итоге выглядит так:
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 обеспечивают границы на уровне сборки - Каждый слой независимо тестируется юнит-тестами с моками
Что дальше
- HAL vs доступ к регистрам напрямую — что живёт в слое HAL и что его обходит
- Внедрение зависимостей без фреймворка — инъекция через конструктор как клей между слоями
- Проектирование на основе интерфейсов — написание интерфейсов, пересекающих границы слоёв