State Machines with Function Pointers
A state machine has a finite set of states, a set of events, and transition rules. When an event arrives, the machine moves from its current state to a new one and optionally performs an action. This chapter builds state machines in C with function-pointer dispatch tables, then rebuilds them in Rust with enums and pattern matching.
Why State Machines Matter
Drivers, protocol parsers, network stacks, and user-interface logic are all state machines. A TCP connection goes through LISTEN, SYN_SENT, ESTABLISHED, FIN_WAIT, and more. A UART driver handles IDLE, RECEIVING, ERROR. If you write systems code, you write state machines.
Dispatch Tables: Array of Function Pointers
The simplest state machine is an array of function pointers indexed by state. Each function handles the current state and returns the next state.
/* dispatch_table.c -- state machine via function pointer array */
#include <stdio.h>
typedef enum {
STATE_IDLE,
STATE_RUNNING,
STATE_STOPPED,
STATE_COUNT /* number of states */
} state_t;
typedef enum {
EVENT_START,
EVENT_STOP,
EVENT_RESET,
EVENT_COUNT
} event_t;
typedef state_t (*handler_fn)(void);
state_t on_idle_start(void)
{
printf(" IDLE -> start -> RUNNING\n");
return STATE_RUNNING;
}
state_t on_idle_stop(void)
{
printf(" IDLE -> stop -> (stay IDLE)\n");
return STATE_IDLE;
}
state_t on_idle_reset(void)
{
printf(" IDLE -> reset -> (stay IDLE)\n");
return STATE_IDLE;
}
state_t on_running_start(void)
{
printf(" RUNNING -> start -> (stay RUNNING)\n");
return STATE_RUNNING;
}
state_t on_running_stop(void)
{
printf(" RUNNING -> stop -> STOPPED\n");
return STATE_STOPPED;
}
state_t on_running_reset(void)
{
printf(" RUNNING -> reset -> IDLE\n");
return STATE_IDLE;
}
state_t on_stopped_start(void)
{
printf(" STOPPED -> start -> RUNNING\n");
return STATE_RUNNING;
}
state_t on_stopped_stop(void)
{
printf(" STOPPED -> stop -> (stay STOPPED)\n");
return STATE_STOPPED;
}
state_t on_stopped_reset(void)
{
printf(" STOPPED -> reset -> IDLE\n");
return STATE_IDLE;
}
/* 2D dispatch table: [state][event] -> handler */
static handler_fn dispatch[STATE_COUNT][EVENT_COUNT] = {
[STATE_IDLE] = { on_idle_start, on_idle_stop, on_idle_reset },
[STATE_RUNNING] = { on_running_start, on_running_stop, on_running_reset },
[STATE_STOPPED] = { on_stopped_start, on_stopped_stop, on_stopped_reset },
};
static const char *state_names[] = { "IDLE", "RUNNING", "STOPPED" };
int main(void)
{
state_t current = STATE_IDLE;
event_t events[] = {
EVENT_START, EVENT_STOP, EVENT_RESET, EVENT_START, EVENT_STOP
};
for (int i = 0; i < 5; i++) {
printf("State: %s\n", state_names[current]);
current = dispatch[current][events[i]]();
}
printf("Final state: %s\n", state_names[current]);
return 0;
}
Dispatch table layout:
EVENT_START EVENT_STOP EVENT_RESET
+----------------+----------------+----------------+
IDLE | on_idle_start | on_idle_stop | on_idle_reset |
+----------------+----------------+----------------+
RUNNING | on_run_start | on_run_stop | on_run_reset |
+----------------+----------------+----------------+
STOPPED | on_stop_start | on_stop_stop | on_stop_reset |
+----------------+----------------+----------------+
Lookup: dispatch[current_state][event]() -> next_state
Try It: Add a
STATE_ERRORand anEVENT_ERRORthat transitions from any state toSTATE_ERROR. OnlyEVENT_RESETcan leaveSTATE_ERROR.
Protocol Parser State Machine
A real use case: parsing a simple key=value protocol. Messages look like
KEY=VALUE\n. The parser walks through states as it reads each character.
/* parser_sm.c -- protocol parser state machine */
#include <stdio.h>
#include <string.h>
typedef enum {
PS_KEY, /* reading the key */
PS_VALUE, /* reading the value */
PS_DONE, /* line complete */
PS_ERROR /* malformed input */
} parse_state_t;
struct parser {
parse_state_t state;
char key[64];
char value[256];
int key_len;
int val_len;
};
void parser_init(struct parser *p)
{
p->state = PS_KEY;
p->key_len = 0;
p->val_len = 0;
p->key[0] = '\0';
p->value[0] = '\0';
}
parse_state_t feed_key(struct parser *p, char c)
{
if (c == '=') {
p->key[p->key_len] = '\0';
return PS_VALUE;
}
if (c == '\n' || c == '\r')
return PS_ERROR; /* newline before '=' */
if (p->key_len < 63) {
p->key[p->key_len++] = c;
}
return PS_KEY;
}
parse_state_t feed_value(struct parser *p, char c)
{
if (c == '\n') {
p->value[p->val_len] = '\0';
return PS_DONE;
}
if (p->val_len < 255) {
p->value[p->val_len++] = c;
}
return PS_VALUE;
}
parse_state_t feed_done(struct parser *p, char c)
{
(void)c;
(void)p;
return PS_DONE; /* ignore further input */
}
parse_state_t feed_error(struct parser *p, char c)
{
(void)c;
(void)p;
return PS_ERROR;
}
typedef parse_state_t (*feed_fn)(struct parser *, char);
static feed_fn state_handlers[] = {
[PS_KEY] = feed_key,
[PS_VALUE] = feed_value,
[PS_DONE] = feed_done,
[PS_ERROR] = feed_error,
};
void parser_feed(struct parser *p, char c)
{
p->state = state_handlers[p->state](p, c);
}
int main(void)
{
const char *input = "host=192.168.1.1\n";
struct parser p;
parser_init(&p);
for (int i = 0; input[i] != '\0'; i++)
parser_feed(&p, input[i]);
if (p.state == PS_DONE)
printf("Parsed: key='%s', value='%s'\n", p.key, p.value);
else
printf("Parse error\n");
return 0;
}
State transitions for "host=192.168.1.1\n":
'h' -> PS_KEY
'o' -> PS_KEY
's' -> PS_KEY
't' -> PS_KEY
'=' -> PS_VALUE (key complete: "host")
'1' -> PS_VALUE
'9' -> PS_VALUE
'2' -> PS_VALUE
...
'1' -> PS_VALUE
'\n' -> PS_DONE (value complete: "192.168.1.1")
Driver Prep: Many driver protocols (I2C, SPI, USB) use state machines for packet parsing. The function-pointer-per-state pattern keeps the code modular: each state handler is a small, testable function.
Event-Driven Design
In event-driven systems, a main loop reads events and dispatches them to the current state handler. This decouples event sources from state logic.
/* event_driven.c -- event loop with state machine */
#include <stdio.h>
#include <string.h>
typedef enum { ST_OFF, ST_ON, ST_COUNT } state_t;
typedef enum { EV_PRESS, EV_TIMER, EV_COUNT } event_t;
typedef struct {
state_t state;
int press_count;
} context_t;
typedef state_t (*handler_fn)(context_t *ctx);
state_t off_press(context_t *ctx)
{
ctx->press_count++;
printf(" [OFF] Button pressed (#%d) -> ON\n", ctx->press_count);
return ST_ON;
}
state_t off_timer(context_t *ctx)
{
(void)ctx;
printf(" [OFF] Timer tick -> (stay OFF)\n");
return ST_OFF;
}
state_t on_press(context_t *ctx)
{
ctx->press_count++;
printf(" [ON] Button pressed (#%d) -> OFF\n", ctx->press_count);
return ST_OFF;
}
state_t on_timer(context_t *ctx)
{
(void)ctx;
printf(" [ON] Timer tick -> (stay ON)\n");
return ST_ON;
}
static handler_fn dispatch[ST_COUNT][EV_COUNT] = {
[ST_OFF] = { off_press, off_timer },
[ST_ON] = { on_press, on_timer },
};
void process_event(context_t *ctx, event_t ev)
{
ctx->state = dispatch[ctx->state][ev](ctx);
}
int main(void)
{
context_t ctx = { .state = ST_OFF, .press_count = 0 };
/* Simulate an event stream */
event_t events[] = { EV_TIMER, EV_PRESS, EV_TIMER, EV_PRESS, EV_PRESS };
for (int i = 0; i < 5; i++)
process_event(&ctx, events[i]);
printf("Final press count: %d\n", ctx.press_count);
return 0;
}
Try It: Add a
ST_BLINKstate entered by pressing the button while ON. A timer tick in BLINK goes back to ON. A press in BLINK goes to OFF.
Rust: enum + match
Rust's enums with data (algebraic data types) and exhaustive pattern matching are a natural fit for state machines. The compiler verifies you handle every state.
// state_machine.rs -- state machine with enum + match #[derive(Debug, Clone, Copy, PartialEq)] enum State { Idle, Running, Stopped, } #[derive(Debug, Clone, Copy)] enum Event { Start, Stop, Reset, } fn transition(state: State, event: Event) -> State { match (state, event) { (State::Idle, Event::Start) => { println!(" IDLE -> Start -> RUNNING"); State::Running } (State::Running, Event::Stop) => { println!(" RUNNING -> Stop -> STOPPED"); State::Stopped } (State::Running, Event::Reset) => { println!(" RUNNING -> Reset -> IDLE"); State::Idle } (State::Stopped, Event::Start) => { println!(" STOPPED -> Start -> RUNNING"); State::Running } (State::Stopped, Event::Reset) => { println!(" STOPPED -> Reset -> IDLE"); State::Idle } (s, e) => { println!(" {:?} -> {:?} -> (no change)", s, e); s } } } fn main() { let mut state = State::Idle; let events = [ Event::Start, Event::Stop, Event::Reset, Event::Start, Event::Stop, ]; for &event in &events { println!("State: {:?}", state); state = transition(state, event); } println!("Final state: {:?}", state); }
Rust Note: If you add a new variant to the
Stateenum and forget to handle it in thematch, the compiler refuses to build. In C, adding a new state to the enum but forgetting to add a row to the dispatch table compiles fine and crashes at runtime with a NULL function pointer call.
Protocol Parser in Rust
The same key=value parser, but using Rust enums.
// parser_sm.rs -- protocol parser with enum states #[derive(Debug)] enum ParseState { Key, Value, Done, Error(String), } struct Parser { state: ParseState, key: String, value: String, } impl Parser { fn new() -> Self { Parser { state: ParseState::Key, key: String::new(), value: String::new(), } } fn feed(&mut self, c: char) { self.state = match &self.state { ParseState::Key => { if c == '=' { ParseState::Value } else if c == '\n' || c == '\r' { ParseState::Error("newline before '='".into()) } else { self.key.push(c); ParseState::Key } } ParseState::Value => { if c == '\n' { ParseState::Done } else { self.value.push(c); ParseState::Value } } ParseState::Done => ParseState::Done, ParseState::Error(msg) => ParseState::Error(msg.clone()), }; } fn result(&self) -> Option<(&str, &str)> { match &self.state { ParseState::Done => Some((&self.key, &self.value)), _ => None, } } } fn main() { let input = "host=192.168.1.1\n"; let mut parser = Parser::new(); for c in input.chars() { parser.feed(c); } match parser.result() { Some((k, v)) => println!("Parsed: key='{}', value='{}'", k, v), None => println!("Parse error: {:?}", parser.state), } }
Notice that the Rust Error variant carries a message. Rust enums can hold
data per variant -- C enums cannot.
Try It: Extend the Rust parser to handle multiple key=value pairs separated by newlines. After each
Donestate, reset toKeyand collect results into aVec<(String, String)>.
Why This Matters for Drivers
Device drivers are inherently state machines. A block device goes through initialization, ready, busy, and error states. A network driver manages link negotiation states. An interrupt handler transitions a UART between idle, receiving, and transmitting.
The C function-pointer dispatch table maps directly to how the kernel structures driver state machines. The Rust enum + match approach is how the kernel's Rust abstractions will express the same logic with compile-time exhaustiveness checking.
Typical driver state machine:
init_hw() ready_for_io()
PROBE ----------> INIT --------------> READY
| | ^
| error | | io_complete()
v v |
ERROR <--- error --- BUSY
|
| reset()
v
PROBE (retry)
Driver Prep: When you write a driver, draw the state diagram first. Enumerate every state and every event. Then implement it as a dispatch table (C) or enum + match (Rust). Missing transitions become obvious on paper before they become bugs in production.
Knowledge Check
- What advantage does a 2D dispatch table
[state][event]have over a largeswitchstatement with nestedswitches? - In the Rust parser, why do we use
std::mem::replaceto take ownership of the current state before matching on it? - How does Rust's exhaustive match checking prevent a class of bugs that C dispatch tables are vulnerable to?
Common Pitfalls
- Uninitialized dispatch table entries -- in C, a missing entry is a NULL pointer. Calling it is undefined behavior. Always initialize every cell or add a bounds check.
- Forgetting to handle "no transition" -- some state/event combinations should be no-ops. Make this explicit, not accidental.
- State explosion -- too many states and events make the table unwieldy. Decompose into hierarchical state machines if the table exceeds about 5x5.
- Side effects in transition logic -- keep handlers pure when possible. Separate "compute next state" from "perform action" for testability.
- Rust: forgetting
std::mem::replace-- if you match on&self.stateyou cannot move data out of variants. The take-and-replace idiom is standard for owned state machines.