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.
Tipp

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;
    });
}
Megjegyzés

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:

  1. Hardware task – közvetlenül egy megszakításhoz van kötve a binds paraméterrel (pl. binds = EXTI0), ez a hagyományos ISR megfelelője.
  2. 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.

Jó tudni

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:

SzempontHagyományos ISRRTIC
Adatmegosztásstatic mut + unsafe#[shared] + lock()
PrioritáskezelésManuális NVIC konfigurációDeklaratív, fordítási időben ellenőrzött
Data race védelemProgramozói felelősségFordítási időben garantált
Task-ok közötti kommunikációGlobális változók, flag-ekspawn(), spawn_after() típusos API
OverheadMinimális, de kockázatosMinimális, biztonságos
Figyelem

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.