DEV Community 👩‍💻👨‍💻

Cover image for Embedded Rust & Embassy: UART Serial Communication
Omar.unwrap();
Omar.unwrap();

Posted on • Updated on

Embedded Rust & Embassy: UART Serial Communication

This blog post is the second of a multi-part series where I will explore various peripherals of the STM32 microcontroller using the embedded Rust embassy framework. In this post, I will be exploring the usage of UART.

If you find this post useful, and to keep up to date with similar future posts, here's the list of channels you can follow/subscribe to:

Introduction

Setting up UART serial communication is useful for any type of device-to-device (point-to-point) communication. On of the common examples is printing output to a PC. In this post, I will be configuring and setting up UART communication with a PC terminal using embassy for an STM32 device. I will build on the application from the GPIO button-controlled blinking post to print to the output how many times a GPIO button has been pressed.

📝 For those interested in seeing how embassy compares to other HAL frameworks, you can check out how I created the same application using the stm32f4xx-hal here.

📚 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.
  • Familiarity with UART communication basics.

💾 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.

In addition to the above, you would need to install some sort of serial communication terminal on your host PC. Some recommendations include:

For Windows:

For Mac and Linux:

Apart from Serial Studio, some detailed instructions for the different operating systems are available in the Discovery Book.

For me, Serial Studio comes highly recommended. I personally came across Serial Studio recently and found it to be awesome for two main reasons. First is that you can skip many of those instructions for other tools, especially in Mac and Linux systems. Second, if you are you want to graph data over UART, it has a really nice and easy-to-configure setup. It's also open-source and free to use.

🛠 Hardware Setup

Materials

Nucleo Board

🔌 Connections

There will be no need for external connections. On-board connections will be utilized and 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 input.
  • The UART Tx line that connects to the PC through the onboard USB bridge is via pin PA2 on the microcontroller. This is a hardwired pin, meaning you cannot use any other for this setup. Unless you are using a different board other than the Nucleo-F401RE, you have to check the relevant documentation (reference manual or datasheet) to determine the number of the pin.

👨‍🎨 Software Design

For the purposes of this post, there isn't much to change from an algorithmic perspective compared to the last post. However, the tasks done would differ in one of the state. Meaning, I will still cycle through several LED blinking frequencies based on a button press but do something different in the button press state. As such, the application algorithm representation looks the same:

State Machine

Although this can be reflected on the chart, the button press behavior can be contained in the button press state. Consequently, the button press state behaviour looks something like this:

Flow Chart

The difference is that now there is a count maintained every time we enter the button press state. Also before exiting the button press state, the count is transmitted over UART.

Let's now jump into implementing this algorithm.

👨‍💻 Code Implementation

📝 Setting up an Embassy Project:

Although I followed the embassy documentation to set up my first project, it wasn't all smooth sailing. Issues mainly had to do with configurations/settings of configuration (toml) files. It wasn't all too clear what had to be done without needing to ask. I figure this has to do with embassy still being considered unstable. If using the STM32F401RE-Nucleo, the easiest path would be to clone the project from the apollolabsdev git repo.

📥 Crate Imports

In this implementation the crates required are as follows:

  • The core::fmt::Write crate will allow us to use the writeln! macro for easy printing.
  • The core::sync::atomic crate to import the Atomic sharing type.
  • The embassy_executor crate to import the embassy embedded async/await executor.
  • The embassy_time crate to import timekeeping capabilities.
  • The embassy_stm32 crate to import the embassy STM32 series microcontroller device hardware abstractions. The needed abstractions are imported accordingly.
  • The panic_halt crate to define the panicking behavior to halt on panic.
use core::fmt::Write;
use core::sync::atomic::{AtomicU32, Ordering};
use embassy_executor::Spawner;
use embassy_stm32::dma::NoDma;
use embassy_stm32::exti::ExtiInput;
use embassy_stm32::gpio::{AnyPin, Input, Level, Output, Pin, Pull, Speed};
use embassy_stm32::usart::{Config, UartTx};
use embassy_time::{Duration, Timer};
use panic_halt as _;
Enter fullscreen mode Exit fullscreen mode

🌍 Global Variables

This part remains the same as before. The AtomicU32 type is used to safely share the delay value BLINK_MS among several tasks:

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

💡 The LED Blinking Task

This task is the one that manages the LED blinking (switches between the LED blinking states). It is a task that is spawned by the main task in the device config/setup phase, ahead of the main task entering its continuous loop. As indicated in the software design section, the only changes in the code affect the button press state. The steps taken previously in this task included:

  1. Create a blinking task and handle for the LED GPIO pin.
  2. Define the task loop.

As such, nothing needs to be changed here and the code remains the same as before:

