Rust ohne Betriebssystem

Rust auf dem Mikrocontroller: no_std, HAL, Werkzeugkette

Ohne Standardbibliothek fallen Vec, String und Threads weg, dafür bleibt der Rest der Sprache vollständig erhalten. Was ihr neu lernt, ist die Schichtung darunter.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Die Sprache ist selten das Problem, die Werkzeugkette schon

Wer aus C kommt, hat für ein neues Ziel einen eingeübten Ablauf: Herstellerpaket installieren, Beispielprojekt kopieren, Startcode und Linkerskript übernehmen, in der Entwicklungsumgebung des Herstellers bauen und flashen. In Rust gibt es dieses eine Paket nicht. Stattdessen setzt du das Ziel in der Cargo-Konfiguration, wählst eine Crate für den Registerzugriff, eine für die Hardware-Abstraktion, einen Panic-Handler und ein Werkzeug zum Flashen, und jede dieser Entscheidungen ist im ersten Projekt eine kleine Recherche.

Dazu kommt eine Klasse von Fehlermeldungen, die es in C nicht gibt und die auf den ersten Blick nach einem Sprachproblem aussieht. can't find crate for std beim ersten Build, region FLASH overflowed beim ersten Debug-Build, ein HardFault direkt nach dem Start, eine defmt-Ausgabe, die leer bleibt. Alle vier haben eine banale Ursache in der Konfiguration, und wer sie kennt, verliert damit Minuten statt Tage.

Falsch teuer wird es dort, wo die Entscheidung zwischen den Nebenläufigkeitsmodellen zu spät fällt. Ein Projekt, das mit blockierenden Warteschleifen und einer Handvoll Interrupts beginnt und später vier Funkprotokolle gleichzeitig bedienen soll, wird nicht nachträglich zu einer Embassy-Anwendung. Umgekehrt zwingt euch ein asynchroner Aufbau in Ecken, in denen ihr harte Fristen auf Interrupt-Ebene braucht. Diese Weiche steht am Anfang, nicht am Ende.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Aufbau im Detail

Der Aufbau einer no_std-Anwendung

Diese Zeile steht so nirgends im Code, sie beschreibt die Kette vom ersten Attribut bis zur Einsprungfunktion. Jeder Teil davon ist eine eigene Entscheidung.

Der Aufbau

