DEV Community

Cover image for STM32F4 Embedded Rust at the HAL: Timer Ultrasonic Distance Measurement
Omar Hiari
Omar Hiari

Posted on • Edited on

STM32F4 Embedded Rust at the HAL: Timer Ultrasonic Distance Measurement

This blog post is the fifth 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

In this post, I will be configuring and setting up stm32f4xx-hal timer and GPIO peripherals with an ultrasonic sensor to measure obstacle distance. A distance measurement will be continuously collected and sent to a PC terminal over UART. I will be leveraging the UART Serial Communication application from a previous post. Additionally, I will not be using any interrupts and the example will be set up as a simplex system that transmits in one direction only (towards the PC).

🚨 Important Note:

For the purpose of this post, ideally I would have wanted to leverage the timer peripheral input capture mode. I came to discover later that input capture is yet not supported for the stm32f4xx-hal. As a result, I resorted to a different approach that achieves the same thing but is considered less efficient. One can still leverage input capture at the PAC level but for the purpose of this post, I wanted to stick with the HAL.

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.
  • Familiarity with working principles of Ultrasonic sensors. This page is a good resource.

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

Base Shield

Ultrasonic

🚨 Important Note:

I used the Grove modular system for connection ease. It is a more elegant approach and less prone to mistakes. One can directly wire the ultrasonic sensor to the board if need be.

Connections

  • Ultrasonic echo pin connected to pin PA8 (Grove Connector D7).
  • 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 ultrasonic sensor used is a single-pin interface sensor. The single pin, referred to as the echo pin, operates in a bidirectional mode. The echo pin, first operating as an input, should be triggered by a pulse that is at least 10us wide. This would cause the sensor to emit a series of ultrasonic pulses that it measures the propagation delay of. After that, the echo pin switches to an output providing a pulse width proportional to the distance of the obstacle.

UltraSonic Pulse

The obstacle distance is calculated as:

distance(cm)=echo pulse width(us)292\text{distance} (cm) = \frac{\text{echo pulse width} (us)}{29*2}

The algorithm is quite straightforward in this case. After configuring the device, the algorithmic steps are as follows:

  1. Set PA8 pin output to low for 5 us to get a clean low pulse
  2. Set PA8 pin output to high (trigger) for 10us
  3. Switch PA8 to an input
  4. Keep polling PA8 input until it goes high
  5. Once PA8 input goes high kick-off counter/timer
  6. Keep polling PA8 input until it goes low
  7. Obtain pulse duration measurement from counter/timer
  8. Calculate distance and send the result to UART serial channel
  9. Go back to 1

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 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 panic_halt as _;
use stm32f4xx_hal::{
    gpio::PinState,
    pac::{self},
    prelude::*,
    serial::config::Config,
};
Enter fullscreen mode Exit fullscreen mode

Peripheral Configuration Code

GPIO Peripheral 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: I need to configure the echo pin as input in the beginning and obtain a handler for the pin so that I can control it. I also need to obtain a handle for the UART Tx pin. Both pins are part of GPIOA. Before I can obtain any handles I need to promote the pac-level GPIOA struct to be able to create handles for individual pins. I do this by using the split() method as follows:

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

3️⃣ Obtain a handle for the echo pin and configure it to an input: As earlier stated, the echo pin is connected to pin PA8 (Pin 8 Port A). As such, I need to create a handle for the echo pin that has PA8 configured to an input. I will name the handle echo and configure it as follows:

let mut echo = gpioa.pa8;
Enter fullscreen mode Exit fullscreen mode

📝 Note:

For more detail on GPIO control, please refer to my past post GPIO Button Controlled Blinking.

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 Peripheral 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. This is done 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.

📝 Note:

More detail on UART setup is available in the UART Serial Communication blog post.

Timer and Delay Peripheral Configuration:

In the algorithm, there is a step where I have to provide a pulse trigger that is 10us wide. For that, I would need to use some delay method to keep the echo pin high for that duration. Additionally, in another step, I have to also use a timer to determine how long the pulse width is. For that, I need to configure two peripherals as follows:

1️⃣ Configure a timer for delay and obtain handle: I will be using TIM1 to provide a blocking delay. I will call the handle delay and create it as follows:

let mut delay = dp.TIM1.delay_us(&clocks);
Enter fullscreen mode Exit fullscreen mode

2️⃣ Configure a timer for pulse measurement and obtain handle: I will be using TIM2 to provide a counter I can leverage to obtain a Duration. I will call the handle counter and create it as follows:

let mut counter = dp.TIM2.counter_us(&clocks);
Enter fullscreen mode Exit fullscreen mode

📝 Note:

More detail on timers/counters and their setup is available in the Button Controlled Blinking by Timer Polling blog post.

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

Application Code

Following the design described earlier, I first need to set the echo pin output to low for 5 us to get a clean low pulse. The issue now is that the echo pin is configured as an input. As a result, if one would examine the generic Pin methods, there is a with_push_pull_output_in_state method that, according to its description, temporarily configures a pin as a push-pull output and has the following signature:

pub fn with_push_pull_output_in_state<R>(
    &mut self,
    state: PinState,
    f: impl FnOnce(&mut Pin<P, N, Output<PushPull>>) -> R
) -> R
Enter fullscreen mode Exit fullscreen mode

