Embedded-Rust

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Diese Seite ist aktuell noch im Aufbau

Warum Rust?

C/C++ ist im Embedded-Bereich noch mehr als sonst der Quasi-Standard für Software aller Art. Versuche, modernere Programmiersprachen auf Mikrocontrollern zum laufen zu bringen sind bisher nur im Arduino-Umfeld bekannt geworden (siehe MicroPython) und mit erheblichen Performanceeinbußen verbunden (je nach Quelle 10-100x). Rust hingegen ist mit einer Performance die je nach Programmierstil zwischen C++ und besser als C (Ja, wirklich!) liegt deutlich konkurrenzfähig. Gleichzeitig bringt Rust eine moderne Syntax, ein hohes Abstraktionsniveau und sehr gute Tool-Integration mit.

Vorteile

  • Bei Rust immer zitiert: Speichersicherheit. In Rust stellt der sogenannte Borrow-Checker als Teil des Typsystems sicher, dass jedes Stück Daten genau einen Besitzer (Owner) und entweder mehrere lesbare Referenzen (borrow) oder eine einzige schreibbare Referenz (mutable borrow) hat. Außerdem stellt er sicher, dass nur valide Referenzen (in C Pointer) verwendet werden können. Sobald eine Variable ihren Gültigkeitsbereich verlässt wird (auch dynamisch allokierter) Speicher automatisch freigegeben. Letztendlich besitzt jedes (safe) Rust-Programm ohne Compilerfehler nachweißlich keine Speicherfehler.
  • Borrow-Checker für Peripherals: Eine der genialsten Funktionen im Embedded-Bereich. Die Garantien für Speichersicherheit werden von den HALs auch für ganze Peripherals erzwungen. Auf diese Art ist es unmöglich einen Pin aus Versehen doppelt oder ohne vorherige Konfiguration zu benutzen. Was bei IOs noch einigermaßen von Hand zu lösen wäre, ist bei DMA-Channels eine echte Erleichterung.
  • Durch das genauere Typsystem können vom Compiler einige Optimierungen durchgeführt werden, die in C nicht möglich wären (v.a. in Zusammenhang mit readonly Pointern)
  • Fokussierung auf den Stack: In Rust läuft fast alles über den (schnellen und deterministischen) Stack. Sehr viele Embedded-Rust Programme (auch mit Netzwerk/BT/USB) haben überhaupt keinen Heap konfiguriert. Es spricht allerdings auch nichts dagegen einen zu verwenden
  • Asynchrone Programmierung: Natürlich kann man auch in Rust blockierende Funktionen programmieren, aber async/await ermöglicht mit wenig Aufwand extrem effiziente Nebenläufigkeit ohne ein RTOS.
  • Debugging: Neben dem Debugging mittels Breakpoints führt häufig auch ein einfaches println zu schnellem Erfolg. Allerdings sind Stringoperationen nicht gerade effizient. Für Rust-Nutzer gibt es da defmt. Dabei werden nur eine ID für jedes Log-Statement und die Formatierungsargumente in einen Ringpuffer geschrieben und vom Debugger ausgelesen. Die eigentliche - beliebig aufwendige - Formatierung erfolgt auf dem Hostrechner

Hardwareunterstützung

  • STM32: sehr gut, nicht nur für einzelne Chips
  • RP2040/RP235x: sehr gut, aufgrund der großen Popularität des Raspberry Pico
  • Nordic Semiconductor nrf: sehr gut, der Artikelautor hat aber keinerlei Erfahrungen mit nrf
  • AVR: vom Compiler unterstützt, das HAL-Projekt ist aber etwas (sehr) eingeschlafen
  • ESP32: sehr gut, wird von Espressif selbst unterstützt, wenn auch etwas gespalten zwischen pure-Rust(no_std)/ESP-IDF(std) und Xtensa/Risc-V

Was ist Embassy?

Embassy ist ein Framework für Embeddeded Rust das zahlreiche elementare Funktionen breitstellt:

  • HAL
  • Timer
  • Einen task Scheduler/Async Runtime
  • Synchronisierung: Mutex, etc.

Außer AVR unterstützt Embassy alle der oben genannten Plattformen.

Embassy ist allerdings nicht die einzige Möglichkeit, neben der asynchronen Programmierung mit Embassy kann man natürlich wie in C auch klassischen blockierenden/interruptgetriebenen Code - mit oder ohne RTOS - schreiben. Dafür kann man sowohl den Embassy HAL also auch andere HALs verwenden