#![no_std] #![no_main] -> PAC -> HAL -> BSP -> #[entry] fn main() -> !
  1. 01 Ohne Standardbibliothek #![no_std]

    Statt std steht nur core zur Verfügung, also Typen, Traits, Zahlen, Slices, Option und Result. Weg fallen alle Dinge, die einen Allokator oder ein Betriebssystem brauchen: Vec, String, HashMap, Box, Dateien, Sockets und Threads. Wer einen Heap will, bindet alloc ein und setzt einen #[global_allocator]. In vielen Projekten ist der bessere Weg heapless, dessen Vec und String mit fester Kapazität auf dem Stapel oder im statischen Speicher liegen.

  2. 02 Kein Einsprung des Betriebssystems #![no_main]

    Es gibt kein Betriebssystem, das main aufruft. Den Einsprung legt der Startcode fest, den cortex-m-rt beisteuert: Er füllt die Vektortabelle, kopiert die initialisierten Daten aus dem Flash ins RAM, nullt den Rest und springt dann erst in deine Funktion. Dazu gehört zwingend ein Panic-Handler, in der Entwicklung meist panic-probe, im Feld eine eigene Behandlung, die den Fehler ablegt und einen Neustart auslöst.

  3. 03 Registerzugriff aus dem Datenblatt PAC

    Die Peripheral Access Crate wird mit svd2rust aus der SVD-Datei des Herstellers erzeugt und bildet jedes Register und jedes Bitfeld als eigenen Typ ab. Ein Feld mit drei zulässigen Werten wird zu einem Aufzählungstyp, ein Nur-Lese-Register lässt sich nicht beschreiben. Der Zugriff auf die Peripherie läuft über ein Singleton, das genau einmal übernommen werden kann, damit nicht zwei Programmteile denselben Timer konfigurieren.

  4. 04 Die eigentliche Schnittstelle für Anwendungscode HAL

    Die Hardware-Abstraktion baut aus den Registern brauchbare Typen: ein Pin, der als Ausgang konfiguriert ist, ein SPI-Bus, ein Zeitgeber. Sie implementiert die Traits aus embedded-hal, das seit Januar 2024 in Version 1.0.0 vorliegt und damit stabil ist. Der Gewinn zeigt sich beim Treiber: Wer einen Sensortreiber gegen die Traits schreibt statt gegen einen konkreten Chip, kann ihn ohne Änderung auf STM32, RP2040 und nRF52 einsetzen. Für asynchrone Treiber gibt es dieselben Traits als embedded-hal-async.

  5. 05 Die Namen von der Platine BSP

    Die Board Support Crate ist eine dünne Schicht über der Hardware-Abstraktion und gibt den Anschlüssen die Bezeichnungen, die im Schaltplan stehen, also led statt PC13. Für Entwicklungsboards gibt es sie fertig, für eigene Platinen schreibt man sie selbst, und genau das ist der richtige Ort für die Zuordnung von Pins. Steht die Verdrahtung im Anwendungscode, kostet jede Platinenrevision eine Suche durch das ganze Projekt.

  6. 06 Die Funktion, die nie zurückkehrt #[entry] fn main() -> !

    Der Rückgabetyp ! ist keine Formalität: Es gibt niemanden, zu dem main zurückkehren könnte. Die Funktion endet deshalb in einer Endlosschleife, in einem Schlafzustand oder wird durch RTIC beziehungsweise Embassy ersetzt, die den Einsprung selbst mitbringen. Wer stattdessen mit einem Wert zurückkehrt, bekommt keinen Laufzeitfehler, sondern eine Fehlermeldung des Compilers.

Wenn es nicht funktioniert

Das siehst du

Der erste Build bricht mit can't find crate for std ab, obwohl die Crate mit #![no_std] beginnt.

Warum

cargo baut für den Rechner, auf dem du sitzt, und für den gibt es std. Das Ziel ist nirgends gesetzt.

Was hilft

rustup target add mit dem passenden Tripel, etwa thumbv7em-none-eabihf für einen Cortex-M4F, und dasselbe Tripel in .cargo/config.toml unter [build] target eintragen. Danach genügt cargo build ohne weitere Schalter.

Das siehst du

Der Linker meldet region FLASH overflowed by mehreren Kilobyte.

Warum

Entweder ein unoptimierter Debug-Build, oder memory.x beschreibt einen anderen Chip als den verbauten.

Was hilft

Im Release-Profil opt-level auf "z", lto einschalten, codegen-units auf 1 und panic auf "abort" setzen. Bleibt es zu groß, memory.x gegen das Datenblatt prüfen und mit cargo size sowie cargo bloat nachsehen, welcher Abschnitt tatsächlich wächst.

Das siehst du

Das Programm startet und landet nach wenigen Millisekunden im HardFault-Handler.

Warum

In den meisten Fällen ein Stapelüberlauf. Bei der üblichen Speicheranordnung wächst der Stapel in die statischen Daten hinein und überschreibt sie still.

Was hilft

flip-link als Linker eintragen. Er dreht die Anordnung um, sodass ein Überlauf sofort einen Fault auslöst statt Variablen zu verfälschen. Danach den Fehler an der Stelle suchen, an der er entsteht, meist ein großes Feld auf dem Stapel oder eine tiefe Rekursion.

Das siehst du

Die Firmware läuft, aber im Terminal erscheint keine einzige Logzeile.

Warum

Der Transport ist nicht eingebunden oder der Runner ruft nicht das Werkzeug auf, das die Ausgabe abholt.

Was hilft

In der main.rs use defmt_rtt as _; ergänzen, damit der Transport überhaupt mitgelinkt wird, in .cargo/config.toml runner auf probe-rs run mit dem passenden Chipnamen setzen und den Pegel über die Umgebungsvariable DEFMT_LOG festlegen.

Das siehst du

