UNIT 13 · LESSON 1 OF 6

State Machines and Event-Driven Design

How do you design firmware so that impossible situations cannot be reached?

INTERACTIVEA motor controller as a state machine
A motor controller state machine processing a sequence of eventsstatestartat_speedstoptripresetIDLERAMP··FAULT·RAMP·RUNIDLEFAULT·RUN··IDLEFAULT·FAULT····IDLE1. start: IDLE → RAMP (driver on, ramp timer started)2. at_speed: RAMP → RUN (ramp complete)3. trip: RUN → FAULT (driver off, fault latched)4. start: ignored in FAULT5. reset: FAULT → IDLE (fault cleared, driver still off)6. start: IDLE → RAMP (driver on, ramp timer started)Now in RAMP; driver on. Every (state, event) pair has a defined answer, and eventsthat make no sense in a state are ignored.
A motor controller state machine processing a sequence of eventsstatestartat_speedstoptripresetIDLERAMP··FAULT·RAMP·RUNIDLEFAULT·RUN··IDLEFAULT·FAULT····IDLE1. start: IDLE → RAMP (driver on, ramp timerstarted)2. at_speed: RAMP → RUN (ramp complete)3. trip: RUN → FAULT (driver off, fault latched)4. start: ignored in FAULT5. reset: FAULT → IDLE (fault cleared, driver stilloff)6. start: IDLE → RAMP (driver on, ramp timerstarted)Now in RAMP; driver on. Every (state, event) pairhas a defined answer, and events that make no sensein a state are ignored.

Try this

Event sequence
Written as
6 events: state RAMP, driver on.

The controller has four states and reacts to five events. Written as a transition table, every state lists the events it accepts and ignores the rest, so a start request in FAULT cannot switch the driver on. The same logic written with three boolean flags works for the normal sequence but reaches a combination nobody intended: the driver on with the fault latched. Step through an event sequence to compare the two.

What you will be able to do
  • Describe a controller as states, events, transitions and actions, and draw its transition table.
  • Explain why an explicit state variable prevents combinations that scattered boolean flags allow.
  • Implement a state machine with a switch or a table, including entry and exit actions and ignored events.
  • Explain run-to-completion dispatch from an event queue and compute the worst wait of an event.
  • Split a long action into steps that re-post themselves, and decide which events deserve priority.
Before you start
  • Tasks as small state machines in a superloop (unit 7, lesson 6).
  • Interrupt handlers that capture an event and hand it on (unit 9, lessons 5 and 6).
Steps in this lesson
  1. States, events, transitions and actions
  2. Writing it in C
  3. Event-driven: wait, then react
  4. Run to completion
  5. Worked example: the 40 ms redraw
  6. Common misconceptions

The puzzle

A motor controller starts, ramps up, runs, stops, and trips on overcurrent. The first version uses three flags, running, ramping and fault, and a handful of if statements. It passes every test, until someone presses start after a trip and the driver switches on with the fault still latched. No single line was wrong; the rules were spread over five handlers and nobody wrote down which combinations were allowed. How do you design firmware so that impossible situations cannot be reached?

STEP 1

States, events, transitions and actions

A state machine describes behaviour in four kinds of thing:

  • states: the situations the system can be in (IDLE, RAMP, RUN, FAULT). Exactly one is current.
  • events: things that happen (a start command, a speed sensor reporting “at speed”, an overcurrent trip, a timer expiring).
  • transitions: for each state, which events move it to which next state.
  • actions: what the firmware does on a transition (switch the driver off, start a timer).

The whole behaviour fits in one table with a row per state and a column per event. An empty cell means “this event is ignored in this state”, and that is a design decision you can see and review.

↑ This step uses the figure at the top of the page.

The flag version has 2³ = 8 combinations of three booleans, and nothing in the code says which of them are legal. The table version has 4 states, and the state variable can only ever hold one of them. The number of states grows with the real situations, not with 2 to the power of the number of flags.

STEP 2

Writing it in C

The simplest form is an enum and a switch on the current state, handling one event at a time:

