en / de
AI
Expertisen
Methoden
Dienstleistungen
Referenzen
Jobs & Karriere
Firma
Technologie-Trends TechCast WebCast TechBlog News Events Academy

Blogserie Fullstack Rust – Teil 1: Embedded Rust

“Dieser Blogbeitrag ist einer von einer Serie zum Thema Fullstack Rust. Es soll gezeigt werden, was bereits heute mit der jungen Programmiersprache Rust alles möglich ist. Der Inhalt ist aus der Perspektive eines Rust-Neulings geschrieben, und geht auf Core Elemente der Programmiersprache ein.”

Abstract

Figure 1: Rust Logo

Wenn wir die Entwicklungslandschaft im Embedded Bereich betrachten, so dominieren hier aktuell C und C++. Diese Sprachen sind seit Jahrzehnten etabliert und bieten direkten Zugriff auf die Hardware. Allerdings bringen sie auch einige Herausforderungen mit sich, insbesondere in Bezug auf Speicherverwaltung und Sicherheit. Weiter ist auch MicroPython heute eine beliebte Wahl für Prototyping, jedoch wird diese in der Produktion selten eingesetzt. Rust bietet eine moderne Alternative, die viele der Probleme von C/C++ adressiert, ohne dabei auf Performance zu verzichten. Mit seinem Fokus auf Sicherheit, Effizienz und Concurrency gewinnt Rust zunehmend an Bedeutung in der Embedded-Entwicklung.

Die Verwendung von Rust auf Embedded Systemen ist bereits relativ breit aufgestellt. Es werden diverse Boards wie beispielsweise Raspberry Pi Pico (W/W2), viele STM32 Mikrocontroller und einige Boards der ESP32 Familie bereits unterstützt. In diesem Blogpost soll aufgezeigt werden, welche Möglichkeiten bereits heute mit Rust in der Embedded Welt möglich sind. Es gibt bereits diverse Frameworks und Bibliotheken, die die Entwicklung mit Rust auf Embedded Systemen erleichtern. Eines der bekanntesten ist das Embassy Framework, welches eine asynchrone Runtime für Embedded Systeme bereitstellt. Dieses Framework ermöglicht es, Tasks zu erstellen, die parallel laufen können, ähnlich wie bei FreeRTOS, jedoch basierend auf Rust’s async/await Konzept. Der Umfang des Frameworks ist beeindruckend und bietet Unterstützung für viele gängige Peripherien und Protokolle.

Embedded Rust unsterscheidet sich in einigen Punkten von der herkömmlichen Rust-Entwicklung für Desktop- oder Server-Anwendungen. Zum einen wird in der Regel in einer «no_std»-Umgebung gearbeitet, was bedeutet, dass die Standardbibliothek nicht verfügbar ist. Dies erfordert ein Umdenken in Bezug auf Speicherverwaltung und verfügbare Funktionen. Hierfür gibt das Embedded Rust Book eine gute Übersicht https://docs.rust-embedded.org/book/.

In diesem Blogpost werden wir ein vollständiges IoT-Sensor-System mit dem Raspberry Pi Pico WH entwickeln. Wir werden einen AHT20 Sensor verwenden, um Temperatur und Feuchtigkeit zu messen, und die Daten über WLAN an einen MQTT-Broker senden. Dabei werden wir das Embassy Framework nutzen, um eine asynchrone Task-basierte Architektur zu implementieren. Der Fokus liegt darauf, die Grundlagen der Embedded Rust Entwicklung zu vermitteln und Best Practices für die Entwicklung produktionsreifer Embedded-Anwendungen aufzuzeigen.

Einleitung

In diesem Tutorial entwickeln wir ein produktionsreifes IoT-Gerät, das Temperatur und Feuchtigkeit misst und diese Daten über WLAN an einen zentralen Server sendet. Das System basiert auf einem Raspberry Pi Pico WH mit RP2040 Mikrocontroller und einem AHT20 Sensor, der über die I2C-Schnittstelle ausgelesen wird. Die gemessenen Daten werden über eine WiFi-Verbindung mittels MQTT-Protokoll an einen Broker übertragen, wo sie weiterverarbeitet, visualisiert oder in einer Datenbank gespeichert werden können.

Das Besondere an diesem Projekt ist die Verwendung des Embassy Frameworks – einem modernen async Runtime-System für Embedded Rust, das ähnlich wie FreeRTOS funktioniert, aber auf Rust’s ‚async/await‘ Konzept basiert. Dies ermöglicht eine elegante Task-basierte Architektur mit fünf parallel laufenden Tasks: Ein Task liest kontinuierlich den Sensor aus, zwei weitere Tasks kümmern sich um die WiFi-Kommunikation und den TCP/IP-Stack, ein MQTT-Task versendet die Daten, und der Main-Task überwacht das System mittels Watchdog. Die Tasks kommunizieren über Channels miteinander, wodurch Sensor und Netzwerk entkoppelt sind – das System kann also auch dann weiter messen, wenn die Netzwerkverbindung unterbrochen ist.

In diesem Tutorial lernen Sie die Grundlagen der Embedded Rust Entwicklung kennen: Vom Setup der ’no_std‘ Umgebung über Memory-Layout-Konfiguration bis hin zur Hardware-Kommunikation mit I2C und der Programmierung eines eigenen TCP/IP-Stacks. Sie werden verstehen, wie asynchrone Programmierung ohne Betriebssystem funktioniert, wie Hardware-Interrupts in Rust verwendet werden, und wie man ein robustes IoT-Gerät mit moderner Software-Architektur entwickelt. Das fertige System demonstriert Best Practices für produktionsreife Embedded-Anwendungen und kann als Basis für eigene IoT-Projekte dienen.

Der Testaufbau umfasst den Raspberry Pi Pico WH, der über die I2C-Schnittstelle mit dem AHT20 Sensor verbunden ist. Zusätzlich ist ein Debugger angeschlossen, um Log-Ausgaben und Debugging-Funktionalität zu ermöglichen. Der Pico WH wird mit 3.3V Spannung versorgt, die vom Steck-Board abgegriffen wird.

Figure 2: Testaufbau mit Raspberry Pi Pico WH, AHT20 Sensor und Debugger

