Search topics...
State Machines
intermediate
Weight: 4/10

Event-Driven State Machines

Move from polling to event-driven FSMs: RTOS queue dispatch, run-to-completion, ISR safety, and the active-object pattern.

state-machines
fsm
event-driven
rtos
active-object
isr-safety
Loading quiz status...

Quick Cap

A polling FSM checks "what changed?" every loop iteration — wasteful and bad for power. An event-driven FSM sleeps until an event arrives, then processes one event to completion before sleeping again. The plumbing is RTOS primitives: a queue (or event flags) holds events; a dedicated FSM task waits on the queue and dispatches; ISR-safe API variants let interrupt handlers post events without blocking. The combination of an FSM + thread + event queue is canonically called the active object pattern. Interviewers test whether you understand the trade-off and can wire it up safely.

Key Facts:

  • Polling vs event-driven: polling = "did anything change since last loop?"; event-driven = "wake when something happens"
  • Event queue is the canonical delivery mechanism — FIFO, blocking-receive
  • Event flags / event groups are cheaper if events are stateless and don't carry data
  • Run-to-completion: finish one event entirely before processing the next — eliminates re-entry bugs
  • ISR-safe API: ISRs use xQueueSendFromISR-style variants that don't block the ISR
  • Active object: an FSM that owns its own thread and event queue — the cleanest pattern for complex devices

Deep Dive

At a Glance

AspectPolling FSMEvent-driven FSM
CPU usage when idleHigh (continuous loop)Near zero (task blocked)
LatencyLoop period (could be ms-scale)Microseconds (immediate wake)
PowerHighLow (CPU sleeps)
Code complexityLower (no concurrency)Medium (queue + task management)
TestabilityEasy in unit testsEasy if dispatch is mockable
Best forTiny FSMs, bare-metal main loopRTOS-based, anything responsive or low-power

From Polling to Event-Driven

A polling FSM looks like this:

c
void main_loop(void) {
while (1) {
Event e = check_for_events(); // poll button, UART, timer
if (e != E_NONE) {
fsm_handle_event(e);
}
}
}

The CPU is fully busy even when nothing is happening. On battery-powered devices this is unacceptable; on RTOS-based systems it starves other tasks.

An event-driven FSM rearranges the same logic:

c
QueueHandle_t event_q;
void fsm_task(void *arg) {
while (1) {
Event e;
xQueueReceive(event_q, &e, portMAX_DELAY); // sleep here
fsm_handle_event(e);
}
}
/* Hardware interrupt — runs at ISR priority */
void EXTI0_IRQHandler(void) {
Event e = E_BUTTON;
BaseType_t hpw = pdFALSE;
xQueueSendFromISR(event_q, &e, &hpw);
portYIELD_FROM_ISR(hpw);
}

The task xQueueReceives with portMAX_DELAY — it blocks until something is in the queue. The CPU sleeps. When an interrupt fires, the ISR posts an event and (if a higher-priority task is now ready) yields. The event-FSM task wakes, dispatches, and goes back to sleep.

For peripherals that don't generate interrupts (e.g., a periodic check), use a timer event: an RTOS software timer fires periodically and posts E_TIMER_TICK to the same queue.

Event Delivery Mechanisms

The two main RTOS primitives for event delivery, with rough trade-offs:

PrimitiveCarries data?FIFO?Memory costBest for
Queue / message queueYes (full event struct)YesHigh (queue depth × event size)General-purpose event delivery
Event flags / event groupNo (just bits)NoLow (one word per group)Stateless events that may coalesce

A queue preserves event order and can carry a struct with parameters: Event { type; data; }. Use this when "the third byte received" is different from "the first byte received" and the FSM needs to process them in order.

Event flags are bit-set semaphores. Setting EVENT_BUTTON | EVENT_TIMER twice between two task wake-ups looks the same as setting it once — events coalesce. Use this when "did the button get pressed since I last checked?" is the only question; the count and order don't matter.

