DEV Community

Cover image for STM32F4 Embedded Rust at the HAL: UART Serial Communication
Omar Hiari
Omar Hiari

Posted on • Updated on

STM32F4 Embedded Rust at the HAL: UART Serial Communication

This blog post is the third one of a multi-part series of posts where I explore various peripherals in the STM32F401RE microcontroller using embedded Rust at the HAL level. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.

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

Setting up UART serial communication is useful for any type of device-to-device (point-to-point) communication. One of the common past use cases for UART was in development to print output to a PC. However, for that particular use case, nowadays some microcontrollers have advanced debug features like instruction trace macrocell (aka ITM) that don't leverage the device's own peripheral resources. Obviously this does not make UART obsolete, as it has other use cases and some controllers don't support advanced debug to start with. In this post, I will be configuring and setting up UART communication with a PC terminal for the Nucleo-F401RE board. I will be leveraging the GPIO button-controlled blinking project from a previous post to print to the console how many times the button has been pressed. Additionally, I will not be using interrupts and the example will be set up as a simplex system that transmits in one direction only (towards the PC).

Knowledge Pre-requisites

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

  • Basic knowledge of coding in Rust.
  • Familiarity with the basic template for creating embedded applications in Rust.
  • 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:

Some installation instructions for the different operating systems are available in the Discovery Book.

Hardware Setup

Materials

Nucleo

Connections

There will be no need for external connections. On-board Nucleo-F401RE 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 an 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