Projekt Setup

Wenn Sie bereits mit Rust gearbeitet haben, sind Ihnen die Grundlagen der Projektstruktur und des Build-Systems vertraut. Embedded Rust Projekte unterscheiden sich jedoch in einigen Punkten von herkömmlichen Rust-Projekten, insbesondere in Bezug auf die Toolchain, das Memory-Layout und die Konfiguration zur Compile-Zeit.

Testaufbau

Beginnen wir mit der Hardware Anforderung. Für den Testaufbau verwende ich eine Steck-Board um möglichst schnell und einfach das Setup vorzubereiten.Der Debugger wird für den Logoutput direkt mit dem UART0 an Pin 1 (TX) und Pin 2 (RX) und für die Debug-Funktionalität mit dem Debug Port verbunden. Der Sensor wird in diesem Hands-On an den I2C0 auf Pin 21 (SCL) und Pin 22 (SDA) angeschlossen. 3.3V Spannung kann an Pin 36 abgegriffen werden.

Figure 3: Testaufbau Diagramm

Projekt Struktur

Ein Embedded Rust Projekt kann wie ein herkömmliches Rust Projekt gestartet werden. Mit cargo new <Projektname> wird ein Folder mit den notwendigen Files erstellt.

Anschliessend wechseln wir in das neu erstellte Verzeichnis und erstellen folgende Dateien und Ordner:

embedded_rust/
├── .cargo/
│ └── config.toml   # Cargo/Build-Konfiguration
├── cyw43-firmware/ # WiFi-Firmware
│ ├── 43439A0.bin
│ └── 43439A0_clm.bin
├── src/
│ ├── main.rs
│ ├── aht20.rs      # Sensor-Modul
│ └── mqtt.rs       # MQTT-Modul
├── build.rs        # Build-Script
├── config.toml     # WiFi/MQTT Konfiguration
├── memory.x        # Memory-Layout
└── Cargo.toml

Alle Dateien werden in den folgenden Abschnitten im Detail erklärt und mit Inhalt gefüllt.

Setup Toolchain

Wenn Rust bereits installiert ist, kann folgender Punkt übersprungen werden.

  1. Installieren Sie die Rust-Toolchain “Rustup” auf Ihrem System. Dies kann mit einem Paketmanagern (Homebrew, Winget, etc.) oder direkt über die offizielle Installationsmethode gemacht werden.
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    source $HOME/.cargo/env
    rustup update

    Führen Sie anschliessen rustc --version aus, um sicherzustellen, dass Rust korrekt installiert ist und aktualieren Sie auf die neuste Version mit rustup update.

  2. Damit beim Kompilieren der Code für die korrekte Plattform gebaut wird, muss die Architekur zu rustup hinzugefügt werden. Für den Raspberry Pi Pico WH mit dem RP2040 Mikrocontroller ist dies thumbv6m-none-eabi:
    rustup target add thumbv6m-none-eabi
  3. Um auf den Mikrocontroller flashen und debuggen zu können, wird das Tool probe-rs benötigt. Dieses kann mit Cargo installiert werden:
    cargo install probe-rs-tools

Probe-rs unterstützt eine Vielzahl von Debug-Adaptern und Mikrocontroller-Plattformen. Eine Übersicht der unterstützten Geräte ist auf der offiziellen Webseite zu finden https://probe.rs/.

Mit diesem Setup sind die grundlegenden Voraussetzungen für die Embedded Rust Entwicklung geschaffen.

Grundlagen und Projektkonfiguration

Ein Embedded Rust Projekt kann nicht gleich wie ein kerkömmliches Rust Projekt direkt nach ausführung von cargo new <Projektname> gestartet werden. Es sind einige zusätzliche Konfigurationsschritte notwendig, um die Toolchain, das Memory-Layout und die Compile-Zeit Einstellungen für das Embedded System zu definieren.

Hardware Konfiguration (.cargo/config.toml)

Wenn wir nun zu unserem initialen Setup zurückkehren, müssen wir als nächstes eine .cargo/config.toml Datei anlegen. Mit dieser Konfigurationsdatei können hardware-spezifische Einstellungen für den Package Manager und das Build System vorgenommen werden. In dieser Datei definieren wir folgendes:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi" # Cortex-M0 and Cortex-M0+
[env]
DEFMT_LOG = "debug"

Build-Konfiguration und Compile-Zeit Setup

Im Embedded Bereich muss die Konfiguration der Speichernutzung oder Umgebungsvariablen bereits zur Build-Zeit definiert sein. Anders als Desktop-Anwendungen müssen wir dem Compiler und Linker exakt mitteilen, wie die Hardware aufgebaut ist und wo welche Daten liegen. Gleichzeitig wollen wir sensible Konfigurationsdaten (WiFi-Passwörter) nicht direkt im Code haben.

Das Rust Build-System bietet hierfür das Build-Script (build.rs), das vor dem eigentlichen Kompilieren ausgeführt wird. Es übernimmt in diesem Projekt zwei zentrale Aufgaben:

  1. Hardware-Konfiguration: Memory-Layout für den Mikrocontroller definieren
  2. Anwendungs-Konfiguration: WiFi und MQTT Einstellungen zur Compile-Zeit einbetten

Memory-Layout (memory.x)

Um dem Compiler mitzuteilen wie der Speicher auf dem Mikrocontroller aufgebaut ist, muss eine Linker-Script Datei (memory.x) erstellt werden. Diese definiert die Speicherbereiche für Flash und RAM. Für den RP2040 Mikrocontroller sieht das Memory-Layout wie folgt aus:

MEMORY {
    BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
    FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
    RAM   : ORIGIN = 0x20000000, LENGTH = 264K
}

Erläuterung:

Anwendungskonfiguration (app.toml)

Sensible Daten wie WiFi-Passwörter und MQTT-Broker-Adressen sollten nie direkt im Source-Code stehen. Zum einen aus Sicherheitsgründen (Code wird oft in Git-Repositories geteilt), zum anderen aus praktischen Gründen – jeder Entwickler oder jedes Deployment könnte andere Einstellungen benötigen.