Note here that the method has a closure f that is called with the reconfigured pin. After the closure returns, the pin will be configured back to its original configuration. Addtionally, the method has a state parameter that allows me to assign a certain state to the output pin (high or low) when its reconfigured. As such, I can achieve what I want as follows:

echo.with_push_pull_output_in_state(PinState::Low, |_f| delay.delay_us(5_u32));
Enter fullscreen mode Exit fullscreen mode

what is happening here is that the echo pin is reconfigured to push pull output, with the output being low. In the closure, I am introducing the 5us delay using the delay handle. This means that the pin is going to remain as an output in the low state for 5us, and then return to being an input again.

Steps 2 and 3 in the algorithm require that I set the echo pin output to high (trigger) for 10us and then switch echo back to an input. This can be done exactly in the same manner as the previous step as follows:

echo.with_push_pull_output_in_state(PinState::High, |_f| delay.delay_us(10_u32));
Enter fullscreen mode Exit fullscreen mode

The main differences here is that the state argument is High and that the closure has a 10us delay instead.

Next I need to keep polling the echo pin until it goes high marking the start of the echo pulse. This is done as follows:

while !(echo.is_high()) {}
Enter fullscreen mode Exit fullscreen mode

Using the while loop and the is_high Pin method, the code is going around this same line until the echo pin input goes high.

Afterwards a timer needs to be kicked-off. Using the counter handle created earlier and the start Counter method the counter is kicked-off as follows:

counter.start(1000.millis()).unwrap();
Enter fullscreen mode Exit fullscreen mode

Here a timeout Duration is provided as an argument which presents the maximum duration the counter would run for. The start method also returns a Result which is why I had to unwrap it. I chose a duration of 1000 milliseconds as it corresponds to the longest distance that can be measured.

Now that the timer is kicked off, next step requires that I keep polling the echo pin input until it goes low. This is done exactly as before but rather using the is_low method instead as follows:

while !(echo.is_low()) {}
Enter fullscreen mode Exit fullscreen mode

Once the echo pin goes low, the pulse duration measurement needs to be collected by the counter/timer as follows:

let duration = counter.now().duration_since_epoch();
counter.cancel().unwrap();
Enter fullscreen mode Exit fullscreen mode

Here the now method is leveraged to obtain the current Instance and the duration_since_epoch method to provide back the a Duration value. I am also cancelling/stopping the timer using the cancel Counter method and unwrapping it.

📝 Note:

Again, if any clarity is lacking relative to counter methods I would recommend referring to the Button Controlled Blinking by Timer Polling blog post as it digs into more detail.

Now that the pulse duration is available, a distance can be calculated. Using the earlier presented formula, the distance in centimeters is calculated using the following code:

let distance_cm = duration.to_micros() / 2 / 29;
Enter fullscreen mode Exit fullscreen mode

The to_micros method converts the Duration to an integer number of microseconds.

Finally, the result is sent over UART using the writeln! macro:

writeln!(tx, "Distance {:02} cm\r", distance_cm).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 distance_cm variable that was created in the previous line to store the result of the distance calculation.

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 panic_halt as _;
use stm32f4xx_hal::{
    gpio::PinState,
    pac::{self},
    prelude::*,
    serial::config::Config,
};

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

    // Configure the ultasonic device echo pin as input and obtain handler.
    let gpioa = dp.GPIOA.split();
    let mut echo = gpioa.pa8;

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

    // Delay Configuration
    // Set up a microsecond delay handler
    let mut delay = dp.TIM1.delay_us(&clocks);

    // Counter/timer congig
    // Set up a microsecond counter handler
    let mut counter = dp.TIM2.counter_us(&clocks);

    // Algorithim
    // 1) Set pin ouput to low for 5 us to get clean low pulse
    // 2) Set pin output to high (trigger) for 10us
    // 3) Switch back to input
    // 4) Keep checking if pin goes high
    // 5) Once pin goes high start kick off counter/timer
    // 6) Wait for Pin to go low
    // 7) Obtain pulse measurement from timer
    // 8) Print out measurement on Serial
    // 9) Go back to 1)

    // Application Loop
    loop {
        // 1) Set pin ouput to low for 5 us to get clean low pulse
        echo.with_push_pull_output_in_state(PinState::Low, |_f| delay.delay_us(5_u32));

        // 2) Set pin output to high (trigger) for 10us
        // 3) Switch back to input
        echo.with_push_pull_output_in_state(PinState::High, |_f| delay.delay_us(10_u32));

        // 4) Wait until pin goes high
        while !(echo.is_high()) {}

        // 5) Kick off timer measurement with a max timeout Duration of 100ms?? defined by data sheet (longest distance that can be measured)
        counter.start(1000.millis()).unwrap();

        // 6) Wait until pin goes low.
        while !(echo.is_low()) {}

        // 7) Stop timer and collect elapsed time
        let duration = counter.now().duration_since_epoch();
        counter.cancel().unwrap();

        // 8) Calculate the distance in cms using formula in datasheet
        let distance_cm = duration.to_micros() / 2 / 29;

        // 8) Send calculated distance to serial interface
        writeln!(tx, "Distance {:02} cm\r", distance_cm).unwrap();
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this post, an ultrasonic distance measurement application was created leveraging the GPIO and Counter peripherals for the STM32F401RE microcontroller on the Nucleo-F401RE development board. The resulting measurement is also sent over to a host PC over a UART connection. All code was based on polling (without interrupts). Additionally, 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)