In practice, queues are the default and event flags are an optimization for specific cases. Cross-link to RTOS Sync Primitives for the underlying mechanisms.

Run-to-Completion Discipline

The event-driven model gives you run-to-completion for free, as long as you have one FSM per task and one task draining one queue:

c
while (1) {
xQueueReceive(event_q, &e, portMAX_DELAY); // get one event
fsm_handle_event(e); // process to completion
/* loop back, get next event */
}

The FSM's dispatch function is called once per event, returns when done, and the next event isn't picked up until then. No re-entry possible.

Inside an action, you may need to fire a follow-up event (e.g., BYTE_RX arrives, you parse a complete message, and want to dispatch MSG_COMPLETE). The right move is to enqueue the follow-up event onto the same queue:

c
static void on_byte_rx(uint8_t b) {
if (parser_accumulate(b)) {
/* don't call fsm_handle_event here — enqueue! */
Event next = { .type = E_MSG_COMPLETE };
xQueueSend(event_q, &next, 0);
}
}

The current event runs to completion; on the next loop iteration, the dispatcher picks up E_MSG_COMPLETE and processes it cleanly.

ISR Safety

Interrupt handlers cannot block, cannot call non-ISR-safe RTOS APIs, and should be very short. To deliver an event from an ISR:

  1. Use the *FromISR variant of your queue/event API (xQueueSendFromISR, xEventGroupSetBitsFromISR)
  2. Pass a BaseType_t hpw ("higher-priority woken") and portYIELD_FROM_ISR(hpw) to trigger a context switch if a higher-priority task became ready
  3. Keep the ISR tiny — just enough to capture data and post the event
c
void EXTI0_IRQHandler(void) {
/* Read peripheral data quickly */
Event e = { .type = E_BUTTON, .data = HAL_GetTick() };
/* Post to queue */
BaseType_t hpw = pdFALSE;
xQueueSendFromISR(event_q, &e, &hpw);
/* Yield if a higher-priority task became ready */
portYIELD_FROM_ISR(hpw);
}

This pattern — "ISR captures, task processes" — is the canonical embedded ISR-to-task handoff. Cross-link to RTOS ISR-to-Task Patterns for the underlying mechanics.

⚠️Don't dispatch FSM logic in the ISR

Tempting shortcut: just call fsm_handle_event(e) directly in the ISR. Bad idea: ISRs run at high priority and short duration; the FSM can be slow; the FSM may call APIs not safe at ISR context. Always: ISR enqueues, task dispatches.

The Active Object Pattern

Combine three things into one self-contained unit:

  1. An FSM with its states, events, and transitions
  2. An RTOS task that drains the queue and dispatches
  3. A queue that the task waits on

