STM32F4 Embedded Rust at the HAL: PWM Buzzer

STM32F4 Embedded Rust at the HAL: PWM Buzzer

·

13 min read

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

Prior posts include (in order of publishing):

  1. STM32F4 Embedded Rust at the HAL: GPIO Button Controlled Blinking
  2. STM32F4 Embedded Rust at the HAL: Button Controlled Blinking by Timer Polling
  3. STM32F4 Embedded Rust at the HAL: UART Serial Communication

Introduction

In this post, I will be exploring the usage of the PWM peripheral in the stm32f4xx-hal. I will be configuring and setting up the PWM peripheral to play different tones on a buzzer. The different tones will be used to generate a tune.

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.

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

nucleof401re.jpg

Base_Shield_v2-1.png

groveBuzzer.jpg

Connections

  • Buzzer positive terminal connected to pin PA9 (through Grove Base Shield Connector D8).
  • Buzzer negative terminal connected to GND (through Grove Base Shield Connector D8).

🚨 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 buzzer to the board pins if need be.

Software Design

📝 Note:

The code in this post is an adaptation in Rust of the buzzer example provided by Seeed Studio here.

The buzzer used is quite simple to operate. Through the buzzer-connected signal pin, various tones can be generated by the controller PWM peripheral. This occurs by changing the PWM frequency to match the needed tone. As a result, to generate a certain tune a collection of tones at a certain rate (tempo) need to be provided to the PWM peripheral. This also means that the code would need to include some data structures storing the needed information to provide to the PWM peripheral. Two data structures are needed, the first would include a mapping between notes and their associated frequencies. The second would represent a tune that includes a collection of notes each played for a certain amount of beats.

Following that information, after configuring the device, the algorithmic steps are as follows:

  1. From the tune array data structure obtain a note and its associated beat
  2. From the tones array retrieve the frequency associated with the note obtained in step 1
  3. Play the note for the desired duration (number of beats * tempo)
  4. Include half a beat of silence (0 frequency) between notes
  5. Go back to 1.

There are fine details in between relative to the PWM details that will be discussed in detail in the implementation.

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 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 cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{
    pac::{self},
    prelude::*,
    timer::Channel,
};

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

2️⃣ Promote the PAC-level GPIO structs: In this application, I'm going to need one pin that will be generating the PWM output. The pin being used is PA9 as stated earlier and it's one of the pins that support PWM output. In fact, it can be connected internally to timer 1 or TIM1. Before I can obtain a handle for PA9 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();

3️⃣ Obtain a handle and configure the PWM pin: Here PA9 needs to be configured such that it's connected to the internal circuitry of TIM1 that enables generating a PWM output. This is done using the into_alternate generic Pin type method. I will name the handle buzz and configure it as follows:

let buzz = gpioa.pa9.into_alternate();

PWM Timer Peripheral Configuration:

1️⃣ Configure the system clocks: The system clocks need to be configured as they are needed in setting up the timer 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();

2️⃣ Obtain a PWM handle and configure the timer: The way PWM is structured in the stm32f4xx-hal is somewhat confusing. In the STM32F401 device the TIM1 timer, has 4 channels that can each be connected to PWM output pins. In past posts when configuring peripherals like the UART, it was demonstrated how the peripheral can be configured using extension traits or the peripheral abstraction to instantiate an instance. The confusing part here is that between the extension traits and the abstractions, it seems that the different pwm abstractions do not have a method to instantiate an instance. Instead, in order to instantiate a PWM peripheral (create a handle) there are the following two options:

  1. Pass a tuple of Pin handles into one of the pwm extension traits and apply the split method to obtain a tuple of handles of type PWMChannel.
  2. Pass a single Pin handle into one of the pwm extension traits to obtain a Pwm type.

Going with option 2 will consequently require that I pass the channel number into the methods I use which will be seen later. Simply put, as the stm32f4xx-hal stands right now, PWM instances can be created only by applying traits on timer peripherals. So, since I'm using only a single pin, I need only a single channel so I elected to go with the second option for my application. I created a buzz_pwm handle as follows:

let mut buzz_pwm = dp.TIM1.pwm_hz(buzz, 2000.Hz(), &clocks);

Breaking this line down, you can see I am using the pwm_hz method which has the following signature:

    fn pwm_hz<P, PINS>(
        self, 
        pins: PINS, 
        freq: Hertz, 
        clocks: &Clocks
    ) -> PwmHz<Self, P, PINS>
    where
        PINS: Pins<Self, P>;

