5. Move display logic to separate task
Used includes in this chapter
use defmt::{info, unwrap};
use embassy_executor::Spawner;
use embassy_futures::yield_now;
use embassy_nrf::gpio::AnyPin;
use embassy_nrf::peripherals::SPI3;
use embassy_nrf::{
bind_interrupts,
gpio::{Level, Output, OutputDrive},
peripherals, spim,
};
use embassy_sync::signal::Signal;
use embassy_sync::{blocking_mutex::raw::NoopRawMutex, mutex::Mutex};
use embedded_graphics::pixelcolor::raw::RawU16;
use embedded_graphics::{
image::{Image, ImageRaw, ImageRawLE},
pixelcolor::Rgb565,
prelude::*,
};
use static_cell::StaticCell;
use {defmt_rtt as _, panic_probe as _};
Last chapter, we made a moving Ferris animation with an async version of the display driver.
We accomplished this by using a framebuffer that is able to communicate with embedded_graphics
synchronously, while writing to the display asynchronously.
This is already something, but does not yet show the full potential of what we can do with Embassy. This is because we still wait to first process the framebuffer before we write everything out, so there are moments while writing to the display that Embassy has the opportunity to save some power by not waiting in a busy loop. For our initial simple example this might seem enough, but when working with more complex examples, we would like to have that other parts of our program also get the chance of making progress.
This is what we are going to accomplish in this chapter. We are going to separate the part that writes to the screen from the part that writes to the framebuffer to their own respective tasks, so they can make progress concurrently.
The steps we need to take for this will be:
- Initialize all necessary peripherals
- Create a framebuffer behind a mutex
- Create a signal for the drawing task to signal to the driver that there is new information available
- Make sure that all shared information stays valid during the rest of the program
- Spawn the different tasks
Spawning tasks in Embassy
Before we can do anything, we should first discuss on a high level what is needed for Embassy to run our tasks without the presence of a heap.
In Rust a Future
can be seen as an enum
(Rust) or a tagged union
(C).
Every time a future cannot make progress due to having to wait for something, it stops itself and stores its state into this Future
.
Every time the future wants to make progress, it starts by taking this enum
and continues with its stored state.
This means it needs to store this state somewhere in memory.
In Embassy, every task has a storage pool of its own, where it can store its state while it is waiting to resume.
All the code needed for generating these tasks with their storage pools, is done by one of the 2 possible attribute macros.
// Task that is called as the entry point
// also starts up an initial executor
#[embassy_executor::main]
async fn main(spawner: Spawner) { todo!() }
// Creates a task + storage pool
// this macro also provides a variant
// with which it is possible to change
// the size of the storage pool, and thereby
// change the max total number of tasks
// that can be spawned. The default is 1 Future per task
#[embassy_executor::task]
async fn my_task() { todo!() }
As these tasks may outlive main
, it is necessary that all arguments of a task live for the entire lifetime of the program ('static
lifetime) or at least for as long as the corresponding task stays alive.
Another limitation of the way these tasks are created is that they may not accept any generic arguments, as this would possibly create multiple tasks at once without the corresponding storage.
To combat the first issue, we will use StaticCell
.
This makes it possible to easily allocate static memory in which we can then store our shared resources.
The way we will do this, is very similar to the last chapter where we used a StaticCell
for the FrameBuffer
.
Prepare resources
In this section we will prepare the resources, so we can send them to the corresponding task. But first, we will define some type aliases to prevent having to type out the entire type too many times. Place these above main.
pub type FrameBufferType = Mutex<NoopRawMutex, FrameBuffer>;
pub type FrameBufferRef = &'static FrameBufferType;
pub type SignalType = Signal<NoopRawMutex, ()>;
pub type SignalRef = &'static SignalType;
pub type DisplayType = st7735_lcd_async::ST7735<
spim::Spim<'static, SPI3>,
Output<'static, peripherals::P0_30>,
Output<'static, peripherals::P0_29>,
>;
pub type DisplayRef = &'static mut DisplayType;
With these types, we can start acquiring the required resources.
Notice the first generic parameter of Mutex
is here NoopRawMutex
.
If you want more information about mutexes in Rust + Embassy, see the appendix on mutexes.
But for now, Embassy has different types of mutexes, depending on the type of the hardware we are running on.
As our chip only has one core, and we are using the thread based executor (not the interrupt executor), we have enough protection with a NoopRawMutex
as a backing mutex.
// In main
let led = p.P0_13; // CHANGED: Will make our life later a bit easier
let mut config = spim::Config::default();
config.frequency = spim::Frequency::M32;
let spi = spim::Spim::new_txonly(p.SPI3, Irqs, p.P0_04, p.P0_28, config);
let res = Output::new(p.P0_29, Level::Low, OutputDrive::Standard);
let rs = Output::new(p.P0_30, Level::Low, OutputDrive::Standard);
// setup display
let mut delay = embassy_time::Delay;
let mut display = st7735_lcd_async::ST7735::new(spi, rs.into(), res.into(), true, false);
display.init(&mut delay).await.unwrap();
display
.set_orientation(&st7735_lcd_async::Orientation::Landscape)
.await
.unwrap();
// NEW: Adding StaticCells
static DISPLAY_CONTAINER: StaticCell<DisplayType> = StaticCell::new();
let static_driver: DisplayRef = DISPLAY_CONTAINER.init(display);
// CHANGED: setup framebuffer
static FRAMEBUFFER_CONTAINER: StaticCell<FrameBufferType> = StaticCell::new();
let mut fb_ref: FrameBufferRef = FRAMEBUFFER_CONTAINER.init(Mutex::new(FrameBuffer::new()));
// setup a signal, as we do not need to send information around,
// we can be ok with just sending the unit type ()
static SIGNAL_CONTAINER: StaticCell<SignalType> = StaticCell::new();
let signal_ref = SIGNAL_CONTAINER.init(Signal::new());
When importing types like Mutex
/Signal
/..., make sure that you import the ones from embassy_sync
and not the ones from std
,
because std
is not available on the development kit.
Importing the synchronization primitives from std
will therefore not work.
The specific Mutex
you need from Embassy is the non-blocking one.
The other one will not allow you to use async to wait on the lock.
The NoopRawMutex
, however, is blocking, but is needed for Embassy's internal concurrency management.
The call to StaticCell::init
takes ownership of the variable you give it.
This implies that the memory is first possibly copied to transfer the ownership to StaticCell::init
which will then copy it to a static memory location.
In the case of the framebuffer, this could introduce two expensive copies.
It is possible to remove one of the copies, by using StaticCell::uninit
.
This then allows to do in-place construction, but would in our case remove some of the niceties of our API.
One last option, is to transmute the lifetime of FrameBuffer
to static
.
This is possible through unsafe
and mem::transmute
, but comes at the cost of having to make sure that the reference to the transmuted framebuffer stays valid until the end of the program.
Otherwise, you introduce undefined behavior in you Rust program.
Defining the async driver task
In this task, we wait for a signal, acquire the mutex and display the contents of whatever is stored in the framebuffer.
#[embassy_executor::task] // define this as a task
async fn async_driver(back_buffer: FrameBufferRef, static_driver: DisplayRef, signal: SignalRef) {
loop {
if !signal.signaled() { // wait for a signal
todo!("Use `signal` to wait async")
}
info!("Received signal to draw");
{
let fb = back_buffer.lock().await; // acquire the lock
let fb = &*fb; // unpack the guard, the guard will only drop at the end of the scope
let drawable_area = fb.size();
todo!("draw async the contents of framebuffer");
} // drop mutex guard
todo!("Reset the signal, to prevent an infinite loop");
info!("Drawn");
yield_now().await; // yield here, so we are certain other tasks can make progress
}
}
Solution if !signal.signaled() {
signal.wait().await;
Solution: draw async the contents of the framebuffer
{
let fb = back_buffer.lock().await; // acquire lock
let fb = &*fb; // unpack the guard, the guard will only drop at the end of the scope
let drawable_area = fb.size();
// draw to screen
unwrap!(
static_driver
.set_pixels_buffered(
0,
0,
drawable_area.width as u16 - 1,
drawable_area.height as u16 - 1,
fb.as_ref().iter().map(|&c| RawU16::from(c).into_inner()),
)
.await
);
} // drop mutex guard
Solution: reset signal
signal.reset();
Defining the draw task
The draw task is responsible for drawing to the framebuffer and handling the animation logic.
#[embassy_executor::task] // SEE LATER
async fn draw_task(back_buffer: FrameBufferRef, led: AnyPin, signal: SignalRef) {
// Load the raw image
let image_raw: ImageRawLE<Rgb565> = ImageRaw::new(include_bytes!("../assets/ferris.raw"), 86);
// As we cannot pass generics down to the tasks, we must create the Output
// here from an `AnyPin`.
// `AnyPin` is a dynamic representation of a pin. It still provides the methods
// available on the original pin, but without the need for the generics.
let mut led = Output::new(led, Level::Low, OutputDrive::Standard);
let interval = Duration::from_millis(500);
let mut image = Image::new(&image_raw, Point::new(0, 0));
let mut x = 0;
loop {
led.set_high(); // use the led to indicate what portion is busy where
info!("Hello world");
let image = Image::new(&image_raw, Point::new(x, 0));
{
let mut fb = back_buffer.lock().await;
let fb = &mut *fb; // unpack guard
// replace the todo's with each 1 line of code
todo!("Fill the buffer with a background");
todo!("Draw the image");
todo!("signal to driver that draw has been called");
} // Guard is dropped here
x += 10;
x %= 80;
led.set_low();
yield_now().await; // yield so other tasks can make progress
embassy_time::Timer::after(interval).await; // wait before continuing
}
}
Solution
let image = Image::new(&image_raw, Point::new(x, 0));
{
let mut fb = back_buffer.lock().await;
let fb = &mut *fb; // unpack guard
unwrap!(fb.fill_solid(&fb.bounding_box(), Rgb565::BLACK));
unwrap!(image.draw(fb));
signal.signal(()); // signal to driver that draw has been called
} // Guard is dropped here
Executing the tasks
Now we have the tasks and all resources prepared, it is time to put them together.
Add the following code at the end of main.
Here we use the spawner
that we get as an argument in the main task (you might need to rename _spawner
to spawner
for the following code to work).
unwrap!(spawner.spawn(async_driver(fb_ref, static_driver, signal_ref)));
unwrap!(spawner.spawn(draw_task(fb_ref, led.into(), signal_ref)));
And let us try and execute the code:
$ cargo run
If you would have gone previously with the unsafe mem::transmute
route for the framebuffer, the above code will not work.
This is due to main
ending before the other tasks terminate, the reference to the framebuffer has gone out of scope and has been dropped.
The tasks still having a reference to this framebuffer have now a dangling pointer.
To resolve this issue, you would need to extend the duration of main
until the end of the program.
The easiest way to do this, is taking one of the tasks, and calling it as a normal async function at the end of main.
// ERROR: main should live forever, so putting everything on another task invalidates
// assumptions about the static lifetimes!
// unwrap!(spawner.spawn(draw_task(fb_ref, led.into(), signal_ref)));
draw_task(fb_ref, led.into(), signal_ref).await; // NEW
Also, do not forget to remove the #[embassy_executor::task]
attribute from draw_task
.
Otherwise, the above code will refuse to compile.