Die Lösung ist eine separate app.toml Datei, die zur Build-Zeit eingelesen und in Rust-Konstanten konvertiert wird. Die app.toml verwendet eine flache Struktur:

# WiFi Konfiguration
WIFI_SSID = "Mein-WLAN"
WIFI_PASSWORD = "mein-passwort"

# MQTT Broker Konfiguration
MQTT_BROKER = "192.168.1.100"
MQTT_PORT = "1883"
MQTT_CLIENT_ID = "pico-w-sensor"
MQTT_TOPIC_TEMPERATURE = "sensor/temperature"
MQTT_TOPIC_HUMIDITY = "sensor/humidity"

Wichtig: Die app.toml sollte in .gitignore eingetragen werden, um sie nicht versehentlich zu committen.

Das Build-Script (build.rs)

Das Build-Script verbindet beide Konfigurationsaspekte und wird automatisch vor jedem Build (z.B. argo build) ausgeführt. Das Skript besteht aus drei Teilen. Zuerst wird die memory.x Datei in das Build-Output-Verzeichnis kopiert und dem Linker-Suchpfad hinzugefügt. Anschliessend wird die config. toml Datei eingelesen, Zeile für Zeile verarbeitet und in eine Rust-Datei (config.rs) mit pub const Definitionen konvertiert. Zum Schluss werden noch die notwendigen Linker-Argumente für das Embedded Target gesetzt. Die Übergabe eines Argument an den Linker erfolgt über die cargo:rustc-link-arg-bins= Anweisung.

// Linker-Argumente für embedded target
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
println!("cargo:rustc-link-arg-bins=-Tlink-rp.x");
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
Verwendung im Code

Die generierten Konstanten werden via include!() Makro eingebunden:

mod config {
    include!(concat!(env!("OUT_DIR"), "/config.rs"));
}

// Verwendung:
info!("Connecting to WiFi: {}", config::WIFI_SSID);
control.join(config::WIFI_SSID, JoinOptions::new(config::WIFI_PASSWORD.as_bytes())).await?;

Die Werte sind zur Compile-Zeit bekannt und werden als konstante Strings in die Binary eingebettet – kein Runtime-Overhead, kein Dateisystem nötig.

Projekt Konfiguration

Über die Datei Cargo.toml werden die Paket-Abhängigkeiten und projektspezifische Einstellungen definiert. Die Abhängigkeiten können manuell in der Datei definiert oder via cargo add <Abhängigkeit-Name> automatisch hinzugefügt werden. Ein wichtiger Aspekt sind die Features – sie erlauben in einer Abhängigkeit nur ein bestimmtes Feature-Set zu aktivieren, sodass nicht der gesamte Umfang einer Bibliothek mitkompiliert werden muss.

Grundsätzlich kann die Struktur der Datei beliebig sein. Da in der Dokumentation eine gewisse Regelung vorhanden ist, starten wir mit der Sektion [package]. Unter der Sektion werden allgemeine Informationen zum Projekt definiert.

[package] 
name = "embedded_rust" 
version = "0.1.0"
edition = "2024"
publish = false

Mit der Sektion [profile] können Debug & Release-Profile angelegt werden. Das beduetet das wir den Kompiler für die Builds unterschiedlich konfigurieren können. Für Embedded Systeme sind hier einige Optimierungen notwendig, um die Binary-Grösse zu reduzieren und die Performance zu verbessern.

[profile.dev]
opt-level = 1     # Minimale Optimierung für schnellere Compiles
debug = true      # Debug-Symbole für bessere Fehlermeldungen 

[profile.release]
opt-level = "s"   # Optimierung für Größe
lto = true        # Link-Time Optimization
debug = false     # Keine Debug-Symbole
panic = "abort"   # Direkt abbrechen statt unwinding

Warum diese Einstellungen?

  1. opt-level = «s» (Size-Optimierung):
    • «s» = optimiert für minimale Binary-Größe (size)
    • Alternative: «z» (noch aggressivere Größen-Optimierung) oder «3» (Performance)
    • Wichtig bei nur 2MB Flash-Speicher auf dem RP2040
    • Reduziert die Binary typischerweise um 20-40% gegenüber opt-level = «3»
  2. lto = true (Link-Time Optimization):
    • Compiler optimiert über Crate-Grenzen hinweg
    • Entfernt toten Code und inlined Funktionen global
    • Längere Compile-Zeit, aber 10-30% kleinere Binary
    • Essentiell für Embedded-Projekte mit begrenztem Speicher
  3. debug = false:
    • Keine Debug-Symbole in der Binary
    • Spart erheblich Platz (oft 50%+ Größenreduktion)
    • Debugging erfolgt über RTT-Logs (defmt) statt DWARF-Symbole
    • Bei Problemen temporär auf «true» setzen für bessere Stack-Traces
  4. panic = «abort»:
    • Panics führen direkt zum System-Halt
    • Kein Stack-Unwinding (würde viel Code + Speicher benötigen)
    • Standard für no_std Embedded-Systeme
    • Watchdog kann System nach Panic neu starten

Die Sektion [features] ermöglicht es optionale Features für das Projekt zu definieren. In unserem Fall wird ein Feature «rp2040» definiert, welches die notwendigen Abhängigkeiten für den Raspberry Pi Pico WH inkludiert.

[features]
default = ["rp2040"]
rp2040 = ["dep:embassy-rp", "dep:cyw43", "dep:cyw43-pio", "dep:cortex-m", "dep:cortex-m-rt", "dep:defmt-rtt", "dep:panic-probe"]

# Alternative Plattformen
esp32 = ["dep:embassy-esp32", "dep:defmt-rtt", "dep:panic-probe"]

Abhängigkeiten werden in der Sektion [dependencies] definiert. Hier werden die notwendigen Bibliotheken für das Projekt angegeben. Diese können direkt in die Datei geschrieben oder via cargo add <Abhängigkeit-Name> hinzugefügt werden.

