Harnessing Rust's Typesystem for Reliable State Machines in Embedded Systems

Sjors de Wit
April 6, 2023
Last updated: Apr 16, 2023

State machines are a powerful tool for creating reliable firmware, but they often have a reputation for being cumbersome and difficult to debug. However, with the Rust language, implementing state machines can be a painless and reliable process. In this post, we’ll explore how to use state machines in Rust and show how the language’s advanced features make it an ideal choice for building robust firmware. We’ll also delve into some advanced techniques for state machine implementation that can take your code to the next level.

This blog post is based on a talk I gave at the Rust meetup "Embedded Rust" on march 9, 2023 at TU Delft.
A recording of the talk + slides is available on youtube.

Writing robust firmware is important

Firmware running in an embedded application generally has to meet higher standards than a typical desktop application. Especially if your product

  • is placed in a very remote location (manually resetting may not be possible)
  • requires years of uptime (perhaps on limited battery power)
  • is in charge of an important physical process

For this blog let’s use a 3D printer as an example. This is a typical application where reliability and robustness is important because it is usually working unattended for an extended time.

The implicit state machine

A big part of creating reliable firmware is ensuring we can’t accidentally reach an unexpected state. A 3D printer needs to control a set of actuators. Therefore, as an example, I have written the (simplified) motor driver below. There are just two properties: a target_position and a flag target_reached to indicate the position is reached.

This driver has a very simple interface. You give it a desired position using set_target_pos. Then you repeatedly poll update() until the motor (or some sensor) gives the signal that the target has been reached.

pub struct MotorDriver {
target_position: Option<f32>,
pub target_reached: bool,
}

impl MotorDriver {

// Assume this is polled at regular interval
pub fn update(&mut self) {

// Position reached? Nothing to do!
if self.target_reached {
return;
}

// Is there a target? try to reach it
if let Some(position) = self.target_position {
self.target_reached = try_move_to(position);
}
}

pub fn set_target_pos(&mut self, new_target: f32) {
self.target_position = Some(new_target);
self.target_reached = false;
}
}

Note that this code is a bit fragile: target_position and target_reached must always be updated together. Forget to update target_reached, and the driver is stuck in a state where the position is never applied!
What has happened is that we have accidentally created an implicit state machine with poorly defined states. Lets rewrite update() a bit to expose those states using Rust match syntax:

pub fn update(&mut self) {
match (self.target_position, self.target_reached) {
// Init: no target set
(None, false) => {}

// Illegal state: which target was reached??
(None, true) => {}

// Done: already at target position
(Some(_pos), true) => {}

// Try to reach target position
(Some(pos), false) => {
self.target_reached = motor_move_to(pos);
}
}
}

This already looks more like a state machine, doesn’t it? The code does exactly the same as before, but now it’s clear that some state combinations are not really meaningful. For example getting the target reached signal without a target being set. That shouldn’t happen.
Of course you can write your code in such a way that thse ‘illegal’ states are never reached. But it would be nice to let the compiler help us with that.

Naming the program states explicitly

Continuing on the previous example, let’s make the states more explicit. We can replace the individual properties in the MotorDriver struct with an enum.

pub struct MotorDriver {
state: MotorState,
}

pub enum MotorState {
Idle,
Moving(f32),
PositionReached(f32),
}

Now we only have three states instead of four: the implicit ‘illegal’ state can no longer be expressed: The compiler will guarantee that the motor is always either Idle, Moving, or PositionReached!
The implementation will now look like this:

impl MotorDriver {
pub fn update(&mut self) {
match self.state {

// Nothing to do
MotorState::Idle => {}
MotorState::PositionReached(_pos) => {}

// Try to reach target position
MotorState::Moving(pos) => {
let reached: bool = motor_move_to(pos);
if reached {
self.state = MotorState::PositionReached(pos)
}
}
}
}

pub fn set_target_pos(&mut self, new_target: f32) {
self.state = MotorState::Moving(new_target);
}
}

To me, this is much nicer than the code we started with: the states are clearly described and the implementation of set_target_pos cannot accidentally cause us to end up in an invalid state.

Why use an explicit state machine?

  1. Reliability
    State machines are a valuable tool to create highly reliable firmware. By eliminating unexpected states, you can be confident about what your software will do: all scenarios are explicitly described!

  2. You probably already use one anyways
    If you try to avoid implementing a state machine, you may up with an implicit state machine as we have just seen: you might as well convert it to a proper one!

  3. A joy to implement in Rust
    The Rust language is very suitable for implementing state machines. Its match' statement is much more powerful than for example switch` in C/C++, and both states and even state transitions can be fully validated at compile-time.

So far we have only just touched the surface of the possibilities Rust gives us to write robust state machines. The rest of this blog post will show some more advanced techniques that we often use.

Event-based state transitions

