Embedded Rust: Mikrocontroller, no_std und Embassy
Embedded Rust heißt: Rust für kleine Computer schreiben, die in Dingen stecken – in einem Thermostat, einer Tastatur, einer Drohne, einem Sensor, der Pflanzen gießt. Diese Computer heißen Mikrocontroller. Ein einziger Chip enthält den Prozessor, ein paar hundert Kilobyte Speicher und die Pins, über die er mit LEDs, Tastern und Sensoren spricht. Ein Betriebssystem gibt es meistens gar nicht. Dein Programm ist das Einzige, was läuft – vom Einschalten bis zum Ausschalten.
Das klingt beängstigend, macht aber auch richtig Spaß: Du schreibst ein paar Zeilen, und auf deinem Schreibtisch blinkt ein echtes Licht. Diese Seite zeigt dir, was möglich ist, welches Board und welche Crates du nehmen solltest und wie dein erstes Programm auf den Chip kommt. Unterwegs übst du die „Embedded-Denkweise“ mit Beispielen, die du direkt im Browser ausführen kannst.
Warum Rust so gut zu Embedded passt
Abschnitt betitelt „Warum Rust so gut zu Embedded passt“Jahrzehntelang wurde Embedded-Code vor allem in C geschrieben. Rust bringt ein paar Dinge mit, die auf einem winzigen Chip viel ausmachen:
- Kein Garbage Collector. Nichts hält dein Programm an, um Speicher aufzuräumen, deshalb bleibt das Timing vorhersehbar. Das ist wichtig, wenn du innerhalb von Mikrosekunden auf ein Signal reagieren musst.
- Speichersicherheit ohne Laufzeitumgebung. Die Regeln für Ownership (Eigentum) und Borrowing (Ausleihen) werden beim Kompilieren geprüft und kosten auf dem Chip deshalb nichts. Ganze Klassen von Abstürzen (hängende Zeiger, Data Races zwischen einem Interrupt und deiner Hauptschleife) werden abgefangen, bevor du irgendetwas flashst.
- Abstraktionen ohne Kosten. Mit einer HAL schreibst du
led.set_high(), statt an Speicheradressen herumzustochern. Kompiliert wird das zu genau dem einen Registerzugriff, den du sonst von Hand schreiben würdest. - Ownership bildet Hardware ab. Ein Pin ist ein Wert. Sobald er in einen
Outputverschoben (moved) wurde, kann ihn niemand anderes greifen und versehentlich als Eingang verwenden. Der Compiler erzwingt, was früher nur ein Kommentar in einer C-Datei war. - Hervorragende Werkzeuge.
cargo,rustup-Targets undprobe-rsersetzen viele Hersteller-IDEs und Makefiles.
Ehrlicherweise gibt es auch raue Stellen: Chiphersteller liefern ihre SDKs und Beispiele immer noch zuerst in C aus. Für manche Chips ist die Rust-Unterstützung deshalb dünner oder kommt nur aus der Community. Datenblätter musst du trotzdem lesen. Und manche Crates sind noch vor 1.0 und ändern ihre API zwischen Releases – folge also immer den Beispielen für die Version, die du verwendest.
Was du bauen kannst
Abschnitt betitelt „Was du bauen kannst“- Sensoren und Gadgets: eine Wetterstation, ein Alarm für trockene Blumenerde, ein Schrittzähler.
- USB-Geräte: eigene Tastaturen, Gamecontroller, MIDI-Controller.
- Vernetzte Geräte (IoT): WLAN- oder Bluetooth-Sensoren mit einem ESP32, die Messwerte an einen Server schicken.
- Roboter und Motoren: Linienfolger, Schrittmotor- und Servosteuerung, LED-Streifen und Displays.
- Ernsthafte Systeme: Oxide Computer hat Hubris (auf Englisch), ein kleines Betriebssystem für Mikrocontroller, komplett in Rust geschrieben. Espressif, die Firma hinter dem ESP32, pflegt eine eigene Rust-Unterstützung und veröffentlicht The Rust on ESP Book (auf Englisch).
Boards für einen leichten Einstieg
Abschnitt betitelt „Boards für einen leichten Einstieg“Jedes dieser Boards ist ein gutes erstes Board. Wenn du dich nicht entscheiden kannst, nimm einen Raspberry Pi Pico: Er ist günstig, sehr gut dokumentiert, und das Embassy-Beispiel weiter unten ist für ihn geschrieben.
| Board | Chip | Rust-Target | Was daran gut ist | Starte mit |
|---|---|---|---|---|
| Raspberry Pi Pico / Pico W | RP2040 (Arm Cortex-M0+) | thumbv6m-none-eabi |
Günstig, beliebt, hervorragende Unterstützung in Embassy | embassy-rp oder rp2040-hal + rp-pico |
| Raspberry Pi Pico 2 | RP2350 (Arm Cortex-M33 oder RISC-V) | thumbv8m.main-none-eabihf |
Neuer und schneller, hat eine Gleitkommaeinheit | embassy-rp (Feature rp235xa), rp235x-hal |
| Boards mit ESP32-C3 / ESP32-C6 | RISC-V-Chips von Espressif | riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf |
WLAN und Bluetooth eingebaut, offizielle Rust-Unterstützung | esp-hal und das Tool esp-generate |
| STM32 Nucleo, „Black Pill“ | STM32 (zum Beispiel STM32F4) | thumbv7em-none-eabihf |
Riesige Chipfamilie, die in der Industrie verbreitet ist; Nucleo-Boards haben eine Debug-Probe eingebaut | embassy-stm32, stm32f4xx-hal |
| BBC micro:bit v2 | nRF52833 (Arm Cortex-M4F) | thumbv7em-none-eabihf |
Debug-Probe, LEDs, Taster und Sensoren auf dem Board | microbit-v2, dazu das Discovery-Buch |
Der micro:bit ist das Board, das im offiziellen Embedded Rust Discovery Book (auf Englisch) verwendet wird – einem kostenlosen Kurs für Einsteiger, Schritt für Schritt. Der ursprüngliche ESP32 und der ESP32-S3 haben Xtensa-Kerne statt RISC-V. Für sie installiert das Tool espup von Espressif eine spezielle Rust-Toolchain. Die LED des Pico W hängt an seinem WLAN-Chip, nicht an einem normalen Pin. Sie braucht deshalb ein anderes Blinky (Embassy hat dafür ein Beispiel wifi_blinky).
no_std und no_main erklärt
Abschnitt betitelt „no_std und no_main erklärt“Ein normales Rust-Programm verwendet die Standardbibliothek (std). std erwartet ein Betriebssystem: Dateien, Threads, einen Heap, ein Terminal für println!. Ein Mikrocontroller hat nichts davon. Deshalb beginnen Embedded-Programme mit zwei Zeilen:
#![no_std] // die Standardbibliothek nicht linken, nur `core`#![no_main] // nicht den üblichen Einstiegspunkt `fn main()` verwenden#![no_std]bedeutet, dass du nurcore(auf Englisch) bekommst: Ganzzahlen,Option,Result, Iteratoren, Slices, Traits, Formatierung. KeinVec,String,Box, keine Dateien und keine Threads. (Mit einem Allocator kannst duVecund Co. über das Cratealloczurückholen, aber viele Projekte brauchen das nie.)#![no_main]brauchst du, weil auf einem Chip kein Betriebssystemmainaufruft. Ein Laufzeit-Crate wiecortex-m-rtrichtet nach dem Reset den Speicher ein und springt dann in deine Funktion, die du mit einem Attribut wie#[cortex_m_rt::entry]oder, mit Embassy,#[embassy_executor::main]markierst.- Du musst außerdem festlegen, was bei einem Panic passiert, denn es gibt kein Terminal, auf dem etwas ausgegeben werden könnte. Crates wie
panic-probeoderpanic-halterledigen das. Du bindest eines mituse panic_probe as _;ein.
Ohne Vec – wie speicherst du dann eine Liste? Das Crate heapless (auf Englisch) gibt dir Vec, String und Queues mit einer festen Kapazität, die beim Kompilieren feststeht. Sie liegen auf dem Stack oder in einem static. Es gibt also keinen Heap und keine bösen Überraschungen, weil der Speicher ausgeht. Ein voller Puffer gibt dir deinen Wert einfach zurück:
use core::fmt::Write;use heapless::{String, Vec};
fn main() { // Platz für genau 4 Messwerte. Der Speicher ist Teil der Variable: // kein Heap, kein Allocator, und die Größe steht beim Kompilieren fest. let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] { match readings.push(value) { Ok(()) => println!("stored {value}"), Err(rejected) => println!("buffer full, dropped {rejected}"), } }
// Ein String mit einer festen Kapazität von 16 Bytes let mut line: String<16> = String::new(); write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap(); println!("{line} (capacity {})", line.capacity());}heapless funktioniert auch auf einem PC. Du kannst das also mit cargo add [email protected] und cargo run ausprobieren (im Playground ist es nicht verfügbar):
stored 215stored 218stored 221stored 224buffer full, dropped 227avg=219 (capacity 16)Das Ökosystem in Schichten
Abschnitt betitelt „Das Ökosystem in Schichten“Embedded-Crates bauen aufeinander auf. Meistens arbeitest du auf der Ebene der HAL oder eines Frameworks, aber es hilft zu wissen, was darunter liegt:
| Schicht | Was es ist | Beispiele |
|---|---|---|
| PAC (Peripheral Access Crate) | Wird aus der Registerbeschreibung des Chips erzeugt. Eine halbwegs sichere Funktion pro Register und Feld. Sehr hardwarenah. | rp2040-pac, stm32f4, esp32c3 |
| HAL (Hardware Abstraction Layer) | Freundliche Typen für eine Chipfamilie: Output, Uart, I2c, Timer. Baut auf einem PAC auf. |
rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32 |
embedded-hal-Traits |
Gemeinsame Traits wie OutputPin, I2c, SpiDevice und DelayNs. HALs implementieren sie, Treiber verwenden sie. Version 1.0 ist stabil. |
embedded-hal, embedded-hal-async |
| BSP (Board Support Package) | Eine HAL plus Namen für die Pins und Bauteile eines bestimmten Boards („die LED“, „Taster A“). | rp-pico, microbit-v2 |
| RTIC | Ein Framework für Interrupt-gesteuerte Apps. Tasks sind an Interrupts und Prioritäten gebunden, und der Compiler prüft gemeinsam genutzte Daten schon beim Build. | rtic |
| Embassy | Ein async Framework: ein Executor, Timer, Netzwerk und USB, dazu eigene HALs. Du schreibst async fn-Tasks und wartest mit .await auf Hardware-Ereignisse. |
embassy-executor, embassy-time, embassy-rp |
Der Zauber steckt in der mittleren Zeile. Ein Treiber für einen Temperatursensor, der gegen den Trait I2c von embedded-hal geschrieben ist, funktioniert ohne Änderungen auf einem Pico, einem ESP32 und einem STM32, weil jede HAL denselben Trait implementiert. Weiter unten schreibst du selbst so einen Treiber.
Ein Target und probe-rs installieren
Abschnitt betitelt „Ein Target und probe-rs installieren“Dein PC hat einen x86- oder Arm-Desktop-Prozessor, der Pico einen Cortex-M0+. Du cross-kompilierst also: Du baust auf einer Maschine für eine andere. rustup lädt die vorkompilierte core-Bibliothek für den Chip herunter:
rustup target add thumbv6m-none-eabi # RP2040 (Pico)rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4Um das Programm auf den Chip zu kopieren und seine Logs zu sehen, brauchst du probe-rs (auf Englisch) und eine Debug-Probe: ein kleines USB-Gerät, das an die Debug-Pins des Chips angeschlossen wird. Beim micro:bit und den STM32-Nucleo-Boards ist eine eingebaut. Für einen Pico nimmst du eine Raspberry Pi Debug Probe oder einen zweiten Pico mit der kostenlosen Firmware debugprobe. Installier probe-rs mit seinem Installer-Skript (für Windows siehe die Installationsseite, auf Englisch) oder mit cargo:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh# oder, langsamer, aus dem Quellcode:cargo install probe-rs-tools --locked
probe-rs list # zeigt die Probes, die es findetNoch keine Probe? Halt beim Einstecken des Pico den Taster BOOTSEL gedrückt, dann erscheint er als USB-Laufwerk. Werkzeuge wie picotool oder elf2uf2-rs können dein Programm auf diesem Weg daraufkopieren. Ohne Probe siehst du allerdings keine defmt-Logs.
Bei einem ESP32-C3 oder C6 brauchst du meistens gar keine Probe: Der Chip hat USB eingebaut, und das Tool espflash flasht ihn über das normale USB-Kabel. Führ cargo install esp-generate espflash --locked aus, dann esp-generate, wähl deinen Chip und die Optionen, und cargo run --release baut, flasht und zeigt die Logs.
Blinky mit Embassy auf einem Raspberry Pi Pico
Abschnitt betitelt „Blinky mit Embassy auf einem Raspberry Pi Pico“„Blinky“ ist das „Hallo, Welt!“ der Embedded-Welt: LED an, warten, LED aus, warten, für immer. Das hier ist das offizielle Blinky-Beispiel für den RP2040 von Embassy (auf Englisch), ergänzt um Kommentare. Es braucht ein echtes Board und läuft nicht im Playground.
#![no_std]#![no_main]
use defmt::*;use defmt_rtt as _; // defmt-Logs an die Probe schickenuse embassy_executor::Spawner;use embassy_rp::gpio;use embassy_time::Timer;use gpio::{Level, Output};use panic_probe as _; // bei einem Panic: Meldung über die Probe ausgeben, dann anhalten
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]async fn main(_spawner: Spawner) { // Ownership über jede Peripherie des Chips übernehmen, genau einmal let p = embassy_rp::init(Default::default()); // Die LED des Pico hängt an Pin 25. PIN_25 wird in `led` verschoben. let mut led = Output::new(p.PIN_25, Level::Low);
loop { info!("led on!"); led.set_high(); Timer::after_millis(500).await;
info!("led off!"); led.set_low(); Timer::after_millis(500).await; }}Ein paar Dinge, die dir auffallen sollten:
mainist eineasync fn.Timer::after_millis(500).awaitdreht sich nicht in einer Warteschleife: Der Executor legt den Chip schlafen, bis der Timer auslöst. Mit weiteren Tasks (über denSpawnergestartet) laufen die anderen, während dieser wartet. Wenn Async/Await neu für dich ist: Die Idee ist dieselbe wie mit Tokio auf einem PC, nur ohne Betriebssystem.p.PIN_25wird inOutput::newverschoben (moved). Versuchst du,p.PIN_25ein zweites Mal zu verwenden, hält dich der Compiler auf: Ownership, angewandt auf Hardware.use defmt_rtt as _;unduse panic_probe as _;importieren Crates nur für das, was sie einrichten (den Log-Kanal und den Panic-Handler), nicht für einen Namen.
Am schnellsten siehst du es blinken, wenn du den offiziellen Beispielordner selbst verwendest: Klon das Embassy-Repository und führ in examples/rp den Befehl cargo run --bin blinky --release aus. Für dein eigenes Projekt kommen die Abhängigkeiten aus der Cargo.toml dieses Beispiels:
[package]name = "blinky"version = "0.1.0"edition = "2024"
[dependencies]embassy-executor = { version = "0.10.0", features = ["defmt"] }embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }defmt = "1.0.1"defmt-rtt = "1.0.0"panic-probe = { version = "1.0.0", features = ["print-defmt"] }cortex-m = { version = "0.7.6", features = ["inline-asm"] }cortex-m-rt = "0.7.0"
[profile.release]debug = true # Debug-Infos für probe-rs behalten; sie werden nicht auf den Chip geflashtZwei weitere Dateien sagen cargo, was gebaut und wie es ausgeführt wird. .cargo/config.toml legt das Target fest und sorgt dafür, dass cargo run über probe-rs flasht:
[target.'cfg(all(target_arch = "arm", target_os = "none"))']runner = "probe-rs run --chip RP2040"
[build]target = "thumbv6m-none-eabi"
[env]DEFMT_LOG = "debug"Kopier zum Schluss memory.x (wo Flash und RAM auf dem Chip liegen) und build.rs (das die Linker-Skripte übergibt) aus demselben Ordner examples/rp. Dann steck deine Probe ein und führ aus:
cargo run --releasecargo baut für thumbv6m-none-eabi, probe-rs flasht den Chip, die LED fängt an zu blinken, und die Meldungen led on! / led off! laufen mit Zeitstempeln in dein Terminal.
Logging mit defmt
Abschnitt betitelt „Logging mit defmt“Ein Mikrocontroller hat keinen Bildschirm, und Text mit core::fmt zu formatieren kostet viel Flash-Speicher. defmt (auf Englisch; „deferred formatting“, also aufgeschobenes Formatieren) löst beides. Die Format-Strings werden auf deinem PC gespeichert, nicht auf dem Chip. Der Chip schickt nur eine winzige ID plus die rohen Werte über die Probe, und probe-rs macht daraus wieder lesbaren Text.
use defmt::{info, warn, Format};
#[derive(Format)] // wie #[derive(Debug)], aber für defmtpub struct Reading { pub sensor_id: u8, pub milli_celsius: i32,}
pub fn report(r: &Reading) { info!("new reading: {}", r); if r.milli_celsius > 60_000 { // `=u8` und `=i32` nennen defmt den genauen Typ, das spart noch mehr Platz warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius); }}Die Level sind trace!, debug!, info!, warn! und error!, genau wie beim Crate log. Die Einstellung DEFMT_LOG in .cargo/config.toml legt fest, welche Level überhaupt einkompiliert werden. Alles darunter kostet null Bytes.
In Registern denken: Bits und Masken
Abschnitt betitelt „In Registern denken: Bits und Masken“Unter jeder HAL bedeutet Hardware steuern: Zahlen in Register schreiben – spezielle Speicheradressen, bei denen jedes Bit etwas auf dem Chip ein- oder ausschaltet. Auf einem typischen Chip hat ein 32-Bit-GPIO-Ausgangsregister ein Bit pro Pin. Hier spielt ein einfacher u32 dieses Register. So kannst du die vier Handgriffe üben, die alle Embedded-Entwickler verwenden. (Die ausgegebenen Texte bleiben auf Englisch, weil sie mit Leerzeichen in Spalten ausgerichtet sind.)
const LED: u32 = 25; // die LED auf dem Pico-Board hängt an Pin 25
fn main() { // Stell dir vor, dieser u32 ist das GPIO-Ausgangsregister des Chips. // Bit 0 steuert Pin 0, Bit 1 steuert Pin 1 und so weiter. let mut gpio_out: u32 = 0b1000; // Pin 3 ist schon an
// Ein Bit setzen: OR mit einer Maske gpio_out |= 1 << LED; println!("LED on: {gpio_out:#034b}");
// Ein Bit lesen: AND mit einer Maske, dann mit null vergleichen let led_is_on = (gpio_out & (1 << LED)) != 0; let pin2_is_on = (gpio_out & (1 << 2)) != 0; println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// Ein Bit umschalten: XOR mit einer Maske gpio_out ^= 1 << LED; println!("toggled: {gpio_out:#034b}");
// Ein Bit löschen: AND mit der invertierten Maske gpio_out |= 1 << LED; gpio_out &= !(1 << 3); println!("pin 3 off: {gpio_out:#034b}"); println!("as hex: {gpio_out:#010x}");}Ausgabe:
LED on: 0b00000010000000000000000000001000LED on? true, pin 2 on? falsetoggled: 0b00000000000000000000000000001000pin 3 off: 0b00000010000000000000000000000000as hex: 0x02000000In Rust kehrt ! bei einer Ganzzahl jedes Bit um (C verwendet dafür ~). {:#034b} gibt den Wert binär mit dem Präfix 0b aus und füllt ihn auf 32 Stellen auf. So kannst du zusehen, wie Bit 25 kommt und geht. Die vollständige Liste der Bit-Operatoren findest du unter Operatoren in Rust.
Viele Register packen mehrere Felder in eine Zahl: ein paar Bits für eine Geschwindigkeitsstufe, ein Bit für „aktiviert“. Um ein Feld zu ändern, machst du ein Read-Modify-Write (lesen, ändern, schreiben): Du löschst die Bits des Felds und setzt dann den neuen Wert per OR ein. Alle anderen Bits bleiben unberührt:
// Ein ausgedachtes UART-Steuerregister:// Bit 0 = aktivieren// Bits 4..=6 = Geschwindigkeitsstufe (eine 3-Bit-Zahl, 0 bis 7)const ENABLE: u32 = 1 << 0;const SPEED_SHIFT: u32 = 4;const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 { (reg & SPEED_MASK) >> SPEED_SHIFT}
fn write_speed(reg: u32, speed: u32) -> u32 { // Das alte Feld löschen, dann den neuen Wert einsetzen. // Jedes andere Bit bleibt genau so, wie es war. (reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)}
fn main() { let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT); println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5); println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 9 passt nicht in 3 Bits. Die Maske schneidet die Zahl ab, statt // in die benachbarten Bits überzulaufen. ctrl = write_speed(ctrl, 9); println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0; println!("still enabled: {still_enabled}");}Ausgabe:
start: 0b00100001, speed = 2speed 5: 0b01010001, speed = 5speed 9?: 0b00010001, speed = 1still enabled: trueFällt dir auf, dass aus 9 stillschweigend 1 geworden ist? Genau vor solchen Fehlern schützen dich PACs und HALs: Sie geben jedem Feld eine eigene Methode mit Namen (oft auch mit benannten Werten), sodass du Masken nur selten von Hand schreibst. Auf echter Hardware ist das Register eine feste Speicheradresse, die mit core::ptr::write_volatile in unsafe-Code beschrieben wird. Diesen Teil übernimmt das PAC für dich.
Einen Treiber schreiben, den du am PC testen kannst
Abschnitt betitelt „Einen Treiber schreiben, den du am PC testen kannst“Die beste Embedded-Gewohnheit, die du dir zulegen kannst: Schreib deine Logik gegen Traits, nicht gegen einen bestimmten Chip. Dann läuft derselbe Code auf dem Board mit der echten HAL und auf deinem PC mit gespielten Bauteilen („Mocks“) in einem normalen Test. Hier braucht StatusLed nur „etwas, das ich ein- und ausschalten kann“ und „etwas, das warten kann“:
use std::cell::RefCell;use std::rc::Rc;
// Die zwei Fähigkeiten, die ein Blink-Treiber braucht. Sie bilden die Traits// OutputPin und DelayNs aus embedded-hal 1.0 nach, vereinfacht, damit das im Playground läuft.trait OutputPin { fn set_high(&mut self); fn set_low(&mut self);}
trait Delay { fn delay_ms(&mut self, ms: u32);}
// Der Treiber weiß nichts über irgendeinen Chip. Er verwendet nur die Traits.struct StatusLed<P: OutputPin, D: Delay> { pin: P, delay: D,}
impl<P: OutputPin, D: Delay> StatusLed<P, D> { // `times`-mal blinken: `on_ms` lang an, dann `off_ms` lang aus. fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) { for _ in 0..times { self.pin.set_high(); self.delay.delay_ms(on_ms); self.pin.set_low(); self.delay.delay_ms(off_ms); } }}
// --- Attrappen zum Testen am PC ---// Beide Attrappen schreiben in dasselbe gemeinsame Log, damit wir die Reihenfolge sehen.type Log = Rc<RefCell<Vec<String>>>;
struct MockPin { log: Log,}
impl OutputPin for MockPin { fn set_high(&mut self) { self.log.borrow_mut().push("an".to_string()); } fn set_low(&mut self) { self.log.borrow_mut().push("aus".to_string()); }}
struct MockDelay { log: Log,}
impl Delay for MockDelay { fn delay_ms(&mut self, ms: u32) { self.log.borrow_mut().push(format!("warte {ms}")); }}
fn main() { let log: Log = Rc::new(RefCell::new(Vec::new())); let pin = MockPin { log: Rc::clone(&log) }; let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay }; led.blink(2, 100, 400);
let events = log.borrow(); println!("{}", events.join(", "));
assert_eq!(events.len(), 8); assert_eq!(events[0], "an"); assert_eq!(events[1], "warte 100"); println!("alle Prüfungen bestanden");}Ausgabe:
an, warte 100, aus, warte 400, an, warte 100, aus, warte 400alle Prüfungen bestandenDie zwei Mocks teilen sich über Rc<RefCell<...>> ein Log (siehe Smart Pointer). So kannst du die genaue Reihenfolge der Ereignisse prüfen. In einem echten Projekt kommen die assert_eq!-Zeilen in eine #[test]-Funktion und laufen mit cargo test auf deinem PC – ganz ohne Board (siehe Testen in Rust und Testen für Fortgeschrittene).
Auf dem Board schreibst du denselben Treiber gegen die echten Traits aus embedded-hal 1.0. Es ändert sich nur Folgendes: Die Pin-Methoden geben ein Result zurück (ein Pin an einem I/O-Expander-Chip kann fehlschlagen), du reichst Fehler also mit ? weiter, und der Code braucht überhaupt kein std:
#![no_std]
use embedded_hal::delay::DelayNs;use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> { pin: P, delay: D,}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> { pub fn new(pin: P, delay: D) -> Self { StatusLed { pin, delay } }
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> { for _ in 0..times { self.pin.set_high()?; self.delay.delay_ms(on_ms); self.pin.set_low()?; self.delay.delay_ms(off_ms); } Ok(()) }}Output aus Embassy implementiert OutputPin, und embassy_time::Delay implementiert DelayNs. StatusLed::new(led, embassy_time::Delay) funktioniert also auf dem Pico. Für Tests am PC gegen diese echten Traits liefert das Crate embedded-hal-mock (auf Englisch) fertige gespielte Pins sowie I2C- und SPI-Busse.
Sensor-Rechnung ohne Gleitkommazahlen
Abschnitt betitelt „Sensor-Rechnung ohne Gleitkommazahlen“Viele kleine Chips, etwa der Cortex-M0+ im RP2040, haben keine Gleitkommaeinheit. Floats funktionieren trotzdem, aber in Software: langsamer, und dein Programm wird größer. Embedded-Code verwendet deshalb oft Festkommazahlen: Ganzzahlen in einer kleineren Einheit. Statt 25.25 Grad speicherst du 25250 Milligrad.
Ein Temperatursensor nach Art des TMP102 schickt zwei Bytes. Die oberen 12 Bits sind eine vorzeichenbehaftete Zahl in Schritten von 1/16 °C:
// Die oberen 12 Bits sind die Temperatur in Schritten von 1/16 °C (0,0625 °C),// als vorzeichenbehaftete Zahl (Zweierkomplement).fn to_millicelsius(bytes: [u8; 2]) -> i32 { // i16 behält das Vorzeichen. `>> 4` auf einer vorzeichenbehafteten Zahl auch. let raw = i16::from_be_bytes(bytes) >> 4; // 1/16 °C = 62,5 m°C, also mit 625 multiplizieren und durch 10 teilen. raw as i32 * 625 / 10}
// 25250 als "25.250" ausgeben, ganz ohne Float.fn format_milli(m: i32) -> String { let sign = if m < 0 { "-" } else { "" }; let m = m.abs(); format!("{sign}{}.{:03}", m / 1000, m % 1000)}
fn main() { let samples = [ [0x19, 0x40], // ein warmes Zimmer [0x00, 0x10], // der kleinste Schritt über null [0xFF, 0x80], // knapp unter dem Gefrierpunkt [0xE7, 0x00], // ein Gefrierschrank ];
for bytes in samples { let m = to_millicelsius(bytes); println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m)); }}Ausgabe:
[19, 40] -> 25250 m°C = 25.250 °C[00, 10] -> 62 m°C = 0.062 °C[FF, 80] -> -500 m°C = -0.500 °C[E7, 00] -> -25000 m°C = -25.000 °CDrei Details sorgen dafür, dass das funktioniert. i16::from_be_bytes liest die zwei Bytes als eine vorzeichenbehaftete Zahl, das höchstwertige Byte zuerst („Big-Endian“) – so schicken die meisten Sensoren ihre Daten. >> 4 auf einer vorzeichenbehafteten Ganzzahl behält das Vorzeichen, deshalb kommen negative Temperaturen richtig heraus. Und format_milli behandelt das Vorzeichen getrennt: -500 / 1000 ergibt in Ganzzahl-Rechnung 0, ohne diesen Schritt würde -0.5 °C also als 0.500 ausgegeben. (Die Ausgabe verwendet übrigens den Punkt als Dezimaltrennzeichen, wie im Englischen.)
Ganzzahl-Division schneidet außerdem ab: Aus 62,5 m°C wurde 62. Ein halbes Milligrad spielt für einen Raumsensor keine Rolle, aber frag dich immer, wie viel Genauigkeit dir eine Einheit gibt. Und behalte den Wertebereich im Blick: Ein großer Messwert mal ein großer Faktor kann einen i32 überlaufen lassen (siehe Integer-Überlauf). Auf einem PC ist String in Ordnung; auf dem Chip würdest du mit defmt ausgeben oder in einen heapless::String schreiben.
Häufige Fehler
Abschnitt betitelt „Häufige Fehler“Dinge aus std in einem no_std-Programm verwenden. println!, Vec und String kommen aus std und sind deshalb einfach nicht da:
error: cannot find macro `println` in this scope --> src/main.rs:4:5 |4 | println!("hello"); | ^^^^^^^Der Compiler findet das Makro println nicht. Nimm defmt::info! zum Loggen und heapless (oder das Crate alloc mit einem Allocator) für Collections.
Den Panic-Handler vergessen. Ein no_std-Binary muss festlegen, was ein Panic tut. Ohne ein Crate wie panic-probe bekommst du:
error: `#[panic_handler]` function required, but not foundAlso: Eine #[panic_handler]-Funktion ist nötig, wurde aber nicht gefunden. Füg panic-probe (oder panic-halt) zu Cargo.toml hinzu und use panic_probe as _; zu main.rs.
Ein ganzes Register überschreiben. reg = 1 << 3 schaltet Pin 3 ein – und jeden anderen Pin aus. Um ein einzelnes Bit zu ändern, nimm reg |= 1 << 3. Für Felder machst du das Read-Modify-Write von oben.
Einem alten Tutorial folgen. Embedded-Crates entwickeln sich schnell, und Blogbeiträge von vor ein paar Jahren verwenden oft embedded-hal 0.2 oder ältere APIs von Embassy. Fang mit dem Beispielordner der Crate-Version an, von der du tatsächlich abhängst.
Übungen
Abschnitt betitelt „Übungen“1. Welche Pins sind an?
Abschnitt betitelt „1. Welche Pins sind an?“Schreib toggle(reg: u32, pin: u32) -> u32, das ein Bit umschaltet, und pins_on(reg: u32) -> Vec<u32>, das jeden Pin auflistet, dessen Bit 1 ist. Fang mit den Pins 0, 5 und 25 an, gib die Liste und die Zahl der eingeschalteten Pins aus, schalte dann die Pins 5 und 7 um und gib alles noch einmal aus. Tipp: Prüf jeden Pin von 0 bis 31 mit einer Maske. Und u32 hat eine Methode count_ones().
Lösung anzeigen
fn toggle(reg: u32, pin: u32) -> u32 { reg ^ (1 << pin)}
fn pins_on(reg: u32) -> Vec<u32> { (0..32).filter(|pin| reg & (1 << pin) != 0).collect()}
fn main() { let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25); println!("an: {:?} ({} Pins)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5); reg = toggle(reg, 7); println!("an: {:?} ({} Pins)", pins_on(reg), reg.count_ones());}Ausgabe:
an: [0, 5, 25] (3 Pins)an: [0, 7, 25] (3 Pins)XOR schaltet ein Bit um, egal welchen Wert es vorher hatte. Das Umschalten von Pin 5 hat ihn also ausgeschaltet und das Umschalten von Pin 7 hat ihn eingeschaltet. count_ones() zählt die 1-Bits für dich, für diesen Teil brauchst du also keine Schleife.
2. Akkustand ohne Gleitkommazahlen
Abschnitt betitelt „2. Akkustand ohne Gleitkommazahlen“Ein 12-Bit-ADC liefert Messwerte von 0 bis 4095 für 0 bis 3300 mV. Der Akku ist über einen Spannungsteiler angeschlossen, der seine Spannung halbiert. Die echte Spannung ist also doppelt so hoch wie die gemessene. Schreib battery_mv(raw: u16) -> i32 und battery_percent(mv: i32) -> i32, wobei 3000 mV 0 % und 4200 mV 100 % sind, nie unter 0 und nie über 100. Probier die Rohwerte 2606, 2482, 2234 und 1861 aus. Tipp: Erst multiplizieren, dann teilen, damit keine Genauigkeit verloren geht. Und schau dir clamp an.
Lösung anzeigen
const EMPTY_MV: i32 = 3000;const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 { // 12-Bit-ADC (0..=4095) misst 0..=3300 mV, hinter einem Teiler, der die Spannung halbiert raw as i32 * 3300 * 2 / 4095}
fn battery_percent(mv: i32) -> i32 { let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV); percent.clamp(0, 100)}
fn main() { for raw in [2606, 2482, 2234, 1861] { let mv = battery_mv(raw); println!("Rohwert {raw} -> {mv} mV -> {}%", battery_percent(mv)); }}Ausgabe:
Rohwert 2606 -> 4200 mV -> 100%Rohwert 2482 -> 4000 mV -> 83%Rohwert 2234 -> 3600 mV -> 50%Rohwert 1861 -> 2999 mV -> 0%Wenn du erst am Schluss durch 4095 teilst, bleibt die Genauigkeit erhalten: raw / 4095 * 6600 wäre für jeden Messwert unter 4095 gleich 0. Das größte Produkt, 4095 * 6600, liegt bei etwa 27 Millionen und passt locker in einen i32.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Embedded Rust läuft auf Mikrocontrollern ohne Betriebssystem. Kein Garbage Collector, Sicherheit schon beim Kompilieren und Abstraktionen ohne Kosten machen Rust zu einer tollen Wahl.
#![no_std]lässt dir nurcore;#![no_main]lässt ein Laufzeit-Crate deine Einstiegsfunktion aufrufen; einen Panic-Handler musst du selbst wählen.heaplessgibt dir Collections mit fester Kapazität.- Die Schichten: PAC (Register), HAL (eine Chipfamilie),
embedded-hal-Traits (gemeinsam), BSP (ein Board) und darüber Frameworks wie RTIC und Embassy. - Füg das Target des Chips mit
rustup target addhinzu, dann flashst du und siehst Logs mit probe-rs und einer Debug-Probe (oder mitespflashauf einem ESP32).defmthält das Logging winzig. - Üb am PC: Bitmasken für Register, Treiber gegen Traits mit gespielten Bauteilen und Festkomma-Ganzzahlen statt Floats.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Was bewirkt #![no_std]?
2. Welche Schicht sorgt dafür, dass ein Sensortreiber ohne Änderungen auf einem Pico, einem ESP32 und einem STM32 funktioniert?
3. Wie schaltest du Bit 3 von reg ein, ohne ein anderes Bit zu ändern?
4. Warum speichert Embedded-Code 25,25 °C oft als 25250 Milligrad?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- The Embedded Rust Book (auf Englisch): die offizielle Einführung in
no_std, Peripherie, Register und das Embedded-Ökosystem. - Discovery (micro:bit v2) (auf Englisch): ein kostenloser Kurs für Einsteiger, der dich Schritt für Schritt von einer blinkenden LED bis zu Sensoren auf einem BBC micro:bit führt.
- The Embassy Book (auf Englisch): der Einstieg in Embassy, wie sein Executor und seine HALs funktionieren und wie du die offiziellen Beispiele ausführst.
- The Rust on ESP Book (auf Englisch): der eigene Leitfaden von Espressif zu
esp-hal,esp-generate,espflashund den ESP32-Toolchains. - probe-rs-Installation (auf Englisch): wie du probe-rs auf jedem Betriebssystem installierst und deine Debug-Probe einrichtest.