[dependencies]
# Embassy Framework - Async Runtime für Embedded Systeme
embassy-executor = { version = "0.9.0", features = ["defmt"] }
embassy-time = { version = "0.5.0", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-sync = { version = "0.7.2", features = ["defmt"] }

#
# ... weitere Dependencies ...
#

# Architektur-spezifische Executor Features
[target.'cfg(target_arch = "arm")'.dependencies]
embassy-executor = { version = "0.9.0", features = ["arch-cortex-m", "executor-thread", "executor-interrupt", "defmt"] }
[target.'cfg(target_arch = "riscv32")'.dependencies]
embassy-executor = { version = "0.9.0", features = ["arch-riscv32", "executor-thread", "defmt"] }

Sensor-Integration

In diesem Abschnitt wird die Integration des AHT20 Sensors beschrieben. Der AHT20 ist ein digitaler Sensor zur Messung von Temperatur und Luftfeuchtigkeit, der über die I2C-Schnittstelle kommuniziert. Wir werden die Konfiguration der I2C-Peripherie auf dem Raspberry Pi Pico WH vornehmen und ein eigenes Modul zur Kommunikation mit dem Sensor implementieren.

Hardware-Interrupts

Bevor wir mit der I2C-Konfiguration beginnen, müssen wir verstehen, wie Hardware-Interrupts in Embassy funktionieren. Interrupts sind essentiell für effiziente Embedded-Programmierung – sie erlauben es der Hardware, den Prozessor zu “unterbrechen” wenn ein Ereignis eintritt, anstatt ständig den Status zu überprüfen (Polling).

Das bind_interrupts! Makro

Embassy verwendet das bind_interrupts! Makro, um Hardware-Interrupt-Vektoren mit Rust-Handler-Funktionen zu verbinden. Die Syntax ist deklarativ und typ-sicher:

use embassy_rp::{bind_interrupts, i2c};
use embassy_rp::peripherals::PIO0;
use embassy_rp::pio::InterruptHandler as PioInterruptHandler;
use i2c::InterruptHandler as I2cInterruptHandler;

bind_interrupts!(struct Irqs {
    I2C0_IRQ => I2cInterruptHandler<embassy_rp::peripherals::I2C0>;
    PIO0_IRQ_0 => PioInterruptHandler<PIO0>;
});

Was passiert hier?

  1. Struct Definition: struct Irqs wird generiert – ein Zero-Sized Type (ZST), der keine Runtime-Größe hat
  2. Interrupt-Mapping: Jede Zeile verbindet einen Hardware-Interrupt mit einem Handler:
    • I2C0_IRQ: Hardware-Interrupt-Nummer für I2C0 Peripherie
    • I2cInterruptHandler<I2C0>: Embassy’s generischer Handler für I2C0
    • Der Handler wird typisiert mit der konkreten Peripherie (I2C)
  3. Zero-Cost Abstraction: Zur Compile-Zeit wird der richtige Interrupt-Vektor mit der Handler-Funktion verknüpft – kein Runtime-Overhead

Interrupt-Handler im Detail

Die InterruptHandler sind von Embassy bereitgestellte Trait-Implementierungen, die:

Beispiel I2C-Interrupt-Flow:

  1. I2C-Hardware sendet/empfängt Daten
  2. Hardware löst I2C0_IRQ  Interrupt aus
  3. CPU springt zum registrierten Handler
  4. Handler liest I2C-Status-Register
  5. Handler weckt wartenden async Task auf
  6. Task fährt mit .await fort

Interrupt-Bindings in unserem Projekt

Unser Projekt verwendet zwei verschiedene Interrupt-Typen:

  1. I2C0_IRQ (Sensor-Kommunikation):
    • Wird ausgelöst bei I2C-Transaktionen (Start, Stop, Data)
    • Ermöglicht async I2C-Operationen ohne Blocking
    • Wichtig für aht20_sensor.read_temperature_humidity().await
  2. PIO0_IRQ_0 (WiFi-Kommunikation):
    • PIO = Programmable I/O (RP2040-Feature)
    • Wird für SPI-ähnliche Kommunikation mit CYW43 WiFi-Chip verwendet
    • Ermöglicht parallele WiFi-Kommunikation ohne CPU-Blocking

I2C Peripherie Konfiguration

Die Peripherie wird mit der I2C Konfiguration erweitert. Die Pins für die Verbindung können anhand der Pinbelegung des Raspberry Pi Pico soweit möglich frei gewählt werden. Wichtig ist die I2C Gruppe. Die beiden Pins (SDA & SCL) müssen aus der gleichen Gruppe stammen.

Figure 3: Raspberry Pi Pico WH Pinbelegung

Die Implementation der I2C Peripherie ist wie folgt möglich. Über die i2c_config können weitere Parameter wie Frequenz oder Pull-Up Widerstände eingestellt werden.

let p = embassy_rp::init(Default::default());
let sda = p.PIN_16; let scl = p.PIN_17;
let i2c_config = Config::default();
let i2c = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);

