../squeezing-rust-onto-more-things

By Ben

Squeezing rust onto more things, this time: Flashlights

Putting rust on smaller and smaller things

Figure 7: These torches are all running on rust

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:

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:

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:

rust
1
impl AdcRegExt for ADC0 {
2
// ...
3
4
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
}
rust
1
impl AdcRegExt for ADC0 {
2
// ...
3
4
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:

Embassy models this as a Trait with a single schedule_wake method:

rust
1
struct MyTimerQueue{}; // not public!
2
3
impl TimerQueue for MyTimerQueue {
4
fn schedule_wake(&'static self, at: u64, waker: &Waker) {
5
todo!()
6
}
7
}
rust
1
struct MyTimerQueue{}; // not public!
2
3
impl 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:

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:

rust
1
use core::{
2
cell::Cell,
3
task::Waker,
4
};
5
6
use avr_device::interrupt::{CriticalSection, Mutex};
7
8
pub type Time = u32;
9
10
const QUEUE_SIZE: usize = 10;
11
12
struct Entry {
13
at: Time,
14
waker: Waker,
15
}
16
17
/// An array of queue entries. The `Mutex<Cell<_>>` here is actually
18
/// a noop at runtime, and just serves to prove we're inside a
19
/// critical section when accessing the entries
20
static ENTRIES: [Mutex<Cell<Option<Entry>>>; QUEUE_SIZE] =
21
[const { Mutex::new(Cell::new(None)) }; QUEUE_SIZE];
rust
1
use core::{
2
cell::Cell,
3
task::Waker,
4
};
5
6
use avr_device::interrupt::{CriticalSection, Mutex};
7
8
pub type Time = u32;
9
10
const QUEUE_SIZE: usize = 10;
11
12
struct Entry {
13
at: Time,
14
waker: Waker,
15
}
16
17
/// An array of queue entries. The `Mutex<Cell<_>>` here is actually
18
/// a noop at runtime, and just serves to prove we're inside a
19
/// critical section when accessing the entries
20
static 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:

rust
1
2
/// Allocate an entry, returning on success the index, and whether
3
/// there was already an entry for this waker
4
pub fn allocate(
5
/// A handle to a critical section, this proves that interrupts
6
/// are disabled while this function is called
7
_: CriticalSection,
8
/// The waker we're allocating for, used so we only ever have
9
/// one entry in the queue for each task/ waker
10
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 same
14
// waker, return that.
15
// this happens when a future tries to sleep again after
16
// being woken by something other than a timeout
17
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 slot
23
if !TAKEN[i] {
24
TAKEN[i] = true;
25
return Some((NonMaxU8(i as u8), false));
26
}
27
}
28
29
None
30
}
31
}
32
33
/// Add a waker to the queue, correctly handles when the
34
/// waker is already in the queue
35
pub fn allocate(
36
/// A handle to a critical section, this proves that interrupts
37
/// are disabled while this function is called
38
cs: CriticalSection,
39
at: Time,
40
waker: &Waker) {
41
42
// do a first pass over the entries to ensure the waker
43
// isn't already in the queue
44
for entry in &ENTRIES {
45
// does rust optimise out the copies here? Ich weiß es nicht
46
let entry = entry.borrow(cs);
47
let e = entry.replace(None);
48
49
if let Some(mut e) = e {
50
// waker is the same, simply store back the earliest time
51
if e.waker.will_wake(waker) {
52
e.at = at.min(e.at);
53
54
entry.set(Some(e));
55
return;
56
} else {
57
entry.set(Some(e));
58
}
59
}
60
}
61
62
// if the waker isn't already in the queue,
63
// find the first unused entry and store it there
64
for entry in &ENTRIES {
65
let entry = entry.borrow(cs);
66
let e = entry.replace(None);
67
68
if e.is_some() {
69
entry.set(e);
70
continue;
71
}
72
73
entry.set(Some(Entry { at, waker: waker.clone() }));
74
75
return;
76
}
77
78
// Ideally this could only be hit if we have more tasks
79
// than the queue capacity
80
panic!("queue full");
81
}
82
rust
1
2
/// Allocate an entry, returning on success the index, and whether
3
/// there was already an entry for this waker
4
pub fn allocate(
5
/// A handle to a critical section, this proves that interrupts
6
/// are disabled while this function is called
7
_: CriticalSection,
8
/// The waker we're allocating for, used so we only ever have
9
/// one entry in the queue for each task/ waker
10
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 same
14
// waker, return that.
15
// this happens when a future tries to sleep again after
16
// being woken by something other than a timeout
17
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 slot
23
if !TAKEN[i] {
24
TAKEN[i] = true;
25
return Some((NonMaxU8(i as u8), false));
26
}
27
}
28
29
None
30
}
31
}
32
33
/// Add a waker to the queue, correctly handles when the
34
/// waker is already in the queue
35
pub fn allocate(
36
/// A handle to a critical section, this proves that interrupts
37
/// are disabled while this function is called
38
cs: CriticalSection,
39
at: Time,
40
waker: &Waker) {
41
42
// do a first pass over the entries to ensure the waker
43
// isn't already in the queue
44
for entry in &ENTRIES {
45
// does rust optimise out the copies here? Ich weiß es nicht
46
let entry = entry.borrow(cs);
47
let e = entry.replace(None);
48
49
if let Some(mut e) = e {
50
// waker is the same, simply store back the earliest time
51
if e.waker.will_wake(waker) {
52
e.at = at.min(e.at);
53
54
entry.set(Some(e));
55
return;
56
} else {
57
entry.set(Some(e));
58
}
59
}
60
}
61
62
// if the waker isn't already in the queue,
63
// find the first unused entry and store it there
64
for entry in &ENTRIES {
65
let entry = entry.borrow(cs);
66
let e = entry.replace(None);
67
68
if e.is_some() {
69
entry.set(e);
70
continue;
71
}
72
73
entry.set(Some(Entry { at, waker: waker.clone() }));
74
75
return;
76
}
77
78
// Ideally this could only be hit if we have more tasks
79
// than the queue capacity
80
panic!("queue full");
81
}
82