Unterschiede zu C / Rust-Crashkurs

Wer wirklich Rust lernen will, sei auf die zahlreichen Ressourcen und vor allem "The Book" verwiesen.

Im folgenden sollen kurz und knapp die wichtigsten und überraschensten Unterschiede zu C dargestellt werden, nachdem die meisten Leser hier vermutlich einen gewissen "C-Hintergrund" haben.

Integertypen

Rust verwendet immer explizite Integergrößen wie u32 for einen unsigened 32-bit integer

Typen

Im Gegensatz zu C/C++ kommt in Rust der Typ immer nach dem Identifier.

Aus

byte foo(int x){
    int y = x * 2;
    return 5;
}

wird

fn foo(x: i32) -> u8{
    //  ähnlich wie bei auto in C++ wird hier der Typ automatisch erkannt
    // nur in Rust funktioniert das wesentlich besser
    let y = x * 2;

    // der letzte Ausdruck in einem Codeblock ist auch automatisch der Rückgabewert
    // Alternativ ist natürlich auch ein return 1:1 wie in C möglich
    5
}

Generics, Closures (lambda functions), etc.

Fast alle erweiterten Sprachkonstrukte können und werden im Embeddedbereich verwendet. Rust verfolgt generell ein Konzept von "Zero-cost abstractions", also Abstraktion ohne Performanceeinbußen. Außer dynamischem Speicher (und selbst der, wenn man die Nachteile in Kauf nimmt) kann daher fast alles was Rust zu bieten hat auch verwendet werden.

Enums

Enums sind vermutlich der gewaltigste Unterschied. In C sind enums eher eine Ansammlung von automatisch durchnummerierten Konstanten, während in Rust enums wesentlich mächtiger sind.

Natürlich funktioniert der Standardfall in Rust genauso:

enum Foo {
    A,
    B,
}

let foo = Foo::A;

Der Große Unterschied ist dass einzelne Varianten Daten haben können:

enum Foo {
    A(u8, bool),
    B{
        bar: u32,
    },
}

let foo = Foo::A(6, true);
let bar = Foo::B{bar: 7};

Im Prinzip verhält sich dieses Konstrukt wie ein C enum zusammen mit einer union über die Datenanhänge. Diese Struktur eignet sich hervorragend zur Implementierung von Zustandsautomaten.

In der Standardbibliothek findet sich übrigens auch dieses extrem häufig verwendete enum: Option

pub enum Option<T> {
    /// No value.
    None,
    /// Some value of type `T`.
    Some(T),
}

Option<T> ist die Rust-entsprechung von null(-pointern). Anstatt den Fehler irgendwie in den Rückgabewert zu quetschen, wird der Rückgabewert in ein Option verpackt: z.B. Option<u32>. Dadurch ist klar erkennbar wenn in einer Funktion ein Fehler auftreten kann und ohne diesen Fall zu beachten kompiliert das Programm nicht. Daneben gibt es noch Result<R,E> mit einem expliziten Fehler E. Mehr dazu findet ihr in jeder Rust-einführung.

Falls ihr euch Sorgen um den Speicherverbrauch macht, sobald T einen verbotenen Wert hat (z.B. 0 für eine Referenz (alle Referenzen in Rust sind garantiert gültig und daher nicht 0) oder 5 für einen bool), führt der Compiler automatisch eine sog. Nieschenoptimierung durch und verwendet genau diese Werte für None. Effektiv entsteht dann auch wieder ein NULLpointer, nur dass dieser auf jeden Fall explizit gehandhabt wird.

Asynchronität

Installation

Zunächst einmal benötigt man natürlich den Rust-compiler selbst. Das empfohlene Weg dafür ist rustup. Mehr dazu findet ihr auf der Rust webseite

Ein extra Buildsystem (z.B. Make) wie bei C benötigt ihr nicht, Rust bringt selber cargo mit, das sich auch noch um Packetdownloads kümmert.

Für die Desktop-entwicklung hättet ihr nun alles beisammen, für die Emebdded-entiwcklung braucht ihr neben der Hardware (uC & Debugger) nur noch probe-rs, das ist so ähnlich wie AVRDude nur für Rust und 32-bit ARM. Folgt dazu einfach der Anleitung von Embassy https://embassy.dev/book/ , da gibt es auch gleich ein paar Beispielprogramme.

Beispiel für ein einfaches Programm

Weiterführende Links