Nachdem die Konfiguration für die Schnittstelle abgeschlossen ist, muss die Verbindung initialisiert werden. Das Modul AHT20 von DFRobot wurde anhand der Implementation der zur Verfügung stehenden C-Bibliothek entwickelt (https://github.com/DFRobot/DFRobot_AHT20/tree/master). Über die Konstruktor-Methode wird die konfigurierte I2C Schnittstelle dem Modul übergeben und gepeichert. Die Übergabe ist ein Move-Operator, welcher die Verantwortlichkeit des Objekts an das Modul übergibt. Im Embedded Umfeld ist die Verwendung von dynamischen Objekten meistens nicht möglich oder schlichtweg nicht erwünscht. Zum einen aus Speicher und Performance Gründen, wie auch durch begrenzte Hardwaremöglichkeiten. Im Modul AHT20Senosr wird die Variable als 'static deklariert. Dies bedeutet, dass der Kompiler garantiert, dass die Variable über die gesamte Programmlaufzeit “lebt”. In Rust gibt es verschiedene Lifetime-Parameter, 'static ist der am längsten gültige, immer über die gesamte Laufzeit des Programmes.

Mit diesem kleinen Exkurs ist nun klar wie auf die I2C-Peripherie zugegriffen werden kann. Nach Instanzierung des Sensors wird dieser initialisiert.

let mut aht20_sensor = AHT20Sensor::new(i2c);
match aht20_sensor.init().await

AHT20 Sensor Modul

Das Sensor Modul wird auf einem Struct aufgebaut, welches die folgenden Methoden implementiert:

Für die Funktionalität des Modul werden lediglich zwei Parameter benötigt. Wie bereits erwähnt die I2C-Peripherie-Instanz und ein Flag, ob der Sensor initialisiert wurde.

pub struct AHT20Sensor { i2c: i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>, is_initialized: bool, }

Die folgenden Methoden-Implementationen werden in einem impl Block eingefügt. Dieser macht den Rust Code in einer gewissen Art und Weise “objektorientiert”.ie Methoden können nur verwendet werden, wenn eine Instanz des Sensor Modul erzeugt wurde.

impl AHT20Sensor { // pub fn beispiel1() {} // pub fn beispiel2() {} }

Die Initialiserer-Methode new() wäre grundsätzlich nicht notwendig, da die Initialisierung der Sensor Instanz direkt über den Struct Aufruf gemacht werden könnte. Jedoch bietet die new() Methode den Vorteil Standardwerte direkt setzen zu können, ohne dass sich der Verwender Gedanken darüber machen muss.

pub const fn new(
    i2c: i2c::I2c<'static,
    embassy_rp::peripherals::I2C0,
    embassy_rp::i2c::Async>,
) -> Self {
    Self {
        i2c, is_initialized: false,
    }
}

// Verwendung Methode
let sensor = AHT20Sensor::new(/*I2C-Instanz*/);
// Verwendung Module
let sensor = AHT20Sensor { i2c: i2c, is_initialized: false };

Anschliessend wird der Sensor initialisiert. Die Initialisierung wurde in ein match Statement gepackt, so kann die Fehlerverwaltung sauber durchgeführt werden. Nach der Initialisierung kann der Sensor in einer Endlosschleife zyklisch ausgelesen werden.

let mut aht20_sensor = AHT20Sensor::new(i2c);
match aht20_sensor.init().await {
    Ok(_) => info!("AHT20 initialized successfully"),
    Err(e) => { 
        info!("AHT20 initialization error: {:?}", e);
        return;
    },
} 

loop {
    let (temperature, humidity) = aht20_sensor.read_temperature_humidity().await.unwrap();
    info!("Temperature: {} °C, Humidity: {} %", temperature, humidity);
    Timer::after_secs(1).await;
}

Nun ist es möglich die Temperatur und die Feuchtigkeit des Sensors auszulesen. Um die Daten nutzbar zu machen, sollen diese via MQTT an einen Broker weitergeleitet werden. Dafür muss zuerst noch die Verwendung von Wifi auf dem Pico integriert und anschliessend die Verbindung zum Broker via MQTT aufgebaut werden.

WiFi Integration

Im folgenden Kapitel wird die Integration des WiFi-Moduls des Raspberry Pi Pico WH beschrieben. Der Pico WH hat im Gegensatz zum Standard Pico ein integriertes WiFi-Modul. Dieses basiert auf dem CYW43439 Chip von Infineon.

Der CYW43 WiFi-Chip – Architektur und Konzept

Der CYW43-Chip ist ein eigenständiger WLAN-SoC, der die gesamte WiFi-Funktionalität übernimmt. Anders als bei einfachen WiFi-Modulen, bei denen der Hauptprozessor die gesamte Netzwerk-Logik übernimmt, agiert der CYW43 als Co-Prozessor. Der RP2040 Mikrocontroller kommuniziert mit dem CYW43 über eine spezielle Schnittstelle und gibt ihm Anweisungen (“verbinde dich mit diesem WLAN”, “sende dieses Paket”), während der CYW43 die komplexe WiFi-Verarbeitung selbstständig durchführt.

Firmware-Management

Ein besonderes Merkmal des CYW43 ist, dass er bei jedem Start seine Firmware neu geladen bekommen muss. Dies mag zunächst umständlich erscheinen, bietet aber auch Vorteile: Die Firmware kann aktualisiert werden, ohne den physischen Chip zu ersetzen, und unterschiedliche Anwendungen können verschiedene Firmware-Versionen nutzen.

Zwei Firmware-Dateien sind notwendig:

Diese Datein müssen vor dem Kompilieren heruntergeladen und im Projektverzeichnis cyw43-firmware abgelegt werden:

cd cyw43-firmware
wget https://github.com/embassy-rs/embassy/raw/main/cyw43-firmware/43439A0.bin
wget https://github.com/embassy-rs/embassy/raw/main/cyw43-firmware/43439A0_clm.bin

Mithilfe des Makros mit include_bytes!() werden die Dateien direkt in das Binary eingebettet, sodass zur Laufzeit kein Dateisystem benötigt wird.

Alternativ kann auch eine Firmware für die Funktionalität von Bluetooth geladen werden. Dies ist aber kein Inhalt diese Hands-On.

PIO – Die programmierbare Schnittstelle

Ein technisches Highlight ist die Kommunikation zwischen RP2040 und CYW43. Der RP2040 besitzt sogenannte PIO (Programmable I/O) Blöcke – spezialisierte Mini-Prozessoren, die für schnelle I/O-Operationen optimiert sind. Diese PIO-Blöcke werden genutzt, um eine SPI-ähnliche Schnittstelle zu implementieren, die speziell auf die Anforderungen des CYW43 zugeschnitten ist. Dies ist effizienter als die Verwendung der Standard-SPI-Peripherie.

Embassy Tasks für WiFi

Die WiFi-Integration basiert auf dem Task-Konzept von Embassy. Dabei werden zwei unabhängige Tasks verwendet:

  1. WiFi Task: Kümmert sich um die low-level Kommunikation mit dem CYW43-Chip. Dieser Task läuft kontinuierlich im Hintergrund und verarbeitet Befehle sowie Ereignisse vom WiFi-Chip.
  2. Network Task: Verwaltet den TCP/IP-Stack auf höherer Ebene. Hier werden IP-Adressen verwaltet, TCP-Verbindungen aufgebaut und DHCP-Anfragen durchgeführt.

Beide Tasks laufen parallel und kommunizieren über den Stack, der als zentrale Datenstruktur fungiert.

Netzwerk-Stack und DHCP

Embassy bringt einen vollständigen TCP/IP-Stack mit, der speziell für Embedded-Systeme optimiert ist. Der Stack unterstützt:

Static Cells und Lifetime Management

Eine technische Herausforderung in Embedded Rust ist das Lifetime-Management. Tasks in Embassy müssen über die gesamte Programmlaufzeit existieren ('static Lifetime), aber gleichzeitig sollen keine dynamischen Speicherallokationen (Heap) verwendet werden.

Die Lösung sind StaticCell-Strukturen. Diese ermöglichen es, zur Compile-Zeit Speicher zu reservieren, der dann zur Laufzeit initialisiert wird. Der Compiler garantiert, dass jede StaticCell nur einmal initialisiert wird – ein Fehler würde zu einem Panic führen. Dies ist ein eleganter Weg, statische Garantien zu erhalten, ohne unsafe Code zu schreiben.

Verbindungsaufbau und Fehlerbehandlung

Der Verbindungsaufbau zum WiFi-Netzwerk erfolgt asynchron und ist in eine Retry-Schleife eingebettet. Die Implementation in der main() Funktion sieht wie folgt aus:

// WiFi-Verbindung mit Retry-Logik
const MAX_WIFI_RETRIES: u32 = 10;
const WIFI_RETRY_DELAY_SECS: u64 = 2;

info!("Joining WiFi network: {}", config::WIFI_SSID);
let mut retries = 0;
while let Err(err) = control
    .join(
        config::WIFI_SSID,
        JoinOptions::new(config::WIFI_PASSWORD.as_bytes()),
    )
    .await
{
    retries += 1;
    if retries >= MAX_WIFI_RETRIES {
        error!("Max WiFi retries ({}) reached, giving up", MAX_WIFI_RETRIES);
        break;
    }
    info!("WiFi join failed with status={} (attempt {}/{}), retrying in {}s...",
        err.status, retries, MAX_WIFI_RETRIES, WIFI_RETRY_DELAY_SECS);
    Timer::after_secs(WIFI_RETRY_DELAY_SECS).await;
}

Wenn die Verbindung fehlschlägt (z.B. wegen falschen Credentials oder schwachem Signal), wartet das System 2 Sekunden und versucht es bis zu 10 Mal erneut. Dies macht das System robust gegenüber temporären Problemen.

Nach erfolgreicher WiFi-Verbindung warten wir auf Link-Up und DHCP-Konfiguration:

// Warten auf Link-Up
info!("Waiting for link up...");
stack.wait_link_up().await;

// Warten auf DHCP-Konfiguration
info!("Waiting for DHCP...");
stack.wait_config_up().await;
info!("✓ WiFi connected and ready");

// IP-Konfiguration loggen
if let Some(config) = stack.config_v4() {
    info!("IP Address: {:?}", config.address);
    if let Some(gateway) = config.gateway {
        info!("Gateway: {:?}", gateway);
    }
}

MQTT Client Integration

Mit der funktionierenden WiFi-Verbindung können wir nun einen MQTT-Client implementieren, um die Sensordaten an einen MQTT-Broker zu senden. MQTT (Message Queuing Telemetry Transport) ist ein leichtgewichtiges Publish/Subscribe-Protokoll, das sich ideal für ressourcenbeschränkte IoT-Anwendungen eignet.

MQTT-Client in no_std Umgebungen

Die Verwendung von MQTT in einer «no_std»-Umgebung stellt besondere Anforderungen. Wir verwenden die «rust-mqtt»-Bibliothek, die speziell für Embedded-Systeme entwickelt wurde und ohne Heap-Allokation auskommt. Die Bibliothek unterstützt MQTTv5 und arbeitet mit statisch allokierten Buffern.

Im Code werden die benötigten Komponenten importiert:

use rust_mqtt::{
    client::{client::MqttClient, client_config::ClientConfig},
    packet::v5::publish_packet::QualityOfService,
    utils::rng_generator::CountingRng,
};

Client-Konfiguration

Die Konfiguration erfolgt über ClientConfig. Wichtig ist die Verwendung von statisch allokierten Buffern:

// Statische Buffer für MQTT-Kommunikation
let mut recv_buffer = [0u8; 512];
let mut write_buffer = [0u8; 512];

// Client-Konfiguration erstellen
let mut client_config = ClientConfig::new(
    rust_mqtt::client::client_config::MqttVersion::MQTTv5,
    CountingRng(20000), // Seed für Packet-ID Generator
);
client_config.add_max_subscribe_qos(QualityOfService::QoS0);
client_config.add_client_id("pico-w-sensor");
client_config.max_packet_size = 512;

Wichtige Konfigurationsoptionen:

MqttClient erstellen

Der MqttClient ist generisch und arbeitet mit jedem Socket, der embedded_io_async::Read und Write implementiert:

let mut client = MqttClient::<_, 5, _>::new(
    socket, // TCP-Socket (implementiert Read + Write)
    &mut write_buffer,
    512, // Write buffer size
    &mut recv_buffer,
    512, // Receive buffer size
    client_config,
);

Der generische Parameter «5» gibt die maximale Anzahl gleichzeitiger Subscribe-Topics an. Da wir nur publishen, ist dieser Wert nicht kritisch.

Verbindung zum Broker

Nach dem Erstellen des Clients wird die Verbindung zum Broker hergestellt:

client.connect_to_broker().await.map_err(|_| MqttError::ConnectionFailed)?;

Diese Methode führt den MQTT CONNECT Handshake durch. Bei Erfolg ist der Client bereit zum Publishen.

Nachrichten publishen

Das Publishen erfolgt über die send_message Methode:

client.send_message(
    topic,                     // z.B. "sensor/temperature"
    payload.as_bytes(),        // Payload als Byte-Slice
    QualityOfService::QoS0,    // Fire-and-forget
    false,                     // Retain-Flag
).await.map_err(|_| MqttError::PublishFailed)?;

Parameter:

Float-Formatierung ohne Standardbibliothek

Eine Herausforderung in «no_std» ist die Formatierung von Fließkommazahlen. Die Standardbibliothek-Funktionen wie format! oder ToString sind nicht verfügbar. Die Lösung ist eine eigene Formatierungsfunktion:

fn format_float<'a>(buf: &'a mut [u8], value: f32) -> Result<&'a str, ()> {
    use core::fmt::Write;

    // Wrapper-Struct um in einen Byte-Buffer zu schreiben
    struct Wrapper<'a> {
        buf: &'a mut [u8],
        offset: usize,
    }

    impl<'a> Write for Wrapper<'a> {
        fn write_str(&mut self, s: &str) -> core::fmt::Result {
            let bytes = s.as_bytes();
            let end = self.offset + bytes.len();
            if end > self.buf.len() {
                return Err(core::fmt::Error);
            }
            self.buf[self.offset..end].copy_from_slice(bytes);
            self.offset = end; Ok(())
        }
    }
    
    let mut w = Wrapper { buf, offset: 0 };
    core::fmt::write(&mut w, format_args!("{:.2}", value)).map_err(|_| ())?;
    core::str::from_utf8(&w.buf[..w.offset]).map_err(|_| ())
}

