Programming an ARM microcontroller in Rust at four different levels of abstraction

Home | Archive

This content was originally meant to be presented as part of my workshop on Rust and microcontrollers at the NITC FOSSMeet 2018. As I was not able to cover it satisfactorily during the workshop, I thought of writing it down!

Repository with all the example programs demonstrated here.

Thanks to Jorge Aparicio for his amazing code (and blog posts!) without which there would be nothing to write about. Please read this blog post by Jorge to understand more about the principles behind the design of the I/O framework which we will be using here.

Why Rust on Microcontrollers?

Here are some of the motivating factors:

We can use the considerably more sophisticated type system (compared to C) of Rust, together with ideas like ownership and move semantics, to encode properties of peripherals statically in the type of the variables representing those peripherals in our code, thereby catching certain kind of errors at compile time itself (we will see examples later in this post).

We can write low-level code and still have the luxury of using high-level abstractions like sum types, closures/iterators, generics, traits etc with very little or no run time overhead.

Memory safety, data-race-free concurrency, no undefined behaviours (in safe mode) are attractive to embedded systems programmers as well as application developers!

The powerful trait system, together with an excellent package manager (cargo) and a central repository for crates (libraries), opens up the possibility of creating highly re-usable code.

Level 1: Direct register access in an unsafe block

Please refer this article for toolchain setup instructions. You will need to install st-flash additionally.

You can see the code here. It is very similar to the kind of code you would write in C. The code will put on LED’s connected to PE9 and PE11.

Level 2: No unsafe blocks, use the API provided by svd2rust

I have written about svd2rust in this article. Here is the code written in this style:

fn main() { let p = Peripherals::take().unwrap(); let gpioe = p.GPIOE; let rcc = p.RCC; rcc.ahbenr.modify(|r, w| w.iopeen().set_bit()); gpioe.moder.write(|w| w.moder11().output().moder9().output()); gpioe.bsrr.write(|w| w.bs9().set().bs11().set()); loop {} }

This is an improvement over code in Level 1 - we do not use hardcoded addresses, no unsafe blocks and no tricky bit manipulations! You can see the full program here.

Look at this line:

gpioe.moder.write(|w| w.moder11().output().moder9().output());

When the closure is evaluated, initial value of w is the reset state of the MODE register (this register sets the pin mode); writing w.moder11.output() will extract the two mode register bits corresponding to pin 11 and will give them the value which configures that pin as an output; this is then chained with moder9().output() which will properly set the bits corresponding to pin 9 so that that pin is configured as an output. After the closure is evaluated, the write function will simply store whatever bit pattern is present in w to the MODE register. It is obvious that this is better than direct register manipulation with bit-twiddling!

A problem

What if we had written:

gpioe.moder.write(|w| w.moder9().output()); gpioe.bsrr.write(|w| w.bs9().set().bs11().set());

We are writing to pin 11 without configuring it as an output pin! This will not have the desired result. Unfortunately, it is hard to prevent errors like this coding at this level.

Level 3: The embedded HAL to the rescue!

Let’s now use the higher-level abstractions provided by stm32f30x-hal to rewrite the code!

Here is the re-written program:

let p = stm32f30x::Peripherals::take().unwrap(); let mut rcc = p.RCC.constrain(); let mut gpioe = p.GPIOE.split(&mut rcc.ahb); let mut pe9: PE9<Output<PushPull>> = gpioe.pe9.into_push_pull_output(&mut gpioe.moder, &mut gpioe.otyper); let mut pe11: PE11<Output<PushPull>> = gpioe.pe11.into_push_pull_output(&mut gpioe.moder, &mut gpioe.otyper); pe9.set_high(); pe11.set_high();

The key idea here is the pe9 and pe11 are types which represent pins configured as push-pull outputs. The set_high function is available only on such pins.

What if you do something like:

let mut pe8: PE8<Input<Floating>> = gpioe.pe8; pe8.set_high();

A GPIO pin starts out as an input; in that state, you can’t call the function set_high. It is a compile time error!

What about this?

let mut pe8: PE8<Input<Floating>> = gpioe.pe8; let mut pe8_1: PE8<Output<PushPull>> = gpioe.pe8.into_push_pull_output(&mut gpioe.moder, &mut gpioe.otyper);

A GPIO pin can’t exist in two states (Input and Output) at the same time. Rust move semantics guarantees that you get a compile time error in the above case (gpioe.pe8 gets moved to the variable pe8; you can no longer call gpioe.pe8.push_pull_output()).

Here is a similar situation:

let mut pe9: PE9<Output<PushPull>> = gpioe.pe9.into_push_pull_output(&mut gpioe.moder, &mut gpioe.otyper); let mut pe9_1 = pe9.into_floating_input(&mut gpioe.moder, &mut gpioe.pupdr); pe9.set_high();

Invoking pe9.into_floating_input results in pe9 getting moved into the function; it is no longer accessible to us; we can’t write pe9.set_high.

Ownership and move semantics guarantees that there is a single representation for a peripheral in our program.

The complete program is given here.

Level 4: Use a board support crate

A board support crate knows how the peripherals in the microcontroller are connected to various devices (LED’s, switches, sensors etc). That makes it very easy for us to write code without having to know about pin connections.

fn main() { let p = stm32f30x::Peripherals::take().unwrap(); let mut rcc = p.RCC.constrain(); let gpioe = p.GPIOE.split(&mut rcc.ahb); let mut leds = Leds::new(gpioe); for led in leds.iter_mut() { led.on(); } }

The program lights up all the 8 LED’s on the STM32F3Discovery board! Note that we are not specifying any pin connections in the code.

The full program is given here.