Почему автоматы важны во встраиваемом ПО

Большинство прошивок — конечные автоматы, даже если так не задумывались. Устройство, которое загружается, настраивает периферию, ждёт данных, обрабатывает их и обрабатывает ошибки, имеет минимум пять состояний. Когда эта логика живёт в клубке флагов, вложенных if и глобальных переменных, о ней невозможно рассуждать — и невозможно тестировать.

Явный КА заставляет перечислить все состояния и все переходы. В этой статье сравниваются три подхода на примере менеджера соединений: Idle → Connecting → Connected → Disconnecting → Idle.


Подход 1: Enum + switch

Простейший КА. Перечисление State и switch в обработчике событий.

 1enum class State { Idle, Connecting, Connected, Disconnecting };
 2
 3class ConnectionManager {
 4    State state_ = State::Idle;
 5
 6public:
 7    void onConnect() {
 8        switch (state_) {
 9            case State::Idle:
10                startConnection();
11                state_ = State::Connecting;
12                break;
13            case State::Connected:
14                // уже подключены — игнорируем
15                break;
16            default:
17                logError("onConnect в неожиданном состоянии");
18                break;
19        }
20    }
21
22    void onConnected() {
23        if (state_ == State::Connecting) {
24            state_ = State::Connected;
25            notifyReady();
26        }
27    }
28
29    void onDisconnect() {
30        if (state_ == State::Connected) {
31            beginShutdown();
32            state_ = State::Disconnecting;
33        }
34    }
35
36    void onTimeout() {
37        if (state_ == State::Connecting || state_ == State::Disconnecting) {
38            state_ = State::Idle;
39            notifyError();
40        }
41    }
42
43private:
44    void startConnection() { /* ... */ }
45    void beginShutdown()   { /* ... */ }
46    void notifyReady()     { /* ... */ }
47    void notifyError()     { /* ... */ }
48};

Когда использовать

  • Мало состояний (≤ 6) и мало событий (≤ 6)
  • Состояния и переходы стабильны — будут меняться нечасто
  • Команда не знакома с более сложными паттернами

Проблемы при масштабировании

Каждое новое событие требует правки каждого блока switch. Логика одного состояния разбросана по нескольким функциям. Добавление нового состояния означает проверку каждого switch, чтобы решить, нужна ли там обработка нового состояния. При 8+ состояниях switch становится неуправляемым.


Подход 2: Табличный КА

Кодируйте переходы в таблицу данных: {текущее_состояние, событие} → {действие, следующее_состояние}.

 1enum class State { Idle, Connecting, Connected, Disconnecting, COUNT };
 2enum class Event { Connect, Connected, Disconnect, Timeout, COUNT };
 3
 4struct Transition {
 5    State  from;
 6    Event  event;
 7    State  to;
 8    void (*action)(ConnectionManager&);
 9};
10
11static void doConnect    (ConnectionManager& cm) { cm.startConnection(); }
12static void doReady      (ConnectionManager& cm) { cm.notifyReady(); }
13static void doShutdown   (ConnectionManager& cm) { cm.beginShutdown(); }
14static void doError      (ConnectionManager& cm) { cm.notifyError(); }
15
16static const Transition transitions[] = {
17    { State::Idle,          Event::Connect,    State::Connecting,    doConnect  },
18    { State::Connecting,    Event::Connected,  State::Connected,     doReady    },
19    { State::Connected,     Event::Disconnect, State::Disconnecting, doShutdown },
20    { State::Connecting,    Event::Timeout,    State::Idle,          doError    },
21    { State::Disconnecting, Event::Timeout,    State::Idle,          doError    },
22};
23
24class ConnectionManager {
25    State state_ = State::Idle;
26
27public:
28    void process(Event event) {
29        for (const auto& t : transitions) {
30            if (t.from == state_ && t.event == event) {
31                t.action(*this);
32                state_ = t.to;
33                return;
34            }
35        }
36        // Нет подходящего перехода — игнорируем или логируем
37    }
38    // ... реализации действий
39};

Весь конечный автомат — это таблица. Чтобы добавить состояние или событие, добавьте строку. Цикл диспетчеризации никогда не меняется.

Преимущества

  • Все переходы видны в одном месте — легко проверять
  • Добавление состояний/событий без правки существующего кода (принцип открытости/закрытости)
  • Таблица самодокументируется — напрямую соответствует диаграмме состояний
  • Легко генерировать из инструмента или электронной таблицы

Оптимизация диспетчеризации

Для небольших автоматов линейный поиск допустим. Для автоматов со многими состояниями и событиями используйте двумерный массив с индексацией по состоянию и событию:

1// action_table[состояние][событие] = {действие, следующее_состояние}
2// nullptr действие означает "игнорировать это событие в этом состоянии"

Это даёт O(1) диспетчеризации ценой большей таблицы (большинство записей null).


Подход 3: Паттерн объектов-состояний

