How to dance with embedded Rust generics
As part of my work on Grid Bot “Tahi”, I finally figured out how to make the code for my robot re-usable as a library. Since to do this I needed to go on a deep journey into understanding Rust generic types, I thought I might share my learnings.
Disclaimer ⚠️§
As a quick disclaimer: This post is not a basic introduction to embedded Rust. For a basic introduction to embedded Rust, see the embedded Rust Bookshelf for resources.
This post is instead about solving a niche problem: how to create a re-usable library where users (developers) can setup their own hardware devices (LEDs, linear axes, spindles, relays, etc) and then use a command language (like G-Code) to control those devices, all in an Rust-y embedded-y device-agnostic type-safe heap-free way.
The embedded Rust world 🗺️§
Before we begin, here’s a quick recap of the embedded Rust world:
- Rust can compile to any
target
supported by LLVM, so most everything. - You can tell Rust to be in “
#[no_std]
” mode and your code will not load the standard library (std
) or data structures that depend on heap allocations (alloc
).- To use data structures that depend on heap allocations (
alloc
), you can then BYO (bring your own) allocator, such asembedded-alloc
.
- To use data structures that depend on heap allocations (
- Each processor architecture usually has a Rust crate (module) for low-level access to the processor
- Each chip family has a “Peripheral Access Crate” for low level control of peripherals
- Hardware manufacturers provide an SVD file (System View Description), which define how the hardware’s (magic) memory addresses are mapped to peripheral registers,
svd2rust
converts these to a type-safe Rust interface, so you can only use the registers in a safe way. - For example, the
stm32-rs
for STM32 microcontrollers,nrf-pacs
for nRF microcontrollers
- Hardware manufacturers provide an SVD file (System View Description), which define how the hardware’s (magic) memory addresses are mapped to peripheral registers,
- Each chip family then also has a “Hardware Abstraction Layer” crate for higher level control of peripherals
- To provide a foundation for building device-agnostic hardware drivers,
embedded-hal
provides Traits (abstract interfaces) for (most) hardware abstractions. - Each each device has their own
xxx-hal
which provides the specific hardware implementations for these abstract interfaces. - For example, I’m using a Nucleo-F767ZI, which is supported by
stm32f7xx-hal
. - Another example is the
ESP32-C3
supported byes32c3-hal
- To provide a foundation for building device-agnostic hardware drivers,
(For an in-depth adventure into porting Rust to a chip, see Rust on the CH32V003)
In our quest, we will be building something device-agnostic using the generic HAL (Hardware Abstraction Layer) traits. We won’t need to worry much about the lower level details, things just work.
A device-agnostic Led
interface 🟢§
So let’s say we want to create a device-agnostic (non-blocking) interface for an LED connected to your micro-controller. Here’s how we might do this:
1 | use core::{fmt::Debug, task::Poll}; |
For a simple example there’s a lot happening, especially if you are new to Rust!
LedAction
is an enum we will use to tell the LED how to update.LedError
is an object we will use to represent any error.- This receives one generic type,
PinError
(which implements theDebug
trait), since we don’t know the specific type of error a hardware pin might return. - This also uses the
#[derive(...)]
macro to automatically derive the traitsCopy
,Clone
, andDebug
. Note: We can only derive these traits if all the types within this object also implement the trait. This is why we must explicitly say thatPinError
must implement theDebug
trait.
- This receives one generic type,
Led
is a struct we will use as our LED abstraction, like a class in other languages.- For the methods,
- The
new
method is our constructor for creating a new LED. - The
run
method receives our action telling the LED how to update, and update our LED abstraction’s internal state (but not yet updating the external hardware).- This method returns nothing (which is by default the empty tuple
()
).
- This method returns nothing (which is by default the empty tuple
- The
poll
method will update the external hardware as needed to match the internal state.- This method returns a
Poll
(which can be eitherPending
orReady(value)
) of aResult
(which can be eitherOk(value)
orErr(error)
) of either a empty value()
or an errorP::Error
(the associated typeError
, attached to theOutputPin
trait).
- This method returns a
- The
- For the types, this receives one generic type
P
, which implementsOutputPin
(provided by theembedded-hal
library). We also specify thatP::Error
(the associated typeError
, attached to theOutputPin
trait) implementsDebug
.
- For the methods,
An dummy GpioA
struct to impl OutputPin
👤§
Now if you’re curious, here’s what a dummy struct that implements the OutputPin
trait would look like.
1 | use embedded_hal::digital::v2::{OutputPin, PinState}; |
Note: This won’t do anything!
In the real-world, these structs affect registers on the hardware and are provided by your device’s xxx-hal
library, almost certainly generated with a macro.
An example top-level entry 🔝§
To set the stage, let’s show how we might call our Led
.
An example fn main()
you can run on a normal PC, using our dummy GpioA
:
1 | fn main() { |
An example embedded entry for a Nucleo-F767ZI:
(Assuming knurling-rs/app-template
as a starting point.)
1 |
|
A more detailed Led
using a timer ⏲️§
If you’re wondering why there’s a difference between run
and poll
, let’s change our LED abstraction so we can also tell an LED to blink for a specific amount of time. Since we can’t do a blocking sleep
, you’ll see why poll
is designed to be non-blocking.
1 | use core::fmt::Debug; |
Oh gosh that’s a mouthful!
LedState
is an enum with either:Set
with a booleanis_on
Blink
with astatus
(LedBlinkStatus
) and aduration
LedBlinkStatus
is eitherStart
,Wait
, orDone
.
- In
Led
, we’re using a state machine:- The current
LedState
is stored inself.state
. - For the methods:
- On
run
, we receive anLedAction
and updateself.state
, but don’t do anything to affect the LED pin or timer. - On
poll
,- If the current state is
None
, we’re done and we return (send a message to whoever polled us) that we’re done (Poll::Ready(Ok(()))
) - If the current state is
Some(LedState::Set {})
, we set the LED to the desired on/off state. Then, we reset the state toNone
and return that we’re done (Poll::Ready(Ok(()))
). - If the current state is
Some(LedState::Blink {})
, the method handles the blinking action based on the currentLedBlinkStatus
:- If the status is
Start
, it turns the LED on, starts the timer with the given duration, updates the status toWait
, and returnsPoll::Pending
, indicating it’s still waiting. - If the status is
Wait
, it checks the timer:- If the timer returns an error, it returns
Poll::Ready(Err(LedError::TimerWait(err)))
, propagating the error. - If the timer is not done and returns “would block”, it returns
Poll::Pending
, indicating it’s still waiting. - If the timer is done, it updates the status to
Done
and returnsPoll::Pending
.
- If the timer returns an error, it returns
- If the status is
Done
, it turns the LED off, resets the state toNone
, and returnsPoll::Ready(Ok(()))
, indicating it’s done.
- If the status is
- In any of the steps mentioned above, if we get an erro, we send the error upwards
return Poll::Ready(Err(LedError))
.
- If the current state is
- On
- For the types, this receives three generics:
- A type
P
, which implementsOutputPin
(provided by theembedded-hal
library). We also specify thatP::Error
(the associated typeError
, attached to theOutputPin
trait) implementsDebug
. - A constant
TIMER_HZ
, which describes the frequency of the timer. - A type
T
, which implementsTimer<TIMER_HZ>
(provided by thefugit-timer
library). We also specify thatT::Error
implementsDebug
.
- A type
- The current
I hope that makes some sense.
And for completion, here’s an example embedded entry for a Nucleo-F767ZI:
(Assuming knurling-rs/app-template
as a starting point.)
1 |
|
For the rest of the post, I’ll be assuming the original, simpler Led
struct.
A Runner
to control multiple Led
🚥§
Now say we want to control multiple LEDs together.
We will create a Runner
that can receive a command, delegate that command to the associated LED, and poll all active commands until completion.
To start, we know there will be three LEDs: a green LED, a blue LED, and a red LED.
1 | use alloc::collections::VecDeque; |
Woah, okay!
Command
is an enum to represents any action we might want to send to any LED.CommandError
is an enum to represent any error that might happen with any LED.Runner
is the struct to manage our three LEDs (green, blue, and red).- We store a list of active commands (
active_commands
) and anLed
object for each color. - For the methods:
new
: Creates a newRunner
object, taking green, blue, and red LEDs as inputs.run
: Takes aCommand
as input, performs the action for the specified LED color, and adds the command to the list of active commands.poll
: Checks the progress of each command in the list:- If a command is done, it removes the command from the list.
- If a command is not done, it keeps the command in the list.
- If there’s an error, it returns the error and puts the command back in the list.
- We store a list of active commands (
Note: In this code I’m using
alloc::collections::VecDeque
, to make things easier. If we want collections without usingalloc
, I recommend the crateheapless
, so here we’d useheapless::Deque
.
In a nutshell, this code manages a set of LEDs, allowing you to turn them on or off by adding commands to a list, and it checks the progress of these commands. If you run into any issues, it will handle the errors for each LED color.
Problem #1: Generic type hell 😈§
Now here’s the point where I can finally start to explain why I had such a hard time to make the code for my robot re-usable as a library.
Let’s go back to Runner
’s generic types: GreenPin
, BluePin
, and RedPin
.
1 | pub struct Runner<GreenPin, BluePin, RedPin> |
Now, this looks okay (when you’ve become accustomed to generic types), but this is only considering our original Led
struct that only needs a P: OutputPin
. In our later Led
struct, we need two more generics: const TIMER_HZ: u32
and T: Timer<TIMER_HZ>
.
Imagine we needed more:
1 | pub struct Runner< |
Yuck. 🤮
The problem with these generic types is that anything that consumes a struct also need to provide their generic types. This becomes a sort of “generic hell”, where we can’t escape generic types just bubbling up to the consumer’s code and beyond.
The solution to generic type hell is traits.
So let’s go!
Solution #1: Traits to the rescue 😇§
Beyond the generic type hell problem, we want our system to support multiple types of hardware interfaces that affect the real world.
trait Actuator
💪§
We create an Actuator
trait to generalize our use of hardware interfaces (while still being device agnostic).
1 | use core::task::Poll; |
As an aside, a Rust expert might say that
run
should return aFuture
, which we canpoll
. I chose this design because such a future would need a mutable reference to the hardware peripherals (and Rust has strict rules about object ownership, references, and mutability) and at the time I wanted to avoid using allocations (Rc
). If this is an achievable change for my current code base, I’d love to learn how, every day is a school day.
impl Actuator for Led
🟢§
Now to update Led
:
1 | use core::{fmt::Debug, task::Poll}; |
The code is similar, except now we’re implementing the Actuator
trait instead of implementing those methods directly on the struct.
Same Runner
, different generics 🚥§
And now we can improve the Runner
struct:
1 | pub struct Runner<GreenLed, BlueLed, RedLed> |
By using a trait, we’re able to hide the generics of the implementing struct and instead focus our generics on the structs we need.
We are saying: Runner
will receive three types (named GreenLed
, BlueLed
, and RedLed
), where each type must implement the Actuator
trait where the associated type Action
is LedAction
. Those three types correspond to a struct, and in this case correspond to the Led
struct, but we didn’t specify so as to be generic.
Now the definition of Runner
doesn’t “leak” the generic types of Led
.
For completeness:
1 | use alloc::collections::VecDeque; |
However we still have the problem where the shape of 3 LEDs: green, blue, and red, is pre-defined.
Note: If you’re wondering why we need to be so specific with our
Command
enum, ourrun
match, and ourpoll
match: it’s because we’re avoiding heap allocations and dynamic objects. We can only use static types, so Rust knows the size of every struct at compile time, and objects are created on the stack. This approach, while extra boilerplate-y, is very efficient.
Problem #2: Pre-defined shapes 😈§
If you want to use my Runner
as written above, sure the use of generic types meant you can use any device, and now the use of traits means generic types don’t leak upward. But you better have only 3 LEDs, and you better want them to be named “Green”, “Blue”, and “Red”.
Generic types were helpful to be device-agnostic, but at the same time didn’t help us be shape-agnostic.
To solve this, I discovered another trick, while still avoiding alloc
.
Solution #2: User defined shapes 😇§
What if, in all the places that we hard-code GreenLed
, BlueLed
, and RedLed
, we could allow the user to give us something which did those things?
Let’s make a new trait for this:
trait ActuatorSet
⛶⛶⛶§
1 | pub trait ActuatorSet { |
Here we design a way to store a set of actuators for a single type (e.g. LED).
And then the user can defined their own ActuatorSet
as follows:
impl LedSet for ActuatorSet
🚥§
1 |
|
Runner<LedSet>
🏃§
And now our runner can be re-written to receive this actuator set:
1 | use alloc::collections::VecDeque; |
Now we only need to pre-define the types of actuators (e.g. LED, axis, spindle, relay, etc), not the names of the actuators. The user will give us something that defines the names. This is good!
If you’re still wondering why we still need all this boilerplate, it’s because we’re still avoiding heap allocations (alloc
) and dynamic objects. Our code still only uses static objects where Rust knows the size of the objects at compile time, everything can be included on the stack, and everything is very efficient.
Depending on your situation, you could stop here. We solved the problems of generic hell and pre-defined shapes. We did all this while being maximally efficient for embedded devices.
But for my next trick, I’ll break these guarantees for the sake of a more ergonomic developer experience.