Ein Wert, den der Interrupt setzt, ändert sich in der Hauptschleife nie.

Warum

Der Zustand wird ohne Absicherung geteilt, und der Compiler darf ihn dann in einem Register halten und die Speicherstelle gar nicht mehr lesen.

Was hilft

Geteilten Zustand über die critical-section-Crate führen, üblich als Mutex<RefCell<Option<T>>> im statischen Speicher, oder gleich auf die Ressourcen von RTIC beziehungsweise die Kanäle von Embassy umstellen, die genau dieses Muster kapseln.

Sechs Schichten zwischen Registerwort und Anwendung

  1. 01 Der Startcode aus cortex-m-rt richtet Stapel, Daten und Nullsegment ein.
  2. 02 Die Registercrate entsteht aus der Chipbeschreibung des Herstellers.
  3. 03 Die Hardware-Abstraktion macht daraus Pins, Zeitgeber und Busse.
  4. 04 Die Board-Crate gibt den Pins die Namen, die auf der Platine stehen.
  5. 05 Treiber sprechen nur embedded-hal und laufen dadurch auf mehreren Chips.
  6. 06 RTIC oder Embassy verteilen darüber die Arbeit auf Tasks.
Was du mitnimmst

Danach bringst du ein neues Ziel selbst zum Laufen

Der Einstieg besteht aus einer überschaubaren Menge Wissen, das sich über alle Chipfamilien hinweg wiederholt. Wenn die Schichtung sitzt, ist ein Wechsel von STM32 auf RP2350 oder nRF52 eine Frage von zwei Crates und einer Datei, nicht von einem neuen Projekt.

Ein Ziel von Hand einrichten

Du setzt Ziel-Tripel, Linkerskript und Runner in der Cargo-Konfiguration selbst und bist nicht darauf angewiesen, dass jemand ein Vorlagenprojekt für genau deinen Chip gepflegt hat.

Die Schichten auseinanderhalten

Du weißt, was in die aus dem Datenblatt erzeugte Registercrate gehört, was die Hardware-Abstraktion darüber leistet und warum ein Treiber gegen embedded-hal auf zwei völlig verschiedenen Chips läuft.

Geteilten Zustand sauber führen

Du teilst Daten zwischen Interrupt und Hauptschleife über einen kritischen Abschnitt statt über eine veränderliche statische Variable, die seit der Edition 2024 ohnehin nicht mehr durchgeht.

Zwischen RTIC und Embassy entscheiden

Du kannst begründen, wann ein prioritätsbasierter Aufbau mit Interrupt-Tasks das Richtige ist und wann ein asynchroner Executor die vielen wartenden Vorgänge besser einsammelt.

Ohne Öffnen des Debuggers messen

Du bekommst mit cargo run eine geflashte Firmware samt Logausgabe und siehst über cargo size, wie sich Flash und RAM auf die Abschnitte verteilen, bevor der Speicher knapp wird.

Die Zulassungsfrage beantworten

Du weißt, was eine qualifizierte Werkzeugkette abdeckt und was sie nicht abdeckt, und kannst das im Gespräch mit der Sicherheitsabteilung sauber trennen.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Was ohne Standardbibliothek wegfällt und was bleibt

Rust teilt seine Standardbibliothek in drei Ebenen. core enthält alles, was ohne Betriebssystem und ohne Speicherverwaltung funktioniert: Zahlen, Slices, Iteratoren, Traits, Option und Result. alloc kommt dazu, sobald ein Allokator vorhanden ist, und bringt Box, Vec und String mit. std setzt beides voraus und ergänzt Dateien, Netzwerk, Threads und Zeit. Auf einem Mikrocontroller schaltest du std mit #![no_std] ab und entscheidest anschließend bewusst, ob du alloc überhaupt willst.