typedef enum { IDLE, RAMP, RUN, FAULT } state_t;
typedef enum { EV_START, EV_AT_SPEED, EV_STOP, EV_TRIP, EV_RESET } event_t;
static state_t state = IDLE;

static void enter(state_t next) {              /* exit, then entry actions, in one place */
    if (state == RAMP) ramp_timer_stop();      /* exit action: no stale ramp timer */
    switch (next) {
    case RAMP:  driver_on(); ramp_timer_start(); break;
    case IDLE:
    case FAULT: driver_off(); break;
    default:    break;
    }
    state = next;
}

void motor_dispatch(event_t ev) {
    switch (state) {
    case IDLE:  if (ev == EV_START) enter(RAMP); else if (ev == EV_TRIP) enter(FAULT); break;
    case RAMP:  if (ev == EV_AT_SPEED) enter(RUN); else if (ev == EV_STOP) enter(IDLE);
                else if (ev == EV_TRIP) enter(FAULT); break;
    case RUN:   if (ev == EV_STOP) enter(IDLE); else if (ev == EV_TRIP) enter(FAULT); break;
    case FAULT: if (ev == EV_RESET) enter(IDLE); break;   /* start is ignored here */
    }
}

Entry and exit actions keep the rules in one place: “the driver is off whenever we are in IDLE or FAULT” is written once, in enter(), not repeated in every transition that reaches those states. Zephyr’s State Machine Framework uses exactly this structure: each state is three functions (entry, run, exit) in a table indexed by an enum, and it supports hierarchical states, where an event a child state does not handle is passed to its parent, so a common reaction such as “trip goes to FAULT from anywhere” can be written once in a parent state.

STEP 3

Event-driven: wait, then react

In an event-driven program, the code runs only when something happens. Interrupt handlers capture events and put them in a queue (unit 9, lesson 6); a dispatcher loop takes one event at a time and passes it to the state machine:

for (;;) {
    event_t ev;
    queue_remove_blocking(&events, &ev);   /* waits until an event is posted */
    motor_dispatch(ev);
}

Queues copy the event into their storage, so a handler can post from a local variable and return at once. The pico-sdk queue is “multi-core and IRQ safe” and queue_try_add() returns false instead of waiting when full; FreeRTOS provides xQueueSendFromISR() for handlers and a blocking xQueueReceive() for the dispatcher task. A full queue means events arrive faster than they are handled: count the failures rather than ignoring them.

STEP 4

Run to completion

The dispatcher runs each handler to the end before it looks at the next event. This run-to-completion rule is what makes state machines easy to reason about: while one event is being handled, the state cannot change underneath it, so no locking is needed between handlers of the same machine. The price is latency. An event that arrives while a long handler runs waits for all of it, and for every event queued before it:

twait≤Crunning+∑k ahead in the queueCkt_{\text{wait}} \le C_{\text{running}} + \sum_{k\ \text{ahead in the queue}} C_k
INTERACTIVERun to completion: one long handler delays everything
A timeline of four event handlers run to completion from a queueredrawbuttontripuart01020304050mstrip handled 38.7 ms after it arrived (waited 38.5 ms); the deadline is 5 msbutton 39.5 ms, UART 38.2 ms after arrivalThe trip waits behind the redraw. Keep every handler short, or split long work intosteps.
A timeline of four event handlers run to completion from a queueredrawbuttontripuart01020304050mstrip handled 38.7 ms after it arrived (waited 38.5ms); the deadline is 5 msbutton 39.5 ms, UART 38.2 ms after arrivalThe trip waits behind the redraw. Keep every handlershort, or split long work into steps.
Redraw handler takes
Trip handled after 38.7 ms (beyond 5 ms).

An event-driven program takes events from a queue and runs each handler to completion before looking at the next. Four events arrive in the first 3 ms: a display redraw, a button, an overcurrent trip that must be handled within 5 ms, and a UART message. A priority queue lets the trip jump the queue but cannot interrupt a handler that is already running; splitting the redraw into 2 ms steps, each re-posting the rest of the work, bounds how long any event waits.

