Finite State Machines in C for Embedded Firmware: Switch and Table

Most embedded products are not calculations; they are behaviours. A washing machine fills, washes, drains and spins. A pedestrian crossing shows green to cars until someone presses the button, then switches through amber to red. Writing such behaviour with nested if statements and flags quickly becomes unreadable. A finite state machine (FSM) gives it a clear structure: the system is always in exactly one state, and defined events move it to another state.

In this article you will design a state machine for a pedestrian crossing on paper, then implement it in C in two styles, and learn the rules that keep state machines maintainable.

States, Events, Transitions and Actions

TermMeaningCrossing example
StateA situation the system stays in until something happensCARS_GREEN, CARS_AMBER, WALK, WALK_ENDING
EventSomething that happens: an input, a timeout, a messageBUTTON pressed, TIMEOUT
TransitionA move from one state to another caused by an eventCARS_GREEN + BUTTON goes to CARS_AMBER
ActionWork done on a transition or on entering a stateSwitch lamps, start a timer

Two classic models exist. In a Moore machine outputs depend only on the current state: in WALK the walk lamp is on. In a Mealy machine outputs depend on the state and the event, so actions are attached to transitions. Firmware usually mixes both: entry actions set the outputs for a state, and transitions occasionally do extra work.

Designing the Pedestrian Crossing

Always draw the machine before coding it. Each line below is a transition. Events not listed for a state are ignored, which is itself a design decision: pressing the button while people are already walking does nothing.

            BUTTON                 TIMEOUT (3 s)
 CARS_GREEN --------> CARS_AMBER --------------> WALK
     ^                                            |
     |                                            | TIMEOUT (10 s)
     |          TIMEOUT (5 s, flashing)           v
     +------------------------------------- WALK_ENDING
StateCar lampWalk lampTimer on entry
CARS_GREENGreenDon't walkNone (waits for button)
CARS_AMBERAmberDon't walk3 s
WALKRedWalk10 s
WALK_ENDINGRedFlashing walk5 s

The timings here are example values for the tutorial, not values from any traffic standard. A real installation must follow the local road authority's rules.

Implementation 1: Switch-Case Machine

The most readable form uses an enum for states and events and a switch on the current state. The program runs on a PC with a scripted list of events; on hardware the events would come from a debounced button and from a non-blocking timer.

Pedestrian crossing state machine with entry actions, driven by a scripted event list
#include <stdio.h>

typedef enum { CARS_GREEN, CARS_AMBER, WALK, WALK_ENDING, NUM_STATES } state_t;
typedef enum { EV_BUTTON, EV_TIMEOUT, NUM_EVENTS } event_t;

static const char *state_name[NUM_STATES] =
    { "CARS_GREEN", "CARS_AMBER", "WALK", "WALK_ENDING" };

static state_t state;

/* Hardware layer stubs: on a board these drive GPIO pins and a soft timer */
static void set_lamps(const char *car, const char *walk)
{
    printf("   lamps: cars=%s, pedestrians=%s\n", car, walk);
}
static void start_timer_ms(unsigned ms) { printf("   timer: %u ms\n", ms); }

/* Entry actions: everything that must happen when a state is entered */
static void enter(state_t s)
{
    state = s;
    printf("-> %s\n", state_name[s]);
    switch (s) {
    case CARS_GREEN:  set_lamps("GREEN", "DONT_WALK");                      break;
    case CARS_AMBER:  set_lamps("AMBER", "DONT_WALK"); start_timer_ms(3000);  break;
    case WALK:        set_lamps("RED",   "WALK");      start_timer_ms(10000); break;
    case WALK_ENDING: set_lamps("RED",   "FLASHING");  start_timer_ms(5000);  break;
    default: break;
    }
}

