DEV Community

Cover image for STM32F4 Embedded Rust at the HAL: The RTIC Framework
Omar Hiari
Omar Hiari

Posted on • Updated on

STM32F4 Embedded Rust at the HAL: The RTIC Framework

This blog post is the third of a three-part series of posts where I explore interrupts for the STM32F401RE microcontroller using embedded Rust at the HAL level.

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 the prior two posts, I walked through creating an application, completely based on interrupts, that changes the rate of blinking for an LED based on a button press. There were two interrupt service routines, one that responded to a button press event and another that responded to a timer delay expiring. It was shown how in order to create safe operations the abstractions utilized make the code quite verbose. Gladly, there is an alternative solution that can be leveraged which is the Real-Time Interrupt-driven Concurrency (RTIC) framework. The RTIC framework provides the same safety guarantees in the prior code though is a less verbose and more elegant solution. In this post, I will be porting the code from the latest timer interrupts post to demonstrate the ease of transferring to the RTIC framework. I will be building up the RTIC application step by step explaining how each part relates to the timer interrupt application post.

The full code is also available for reference on the apollolabsdev Nucleo-F401RE git repo.

๐Ÿ“ Notes:

1๏ธโƒฃ The code porting the GPIO interrupts post is also provided on the apollolabsdev Nucleo-F401RE git repo.

2๏ธโƒฃ The RTIC provides much more features other than just handling interrupts (Ex. scheduling, message passing...etc.) but they will not be addressed in this post. For example, the RTIC can provide a convenient setup for a programmer that wants to create an OS. I would recommend the interested reader to refer to the Real-Time Interrupt-driven Concurrency (RTIC) framework documentation for details.

๐Ÿ“ฑ The RTIC Application

The RTIC framework structure can be viewed as a combination of specialized attributes. Each attribute can define a task, an initialization function, a timer for scheduling, local and global resources, or even the application itself. Some of the attributes can also take arguments. In the following sections, I will be breaking down the structure to the individual attributes and describe the contents of each importing our application to it.

The #[app] attribute

As a start, all RTIC applications require the #[app] attribute. The #[app] attribute also requires that we pass a mandatory device argument that contains the path pointing to the PAC utilized. In our case, this would be stm32f4xx_hal::pac and the attribute is defined as follows:

#[rtic::app(device = stm32f4xx_hal::pac, peripherals = true)]
Enter fullscreen mode Exit fullscreen mode

Note that here that peripherals = true makes sure that the device handle/field is available for use later in our code. By default, peripherals is set to true but can be set to false to reduce the size of the application if one does not require access to the device peripherals. Directly under the #[app] attribute, we include an app module, mod app that will encapsulate the rest of the attributes and the imports needed in our application. As such, before introducing any new attributes, right at the beginning of the mod app body/scope, we can simply copy over the imports from the timer interrupt post. The code under #[rtic::app(device = stm32f4xx_hal::pac, peripherals = true)] thus expands to the following:

#[rtic::app(device = stm32f4xx_hal::pac, peripherals = true)]
mod app {