The application in this post adopts the same algorithmic approach as my previous post. Here, however, I will be adding two things:

  1. I will be leveraging the debouncr crate (yes, that's how the crate name is spelled 😀) to eliminate the effect of button debouncing.
  2. I will be sending/printing a value that tracks the number of times the button is pressed to the PC terminal.

The updated flow diagram would look as follows:

Flow Chart

Code Implementation

Crate Imports

In this implementation, the following crates are required:

  • The cortex_m_rt crate for startup code and minimal runtime for Cortex-M microcontrollers.
  • The core::fmt crate will allow us to use the writeln! macro for easy printing.
  • The debouncr crate to debounce the button press.
  • The panic_halt crate to define the panicking behavior to halt on panic.
  • The stm32f4xx_hal crate to import the STMicro STM32F4 series microcontrollers device hardware abstractions on top of the peripheral access API.
use core::fmt::Write; 
use cortex_m_rt::entry;
use debouncr::{debounce_3, Edge};
use panic_halt as _;
use stm32f4xx_hal::{
    pac::{self},
    prelude::*,
    serial::{Config, Serial},
};
Enter fullscreen mode Exit fullscreen mode

Peripheral Configuration Code

LED & Button GPIO Configuration:

1️⃣ Obtain a handle for the device peripherals: In embedded Rust, as part of the singleton design pattern, we first have to take the PAC level device peripherals. This is done using the take() method. Here I create a device peripheral handler named dp as follows:

let dp = pac::Peripherals::take().unwrap();
Enter fullscreen mode Exit fullscreen mode

2️⃣ Promote the PAC-level GPIO structs: We need to configure the LED pin as a push-pull output and obtain a handler for the pin so that we can control it. We also need to obtain a handle for the button input pin. According to the connection details, the LED pin connection is part of GPIOA and the button connection is part of GPIOC. Before we can obtain any handles for the LED and the button we need to promote the pac-level GPIOA and GPIOC structs to be able to create handles for individual pins. We do this by using the split() method as follows:

let gpioa = dp.GPIOA.split();
let gpioc = dp.GPIOC.split();
Enter fullscreen mode Exit fullscreen mode

3️⃣ Obtain a handle for the LED 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 a push-pull output using the into_push_pull_output() method. We will name the handle led and configure it as follows:

let mut led = gpioa.pa5.into_push_pull_output();
Enter fullscreen mode Exit fullscreen mode

For those interested, this HAL documentation page has the full list of methods that the Pin type supports. Also, if you find the split() method confusing, please refer to my blog post here for more detail.

4️⃣ 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. Pins are configured to an input by default so when creating the handle for the button we don't call any special methods.

let button = gpioc.pc13;
Enter fullscreen mode Exit fullscreen mode

Note that as opposed to the LED output, the button handle here does not need to be mutable since we will only be reading it.

Serial Communication Configuration:

1️⃣ Configure the system clocks: The system clocks need to be configured as they are needed in setting up the UART peripheral. To set up the system clocks we need to first promote the RCC struct from the PAC and constrain it using the constrain() method (more detail on the constrain method here) to give use access to the cfgr struct. After that, we create a clocks handle that provides access to the configured (and frozen) system clocks. The clocks are configured to use an HSE frequency of 8MHz by applying the use_hse() method to the cfgr struct. The HSE frequency is defined by the reference manual of the Nucleo-F401RE development board. Finally, the freeze() method is applied to the cfgr struct to freeze the clock configuration. Note that freezing the clocks is a protection mechanism by the HAL to avoid the clock configuration changing during runtime. It follows that the peripherals that require clock information would only accept a frozen Clocks configuration struct.

let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();
Enter fullscreen mode Exit fullscreen mode

🚨 Important Note:

Using a frequency different than 8 MHz for HSE on the Nucleo-F401RE board will cause the UART to output erroneous characters. This value needs to be adjusted to what the individual board settings are.

2️⃣ Obtain a handle and configure the serial transmit (Tx) pin: Since the Tx button is PA2, earlier I had already created a handle for gpioa that I have to leverage. However, now that we are not using the pin as a regular GPIO input or output it means that the pin needs to be connected to a different peripheral internal to the microcontroller. The pin can be configured as such using the into_alternate() method as follows.

let tx_pin = gpioa.pa2.into_alternate();
Enter fullscreen mode Exit fullscreen mode

3️⃣ Configure the serial peripheral channel: 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. To configure/instantiate the serial peripheral channel we have two options. The first is to use the device peripheral handle dp to directly access USART2 and instantiate a transmitter instance using the tx method from the serial extension traits. The second is to use the tx method in the Serial abstraction struct to instantiate a transmitter instance. Note that both are different ways of doing exactly the same thing!

For the first option, if we examine the tx method signature in the serial extension traits, it looks like this:

fn tx<TX, WORD>(
    self,
    tx_pin: TX,
    config: impl Into<Config>,
    clocks: &Clocks
) -> Result<Tx<Self, WORD>, InvalidConfig>
Enter fullscreen mode Exit fullscreen mode

The method takes three parameters, a pin instance, a configuration, and a frozen Clocks instance reference. As such, we can create a handle tx for the UART transmitter as follows:

    let mut tx = dp
        .USART2
        .tx(
            tx_pin,
            Config::default()
                .baudrate(115200.bps())
                .wordlength_8()
                .parity_none(),
            &clocks,
        )
        .unwrap();
Enter fullscreen mode Exit fullscreen mode

tx_pin and clocks are the handles that we created earlier. Config is a type struct that contains the configuration information needed for configuring the UART peripheral. Here I am creating an instance of Config with the default trait first to configure default parameters. After that, I apply the baudrate, wordlength_8, and parity_none methods to configure the UART peripheral to the settings I need. A full list of Config methods can be found here. I configured the UART settings as shown to 115200 bps baud with 8 bits of data, and no parity, also commonly referred to as 8N1. Finally, since the tx method returns a result, we would have to unwrap it using the unwrap method.

🚨 Important Note:

To figure out what the default configuration in the Config struct entailed, I had to always go into the source code of the default trait implementation. Unfortunately, the HAL documentation itself does not make it easily obvious what the default configuration is.

Alternatively, the second option using the Serial abstraction looks like this:

let mut tx = Serial::tx(
      dp.USART2,
      tx_pin,
      Config::default()
                .baudrate(115200.bps())
                .wordlength_8()
                .parity_none(),
      &clocks,
      )
    .unwrap();
Enter fullscreen mode Exit fullscreen mode

You can see that the main difference here is that tx is applied as an instance method on the Serial struct. It can be observed here that the tx method also accepts a fourth parameter here which is an instance of the UART peripheral USART2. This can be observed in the signature of the tx instance method in the documentation which looks as follows:

pub fn tx(
    usart: USART,
    tx_pin: TX,
    config: impl Into<Config>,
    clocks: &Clocks
) -> Result<Tx<USART, WORD>, InvalidConfig>
Enter fullscreen mode Exit fullscreen mode

Note that in both cases, we used a tx method. This is because earlier I mentioned that I will be doing simplex communication. Meaning that I will be transmitting in one direction only. If you delve into the documentation, you'll find that there are also methods to support receive only and also bidirectional transmit and receive.

This is it for configuration! Let's now jump into the application code.

Application Code

Following the design described earlier, I first need to initialize a delay variable del_var and initialize the output of the LED. del_var needs to be mutable as it will be modified by the delay loop. I also choose to set the initial led output level to low by default. Using the same Pin methods mentioned earlier, there is a set_low() method that I use to achieve that.

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

    // Initialize LED to on or off
    led.set_low();
Enter fullscreen mode Exit fullscreen mode

I also want to initialize a variable value that I want to use to track how many times the button has been pressed:

let mut value: u8 = 0;
Enter fullscreen mode Exit fullscreen mode

Afterward, I have one small thing remaining. I mentioned that I will be using the debouncr crate to debounce button presses. This means I need to create some sort of handler to utilize the crate methods. In the crate documentation, to instantiate, I first need to determine how many states need to be detected. In a way, it sort of means how many state changes you want to see before a press is determined? Truth be told I experimented a bit with this and found that 3 states was the most suitable for my application.

    let mut debouncer = debounce_3(false);
Enter fullscreen mode Exit fullscreen mode

The reason I initialized debouncer to false is that the documentation mentioned that I have to do that if the button is active low.

Next inside the program loop, I first start by calling a delay function where inside of it I check if the button is pressed. After the delay completes, I toggle led using the toggle() method, again part of the methods available for the Pin type. This is the complete application loop:

    loop {
        for _i in 1..del_var {
            if debouncer.update(button.is_low()) == Some(Edge::Falling) {
                writeln!(tx, "Button Press {:02}\r", value).unwrap();
                value = value.wrapping_add(1);
                del_var = del_var - 3_0000_i32;
                if del_var < 1_0000 {
                    del_var = 7_0000_i32;
                }
                break;
            }
        }
        led.toggle();
    }
Enter fullscreen mode Exit fullscreen mode

The general structure is exactly the same as the application loop in the regular polled button-controlled blinking application. The outer for loop is the one that keeps track of the delay throughdel_var. A difference here is the if statement condition. For the condition, I am leveraging the update method from the debouncr crate. When polling the pin, the update method is repeatedly called. The update method returns an Option enum that keeps providing a None when no press is detected. As soon as a (debounced) press is detected, the update method returns either a Some(Edge::Falling) or Some(Edge::Rising). Since the Nucleo-F401RE baord has an active-low button, a press is detected with a falling edge and a Some(Edge::Falling) is returned on a successful debounce.

Once the button detect is completed, I use the writeln! macro provided through core::fmt::Write that I imported earlier. The usage is exactly the same as the formatted print using println! in Rust with a couple of small exceptions. Examining the statement,

writeln!(tx, "Button Press {:02}\r", value).unwrap();
Enter fullscreen mode Exit fullscreen mode

If you have noticed, writeln! takes three parameters and in the first parameter of writeln!, I am passing the tx serial handler as an argument. Additionally, the writeln! macro needs to be unwrapped since it returns a Result. The third parameter of writeln! also contains the value variable initialized earlier that is being incremented by the following line as follows:

value = value.wrapping_add(1);
Enter fullscreen mode Exit fullscreen mode

The wrapping_add method, as the name implies performs a wrapping add on value adding 1 every time the method is called and wraps around if needed. The remaining code takes care of decreasing the value of del_var to reduce the delay and make sure that it does not drop below zero. Finally, outside of the delay loop the led is toggled using the Pin toggle method.

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 Nucleo-F401RE git repo.

#![no_std]
#![no_main]

// Imports
use core::fmt::Write; // allows use to use the WriteLn! macro for easy printing
use cortex_m_rt::entry;
use debouncr::{debounce_3, Edge};
use panic_halt as _;
use stm32f4xx_hal::{
    pac::{self},
    prelude::*,
    serial::{Config, Serial},
};

#[entry]
fn main() -> ! {
    // Setup handler for device peripherals
    let dp = pac::Peripherals::take().unwrap();

    // 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 gpioa = dp.GPIOA.split();
    let mut led = gpioa.pa5.into_push_pull_output();

    // Configure the button pin (if needed) and obtain handler.
    // On the Nucleo FR401 there is a button connected to pin PC13.
    // Pin is input by default
    let gpioc = dp.GPIOC.split();
    let button = gpioc.pc13;

    // Serial config steps:
    // 1) Need to configure the system clocks
    // - Promote RCC structure to HAL to be able to configure clocks
    let rcc = dp.RCC.constrain();
    // - Configure system clocks
    // 8 MHz must be used for the Nucleo-F401RE board according to manual
    let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();

    // 2) Configure/Define TX pin
    // Note that we already split port A earlier for the led pin
    // Use PA2 as it is connected to the host serial interface
    let tx_pin = gpioa.pa2.into_alternate();

    // 3) Configure Serial perihperal channel
    // We're going to use USART2 since its pins are the ones connected to the USART host interface
    // To configure/instantiate serial peripheral channel we have two options:
    // Use the device peripheral handle to directly access USART2 and instantiate a transmitter instance
    let mut tx = dp
        .USART2
        .tx(
            tx_pin,
            Config::default()
                .baudrate(115200.bps())
                .wordlength_8()
                .parity_none(),
            &clocks,
        )
        .unwrap();
    // or
    // Use the Serial abstraction to instantiate a transmitter instance
    // let mut tx = Serial::tx(
    //     dp.USART2,
    //     tx_pin,
    //     Config::default()
    //         .baudrate(115200.bps())
    //         .wordlength_8()
    //         .parity_none(),
    //     &clocks,
    // )
    // .unwrap();

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

    // Initialize LED to on or off
    led.set_low();

    // Initialize debouncer to false because button is active low
    // Chose 3 consecutive states based on testing
    let mut debouncer = debounce_3(false);

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

    // Application Loop
    loop {
        // Enter Delay Loop
        for _i in 1..del_var {
            // Keep checking if button got pressed
            if debouncer.update(button.is_low()) == Some(Edge::Falling) {
                // If button is pressed print to derial and decrease the delay value
                writeln!(tx, "Button Press {:02}\r", value).unwrap();
                // Increment value keeping track of button presses
                value = value.wrapping_add(1);
                // Decrement the amount of delay
                del_var = del_var - 3_0000_i32;
                // If updated delay value drops below threshold then reset it back to starting value
                if del_var < 1_0000 {
                    del_var = 7_0000_i32;
                }
                // Exit delay loop since button was pressed
                break;
            }
        }

        // Delay loop without button debouncing
        // for _i in 1..del_var {
        //     // Keep checking if button got pressed
        //     if button.is_low() {
        //         // If button is pressed print to derial and decrease the delay value
        //         writeln!(tx, "Button Pressed\r").unwrap();
        //         del_var = del_var - 3_0000_i32;
        //         // If updated delay value drops below threshold then reset it back to starting value
        //         if del_var < 1_0000 {
        //             del_var = 7_0000_i32;
        //         }
        //         // Exit loop
        //         break;
        //     }
        // }

        // Toggle LED
        led.toggle();
    }
}
Enter fullscreen mode Exit fullscreen mode

Further Experimentation/Ideas

  • Notice how for led pin pa5 we've always separated the configuration of the pin as an output using into_push_pull_output from the code to set its initial state to low using set_low. Check out the documentation for the generic Pin type and see if there is a method that does both in one shot.
  • Instead of doing only transmission, experiment with receive as well. Instead of having a button press to control the speed, replace it with a message received over UART from a PC terminal.

Conclusion

In this post, an LED control application was created leveraging the GPIO and UART peripherals for the STM32F401RE microcontroller on the Nucleo-F401RE development board. The UART peripheral sends to a host PC a status update every time a GPIO button is pressed. All code was based on polling (without interrupts). All code was created at the HAL level using the stm32f4xx Rust HAL. Have any questions? 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)