Проблема связанности

Архитектура с прямыми вызовами — по умолчанию:

1void SensorTask::onReading(float value) {
2    display_.update(value);     // SensorTask знает о DisplayTask
3    logger_.log(value);         // SensorTask знает о Logger
4    alarmCheck_.check(value);   // SensorTask знает о AlarmCheck
5}

Добавление нового потребителя (например, DataRecorder) требует правки SensorTask. Удаление потребителя — найти и убрать его вызов. Тестирование SensorTask в изоляции требует заглушек для всех трёх зависимостей.

Событийная архитектура инвертирует это: SensorTask публикует TemperatureEvent; потребители регистрируют интерес и получают его. SensorTask не знает, кто слушает.


Минимальная шина событий

 1#include <functional>
 2#include <map>
 3#include <typeindex>
 4#include <vector>
 5
 6class EventBus {
 7public:
 8    template <typename Event>
 9    void subscribe(std::function<void(const Event&)> handler) {
10        auto key = std::type_index(typeid(Event));
11        handlers_[key].push_back([h = std::move(handler)](const void* e) {
12            h(*static_cast<const Event*>(e));
13        });
14    }
15
16    template <typename Event>
17    void publish(const Event& event) {
18        auto key = std::type_index(typeid(Event));
19        auto it = handlers_.find(key);
20        if (it == handlers_.end()) return;
21        for (auto& h : it->second)
22            h(&event);
23    }
24
25private:
26    using Handler = std::function<void(const void*)>;
27    std::map<std::type_index, std::vector<Handler>> handlers_;
28};

Использование:

 1struct TemperatureEvent { float celsius; uint32_t tick; };
 2struct ButtonEvent      { uint8_t id; bool pressed; };
 3
 4EventBus bus;
 5
 6// Подписчики регистрируются независимо — между ними нет связанности
 7bus.subscribe<TemperatureEvent>([](const TemperatureEvent& e) {
 8    display.showTemp(e.celsius);
 9});
10
11bus.subscribe<TemperatureEvent>([](const TemperatureEvent& e) {
12    if (e.celsius > 85.0f) alarm.trigger();
13});
14
15bus.subscribe<ButtonEvent>([](const ButtonEvent& e) {
16    if (e.id == 0 && e.pressed) backlight.toggle();
17});
18
19// Издатель — знает только тип события, не знает подписчиков
20bus.publish(TemperatureEvent{23.5f, HAL_GetTick()});
21bus.publish(ButtonEvent{0, true});

Добавление нового подписчика (DataRecorder) — один вызов subscribe, никаких изменений в существующем коде. Удаление — убрать одну лямбду.


Встраиваемая шина событий — без кучи, без std::function

std::function имеет накладные расходы: выделение памяти из кучи для больших замыканий, косвенный вызов, нетривиальное копирование. Для МК используйте массив подписчиков фиксированного размера:

 1template <typename Event, size_t MaxHandlers = 8>
 2class EventChannel {
 3public:
 4    using Handler = void(*)(const Event&);
 5
 6    bool subscribe(Handler h) {
 7        if (count_ >= MaxHandlers) return false;
 8        handlers_[count_++] = h;
 9        return true;
10    }
11
12    void publish(const Event& e) const {
13        for (size_t i = 0; i < count_; ++i)
14            handlers_[i](e);
15    }
16
17private:
18    Handler handlers_[MaxHandlers]{};
19    size_t  count_ = 0;
20};
21
22// Один канал на тип события
23static EventChannel<TemperatureEvent> tempChannel;
24static EventChannel<ButtonEvent>      buttonChannel;
25
26// Подписка с помощью свободной функции или статического члена
27tempChannel.subscribe([](const TemperatureEvent& e) { display.showTemp(e.celsius); });
28
29// Публикация
30tempChannel.publish({23.5f, tick});

Никакой кучи, никакого std::function, никакой vtable. Подписчики хранятся как сырые указатели на функции в фиксированном массиве. Ёмкость — параметр шаблона времени компиляции.

Для callback-ов методов-членов используйте трюк со статической функцией и указателем из статьи про паттерн «наблюдатель».


Безопасная публикация событий из ISR