In vielen Geräten lautet die Antwort nein, und zwar nicht aus Prinzip, sondern wegen der Fragmentierung: Ein Heap, der nach drei Wochen Dauerbetrieb keinen zusammenhängenden Block mehr findet, ist ein Fehler, der sich im Labor nicht reproduzieren lässt. Der übliche Ersatz heißt heapless. Dessen Vec, String und Queue haben eine zur Übersetzungszeit festgelegte Kapazität, liegen im statischen Speicher oder auf dem Stapel und geben beim Überlauf ein Result zurück, statt Speicher nachzufordern. Damit steht der Spitzenbedarf schon im Linkerbericht und nicht erst im Feld.

Was bleibt, ist die ganze Sprache. Enums mit Daten, Pattern Matching, Traits, Generics, Iteratoren und die Fehlerbehandlung mit Result funktionieren unverändert, und die Prüfung der Eigentumsverhältnisse wirkt hier sogar stärker als auf dem Server, weil sie den klassischen Fehler mit einem Zeiger auf einen Puffer, der schon wieder verwendet wird, an derselben Stelle abfängt.

Die Schichtung, die auf jedem Chip gleich aussieht

Der Unterbau besteht aus drei Crates mit klarer Aufgabenteilung, und diese Teilung ist der Grund, warum der Wechsel der Chipfamilie in Rust so wenig kostet. Ganz unten liegt die aus der Herstellerbeschreibung erzeugte Registercrate. Sie ist reine Abbildung: jedes Register ein Typ, jedes Bitfeld eine Methode, jede zulässige Belegung ein Aufzählungswert. Darüber liegt die Hardware-Abstraktion, die aus diesen Registern arbeitsfähige Objekte macht und dabei den Zustand im Typ mitführt. Ein Pin, der als Eingang konfiguriert ist, hat schlicht keine Methode zum Setzen des Pegels, deshalb kann dieser Fehler nicht in eine Firmware gelangen.

Der eigentliche Hebel liegt in embedded-hal. Die Crate definiert keine Implementierung, sondern Traits: einen digitalen Ausgang, einen SPI-Bus, eine I2C-Verbindung, eine Verzögerung. Seit Version 1.0.0 vom Januar 2024 sind diese Traits stabil, und die Hardware-Abstraktionen der großen Chipfamilien implementieren sie. Für euch heißt das: Ein Sensortreiber, der gegen die Traits geschrieben ist, läuft auf STM32, RP2040 und nRF52, ohne dass ihn jemand anfasst. Umgekehrt ist ein Treiber, der direkt gegen eine bestimmte Hardware-Abstraktion geschrieben wurde, beim nächsten Chipwechsel Sondermüll.

Die Board-Crate ganz oben wird oft weggelassen, und das rächt sich bei der zweiten Platinenrevision. Ihr einziger Zweck ist, die Zuordnung von Signalnamen zu Anschlüssen an genau einer Stelle zu halten. Steht sie stattdessen verteilt im Anwendungscode, kostet jede geänderte Verdrahtung eine Suche durch das ganze Projekt, und ein übersehener Fall fällt erst beim Test auf der neuen Platine auf.

Interrupts und geteilter Zustand, wenn niemand die Threads verwaltet

Ohne Betriebssystem gibt es zwei Ausführungskontexte: die Hauptschleife und die Interrupt-Handler, die sie jederzeit unterbrechen können. Der klassische C-Weg, eine globale Variable als volatile zu markieren und in beiden anzufassen, hat in Rust keine Entsprechung. Eine veränderliche statische Variable anzufassen ist eine der fünf Operationen, die unsafe freigibt, und seit der Edition 2024 ist bereits eine Referenz darauf ein standardmäßig als Fehler gemeldeter Verstoß. Das ist kein Schikanieren: Genau dieses Muster erzeugt die Fehler, die nur unter Last und nur beim Kunden auftreten.

Das übliche sichere Muster nutzt die critical-section-Crate. Der geteilte Wert liegt als static in einem Mutex<RefCell<Option<T>>>. Beim Start wird die Peripherie einmal hineingelegt, danach greifen Hauptschleife und Handler nur noch innerhalb eines kritischen Abschnitts darauf zu. Der Mutex ist hier keiner mit Warteschlange, sondern ein Nachweis gegenüber dem Typsystem, dass der Zugriff mit gesperrten Interrupts stattfindet. Das ist etwas Schreibarbeit, dafür lässt sich das Ergebnis nicht falsch benutzen.