#[embassy_executor::task]
async fn led_task(led: AnyPin) {
    // Configure the LED pin as a low-speed output and obtain a handler
    // Initialize the LED output to Low
    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();
    }
}
Enter fullscreen mode Exit fullscreen mode

📱 The Main Task (Button Press Task)

🎛 Peripheral Configuration & Task Spawning

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 indicated before, the main task will be where we manage the button press and its count. The following steps will mark the tasks performed in the main task:

1️⃣ Initialize MCU and obtain a handle for the device peripherals: A device peripheral handler p is created:

 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. A button handle for the input button is created 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. Using the EXTI external interrupt input driver and knowing from the datasheet that PC13 attaches to EXTI13, the interrupt is attached as follows:

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 also provide an initial value for the delay and update it to the global context:

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

Then led_task is kicked off using the spawn method:

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

5️⃣ Configure UART and obtain handle: Looking into the Nucleo-F401RE board pinout, the Tx line pin PA2 connects to the USART2 peripheral in the microcontroller device. As such, this means we need to configure USART2 and somehow pass it to the handle of the pin we want to use. Under embassy_stm32::usart::UartTx there exists a new method to configure UART with the following signature:

pub fn new(
    _inner: impl Peripheral<P = T> + 'd,
    tx: impl Peripheral<P = impl TxPin<T>> + 'd,
    tx_dma: impl Peripheral<P = TxDma> + 'd,
    config: Config
) -> Self
Enter fullscreen mode Exit fullscreen mode

Where inner expects an argument passing in a UART peripheral instance, tx a Pin instance for the UART Tx pin, tx_dma an instance for a DMA channel, and config a Config configuration struct to configure UART.

This results in the following line of code:

let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());
Enter fullscreen mode Exit fullscreen mode

Here USART2 is passed since it corresponds in the STM32F401 to pin PA2 which is also passed as a second parameter. PA2 is also the pin that connects to the onboard USB bridge.

Finally, I create a value variable to track the number of counts, and a String type msg handle to store the formatted text that will be transmitted over UART.

let mut value: u8 = 0;
let mut msg: String<8> = String::new();
Enter fullscreen mode Exit fullscreen mode

Next, we can move on to the application Loop.

📝 UART with Interrupts:

The UART can actually be configured easily with interrupts. This would enable us to later use await with the UART handle. However, in embassy, it seems to require that UART is attached to a DMA channel an interrupt is going to be attached. Setting up DMA in embassy turns out to be quite smooth actually. THough I leave the DMA topic to a different post.

🔁 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:

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

Next, the counter message is prepared using the writeln! macro, and the message is transmitted using the blocking_write method from embassy_stm32::usart::UartTx:

        core::writeln!(&mut msg, "{:02}", value).unwrap();
        usart.blocking_write(msg.as_bytes()).unwrap();
Enter fullscreen mode Exit fullscreen mode

📝 Notice that here that this is a blocking write for UART. This means that this line will block operations, not allowing any others to execute until it completes. For non-blocking UART, there is an async method, however it requires that interrupts are configured for UART.

Finally, the push button counter value is recorded by incrementing value and msg is cleared for the next transmit.

        value = value.wrapping_add(1);
        msg.clear();
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::fmt::Write;
use core::sync::atomic::{AtomicU32, Ordering};

use heapless::String;

use embassy_executor::Spawner;
use embassy_stm32::dma::NoDma;
use embassy_stm32::exti::ExtiInput;
use embassy_stm32::gpio::{AnyPin, Input, Level, Output, Pin, Pull, Speed};
use embassy_stm32::usart::{Config, UartTx};
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 low -speed output and obtain a handler
    // Initialize LED output to Low
    // 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);

    //Configure UART
    let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Config::default());

    // 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();

    // Variable to keep track of how many button presses occured
    let mut value: u8 = 0;

    // Create empty String for message
    let mut msg: String<8> = String::new();

    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);

        // Format Message
        core::writeln!(&mut msg, "{:02}", value).unwrap();

        // Transmit Message
        usart.blocking_write(msg.as_bytes()).unwrap();

        // Update Value Parameter
        value = value.wrapping_add(1);

        // Clear String for next message
        msg.clear();
    }
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

In this post, a UART-based application was created using Rust on the Nucleo-F401RE development board. All code was created leveraging the embassy framework for STM32. It is also shown in this post how global variables can be created and shared among tasks. It still remains that leveraging embassy provides for a smoother sail compared to other existing approaches. Have any questions/comments? Share your thoughts in the comments below 👇.

If you find this post useful, and to keep up to date with similar future posts, here's the list of channels you can follow/subscribe to:

Top comments (0)

Stop sifting through your feed.

Find the content you want to see.

Change your feed algorithm by adjusting your experience level and give weights to the tags you follow.