Key Concepts of embassy
What is embassy
Embassy is the next-generation framework for embedded applications. Write safe, correct, and energy-efficient embedded code faster, using the Rust programming language, its async facilities, and the Embassy libraries. Source
TL;DR: It brings Multitasking to the embedded world without an OS on bare metal
Key concepts of embassy
- An executor: A runtime scheduling and running async tasks
- Tasks: Async functions, that yield control when waiting for I/O or timers.
- Everything is bare-metal without any heap - tasks are allocated at compile time.
Let's have a look at code/hello_embassy
The main entry point
The main entry point for an embassy project is its async main.
#[esp_hal_embassy::main] async fn main(spawner: Spawner) { // generator version: 0.5.0 rtt_target::rtt_init_defmt!(); let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); let timer0 = SystemTimer::new(peripherals.SYSTIMER); esp_hal_embassy::init(timer0.alarm0); info!("Embassy initialized!"); spawner .spawn(my_led_blink_task()) .expect("Could not spawn LED task"); spawner .spawn(my_other_things()) .expect("Could not spawn my other task") }
Changes you see from normal bare metal:
- Other macro on top
- No infinite loop in main
- You spawn tasks in this main
The actual work
The tasks then usually carry out the jobs - asynchronous. They are marked async as well and are attributed
by the #[embassy_executor::task]
macro.
#![allow(unused)] fn main() { #[embassy_executor::task] async fn my_led_blink_task() { loop { info!("On"); Timer::after_millis(500).await; info!("Off"); Timer::after_millis(500).await; } } #[embassy_executor::task] async fn my_other_things() { loop { info!("Other GPIO HIGH"); Timer::after_millis(1000).await; info!("Other GPIO LOW"); Timer::after_millis(1000).await; } } }
Communication between tasks
Let's have a look at code/embassy_polling_button
.
What is the code doing?
One task polls the state of the your boot button (GPIO9
).
If the button state changes, it sends an event. The other task,
waiting for events, will wake up and process the incoming event.
To send such events, we need a so called CHANNEL
(global). It's a queue, where
we can enqueue data, and in this case, can hold up to 10 elements in the queue.
#![allow(unused)] fn main() { static BUTTON_CHANNEL: Channel< embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, ButtonEvent, 10, > = Channel::new(); #[derive(Clone, Copy, defmt::Format)] enum ButtonEvent { Pressed, Released, } }
The sending task (polling button state) gets the sender part of the channel
#![allow(unused)] fn main() { let sender = BUTTON_CHANNEL.sender(); }
And the other task the receiving end:
#![allow(unused)] fn main() { let receiver = BUTTON_CHANNEL.receiver(); }
The cool thing! The receiver can sleep, until it receives it and event from the queue:
#![allow(unused)] fn main() { info!("I am idle and waiting for an event"); let event = receiver.receive().await; }
The executor runtime will continue the computation of that task, when an event is received.
You can try it out.
Go to code/embassy_polling_task
and run
cargo run --release
Interrupts in Embassy
Comparing interrupts with normal bare metal, interrupts in embassy are quite easy.
Embassy directly gives you methods, where you can await
for example a falling edge of a given button.
#![allow(unused)] fn main() { #[embassy_executor::task] async fn my_interrupt_awaiting_task(mut input_button: Input<'static>) { loop { info!("Waiting for a button press"); // I.E.: When we press the button, the edge will fall input_button.wait_for_falling_edge().await; info!("I got woken up!") } } }
The task will be woken up, when we detect a falling edge on that button. This happens, when pressing it, given the following config of the button:
#![allow(unused)] fn main() { // Configure GPIO9 as input with pull-up resistor let config = InputConfig::default().with_pull(Pull::Up); let button = Input::new(peripherals.GPIO9, config); spawner .spawn(my_interrupt_awaiting_task(button)) .expect("Could not spawn this task"); }
You can try it out youself. The code is code/embassy_interrupt
.
Run
cargo run --release
to build, flash and run it.
More to embassy
If you are interested, what else embassy can do you can have a look at the
Other Embedded Framworks
RTIC - Realtime interrupt driven concurrency
You describe everything in an app module - where you define your local
and shared
ressources.
- #[local] resources are locally accessible to a specific task, meaning that only that task can access the resource and does so without locks or critical sections.
- #[shared] resources can only be accessed by a critical section (
lock
). Whoever has it, cann access the the ressource. Locks are given by priority of the interrupt handlers
#![allow(unused)] fn main() { #[rtic::app(device = stm32f4xx_hal::pac)] mod app { use rtic::Mutex; #[shared] struct Shared { counter: u32, } #[local] struct Local {} #[init] fn init(ctx: init::Context) -> (Shared, Local) { // Initialize hardware and return resources (Shared { counter: 0 }, Local {}) } #[task(priority = 1, shared = [counter])] async fn task1(mut ctx: task1::Context) { ctx.shared.counter.lock(|c| *c += 1); } #[task(binds = EXTI0, priority = 2, shared = [counter])] fn hardware_task(mut ctx: hardware_task::Context) { ctx.shared.counter.lock(|c| *c += 10); } } }