The coupling problem
Direct-call architecture — the default:
1void SensorTask::onReading(float value) {
2 display_.update(value); // SensorTask knows about DisplayTask
3 logger_.log(value); // SensorTask knows about Logger
4 alarmCheck_.check(value); // SensorTask knows about AlarmCheck
5}
Adding a new consumer (say, DataRecorder) requires editing SensorTask.
Removing a consumer requires finding and removing its call. Testing
SensorTask in isolation requires stubs for all three dependencies.
Event-driven architecture inverts this: SensorTask publishes a
TemperatureEvent; consumers register interest and receive it.
SensorTask doesn’t know who’s listening.
A minimal event bus
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};
Usage:
1struct TemperatureEvent { float celsius; uint32_t tick; };
2struct ButtonEvent { uint8_t id; bool pressed; };
3
4EventBus bus;
5
6// Subscribers register independently — no coupling between them
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// Publisher — knows only the event type, not the subscribers
20bus.publish(TemperatureEvent{23.5f, HAL_GetTick()});
21bus.publish(ButtonEvent{0, true});
Adding a new subscriber (DataRecorder) means one subscribe call — no
existing code changes. Removing it means removing one lambda.
Embedded event bus — no heap, no std::function
std::function has overhead: heap allocation for large closures, virtual call
dispatch, non-trivial copy. For MCUs, use a fixed-size subscriber array:
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// One channel per event type
23static EventChannel<TemperatureEvent> tempChannel;
24static EventChannel<ButtonEvent> buttonChannel;
25
26// Subscribe with a free function or a static member
27tempChannel.subscribe([](const TemperatureEvent& e) { display.showTemp(e.celsius); });
28
29// Publish
30tempChannel.publish({23.5f, tick});
No heap, no std::function, no vtable. Subscribers are stored as raw function
pointers in a fixed array. Capacity is a compile-time template parameter.
For member function callbacks, use the static-function-plus-pointer trick from the Observer pattern article.
ISR-safe event posting
Events from an ISR must not call handlers directly — handlers may not be ISR-safe (they might allocate, use mutexes, take too long). Instead, post events to a queue; the main task loop dispatches them:
1// Event variant — holds any event type
2using AnyEvent = std::variant<TemperatureEvent, ButtonEvent, ErrorEvent>;
3
4// ISR-safe ring buffer of events
5static RingBuffer<AnyEvent, 32> eventQueue;
6
7// ISR — post event, don't dispatch
8void EXTI0_IRQHandler() {
9 eventQueue.push(ButtonEvent{0, true}); // non-blocking, ISR-safe
10 // Never call handlers from here
11}
12
13// Main loop — dispatch from non-ISR context
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 // ... other tasks
24 }
25}
ISR posts to the ring buffer (O(1), no blocking). The main loop dispatches when it’s safe. This is the deferred dispatch pattern — ISR and handler execution are decoupled in time.
With FreeRTOS
On FreeRTOS, the event queue becomes a FreeRTOS queue; dispatch happens in a dedicated event dispatcher task:
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// Dispatcher task
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}
Subscribers run in the dispatcher task’s context. If a subscriber needs to do heavy work, it posts to its own task’s queue rather than blocking the dispatcher.
Event priority and filtering
Not all events are equal. Critical events (watchdog feed, emergency stop) shouldn’t wait behind low-priority ones.
Two approaches:
Multiple queues: one queue per priority level; dispatcher drains higher-priority queues first.
1static QueueHandle_t criticalQueue;
2static QueueHandle_t normalQueue;
3
4void eventDispatcherTask(void*) {
5 while (true) {
6 AnyEvent ev;
7 // Drain critical first
8 while (xQueueReceive(criticalQueue, &ev, 0) == pdTRUE)
9 dispatch(ev);
10 // Then normal, with a short timeout to return to critical check
11 if (xQueueReceive(normalQueue, &ev, pdMS_TO_TICKS(10)) == pdTRUE)
12 dispatch(ev);
13 }
14}
Event filtering at subscribe: subscribers specify which events they care about — the bus dispatches only matching ones. For typed channels, this is already built in by type.
Tradeoffs
| Aspect | Direct call | Event bus |
|---|---|---|
| Coupling | Caller knows callee | No knowledge of subscribers |
| Testability | Requires stubs | Publish; check side effects |
| Debuggability | Clear call stack | Event trace needed |
| Ordering guarantees | Synchronous, deterministic | Depends on queue/dispatch order |
| ISR compatibility | Careful, often problematic | Queue separates ISR from handler |
| Overhead | Function call | Queue push + pop + dispatch |
Event-driven is not always better. For simple, linear data pipelines (sensor → filter → display, one path, no branching), direct calls are clearer and faster. Event-driven pays off when:
- Multiple independent consumers react to the same event
- The producer and consumer run on different threads/tasks
- You need to add/remove consumers without touching existing code
Summary
- Event bus: publish typed events; subscribers register independently — no coupling between publisher and subscribers
- Embedded variant: fixed-size handler arrays, raw function pointers — no heap, no
std::function - ISR → queue → main loop dispatch: decouple interrupt context from handler execution
- FreeRTOS: QueueHandle_t as the event queue, dispatcher task for routing
- Use direct calls for simple linear pipelines; event bus for fan-out, multi-consumer, multi-thread scenarios
What’s next
- Active object pattern — event bus built into a thread+queue class
- Observer pattern — simpler single-event subscription
- FreeRTOS task and queue design — queues as the RTOS event primitive