Проблема связанности
Архитектура с прямыми вызовами — по умолчанию:
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, множества потребителей, множества потоков
Что дальше
- Паттерн «активный объект» — шина событий, встроенная в класс с потоком и очередью
- Паттерн «наблюдатель» — более простая подписка на одно событие
- Проектирование задач и очередей FreeRTOS — очереди как примитив событий в RTOS