A priority queue lets urgent events jump ahead of the waiting ones, but it cannot shorten the handler that is already running. The remedy is to keep every handler short and to turn long jobs into several steps: the handler does 2 ms of work, stores where it got to, and posts a “continue” event to itself at the back of the queue, so other events get their turn in between. This is the same chunking idea as in unit 7, lesson 6, expressed as events. When some reaction must happen within microseconds regardless of what the dispatcher is doing, it belongs in the interrupt handler itself (switch the driver off in the overcurrent ISR, then post the event), or in a higher-priority task (lesson 3).

STEP 5

Worked example: the 40 ms redraw

A display redraw handler takes 40 ms. At t = 1 ms a button event arrives (0.5 ms handler), at t = 2 ms an overcurrent trip (0.2 ms, deadline 5 ms), at t = 3 ms a UART message (0.5 ms). With a first-in, first-out queue, the trip waits for the rest of the redraw and for the button handler queued ahead of it:

tdone−tarrive=(40−2)+0.5+0.2=38.7 mst_{\text{done}} - t_{\text{arrive}} = (40 - 2) + 0.5 + 0.2 = 38.7\ \text{ms}

Putting the trip at the front of the queue only removes the 0.5 ms of the button: 38.2 ms, still far past the deadline. Splitting the redraw into 2 ms steps bounds the running handler at 2 ms. The worst case for the trip is then a step that has just started plus the button ahead of it, 2 + 0.5 + 0.2 = 2.7 ms; in this particular sequence the trip arrives exactly as the first step ends and is handled 0.7 ms after it arrived. The redraw now finishes at 41.2 ms instead of 40 ms, because the other handlers ran in between.

MYTHS AND FACTS

Common misconceptions

A state machine needs a framework or a code generator

An enum and a switch statement are a state machine; frameworks add hierarchy and tooling.

Flags are simpler

Only until there are three of them: then the legal combinations are no longer written anywhere.

Ignoring an event is a bug

Often it is the correct answer (start while faulted); what matters is that it is a deliberate, visible decision.

Event-driven code is automatically responsive

Only if every handler is short: run to completion means one long handler delays everything behind it.

Check yourself

Answer in your head, then open the card.

A controller is written with four independent boolean flags. How many combinations can the flags form, and why does that matter?

2⁴ = 16. Unless every handler checks every relevant flag, the code can reach combinations that no one designed for; an explicit state variable limits it to the states that were listed.

In the motor controller, where should “switch the driver off” be written so that it cannot be forgotten?

In the entry action of IDLE and FAULT (the enter() function), so every transition into those states performs it, rather than in each individual transition.

Handlers in a run-to-completion dispatcher take at most 3 ms. An urgent event is posted while one handler is running and two 0.5 ms events are already queued. What is its worst wait with a FIFO queue, and with a priority queue?

FIFO: up to 3 + 0.5 + 0.5 = 4 ms before its handler starts. Priority queue: up to 3 ms, the rest of the running handler, which cannot be interrupted.

Why can two handlers of the same run-to-completion state machine share its variables without a lock?

They never run at the same time: the dispatcher calls one handler, waits for it to return, then calls the next. Locks are needed only for data shared with interrupt handlers or other tasks.

Sources (3)
  1. Zephyr Project documentation, doc/services/smf/index.rst (State Machine Framework) — “A state is represented by three functions … Entry actions … Run actions … Exit actions”; a state machine is “a table of states that's indexed by an enum”; hierarchical states propagate unhandled events to parents; “Events are not explicitly part of the State Machine Framework but an event driven state machine can be implemented using Zephyr events” (the example posts EVENT_BTN_PRESS from a GPIO callback with k_event_post)
  2. FreeRTOS Kernel, include/queue.h — “Items are queued by copy, not by reference”; xQueueSend “must not be called from an interrupt service routine. See xQueueSendFromISR () for an alternative which may be used in an ISR”; xTicksToWait is “The maximum amount of time the task should block”
  3. Raspberry Pi Ltd, pico-sdk 1.5.1, common/pico_util/include/pico/util/queue.h — “Multi-core and IRQ safe queue implementation … pushed values are copied into the queue”; queue_try_add “will return immediately with false” if full; queue_remove_blocking “will block until a value is added”