As can be seen, the pwm_hz method returns a PwmHz type in which I am also passing as arguments the buzz pin handle, the PWM frequency, and the frozen clocks handle. The reason that I chose is because pwm_hz among the other two available methods is that its methods are going to allow me to easier change the PWM output based on frequency values.

🚨 Important Note:

At the time of writing this post, I noticed that if going with option 1 stated earlier that returns a PWMChannel can prove to be quite problematic. In navigating the documentation, the PWMChannel implementations do not include methods that allow to get and set the period of the peripheral. There is an issue that I submitted here for that.

3️⃣ Configure the PWM duty cycle: Here all I need is to generate a regular square wave, so I need a duty cycle of 50%. This is being done over two steps as follows:

let max_duty = buzz_pwm.get_max_duty();
buzz_pwm.set_duty(Channel::C2, max_duty / 2);

Analyzing the above lines, the first line applies the get_max_duty method on the buzz_pwm handle which returns a u16 value representing the maximum duty cycle. The second line applies the set_duty method that accepts two parameters, an enum of the channel that that pin is connected to and the duty cycle value. It can be determined from the device datasheet that pin PA9 is connected to channel 2 of the timer. In any case, if by mistake the programmer happens to insert the incorrect channel number, the compiler will generate an error.

🚨 Important Note:

I've found that by skipping this step (not configuring the duty cycle) the PWM output will not operate properly. I had assumed that the code would fall back to a default value but that was not the case.

Timer and Delay Peripheral Configuration:

In the algorithm, a delay will need to be introduced to control the tempo. Since I am already using TIM1 I need to leverage a different timer of which I will be using TIM2. I create a millisecond delay handle delay as follows:

let mut delay = dp.TIM2.delay_ms(&clocks);

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

Application Code

According to the software design description, two arrays are needed to store the tone and tune information. The first array tones, contains a collection of tuples that provide a mapping of the note letter and its corresponding frequency. The second array tune contains a collection of tuples that present the note that needs to be played and the number of beats per note. Note that the tune array contains an empty note ' ' that presents silence and does not have a corresponding mapping in the tones array.

    let tones = [
        ('c', 261.Hz()),
        ('d', 294.Hz()),
        ('e', 329.Hz()),
        ('f', 349.Hz()),
        ('g', 392.Hz()),
        ('a', 440.Hz()),
        ('b', 493.Hz()),
    ];

    let tune = [
        ('c', 1),
        ('c', 1),
        ('g', 1),
        ('g', 1),
        ('a', 1),
        ('a', 1),
        ('g', 2),
        ('f', 1),
        ('f', 1),
        ('e', 1),
        ('e', 1),
        ('d', 1),
        ('d', 1),
        ('c', 2),
        (' ', 4),
    ];

Next, before jumping into the algorithmic loop the tempo needs to be defined which will be used in the delay handle. A tempo variable is created as follows:

let tempo = 300_u32;

Next, the application loop looks as follows:

    loop {
        // 1. Obtain a note in the tune
        for note in tune {
            // 2. Retrieve the freqeuncy and beat associated with the note
            for tone in tones {
                // 2.1 Find a note match in the tones array and update frequency and beat variables accordingly
                if tone.0 == note.0 {
                    // 3. Play the note for the desired duration (beats*tempo)
                    // 3.1 Adjust period of the PWM output to match the new frequency
                    buzz_pwm.set_period(tone.1);
                    // 3.2 Enable the channel to generate desired PWM
                    buzz_pwm.enable(Channel::C2);
                    // 3.3 Keep the output on for as long as required
                    delay.delay_ms(note.1 * tempo);
                } else if note.0 == ' ' {
                    // 2.2 if ' ' tone is found disable output for one beat
                    buzz_pwm.disable(Channel::C2);
                    delay.delay_ms(tempo);
                }
            }
            // 4. Silence for half a beat between notes
            // 4.1 Disable the PWM output (silence)
            buzz_pwm.disable(Channel::C2);
            // 4.2 Keep the output off for half a beat between notes
            delay.delay_ms(tempo / 2);
            // 5. Go back to 1.
        }
    }

Let's break down the loop line by line. The line

for note in tune

iterates over the tune array obtaining a note with each iteration. Within the first loop another for loop for tone in tones is nested which iterates over the tones array. The second loop retrieves the frequency and beat associated for each note obtained from the tune array. The statement

if tone.0 == note.0

