Quick Cap
There are three canonical ways to implement a finite state machine in C: switch-case (simplest, fastest for small FSMs), 2D table-driven (data-as-state-table — easy to extend, easy to test), and function-pointer table (each state is a handler function — cleanest separation, scales best). Each has clear trade-offs in memory, speed, readability, and extensibility. Interviewers want to see that you can pick the right pattern for the size and complexity of the FSM, and that you can test any of them deterministically.
Key Facts:
- switch-case: outer switch on state, inner switch on event. Best for ≤ 5 states with simple transitions.
- Table-driven: 2D array
[state][event] = {next_state, action_fn}. Best for medium FSMs with mostly-uniform transition logic. - Function-pointer: each state is a function
state_X(event). Best for large FSMs with state-specific complex behavior. - Determinism makes FSMs easy to test: drive
(state, event) → next_statedirectly without peripherals - Transition coverage: every (state, event) pair in your transition table should be exercised by tests
Deep Dive
At a Glance
| Pattern | Code style | Best for | Memory | Speed | Extensibility |
|---|---|---|---|---|---|
| switch-case | Nested switch in one function | Small (≤ 5 states), simple actions | Low | Fastest | Low (adding event = touching every state) |
| Table-driven | Static 2D array of transitions | Medium FSMs, uniform structure | Medium (table) | Medium (one indirect call per event) | High (edit table, no logic changes) |
| Function-pointer | Per-state handler function | Large/complex FSMs, state-specific logic | Medium (handler array) | Medium | Highest (add state = add function) |
Pattern 1: Switch-Case
The simplest implementation. One function, outer switch on state, inner switch on event:
typedef enum { S_IDLE, S_ACTIVE, S_PAUSED, S_ERROR } State;typedef enum { E_BUTTON, E_TIMEOUT, E_FAULT } Event;static State state = S_IDLE;void fsm_handle_event(Event e) {switch (state) {case S_IDLE:switch (e) {case E_BUTTON:led_on();state = S_ACTIVE;break;case E_TIMEOUT:/* stay in IDLE */break;case E_FAULT:error_blink();state = S_ERROR;break;}break;case S_ACTIVE:switch (e) {case E_BUTTON:led_dim();state = S_PAUSED;break;/* ... */}break;/* ... S_PAUSED, S_ERROR ... */}}
When to use: ≤ 5 states, ≤ 5 events, transitions all visible at once on a printed page. The control flow is direct, the optimizer generates a tight jump table, and a reader can trace any transition by eye.
When it falls apart: Past 5 states, the function explodes. Adding a new event means adding a case in every state. Cross-state actions (entry/exit) get duplicated. Refactoring is risky because the structure is fragile.
Pattern 2: Table-Driven (2D Transition Table)
Encode transitions as a 2D array. Each cell holds the next state and (optionally) an action function pointer:
typedef enum { S_IDLE, S_ACTIVE, S_PAUSED, S_ERROR, NUM_STATES } State;typedef enum { E_BUTTON, E_TIMEOUT, E_FAULT, NUM_EVENTS } Event;typedef void (*ActionFn)(void);typedef struct {State next;ActionFn action;} Transition;/* Empty cell = stay in same state, no action */#define STAY { (State)-1, NULL }static const Transition fsm_table[NUM_STATES][NUM_EVENTS] = {/* E_BUTTON E_TIMEOUT E_FAULT */[S_IDLE] = { {S_ACTIVE, led_on}, STAY, {S_ERROR, error_blink} },[S_ACTIVE] = { {S_PAUSED, led_dim}, {S_IDLE, NULL}, {S_ERROR, error_blink} },[S_PAUSED] = { {S_ACTIVE, led_on}, STAY, {S_ERROR, error_blink} },[S_ERROR] = { {S_IDLE, led_off}, STAY, STAY },};static State state = S_IDLE;void fsm_handle_event(Event e) {const Transition *t = &fsm_table[state][e];if (t->next != (State)-1) {if (t->action) t->action();state = t->next;}}
When to use: 5-20 states with mostly-uniform transition logic. The transition table is the spec — reviewable as a 2D matrix, easy to verify completeness ("every (state, event) cell defined?"), trivially testable by iterating rows and columns.
When it falls apart: When transitions need rich logic — guards, multiple actions, computed next-states. You end up squeezing too much into the cell, or splitting the action into mini-FSMs of its own. Also, sparse FSMs (few real transitions) waste memory on empty cells.
Tip — guarded transitions: Add a guard function pointer to the cell:
typedef bool (*GuardFn)(void);typedef struct {GuardFn guard; /* NULL = always-true */State next;ActionFn action;} Transition;
When multiple transitions match (state, event), iterate them and pick the first whose guard returns true.
Pattern 3: Function-Pointer Table (Handler-Per-State)
Each state is a function. The FSM dispatches by calling the current state's handler. State changes happen by reassigning the handler pointer:
typedef enum { E_BUTTON, E_TIMEOUT, E_FAULT } Event;typedef void (*StateFn)(Event);static StateFn current_state;/* Forward declarations */static void state_idle(Event e);static void state_active(Event e);static void state_paused(Event e);static void state_error(Event e);static void state_idle(Event e) {switch (e) {case E_BUTTON:led_on();current_state = state_active;break;case E_FAULT:error_blink();current_state = state_error;break;case E_TIMEOUT:/* stay */break;}}static void state_active(Event e) {switch (e) {case E_BUTTON:led_dim();current_state = state_paused;break;/* ... */}}/* ... state_paused, state_error ... */void fsm_handle_event(Event e) {current_state(e);}void fsm_init(void) {current_state = state_idle;}
When to use: Large FSMs (10+ states) with state-specific complex behavior — entry/exit actions, per-state local data, state-specific helper functions. Each state lives in its own block of code, can have static-scoped helpers, and can be unit-tested in isolation. Adding a new state means adding one function — no central table to coordinate.
When it falls apart: When you want to see the entire transition matrix at a glance. The transitions are scattered across N functions; reviewing "what does event X do in state Y?" requires hunting through code.
Tip — entry/exit actions: Put them in a wrapper:
static void enter_state(StateFn new_state) {/* exit action of current state */if (current_state == state_active) led_off();/* ... */current_state = new_state;/* entry action of new state */if (new_state == state_active) led_on();/* ... */}
Or, more cleanly, have each state expose entry/exit hooks:
typedef struct {StateFn handler;void (*on_entry)(void);void (*on_exit)(void);} StateDef;
Choosing the Pattern
| If you have... | Pick... |
|---|---|
| ≤ 5 states, simple actions, "I want to see it all in one screen" | switch-case |
| 5-20 states, mostly-uniform transitions, want it spec-readable | table-driven |
| 10+ states, complex per-state behavior, entry/exit actions matter | function-pointer |
| Need code generation from a model file (Yakindu, Stateflow) | table-driven (the generator's natural output) |
| Need to inject mock events for testing | All three are testable; table-driven is easiest |
You can also mix: a coarse outer FSM in function-pointer style, with an inner switch-case FSM inside the most complex state. This is essentially how hierarchical state machines (covered in Hierarchical State Machines) are often implemented manually.
Testing FSMs
The single best property of FSMs is determinism: given the same starting state and event sequence, the FSM produces the same final state and same actions. This makes unit testing straightforward — no mocking of complex peripherals, no flakiness from timing. The pattern:
- Reset the FSM to a known starting state
- Inject a sequence of events by calling the dispatch function
- Assert the final state and that the right actions fired (ideally via a mock that records calls)
void test_button_press_in_idle_goes_active(void) {fsm_init(); // start in S_IDLEled_mock_reset(); // mock recorderfsm_handle_event(E_BUTTON);assert(fsm_get_state() == S_ACTIVE);assert(led_mock_was_called("led_on"));}void test_fault_in_any_state_goes_error(void) {State states[] = {S_IDLE, S_ACTIVE, S_PAUSED};for (int i = 0; i < 3; i++) {fsm_init();fsm_force_state(states[i]); // test helperfsm_handle_event(E_FAULT);assert(fsm_get_state() == S_ERROR);}}
Transition Coverage
For a transition table with N states and M events, there are N×M cells — the transition coverage target is to exercise every cell at least once. Generate test cases programmatically:
void test_all_transitions(void) {for (int s = 0; s < NUM_STATES; s++) {for (int e = 0; e < NUM_EVENTS; e++) {fsm_init();fsm_force_state(s);State expected_next = fsm_table[s][e].next;fsm_handle_event(e);assert(fsm_get_state() == expected_next);}}}
This iterates the entire (state, event) cross-product. For switch-case and function-pointer patterns, the same idea applies — you just iterate enum values.
Mock Event Injection
The test harness becomes trivially mockable when the dispatch function is a single entry point:
void fsm_handle_event(Event e);
Tests don't need to fire interrupts, drive peripherals, or wait for timers. They call fsm_handle_event(E_TIMEOUT) directly. This is why FSMs are "test friendly" — the abstraction lets you sever event sources from the FSM logic.
If your FSM dispatch is fsm_handle_event(Event) with no side-effecty parameters, your tests are pure functions of event sequences. Aim for 100% transition-coverage and 100% guard-coverage; both are bounded by the table size.
Determinism, Run-to-Completion, and the Re-entry Trap
A subtle issue: what happens if fsm_handle_event is called while another fsm_handle_event is in progress? For example, an action inside a transition itself dispatches an event:
static void on_button_in_idle(void) {led_on();fsm_handle_event(E_TIMEOUT); // re-entry!state = S_ACTIVE;}
This is dangerous: the inner call modifies state mid-transition, and when the outer call sets state = S_ACTIVE, the inner call's update is lost. The fix is run-to-completion: an event handler must finish entirely before the next event is dispatched. Implementations enqueue events rather than dispatching synchronously — covered in Event-Driven FSMs.
Debugging Story: The Vanishing Transition
A team's table-driven FSM had a bug where (S_RUNNING, E_PAUSE) transitions were "sometimes lost." The transition table looked correct, the state was clearly RUNNING, but the transition didn't fire.
The cause: the table cell was {S_PAUSED, brake_action}, and brake_action() was calling led_off() which (via a callback) dispatched E_TIMEOUT synchronously, which re-entered the FSM and reset state to S_IDLE — which the outer transition then overwrote to S_PAUSED. The inner E_TIMEOUT handling was clobbered.
Fix: switch to a queue-based dispatch (run-to-completion). Action functions enqueue follow-up events; the dispatcher drains the queue one event at a time. After this fix, behavior was deterministic.
The lesson: Synchronous re-entry into FSM dispatch is a footgun. Run-to-completion via event queue is the standard discipline.
What Interviewers Want to Hear
- You can name and implement all three patterns from memory
- You can choose between them based on FSM size and complexity
- You know the trade-offs: switch-case (simple, doesn't scale), table (data-driven, scales medium), function-pointer (rich per-state behavior, scales largest)
- You can describe how to test FSMs deterministically with mock event injection
- You know about transition coverage as a testing target
- You're aware of the run-to-completion / re-entry trap
Interview Focus
Classic Interview Questions
Q1: "Walk me through three ways to implement an FSM in C."
Model Answer Starter: "First, switch-case: one dispatch function with an outer switch on state and an inner switch on event. Best for small FSMs of 5 states or fewer; the optimizer turns the nested switches into a tight jump table. Second, table-driven: a 2D array indexed by [state][event], each cell holding the next state and an action function pointer. Best for medium FSMs where the table is the spec — easy to review and easy to test by iterating cells. Third, function-pointer table: each state is its own function state_X(event), and the dispatcher just calls the current state's function. Best for larger FSMs where each state has rich state-specific behavior, entry/exit actions, or local helpers. The current state is just a function pointer; transitions reassign it."
Q2: "When would you choose table-driven over switch-case?"
Model Answer Starter: "When the FSM has more than 4-5 states or when the transition logic is mostly uniform across states. The table-driven approach makes the transition matrix the spec — you can read it as a 2D table and verify completeness by inspection ('every cell defined?'). Adding a new event just adds a column; adding a new state adds a row; logic doesn't change. It's also the easiest pattern to test — iterate rows × columns and verify each cell's predicted next-state. Switch-case starts to break down past 5 states because adding an event means touching every state's switch block, and the function gets too long to read."
Q3: "How would you test an FSM?"
Model Answer Starter: "FSMs are deterministic by design — given the same starting state and event sequence, you get the same final state and actions. That makes them perfect unit-test targets. The pattern is: reset the FSM to a known state (via fsm_init or a test-only fsm_force_state helper), inject a sequence of events through the public dispatch function, and assert the final state plus that the right actions fired (ideally via mocks that record calls). For transition coverage, iterate every (state, event) pair in a generated test loop. The whole test suite can run in microseconds because nothing waits on hardware — the event source is mocked away."
Q4: "What's run-to-completion semantics, and why does it matter for FSMs?"
Model Answer Starter: "Run-to-completion means a single event is fully processed — including all actions and the state update — before the next event is dispatched. Without it, you can have re-entry bugs: an action inside a transition dispatches another event, which mutates state mid-transition, and when the outer transition's state assignment runs, the inner update is lost. The standard discipline is to enqueue events into a queue and have a single dispatcher loop that takes one event at a time, runs it to completion, then takes the next. This is why event-driven FSMs in RTOS contexts use a task with a message queue rather than dispatching synchronously."
Q5: "How would you implement entry and exit actions in a function-pointer FSM?"
Model Answer Starter: "Two clean approaches. First, wrap state changes in an enter_state(StateFn new) function that runs the current state's exit hook, switches the pointer, then runs the new state's entry hook. The hooks can be stored in a parallel table indexed by state, or as fields in a per-state struct: typedef struct { StateFn handler; void (*on_entry)(void); void (*on_exit)(void); } StateDef;. Second, encode entry/exit actions inside the handler functions themselves — every time state_X is called for the first time, it does its entry work; before returning a new state, it does its exit work. The struct-based approach is cleaner because the discipline is structural, not per-handler."
Trap Alerts
- Don't say: "I'd just use a big switch" — for anything past trivial FSMs, that's a code smell
- Don't forget: Run-to-completion / re-entry — synchronous nested dispatch breaks determinism
- Don't ignore: Total specification — define every (state, event) cell, even if it's "stay/ignore"
Follow-up Questions
- "How would you generate code for a table-driven FSM from a YAML or JSON spec?"
- "What's the memory cost of each pattern for a 10-state, 8-event FSM?"
- "How do you debug an FSM that's stuck in the wrong state?"
- "How do you handle parameter-carrying events (e.g.,
BYTE_RX(byte_value))?" - "Can you implement entry/exit actions in pure switch-case?"
- "When would you use a state-design-pattern (OOP) instead of any of these?"
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
❓ Which FSM pattern is best for a 12-state FSM where each state has rich state-specific behavior?
❓ Why is the table-driven pattern often the easiest to test?
❓ What is 'run-to-completion' semantics in FSMs?
❓ In a 2D transition table fsm_table[state][event], what does an empty/STAY cell typically represent?
❓ Why is the function-pointer FSM pattern often preferred for code-generated state machines from tools like Yakindu?
Real-World Tie-In
UART Protocol Parser — A binary protocol parser is implemented as a table-driven FSM with states WAIT_SOF, READ_LEN, READ_PAYLOAD, READ_CRC. The byte-rx handler dispatches BYTE_RX(value) events; the table accumulates bytes per state and transitions when length is reached. Test suite uses test_byte_sequence("\x7e\x05hello\xab\xcd") style helpers that drive byte sequences and assert final parser output. 100% transition coverage achievable in ~50 generated test cases.
Wearable Charging State Machine — A wearable's charge controller uses function-pointer FSM with states DISCHARGED, CHARGING_FAST, CHARGING_TRICKLE, FULL, FAULT, THERMAL_LIMIT. Each state owns its own peripheral configuration: state_charging_fast configures the PMIC for 1 A current limit; entering state_thermal_limit derates the limit and sets a recovery timer. Entry/exit hooks (via per-state struct) make the configuration changes structural — adding a new charge state means writing one self-contained function.
FSM Generator from YAML — A team defines their device's mode-management FSM as YAML in a modes.yaml file (states, events, transitions, guards). A small Python generator emits a C transition table at build time. Reviewing the YAML diff in PRs is much easier than reviewing the generated C — and changing modes never requires touching dispatch logic, only the YAML.