    use stm32f4xx_hal::{
        gpio::{self, Edge, Input, Output, PushPull},
        pac::TIM2,
        prelude::*,
        timer::{self, Event},
    };
Enter fullscreen mode Exit fullscreen mode

Now we should be ready to define the rest of our application (implementation code).

๐Ÿ“š The RTIC Application Resources

In the RTIC application, there are two types of resources, shared and local. Shared resources are the resources/variables that are going to be shared/accessed among several tasks. On the other hand, local resources are ones that can be accessed by only one task. Note that all these resources are in reference to ones that we will be initialized at the beginning of our application (under the #[init] attribute shown later) to utilize later in various tasks. This means that I don't need to assign a resource for a local variable if I were to create a variable to use locally in any of the tasks.

In other words, what I am trying to say is that both the shared and the local resources in RTIC are global variables under our prior definition. The key difference is that shared resources are for global variables that are leveraged by several tasks. However, local resources are for global variables leveraged by one task (technically two, the #[init] task and one other).

๐Ÿ—„ Shared Resources

The shared resources are defined under the #[shared] attribute. Inside of #[shared], there is a Shared struct where we include handles for the types that are going to be shared.

    #[shared]
    struct Shared {     
        timer: timer::CounterMs<TIM2>,
    }
Enter fullscreen mode Exit fullscreen mode

Note here that timer is the timer peripheral that I am going to be sharing between the press button interrupt and the timer expiry interrupt. As a reminder, in the button press interrupt service routine (ISR) the timer was restarted with a new delay value. On the other hand, in the timer expiry ISR, the timer handle was needed to clear the pending interrupt in the peripheral.

๐Ÿ“ Local Resources

Similar to the shared resources, local resources are defined under the #[local] attribute. Inside of #[local], there is also a Local struct where we include handles for the types that are going to be local.

    #[local]
    struct Local {
        delayval: u32,
        button: gpio::PC13<Input>,
        led: gpio::PA5<Output<PushPull>>,
    }
Enter fullscreen mode Exit fullscreen mode

Where delayval is the delay variable that is going to be modified every time the button is pressed. button is the handle for the GPIO input button and is needed to clear the button press interrupt pending flag. Finally, led is the handle for the GPIO output led and is needed to toggle the output led. Note that all of these resources are local since they will be used only in the button pressed ISR.

๐Ÿคน The RTIC Application Tasks

The #[init] Task

The #[init] task is the one that includes the system setup code (our configuration code) and executes after system reset. For the #[init] task, the #[init] attribute is immediately followed by an init function that must have the signature fn(ctx: init::Context) -> (Shared, Local, init::Monotonics). In the init task, we are able to access the device peripherals through the device and core fields in init::Context that is bound to the ctx handle. This sort of replaces what we used to do using the take handle. As such, in order to be able to copy the configuration code from the previous post as is, I include the following line:

let mut dp = ctx.device;
Enter fullscreen mode Exit fullscreen mode

This will allow me to use the dp handle that I was using before, replacing what we used to do using the take method. Before showing the full code for init, there is one more thing. At the end of the init task, we must return the initialized values for the system-wide #[shared] and #[local] resources, in addition to the set of initialized timers used by the application. This looks as follows:

        (
            Shared { timer },
            Local { button, led, delayval: 2000_u32 },
            init::Monotonics(),
        )
Enter fullscreen mode Exit fullscreen mode

A couple of things to note here. First, although we are not using Monotonics for scheduling, they still need to be returned. Second, I am initializing delayval with its starting value directly in the return structure.

As such, here is the code for the #[init] task:

#[init]
    fn init(ctx: init::Context) -> (Shared, Local, init::Monotonics) {
        let mut dp = ctx.device;

        // Configure the LED pin as a push pull ouput and obtain handle
        // On the Nucleo FR401 theres an on-board LED connected to pin PA5
        // 1) Promote the GPIOA PAC struct
        let gpioa = dp.GPIOA.split();
        // 2) Configure Pin and Obtain Handle
        let led = gpioa.pa5.into_push_pull_output();

        // Configure the button pin as input and obtain handle
        // On the Nucleo FR401 there is a button connected to pin PC13
        // 1) Promote the GPIOC PAC struct
        let gpioc = dp.GPIOC.split();
        // 2) Configure Pin and Obtain Handle
        let mut button = gpioc.pc13;

        // Configure Button Pin for Interrupts
        // 1) Promote SYSCFG structure to HAL to be able to configure interrupts
        let mut syscfg = dp.SYSCFG.constrain();
        // 2) Make button an interrupt source
        button.make_interrupt_source(&mut syscfg);
        // 3) Make button an interrupt source
        button.trigger_on_edge(&mut dp.EXTI, Edge::Rising);
        // 4) Enable gpio interrupt for button
        button.enable_interrupt(&mut dp.EXTI);

        // Configure and obtain handle for delay abstraction
        // 1) Promote RCC structure to HAL to be able to configure clocks
        let rcc = dp.RCC.constrain();
        // 2) Configure the system clocks
        // 8 MHz must be used for HSE on the Nucleo-F401RE board according to manual
        let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();
        // 3) Create delay handle
        //let mut delay = dp.TIM1.delay_ms(&clocks);
        let mut timer = dp.TIM2.counter_ms(&clocks);

        // Kick off the timer with 2 seconds timeout first
        // It probably would be better to use the global variable here but I did not to avoid the clutter of having to create a crtical section
        timer.start(2000.millis()).unwrap();

        // Set up to generate interrupt when timer expires
        timer.listen(Event::Update);

        (
            // Initialization of shared resources
            Shared { timer },
            // Initialization of task local resources
            Local {
                button,
                led,
                delayval: 2000_u32,
            },
            // Move the monotonic timer to the RTIC run-time, this enables
            // scheduling
            init::Monotonics(),
        )
    }
Enter fullscreen mode Exit fullscreen mode

Notice how our prior code practically didn't change here, cool, huh?!

The #[idle] Task

The idle task is also known as the background task that runs when there aren't any interrupts executing and is optional. Like before, the#[idle] attribute is followed immediately by an idle function that must have a fn(idle::Context) -> ! signature. Recall that all we did when idle is go to sleep. As such, the idle task code is as follows:

    #[idle]
    fn idle(_: idle::Context) -> ! {
        loop {
            cortex_m::asm::wfi();
        }
    }
Enter fullscreen mode Exit fullscreen mode

Here, device and core are available through the context in case either needs to be accessed. However, since I am accessing neither, I use the underscore _ identifier since I don't need to bind idle::Context to anything.

The Hardware Tasks

In our code, all that remains now is defining the hardware tasks that are bound to our interrupts. To bind an interrupt we need to use the #[task] attribute with the argument binds = InterruptName. As such, the task becomes the interrupt handler for the bound hardware interrupt vector. In this case for us, there are two hardware tasks, one that binds to the EXTI15_10 interrupt name and the other that binds to the TIM2interrupt name. Additionally, the attribute takes as arguments the resources that the task will be using. The attribute is also followed immediately by a function that you can name that has the following signature fn func_name (func_name::Context), where func_name is a name of your choice. As such, here is the code for the button press ISR:

#[task(binds = EXTI15_10, local = [delayval, button], shared=[timer])]
    fn button_pressed(mut ctx: button_pressed::Context) {

        // Obtain a copy of the delay value from the global context
        let mut delay = *ctx.local.delayval;

        // Adjust the amount of delay
        delay = delay - 500_u32;
        if delay < 500_u32 {
            delay = 2000_u32;
        }

        // Update delay value in global context
        *ctx.local.delayval = delay;

        // Update the timeout value in the timer peripheral
        ctx.shared
            .timer
            .lock(|tim| tim.start(delay.millis()).unwrap());

        // Obtain access to Button Peripheral and Clear Interrupt Pending Flag
        ctx.local.button.clear_interrupt_pending_bit();
    }
Enter fullscreen mode Exit fullscreen mode

As can be seen in the line #[task(binds = EXTI15_10, local = [button], shared=[delayval, timer])], I am binding the EXTI15_10 interrupt source to this task which has the function I am calling button_pressed. Additionally, I am passing as arguments the previously defined local and shared resources that the ISR is going to be using. The shared resources here are the ones that are going to be shared with the timer expiry ISR, which is only the timer peripheral timer.

Next, notice the lines in which I am accessing local resources. This includes ctx.local.button.clear_interrupt_pending_bit(); and let mut delay = *ctx.local.delayval;. Access to the local resources is obtained through local which is a field of the task context ctx. This is the same as what we had in the init task, we were able to access the device peripherals through the device and core fields in init::Context that is bound to the ctx handle. One more thing here is that delayval is dereferenced because ctx.local.delayval on its own is a &mut u32 but we need the u32 value. Also, delayval needs to be updated in the global context so that the value is preserved for the next time the task is called.

In the case of shared resources its not as straightforward. Since the resource is shared, we need to obtain a lock in which a token is provided in a closure to write the code. This is similar to our past code, albeit mush less verbose, in which we obtained a lock using interrupt::free. Again, like with local, we can access timer through the shared field and ctx. However, additionally, we need to use the lock method the provides access to the "locked" resource through a token in a closure. This is done in the line ctx.shared.timer.lock(|tim| tim.start(delay.millis()).unwrap()); where a new delay value is loaded to the timer shared resource.

Finally, all that remains is defining the timer expiry ISR. The following is the timer expiry ISR:

    #[task(binds = TIM2, local=[led], shared=[timer])]
    fn timer_expired(mut ctx: timer_expired::Context) {
        ctx.local.led.toggle();
        ctx.shared
            .timer
            .lock(|tim| tim.clear_interrupt(Event::Update));
    }
Enter fullscreen mode Exit fullscreen mode

The idea is exactly the same as the earlier button_pressed ISR. All that is done here is toggle the led which is local to the timer expiry ISR. Additionally, note that the shared timer resource timer is being locked here as well to clear the interrupt.

๐Ÿ“ฑ 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.

#![deny(unsafe_code)]
#![deny(warnings)]
#![no_main]
#![no_std]

use panic_halt as _;

#[rtic::app(device = stm32f4xx_hal::pac, peripherals = true)]
mod app {

    use stm32f4xx_hal::{
        gpio::{self, Edge, Input, Output, PushPull},
        pac::TIM2,
        prelude::*,
        timer::{self, Event},
    };

    // Resources shared between tasks
    #[shared]
    struct Shared {
        timer: timer::CounterMs<TIM2>,
    }

    // Local resources to specific tasks (cannot be shared)
    #[local]
    struct Local {
        button: gpio::PC13<Input>,
        led: gpio::PA5<Output<PushPull>>,
        delayval: u32,
    }

    #[init]
    fn init(ctx: init::Context) -> (Shared, Local, init::Monotonics) {
        let mut dp = ctx.device;

        // Configure the LED pin as a push pull ouput and obtain handle
        // On the Nucleo FR401 theres an on-board LED connected to pin PA5
        // 1) Promote the GPIOA PAC struct
        let gpioa = dp.GPIOA.split();
        // 2) Configure Pin and Obtain Handle
        let led = gpioa.pa5.into_push_pull_output();

        // Configure the button pin as input and obtain handle
        // On the Nucleo FR401 there is a button connected to pin PC13
        // 1) Promote the GPIOC PAC struct
        let gpioc = dp.GPIOC.split();
        // 2) Configure Pin and Obtain Handle
        let mut button = gpioc.pc13;

        // Configure Button Pin for Interrupts
        // 1) Promote SYSCFG structure to HAL to be able to configure interrupts
        let mut syscfg = dp.SYSCFG.constrain();
        // 2) Make button an interrupt source
        button.make_interrupt_source(&mut syscfg);
        // 3) Make button an interrupt source
        button.trigger_on_edge(&mut dp.EXTI, Edge::Rising);
        // 4) Enable gpio interrupt for button
        button.enable_interrupt(&mut dp.EXTI);

        // Configure and obtain handle for delay abstraction
        // 1) Promote RCC structure to HAL to be able to configure clocks
        let rcc = dp.RCC.constrain();
        // 2) Configure the system clocks
        // 8 MHz must be used for HSE on the Nucleo-F401RE board according to manual
        let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();
        // 3) Create delay handle
        //let mut delay = dp.TIM1.delay_ms(&clocks);
        let mut timer = dp.TIM2.counter_ms(&clocks);

        // Kick off the timer with 2 seconds timeout first
        // It probably would be better to use the global variable here but I did not to avoid the clutter of having to create a crtical section
        timer.start(2000.millis()).unwrap();

        // Set up to generate interrupt when timer expires
        timer.listen(Event::Update);

        (
            // Initialization of shared resources
            Shared { timer },
            // Initialization of task local resources
            Local {
                button,
                led,
                delayval: 2000_u32,
            },
            // Move the monotonic timer to the RTIC run-time, this enables
            // scheduling
            init::Monotonics(),
        )
    }

    // Background task, runs whenever no other tasks are running
    #[idle]
    fn idle(_: idle::Context) -> ! {
        loop {
            // Go to sleep
            cortex_m::asm::wfi();
        }
    }

    #[task(binds = EXTI15_10, local = [delayval, button], shared=[timer])]
    fn button_pressed(mut ctx: button_pressed::Context) {
        // When Button press interrupt happens three things need to be done
        // 1) Adjust Global Delay Variable
        // 2) Update Timer with new Global Delay value
        // 3) Clear Button Pending Interrupt

        // Obtain a copy of the delay value from the global context
        let mut delay = *ctx.local.delayval;

        // Adjust the amount of delay
        delay = delay - 500_u32;
        if delay < 500_u32 {
            delay = 2000_u32;
        }

        // Update delay value in global context
        *ctx.local.delayval = delay;

        // Update the timeout value in the timer peripheral
        ctx.shared
            .timer
            .lock(|tim| tim.start(delay.millis()).unwrap());

        // Obtain access to Button Peripheral and Clear Interrupt Pending Flag
        ctx.local.button.clear_interrupt_pending_bit();
    }

    #[task(binds = TIM2, local=[led], shared=[timer])]
    fn timer_expired(mut ctx: timer_expired::Context) {
        // When Timer Interrupt Happens Two Things Need to be Done
        // 1) Toggle the LED
        // 2) Clear Timer Pending Interrupt

        ctx.local.led.toggle();
        ctx.shared
            .timer
            .lock(|tim| tim.clear_interrupt(Event::Update));
    }
}
Enter fullscreen mode Exit fullscreen mode

## ๐Ÿ”ฌ Further Experimentation/Ideas

  • Refactor this code to mimic the GPIO interrupt application behavior that was based on GPIO interrupts. An example of the refactored code is available on the git repo. You can compare your refactored code to the one available in the repo.
  • If you have extra buttons, using the RTIC try implementing additional interrupts from other input pins where each button press applies a different delay and has a different source. This means that you would have to create additional interrupt tasks.
  • Convert the Analog Temperature Sensing Application to an interrupt based application using the RTIC.

Conclusion

It was shown in prior posts, examples of Rust applications that are completely driven by interrupts. It was also shown that doing interrupts in Rust can be a bit verbose for all the safe abstractions that are added. In this post, the RTIC was leveraged instead to demonstrate the process of porting code of an existing interrupt-based application created in Rust. The post also shows the power and ease in using the RTIC where safe guarantees are provided without much code verbosity. The application was created leveraging the STM32F401RE microcontroller on the Nucleo-F401RE development board. All code was created at the HAL level using the stm32f4xx Rust HAL. 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

Latest comments (0)