And 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:

rust
1
pub struct AvrTc0EmbassyTimeDriver {}
2
3
impl 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
}
rust
1
pub struct AvrTc0EmbassyTimeDriver {}
2
3
impl 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:

rust
1
pub static TICKS_ELAPSED: Mutex<Cell<Time>> = Mutex::new(Cell::new(0));
2
3
#[allow(dead_code)]
4
const TICKS_PER_COUNT: Time = 1;
5
6
/// Check each entry in the queue, if the timer has elapsed,
7
/// then wake the associated task
8
pub 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);
13
14
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
}
21
22
None
23
});
24
25
if let Some(w) = w {
26
w.wake();
27
}
28
}
29
}
30
31
// A flag we use to ensure we don't try to process the timer queue
32
// recursively if handle_tick is entered again somehow
33
static IN_PROGRESS: Mutex<Cell<bool>> = Mutex::new(Cell::new(false));
34
35
pub fn mark_in_progress(cs: CriticalSection) -> bool {
36
!IN_PROGRESS.borrow(cs).replace(true)
37
}
38
39
pub fn mark_finished(cs: CriticalSection) {
40
IN_PROGRESS.borrow(cs).set(false);
41
}
42
43
// Declare an interrupt handler for the RTC_PIT interrupt
44
#[avr_device::interrupt(attiny1616)]
45
unsafe fn RTC_PIT() {
46
handle_tick()
47
}
48
49
#[inline(always)]
50
pub unsafe fn handle_tick() {
51
let (should_process, ticks_elapsed) = avr_device::interrupt::free(|t| {
52
// increment the global ticks counter
53
let elapsed = TICKS_ELAPSED.borrow(t).get() + 1;
54
TICKS_ELAPSED.borrow(t).set(elapsed);
55
56
// ensure we're not already processing the queue
57
(mark_in_progress(t), elapsed)
58
});
59
60
if should_process {
61
wake_queue::process(ticks_elapsed);
62
}
63
64
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 flag
70
state.as_mut().unwrap().counter.clear_interrupt();
71
});
72
}
73
74
/// Configure the RTC with the PIT enabled, firing at a rate of 1024Hz
75
pub 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);
80
81
let pitconfig = PitConfig::new(1, RTCClockSource::OSCULP32K_32K, PERIOD_A::CYC32);
82
83
let mut pit = Pit::from_rtc(tc, pitconfig.clock_source, pitconfig.period);
84
pit.enable_interrupt();
85
pit.start();
86
87
*INTERRUPT_STATE.borrow(t).borrow_mut() = Some(InterruptState {
88
counter: pit,
89
});
90
});
91
}
92
}
rust
1
pub static TICKS_ELAPSED: Mutex<Cell<Time>> = Mutex::new(Cell::new(0));
2
3
#[allow(dead_code)]
4
const TICKS_PER_COUNT: Time = 1;
5
6
/// Check each entry in the queue, if the timer has elapsed,
7
/// then wake the associated task
8
pub 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);
13
14
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
}
21
22
None
23
});
24
25
if let Some(w) = w {
26
w.wake();
27
}
28
}
29
}
30
31
// A flag we use to ensure we don't try to process the timer queue
32
// recursively if handle_tick is entered again somehow
33
static IN_PROGRESS: Mutex<Cell<bool>> = Mutex::new(Cell::new(false));
34
35
pub fn mark_in_progress(cs: CriticalSection) -> bool {
36
!IN_PROGRESS.borrow(cs).replace(true)
37
}
38
39
pub fn mark_finished(cs: CriticalSection) {
40
IN_PROGRESS.borrow(cs).set(false);
41
}
42
43
// Declare an interrupt handler for the RTC_PIT interrupt
44
#[avr_device::interrupt(attiny1616)]
45
unsafe fn RTC_PIT() {
46
handle_tick()
47
}
48
49
#[inline(always)]
50
pub unsafe fn handle_tick() {
51
let (should_process, ticks_elapsed) = avr_device::interrupt::free(|t| {
52
// increment the global ticks counter
53
let elapsed = TICKS_ELAPSED.borrow(t).get() + 1;
54
TICKS_ELAPSED.borrow(t).set(elapsed);
55
56
// ensure we're not already processing the queue
57
(mark_in_progress(t), elapsed)
58
});
59
60
if should_process {
61
wake_queue::process(ticks_elapsed);
62
}
63
64
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 flag
70
state.as_mut().unwrap().counter.clear_interrupt();
71
});
72
}
73
74
/// Configure the RTC with the PIT enabled, firing at a rate of 1024Hz
75
pub 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);
80
81
let pitconfig = PitConfig::new(1, RTCClockSource::OSCULP32K_32K, PERIOD_A::CYC32);
82
83
let mut pit = Pit::from_rtc(tc, pitconfig.clock_source, pitconfig.period);
84
pit.enable_interrupt();
85
pit.start();
86
87
*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:

rust
1
impl 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
}
8
9
// ... there's some more stuff here but it's unimportant
10
}
rust
1
impl 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
}
8
9
// ... there's some more stuff here but it's unimportant
10
}

And with that, we can now use Embassy:

rust
1
#[embassy_executor::task]
2
async fn blink(pin: atxtiny_hal::gpio::PA7<Input>) {
3
let mut pin = pin.into_push_pull_output();
4
loop {
5
pin.toggle();
6
7
embassy_time::Timer::after_millis(500).await;
8
}
9
}
rust
1
#[embassy_executor::task]
2
async fn blink(pin: atxtiny_hal::gpio::PA7<Input>) {
3
let mut pin = pin.into_push_pull_output();
4
loop {
5
pin.toggle();
6
7
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:

rust
1
// GPIO pins on AVR are grouped into 'ports'
2
const PORTA_PIN_COUNT: usize = 8;
3
const PORTB_PIN_COUNT: usize = 8;
4
const PORTC_PIN_COUNT: usize = 6;
5
6
// AtomicWaker is effectively just `Mutex<Cell<Option<Waker>>>`
7
static WAKERS: [AtomicWaker; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT] =
8
[const { AtomicWaker::new() }; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT];
9
10
11
fn 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 could
14
// be elided.
15
//
16
// unsafe { WAKERS.get_unchecked((port * PORTA_PIN_COUNT as u8 + pin) as usize) }
17
}
rust
1
// GPIO pins on AVR are grouped into 'ports'
2
const PORTA_PIN_COUNT: usize = 8;
3
const PORTB_PIN_COUNT: usize = 8;
4
const PORTC_PIN_COUNT: usize = 6;
5
6
// AtomicWaker is effectively just `Mutex<Cell<Option<Waker>>>`
7
static WAKERS: [AtomicWaker; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT] =
8
[const { AtomicWaker::new() }; PORTA_PIN_COUNT + PORTB_PIN_COUNT + PORTC_PIN_COUNT];
9
10
11
fn 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 could
14
// 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.

rust
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 up
3
// any wakers for pins which have a pending interrupt.
4
fn 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();
8
9
// clear and disable the interrupt, disabling the interrupt
10
// is used to signal that the pin was woken.
11
gpio.clear(i);
12
}
13
}
14
}
15
16
// Pin interrupts on AVR are grouped to the port the pin belongs to, the
17
// 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)]
20
unsafe fn PORTA_PORT() {
21
int_handler(&*PORTA::PTR as &dyn GpioInt, 0, PORTA_PIN_COUNT as u8);
22
}
23
24
#[avr_device::interrupt(attiny1616)]
25
unsafe fn PORTB_PORT() {
26
int_handler(&*PORTB::PTR as &dyn GpioInt, 1, PORTB_PIN_COUNT as u8);
27
}
28
29
#[avr_device::interrupt(attiny1616)]
30
unsafe fn PORTC_PORT() {
31
int_handler(&*PORTC::PTR as &dyn GpioInt, 2, PORTC_PIN_COUNT as u8);
32
}
33
34
// Helper trait used for its vtable, this seems to have the least
35
// code size impact.
36
trait GpioInt {
37
fn is_pending(&self, n: u8) -> bool;
38
fn clear(&self, n: u8);
39
}
40
41
impl<T: GpioRegExt> GpioInt for T {
42
fn is_pending(&self, n: u8) -> bool {
43
// we need this proxy method as GpioRegExt isn't object safe
44
self.interrupt_pending(n)
45
}
46
47
fn clear(&self, n: u8) {
48
// enabling input buffering disables the interrupt
49
self.enable_input_buffer(n);
50
self.clear_interrupt_pending(n);
51
}
52
}
rust
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 up
3
// any wakers for pins which have a pending interrupt.
4
fn 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();
8
9
// clear and disable the interrupt, disabling the interrupt
10
// is used to signal that the pin was woken.
11
gpio.clear(i);
12
}
13
}
14
}
15
16
// Pin interrupts on AVR are grouped to the port the pin belongs to, the
17
// 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)]
20
unsafe fn PORTA_PORT() {
21
int_handler(&*PORTA::PTR as &dyn GpioInt, 0, PORTA_PIN_COUNT as u8);
22
}
23
24
#[avr_device::interrupt(attiny1616)]
25
unsafe fn PORTB_PORT() {
26
int_handler(&*PORTB::PTR as &dyn GpioInt, 1, PORTB_PIN_COUNT as u8);
27
}
28
29
#[avr_device::interrupt(attiny1616)]
30
unsafe fn PORTC_PORT() {
31
int_handler(&*PORTC::PTR as &dyn GpioInt, 2, PORTC_PIN_COUNT as u8);
32
}
33
34
// Helper trait used for its vtable, this seems to have the least
35
// code size impact.
36
trait GpioInt {
37
fn is_pending(&self, n: u8) -> bool;
38
fn clear(&self, n: u8);
39
}
40
41
impl<T: GpioRegExt> GpioInt for T {
42
fn is_pending(&self, n: u8) -> bool {
43
// we need this proxy method as GpioRegExt isn't object safe
44
self.interrupt_pending(n)
45
}
46
47
fn clear(&self, n: u8) {
48
// enabling input buffering disables the interrupt
49
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:

rust
1
struct InputFuture<'d, Gpio, Index> {
2
// A 'PeripheralRef' to the pin this future is for, this is a
3
// Zero Sized Type that represents `&'d Pin<...>`
4
pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>,
5
}
6
7
impl<'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 future
11
fn new(mut pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>, edge: Edge) -> Self {
12
// clear the interrupt first in case the previous future was dropped
13
pin.0.clear_interrupt();
14
15
pin.0.configure_interrupt(edge);
16
17
Self { pin }
18
}
19
}
20
21
impl<'d, Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index> Future
22
for InputFuture<'d, Gpio, Index>
23
{
24
type Output = ();
25
26
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);
32
33
waker.register(cx.waker());
34
35
// creating the future enabled the interrupt, if it is disabled
36
// then we know the interrupt handler fired for this pin and
37
// disabled the interrupt on it.
38
if !self.pin.0.is_interrupt_enabled() {
39
return Poll::Ready(());
40
}
41
42
Poll::Pending
43
}
44
}
45
46
47
impl<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
}
53
54
pub fn wait_high(&mut self) -> impl Future<Output = ()> + '_ {
55
self.wait(Edge::Rising)
56
}
57
58
pub fn wait_low(&mut self) -> impl Future<Output = ()> + '_ {
59
self.wait(Edge::Falling)
60
}
61
}
rust
1
struct InputFuture<'d, Gpio, Index> {
2
// A 'PeripheralRef' to the pin this future is for, this is a
3
// Zero Sized Type that represents `&'d Pin<...>`
4
pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>,
5
}
6
7
impl<'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 future
11
fn new(mut pin: PeripheralRef<'d, Pin<Gpio, Index, Input>>, edge: Edge) -> Self {
12
// clear the interrupt first in case the previous future was dropped
13
pin.0.clear_interrupt();
14
15
pin.0.configure_interrupt(edge);
16
17
Self { pin }
18
}
19
}
20
21
impl<'d, Gpio: atxtiny_hal::gpio::marker::Gpio, Index: atxtiny_hal::gpio::marker::Index> Future
22
for InputFuture<'d, Gpio, Index>
23
{
24
type Output = ();
25
26
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);
32
33
waker.register(cx.waker());
34
35
// creating the future enabled the interrupt, if it is disabled
36
// then we know the interrupt handler fired for this pin and
37
// disabled the interrupt on it.
38
if !self.pin.0.is_interrupt_enabled() {
39
return Poll::Ready(());
40
}
41
42
Poll::Pending
43
}
44
}
45
46
47
impl<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
}
53
54
pub fn wait_high(&mut self) -> impl Future<Output = ()> + '_ {
55
self.wait(Edge::Rising)
56
}
57
58
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:

rust
1
#[embassy_executor::task]
2
async 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();
6
7
// wait for the button to be pressed
8
t.wait(Edge::Falling).await;
9
10
led.set_low();
11
12
embassy_time::Timer::after_secs(1).await;
13
}
14
}
rust
1
#[embassy_executor::task]
2
async 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();
6
7
// wait for the button to be pressed
8
t.wait(Edge::Falling).await;
9
10
led.set_low();
11
12
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:

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

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