События из ISR не должны вызывать обработчики напрямую — обработчики могут быть небезопасны для ISR (они могут выделять память, использовать мьютексы, занимать слишком много времени). Вместо этого публикуйте события в очередь; главный цикл задачи диспетчеризует их:

 1// Вариант события — содержит любой тип события
 2using AnyEvent = std::variant<TemperatureEvent, ButtonEvent, ErrorEvent>;
 3
 4// ISR-безопасный кольцевой буфер событий
 5static RingBuffer<AnyEvent, 32> eventQueue;
 6
 7// ISR — публикует событие, не диспетчеризует
 8void EXTI0_IRQHandler() {
 9    eventQueue.push(ButtonEvent{0, true});  // неблокирующий, безопасен для ISR
10    // Никогда не вызываем обработчики отсюда
11}
12
13// Главный цикл — диспетчеризует из контекста вне ISR
14void mainLoop() {
15    while (true) {
16        while (auto ev = eventQueue.pop()) {
17            std::visit(overloaded{
18                [](const TemperatureEvent& e) { tempChannel.publish(e); },
19                [](const ButtonEvent& e)      { buttonChannel.publish(e); },
20                [](const ErrorEvent& e)       { errorChannel.publish(e); },
21            }, *ev);
22        }
23        // ... другие задачи
24    }
25}

ISR публикует в кольцевой буфер (O(1), без блокировки). Главный цикл диспетчеризует, когда это безопасно. Это паттерн отложенной диспетчеризации — выполнение ISR и обработчика разделены во времени.


С FreeRTOS

На FreeRTOS очередь событий становится очередью FreeRTOS; диспетчеризация происходит в специальной задаче-диспетчере:

 1static QueueHandle_t eventQueue;
 2
 3// ISR
 4void EXTI0_IRQHandler() {
 5    AnyEvent ev = ButtonEvent{0, true};
 6    BaseType_t woken;
 7    xQueueSendFromISR(eventQueue, &ev, &woken);
 8    portYIELD_FROM_ISR(woken);
 9}
10
11// Задача-диспетчер
12void eventDispatcherTask(void*) {
13    AnyEvent ev;
14    while (true) {
15        if (xQueueReceive(eventQueue, &ev, portMAX_DELAY) == pdTRUE) {
16            std::visit(overloaded{
17                [](const TemperatureEvent& e) { tempChannel.publish(e); },
18                [](const ButtonEvent& e)      { buttonChannel.publish(e); },
19                [](const ErrorEvent& e)       { errorChannel.publish(e); },
20            }, ev);
21        }
22    }
23}

Подписчики работают в контексте задачи-диспетчера. Если подписчику нужно выполнить тяжёлую работу, он публикует в очередь своей задачи, а не блокирует диспетчер.


Приоритет событий и фильтрация

Не все события одинаково важны. Критические события (сброс сторожевого таймера, аварийный останов) не должны ждать за низкоприоритетными.

Два подхода:

Несколько очередей: одна очередь на уровень приоритета; диспетчер сначала опустошает очереди с более высоким приоритетом.

 1static QueueHandle_t criticalQueue;
 2static QueueHandle_t normalQueue;
 3
 4void eventDispatcherTask(void*) {
 5    while (true) {
 6        AnyEvent ev;
 7        // Сначала опустошить критическую
 8        while (xQueueReceive(criticalQueue, &ev, 0) == pdTRUE)
 9            dispatch(ev);
10        // Затем обычную, с коротким таймаутом для возврата к проверке критической
11        if (xQueueReceive(normalQueue, &ev, pdMS_TO_TICKS(10)) == pdTRUE)
12            dispatch(ev);
13    }
14}

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


Компромиссы

Аспект Прямой вызов Шина событий
Связанность Вызывающий знает о вызываемом Нет знания о подписчиках
Тестируемость Требует заглушек Публикуй; проверяй побочные эффекты
Отлаживаемость Чёткий стек вызовов Нужна трассировка событий
Гарантии порядка Синхронный, детерминированный Зависит от порядка очереди/диспетчеризации
Совместимость с ISR Осторожно, часто проблематично Очередь отделяет ISR от обработчика
Накладные расходы Вызов функции Помещение в очередь + извлечение + диспетчеризация

Событийная архитектура не всегда лучше. Для простых линейных конвейеров данных (сенсор → фильтр → дисплей, один путь, без ветвления), прямые вызовы яснее и быстрее. Событийная архитектура оправдана, когда:

  • Несколько независимых потребителей реагируют на одно событие
  • Производитель и потребитель работают в разных потоках/задачах
  • Нужно добавлять/удалять потребителей, не трогая существующий код

Итоги

  • Шина событий: публикуйте типизированные события; подписчики регистрируются независимо — нет связанности между издателем и подписчиками
  • Встраиваемый вариант: массивы обработчиков фиксированного размера, сырые указатели на функции — без кучи, без std::function
  • ISR → очередь → диспетчеризация в главном цикле: отделить контекст прерывания от выполнения обработчика
  • FreeRTOS: QueueHandle_t как очередь событий, задача-диспетчер для маршрутизации
  • Используйте прямые вызовы для простых линейных конвейеров; шину событий — для fan-out, множества потребителей, множества потоков

Что дальше