Squeezing rust onto more things, this time: Flashlights

For a while now I have been pretty interested in niche torches that run the Andúril flashlight firmware, which is written in C and supports many flashlights that use AVR microcontrollers, such as Hank’s and Fireflylite.
In my opinion the standout features of Andúril are:
- Support for aux LEDs: it’s common for these flashlights to have a set of RGB LEDs on the front, Andúril supports using these to show the battery voltage by changing between 6 colours.
- Many different mode, some useful like the candle flicker mode, which along with the normal mode supports a fading-off timer, and some silly modes like a police strobe (flashes the aux lights between red and blue.)
- Very good thermal and battery voltage handling: the maximum output is smoothy adjusted to regulate the temperature of the light, and prevent the battery voltage dipping too low.
- Open source and user flashable: all (or most?) flashlights sold with Andúril have accessible flashing pads on the exposed side of the driver circuit board, making it easy for people to reflash the firmware.
I dabbled in customising Andúril for about a year while I was also playing around with writing rust firmware for keyboards^rusty-keyboards, and in January 2024 I decided to try squeezing (async) Rust onto an AVR chip. In this case the chip-to-be was an attiny1616 — which has 16kb of flash and 2kb of ram.
AVR Beginnings
I decided I certainly wanted to use Embassy^embassy for writing the firmware, I had used it previously with my keyboards and loved how easy it made breaking up the individual tasks that needed to be performed into their own asynchronous tasks.
However, unlike microcontroller platforms such as nRF, STM32, and RP2040, Embassy does not have excellent support for AVR. Fortunately, it isn’t difficult to get Embassy up and running on new hardware as long as Rust supports it. Embassy only needs a hardware specific timer queue implementation, which is used to schedule tasks for wakeup.
One other slight issue was that there was no Rust peripheral access crate for the attiny1616, but luckily there was already the avr-device crate, which has support for some similar AVR chips, and scripts for generating PACs from machine readable register description documents.
For those unfamiliar with embedded (and specifically embedded Rust development), the ecosystem is generally broken down into:
- Peripheral Access Crates (PAC): Provide safe Rust abstractions for reading and writing to memory mapped IO registers.
- Hardware Abstraction Libraries (HAL): Provide ergonomic Rust bindings for peripherals which often provide fully compile time verified configuration and usage of peripherals. (in my opinion HALs in Rust are much nicer to use than in other languages, as documentation is easily explored and the type system makes sure you’re getting everything right)
- Instruction set and runtime support crates: Provide helper functions and the necessities to get the CPU going before
maincan run. (e.g. cortex-m(-rt))
After some fiddling I was able to generate the PAC for the attiny1616, and with that I was able to start poking registers and write functions such as this one to configure the ADC peripheral:
1impl AdcRegExt for ADC0 {2 // ...34 fn set_c_state(&mut self, prescaler: Self::PreScaler, refsel: Self::RefSel, sampcap: bool) {5 self.ctrlc().modify(|_, w| {6 w.presc()7 .variant(prescaler)8 .refsel()9 .variant(refsel)10 .sampcap()11 .variant(sampcap)12 })13 }14}1impl AdcRegExt for ADC0 {2 // ...34 fn set_c_state(&mut self, prescaler: Self::PreScaler, refsel: Self::RefSel, sampcap: bool) {5 self.ctrlc().modify(|_, w| {6 w.presc()7 .variant(prescaler)8 .refsel()9 .variant(refsel)10 .sampcap()11 .variant(sampcap)12 })13 }14}Also very lucky for me was that someone had already put in the effort of writing a hardware abstraction library for tinyAVR microcontrollers (of which the t1616 is a member of), which only needs a working PAC to provide safe rust abstractions for configuring clocks, GPIO pins, timers, and the ADC.
Now that we have a PAC for the attiny1616 we can proceed with implementing a timer queue so that we can get some tasks running.
Timer queue
It’s a common requirement in many situations that a delay can be inserted between two operations, and I suppose it’s even more common in embedded systems where simple inputs need to trigger complex outputs and vice-versa. In fact at the time of writing my flashlight firmware uses eight timeouts and fifteen sleeps.
Now in many embedded codebases, especially those written using Arduino and such, it is usual that delays are implemented using busy loops that count cycles until a period has passed. This is usually because the alternative is to do horrible things like manually breaking up your code into state machines or use a RTOS that provides stackful[^stackful_tasks] tasks (and you’ll struggle to get a stackful task scheduler onto an AVR microcontroller with only 2k of RAM).
I’ll spare you the usual Rust async spiel about how async functions map to the Future trait, the gist is that with Rust’s async the compiler does the state-machine-ification for you, so you can write nice sequential code that once compiled doesn’t require wasteful stack switching to achive concurrency. With all our tasks being state machines, our sleep(...) function can just be an instruction to the scheduler to not schedule our task until the timeout has elapsed (This is of course how a sleep function works everywhere it isn’t a busy loop). With out sleeping task sleeping, the scheduler can choose to run another task if one is ready, or if all the tasks are waiting on something the scheduler can choose to put the microcontroller to sleep instead.
The core of this functionality is the timer queue, and it simply has two jobs:
- When a task wants to sleep, Embassy passes to the timer queue a
Wakerand a timestamp indicating when the waker should be woken. - When this timer is reached, the timer queue should call the
.wake()method of the waker, which does whatever is necessary to mark the task as ready to run again.
Embassy models this as a Trait with a single schedule_wake method:
1struct MyTimerQueue{}; // not public!23impl TimerQueue for MyTimerQueue {4 fn schedule_wake(&'static self, at: u64, waker: &Waker) {5 todo!()6 }7}1struct MyTimerQueue{}; // not public!23impl TimerQueue for MyTimerQueue {4 fn schedule_wake(&'static self, at: u64, waker: &Waker) {5 todo!()6 }7}So to implement a timer queue you need only to implement this trait, and some way to handle waking. On embedded devices there is in general two ways to do this:
Have an interrupt fire periodically (say, at 1000Hz), the handler to this interrupt can step a counter and then wake up all the tasks which have timestamps that are now in the past.
This solution is simple, but forces the microcontroller to wake up periodically even when there’s no work to do.
Configure a hardware timer to fire an interrupt when the next timer is due, then process elapsed timeouts as with (.1)
This is more complicated as you have to handle cancelling and restarting a hardware timer, but allows the system to sleep uninterrupted for longer periods of time.
I chose to use a periodic interrupt for the simplicity of implementation, as there’s always the option to switch to dynamically reconfiguring the timer in the future.
To begin with, we need to define how the state of the timer queue is stored. For my implementation I store for each queue entry:
1use core::{2 cell::Cell,3 task::Waker,4};56use avr_device::interrupt::{CriticalSection, Mutex};78pub type Time = u32;910const QUEUE_SIZE: usize = 10;1112struct Entry {13 at: Time,14 waker: Waker,15}1617/// An array of queue entries. The `Mutex<Cell<_>>` here is actually18/// a noop at runtime, and just serves to prove we're inside a19/// critical section when accessing the entries20static ENTRIES: [Mutex<Cell<Option<Entry>>>; QUEUE_SIZE] =21 [const { Mutex::new(Cell::new(None)) }; QUEUE_SIZE];1use core::{2 cell::Cell,3 task::Waker,4};56use avr_device::interrupt::{CriticalSection, Mutex};78pub type Time = u32;910const QUEUE_SIZE: usize = 10;1112struct Entry {13 at: Time,14 waker: Waker,15}1617/// An array of queue entries. The `Mutex<Cell<_>>` here is actually18/// a noop at runtime, and just serves to prove we're inside a19/// critical section when accessing the entries20static ENTRIES: [Mutex<Cell<Option<Entry>>>; QUEUE_SIZE] =21 [const { Mutex::new(Cell::new(None)) }; QUEUE_SIZE];We then need a function to allocate an entry on this timer queue:
12/// Allocate an entry, returning on success the index, and whether3/// there was already an entry for this waker4pub fn allocate(5 /// A handle to a critical section, this proves that interrupts6 /// are disabled while this function is called7 _: CriticalSection,8 /// The waker we're allocating for, used so we only ever have9 /// one entry in the queue for each task/ waker10 waker: &Waker) -> Option<(NonMaxU8, bool)> {11 unsafe {12 for i in 0..QUEUE_SIZE {13 // if this entry is taken, but is allocted for the same14 // waker, return that.15 // this happens when a future tries to sleep again after16 // being woken by something other than a timeout17 if TAKEN[i] && WAKERS[i].as_ref().map_or(false, |w| w.will_wake(waker)) {18 return Some((NonMaxU8(i as u8), true));19 }20 }21 for i in 0..QUEUE_SIZE {22 // otherwise, return the first empty slot23 if !TAKEN[i] {24 TAKEN[i] = true;25 return Some((NonMaxU8(i as u8), false));26 }27 }2829 None30 }31}3233/// Add a waker to the queue, correctly handles when the34/// waker is already in the queue35pub fn allocate(36 /// A handle to a critical section, this proves that interrupts37 /// are disabled while this function is called38 cs: CriticalSection,39 at: Time,40 waker: &Waker) {4142 // do a first pass over the entries to ensure the waker43 // isn't already in the queue44 for entry in &ENTRIES {45 // does rust optimise out the copies here? Ich weiß es nicht46 let entry = entry.borrow(cs);47 let e = entry.replace(None);4849 if let Some(mut e) = e {50 // waker is the same, simply store back the earliest time51 if e.waker.will_wake(waker) {52 e.at = at.min(e.at);5354 entry.set(Some(e));55 return;56 } else {57 entry.set(Some(e));58 }59 }60 }6162 // if the waker isn't already in the queue,63 // find the first unused entry and store it there64 for entry in &ENTRIES {65 let entry = entry.borrow(cs);66 let e = entry.replace(None);6768 if e.is_some() {69 entry.set(e);70 continue;71 }7273 entry.set(Some(Entry { at, waker: waker.clone() }));7475 return;76 }7778 // Ideally this could only be hit if we have more tasks79 // than the queue capacity80 panic!("queue full");81}8212/// Allocate an entry, returning on success the index, and whether3/// there was already an entry for this waker4pub fn allocate(5 /// A handle to a critical section, this proves that interrupts6 /// are disabled while this function is called7 _: CriticalSection,8 /// The waker we're allocating for, used so we only ever have9 /// one entry in the queue for each task/ waker10 waker: &Waker) -> Option<(NonMaxU8, bool)> {11 unsafe {12 for i in 0..QUEUE_SIZE {13 // if this entry is taken, but is allocted for the same14 // waker, return that.15 // this happens when a future tries to sleep again after16 // being woken by something other than a timeout17 if TAKEN[i] && WAKERS[i].as_ref().map_or(false, |w| w.will_wake(waker)) {18 return Some((NonMaxU8(i as u8), true));19 }20 }21 for i in 0..QUEUE_SIZE {22 // otherwise, return the first empty slot23 if !TAKEN[i] {24 TAKEN[i] = true;25 return Some((NonMaxU8(i as u8), false));26 }27 }2829 None30 }31}3233/// Add a waker to the queue, correctly handles when the34/// waker is already in the queue35pub fn allocate(36 /// A handle to a critical section, this proves that interrupts37 /// are disabled while this function is called38 cs: CriticalSection,39 at: Time,40 waker: &Waker) {4142 // do a first pass over the entries to ensure the waker43 // isn't already in the queue44 for entry in &ENTRIES {45 // does rust optimise out the copies here? Ich weiß es nicht46 let entry = entry.borrow(cs);47 let e = entry.replace(None);4849 if let Some(mut e) = e {50 // waker is the same, simply store back the earliest time51 if e.waker.will_wake(waker) {52 e.at = at.min(e.at);5354 entry.set(Some(e));55 return;56 } else {57 entry.set(Some(e));58 }59 }60 }6162 // if the waker isn't already in the queue,63 // find the first unused entry and store it there64 for entry in &ENTRIES {65 let entry = entry.borrow(cs);66 let e = entry.replace(None);6768 if e.is_some() {69 entry.set(e);70 continue;71 }7273 entry.set(Some(Entry { at, waker: waker.clone() }));7475 return;76 }7778 // Ideally this could only be hit if we have more tasks79 // than the queue capacity80 panic!("queue full");81}82And that’s all we need to implement the first half of the timer queue, we just need to provide an interface for Embassy to use it:
1pub struct AvrTc0EmbassyTimeDriver {}23impl TimerQueue for AvrTc0EmbassyTimeDriver {4 fn schedule_wake(&'static self, at: Time, waker: &Waker) {5 avr_device::interrupt::free(|t| {6 wake_queue::allocate(t, at, waker);7 })8 }9}1pub struct AvrTc0EmbassyTimeDriver {}23impl TimerQueue for AvrTc0EmbassyTimeDriver {4 fn schedule_wake(&'static self, at: Time, waker: &Waker) {5 avr_device::interrupt::free(|t| {6 wake_queue::allocate(t, at, waker);7 })8 }9}Now that we can add to our timer queue, we just need to periodically process the entries and wake up tasks which need waking up. I chose to do this using the Periodic Interrupt Timer functionality (PIT) of the Real Time Clock (RTC) peripheral on the AVR:
1pub static TICKS_ELAPSED: Mutex<Cell<Time>> = Mutex::new(Cell::new(0));23#[allow(dead_code)]4const TICKS_PER_COUNT: Time = 1;56/// Check each entry in the queue, if the timer has elapsed,7/// then wake the associated task8pub fn process(ticks_elapsed: Time) {9 for entry in &ENTRIES {10 let w = avr_device::interrupt::free(|cs| {11 let entry = entry.borrow(cs);12 let e = entry.replace(None);1314 if let Some(e) = e {15 if e.at <= ticks_elapsed {16 return Some(e.waker);17 } else {18 entry.set(Some(e));19 }20 }2122 None23 });2425 if let Some(w) = w {26 w.wake();27 }28 }29}3031// A flag we use to ensure we don't try to process the timer queue32// recursively if handle_tick is entered again somehow33static IN_PROGRESS: Mutex<Cell<bool>> = Mutex::new(Cell::new(false));3435pub fn mark_in_progress(cs: CriticalSection) -> bool {36 !IN_PROGRESS.borrow(cs).replace(true)37}3839pub fn mark_finished(cs: CriticalSection) {40 IN_PROGRESS.borrow(cs).set(false);41}4243// Declare an interrupt handler for the RTC_PIT interrupt44#[avr_device::interrupt(attiny1616)]45unsafe fn RTC_PIT() {46 handle_tick()47}4849#[inline(always)]50pub unsafe fn handle_tick() {51 let (should_process, ticks_elapsed) = avr_device::interrupt::free(|t| {52 // increment the global ticks counter53 let elapsed = TICKS_ELAPSED.borrow(t).get() + 1;54 TICKS_ELAPSED.borrow(t).set(elapsed);5556 // ensure we're not already processing the queue57 (mark_in_progress(t), elapsed)58 });5960 if should_process {61 wake_queue::process(ticks_elapsed);62 }6364 avr_device::interrupt::free(|t| {65 if should_process {66 mark_finished(t);67 }68 let mut state = INTERRUPT_STATE.borrow(t).borrow_mut();69 // clear the interrupt flag70 state.as_mut().unwrap().counter.clear_interrupt();71 });72}7374/// Configure the RTC with the PIT enabled, firing at a rate of 1024Hz75pub fn init_system_time(tc: RTC) {76 unsafe {77 avr_device::interrupt::enable();78 avr_device::interrupt::free(|t| {79 TICKS_ELAPSED.borrow(t).set(0);8081 let pitconfig = PitConfig::new(1, RTCClockSource::OSCULP32K_32K, PERIOD_A::CYC32);8283 let mut pit = Pit::from_rtc(tc, pitconfig.clock_source, pitconfig.period);84 pit.enable_interrupt();85 pit.start();8687 *INTERRUPT_STATE.borrow(t).borrow_mut() = Some(InterruptState {88 counter: pit,89 });90 });91 }92}1pub static TICKS_ELAPSED: Mutex<Cell<Time>> = Mutex::new(Cell::new(0));23#[allow(dead_code)]4const TICKS_PER_COUNT: Time = 1;56/// Check each entry in the queue, if the timer has elapsed,7/// then wake the associated task8pub fn process(ticks_elapsed: Time) {9 for entry in &ENTRIES {10 let w = avr_device::interrupt::free(|cs| {11 let entry = entry.borrow(cs);12 let e = entry.replace(None);1314 if let Some(e) = e {15 if e.at <= ticks_elapsed {16 return Some(e.waker);17 } else {18 entry.set(Some(e));19 }20 }2122 None23 });2425 if let Some(w) = w {26 w.wake();27 }28 }29}3031// A flag we use to ensure we don't try to process the timer queue32// recursively if handle_tick is entered again somehow33static IN_PROGRESS: Mutex<Cell<bool>> = Mutex::new(Cell::new(false));3435pub fn mark_in_progress(cs: CriticalSection) -> bool {36 !IN_PROGRESS.borrow(cs).replace(true)37}3839pub fn mark_finished(cs: CriticalSection) {40 IN_PROGRESS.borrow(cs).set(false);41}4243// Declare an interrupt handler for the RTC_PIT interrupt44#[avr_device::interrupt(attiny1616)]45unsafe fn RTC_PIT() {46 handle_tick()47}4849#[inline(always)]50pub unsafe fn handle_tick() {51 let (should_process, ticks_elapsed) = avr_device::interrupt::free(|t| {52 // increment the global ticks counter53 let elapsed = TICKS_ELAPSED.borrow(t).get() + 1;54 TICKS_ELAPSED.borrow(t).set(elapsed);5556 // ensure we're not already processing the queue57 (mark_in_progress(t), elapsed)58 });5960 if should_process {61 wake_queue::process(ticks_elapsed);62 }6364 avr_device::interrupt::free(|t| {65 if should_process {66 mark_finished(t);67 }68 let mut state = INTERRUPT_STATE.borrow(t).borrow_mut();69 // clear the interrupt flag70 state.as_mut().unwrap().counter.clear_interrupt();71 });72}7374/// Configure the RTC with the PIT enabled, firing at a rate of 1024Hz75pub fn init_system_time(tc: RTC) {76 unsafe {77 avr_device::interrupt::enable();78 avr_device::interrupt::free(|t| {79 TICKS_ELAPSED.borrow(t).set(0);8081 let pitconfig = PitConfig::new(1, RTCClockSource::OSCULP32K_32K, PERIOD_A::CYC32);8283 let mut pit = Pit::from_rtc(tc, pitconfig.clock_source, pitconfig.period);84 pit.enable_interrupt();85 pit.start();8687 *INTERRUPT_STATE.borrow(t).borrow_mut() = Some(InterruptState {88 counter: pit,89 });90 });91 }92}We’re almost done, the last thing to do is tell Embassy how to read what the current time is:
1impl Driver for AvrTc0EmbassyTimeDriver {2 #[inline(always)]3 fn now(&self) -> Time {4 avr_hal_generic::avr_device::interrupt::free(|cs|5 TICKS_ELAPSED.borrow(cs).get()6 );7 }89 // ... there's some more stuff here but it's unimportant10}1impl Driver for AvrTc0EmbassyTimeDriver {2 #[inline(always)]3 fn now(&self) -> Time {4 avr_hal_generic::avr_device::interrupt::free(|cs|5 TICKS_ELAPSED.borrow(cs).get()6 );7 }89 // ... there's some more stuff here but it's unimportant10}And with that, we can now use Embassy:
1#[embassy_executor::task]2async fn blink(pin: atxtiny_hal::gpio::PA7<Input>) {3 let mut pin = pin.into_push_pull_output();4 loop {5 pin.toggle();67 embassy_time::Timer::after_millis(500).await;8 }9}1#[embassy_executor::task]2async fn blink(pin: atxtiny_hal::gpio::PA7<Input>) {3 let mut pin = pin.into_push_pull_output();4 loop {5 pin.toggle();67 embassy_time::Timer::after_millis(500).await;8 }9}Async peripheral drivers
With timers out of the way we can now look into implementing peripheral drivers that are async compatible. Microcontrollers are already all setup for this as it’s common for peripherals to fire interrupts when its state changes, so we can simply just hook up an interrupt handler to wake up tasks waiting on the peripheral.
As an example, for GPIO pins it is common to want to wait until the state of an input pin changes in some way, such as low to high, or high to low. On AVR you may configure the microcontroller to fire an interrupt when such a state transition happens.
This means we can easily build a Rust future which configures pin interrupts for a pin, and then registers a waker such that when an interrupt is fired for the pin, the task is woken back up.
To implement this for AVR I started with declaring a place to store a waker for each pin:
1// GPIO pins on AVR are grouped into 'ports'2const PORTA_PIN_COUNT: usize = 8;3const PORTB_PIN_COUNT: usize = 8;4const PORTC_PIN_COUNT: usize = 6;56// AtomicWaker is effectively just `Mutex<Cell<Option<Waker>>>`7static WAKERS: [AtomicWaker; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT] =8 [const { AtomicWaker::new() }; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT];91011fn get_waker(port: u8, pin: u8) -> &'static AtomicWaker {12 &WAKERS[(port * PORTA_PIN_COUNT as u8 + pin) as usize]13 // omitting the bounds check saves only 8 bytes, it'd be ideal if it could14 // be elided.15 //16 // unsafe { WAKERS.get_unchecked((port * PORTA_PIN_COUNT as u8 + pin) as usize) }17}1// GPIO pins on AVR are grouped into 'ports'2const PORTA_PIN_COUNT: usize = 8;3const PORTB_PIN_COUNT: usize = 8;4const PORTC_PIN_COUNT: usize = 6;56// AtomicWaker is effectively just `Mutex<Cell<Option<Waker>>>`7static WAKERS: [AtomicWaker; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT] =8 [const { AtomicWaker::new() }; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT];91011fn get_waker(port: u8, pin: u8) -> &'static AtomicWaker {12 &WAKERS[(port * PORTA_PIN_COUNT as u8 + pin) as usize]13 // omitting the bounds check saves only 8 bytes, it'd be ideal if it could14 // be elided.15 //16 // unsafe { WAKERS.get_unchecked((port * PORTA_PIN_COUNT as u8 + pin) as usize) }17}Then we can declare the interrupt handlers for the pin interrupts, which will wake up any wakers for pins that have an interrupt pending.
1// To reduce code size, the true handler for pin interrupts is this function,2// which is passed the port for which the interrupt was served and wakes up3// any wakers for pins which have a pending interrupt.4fn int_handler(gpio: &dyn GpioInt, port: u8, pin_count: u8) {5 for i in 0..pin_count {6 if gpio.is_pending(i) {7 get_waker(port, i).wake();89 // clear and disable the interrupt, disabling the interrupt10 // is used to signal that the pin was woken.11 gpio.clear(i);12 }13 }14}1516// Pin interrupts on AVR are grouped to the port the pin belongs to, the17// pin has a 'pending interrupt' flag which is used to check which pin(s)18// the interrupt was fired for.19#[avr_device::interrupt(attiny1616)]20unsafe fn PORTA_PORT() {21 int_handler(&*PORTA::PTR as &dyn GpioInt, 0, PORTA_PIN_COUNT as u8);22}2324#[avr_device::interrupt(attiny1616)]25unsafe fn PORTB_PORT() {26 int_handler(&*PORTB::PTR as &dyn GpioInt, 1, PORTB_PIN_COUNT as u8);27}2829#[avr_device::interrupt(attiny1616)]30unsafe fn PORTC_PORT() {31 int_handler(&*PORTC::PTR as &dyn GpioInt, 2, PORTC_PIN_COUNT as u8);32}3334// Helper trait used for its vtable, this seems to have the least35// code size impact.36trait GpioInt {37 fn is_pending(&self, n: u8) -> bool;38 fn clear(&self, n: u8);39}4041impl<T: GpioRegExt> GpioInt for T {42 fn is_pending(&self, n: u8) -> bool {43 // we need this proxy method as GpioRegExt isn't object safe44 self.interrupt_pending(n)45 }4647 fn clear(&self, n: u8) {48 // enabling input buffering disables the interrupt49 self.enable_input_buffer(n);50 self.clear_interrupt_pending(n);51 }52}1// To reduce code size, the true handler for pin interrupts is this function,2// which is passed the port for which the interrupt was served and wakes up3// any wakers for pins which have a pending interrupt.4fn int_handler(gpio: &dyn GpioInt, port: u8, pin_count: u8) {5 for i in 0..pin_count {6 if gpio.is_pending(i) {7 get_waker(port, i).wake();89 // clear and disable the interrupt, disabling the interrupt10 // is used to signal that the pin was woken.11 gpio.clear(i);12 }13 }14}1516// Pin interrupts on AVR are grouped to the port the pin belongs to, the17// pin has a 'pending interrupt' flag which is used to check which pin(s)18// the interrupt was fired for.19#[avr_device::interrupt(attiny1616)]20unsafe fn PORTA_PORT() {21 int_handler(&*PORTA::PTR as &dyn GpioInt, 0, PORTA_PIN_COUNT as u8);22}2324#[avr_device::interrupt(attiny1616)]25unsafe fn PORTB_PORT() {26 int_handler(&*PORTB::PTR as &dyn GpioInt, 1, PORTB_PIN_COUNT as u8);27}2829#[avr_device::interrupt(attiny1616)]30unsafe fn PORTC_PORT() {31 int_handler(&*PORTC::PTR as &dyn GpioInt, 2, PORTC_PIN_COUNT as u8);32}3334// Helper trait used for its vtable, this seems to have the least35// code size impact.36trait GpioInt {37 fn is_pending(&self, n: u8) -> bool;38 fn clear(&self, n: u8);39}4041impl<T: GpioRegExt> GpioInt for T {42 fn is_pending(&self, n: u8) -> bool {43 // we need this proxy method as GpioRegExt isn't object safe44 self.interrupt_pending(n)45 }4647 fn clear(&self, n: u8) {48 // enabling input buffering disables the interrupt49 self.enable_input_buffer(n);50 self.clear_interrupt_pending(n);51 }52}Then on the other side we just need to create a Future which configures the interrupt and registers the waker:
1struct InputFuture<'d, Gpio, Index> {2 // A 'PeripheralRef' to the pin this future is for, this is a3 // Zero Sized Type that represents `&'d Pin<...>`4 pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>,5}67impl<'d, Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index>8 InputFuture<'d, Gpio, Index>9{10 // configure the interrupt when we create the future11 fn new(mut pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>, edge: Edge) -> Self {12 // clear the interrupt first in case the previous future was dropped13 pin.0.clear_interrupt();1415 pin.0.configure_interrupt(edge);1617 Self { pin }18 }19}2021impl<'d, Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index> Future22 for InputFuture<'d, Gpio, Index>23{24 type Output = ();2526 fn poll(27 self: core::pin::Pin<&mut Self>,28 cx: &mut core::task::Context<'_>,29 ) -> core::task::Poll<Self::Output> {30 let pin_idx = self.pin.0.pin_index();31 let waker = get_waker(self.pin.0.port_index(), pin_idx);3233 waker.register(cx.waker());3435 // creating the future enabled the interrupt, if it is disabled36 // then we know the interrupt handler fired for this pin and37 // disabled the interrupt on it.38 if !self.pin.0.is_interrupt_enabled() {39 return Poll::Ready(());40 }4142 Poll::Pending43 }44}454647impl<Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index>48 Pin<Gpio, Index, Input>49{50 pub fn wait(&mut self, edge: Edge) -> impl Future<Output = ()> + '_ {51 InputFuture::new(self.into_ref(), edge)52 }5354 pub fn wait_high(&mut self) -> impl Future<Output = ()> + '_ {55 self.wait(Edge::Rising)56 }5758 pub fn wait_low(&mut self) -> impl Future<Output = ()> + '_ {59 self.wait(Edge::Falling)60 }61}1struct InputFuture<'d, Gpio, Index> {2 // A 'PeripheralRef' to the pin this future is for, this is a3 // Zero Sized Type that represents `&'d Pin<...>`4 pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>,5}67impl<'d, Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index>8 InputFuture<'d, Gpio, Index>9{10 // configure the interrupt when we create the future11 fn new(mut pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>, edge: Edge) -> Self {12 // clear the interrupt first in case the previous future was dropped13 pin.0.clear_interrupt();1415 pin.0.configure_interrupt(edge);1617 Self { pin }18 }19}2021impl<'d, Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index> Future22 for InputFuture<'d, Gpio, Index>23{24 type Output = ();2526 fn poll(27 self: core::pin::Pin<&mut Self>,28 cx: &mut core::task::Context<'_>,29 ) -> core::task::Poll<Self::Output> {30 let pin_idx = self.pin.0.pin_index();31 let waker = get_waker(self.pin.0.port_index(), pin_idx);3233 waker.register(cx.waker());3435 // creating the future enabled the interrupt, if it is disabled36 // then we know the interrupt handler fired for this pin and37 // disabled the interrupt on it.38 if !self.pin.0.is_interrupt_enabled() {39 return Poll::Ready(());40 }4142 Poll::Pending43 }44}454647impl<Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index>48 Pin<Gpio, Index, Input>49{50 pub fn wait(&mut self, edge: Edge) -> impl Future<Output = ()> + '_ {51 InputFuture::new(self.into_ref(), edge)52 }5354 pub fn wait_high(&mut self) -> impl Future<Output = ()> + '_ {55 self.wait(Edge::Rising)56 }5758 pub fn wait_low(&mut self) -> impl Future<Output = ()> + '_ {59 self.wait(Edge::Falling)60 }61}Now we can write a function which waits for a button press, and then lights up a LED for one second after:
1#[embassy_executor::task]2async fn respond(led: atxtiny_hal::gpio::PA7<Input>, button: atxtiny_hal::gpio::PC3<Input>) {3 let mut button = crate::gpio::Pin::new(button.into_floating_input());4 loop {5 led.set_low();67 // wait for the button to be pressed8 t.wait(Edge::Falling).await;910 led.set_low();1112 embassy_time::Timer::after_secs(1).await;13 }14}1#[embassy_executor::task]2async fn respond(led: atxtiny_hal::gpio::PA7<Input>, button: atxtiny_hal::gpio::PC3<Input>) {3 let mut button = crate::gpio::Pin::new(button.into_floating_input());4 loop {5 led.set_low();67 // wait for the button to be pressed8 t.wait(Edge::Falling).await;910 led.set_low();1112 embassy_time::Timer::after_secs(1).await;13 }14}This same technique can then be used to create an async driver for the ADC, which fires an interrupt when the result is ready to be retrieved.
Splitting up tasks
Initially I expected to not actually use that many async tasks for this flashlight firmware, but as it turns out, there’s actually quite a few concurrent processes you can decompse a flashlight into:
Debouncing the power button
We want to do things when the power button is pressed and depressed, but due to the realities of the world we cannot just wait for highs and lows on the pin connected to the button as the signal will actually very quickly flip between low and high when the button is pressed and depressed. And so we need to perform debouncing of the button.
We can model this incredibly simply as a single process: When we first see the button is pressed we can wait a period of time (16ms), and if the button is still pressed we then treat it as a press. We can act likewise for depresses.
rust1#[embassy_executor::task]2pub async fn debouncer(t: atxtiny_hal::gpio::PC3<Input>) {3let mut t = crate::gpio::Pin::new(t.into_floating_input());4let mut l = unsafe {5atxtiny_hal::avr_device::attiny1616::PORTC::steal()6.split()7.pc18.into_push_pull_output()9};1011loop {12l.set_low().unwrap_infallible();1314// wait for a pin event on the button pin (either a press, or bouncing)15t.wait(Edge::Falling).await;16let v = t.pin().is_low().unwrap_infallible();1718// if the button isn't pressed, abort19if !v {20continue;21}2223embassy_time::Timer::after_millis(16).await;2425// if the button is still pressed after 16ms, consider it debounced and pressed26if t.pin().is_low().unwrap_infallible() {27BUTTON_STATES.signal(ButtonState::Press);28LOCKOUT_BUTTON_STATES.signal(ButtonState::Press);29} else {30continue;31}32l.set_high().unwrap_infallible();3334// once pressed, we poll the button for depresses since sometimes the35// edge interrupt can be missed36loop {37embassy_time::Timer::after_millis(16).await;38// if the button is still pressed, do nothing39if t.pin().is_low().unwrap_infallible() {40continue;41}4243embassy_time::Timer::after_millis(16).await;4445// if the button has been depressed for two cycles, consider it46// debounced and depressed47if t.pin().is_high().unwrap_infallible() {48BUTTON_STATES.signal(ButtonState::Depress);49LOCKOUT_BUTTON_STATES.signal(ButtonState::Depress);50break;51}52}53}54}rust1#[embassy_executor::task]2pub async fn debouncer(t: atxtiny_hal::gpio::PC3<Input>) {3let mut t = crate::gpio::Pin::new(t.into_floating_input());4let mut l = unsafe {5atxtiny_hal::avr_device::attiny1616::PORTC::steal()6.split()7.pc18.into_push_pull_output()9};1011loop {12l.set_low().unwrap_infallible();1314// wait for a pin event on the button pin (either a press, or bouncing)15t.wait(Edge::Falling).await;16let v = t.pin().is_low().unwrap_infallible();1718// if the button isn't pressed, abort19if !v {20continue;21}2223embassy_time::Timer::after_millis(16).await;2425// if the button is still pressed after 16ms, consider it debounced and pressed26if t.pin().is_low().unwrap_infallible() {27BUTTON_STATES.signal(ButtonState::Press);28LOCKOUT_BUTTON_STATES.signal(ButtonState::Press);29} else {30continue;31}32l.set_high().unwrap_infallible();3334// once pressed, we poll the button for depresses since sometimes the35// edge interrupt can be missed36loop {37embassy_time::Timer::after_millis(16).await;38// if the button is still pressed, do nothing39if t.pin().is_low().unwrap_infallible() {40continue;41}4243embassy_time::Timer::after_millis(16).await;4445// if the button has been depressed for two cycles, consider it46// debounced and depressed47if t.pin().is_high().unwrap_infallible() {48BUTTON_STATES.signal(ButtonState::Depress);49LOCKOUT_BUTTON_STATES.signal(ButtonState::Depress);50break;51}52}53}54}Recognising button clicks and holds
The UI of Andúril is structured around sequences of clicks that are optionally finished by a hold (long presses). For example: when the torch is unlocked, 1C (a single click) will turn on the light at the previously used brightness, while 1H (a single hold) will turn the light on at a default ‘low’ brightness level. 4C (three clicks in a row) while the torch is locked will unlock it, and likewise when the torch is unlocked.
I implemented recognising sequences of clicks and holds with a simple state machine that receives press and depress events from the debouncer process. After receiving a press event we wait for either a depress or a timeout of 300ms. If a timeout occured we emit a hold event and proceed to wait for an eventual depress, however if a depress occured we count the click and proceed to wait for another 300ms in case the button is pressed again (in which case we return to see if that is a click or a hold), if nothing is pressed within the timeout we can emit a click event containg the count of clicks so far.
Implemented in code, this looks like this:
rust1// This isn't the tidiest as we're intentionally encoding the state machine as data rather than code2// as to reduce the number of await points.3#[embassy_executor::task]4pub async fn event_generator() {5let mut state = EventGenState::FirstClick;6loop {7let (wait_until, expecting) = match state {8EventGenState::FirstClick => (None, ButtonState::Press),9EventGenState::ForHigh { .. } => (Some(Duration::from_millis(300)), ButtonState::Press),10EventGenState::ForLow { .. } => {11(Some(Duration::from_millis(300)), ButtonState::Depress)12}13EventGenState::HoldFinish => (None, ButtonState::Depress),14};1516let r = crate::with_timeout::with_timeout(wait_until, BUTTON_STATES.wait()).await;1718// r: true if pressed, false if held19let r = match r {20Ok(state) if state == expecting => true,21Ok(_) => {22state = EventGenState::FirstClick;23continue;24}25Err(_) => false,26};2728let (state_, evt) = match state {29EventGenState::FirstClick => (EventGenState::ForLow { clicks: 1 }, None),30EventGenState::ForHigh { clicks } => {31if r {32(EventGenState::ForLow { clicks: clicks + 1 }, None)33} else {34(35EventGenState::FirstClick,36Some(ButtonEvent::click_from_count(clicks)),37)38}39}40EventGenState::ForLow { clicks } => {41if r {42(EventGenState::ForHigh { clicks }, None)43} else {44(45EventGenState::HoldFinish,46Some(ButtonEvent::hold_from_count(clicks)),47)48}49}50EventGenState::HoldFinish => (EventGenState::FirstClick, Some(ButtonEvent::HoldEnd)),51};52state = state_;53if let Some(evt) = evt {54BUTTON_EVENTS.signal(evt);55}56}57}rust1// This isn't the tidiest as we're intentionally encoding the state machine as data rather than code2// as to reduce the number of await points.3#[embassy_executor::task]4pub async fn event_generator() {5let mut state = EventGenState::FirstClick;6loop {7let (wait_until, expecting) = match state {8EventGenState::FirstClick => (None, ButtonState::Press),9EventGenState::ForHigh { .. } => (Some(Duration::from_millis(300)), ButtonState::Press),10EventGenState::ForLow { .. } => {11(Some(Duration::from_millis(300)), ButtonState::Depress)12}13EventGenState::HoldFinish => (None, ButtonState::Depress),14};1516let r = crate::with_timeout::with_timeout(wait_until, BUTTON_STATES.wait()).await;1718// r: true if pressed, false if held19let r = match r {20Ok(state) if state == expecting => true,21Ok(_) => {22state = EventGenState::FirstClick;23continue;24}25Err(_) => false,26};2728let (state_, evt) = match state {29EventGenState::FirstClick => (EventGenState::ForLow { clicks: 1 }, None),30EventGenState::ForHigh { clicks } => {31if r {32(EventGenState::ForLow { clicks: clicks + 1 }, None)33} else {34(35EventGenState::FirstClick,36Some(ButtonEvent::click_from_count(clicks)),37)38}39}40EventGenState::ForLow { clicks } => {41if r {42(EventGenState::ForHigh { clicks }, None)43} else {44(45EventGenState::HoldFinish,46Some(ButtonEvent::hold_from_count(clicks)),47)48}49}50EventGenState::HoldFinish => (EventGenState::FirstClick, Some(ButtonEvent::HoldEnd)),51};52state = state_;53if let Some(evt) = evt {54BUTTON_EVENTS.signal(evt);55}56}57}- Controlling the AUX lights
- PWM and low aux modes
- Monitoring temperature and battery voltage
- Also the watchdog
- Controlling output brightness
- Reducing brightness to limit temperature
- Smoothly interpolating between levels
Fighting the inliner, testing different representations, compiler flags and turbowakers
Pins need to be modelled at the type level so that we can verify pin compatability statically, but once a pin is used by a peripheral, we can either keep it generic or turn it into a runtime integer representing the pin number, which can result in different code sizes.
Async state machine code sizes, coalescing future helpers to reduce code size.
Running out of flash
- STM32 is the solution, but would require a custom driver design. Time to learn how a current controlled boost converter works.
Designed a simple MP3432 boost driver using an stm32 to control it.
- went with STM32L072KB, needs only some bypass caps and a LDO to provide a constant voltage, doesn’t require an external oscillator. This chip has plenty of timers, flash, DACs, and ADCs.
Ported codebase to stm32
Didn’t require much work as the UI and power control logic was already fairly generic.
Also tried out maitake and designed a new driver
[^rusty-keyboards]: Writing keyboard firmware in rust
[^stackful_tasks]: By this I mean a runtime system that swaps out thread stacks