/* Transition logic: one place decides what each event does in each state */
static void fsm_dispatch(event_t ev)
{
    switch (state) {
    case CARS_GREEN:
        if (ev == EV_BUTTON) enter(CARS_AMBER);
        break;
    case CARS_AMBER:
        if (ev == EV_TIMEOUT) enter(WALK);
        break;
    case WALK:
        if (ev == EV_TIMEOUT) enter(WALK_ENDING);
        break;
    case WALK_ENDING:
        if (ev == EV_TIMEOUT) enter(CARS_GREEN);
        break;
    default:
        enter(CARS_GREEN);   /* corrupted state: recover to a safe state */
        break;
    }
}

int main(void)
{
    const event_t script[] = { EV_TIMEOUT, EV_BUTTON, EV_BUTTON, EV_TIMEOUT,
                               EV_BUTTON, EV_TIMEOUT, EV_TIMEOUT };
    const char *ev_name[NUM_EVENTS] = { "BUTTON", "TIMEOUT" };

    enter(CARS_GREEN);
    for (unsigned i = 0; i < sizeof script / sizeof script[0]; i++) {
        printf("event %s\n", ev_name[script[i]]);
        fsm_dispatch(script[i]);
    }
    return 0;
}

Run it and you will see that the first TIMEOUT in CARS_GREEN, the second BUTTON in CARS_AMBER and the BUTTON in WALK are ignored, exactly as the diagram says. Ignored events are not errors; they are part of the specification.

Implementation 2: Table-Driven Machine

When a machine has many states and events, the switch grows long. A state transition table stores the next state for every state and event pair, and the dispatcher becomes a single lookup. The fragment below replaces fsm_dispatch in the previous program.

Drop-in table-driven dispatcher for the same machine (replace fsm_dispatch above)
#define NO_CHANGE NUM_STATES   /* marker: event ignored in this state */

static const state_t next_state[NUM_STATES][NUM_EVENTS] = {
    /*                 EV_BUTTON    EV_TIMEOUT  */
    /* CARS_GREEN  */ { CARS_AMBER,  NO_CHANGE   },
    /* CARS_AMBER  */ { NO_CHANGE,   WALK        },
    /* WALK        */ { NO_CHANGE,   WALK_ENDING },
    /* WALK_ENDING */ { NO_CHANGE,   CARS_GREEN  },
};

static void fsm_dispatch(event_t ev)
{
    if (state >= NUM_STATES || ev >= NUM_EVENTS) {
        enter(CARS_GREEN);               /* defensive recovery */
        return;
    }
    state_t next = next_state[state][ev];
    if (next != NO_CHANGE) {
        enter(next);
    }
}

The table is const, so on most 32-bit microcontrollers it lives in flash. It is also easy to review: every cell is a decision someone made. Some designs extend each cell into a structure holding the next state and an action function pointer, which gives a full Mealy machine.

Testing the machine on a PC

Because the hardware calls are isolated in set_lamps and start_timer_ms, the whole machine can be tested on a laptop before any board exists. Write event scripts for normal use, for repeated button presses, and for every event in every state, then compare the printed transitions with the diagram. Bugs found this way cost minutes instead of hours on a target with a debugger.

Guidelines and Common Mistakes

  • Keep the dispatcher non-blocking. A state machine replaces delays; never call a delay inside it. Timeouts arrive as events from a soft timer.
  • Put outputs in entry actions so a state always looks the same no matter which transition led to it.
  • Handle the default case. A corrupted state variable from a stack overflow or a bit flip should lead to a safe state, not to undefined behaviour.
  • Feed events from one place. ISRs should only queue events; the main loop dispatches them, so the machine is never re-entered from an interrupt.
  • Avoid flags that shadow the state, such as a separate walking boolean. If it can disagree with the state variable, eventually it will.
  • Split large machines into hierarchical or parallel machines, for example one for the motor and one for the user interface, communicating through events.

Summary

A finite state machine describes behaviour as states, events, transitions and actions. Draw it first, list what each state does on entry and decide which events each state ignores. Code it with a switch for small machines or a const transition table for larger ones, keep it non-blocking, and recover to a safe state on unexpected values. Combined with non-blocking timers and debounced inputs, state machines are the backbone of reliable bare-metal firmware.