This is called an active object (term coined by Miro Samek's QP framework). Each subsystem (BLE controller, charger manager, sensor driver) is one active object. They communicate by posting events to each other's queues — never by calling each other's functions directly.

Benefits:

  • Encapsulation: each active object owns its state. No shared globals between subsystems.
  • Testability: drive the active object by posting test events to its queue.
  • Concurrency safety: events are dispatched serially within the task, so no locks needed for the FSM's own state.
  • Composability: subsystems are independent; replacing one doesn't disturb others.

Active objects are how complex embedded systems (Tesla autopilot, BLE stacks, automotive infotainment) are typically architected.

Cross-Active-Object Communication

When active object A needs to tell active object B that something happened, A posts an event to B's queue:

c
/* In active object A */
void a_on_signal_lost(void) {
Event e = { .type = E_LINK_DOWN };
xQueueSend(b_event_q, &e, 0);
}

A and B are decoupled — A doesn't know how B handles the event, and B doesn't know A exists. This is essentially the actor model in embedded form.

When Event-Driven Is Overkill

Don't use the active-object pattern for trivial logic:

  • A 3-state FSM that runs in the main loop and isn't power-critical: just call it from main()
  • Bare-metal targets without an RTOS: use a single event queue + main-loop dispatcher (still event-driven, just without OS task)
  • Simple polling at low rate (e.g., every 100 ms): a polling FSM may be simpler than wiring up a timer event

The pattern shines when you have ≥ 3 concurrent subsystems with their own state, when low power matters, or when responsiveness to multiple event sources matters.

Bare-Metal Event Loop (No RTOS)

You can get most of the active-object benefits without an RTOS using a hand-rolled event queue and a main loop:

c
static volatile Event event_buf[QUEUE_SIZE];
static volatile uint8_t head = 0, tail = 0;
void post_event_isr_safe(Event e) {
/* with critical section */
__disable_irq();
if (next(head) != tail) {
event_buf[head] = e;
head = next(head);
}
__enable_irq();
}
void main_loop(void) {
while (1) {
if (head != tail) {
Event e = event_buf[tail];
tail = next(tail);
fsm_handle_event(e);
} else {
__WFI(); /* sleep until interrupt */
}
}
}

This gives you run-to-completion (events drained one at a time), ISR safety (interrupts post; main loop drains), and low power (__WFI sleeps the CPU). It's roughly the bare-metal active-object equivalent.

Debugging Story: The Disappearing Bytes

A team's UART parser FSM was an active object. Bytes received in UART_IRQHandler were posted to its queue with xQueueSendFromISR. Under heavy load, the parser would occasionally lose bytes mid-message.

The first hypothesis was queue overflow. Profiling showed the queue depth was fine — never above 2 entries. The real bug was in the ISR: xQueueSendFromISR returns a status code indicating whether the post succeeded. The handler ignored it. When two bytes arrived back-to-back (rare but possible at higher baud rates), the second xQueueSendFromISR was failing because the queue had only one slot configured, and the developer hadn't sized for back-to-back posts.

Two fixes: (1) check the return value of every xQueueSendFromISR; (2) size queues for worst-case burst — for UART RX, that's "max consecutive bytes between FSM task scheduling slices."

The lesson: Queues are not magic. ISR-safe API return values matter. Worst-case burst load is the right sizing metric, not average.

What Interviewers Want to Hear

  • You can compare polling vs event-driven and explain when each is appropriate
  • You can describe queue-based event delivery and ISR-to-task handoff
  • You know run-to-completion is essential for FSM determinism
  • You can name and explain the active-object pattern
  • You know to use *FromISR API variants in interrupts
  • You know event flags vs queues — when each is preferable

Interview Focus

Classic Interview Questions

Q1: "Compare polling and event-driven FSM dispatch — when would you use each?"

Model Answer Starter: "A polling FSM checks 'did anything change?' every loop iteration. The CPU is fully busy even when idle, latency is bounded by loop period, and power consumption is high. It's fine for trivial bare-metal projects where there's nothing else to do anyway. An event-driven FSM sleeps until an event arrives — typically via an RTOS queue receive with a timeout of forever. The CPU sleeps when there's nothing to do, latency is microseconds (the wakeup time of the task), and power is much lower. Event-driven is the right choice for any battery-powered device, anything responsive to multiple event sources, or anything with concurrent subsystems. For embedded RTOS projects it's the default."

Q2: "How do you safely deliver events from an ISR to an FSM task?"

Model Answer Starter: "Use the ISR-safe variants of your queue API — for FreeRTOS, that's xQueueSendFromISR. The ISR captures whatever data it needs to (e.g., a byte from UART RDR), packages it into an event struct, and posts it. The post returns a BaseType_t 'higher-priority task woken' flag, which you pass to portYIELD_FROM_ISR so a context switch happens immediately if the FSM task is now ready and higher priority than the interrupted task. Critically: the ISR must NOT call fsm_handle_event directly — that would run FSM logic at interrupt priority, blocking other interrupts and possibly calling unsafe APIs. The pattern is always: ISR captures and enqueues; task dispatches."

Q3: "What is run-to-completion and how does the event-driven pattern give it to you?"

Model Answer Starter: "Run-to-completion means a single event is fully processed — actions executed and state updated — before the next event is dispatched. The event-driven pattern naturally provides this: the FSM task does a queue-receive, processes one event with fsm_handle_event, then loops back to receive the next event. There's no re-entry possible because the dispatch function returns before the loop reads the next event. If an action inside a transition needs to fire a follow-up event, it should xQueueSend to the same queue rather than calling fsm_handle_event directly — the follow-up gets processed cleanly on the next iteration."

Q4: "What is the active-object pattern?"

Model Answer Starter: "An active object is a self-contained subsystem consisting of three things: an FSM (with its states, events, transitions), an RTOS task that owns the FSM, and a queue the task drains. Each subsystem in the system — BLE controller, charger manager, sensor driver — is one active object. Subsystems communicate only by posting events to each other's queues; they never call each other's functions directly. This gives encapsulation (each AO owns its state), concurrency safety (events are processed serially within the task — no locks needed for the FSM's own data), testability (post test events to the queue), and composability (subsystems are independent). It's roughly the actor model applied to embedded systems."

Q5: "When would you prefer event flags over a queue for event delivery?"

Model Answer Starter: "When events are stateless and can coalesce. Event flags are essentially bit-set semaphores — setting EVENT_TIMER twice between task wake-ups looks the same as setting it once. Use them when the question is 'did this happen since I last looked?' rather than 'how many times did this happen and in what order?'. They're cheaper (one word per group versus one queue slot per pending event) and faster (no copy of an event struct). Use a queue when events carry parameters (e.g., a received byte's value), when order matters (parse-byte sequence), or when you can't lose intermediate events."

Trap Alerts

  • Don't say: "I'd just call the FSM dispatch from the ISR" — runs FSM at ISR context, dangerous and slow
  • Don't forget: Check xQueueSendFromISR return values — silent post failures are the silent-byte-loss bug
  • Don't ignore: Queue depth sizing — worst-case burst, not average

Follow-up Questions

  • "How would you implement event timeouts (e.g., 'fire E_TIMEOUT if no other event in 5 seconds')?"
  • "How do active objects pass complex data — e.g., a 100-byte received message — without expensive copies?"
  • "What happens if an active object's queue fills up?"
  • "How would you debug a system with 5 active objects all posting to each other?"
  • "Compare RTOS event groups, queues, and direct task notifications for FSM event delivery."
  • "How do you measure end-to-end latency from ISR to FSM action?"
💡Practice State Machines Interview Questions

Ready to test yourself? Head over to the State Machines Interview Questions page for a full set of Q&A with collapsible answers — perfect for self-study and mock interview practice.

Practice

Why must ISRs not call fsm_handle_event() directly?

What's the main benefit of the active-object pattern?

When would event flags be preferable to a queue for FSM event delivery?

An action inside a transition needs to fire a follow-up event. What's the correct way?

What does portYIELD_FROM_ISR do in a typical ISR-to-FSM event handoff?

Real-World Tie-In

Wearable BLE Stack — A wearable's BLE controller is an active object. The Link Layer ISR receives raw Bluetooth packets, parses just enough to identify the type, and posts events to the BLE controller task: E_CONN_REQ, E_DATA_PDU, E_LINK_LOSS. The controller's FSM updates GAP/L2CAP state and posts higher-level events to the application active object. The whole stack runs in 2 tasks with 2 queues, deterministic, no shared mutable state across boundaries.

Sensor Hub Polling Replacement — A sensor hub originally polled 8 sensors in a busy loop, draining the battery. Refactored each sensor into an active object: timer event drives sample acquisition, completed samples are posted to the application active object via cross-AO event. CPU usage dropped from ~90% to ~3%; battery life tripled. Same logical behavior, different dispatch.

FreeRTOS DataX Stack — Meta's wearables stack uses event-driven active objects extensively for connectivity components (BLE, IPC, channel management). Each component is an FSM with its own task and queue. Cross-component events are explicit and traceable — replays and reproductions of complex bugs are possible because the entire system state can be reconstructed from queue traces.

Was this helpful?