Az embedded Rust ökoszisztéma mára annyira megérett, hogy egy hobbi STM32F4 Discovery boardon (vagy bármilyen más STM32F4-es panelen) percek alatt futtathatunk saját, memóriabiztos firmware-t. Ebben a cikkben végigmegyünk a teljes folyamaton: a fejlesztői környezet előkészítésétől a flash-elésig.
A fejlesztői környezet előkészítése
Először is szükségünk van néhány toolra. A rustup segítségével adjuk hozzá a Cortex-M4 célt (az STM32F4 sorozat ARM Cortex-M4 magot tartalmaz, FPU-val):
rustup target add thumbv7em-none-eabihf
A projekt gyors felállításához a cargo-generate crate-et használjuk, ami sablonokból tud új projektet generálni:
cargo install cargo-generate
cargo generate --git https://github.com/rust-embedded/cortex-m-quickstart
A generátor több kérdést fel fog tenni (projekt neve, célchip stb.), ezeket a saját boardunknak megfelelően töltsük ki.
Ha STM32F4 Discovery boardod van, érdemes a knurling-rs app-template-jét is megnézni — az alapból be van kötve defmt logolással és probe-run-nal, ami sokkal kényelmesebb debug élményt ad, mint a puszta println!.
A flash-eléshez és debugoláshoz a probe-rs projektet fogjuk használni, ami egy Rust-ban írt, gyors és megbízható eszközkészlet ST-Link, J-Link és más debug probe-okhoz:
cargo install probe-rs --features cli
Ez telepíti a cargo-flash és cargo-embed subcommandokat is, amikkel közvetlenül a cargo-ból tudunk flash-elni és debug session-t indítani.
Az embedded-hal absztrakció szerepe
Az embedded Rust világ egyik legfontosabb tervezési döntése az embedded-hal crate. Ez nem egy konkrét hardveres implementáció, hanem egy trait-gyűjtemény, ami leírja, hogyan kell viselkednie egy GPIO pinnek, egy SPI busznak, egy I2C perifériának, egy delay providernek, és így tovább.
Ennek köszönhetően a te alkalmazásod kódja nem közvetlenül egy adott chip regisztereivel dolgozik, hanem az embedded-hal traitjeivel — így elméletileg ugyanaz a driver kód (mondjuk egy BME280 szenzor driver) működik STM32-n, nRF52-n vagy RP2040-en is, feltéve hogy az adott chip HAL crate-je implementálja ezeket a traiteket.
use embedded_hal::digital::v2::OutputPin;
fn blink_once<P: OutputPin>(led: &mut P) -> Result<(), P::Error> {
led.set_high()?;
led.set_low()?;
Ok(())
}
Ebben a példában a blink_once függvény semmit sem tud arról, hogy konkrétan milyen mikrovezérlőn fut — csak azt követeli meg, hogy a paraméterként kapott típus implementálja az OutputPin traitet.
Jelenleg (2023 eleje) az embedded-hal 0.2.x verziója a de facto standard, ezért embedded_hal::digital::v2::OutputPin-t importálunk. Az 1.0-s major verzió még fejlesztés alatt áll, azt majd egy külön cikkben tárgyaljuk, ha stabil lesz.
GPIO pin konfigurálása stm32f4xx-hal-lal
A konkrét chip-specifikus implementációt a stm32f4xx-hal crate adja. Ez a crate az embedded-hal trait-jeit implementálja az STM32F4 család regisztereire építve (amiket a stm32f4 PAC — Peripheral Access Crate — generál automatikusan az SVD fájlokból).
Állítsuk össze a Cargo.toml-t:
[package]
name = "stm32-blink"
version = "0.1.0"
edition = "2021"
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
panic-halt = "0.2"
embedded-hal = "0.2.7"
[dependencies.stm32f4xx-hal]
version = "0.14"
features = ["stm32f411"]
A features = ["stm32f411"] sorral mondjuk meg a HAL crate-nek, hogy pontosan melyik chip variánst célozzuk — ez fontos, mert az STM32F4 család tagjai között jelentős különbségek vannak a perifériák elrendezésében.
Ha rossz feature flaget adsz meg (pl. stm32f401 helyett stm32f411-et), a kód lefordul, de a chip pinout-ja vagy a clock tree konfigurációja nem fog egyezni a valósággal — a program vagy nem indul el, vagy rossz pinen fog villogni. Mindig ellenőrizd a boardod pontos chip típusát!
Az egyszerű blink program megírása
Íme a teljes main.rs, ami az STM32F411-es Discovery boardon (vagy hasonló panelen) a PC13-as pinen villogtatja a beépített LED-et:
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{pac, prelude::*};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let cp = cortex_m::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
let gpioc = dp.GPIOC.split();
let mut led = gpioc.pc13.into_push_pull_output();
let mut delay = cp.SYST.delay(&clocks);
loop {
led.set_high();
delay.delay_ms(500u32);
led.set_low();
delay.delay_ms(500u32);
}
}
Nézzük végig, mi történik itt:
#![no_std]és#![no_main]— nincs operációs rendszerünk, tehát nincs standard könyvtár és nincs hagyományosmainbelépési pont sem, helyette acortex-m-rtcrate#[entry]makróját használjuk.pac::Peripherals::take()— a Peripheral Access Crate-ből lekérjük a chip perifériáit egy singleton mintában, ami biztosítja, hogy ne tudjuk véletlenül kétszer birtokba venni ugyanazt a regisztert.- Az
rcc.cfgr.sysclk(48.MHz()).freeze()beállítja az órajel fát — enélkül a chip az alapértelmezett belső oszcillátorral futna. - A
gpioc.split()szétbontja a GPIOC portot egyedi pin típusokra, majd apc13.into_push_pull_output()átkonfigurálja a pint kimeneti módba — ez típusállapot-alapú (typestate) API, tehát fordítási időben garantált, hogy csak olyan műveleteket hívhatunk a pinen, amik az aktuális módjában érvényesek. - A
delay_msazembedded-halDelayMstraitjéből jön, amit a SysTick timer implementál.
A typestate minta miatt, ha megpróbálnád a pc13-at bemenetként és kimenetként is használni ugyanabban a scope-ban módváltás nélkül, a borrow checker és a típusrendszer egyszerűen nem engedné lefordulni a kódot. Ez klasszikus példája annak, hogyan tud a Rust típusrendszere hardveres hibákat fordítási időben kiszűrni.
A fordításhoz szükségünk van egy memory.x linker script-re is, ami leírja a chip flash és RAM méretét:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}
Ezt a projekt gyökerébe kell tenni, a build.rs (amit a cortex-m-quickstart sablon már tartalmaz) automatikusan felszedi.
Flash-elés és debugolás probe-rs-szel
Ha a hardver (pl. egy ST-Link programozó) csatlakoztatva van USB-n, a flash-elés egyetlen paranccsal megy:
cargo flash --chip STM32F411CEUx --release
A --chip paraméterrel pontosan meg kell adni a chip nevét — a probe-rs egy beépített chip adatbázissal dolgozik, amit a probe-rs chip list paranccsal listázhatunk ki, ha nem vagyunk biztosak a pontos névben.
Ha debug session-re van szükségünk (breakpointok, regiszter vizsgálat), a cargo embed parancs egy GDB szerver-t is elindít a háttérben, amihez csatlakozhatunk gdb-multiarch-csal vagy VS Code-ból a cortex-debug extension segítségével. Ehhez egy Embed.toml konfigurációs fájlra van szükség a projekt gyökerében:
[default.general]
chip = "STM32F411CEUx"
[default.rtt]
enabled = true
[default.gdb]
enabled = true
Az rtt (Real-Time Transfer) engedélyezésével a chipről valós idejű logokat is kaphatunk vissza a hosztra, ami sokkal kényelmesebb, mint a hagyományos UART alapú debug printelés.
Ha a defmt crate-et is bekötöd a projektbe, a log üzenetek bináris formában, minimális overhead-del kerülnek át a hoszt gépre, ami kritikus lehet, ha szűkös a flash méret vagy időzítés-érzékeny a kód.
Összefoglalás
Egy egyszerű LED villogtatás mögött meglepően sok érdekes Rust-specifikus tervezési minta húzódik meg: a típusállapot alapú GPIO API, az embedded-hal absztrakciós réteg, és a probe-rs moduláris, Rust-natív toolchain-je. A fenti lépések — cargo-generate a projekt vázhoz, stm32f4xx-hal a hardveres absztrakcióhoz, probe-rs a flash-eléshez és debugoláshoz — egy jól bejáratott, teljesen nyílt forráskódú munkafolyamatot adnak, ami C/C++ alapú embedded fejlesztőknek is könnyen megközelíthető. Ha ez az első embedded Rust projekted volt, a következő lépés érdemes lehet egy szenzor (pl. I2C-s hőmérő) bekötése, ahol már igazán kiderül, mennyire kényelmes a HAL-alapú, típusbiztos API driverek írásához.