Diese Funktion nutzt core::fmt::Write (verfügbar in «no_std») um Fließkommazahlen mit zwei Dezimalstellen in einen vorallokierten Buffer zu formatieren. Der Buffer wird vom Aufrufer bereitgestellt – keine Heap-Allokation nötig.

Task-basierte Architektur mit Channel-Kommunikation

Um das Lesen von Senden von Sensordaten zu trennen wurden zwei seperate Embassy-Task erstellt. Diese kommunizieren über eine globale Channel Instanz. Dies bietet den Vorteil, wenn der MQTT-Broker nicht mehr erreichbar wäre, können die Sensordaten weiter ausgelesen werden. So könnten die Daten Programm intern weiter verwendet werden.

Der Sensor Channel

Im globalen Scope wird ein statischer Channel definiert:

use embassy_sync::channel::Channel;
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;

// Channel für Sensor-Daten: (temperature, humidity)
static SENSOR_CHANNEL: Channel<ThreadModeRawMutex, (f32, f32), 4> = Channel::new();

Dieser Channel hat eine Kapazität von 4 Messwerten und verwendet ThreadModeRawMutex für Thread-Safety. Werden die Werte nicht verarbeitet, werden diese im FIFO-Prinzip verworfen.

Sensor Task – Der Producer

Der Sensor-Task liest kontinuierlich Daten und sendet sie in den Channel:

