Zum Inhalt springen

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.

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 Output verschoben (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 und probe-rs ersetzen 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.

  • 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).

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

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 nur core (auf Englisch) bekommst: Ganzzahlen, Option, Result, Iteratoren, Slices, Traits, Formatierung. Kein Vec, String, Box, keine Dateien und keine Threads. (Mit einem Allocator kannst du Vec und Co. über das Crate alloc zurückholen, aber viele Projekte brauchen das nie.)
  • #![no_main] brauchst du, weil auf einem Chip kein Betriebssystem main aufruft. Ein Laufzeit-Crate wie cortex-m-rt richtet 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-probe oder panic-halt erledigen das. Du bindest eines mit use 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 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

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.

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:

Terminal window
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, STM32F4

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

Terminal window
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 findet

Noch 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“ 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 schicken
use 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:

  • main ist eine async fn. Timer::after_millis(500).await dreht sich nicht in einer Warteschleife: Der Executor legt den Chip schlafen, bis der Timer auslöst. Mit weiteren Tasks (über den Spawner gestartet) 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_25 wird in Output::new verschoben (moved). Versuchst du, p.PIN_25 ein zweites Mal zu verwenden, hält dich der Compiler auf: Ownership, angewandt auf Hardware.
  • use defmt_rtt as _; und use 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 geflasht

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

Terminal window
cargo run --release

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

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 defmt
pub 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.

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: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ Selbst ausprobieren

In 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 = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ Selbst ausprobieren

Fä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 400
alle Prüfungen bestanden
▶ Selbst ausprobieren

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

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 °C
▶ Selbst ausprobieren

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

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 found

Also: 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.

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)
▶ Selbst ausprobieren

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.

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%
▶ Selbst ausprobieren

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.

  • 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 nur core; #![no_main] lässt ein Laufzeit-Crate deine Einstiegsfunktion aufrufen; einen Panic-Handler musst du selbst wählen. heapless gibt 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 add hinzu, dann flashst du und siehst Logs mit probe-rs und einer Debug-Probe (oder mit espflash auf einem ESP32). defmt hält das Logging winzig.
  • Üb am PC: Bitmasken für Register, Treiber gegen Traits mit gespielten Bauteilen und Festkomma-Ganzzahlen statt Floats.

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

  • 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, espflash und den ESP32-Toolchains.
  • probe-rs-Installation (auf Englisch): wie du probe-rs auf jedem Betriebssystem installierst und deine Debug-Probe einrichtest.