Почему 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.

Применяйте эти принципы там, где код имеет несколько причин изменяться, где тесты трудно писать или где замена железа важна. Маленький, стабильный, одноразовый код может оставаться простым.

Что дальше