#[embassy_executor::task]
pub async fn sensor_task(mut sensor: AHT20Sensor) {
    // Sensor initialisieren mit Retry-Logik
    loop {
        match sensor.init().await {
            Ok(_) => {
                info!("✓ Sensor initialized");
                break;
            }
            Err(e) => {
                error!("Sensor init failed: {:?}, retrying...", e);
                Timer::after_secs(5).await;
            }
        }
    }

    // Hauptloop: Kontinuierlich Daten auslesen
    loop {
        match sensor.read_temperature_humidity().await {
            Ok((temperature, humidity)) => {
                info!("Sensor read: {}°C, {}%", temperature, humidity); 
                // Non-blocking send - droppt wenn Channel voll
                match SENSOR_CHANNEL.try_send((temperature, humidity)) {
                    Ok(_) => debug!("Sensor data sent to channel"),
                    Err(_) => warn!("Channel full, dropping sensor reading"),
                }
            }
            Err(e) => error!("Sensor read error: {:?}", e),
        }
        Timer::after_secs(5).await; // Alle 5 Sekunden messen
    }
}

Wichtig: Der Sensor-Task verwendet try_send() (non-blocking), damit er nicht blockiert wenn der Channel voll ist. Sensor-Messwerte sind nicht kritisch – wenn der MQTT-Task nicht hinterherkommt, können Werte verworfen werden.

MQTT Task – Der Consumer

Der MQTT-Task empfängt Daten vom Channel und versendet diese:

#[embassy_executor::task]
pub async fn mqtt_publish_task(
    stack: &'static Stack<'static>,
    mqtt_config: MqttConfig,
) {
    loop {
        // Äußere Schleife: Verbindungsaufbau
        // ... TCP Socket erstellen, zu Broker verbinden ...
    
        match handle_mqtt_session(&mut socket, &mqtt_config).await {
            Ok(_) => info!("MQTT session ended gracefully"),
            Err(e) => error!("MQTT session error: {:?}", e),
        }

        Timer::after_secs(3).await; // Reconnect-Delay
    }
}

async fn handle_mqtt_session(
    socket: &mut TcpSocket<'_>,
    mqtt_config: &MqttConfig,
) -> Result<(), MqttError> {
    // MQTT Client erstellen und verbinden
    let mut client = MqttClient::<_, 5, _>::new(...);
    client.connect_to_broker().await?; 

    // Innere Schleife: Daten vom Channel empfangen und publishen
    loop { 
        info!("Waiting for sensor data from channel..."); 
        let (temperature, humidity) = SENSOR_CHANNEL.receive().await; // Blocking!
        info!("Received from channel: {}°C, {}%", temperature, humidity);

        // Publish mit Retry-Logik
        publish_with_retry(&mut client, mqtt_config.topic_temperature, temperature).await?;
        publish_with_retry(&mut client, mqtt_config.topic_humidity, humidity).await?;
    }
}

Wichtig: Der MQTT-Task verwendet receive() (blocking), d.h. er wartet bis Daten verfügbar sind. Dies ist effizient und verbraucht keine CPU-Zeit.

MQTT-Broker Setup

Um die Sensordaten zu empfangen und diese an einen Subscriber zu senden, wird ein MQTT-Broker benötigt. Für Testzwecke kann ein lokaler Broker wie Mosquitto verwendet werden.

# === Linux (Debian/Ubuntu)===================================
sudo apt-get install mosquitto mosquitto-clients

# Startet automatisch als Service, manuell:
sudo systemctl start mosquitto

# === macOS ==================================================
brew install mosquitto

# Starten
brew services start mosquitto # Als Hintergrund-Service

# === Windows ================================================
winget install EclipseFoundation.Mosquitto

# Starten (als Administrator in PowerShell):
net start mosquitto

# === Docker (alle Plattformen - einfachste Methode) =========
docker run -d --name mosquitto -p 1883:1883 eclipse-mosquitto

Das Zusammenspiel aller Komponenten

