Firmware Architecture: Super Loop, State Machines & Events
How you structure firmware decides whether it stays maintainable and meets its deadlines. Learn the super loop, foreground/background (interrupt + main) design, cooperative time-triggered schedulers, finite state machines (table and switch based), event-driven/active-object architectures, layering and modularity, and how to choose between bare-metal and an RTOS — with worst-case response analysis.
A blinking LED needs no architecture. A product with a display, three sensors, a radio, a motor and a menu does. Without structure, code turns into nested delays and global flags where changing one feature breaks three others.
Most embedded products use a small set of proven patterns: a super loop with interrupts, a time-triggered cooperative scheduler, state machines for behaviour, and an RTOS when concurrency gets complex. Knowing their timing properties lets you pick the simplest one that works.
An analogy
A super loop is a waiter walking round every table in order. A state machine is a recipe card that says exactly what to do next depending on the current step. An event-driven system is a kitchen with order tickets: cooks react to tickets instead of checking every pot constantly.
Super loop + interrupts
The main loop calls each job in turn; interrupts capture time-critical events (bytes, edges, timer ticks) and set flags or fill buffers. Simple and deterministic, but the response time to a flagged event is up to one full loop. Never use blocking delays (delay_ms(500)) inside the loop — use timers and state instead.
State machines
Most device behaviour is naturally a state machine: a charger (idle, pre-charge, constant current, constant voltage, done, fault), a protocol parser, a menu. Explicit FSMs replace tangled flags, make illegal transitions impossible, and map directly to UML state charts. Hierarchical state machines (Harel statecharts) handle shared behaviour like 'any state → FAULT on over-temperature'.
- switch(state) in a function — simple
- Table-driven — compact, reviewable
- Function-pointer states — each state is a handler
- Frameworks: QP/C, SMC, Yakindu
Event-driven and active objects
Components communicate by posting events to queues instead of calling each other directly. Each 'active object' owns its data, has an event queue and a state machine, and processes one event at a time — run-to-completion. This avoids shared-state races and scales to large systems (QP framework, Zephyr's work queues).
Choosing an architecture
Use a super loop for simple, few-deadline systems; a time-triggered scheduler when timing must be predictable and certifiable; an RTOS when there are many independent activities with different rates, blocking I/O (networks, files) or complex priorities. Layer the code (application → services → drivers → HAL) with clean interfaces so business logic can be unit-tested on a PC and hardware can change without rewriting features.
Formulas
- Super-loop worst-case response:
R ≈ Σ Cᵢ (one full loop) + ISR load - Cooperative scheduler tick:
Tick ≥ max Cᵢ run in one tick; tasks run at multiples of the tick - Utilisation:
U = Σ Cᵢ / Tᵢ ≤ 1 (keep ≤ 0.7 for margin) - FSM:
next_state = T(state, event); output = G(state[, event])
Worked example: Super-loop response time
Given: Loop: sensors 1 ms, control 0.5 ms, display 8 ms, comms 2 ms
Find: Worst-case delay before a flagged button is handled.
- One full loop = 11.5 ms
- Plus ISR time (say 0.5 ms)
Answer: ≈ 12 ms — fine for a button, too slow for a 1 kHz control loop
Worked example: Scheduler utilisation
Given: Tasks: 0.3 ms/10 ms, 0.8 ms/10 ms, 4 ms/100 ms, 0.5 ms/20 ms
Find: U.
- 0.03 + 0.08 + 0.04 + 0.025
Answer: U = 17.5% — plenty of margin
Worked example: Blocking delay problem
Given: Display update uses delay_ms(50)
Find: Effect on a 10 ms control task in the same loop.
- Loop time ≥ 50 ms
Answer: Control runs at ≤ 20 Hz instead of 100 Hz — replace delay with a timer-driven state
Table-driven state machine (host-testable C)
#include <stdio.h>
typedef enum { S_OFF, S_ON, S_BLINK, S_COUNT } state_t;
typedef enum { E_PRESS, E_LONG, E_TIMEOUT, E_COUNT } event_t;
static const char *SN[] = { "OFF", "ON", "BLINK" };
static const char *EN[] = { "PRESS", "LONG", "TIMEOUT" };
/* next-state table: rows = state, cols = event */
static const state_t T[S_COUNT][E_COUNT] = {
/* PRESS LONG TIMEOUT */
/* OFF */ { S_ON, S_BLINK, S_OFF },
/* ON */ { S_OFF, S_BLINK, S_OFF },
/* BLINK */ { S_OFF, S_BLINK, S_OFF },
};
int main(void)
{
state_t s = S_OFF;
event_t seq[] = { E_PRESS, E_TIMEOUT, E_LONG, E_PRESS, E_PRESS, E_TIMEOUT };
for (unsigned i = 0; i < sizeof seq / sizeof seq[0]; i++) {
state_t n = T[s][seq[i]];
printf("%-7s : %5s -> %s\n", EN[seq[i]], SN[s], SN[n]);
s = n; /* entry actions would run here */
}
return 0;
}
/* Output:
PRESS : OFF -> ON
TIMEOUT : ON -> OFF
LONG : OFF -> BLINK
PRESS : BLINK -> OFF
PRESS : OFF -> ON
TIMEOUT : ON -> OFF */All behaviour lives in one table: easy to review against the specification, easy to test on a PC, and adding a state or event is a data change. On target, events come from an ISR-filled queue.
Time-triggered cooperative scheduler
typedef struct { void (*fn)(void); uint16_t period_ms, countdown; } task_t;
static task_t tasks[] = {
{ read_sensors, 10, 0 },
{ control_loop, 10, 5 }, /* offset spreads load across ticks */
{ update_display, 100, 2 },
{ comms_poll, 20, 7 },
};
static volatile uint8_t tick_flag;
void SysTick_Handler(void) { tick_flag = 1; } /* 1 ms tick */
void scheduler_run(void)
{
for (;;) {
while (!tick_flag) __WFI(); /* sleep until tick */
tick_flag = 0;
for (unsigned i = 0; i < sizeof tasks / sizeof tasks[0]; i++) {
if (tasks[i].countdown-- == 0) {
tasks[i].countdown = tasks[i].period_ms - 1;
tasks[i].fn(); /* must be short! */
}
}
}
}Each task runs to completion; no preemption means no race conditions between tasks and a very small footprint. Offsets avoid all tasks landing on the same tick. Every task must finish well within a tick.
Common mistakes
- Wrong: Blocking delays inside the main loop. — Right: Use timers and non-blocking state progression.
- Wrong: Behaviour spread across dozens of global flags. — Right: One explicit state variable per behaviour + FSM.
- Wrong: Drivers calling application code directly. — Right: Callbacks/events upward; dependencies point downward only.
Applications
- Appliances: Washing-machine cycles as hierarchical state machines.
- Battery chargers: CC/CV charging FSM with fault states.
- Protocol handlers: Byte-by-byte parsers as FSMs fed by ISR queues.
- Safety systems: Time-triggered schedulers for certifiable timing.
Key takeaways
- Super loop + ISRs for simple systems; no blocking delays.
- Model behaviour as explicit state machines.
- Events/queues decouple components.
- Layer the code; pick the simplest architecture that meets timing.
Going further
- Hierarchical state machines: Nested states inherit transitions (UML statecharts).
- Active object frameworks: QP/C, Zephyr SMF, message-passing designs.
- Model-based design: Generate FSM code from Simulink Stateflow or UML.