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.

Tipp

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.

Megjegyzés

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.

Figyelem

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!

Í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:

  1. #![no_std] és #![no_main] — nincs operációs rendszerünk, tehát nincs standard könyvtár és nincs hagyományos main belépési pont sem, helyette a cortex-m-rt crate #[entry] makróját használjuk.
  2. 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.
  3. 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.
  4. A gpioc.split() szétbontja a GPIOC portot egyedi pin típusokra, majd a pc13.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.
  5. A delay_ms az embedded-hal DelayMs traitjéből jön, amit a SysTick timer implementál.
Jó tudni

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.

Tipp

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.