Почему SOLID важен на микроконтроллере
Проект STM32 на 50 тыс. строк — именно то место, где игнорирование SOLID причиняет
наибольшую боль. Нетестируемые ISR-обработчики, main.cpp на 800 строк, перекомпиляция
HAL ради замены сенсора — симптомы кодовой базы, выросшей без архитектурной дисциплины.
Эта статья разбирает каждый принцип с конкретными примерами до/после на C++, взятыми из конвейера сенсор → фильтр → дисплей на STM32. Для каждого принципа:
- однострочное определение
- нарушение из реального кода прошивки
- исправление
- заметка о том, почему это важно именно на микроконтроллере
S — Принцип единственной ответственности
Класс должен иметь только одну причину для изменения.
Нарушение
1class SensorManager {
2public:
3 float readAdc(); // читает регистр оборудования
4 float calibrate(float); // применяет кривую калибровки
5 std::string toJson(); // форматирует вывод
6 void sendUart(); // пишет в периферию
7 void logError(); // пишет в флеш-лог
8};
Изменение скорости UART заставляет трогать логику калибровки — один файл, разные причины. Тестировать калибровку юнит-тестами без инициализации железа невозможно.
Исправление
Разделить обязанности по исполнителю:
1class AdcReader { public: float read(); };
2class SensorCalibrator { public: float calibrate(float raw); };
3class JsonSerializer { public: std::string serialize(float v); };
4class UartTransport { public: void send(std::string_view data); };
Каждый класс помещается на одном экране, имеет одну группу аргументов конструктора и один тестовый файл.
Заметка о встраиваемых: ISR должен только выставить флаг и вернуться — вся логика
живёт в цикле задачи. ISR, вызывающий calibrate(), форматирующий строку и передающий
по UART — нарушение SRP с последствиями в реальном времени.
1// ПЛОХО: ISR делает слишком много
2void ADC_IRQHandler() {
3 float raw = ADC1->DR;
4 float cal = calibrate(raw); // числа с плавающей точкой в ISR
5 sendUart(std::to_string(cal)); // блокирующий I/O
6}
7
8// ХОРОШО: только флаг, логика в задаче
9volatile bool adcReady = false;
10volatile uint16_t adcRaw = 0;
11
12void ADC_IRQHandler() {
13 adcRaw = ADC1->DR;
14 adcReady = true;
15}
16
17// Главный цикл или задача RTOS:
18if (adcReady) {
19 adcReady = false;
20 float v = calibrator.calibrate(adcRaw);
21 transport.send(serializer.serialize(v));
22}
O — Принцип открытости/закрытости
Программные сущности должны быть открыты для расширения, закрыты для изменения.
Нарушение
1float readSensor(SensorType t) {
2 switch (t) {
3 case SensorType::BMP280: return bmp280_read();
4 case SensorType::NTC10K: return ntc10k_read();
5 // добавление сенсора = редактирование этой протестированной функции
6 }
7}
Каждый новый тип сенсора требует редактирования — и повторного тестирования — уже проверенного кода.
Исправление
Определите абстракцию, расширяйте добавлением новых типов:
1struct ISensor {
2 virtual float read() = 0;
3 virtual ~ISensor() = default;
4};
5
6class Bmp280Sensor : public ISensor {
7public:
8 float read() override { return bmp280_read_temperature(); }
9};
10
11class Ntc10kSensor : public ISensor {
12public:
13 explicit Ntc10kSensor(uint8_t channel) : ch_(channel) {}
14 float read() override { return ntc_read(ch_); }
15private:
16 uint8_t ch_;
17};
18
19// Добавление нового сенсора = новый файл, ноль изменений в существующем коде
20class Sht31Sensor : public ISensor {
21public:
22 float read() override { return sht31_read_humidity(); }
23};
Заметка о встраиваемых: на ресурсоограниченных платформах виртуальный вызов имеет стоимость — одно косвенное обращение и запись в vtable на объект. Для горячих путей на Cortex-M0 рассмотрите CRTP для статического полиморфизма с нулевыми накладными расходами. Для большинства прошивок стоимость vtable несущественна.
L — Принцип подстановки Лисков
Подтипы должны быть заменяемы своими базовыми типами без нарушения работы программы.
Этот принцип тоньше остальных. Его можно нарушить без ошибок компиляции.
Нарушение — бросок исключения из обязательного метода
1struct IDisplay {
2 virtual void setBrightness(uint8_t level) = 0;
3 virtual void draw(const Frame&) = 0;
4};
5
6class UartDebugDisplay : public IDisplay {
7public:
8 void setBrightness(uint8_t) override {
9 throw std::runtime_error("не поддерживается"); // нарушение LSP
10 }
11 void draw(const Frame& f) override { uart_print(f.to_string()); }
12};
Любой код, вызывающий setBrightness через IDisplay*, падает в рантайме при получении
UartDebugDisplay. Подтип не является заменяемым.
Нарушение — ужесточение предусловий
1struct IStorage {
2 virtual void write(const uint8_t* data, size_t len) = 0;
3 virtual void read(uint8_t* buf, size_t len) = 0;
4};
5
6class ReadOnlyFlash : public IStorage {
7public:
8 void write(const uint8_t*, size_t) override {
9 HAL_assert(false); // ужесточенное предусловие: никогда не вызывать это
10 }
11 void read(uint8_t* buf, size_t len) override { /* ... */ }
12};
Базовый тип обещает, что write() работает. Подтип молча нарушает этот контракт.
Исправление
Разделите интерфейс в соответствии с реальными возможностями:
1struct IReadable { virtual void read(uint8_t* buf, size_t len) = 0; };
2struct IWritable { virtual void write(const uint8_t* data, size_t len) = 0; };
3struct IStorage : IReadable, IWritable {}; // полный read-write
4
5class ReadOnlyFlash : public IReadable {
6public:
7 void read(uint8_t* buf, size_t len) override { /* ... */ }
8 // write() не существует — никакого ложного обещания
9};
Для дисплея разделите так, чтобы управление яркостью было опциональным:
1struct IDisplay { virtual void draw(const Frame&) = 0; };
2struct IDimmable { virtual void setBrightness(uint8_t) = 0; };
3struct IFullDisplay : IDisplay, IDimmable {};
4
5class UartDebugDisplay : public IDisplay { /* только draw() */ };
6class TftDisplay : public IFullDisplay { /* оба */ };
Заметка о встраиваемых: нарушения LSP особенно болезненны в прошивке, потому что
исключения часто вообще не поддерживаются (-fno-exceptions распространено). Нарушенный
контракт становится тихим HAL_assert, срабатывающим в 3 ночи во время производственного запуска.
I — Принцип разделения интерфейсов
Клиенты не должны зависеть от интерфейсов, которые они не используют.
Нарушение
1struct IDevice {
2 virtual void read() = 0;
3 virtual void write() = 0;
4 virtual void configure() = 0;
5 virtual void reset() = 0;
6 virtual void getDiagnostics() = 0;
7};
Драйвер сенсора, который только читает, вынужден заглушать write(), configure(),
reset() и getDiagnostics() пустыми телами или assert(false). Теперь ваш мок для
юнит-тестов несёт пять методов, когда вас интересует только один.
Исправление
Компонуйте небольшие, сфокусированные интерфейсы:
1struct IReadable { virtual float read() = 0; };
2struct IWritable { virtual void write(float v) = 0; };
3struct IConfigurable { virtual void configure(Config) = 0; };
4struct IDiagnosable { virtual Diag getDiagnostics() = 0; };
5
6// Драйверы реализуют только то, что они предоставляют:
7class Bmp280 : public IReadable, public IConfigurable { /* ... */ };
8class Eeprom : public IReadable, public IWritable { /* ... */ };
9
10// Потребители зависят только от того, что они используют:
11class DataPipeline {
12 IReadable& sensor_;
13public:
14 explicit DataPipeline(IReadable& s) : sensor_(s) {}
15};
Заметка о встраиваемых: малые интерфейсы означают малые моки. Когда вашему моку нужен только один метод, это три строки кода, и ошибиться невозможно. Именно этот принцип делает тестирование с реальным железом управляемым — тестовые заглушки не должны притворяться полноценными устройствами.
D — Принцип инверсии зависимостей
Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
Нарушение
1class DataLogger {
2 Stm32SpiFlash flash; // конкретное железо, создаётся здесь
3public:
4 DataLogger() : flash(SPI1, GPIOA, GPIO_PIN_4) {}
5 void log(float v) { flash.write(v); }
6};
Стрелка зависимости направлена от бизнес-логики к железу — в обратном направлении. Запустить на PC невозможно, внедрить мок невозможно.
DataLogger ──зависит от──▶ Stm32SpiFlash
Исправление
Инвертируйте зависимость через абстракцию:
1struct IStorage {
2 virtual void write(float v) = 0;
3 virtual ~IStorage() = default;
4};
5
6class DataLogger {
7 IStorage& storage_;
8public:
9 explicit DataLogger(IStorage& s) : storage_(s) {}
10 void log(float v) { storage_.write(v); }
11};
DataLogger ──зависит от──▶ IStorage ◀── Stm32SpiFlash
◀── MockStorage
◀── RamBuffer
Теперь стрелка от DataLogger направлена вверх к абстракции, а не вниз к железу.
Конкретный тип подключается в корне компоновки — main.cpp на платформе, тестовый фикстур в CI.
1// На платформе:
2Stm32SpiFlash flash(SPI1, GPIOA, GPIO_PIN_4);
3DataLogger logger(flash);
4
5// В тестах — железо не нужно:
6struct MockStorage : IStorage {
7 std::vector<float> log;
8 void write(float v) override { log.push_back(v); }
9};
10
11TEST(DataLogger, WritesValue) {
12 MockStorage mock;
13 DataLogger logger(mock);
14 logger.log(3.14f);
15 EXPECT_EQ(mock.log.back(), 3.14f);
16}
Заметка о встраиваемых: DIP — принцип, открывающий всё остальное — тестируемость, переносимость, возможность заменить весь слой HAL на Linux-симуляцию одной перекомпиляцией. Если применяете только один принцип из этого списка — применяйте этот.
Совокупный эффект
Применённые вместе, эти пять принципов превращают монолитную прошивку в набор модулей с чёткими границами:
| Принцип | Однострочное определение | Типичный запах | Исправление в 10 словах |
|---|---|---|---|
| S | Одна причина изменяться | Бог-класс, ISR делает I/O | Разделить по исполнителю, ISR только выставляет флаг |
| O | Расширять без изменений | switch (sensorType) |
Абстрактная база + подкласс на вариант |
| L | Подтипы заменяемы | throw в override, тихий assert |
Разделить интерфейс по возможностям |
| I | Нет вынужденных зависимостей | Интерфейс на 5 методов, 4 заглушены | Компоновать небольшие сфокусированные интерфейсы |
| D | Зависеть от абстракций | new ConcreteHardware внутри класса |
Внедрять через конструктор |
Настоящий выигрыш наступает при портировании на новый МК. Если DIP применён последовательно, вы меняете реализации HAL и перекомпилируете — бизнес-логика не движется. Это совокупный эффект архитектурной дисциплины.
Что НЕ надо делать
Чрезмерное проектирование — зеркальная ошибка. Драйвер для одного сенсора на 50 строк с пятью интерфейсами, тремя абстрактными базовыми классами и фабрикой не нуждается в SOLID — ему нужен один конкретный класс и клавиша Delete.
Применяйте эти принципы там, где код имеет несколько причин изменяться, где тесты трудно писать или где замена железа важна. Маленький, стабильный, одноразовый код может оставаться простым.
Что дальше
- Внедрение зависимостей без фреймворка — чистое подключение в корне компоновки
- Проектирование на основе интерфейсов — проектирование контрактов, которые не лгут
- Конечные автоматы — три подхода — применение SRP и OCP к управляющей логике прошивки