beaumont.dev posts

Embedded Rust with LoRa and ePaper - Refactoring & RTIC

Mar. 12, 2021

In the previous post, we created a state machine and implemented it in Rust as a combination of interrupt handlers and structs.

In this post, I want to cover converting over to RTIC, which is a sort of lightweight real-time OS/framework that focuses on making it easier to handle concurrency on cortex-m devices. The main thing RTIC is going to give us is better abstractions so that we can focus more on the “business logic” of our device.

I’ll also touch on some refactoring to improve our interrupt usage and Timer handling.

RTIC

Before moving up an abstraction level, I like making sure I understand things well enough at the previous level so I can generally understand what the next level is giving us and how it does so. Now that we’ve seen the basic abstractions provided to us by the cortex-m crate, it’s a good time to move “up” to RTIC.

Migrating over was a very straightforward process since the way we structured everything maps really well to RTIC’s abstractions. By the way, there’s a lot more details available in the RTIC book so I won’t go into everything here.

The main improvement we’ll see is RTIC managing the tedious static Mutex<RefCell<Option<T>>>s for us as well as avoiding both the noisy critical sections and the borrow, as_ref, unwrap chains necessary to use these shared values.

The idea behind RTIC is that we have a set of Resources that are initialized and then shared between Tasks. We declare all of these things inside an item with the [#app] attribute (a const module-level item, for reasons, but just imagine a mod or crate-level attribute). We also provide the PAC crate we’re using and ask for access to the device-specific peripherals.

src/main.rs
#[app(device = stm32l0::stm32l0x2, peripherals = true)]
const APP: () = {

We can convert our shared resources:

src/main.rs
static STATUS: Mutex<RefCell<Option<status::StatusLights<PBOut, PBOut, PBOut>>>> =
    Mutex::new(RefCell::new(None));
static BUTTON: Mutex<RefCell<Option<gpiob::PB2<Input<PullUp>>>>> = Mutex::new(RefCell::new(None));
static MORSE: Mutex<RefCell<Option<machine::MorseMachine>>> = Mutex::new(RefCell::new(None));
static TIMER: Mutex<RefCell<Option<Timer<TIM2>>>> = Mutex::new(RefCell::new(None));

into the following Resources struct:

src/main.rs
    struct Resources {
        button: gpiob::PB2<Input<PullUp>>,
        status: status::StatusLights<PBOut, PBOut, PBOut>,
        timer: Timer<TIM2>,
        #[init(machine::MorseMachine::new(DOT_TICKS))]
        morse: machine::MorseMachine,
    }

#[init(...)] allows us to statically initialize a variable, which we can do with const fn machine::MorseMachine::new (I updated new in between this and the last post to be const, it could now also be used directly Mutex<RefCell<T>> to avoid the Option).

init

The init function is where we initialize whatever parts of our Resources weren’t statically initialized. In our case, it will be identical to our main function except we have direct access to the core peripherals and the device peripherals. Finally, we return init::LateResources which is made up of exactly the values needed to fill in our Resources struct. The magic of code generation!

src/main.rs
    #[init]
    fn init(init::Context { core, device }: init::Context) -> init::LateResources {
        // Configure the clock at the default speed
        let mut rcc = device.RCC.freeze(hal::rcc::Config::default());
src/main.rs
        init::LateResources {
            button,
            status,
            timer: device.TIM2.timer(TICK_LENGTH, &mut rcc),
        }

task

We mark each interrupt handler as a task and tell RTIC which resources we need:

src/main.rs
    #[task(binds = TIM2, resources = [status, timer, morse])]
    fn timer(

Our function is then called with those resources passed in:

src/main.rs
    fn timer(
        timer::Context {
            resources:
                timer::Resources {
                    status,
                    timer,
                    morse,
                    ..
                },
        }: timer::Context,
    ) {

From then on our code remains the same except we have direct access to the resources and don’t have to go through Mutexes and RefCells (there are cases where we need to use a lock to get access to the resource, but RTIC will enforce this if and only if necessary).

src/main.rs
fn TIM2() {
    static mut FLASH: Option<u8> = None;
    const FLASH_TICKS: u8 = 2;
    cortex_m::interrupt::free(|cs| {
        let mut mm = MORSE.borrow(cs).borrow_mut();
        let mut status = STATUS.borrow(cs).borrow_mut();
        let mut timer = TIMER.borrow(cs).borrow_mut();
        timer.as_mut().unwrap().clear_irq();
        if let Some(state_change) = mm.as_mut().unwrap().tick() {
src/main.rs
        static mut FLASH: Option<u8> = None;
        timer.clear_irq();
        if let Some(state_change) = morse.tick() {
            match state_change {
                machine::Transition::Long => status.as_mut().unwrap().on_long(),
                machine::Transition::VeryLong => status.as_mut().unwrap().busy(),
                machine::Transition::Long => status.on_long(),
                machine::Transition::VeryLong => status.busy(),
                machine::Transition::Transmit => {
                    // send letters

Our button handler is improved similarly.

Fewer interrupts

In the previous post we implemented our morse code machine by counting timer “ticks”, to avoid having to deal with setting different timers. The downside of this is that we run an interrupt every tick and most of the time nothing happens.

Timeless morse machine

Instead of counting, we can set up our Timer to countdown to exactly the time we need, depending on our current state, so that we only tick once per transition. I.e. a tick moves us directly from short to long, long to very long, or waiting to idle.

The new MorseTimelessMachine now no longer uses Button to count but instead uses State directly. We also wrap the new machine with a MorseTimingMachine that handles manipulating our timer peripheral and dealing with interrupts for us.

src/machine.rs
impl PressType {
    pub fn tick(&self) -> Self {
        match self {
            Self::Short => Self::Long,
            Self::Long => Self::VeryLong,
            Self::VeryLong => Self::VeryLong,
        }
    }
src/machine.rs
impl State {
    fn release(&mut self) -> Self {
        Self::WaitingOnPress
    }

    fn press(&mut self) -> Self {
        Self::Press(PressType::Short)
    }

    fn tick(&self) -> Self {
        match self {
            Self::WaitingOnPress => Self::Idle,
            Self::Press(p) => Self::Press(p.tick()),
            Self::Idle => Self::Idle,
        }
    }

In MorseTimelessMachine, use State::tick in combination with the current MorseCode:

src/machine.rs
    fn next_state(&self) -> State {
        match (self.current.is_empty(), self.state) {
            (false, State::Press(PressType::Long)) => self.state,
            (_, s) => s.tick(),
        }
    }

Finally, we move all Timer handling into MorseTimingMachine, which is the struct we expose for main.rs:

src/machine.rs
impl MorseTimingMachine {
    pub const fn new(dot_length: MicroSeconds) -> Self {
        Self {
            long_press: dot_length,
            very_long_press: MicroSeconds(2 * dot_length.0),
            timeout: MicroSeconds(3 * dot_length.0),
            machine: MorseTimelessMachine::new(),
        }
    }

    pub fn press(&mut self, timer: &mut Timer<TIM2>) {
        self.machine.press();
        timer.clear_irq();
        timer.start(self.long_press);
        timer.listen();
    }

    pub fn release(&mut self, timer: &mut Timer<TIM2>) {
        self.machine.release();
        timer.clear_irq();
        let timeout = if self.machine.current == morse::TRANSMIT {
            1.ms()
        } else {
            self.timeout
        };
        timer.start(timeout);
        timer.listen();
    }

    pub fn tick(&mut self, timer: &mut Timer<TIM2>) -> Option<Transition> {
        timer.clear_irq();
        let transition = self.machine.tick();
        if let Some(ref state_change) = transition {
            match state_change {
                Transition::Long => timer.start(self.very_long_press),
                Transition::VeryLong | Transition::Transmit | Transition::Character(_) => {
                    timer.unlisten()
                }
            }
        }
        transition
    }
}

The upside of the Timeless/Timing distinction is that testing is significantly easier and as a state machine, Timeless is much more similar to the abstract machine from last post.

src/machine.rs
    #[test]
    fn test_morse_multiple() {
        let mut machine = MorseTimelessMachine::new();
        machine.press().release().press().tick();
        assert_eq!(
            machine.release().tick(),
            Some(Transition::Character('a' as u8))
        );
    }

The less testable Timer handling is left up to MorseTimingMachine.

Flashing lights

Instead of having a static mut local to our timer task, we can put the flash functionality into StatusLights. Similar to the new Machine, we let the Flasher handle our Timer.

src/status.rs
        self.green.set_low().unwrap();
        self.blue.set_low().unwrap();
    }

    pub fn flash_busy(&mut self, timer: &mut Timer<TIM2>) {
        self.busy();
        self.flasher.flash(timer);
    }

    pub fn flash_tick(&mut self, timer: &mut Timer<TIM2>) {
        if self.flasher.tick(timer) {
            self.off();
        }
    }
}

struct Flasher {
    flashing: bool,
}

impl Flasher {
    const fn new() -> Self {
        Flasher { flashing: false }
    }

    fn flash(&mut self, timer: &mut Timer<TIM2>) {
        self.flashing = true;
        timer.start(10.ms());
        timer.listen();
    }

    fn tick(&mut self, timer: &mut Timer<TIM2>) -> bool {
        let turn_off = self.flashing;
        if self.flashing {
            timer.unlisten();
            self.flashing = false;
        }
        turn_off
    }
}

Improvements

The tasks in src/main.rs are now significantly easier to read, due to making our various state machines much more explicit. We can immediately see each transition as well as what triggers them just from looking at the interrupt handlers/tasks.

src/main.rs
                    // send letters
                }
                machine::Transition::Character(ch) => {
                    *FLASH = Some(FLASH_TICKS);
                    status.busy();
                    status.flash_busy(timer);
                    // handle letter
                }
            }
        } else if let Some(flash_count) = *FLASH {
            if flash_count == 0 {
                *FLASH = None;
                timer.unlisten();
                status.off();
            } else {
                *FLASH = Some(flash_count - 1);
            }
        } else {
            status.flash_tick(timer);
        }
    }
src/main.rs
    ) {
        Exti::unpend(GpioLine::from_raw_line(button.pin_number()).unwrap());
        if button.is_low().unwrap() {
            morse.press();
            timer.listen();
            morse.press(timer);
            status.on_short();
        } else {
            morse.release();
            morse.release(timer);
            status.off();
        }
    }

This post was focused on refactoring in embedded Rust. Hopefully it’s interesting to see an example of using RTIC for a real device as well as how to best design interactions between state machines and hardware.

The next post will (really) cover keeping a stack of inputted characters and handling the TRANSMIT value to broadcast over the radio.