Many firmware bugs are actually invalid state transitions. Rust helps by making state explicit and hard to misuse.
I model each controller state as an enum variant with transition functions that consume the old state and return the next one. This prevents accidental mutation paths.
For asynchronous events, I queue typed commands and process them in one control loop. That keeps timing behavior predictable and testable.
The payoff is long-term maintainability: adding a new mode forces compiler-visible updates instead of hidden branching side effects.
A minimal enum state machine
Here is the shape I reach for first. The states are variants, events are a separate enum, and the transition function takes self by value so the old state is consumed and cannot be reused by accident.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Idle,
Heating { target_c: i16 },
Fault,
}
#[derive(Debug, Clone, Copy)]
enum Event {
Start { target_c: i16 },
Reached,
Overtemp,
Reset,
}
impl State {
fn step(self, ev: Event) -> State {
match (self, ev) {
(State::Idle, Event::Start { target_c }) => State::Heating { target_c },
(State::Heating { .. }, Event::Reached) => State::Idle,
(State::Heating { .. }, Event::Overtemp) => State::Fault,
(State::Fault, Event::Reset) => State::Idle,
// Any transition I did not explicitly allow stays put.
(other, _) => other,
}
}
}
Because step moves self, the calling code has to write state = state.step(ev);. There is no way to mutate a stale copy or forget to reassign. The match on the (state, event) tuple also makes the full transition table visible in one place, and adding a new state or event turns any missing arm into something I can grep for and reason about.
In a control loop I drain queued events and fold them through step:
for ev in inbox.drain() {
state = state.step(ev);
}
Enum vs. compile-time typestate
The enum approach checks transitions at runtime: an invalid (state, event) pair simply falls through to a default arm. That is usually what I want on a device, where events arrive from the outside world and I cannot let a bad message panic the loop.
When I want the compiler to reject an invalid transition, I use the typestate pattern instead: each state is a distinct type, and only the legal transitions exist as methods.
struct Idle;
struct Heating { target_c: i16 }
impl Idle {
fn start(self, target_c: i16) -> Heating {
Heating { target_c }
}
}
impl Heating {
fn reached(self) -> Idle {
Idle
}
}
Here Idle has no reached method, so idle.reached() will not compile. The trade-off is that typestate encodes the state in the type, so you cannot hold a State in a plain variable or send it through a [State; N] queue without erasing it back to an enum. I use typestate for driver initialization sequences where the transitions are known at compile time, and the enum for anything driven by runtime events.
Both patterns are fully no_std-friendly: they are just enums and structs with no allocation, so the same code runs on a microcontroller as on the host test bench.
Sources
- Imported from the previous version of the site (gaborl.hu). Original publication around 2024-10-03.
- The Embedded Rust Book — typestate programming
- statig — a hierarchical state machine crate for Rust, including
no_stduse.
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.