checks if there is a match for the mapping between the note and the tone. The .0 index is in reference to the first index in the tuple which is the note letter. Once a match is found, the note is played for the desired duration which equals the beats multiplied by the tempo. This is done over three steps:

First, using the set_period method in the PwmHz abstraction, the tone frequency is adjusted to match the frequency of the found tone. The frequency of the tone corresponds to index 1 o the tuple and is configured as follows:

buzz_pwm.set_period(tone.1);

Second, using the enable method the buzz_pwm channel is enabled to activate the desired PWM.

buzz_pwm.enable(Channel::C2);

In the third and final step the output is kept on for a period of beat*tempo milliseconds. Here I leverage the delay handle created earlier as follows:

delay.delay_ms(note.1 * tempo);

In the case a ' ' note is found the following lines are executed:

else if note.0 == ' ' {
           buzz_pwm.disable(Channel::C2);
           delay.delay_ms(tempo);
}

Which disables the PWM channel output for one beat.

Finally, after exiting the inner loop, half a beat of silence is introduced between notes in the outer loop tune as follows:

buzz_pwm.disable(Channel::C2);
delay.delay_ms(tempo / 2);

🚨 Important Note:

Some of the previous methods do not have a description in the documentation. However, the name of the method itself is quite intuitive to figure out what it does.

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 cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{
    pac::{self},
    prelude::*,
    timer::Channel,
};

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

    // Set up the clocks
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();

    // Configure the Buzzer pin as an alternate and obtain handler.
    // I will use PA9 that connects to Grove shield connector D8
    // On the Nucleo FR401 PA9 connects to timer TIM1
    let gpioa = dp.GPIOA.split();
    let buzz = gpioa.pa9.into_alternate();
    let mut buzz_pwm = dp.TIM1.pwm_hz(buzz, 2000.Hz(), &clocks);

    // Configure the duty cycle to 50%
    // If duty not configured, PWM will not operate properly (suggest comments)
    let max_duty = buzz_pwm.get_max_duty();
    buzz_pwm.set_duty(Channel::C2, max_duty / 2);

    // Configure and create a handle for a second timer using TIM2 for delay puposes
    let mut delay = dp.TIM2.delay_ms(&clocks);

    // Define the notes and their frequencies
    let tones = [
        ('c', 261.Hz()),
        ('d', 294.Hz()),
        ('e', 329.Hz()),
        ('f', 349.Hz()),
        ('g', 392.Hz()),
        ('a', 440.Hz()),
        ('b', 493.Hz()),
    ];

    // Define the notes to be played and the beats per note
    let tune = [
        ('c', 1),
        ('c', 1),
        ('g', 1),
        ('g', 1),
        ('a', 1),
        ('a', 1),
        ('g', 2),
        ('f', 1),
        ('f', 1),
        ('e', 1),
        ('e', 1),
        ('d', 1),
        ('d', 1),
        ('c', 2),
        (' ', 4),
    ];

    // Define the tempo
    let tempo = 300_u32;

    // Application Loop
    loop {
        // 1. Obtain a note in the tune
        for note in tune {
            // 2. Retrieve the freqeuncy and beat associated with the note
            for tone in tones {
                // 2.1 Find a note match in the tones array and update frequency and beat variables accordingly
                if tone.0 == note.0 {
                    // 3. Play the note for the desired duration (beats*tempo)
                    // 3.1 Adjust period of the PWM output to match the new frequency
                    buzz_pwm.set_period(tone.1);
                    // 3.2 Enable the channel to generate desired PWM
                    buzz_pwm.enable(Channel::C2);
                    // 3.3 Keep the output on for as long as required
                    delay.delay_ms(note.1 * tempo);
                } else if note.0 == ' ' {
                    // 2.2 if ' ' tone is found disable output for one beat
                    buzz_pwm.disable(Channel::C2);
                    delay.delay_ms(tempo);
                }
            }
            // 4. Silence for half a beat between notes
            // 4.1 Disable the PWM output (silence)
            buzz_pwm.disable(Channel::C2);
            // 4.2 Keep the output off for half a beat between notes
            delay.delay_ms(tempo / 2);
            // 5. Go back to 1.
        }
    }
}

Conclusion

In this post, a buzzer application that plays a tune was created leveraging the timer peripheral PWM output for the STM32F401RE microcontroller on the Nucleo-F401RE development board. All the code was created at the HAL level using the stm32f4xx Rust HAL. Have any questions? Share your thoughts in the comments below 👇.

Did you find this article valuable?

Support Omar Hiari by becoming a sponsor. Any amount is appreciated!