Wer das mehr als zweimal geschrieben hat, greift zu einem Framework, das genau diesen Teil kapselt, und landet damit bei der Weiche zwischen RTIC und Embassy.

RTIC und Embassy, zwei gangbare Wege

RTIC dreht die Aufgabenverteilung um: Statt einer Hauptschleife mit Zustandsmaschine schreibst du Tasks, die an Interrupts gebunden sind, und deklarierst, welche Ressourcen sie teilen. Die Prioritäten vergibt die Interruptsteuerung des Kerns, das Zusammenspiel prüft ein Makro zur Übersetzungszeit. Der Effekt, auf den es ankommt: Tasks derselben Priorität können sich nicht gegenseitig unterbrechen und brauchen deshalb für gemeinsame Ressourcen gar keine Sperre. Es gibt keinen eigenen Scheduler, der Rechenzeit kostet, und keine dynamische Speicheranforderung. Für Aufbauten mit harten Fristen auf Interrupt-Ebene ist das die Wahl, die sich am besten begründen lässt.

Embassy geht den anderen Weg und bringt einen asynchronen Executor auf den Chip. Tasks sind async-Funktionen, und statt in einer Warteschleife zu stehen, wartet der Code mit await auf das Ende einer DMA-Übertragung, auf einen Zeitgeber oder auf ein Paket, während der Kern in der Zwischenzeit schlafen kann. Dazu gibt es eigene Hardware-Abstraktionen für STM32, nRF und RP, eine Zeitbibliothek, eine Netzwerkschicht mit TCP und IP, USB-Unterstützung und einen Bootloader für Firmware-Aktualisierungen. Der Gewinn zeigt sich, sobald mehrere langsame Vorgänge nebeneinander laufen, also bei Geräten mit Funk, Netzwerk und Sensorik gleichzeitig.

Bei der Reife gibt es einen Unterschied, den man kennen sollte. Die Traits von embedded-hal sind mit 1.0.0 stabil, die Executor-Crate von Embassy steht dagegen weiterhin in der Null-Reihe, und Aktualisierungen bringen von Zeit zu Zeit Anpassungen mit. Das schließt den Einsatz nicht aus, es gehört aber in die Planung: Ein Gerät mit zehn Jahren Feldlaufzeit braucht eine Aussage dazu, wer die Firmware in fünf Jahren noch übersetzen kann und mit welchen Versionen.

Flashen, messen, und die Frage nach der Zulassung

Von der C-Werkzeugkette bleiben zwei Dinge: das Linkerskript, in Rust meist als memory.x mit Start und Größe von Flash und RAM, und der Debug-Adapter. Alles andere übernimmt probe-rs. Trägst du in .cargo/config.toml unter runner den Befehl probe-rs run mit dem Chipnamen ein, flasht cargo run die Firmware, startet sie und zeigt die Logausgabe im selben Terminal, inklusive aufgelöster Rücksprungadressen bei einem Panic. Ein separater Debugserver läuft dabei nicht mehr mit.

Für die Ausgabe selbst gibt es defmt, und die Idee dahinter ist der Grund, warum es sie überhaupt braucht. Die Formatierung mit dem üblichen Apparat aus core::fmt zieht mehrere Kilobyte Code in den Flash, und die Formatzeichenketten kommen dazu. defmt schiebt beides auf den Rechner: Auf dem Gerät wird nur ein Bezeichner plus die rohen Argumente über die Debug-Schnittstelle geschoben, den lesbaren Satz setzt das Werkzeug auf der Gegenseite zusammen. Wie viel dabei tatsächlich im Speicher landet, zeigt cargo size aufgeschlüsselt nach Abschnitten, und cargo bloat nennt die Funktionen, die am meisten Platz brauchen.

