Miért kell no_std környezet

A standard std könyvtár nem varázslat – rá van építve egy operációs rendszerre. Fájlrendszer, szálak, heap allokátor, socketek: mindegyik feltételezi, hogy valami (Linux, Windows, macOS) biztosítja az alapokat egy syscall interfészen keresztül. Egy Cortex-M mikrovezérlőn viszont nincs operációs rendszer, nincs syscall, nincs biztosított heap – csak flash memória, RAM és a periféria regiszterei.

Ezért amikor embedded target-re fordítunk, a #![no_std] attribútummal jelezzük a fordítónak, hogy csak a core crate-re támaszkodunk. A core tartalmazza a nyelv alapvető típusait (Option, Result, iterátorok, slice), de nincs benne semmi, ami dinamikus memóriakezelést vagy OS-interakciót igényelne. Ha heap-re is szükséged van (pl. Vec-hez), az alloc crate-et is bevonhatod egy saját allokátorral – de a mai cikkben ezt szándékosan elkerüljük, és a stack-alapú, allokáció-mentes megközelítést részesítjük előnyben, ami embedded projekteknél amúgy is az iparági norma.

Megjegyzés

A no_std nem jelenti azt, hogy nincs standard könyvtár egyáltalán – a core és opcionálisan az alloc is elérhető. Csak az OS-függő std réteg hiányzik.

Fejlesztői eszközkészlet összeállítása

Mielőtt egy sort is írnánk, be kell szereznünk a megfelelő cross-compilation target-et és a flashelő/debug eszközöket.

Először adjuk hozzá a Cortex-M4/M7 FPU-s magokhoz illő target-et:

rustup target add thumbv7em-none-eabihf

Ezt a target-triplet-et akkor használjuk, ha van hardveres lebegőpontos egység (FPU) a chipben, mint például a legtöbb STM32F4-es sorozatnál. Ha a te mikrovezérlőd nem rendelkezik FPU-val, a thumbv7em-none-eabi (a hf nélküli) a helyes választás.

A flashelésre és debug-ra a probe-rs ökoszisztémát ajánlom. Ez egy Rust-ban írt, gyors és megbízható eszközcsalád, ami USB-n keresztül kommunikál az ST-Link, J-Link vagy CMSIS-DAP debug probe-okkal:

cargo install cargo-embed
cargo install probe-rs-cli

A cargo-embed egy konfigurálható flash+RTT (Real-Time Transfer) megoldás, ami a projekt gyökerében egy Embed.toml fájlból olvassa a chip nevét és a debug beállításokat.

Tipp

Ha nincs kéznél dedikált debug probe, a legtöbb STM32 Nucleo és Discovery board beépített ST-Link-et tartalmaz – ezt gyakran csak egy udev szabály hiányzik ahhoz, hogy Linux alatt root nélkül elérhető legyen.

Minimál projekt: Cargo.toml, memory.x és panic-handler

A projekt struktúrájában két speciális fájl kulcsfontosságú, amik a hagyományos Rust projektekben nincsenek: a memory.x linker script és a .cargo/config.toml.

A memory.x írja le a linker számára, hol kezdődik és mekkora a FLASH, illetve a RAM régió – ezt a chip adatlapjából (vagy a HAL crate dokumentációjából) kell kimásolni:

MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}

Ez a cortex-m-rt crate link.x scriptjéhez kapcsolódik, ami az #[entry] makróval jelölt függvényt a helyes reset-vektorra köti be.

A .cargo/config.toml-ban állítjuk be a target-et és a linkelési flag-eket:

[target.thumbv7em-none-eabihf]
runner = "probe-rs-cli run --chip STM32F411RETx"
rustflags = ["-C", "link-arg=-Tlink.x"]

[build]
target = "thumbv7em-none-eabihf"

Ami a Cargo.toml-t illeti, a lényeg a panic-halt (vagy panic-probe) crate bevonása – ez adja meg, mi történjen egy panic!() hívás esetén, hiszen a core-ban nincs alapértelmezett panic-handler:

[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
panic-halt = "0.2"
embedded-hal = "0.2"
stm32f4xx-hal = { version = "0.15", features = ["stm32f411"] }
Jó tudni

Az embedded-hal 1.0-s verziója írás közben még release candidate státuszban van, ezért a legtöbb HAL crate (így az stm32f4xx-hal is) még a stabil 0.2.x vonalat használja. Ne keverd a kettőt egy projektben, mert a trait-ek nem kompatibilisek egymással.

Hello LED: az első villogó program

Nézzük a klasszikus embedded "Hello World"-öt, ami LED-et villogtat GPIO-n keresztül, embedded-hal absztrakciókat használva a portolhatóság kedvéért:

#![no_std]
#![no_main]

use panic_halt as _;

use cortex_m_rt::entry;
use embedded_hal::digital::v2::OutputPin;
use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();

    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(100.MHz()).freeze();

    let gpioa = dp.GPIOA.split();
    let mut led = gpioa.pa5.into_push_pull_output();

    let mut delay = dp.TIM2.delay_ms(&clocks);

    loop {
        led.set_high().unwrap();
        delay.delay_ms(500u32);
        led.set_low().unwrap();
        delay.delay_ms(500u32);
    }
}