Das Hauptprogramm orchestriert alle besprochenen Komponenten und zeigt das asynchrone Task-Modell von Embassy in Aktion:

Initialisierungssequenz

Die main() Funktion durchläuft folgende Schritte in dieser Reihenfolge:

  1. Hardware-Initialisierung: Alle Peripherie-Einheiten werden initialisiert
let p = embassy_rp::init(Default::default());
let mut watchdog = embassy_rp::watchdog::Watchdog::new(p.WATCHDOG);
  1. I2C und Sensor Setup: Der I2C-Bus wird konfiguriert und der Sensor instanziiert
let sda = p.PIN_16;
let scl = p.PIN_17;
let i2c = I2c::new_async(p.I2C0, scl, sda, Irqs, Config::default());
let aht20_sensor = AHT20Sensor::new(i2c);
  1. WiFi-Setup (alles inline in main):
    • Firmware laden (include_bytes!)
    • PIO für SPI-Kommunikation konfigurieren
    • CYW43 Driver initialisieren
    • Background-Tasks spawnen (cyw43_task, net_task)
    • Network-Stack mit DHCP konfigurieren
    • WiFi-Join mit Retry-Logik
    • Auf Link-Up und DHCP warten
  2. Task-Spawning:
// Sensor-Task spawnen (erhält Ownership über den Sensor)
spawner.spawn(aht20::sensor_task(aht20_sensor))?;
// MQTT-Task spawnen (erhält &'static Stack Referenz)
spawner.spawn(mqtt::mqtt_publish_task(stack, mqtt_config))?;
  1. Main-Loop: Watchdog-Feeding und LED-Blink

Task-Architektur im Überblick

Nach dem Abschluss der Initialisierung läuft das System mit mehreren parallel arbeitenden Tasks:

Diese Tasks werden vom Embassy-Executor koordiniert. Wenn ein Task auf ein Event wartet (z.B. Netzwerkdaten oder Timer), gibt er die Kontrolle ab und ein anderer Task kann laufen. Dies alles geschieht ohne Betriebssystem – Embassy implementiert ein kooperatives Multitasking auf Basis von Rust’s  asyn/await.

Main Loop: Watchdog und Status-LED

Nach dem Spawnen aller Tasks bleibt die main() Funktion nicht idle, sondern übernimmt zwei wichtige Aufgaben:

// Watchdog initialisieren und starten
let mut watchdog = embassy_rp::watchdog::Watchdog::new(p.WATCHDOG);
watchdog.start(Duration::from_secs(3));

info!("=== All tasks spawned successfully ===");
info!("System running...");

loop {
    watchdog.feed(); // Watchdog füttern
    Timer::after_millis(500).await;
    control.gpio_set(0, false).await; // LED aus
    Timer::after_millis(500).await;
    control.gpio_set(0, true).await; // LED an
}

Diese Loop zeigt auch ein wichtiges Konzept: Der Main-Task muss regelmäßig die Kontrolle abgeben (via .await), damit andere Tasks laufen können.

Build und Flash

Nachdem alle Komponenten implementiert sind, kann das Projekt gebaut und auf den Raspberry Pi Pico WH geflasht werden.

Build

Release-Build mit Debug-Symbolen (empfohlen):

cargo build --release

Die Binary befindet sich dann in .

Flash via probe-rs

Falls ein Debug-Probe (z.B. Raspberry Pi Pico als Debug-Probe) vorhanden ist:

# Direkt flashen und Logs anzeigen
cargo run --release

# Oder manuell mit probe-rs
probe-rs run --chip RP2040 --protocol swd target/thumbv6m-none-eabi/release/embedded_rust

Die Logs werden über RTT (Real-Time Transfer) ausgegeben und von probe-rs angezeigt.

Flash via USB (Bootloader-Modus)

Ohne Debug-Probe kann der Pico WH im Bootloader-Modus geflasht werden:

  1. BOOTSEL-Taste am Pico WH gedrückt halten
  2. USB-Kabel einstecken
  3. Das Board erscheint als USB-Laufwerk “RPI-RP2”
  4. Flashen:
elf2uf2-rs target/thumbv6m-none-eabi/release/embedded_rust /Volumes/RPI-RP2/ # Oder unter Linux: /media/$USER/RPI-RP2/

Debugging und Monitoring

MQTT-Nachrichten überwachen

Mit Mosquitto können die publizierten Sensordaten überwacht werden:

# Alle sensor/* Topics abonnieren
mosquitto_sub -h localhost -t 'sensor/#' -v

# Nur Temperatur
mosquitto_sub -h localhost -t 'sensor/temperature' -v

# Nur Feuchtigkeit
mosquitto_sub -h localhost -t 'sensor/humidity' -v

Debug-Logs (nur mit probe-rs)

Wenn mit probe-rs geflasht wurde, werden automatisch die defmt-Logs angezeigt:

Troubleshooting

WiFi verbindet nicht

MQTT-Verbindung schlägt fehl

Sensor-Fehler

LED blinkt nicht

Fazit und Ausblick

Mit der Integration von WiFi und MQTT haben wir ein vollständiges IoT-Gerät mit folgenden Fähigkeiten geschaffen:

Die Verwendung von Embassy als asynchrones Framework ermöglicht es, mehrere Tasks parallel zu betreiben, ohne dass komplexe Thread-Verwaltung notwendig ist. Die asyc/await Syntax macht den Code lesbar und wartbar. Die Channel-basierte Architektur zeigt wie Producer-Consumer-Patterns elegant in Embedded Rust umgesetzt werden können.

Rust bietet durch sein Typ-System und die Ownership-Regeln eine hervorragende Basis für sichere Embedded-Entwicklung. Die Kombination mit modernen Frameworks wie Embassy macht die Entwicklung komplexer IoT-Anwendungen effizient und zuverlässig.

 

Kommentare

Schreiben Sie einen Kommentar

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Newsletter - aktuelle Angebote, exklusive Tipps und spannende Neuigkeiten

 Jetzt anmelden

Copyright © 2025 Noser Engineering AG – Alle Rechte vorbehalten.

NACH OBEN
Privacy Policy Cookie Policy
Zur Webcast Übersicht