At least as important as the states themselves are the transitions between them. Especially for larger state machines it may become hard to track which state transitions are (supposed to be) possible. That is why we also want to explicitely define and constrain the state transitions. We do this based on events that can occur.

In this example, we show what a simple top level state machine for the 3D printer might look like. The graph shows one arrow for each possible event. For example, only the Start event can get the printer from Idle state to Printing.

Let’s model this in Rust:

pub enum State {
Idle,
Printing,
Error,
}

Now we can design the usual state machine by matching on the State and setting the new state. But if we directly manipulate the state everywhere, how do we prevent accidentally making an unwanted state transition? This is where the events come in:

pub enum Event {
NothingHappens,
Start,
Finish,
Error
}

Instead of directly manipulating the State, your code can return an Event that describes a reason for updating the state. The code emitting the Event can be completely separate from the state machine and does not even have to know about state transitions. State updates can be centralized in one place as shown below:

impl State {
pub fn next(self, event: Event) -> Self {
match (self, event) {
// Idle
(State::Idle, Event::NothingHappens) => State::Idle,
(State::Idle, Event::Start) => State::Printing,
(State::Idle, Event::Finish) => State::Error,

// Printing
(State::Printing, Event::NothingHappens) => State::Printing,
(State::Printing, Event::Start) => State::Error,
(State::Printing, Event::Finish) => State::Idle,

// Wildcard match: Error event -> Error state
(_, Event::Error) => State::Error,

// Error: no way out of this state..
(State::Error, _) => State::Error,
}
}
}

Rust proves at compile time that all combinations are covered, even if you use wildcard matches: no runtime surprises! You just have to remember not to mutate the state anywhere else except in this next() function.
Admittedly, this can become quite a long, boring list. Especially with larger state machines. But at least it is easy to verify.

TypeState pattern: compile-time state transitions

Rust allows us to go one step further: its strict type system can be used to lock down the possible state transitions at compile-time.
This pattern is sometimes referred to as the TypeState Pattern (you may have used it if you are familiar with the Builder pattern).

// Note the private field:
// Can only be constructed in the right state!
pub struct Printer {
_dummy: (),
}

impl Printer {
pub fn print_pill(&mut self) {
// Actually start printing a pill
todo!()
}
}

pub struct IdlePrinter {}
impl IdlePrinter {

// Only IdlePrinter can create a Printer
pub fn start_printing(self) -> Printer {
Printer { _dummy: () }
}
}

Note that in this code example, a Printer instance can only be constructed through an IdlePrinter.
If you try to manually construct a Printer struct, the compiler won’t allow you: its _dummy property is private!
If we use these structs in a state machine, the compiler effectively guarantees that transitions from Error to Printing are impossible:

pub enum State {
Idle(IdlePrinter),
Printing(Printer),
Error,
}

impl State {
pub fn next(self) -> Self {
match self {
State::Idle(idle_printer) => {
let printer: Printer = idle_printer.start_printing();
State::Printing(printer)
},
State::Printing(_printer) => {
// Oops (note how the printer goes out of scope)
State::Error
},
State::Error => {
// Can we get to printing state? Let's try..
State::Printing(Printer { _dummy: () }) // (won't compile)
},
}
}
}

Note that next() consumes self and returns a new instance.
Only State::Idle has an IdlePrinter instance that can create a Printer which is necessary to enter State::Printing.
Once State::Error is reached, there is no printer instance anymore: transitions to the Idle and Printing state are therefore impossible!

TypeState + Events

Of course the TypeState and Event patterns can even be combined if you want:

pub enum State {
Idle(IdlePrinter),
Printing(Printer),
Error,
}

pub enum Event {
Start,
Finish,
Error
}

impl State {
pub fn next(self, e: Event) -> Self {
match (self, e) {
(State::Idle(idle_printer), Event::Start) => {
State::Printing(idle_printer.start_printing())
},

(State::Printing(printer), Event::Finish) => {
State::Idle{ IdlePrinter{}}
},

(State::Error, Start) => {
// Wont compile:
State::Printing(Printer { _dummy: () })
},

// Demo: anything else -> error (careful with wildcards)
(_, _) => State::Error,
}
}
}

This complexity is usually only worth it for larger or very critical state machines.

Conclusion

In this blog post, we explored the benefits and techniques of using Rust to implement state machines for embedded systems. Rust’s powerful type system and match statements provide a solid foundation for developing reliable, robust firmware with well-defined states and transitions.

If you have read this far, I hope to have convinced you that state machines don’t have to be scary. Rust is very suitable for this task: knowing the compiler has your back makes it quite satisfying.
Does this mean that I always use a TypeState-based state machine? No. Often an enum + match statement will do the job. In the end, it is a trade-off: which pattern best suits your application?

If you have any questions about this topic, feel free to contact us at rust@jitter.company.