DEV Community

Cover image for Embedded Rust & Embassy: GPIO Button Controlled Blinking
Omar Hiari
Omar Hiari

Posted on • Updated on

Embedded Rust & Embassy: GPIO Button Controlled Blinking

This blog post is the first one of a multi-part series of posts where I will explore various peripherals of the STM32 microcontroller using the embedded Rust embassy framework.

If you find this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Introduction

Until recently, I've found out that I had a misconception of what embassy is in relation to embedded Rust. Along with various internet descriptions I came along, the official description states that: "Embassy is a project to make async/await a first-class option for embedded development". As such, I thought embassy was a framework similar to the idea of RTIC but for async. Though it turns out that embassy is much more than that. As a matter of fact, what I found out is that part of embassy is a HAL implementation that is more of a well-rounded HAL compared to other HALs I've dealt with thus far. What do I mean by that? Ideally, a HAL layer would be as portable as possible. However, in the case of embedded-hal HALs, although they were portable to an extent, the way they were designed made them more specific to particular families of devices. For example, the stm32f4xx family had a separate HAL compared to the stm32f3xx or other stm32 families. Instead, with embassy, there's actually one stm32 HAL that rules them all.

In this blog post series, I'm going to experiment with different stm32f4xx peripherals using embassy. I will be working with the STM32F401 microcontroller to present and walk through examples for different peripherals. I will be exclusively working at the embassy-stm32 HAL level. In this post, I will be starting out with the GPIO peripheral. We'll see how we can configure GPIO, read inputs, and manipulate output. The example here is a bit more advanced version of blinky.

📝 Its worth noting that at the time of writing this post, embassy still leverages nightly features of Rust. This means that a stable version of embassy has not been released yet and is sort of still experimental. Nevertheless, the amount of features embassy provides is quite advanced in nature. Also as of now, the embassy HAL support is available only the stm32 and nrf devices.

📚 Knowledge Pre-requisites

To understand the content of this post, you need the following:

  • Basic knowledge of coding in Rust.
  • Knowledge of async/await and Futures.
  • Familiarity with the basic template for creating embedded applications in Rust.
  • Familiarity with interrupts in Cortex-M processors.

💾 Software Setup

All the code presented in this post in addition to instructions for the environment and toolchain setup are available on the apollolabsdev Nucleo-F401RE git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.

🛠 Hardware Setup

Materials

Nucleo Board

🔌 Connections

There will be no need for external connections. On-board connections will be utilized and the include the following:

  • LED is connected to pin PA5 on the microcontroller. The pin will be used as an output.
  • User button connected to pin PC13 on the microcontroller. The pin will be used as an input.

👨‍🎨 Software Design

In the application developed in this post, I will to cycle through several LED blinking frequencies based on a button press. Meaning, that every time I press the on-board button, I want to see the LED turning on and off at a different rate. This is similar to past posts I've done both with and without interrupts using the stm32f4xx-hal. At that time, I left the interrupt-based approach for a separate post given that implementing it was more involved. In this post, with embassy, I will be using interrupts from the get-go. In fact, what will become obvious, is that implementing interrupts in embassy is nothing but a breeze.

For algorithm design purposes, I will assume that interrupts are configured already which will be detailed in the configuration steps in the implementation section. The algorithm representation remains the same to what I've done in the stm32f4xx-hal interrupts post:

State Machine

As before, the application will start in a state where the LED is turned on (or off). In this state, a timer would also be started. While in the "LED on" state two events could happen to make the system transition to another state; either a button press or a timer delay expiration. If the delay expires the application would transition to an "LED off" state that turns off the LED and resets the delay timer. On the other hand, if a button press is detected the application would transition to a "Button Pressed" state. The "Button Pressed" state would in turn adjust the amount of timer delay and then return to the state it transitioned from. The "LED off" state has exactly the same transition conditions as the "LED on" state.

Note an interesting difference here is that we will be servicing an interrupt without really having the traditional service routine. Instead, there is this executor that will be running in the background that will be informed of the interrupt occurring and execute code in an async block accordingly. As such, we'll create code with two async tasks, one that awaits on a delay to change the LED state and one that awaits on a button press to change the delay state. I will explain more in the implementation section. Also, more detail on the executor can be found here.