Каждое состояние становится классом, который обрабатывает события и определяет переход. Это наиболее полное применение OCP и SRP к КА.

 1class ConnectionManager;  // предварительное объявление
 2
 3struct IState {
 4    virtual void onEnter(ConnectionManager&) {}
 5    virtual void onExit (ConnectionManager&) {}
 6    virtual void onConnect   (ConnectionManager&) {}
 7    virtual void onConnected (ConnectionManager&) {}
 8    virtual void onDisconnect(ConnectionManager&) {}
 9    virtual void onTimeout   (ConnectionManager&) {}
10    virtual ~IState() = default;
11};
12
13// ---- Конкретные состояния ----
14
15struct IdleState : IState {
16    void onConnect(ConnectionManager& cm) override;  // переход к Connecting
17};
18
19struct ConnectingState : IState {
20    void onEnter(ConnectionManager& cm) override;    // запускает таймер
21    void onConnected(ConnectionManager& cm) override;
22    void onTimeout  (ConnectionManager& cm) override;
23};
24
25struct ConnectedState : IState {
26    void onEnter (ConnectionManager& cm) override;   // notifyReady
27    void onDisconnect(ConnectionManager& cm) override;
28};
29
30struct DisconnectingState : IState {
31    void onEnter(ConnectionManager& cm) override;    // beginShutdown
32    void onTimeout(ConnectionManager& cm) override;
33};
34
35// ---- Менеджер ----
36
37class ConnectionManager {
38public:
39    void setState(IState* s) {
40        if (state_) state_->onExit(*this);
41        state_ = s;
42        if (state_) state_->onEnter(*this);
43    }
44
45    void onConnect()    { state_->onConnect(*this); }
46    void onConnected()  { state_->onConnected(*this); }
47    void onDisconnect() { state_->onDisconnect(*this); }
48    void onTimeout()    { state_->onTimeout(*this); }
49
50    // Методы для вызова из состояний
51    void startConnection() { /* ... */ }
52    void notifyReady()     { /* ... */ }
53    void beginShutdown()   { /* ... */ }
54    void notifyError()     { /* ... */ }
55
56private:
57    IState* state_ = &idle_;
58
59    IdleState          idle_;
60    ConnectingState    connecting_;
61    ConnectedState     connected_;
62    DisconnectingState disconnecting_;
63};
64
65// ---- Реализации состояний ----
66
67void IdleState::onConnect(ConnectionManager& cm) {
68    cm.startConnection();
69    cm.setState(&cm.connecting_);   // или через friend/accessor
70}
71
72void ConnectingState::onConnected(ConnectionManager& cm) {
73    cm.setState(&cm.connected_);
74}
75
76void ConnectingState::onTimeout(ConnectionManager& cm) {
77    cm.notifyError();
78    cm.setState(&cm.idle_);
79}

Состояния хранятся как члены ConnectionManager — без выделения памяти в куче. setState автоматически вызывает onExit и onEnter, централизуя логику переходов.

Преимущества

  • Каждое состояние — отдельный класс в своём файле — легко читать и тестировать
  • Добавление состояния: новый класс, правки только в соответствующих переходах
  • onEnter/onExit делают действия входа/выхода явными и гарантированными
  • Состояния можно тестировать по отдельности, создавая их в изоляции

Недостатки

  • Больше файлов и шаблонного кода, чем в подходе со switch
  • Паттерн friend или accessor для вызова setState из состояний может быть неудобен
  • Избыточно для автомата с 3 состояниями

Сравнение

Критерий Enum + switch Табличный Объекты-состояния
Сложность настройки Низкая Средняя Высокая
Масштабируемость Плохая (>6 состояний) Хорошая Отличная
Переходы в одном месте Нет Да Нет (в каждом состоянии)
Действия входа/выхода Вручную Вручную Встроены
Тестируемость Низкая Средняя Высокая
Размер кода (встраиваемый) Наименьший Небольшой Больше
Читаемость для новичков Да Да Требует знания ООП

Особенности встраиваемых систем

Глубина стека: паттерн объектов-состояний с виртуальными вызовами добавляет один уровень косвенности на событие. На Cortex-M с ограниченным стеком (4–8 кБ) это несущественно.

Память: все три подхода избегают выделения памяти в куче, если состояния хранятся как члены. Табличный подход хранит указатели на функции — 4 байта на запись на 32-битных платформах.

Безопасность ISR: вызов process(Event) или onEvent() нельзя делать из ISR напрямую. Публикуйте событие в очередь и обрабатывайте в цикле задачи:

1// ISR:
2eventQueue.push(Event::Timeout);  // неблокирующая SPSC-публикация
3
4// Цикл задачи:
5Event e;
6while (eventQueue.pop(e)) {
7    fsm.process(e);
8}

ROM vs RAM: таблица переходов в подходе 2 может быть размещена во flash (const в области видимости файла с LTO или явно __attribute__((section(".rodata")))), сохраняя RAM на устройствах с ограниченными ресурсами.


Выбор подхода

≤ 5 состояний, стабильных?         → Enum + switch
Много состояний, данные-driven?    → Табличный
Сложная логика входа/выхода?       → Объекты-состояния
Тестируемость критична?            → Объекты-состояния
Размер кода критичен?              → Enum + switch или табличный

Для большинства прошивок табличный подход — золотая середина: полная матрица переходов видна с первого взгляда, добавление состояний не трогает существующую логику, и нет накладных расходов на vtable.

Что дальше