Bleibt die Frage, die in Automobil-, Medizin- und Anlagenprojekten immer zuerst gestellt wird: Darf der Compiler das überhaupt? Dafür gibt es Ferrocene, eine von TÜV SÜD qualifizierte Rust-Werkzeugkette. Qualifiziert ist sie nach ISO 26262 bis ASIL D, nach IEC 61508 bis SIL 3 und nach IEC 62304 bis Klasse C, mit Zielen für Linux, QNX und Bare Metal auf Armv8-A und Armv7E-M. Zwei Dinge gehören in dieselbe Antwort: Es ist derselbe Compiler wie der offizielle, ergänzt um die Nachweise und einen gepflegten Kanal, und eine qualifizierte Werkzeugkette zertifiziert nicht euer Produkt. Sie nimmt euch nur das Argument aus dem Weg, der Compiler sei für diesen Einsatz nicht nachgewiesen.

Dazu passende Kurse

Die Schichtung aus Registercrate, Hardware-Abstraktion und Board-Crate versteht man am schnellsten an laufender Hardware, dafür gibt es Rust-Kurse für eingebettete Systeme .

Wer aus der C-Welt kommt und zuerst die Grundlagen der Zielplattform auffrischen will, findet passende Einstiege bei den Kurse rund um eingebettete Systeme .

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Konkrete Kurse

Wo du genau das übst

Sehr angenehmes, gut vorbereitetes Seminar, kann ich nur empfehlen.
Rust Fortgeschrittenenkurs

Häufige Fragen

Brauche ich für Rust auf dem Mikrocontroller eine bestimmte Entwicklungsumgebung?
Nein, die Werkzeugkette ist die gleiche wie auf dem Rechner: cargo, rustup und der Sprachserver rust-analyzer. Dazu kommen probe-rs zum Flashen und Debuggen und ein Ziel, das du einmal mit rustup target add installierst. Ob du dabei in VS Code, in einer JetBrains-Umgebung oder im Terminal arbeitest, spielt keine Rolle, und die Umgebung des Chipherstellers brauchst du nur noch für Datenblätter und gelegentlich für die Beschreibungsdatei.
Ist Rust auf dem Mikrocontroller größer oder langsamer als C?
Bei gleicher Aufgabe und gleicher Einstellung liegen beide dicht beieinander, denn beide gehen durch dieselbe Codeerzeugung von LLVM. Wo Rust ohne Nachdenken größer wird, liegt es fast immer an zwei Stellen: an der Aufbereitung von Panic-Meldungen und an der Formatierungsmaschinerie fürs Logging. panic auf abort setzen und defmt statt formatierter Ausgaben nutzen holt den größten Teil davon zurück. Die restlichen Stellschrauben sind opt-level, lto und codegen-units im Release-Profil.
RTIC oder Embassy, wonach entscheide ich?
Nach der Art der Nebenläufigkeit. Wenn die Anwendung von harten Fristen und Prioritäten auf Interrupt-Ebene geprägt ist, etwa bei einer Regelung, passt RTIC besser, weil genau das sein Modell ist und der Aufbau zur Übersetzungszeit geprüft wird. Wenn stattdessen viele langsame Vorgänge nebeneinander warten, also Funk, Netzwerk, USB und Sensorik in einem Gerät, spielt Embassy seine Stärke aus, weil await genau diese Wartezeiten einsammelt. Beides zusammen in einer Anwendung ist möglich, aber selten die einfachste Lösung.
Kann ich vorhandene C-Treiber weiter benutzen?
Ja, das ist sogar der übliche Weg beim Einstieg. Mit bindgen erzeugst du aus den Headern die Rust-Deklarationen und rufst die Funktionen über eine Fremdschnittstelle auf. Dabei bleibt zweierlei zu tun: Der Aufruf selbst ist unsafe und gehört in eine kleine Hülle, die die Bedingungen der Bibliothek durchsetzt, und das Übersetzen des C-Anteils muss in den Build eingehängt werden, meist mit der cc-Crate in einem Buildskript.
Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Rust-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.

Vom ersten blinkenden Anschluss bis zur Firmware, die im Feld bestehen soll

Schichtung, Interrupts und Werkzeugkette lassen sich schlecht am Schreibtisch nachlesen, deshalb arbeitest du bei cmt auf echter Hardware statt in einem Simulator.