) -> Result<()> {
// ...
}
This isn't:
fn read_button(gpio: &GpioPin) -> bool {
// ...
}
This allows us to enforce whether code should or should not make changes to hardware at compile time, rather than at runtime. As a note, this generally only works across one application, but for bare metal systems, our software will be compiled into a single application, so this is not usually a restriction.
Rust's type system prevents data races at compile time (see Send and Sync traits). The type system can also be used to check other properties at compile time; reducing the need for runtime checks in some cases.
When applied to embedded programs these static checks can be used, for example, to enforce that configuration of I/O interfaces is done properly. For instance, one can design an API where it is only possible to initialize a serial interface by first configuring the pins that will be used by the interface.
One can also statically check that operations, like setting a pin low, can only be performed on correctly configured peripherals. For example, trying to change the output state of a pin configured in floating input mode would raise a compile error.
And, as seen in the previous chapter, the concept of ownership can be applied to peripherals to ensure that only certain parts of a program can modify a peripheral. This access control makes software easier to reason about compared to the alternative of treating peripherals as global mutable state.
The concept of typestates describes the encoding of information about the current state of an object into the type of that object. Although this can sound a little arcane, if you have used the Builder Pattern in Rust, you have already started using Typestate Programming!
pub mod foo_module {
#[derive(Debug)]
pub struct Foo {
inner: u32,
}
pub struct FooBuilder {
a: u32,
b: u32,
}
impl FooBuilder {
pub fn new(starter: u32) -> Self {
Self {
a: starter,
b: starter,
}
}
pub fn double_a(self) -> Self {
Self {
a: self.a * 2,
b: self.b,
}
}
pub fn into_foo(self) -> Foo {
Foo {
inner: self.a + self.b,
}
}
}
}
fn main() {
let x = foo_module::FooBuilder::new(10)
.double_a()
.into_foo();
println!("{:#?}", x);
}
In this example, there is no direct way to create a Foo object. We must create a FooBuilder, and properly initialize it before we can obtain the Foo object we want.
This minimal example encodes two states:
• FooBuilder, which represents an "unconfigured", or "configuration in process" state
• Foo, which represents a "configured", or "ready to use" state.
Because Rust has a Strong Type System, there is no easy way to magically create an instance of Foo, or to turn a FooBuilder into a Foo without calling the into_foo() method. Additionally, calling the into_foo() method consumes the original FooBuilder structure, meaning it can not be reused without the creation of a new instance.
This allows us to represent the states of our system as types, and to include the necessary actions for state transitions into the methods that exchange one type for another. By creating a FooBuilder, and exchanging it for a Foo object, we have walked through the steps of a basic state machine.
The peripherals of a microcontroller can be thought of as set of state machines. For example, the configuration of a simplified GPIO pin could be represented as the following tree of states:
• Disabled
• Enabled
• Configured as Output
• Output: High
• Output: Low
• Configured as Input
• Input: High Resistance
• Input: Pulled Low
• Input: Pulled High
If the peripheral starts in the Disabled mode, to move to the Input: High Resistance mode, we must perform the following steps:
1. Disabled
2. Enabled
3. Configured as Input
4. Input: High Resistance
If we wanted to move from Input: High Resistance to Input: Pulled Low, we must perform the following steps:
1. Input: High Resistance
2. Input: Pulled Low
Similarly, if we want to move a GPIO pin from configured as Input: Pulled Low to Output: High, we must perform the following steps:
1. Input: Pulled Low
2. Configured as Input
3. Configured as Output
4. Output: High
Typically the states listed above are set by writing values to given registers mapped to a GPIO peripheral. Let's define an imaginary GPIO Configuration Register to illustrate this:
Name | Bit Number(s) | Value | Meaning | Notes |
---|---|---|---|---|
enable | 0 | 0 | disabled | Disables the GPIO |
1 | enabled | Enables the GPIO | ||
direction | 1 | 0 | input | Sets the direction to Input |
1 | output | Sets the direction to Output | ||
input_mode | 2..3 | 00 | hi-z | Sets the input as high resistance |
01 | pull-low | Input pin is pulled low | ||
10 | pull-high | Input pin is pulled high | ||
11 | n/a | Invalid state. Do not set | ||
output_mode | 4 | 0 | set-low | Output pin is driven low |
1 | set-high | Output pin is driven high | ||
input_status | 5 | x | in-val | 0 if input is < 1.5v, 1 if input >= 1.5v |