Az embedded fejlesztés hagyományosan vagy bare-metal szuperloopokról, vagy nehézsúlyú RTOS-okról szólt. Az Embassy egy harmadik utat kínál: a Rust async/await modelljét ülteti át mikrokontrollerekre úgy, hogy közben nincs szükség heap-re, dinamikus szálkezelésre vagy komplex ütemezőre.
Miért fontos az async/await embedded rendszerekben
Egy tipikus mikrokontrolleres alkalmazásban gyakran több dolgot kell "egyszerre" csinálni: figyelni egy gombot, kezelni egy UART kommunikációt, közben villogtatni egy LED-et időzítve. Hagyományos bare-metal kódban ez vagy egy nagy loop-ban való állapotgép-kezelést jelent, vagy megszakításokban tárolt megosztott állapotot, ami könnyen race condition-ökhöz vezet.
Az async/await ezt a problémát oldja meg elegánsan: minden feladatot (task-ot) úgy írhatunk meg, mintha szekvenciális kód lenne, a .await pontok pedig jelzik, hol adhatja át a vezérlést a futásidő más task-oknak. Mindezt kooperatív módon, szálváltás és context-switch overhead nélkül.
Az async modell embedded környezetben különösen jól jön, mert a Future-ök állapotgépekké fordulnak le fordítási időben — nincs szükség dinamikus memóriafoglalásra a task-ok tárolásához, ha statikusan allokáljuk őket.
Az Embassy pontosan ezt a filozófiát követi: a no_std környezetben is működő async runtime-ot ad, amit kifejezetten mikrokontrollerekre terveztek.
Az Embassy runtime architektúrája és alapfogalmai
Az Embassy több crate-ből áll össze:
- embassy-executor — a tényleges async executor, ami a task-okat futtatja
- embassy-time — időzítés,
Timer,Durationtípusok - embassy-stm32 / embassy-nrf / stb. — chip-specifikus HAL implementációk, amik async illesztést adnak a perifériákhoz
- embassy-sync — szinkronizációs primitívek (channel, mutex, signal) task-ok között
Az executor egy egyszerű, statikusan allokált task-listát kezel, és minden task egy Future, amit a #[embassy_executor::task] makró generál a async fn-ből. Amikor egy task .await-el vár valamire (pl. egy megszakításra vagy egy timerre), az executor egyszerűen a listában lévő következő futásra kész task-ra vált.
Az Embassy jelenleg nightly Rust toolchaint igényel, mert a type_alias_impl_trait (TAIT) feature-re épít a task-generáló makróiban. Ez a feature ezen a napon még nem stabil a Rust nyelvben, ezért a projekt Cargo.toml-jában és rust-toolchain.toml-jában nightly channel-t kell megadni.
Ez fontos különbség más embedded async megoldásokhoz képest: nincs szükség globális allokátorra, a Future-ök mérete fordítási időben ismert, tehát statikus memóriafoglalással megoldható a task-ok tárolása.
Egyszerű időzített feladat megvalósítása Embassy-vel
Nézzünk egy klasszikus LED villogtató példát. Az embassy_time::Timer típus adja az aszinkron várakozást:
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use panic_halt as _;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
loop {
// Itt villogtatnánk a LED-et, pl. led.toggle();
Timer::after(Duration::from_millis(500)).await;
}
}
Ami elsőre feltűnhet: a Timer::after(...).await NEM blokkolja a processzort. A Timer egy olyan Future, ami regisztrálja magát egy alacsonyszintű időzítő (RTC vagy hardveres timer) megszakításánál, és csak akkor "ébred fel", amikor az adott idő letelt. Eközben a CPU akár alvó állapotba (WFI) is mehet, ha nincs más futtatható task — ez komoly energiafogyasztás-előnyt jelent akkumulátoros eszközöknél.
Több feladat párhuzamos futtatása task-okkal
Az igazi erő akkor mutatkozik meg, amikor több task-ot indítunk egyszerre. Az Embassy-ben a Spawner segítségével indíthatunk új, statikusan allokált task-okat:
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use panic_halt as _;
#[embassy_executor::task]
async fn blinker() {
loop {
// led.toggle();
Timer::after(Duration::from_millis(300)).await;
}
}
#[embassy_executor::task]
async fn heartbeat_logger() {
let mut counter: u32 = 0;
loop {
counter += 1;
// valamilyen logolás, pl. UART-on keresztül
Timer::after(Duration::from_secs(1)).await;
}
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
spawner.spawn(blinker()).unwrap();
spawner.spawn(heartbeat_logger()).unwrap();
}
A két task teljesen független ütemben fut, mégsem kell semmilyen szálkezelést vagy mutexet írnunk hozzájuk — az executor kooperatív módon váltogatja őket a .await pontokon. Fontos, hogy minden #[embassy_executor::task] annotált függvény statikus méretű állapotgéppé fordul, aminek helyét az executor előre lefoglalja.
Egy task-nak sosem szabad hosszan blokkoló, szinkron kódot futtatnia .await nélkül (pl. egy for ciklusban való delay-elést hardveres late-loop-pal), mert az blokkolja az egész executort — az összes többi task is megáll, amíg az adott task vissza nem adja a vezérlést.
Embassy és a hardveres megszakítások kapcsolata
Az Embassy async modelljének kulcsa, hogy a hardveres megszakítások közvetlenül "ébresztik fel" a megfelelő Future-öket. Amikor egy periféria (pl. UART, ADC, vagy egy GPIO pin) megszakítást generál, az Embassy megszakításkezelője (interrupt handler) beállítja a hozzá tartozó Waker-t, és az executor a következő ütemezési körben újra lefuttatja az érintett task-ot onnan, ahol elakadt.
Ez a mechanizmus teszi lehetővé, hogy pl. egy UART olvasás így nézzen ki:
async fn read_line(uart: &mut Uart<'_>) -> heapless::String<64> {
let mut buf = [0u8; 64];
let n = uart.read_until_idle(&mut buf).await.unwrap();
// A .await itt megszakítás-vezérelt: amíg nincs adat,
// a task alszik, és a CPU mást csinálhat.
heapless::String::from_utf8(heapless::Vec::from_slice(&buf[..n]).unwrap()).unwrap()
}
A chip-specifikus HAL crate-ek (pl. embassy-stm32) alacsony szinten regisztrálják a megszakításkezelőket, ezeket a bind_interrupts! makróval kell összekötni a megfelelő periféria-típussal a projekt kódjában. Ez biztosítja, hogy a Rust típusrendszer fordítási időben ellenőrizze, hogy minden szükséges megszakítás be van-e kötve, elkerülve a klasszikus "elfelejtett interrupt handler" hibákat.
Az Embassy-ben nincs "rejtett" interrupt service routine varázslat — a HAL crate-ek explicit módon deklarálják, mely megszakítás mely perifériához tartozik, típusbiztos módon. Ez fordítási időben kizár egy csomó klasszikus embedded hibalehetőséget.
Összefoglalás
Az Embassy azt bizonyítja, hogy az async/await nem csak webszerverek és hálózati alkalmazások kiváltsága — mikrokontrolleres, erőforrás-szűkös környezetben is kiváló absztrakciót ad a párhuzamos feladatok kezelésére, mindezt heap és nehézsúlyú RTOS nélkül. A statikusan allokált task-ok, a megszakítás-vezérelt Future-ök és a Rust típusrendszerének biztonsága együtt egy meglepően ergonomikus, mégis rendkívül hatékony fejlesztői élményt adnak.
Ha embedded projektbe kezdenél és szereted a modern Rust async ökoszisztémát, mindenképp érdemes kipróbálni az Embassy-t — csak ne felejtsd el nightly toolchainre állítani a projektet, amíg a TAIT feature stabilizálódik!