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'.

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

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.

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.

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.

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

Applications

Key takeaways

Going further