A #[entry] makró a cortex-m-rt-ből jön, és ez helyettesíti a normál main függvényt – gondoskodik a stack inicializálásáról, a .data/.bss szekciók feltöltéséről, majd hívja a mi kódunkat. A visszatérési típus ! (never type), mert egy embedded firmware sosem "tér vissza" – vagy végtelen ciklusban fut, vagy resetelődik.

Az embedded_hal::digital::v2::OutputPin trait az, ami lehetővé teszi, hogy ugyanezt a kódot – apró módosításokkal – egy másik gyártó chipjén is használhatjuk, feltéve, hogy az adott HAL implementálja ugyanezt a trait-et.

GPIO, delay és periféria kezelés HAL crate-eken keresztül

A stm32f4xx-hal (és a hasonló, más gyártókhoz készült HAL crate-ek, pl. stm32g0xx-hal, rp2040-hal) az embedded-hal trait-eket implementálva ad kényelmes, típusbiztos interfészt a regiszterek fölé. A split() hívás a GPIO portot egyedi pin-típusokra bontja, amiket aztán into_push_pull_output(), into_pull_up_input() stb. metódusokkal alakíthatunk a kívánt módra – ezt a fordító típusrendszere kényszeríti ki, tehát fordítási időben kiderül, ha egy pint helytelen módban próbálunk használni.

Egy gyakori mintázat, hogy egy gombnyomást olvasunk, és a let-else szintaxist használjuk a korai kilépéshez – ez remekül működik szűk, allokáció-mentes ciklusokban:

fn read_button_or_skip(button: &impl embedded_hal::digital::v2::InputPin) -> bool {
    let Ok(pressed) = button.is_low() else {
        return false;
    };
    pressed
}
Tipp

A let-else szintaxis (stabil a 2021-es edition-ben is) különösen jól jön embedded kódban, ahol az unwrap() panic-ot okozna, de a hibakezelést egyszerű, olvasható formában szeretnénk tartani anélkül, hogy egész match blokkot kelljen írni.

Delay kezelésre a TIM2.delay_ms(&clocks) egy hardveres timer-t foglal le, ami blokkoló várakozást biztosít – ez egyszerű esetekben megfelelő, de komplexebb projekteknél érdemes megnézni az embedded-hal-async vagy interrupt-alapú megoldásokat, amiket egy következő cikkben részletezünk.

Hibakeresés: natvis támogatás kihasználása

A Rust 1.71-es kiadása stabilizálta a #[debugger_visualizer] attribútumot, amivel natvis (Windows debug visualizer) vagy GDB pretty-printer szkripteket köthetünk a crate-jeinkhez. Ez embedded fejlesztésnél különösen hasznos, mert a probe-rs/cargo-embed GDB szerver módban is elindítható, és a natvis fájlokkal a regiszter-struktúrák (pl. pac::GPIOA) sokkal olvashatóbb formában jelennek meg a debugger ablakában, mint a nyers memóriaérték-dump.

Ha saját crate-et írsz, a natvis fájlt egyszerűen a lib.rs tetejére illesztheted:

#![cfg_attr(debug_assertions, debugger_visualizer(natvis_file = "debug/my_types.natvis"))]
#![no_std]

GDB alatt a cargo embed --gdb paranccsal indíthatunk egy szervert, majd egy külön terminálban csatlakozhatunk arm-none-eabi-gdb vagy VS Code Cortex-Debug bővítménnyel. Az RTT (Real-Time Transfer) csatorna pedig println!-szerű logolást tesz lehetővé anélkül, hogy UART-ot kellene konfigurálni – ezt a rtt-target crate-tel érdemes bekötni.

Figyelem

Debug build-ben (opt-level = 0) a kód mérete és a stack-használat jelentősen megnőhet egy szűk RAM-mal rendelkező chipen. Ha stack overflow-t tapasztalsz, próbálj opt-level = "s"-t beállítani a [profile.dev] szekcióban, vagy kapcsolj release build-re teszteléshez.

Összefoglalás

A no_std Rust világ első pillantásra tele van új fogalommal – linker script-ek, panic-handler-ek, target triplet-ek – de a embedded-hal absztrakciók és a probe-rs/cargo-embed eszközlánc segítségével meglepően gyorsan eljuthatunk egy villogó LED-től komolyabb periféria-vezérlésig. A típusbiztos GPIO állapotkezelés, a let-else mintázatok és a natvis debug támogatás mind hozzájárulnak ahhoz, hogy az embedded fejlesztés Rust-ban ne csak biztonságosabb, hanem kényelmesebb is legyen, mint a hagyományos C-s megközelítés. A következő lépés érdemes lehet az interrupt-vezérelt I/O és az alloc crate bevonása egy saját allokátorral – de ez már egy másik cikk témája.