Let's now jump into implementing this algorithm.

👨‍💻 Code Implementation

📝 Although I followed the documentation to set up my first project, it wasn't all smooth sailing. It was mainly had to do with configurations/settings of configuration (toml) files. I figure this has to do with embassy still being considered to be experimental. Set up I managed to get working is available up in my git repo.

📥 Crate Imports

In this implementation the crates required are as follows:

  • The core::sync::atomic crate to import the Atomic sharing type.
  • The embassy_executor crate to import the embassy embedded async/await executor.
  • The panic_halt crate to define the panicking behavior to halt on panic.
  • The embassy_stm32 crate to import the embassy STM32 series microcontroller device hardware abstractions.
use core::sync::atomic::{AtomicU32, Ordering};
use embassy_executor::Spawner;
use embassy_stm32::exti::ExtiInput;
use embassy_stm32::gpio::{AnyPin, Input, Level, Output, Pin, Pull, Speed};
use embassy_time::{Duration, Timer};
use panic_halt as _;
Enter fullscreen mode Exit fullscreen mode

🌍 Global Variables

In the application at hand, there will be two tasks that share a delay value. The button press detection task will adjust the delay and the LED control task will use it. As such, I create a global variable BLINK_MS to carry the delay value that I'm going to pass around. Here I use the AtomicU32 type which is an integer type that can be safely shared between threads. The AtomicU32 type has the same in-memory representation as the underlying integer type, u32 but is considered safe to share between threads.

static BLINK_MS: AtomicU32 = AtomicU32::new(0);
Enter fullscreen mode Exit fullscreen mode

As such, I would need to create a global shared variable to access the GPIO peripheral (remember singleton pattern).

💡 The LED Blinking Task

Ahead of defining the main task, it would be beneficial to define the LED blinking task as it would be kicked off/spawned by the main task. The LED task is expected to accept a GPIO pin as input and loop forever toggling the pin with a certain delay.

1️⃣ Create a blinking task and handle for the LED: Tasks are marked by the #[embassy_executor::task] macro followed by a async function implementation. The task created is referred to as led_task defined as follows:

#[embassy_executor::task]
async fn led_task(led: AnyPin)
Enter fullscreen mode Exit fullscreen mode

AnyPin marks a generic pin type and will allow us to configure the pin in the task. This means we need to obtain a handle for the LED pin and configure it to an output. As earlier stated, the on-board LED on the Nucleo-F401RE is connected to pin PA5 (Pin 5 Port A). As such, we need to create a handle for the LED pin that has PA5 configured to an output using the gpio::Output gpio output driver. In the documentation, we can find that gpio::Output has a new constructor method with the following signature:

pub fn new(
    pin: impl Peripheral<P = T> + 'd,
    initial_output: Level,
    speed: Speed
) -> Self
Enter fullscreen mode Exit fullscreen mode

Where pin expects an argument of type Pin passing in one of the Peripheral pins, initial_output expects an initial output level setting selected through the Level enum, and speed expects a pin speed selected through the Speed enum. Given this information, we will name the handle led and configure it as follows:

let mut led = Output::new(p.PA5, Level::High, Speed::Low);
Enter fullscreen mode Exit fullscreen mode

In addition to new, this documentation page has the full list of methods that the Output driver supports.

2️⃣ Define the task loop: Next enter the task loop. First we load the blink delay value from the global context as follows:

let del = BLINK_MS.load(Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

Then we establish a timer delay using the following code:

Timer::after(Duration::from_millis(del_var.into())).await;
Enter fullscreen mode Exit fullscreen mode

Timer comes from the embassy_time crate. after is a Timer instance method that accepts a Duration and returns a Future. As such, await allows us to yield execution to the executor such that the task can be polled later to check if the delay expired.

Once the delay expires, the LED is toggled using the toggle method for the Output type:

led.toggle();
Enter fullscreen mode Exit fullscreen mode

Here's the full loop code:

    loop {
        let del = BLINK_MS.load(Ordering::Relaxed);
        Timer::after(Duration::from_millis(del.into())).await;
        led.toggle();
    }

Enter fullscreen mode Exit fullscreen mode

📱 The Main Task (Button Press Task)

The start of the main task is marked by the following code:

#[embassy_executor::main]
async fn main(spawner: Spawner) 
Enter fullscreen mode Exit fullscreen mode

As the documentation states: "The main entry point of an Embassy application is defined using the #[embassy_executor::main] macro. The entry point is also required to take a Spawner argument." As we'll see, Spawner is what will allow us to spawn or kick off led_task.

As indicated before, the main task will also be the one we manage the button press in. The following steps will mark the tasks performed in the main task.

1️⃣ Initialize MCU and obtain a handle for the device peripherals: As part of the embassy-stm32 crate an init function exists at the crate level with the following signature:

pub fn init(config: Config) -> Peripherals
Enter fullscreen mode Exit fullscreen mode

The init function takes a Config configuration struct parameter and returns a Peripherals struct instance. The Config parameter allows us to configure the RCC and whether to enable debug during sleep. The documentation states that this function is to "Initialize embassy". Looking into the source code, what the init function does is initialize device system clocks in addition to a variety of peripherals including timers, dma, and gpio. Here I create a peripheral handler named p as follows:

 let p = embassy_stm32::init(Default::default());
Enter fullscreen mode Exit fullscreen mode

Here we're just passing the default value for the Config type.

2️⃣ Obtain a handle and configure the input button: The on-board user push button on the Nucleo-F401RE is connected to pin PC13 (Pin 13 Port C) as stated earlier. Similar to the Output, there exists an Input driver with a new constructor with the following signature:

pub fn new(pin: impl Peripheral<P = T> + 'd, pull: Pull) -> Self
Enter fullscreen mode Exit fullscreen mode

Where pin here is same as before with Output and pull expects an internal resistor setting (pull-up, pull-down, or none) selected through the Pull enum. As such, we end up with the following line of code:

let button = Input::new(p.PC13, Pull::None);
Enter fullscreen mode Exit fullscreen mode

3️⃣ Attach the interrupt to the input button:
At this point, the button instance is just an input pin. In order to make the device respond to push button interrupts, button needs to be attached to one of the device's external interrupt inputs. The embassy-stm32 crate has an exti external interrupt module that has an EXTI external interrupt input driver. Like Input and Output, EXTI also has a new constructor function that has the following signature:

pub fn new(
    pin: Input<'d, T>,
    _ch: impl Peripheral<P = T::ExtiChannel> + 'd
) -> Self
Enter fullscreen mode Exit fullscreen mode

pin expects an Input instance and ch expects an ExtiChannel which is one of the Peripheral exti channels. In this case, ch would be the exti channel that attaches to PC13 which is EXTI13. This results in the following line of code:

let mut button = ExtiInput::new(button, p.EXTI13);
Enter fullscreen mode Exit fullscreen mode

4️⃣ Publish Delay Value and Spawn LED Blinking Task: before entering the button press loop, we're going to need to kick off our LED blinking task. Though ahead of that, we need to provide an initial value for the delay and update it to the global context as follows:

    let mut del_var = 2000;
    BLINK_MS.store(del_var, Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

Then led_task can be kicked off using the spawn method as follows:

    spawner.spawn(led_task(p.PA5.degrade())).unwrap();
Enter fullscreen mode Exit fullscreen mode

Note that degrade() is a method that, well, "degrades" the pin type to AnyPin that the task parameter requires as observed earlier.

Next, we can move on to the application Loop.

🔁 Main Task Loop

Following the design described earlier in the state machine, the button press will be the only event that is interrupt-based. However, using embassy, events from interrupts or otherwise are managed through Futures. This means that execution can be yielded to allow other code to progress until the event attached to a Future occurs. There is an executor that manages all of this in the background and more detail about it is provided in the documentation.

Given that, in the main task loop all we need to do is wait for a button press to occur. This is done through the following line:

button.wait_for_rising_edge().await;
Enter fullscreen mode Exit fullscreen mode

wait_for_rising_edge is an ExtiInput method that returns a Future as well. This is a Future that is attached to the interrupt event of a button press. This can be read as the task yielding execution to the executor until a rising edge occurs on the button.

This means that once a button press occurs, the code following the above line will execute. The following code adjusts the delay value and updates the global context in the following lines:

del_var = del_var - 300_u32;
   if del_var < 500_u32 {
       del_var = 2000_u32;
   }
BLINK_MS.store(del_var, Ordering::Relaxed);
Enter fullscreen mode Exit fullscreen mode

This concludes the code for the full application.

📱 Full Application Code

Here is the full code for the implementation described in this post. You can additionally find the full project and others available on the apollolabsdev git repo.

#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use core::sync::atomic::{AtomicU32, Ordering};
use embassy_executor::Spawner;
use embassy_stm32::exti::ExtiInput;
use embassy_stm32::gpio::{AnyPin, Input, Level, Output, Pin, Pull, Speed};
use embassy_time::{Duration, Timer};
use panic_halt as _;

static BLINK_MS: AtomicU32 = AtomicU32::new(0);

#[embassy_executor::task]
async fn led_task(led: AnyPin) {
    // Configure the LED pin as a push pull ouput and obtain handler.
    // On the Nucleo FR401 theres an on-board LED connected to pin PA5.
    let mut led = Output::new(led, Level::Low, Speed::Low);

    loop {
        let del = BLINK_MS.load(Ordering::Relaxed);
        Timer::after(Duration::from_millis(del.into())).await;
        led.toggle();
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // Initialize and create handle for devicer peripherals
    let p = embassy_stm32::init(Default::default());

    // Configure the button pin (if needed) and obtain handler.
    // On the Nucleo FR401 there is a button connected to pin PC13.
    let button = Input::new(p.PC13, Pull::None);
    let mut button = ExtiInput::new(button, p.EXTI13);

    // Create and initialize a delay variable to manage delay loop
    let mut del_var = 2000;

    // Publish blink duration value to global context
    BLINK_MS.store(del_var, Ordering::Relaxed);

    // Spawn LED blinking task
    spawner.spawn(led_task(p.PA5.degrade())).unwrap();

    loop {
        // Check if button got pressed
        button.wait_for_rising_edge().await;

        // If button pressed decrease the delay value
        del_var = del_var - 300;
        // If updated delay value drops below 300 then reset it back to starting value
        if del_var < 500 {
            del_var = 2000;
        }
        // Publish updated delay value to global context
        BLINK_MS.store(del_var, Ordering::Relaxed);
    }
}

Enter fullscreen mode Exit fullscreen mode

👨‍⚖️ The Verdict

From a first look, what is probably already obvious is that the code is much less verbose. It takes much less code to achieve the same thing in embassy. Tasks we'd do in non-embassy HALs including taking and configuring the peripherals, setting up the system clocks, and promoting the pac-level structures are all done in a single line here. Attaching timers are also a breeze. There are no ISRs, no interrupt enabling, or NVIC configuration. Finally, when it comes to using timers, the built-in timer abstraction provides for a really convenient way to set up delays.

In summary, from a first look, and aside from async, embassy seems to provide a smoother approach and frankly lower entry barrier to embedded Rust than what I've encountered thus far. Granted that I haven't delved too deep into embassy yet, it took me a while to acquaint myself with thinking async. I figure as I experiment further I am going to encounter more challenges. Nevertheless, embassy still seems rough around the edges as it continues to develop towards a more stable release. I found various gaps in the documentation where I needed to ask to get an answer, setting up the environment was a bit of a hassle, and I did have issues trying to debug. At times, although I got some application code to work just fine, during debug I saw strange behavior that didn't really add up. I figure embassy still has some work to be done to prove its viability from various aspects so it can become a mainstream choice.

Conclusion

In this post, a GPIO interrupt-based LED control application was created for the STM32F401RE microcontroller on the Nucleo-F401RE development board. All code was created leveraging the embassy framework HAL for stm32. It can be seen that leveraging embassy provides for a smoother sail compared to existing approaches. Still, embassy is not yet in a phase as stable as existing solutions. Additionally, embassy leverages Rust's async framework which implies that embedded application programmers need to adjust their thinking approach. Have any questions/comments? Share your thoughts in the comments below 👇.

If you found this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Top comments (0)