Ha már írtál embedded Rust kódot bare-metal környezetben, biztosan szembejött a probléma: hogyan osszunk meg adatot biztonságosan egy megszakításkezelő (ISR) és a main loop között anélkül, hogy unsafe blokkokkal és globális static mut változókkal kelljen zsonglőrködnünk? Erre a problémára ad elegáns választ az RTIC.
Az RTIC keretrendszer célja és előnyei
Az RTIC egy #[no_std] keretrendszer Cortex-M mikrokontrollerekhez, amely a hardveres megszakítás-prioritásokat használja fel arra, hogy garantálja a memóriabiztonságot futásidejű overhead nélkül. A lényeg: az RTIC nem egy hagyományos RTOS ütemezővel dolgozik, hanem statikusan, fordítási időben elemzi a feladatok (tasks) közötti erőforrás-hozzáféréseket, és ebből generál egy determinisztikus, zárolásmentes ütemezést.
Ennek köszönhetően:
- Nincs futásidejű ütemező overhead – a Cortex-M NVIC (Nested Vectored Interrupt Controller) végzi az ütemezést hardveresen.
- Data race-ek kizárva fordítási időben – a borrow checker és az RTIC makrói együtt garantálják, hogy egy erőforráshoz ne férhessen hozzá egyszerre két feladat konfliktusosan.
- Determinisztikus prioritáskezelés – minden task-hoz statikus prioritást rendelünk, ami a Rate Monotonic Analysis (RMA) elveinek megfelelően garantálja a válaszidőket.
Az RTIC nem helyettesíti a RTOS-t minden esetben, de ha determinisztikus, alacsony késleltetésű, megszakítás-vezérelt logikát írsz (szenzorfeldolgozás, motorvezérlés, kommunikációs protokollok), az RTIC gyakran egyszerűbb és gyorsabb megoldás egy teljes RTOS-nál.
Erőforrás-megosztás és zárolásmentes szinkronizáció
Az RTIC két erőforrás-kategóriát különböztet meg: #[local] és #[shared]. A local erőforrásokhoz csak egyetlen task fér hozzá, így ezek mutációja teljesen biztonságos, semmilyen szinkronizáció nem szükséges. A shared erőforrásokhoz több task is hozzáférhet, ezért ezekhez a lock metódust kell használni.
A trükk az, hogy az RTIC a Priority Ceiling Protocol (PCP) elvét alkalmazza: amikor egy alacsonyabb prioritású task zárolja egy megosztott erőforrást, az RTIC a zárolás idejére felemeli a task effektív prioritását a legmagasabb olyan prioritásra, amely az adott erőforrást is használja. Ez garantálja, hogy a magasabb prioritású task, amely ugyanazt az erőforrást akarná használni, nem futhat közben – vagyis nincs klasszikus mutex, nincs spinlock, mégis garantáltan nincs data race.
#[task(shared = [counter], priority = 2)]
fn increment(mut cx: increment::Context) {
cx.shared.counter.lock(|counter| {
*counter += 1;
});
}
A lock hívás nem egy futásidejű mutex-implementáció – fordítási időben az RTIC makrója generálja azt a kódot, amely ideiglenesen módosítja a megszakítás-prioritást (BASEPRI regiszter), majd visszaállítja. Ez rendkívül kevés ciklusba kerül, szemben egy szoftveres mutex várakozási overhead-jével.
Megszakítás-vezérelt feladatok definiálása RTIC-ben
RTIC-ben minden task egy Rust függvény, amelyet a #[task] attribútummal jelölünk meg. Két fő típust különböztetünk meg:
- Hardware task – közvetlenül egy megszakításhoz van kötve a
bindsparaméterrel (pl.binds = EXTI0), ez a hagyományos ISR megfelelője. - Software task – szoftveresen indítható (
spawn()), az RTIC egy szabad megszakítás-vonalat (dispatcher) használ fel a futtatásához.
A software task-ok különösen hasznosak, mert lehetővé teszik, hogy egy ISR-ből "kiszervezzük" a hosszabb feldolgozási logikát egy alacsonyabb prioritású, megszakítható task-ba, így az ISR maga minimális ideig fut.
#[task(binds = EXTI0, shared = [counter])]
fn button_isr(mut cx: button_isr::Context) {
// Rövid, gyors ISR - csak jelez a feldolgozó tasknak
process_button::spawn().ok();
}
#[task(shared = [counter], priority = 1)]
fn process_button(mut cx: process_button::Context) {
cx.shared.counter.lock(|c| {
*c += 1;
});
}
Egy egyszerű RTIC alkalmazás lépésről lépésre
Nézzünk egy teljes, futásra kész példát: egy LED-et villogtató alkalmazást, amely egy gombnyomásra reagálva megszakítja a periodikus villogást.
Először a Cargo.toml függőségei:
[dependencies]
cortex-m = "0.7"
cortex-m-rtic = "1.1"
panic-halt = "0.2"
systick-monotonic = "1.0"
Ezután jöhet a main.rs:
#![no_main]
#![no_std]
use panic_halt as _;
#[rtic::app(device = stm32f4::stm32f411, dispatchers = [USART1])]
mod app {
use systick_monotonic::{Systick, ExtU32};
#[shared]
struct Shared {
counter: u32,
}
#[local]
struct Local {
led_state: bool,
}
#[monotonic(binds = SysTick, default = true)]
type MyMono = Systick<100>;
#[init]
fn init(cx: init::Context) -> (Shared, Local, init::Monotonics) {
let mono = Systick::new(cx.core.SYST, 48_000_000);
// Elindítjuk a periodikus villogtató taskot
blink::spawn().ok();
(
Shared { counter: 0 },
Local { led_state: false },
init::Monotonics(mono),
)
}
#[task(shared = [counter], local = [led_state])]
fn blink(mut cx: blink::Context) {
*cx.local.led_state = !*cx.local.led_state;
cx.shared.counter.lock(|c| {
*c += 1;
});
// Újra ütemezzük magát 500ms múlva
blink::spawn_after(500u32.millis()).ok();
}
#[task(binds = EXTI0, shared = [counter], priority = 3)]
fn button_pressed(mut cx: button_pressed::Context) {
cx.shared.counter.lock(|c| {
// A gomb megszakítja a normál villogást, magas prioritással fut
*c = 0;
});
}
}
Figyeld meg, hogy a #[monotonic] attribútum egy időzítő absztrakciót ad, amivel a spawn_after metódussal jövőbeli időpontra ütemezhetünk task-okat – ez a klasszikus szoftveres időzítők RTIC-es megfelelője.
A dispatchers paraméter az #[rtic::app] makróban azokat a szabad, nem használt megszakítás-vonalakat sorolja fel, amelyeket az RTIC a software task-ok futtatására felhasználhat. Ha elfogynak a dispatcher-ek, az RTIC fordítási időben hibát fog jelezni – ez is a keretrendszer biztonsági garanciáinak része.
Ha a chip inicializálásakor konfigurációt olvasunk be, és opcionális az érték, a modern Rust let-else szintaxisa kiválóan illik az init függvénybe:
fn read_config(raw: Option<u8>) -> u8 {
let Some(value) = raw else {
return 0; // alapérték, ha nincs konfiguráció
};
value
}
RTIC vs. hagyományos megszakításkezelés
Nézzük meg konkrétan, miben különbözik az RTIC egy "kézzel írt" megszakításkezelős megoldástól.
Hagyományos megközelítés:
static mut COUNTER: u32 = 0;
#[interrupt]
fn EXTI0() {
unsafe {
COUNTER += 1;
}
}
Ez a kód működik, de minden hozzáférés unsafe, és a fordító nem tud segíteni abban, hogy a COUNTER-hez való hozzáférés valóban biztonságos-e minden konkurens kontextusban. Ha a main loop is módosítja a COUNTER-t, potenciális data race-t kapunk, amit csak manuális kritikus szekciókkal (cortex_m::interrupt::free) tudunk kezelni, és ezt könnyű elfelejteni.
RTIC megközelítés:
A fentebb bemutatott #[shared] erőforrás és lock hívás statikusan garantálja a biztonságos hozzáférést, unsafe blokk nélkül. A borrow checker és az RTIC makró együtt kényszerítik ki, hogy minden hozzáférés a megfelelő zárolással történjen – ha elfelejtenéd a lock-ot használni, a kód egyszerűen nem fordulna le.
Az alábbi táblázat összefoglalja a legfontosabb különbségeket:
| Szempont | Hagyományos ISR | RTIC |
|---|---|---|
| Adatmegosztás | static mut + unsafe | #[shared] + lock() |
| Prioritáskezelés | Manuális NVIC konfiguráció | Deklaratív, fordítási időben ellenőrzött |
| Data race védelem | Programozói felelősség | Fordítási időben garantált |
| Task-ok közötti kommunikáció | Globális változók, flag-ek | spawn(), spawn_after() típusos API |
| Overhead | Minimális, de kockázatos | Minimális, biztonságos |
Az RTIC nem old meg mindent automatikusan – ha rosszul tervezed meg a prioritásokat (pl. minden task azonos prioritást kap), elveszíted a valós idejű garanciákat, és a viselkedés hasonlóvá válik egy hagyományos, prioritás nélküli megszakításkezeléshez. A prioritások megtervezése továbbra is mérnöki feladat.
Összefoglalás
Az RTIC kiválóan demonstrálja, hogy a Rust type systeme és a hardveres megszakítás-prioritások kombinációjával lehetséges zárolásmentes, mégis teljesen biztonságos konkurenciát elérni beágyazott rendszerekben – mindezt futásidejű overhead nélkül. A #[shared]/#[local] erőforrás-modell, a statikus prioritáskezelés és a típusos spawn/spawn_after API együtt egy olyan eszközkészletet adnak, amivel sokkal magabiztosabban írhatunk valós idejű, megszakítás-vezérelt logikát, mint a hagyományos unsafe static mut mintákkal. Ha még nem próbáltad, érdemes egy egyszerű LED-villogtatós projekttel kezdeni, és onnan bővíteni a saját hardvered specifikus feladataival.