Die etwas anderen Festtage

Eine Revolution: Entdecke Asynchrone eingebettete Programmierung in Rust in Embassy mit STM32, Sensoren und Praxistipps.

Liebe Leser, ich hoffe ihr hattet erholsame Feiertage und seid gut ins neue Jahr 2025 gekommen. Ich möchte keinen typischen Neujahrs Motivation-Artikel schreiben, sondern wieder etwas Wissen mit euch teilen. Ok, den ein oder anderen Kommentar konnte ich mir nicht verkneifen. Viel Spaß mit meinem „etwas anderen“ Neujahrsartikel über Asynchrone eingebettete Programmierung.

Ich habe über die Festtage etwas Zeit freischaufeln können um ein Bastelprojekt in dem Rust Embedded Framework – Embassy zu beginnen. Ihr findet den aktuellen Stand, mit einigermaßen guten Dokumentation, auf GitHub.

Was euch im Artikel erwartet:

  • Entwicklung eingebetteter Systeme!
  • Asynchrone Programmierung!
  • Was ist eigentlich Nebenläufigkeit und Parallelität?
  • Den ein oder anderen „schwarzen“ Kommentar zu Vorsätzen und Änderungsbereitschaft!
  • und natürlich ganz viel zu Rust!

Du liest noch? Super: Der Artikel gliedert sich wie folgt:

  • Die Projektidee
  • Eingebettete Programmierung mit Rust
  • Was ist asynchrone Programmierung?
  • Asynchrone eingebettete Programmierung mit Embassy
  • Fazit

Die Projektidee

Mein letztes Projekt im Bereich Embedded liegt schon einige Jahre zurück. Inzwischen hat man die Möglichkeit mittels KiCAD eine Platine zu designen und dann für wenig Geld eine Produktion, z.B. bei multi-cb, in Auftrag zu geben. Sowas lässt mein Bastlerherz höher schlagen. Das war aber nicht der einzige Grund, dieses Projekt zu starten.

Bei einem Kundenprojekt habe ich einen Freelancer kennengelernt, der auch an eingebetteten Systemen gearbeitet hat und über ihn habe ich Einblicke zum aktuellen Stand der eingebetteten C-Programmierung erhalten.

Dazu kommt, dass ich seit einiger Zeit interessiert die Entwicklung von Embedded Rust beobachte. Insbesondere des Frameworks Embassy, das auf dem Konzept der asynchronen Programmierung basiert. Falls man von asynchroner Programmierung gehört, dann am ehesten im Zusammenhang mit Web-Services anstatt mit bare-metal Entwicklung.

Aber auch ohne Embassy bringt Rust einen tollen Mehrwert in die Welt der eingebetteten Programmierung: Zur Übersetzungszeit überprüft Rust die Hardwarekonfiguration. Cooler ist natürlich Asynchrone eingebettete Programmierung.

Jetzt geht es auf Shopping-Tour

So kam es eine Woche vor den Feiertagen zu einer kleinen Einkaufstour bei Reichelt (ein günstiger und beliebter Elektronikhändler in Deutschland). Ich wollte eine kleine Sensorplattform aufbauen, mit der ich Temperatur und Helligkeit messen und per UART an den PC schicken kann. Dazu Mein Einkauf:

Die Ziele der „Sensorplattform“ und den Status QUO findet ihr auf GitHub.

Natürlich hatte ich einiges vergessen. Zum Beispiel ein 4,7k Ohm Widerstand für den I2C Bus. Vielen Dank an meinen Vater, der fleißig und pflichtbewusst auf die Suche ging um meine alten Elektronik-Teile auszugraben, so dass ich sie nach den Festtagen mitnehmen konnte. Nun gut, zurück zum Thema: Was ist eingebettete Programmierung und warum mit Rust? Das erfahrt ihr im nächsten Abschnitt.

Eingebettete Programmierung mit RUst

Im Grunde basiert eingebettete Programmierung darauf das man die Spannung von PINs steuert. Das Ganze ist natürlich viel komplizierter denn neben Pins gibt es weitere Hardware-Komponenten wie: Speicher, Busse, Timer. Es werden zum Beispiel Kommunikationsprotokolle wie I2C eingesetzt, die mit Funktionen wie Direct-Memory-Access (DMA) betrieben werden können.

DMA ermöglicht kontinuierlichere Datenströme und höherer Performance. Beispiel: Wird ein Sensor per I2C angesteuert, kann DMA dafür sorgen, dass Messwerte ohne CPU-Eingriff in den Speicher wandern, während die CPU noch andere Aufgaben erledigt. Das ist besonders bei zeitkritischen Anwendungen vorteilhaft.

Glücklicherweise stellen die meisten Hardware Hersteller sogenannte SVD Dateien zur Verfügung, die beschreiben im Detail die Register einer MCU, also die Speicheradresse zum Mapping, Registeradressen und Bitfelder. Diese Informationen wurden schon im C-Ecosystem für die automatische Generierung von low-level Code verwendet. Stichwort CMSIS-SVD. Für Rust ist das Kommandozeilen-Tool svd2rust entstanden: Doch Rust, bzw. svd2rust, bietet noch mehr.

Im Rust-Ecosystem bestehen neben den Hardware-Abstraction-Layers (HALs) auch Peripherie-Access-Crates (PACs). Letztere werden automatisch mittels svd2rust generiert. PACs nutzen Rust Traits um bei der Übersetzung sicherzustellen, dass Peripherie mit Treiber und DMA in der richtigen Art und Weise verknüpft wird.

Im Bild oben sieht man z.B. eine Rust Compilermeldung, die ich erhalten habe als ich eine falsche Peripherie auf das serielle UART Protokoll mappen wollte. Der Hinweis zur Lösung ist gelb hervorgehoben.

Der Fehler war, dass ich PA1 statt PA9 als Peripherie ausgewählt habe. In C hätte ich zur Laufzeit irgendwas beobachtet, oder wahrscheinlicher: Ich hätte nichts beobachtet, und mich dann voller Verwunderung auf die Fehlersuche begeben. Danke Rust!

Asynchrone eingebettete Programmierung also, aber was ist asynchrone Programmierung, überhaupt?

Was ist asynchrone Programmierung

Anstatt asynchrone Programmierung zu definieren, legen wir erst fest was synchrone Programmierung bedeutet. Und zwar, dass ein Funktionsaufruf blockiert – also der Funktionsaufruf wird abgeschlossen und dann wird der nächste Funktionsaufruf ausgeführt. Der Code unten läuft von oben nach unten ab, also so wie er gelesen wird. Das ist so intuitiv, das diese Erklärung wohl eher verwirrt.

Nehmen wir an Funktionen fragen Datenbanken ab, laden eine Datei herunter oder warten auf einen Sensorwert, der über den I2C Bus gesendet wird. Die CPU hat nichts zutun, aber man wartet auf Speicher oder Kommunikation. In der Zeit wäre es schön die CPU-Zeit in eine andere Aufgabe zu stecken.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let reval_a = my_func_a();
    let reval_b = my_func_b();
    printnln!("A={}, B={}", reval_a, reval_b);
}

Bei der asynchronen Programmierung stößt ein asynchroner Funktionsaufruf eine Aufgabe an, die zu einem gegebenen Zeitpunkt von einem Executor ausgeführt wird. So kann es sein dass in dem Code unten die Funktion my_async_b zuerst berechnet wird. Wichtig ist: Im asynchronen Rust findet der Funktionsaufruf nur statt wenn das Ergebnis explizit angefragt wird.

Im Code unten wird die asynchrone Laufzeit von Tokio verwendet. Man sieht so etwas meistens im Zusammenhang mit Server/Client Anwendungen. Durch den Aufruf der beiden Funktionen passiert erstmal nichts. Damit etwas passiert muss mittels await oder dem join! Makro das Future explizit angefragt werden (Das ist anders als in C++). Für die eingebettete Programmierung, die meist nur einen Rechenkern verwendet, ist es sinnvoll den Unterschied zwischen Parallelen und Nebenläufigen Programmfluss zu erklären.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let future_a = my_async_func_a();
    let future_b = my_async_func_b();

    let (reval_a, reval_b) = join!(future_a, future_b);
    println!("A={}, B={}", reval_a, reval_b);

    let reval_c = my_async_func_c().await;
}

Nebenläufigkeit und Parallelität

Eine Asynchrone Runtime erzeugt eine nebenläufige Ausführung. Wichtig: Dies ist auch mit einem einzigen Thread oder einem einzigen Rechenkern möglich. Insbesondere in eingebetteter Hardware wird häufig ein Chip mit nur einem Rechenkern verbaut. Dies ist auch der Fall für den STM32 Chip auf dem hier verwendeten NUCLEO-Board.

Eine parallele Ausführung ist erst möglich sobald mehr als einen Rechenkern verfügbar ist. In dem Fall könnte ein Executor Funktion a und b gleichzeitig auf verschiedenen Kernen ausführen. Im Falle eines Single-Cores verhält es sich wie in den Betriebssystemen in den 90igern. Das bedeutet ein Arbeiter wechselt die Aufgaben und weil CPUs und MCUs so schnell sind wirkt es auf uns gleichzeitig oder parallel. Dabei entspricht der Prozess-Scheduler eines Betriebssystems hier einer asynchronen Runtime, z.B. Tokio oder Embassy. Die Bearbeitung der Aufgaben/Funktionen kann sich überlappen, es ist aber weder Gleichzeitigkeit nötig noch garantiert.

Das typische Einsatzszenario für asynchrone Programmierung ist ein Webserver mit vielen Anfragen und langen Wartezeiten aufgrund von Ein und Ausgaben, wie das Auslesen einer Datenbank oder der Zugriff auf eine Datei auf einem anderen Server.

Jedoch wird genau dieses Prinzip von Embassy für eingebettete Systeme genutzt, um z. B. mehrere Sensoren und Kommunikationsaufgaben zeitgleich zu organisieren, ohne einen echten Betriebssystem-Scheduler zu benötigen. Wir erhalten also einige Features eines Echtzeit-Betriebssystem (RTOS).

Das ist asynchrone eingebettete Programmierung. Danke Embassy!

Synchrone Programmierung

  • Funktion kehrt erst nach Abschluss der Berechnung zurück
  • Rückgabewert ist tatsächlicher Rückgabe
  • Reihenfolge der Aufrufe wohldefiniert
  • Programmfluss ist deutlich einfacher nachzuvollziehen

Asynchrone Programmierung

  • Funktion kehrt sofort zurück
  • Rückgabewert ist „In Arbeit“ oder „Erledigt“, der tatsächliche Rückgabewert ist nur im Zustand „Erledigt“ abrufbar.
  • Funktion b könnte ihre Berechnung vor Funktion a abschließen

Asynchrone eingebettete Programmierung mit Embassy

Anders als Tokio ist Embassy auf bare-metal Hardware ausgelegt. Anstatt klassischer Threads, die vom Betriebssystem verwaltet werden, greift Embassy auf eine eigene Scheduler Implementierung zurück, die auf Hardware Interrupts oder nutzerspezifischen Synchronisierungen, wie Timer oder Signale, reagiert um aufzuwachen. Dies bietet eine hervorragende Grundlage um Energie zu sparen, denn dank dieser Konzepte ist klar wann die CPU in den Sleep geschickt werden darf und sollte.

Weitere wichtige Konzepte sind die Tasks, die dem Scheduler übergeben werden. Tasks sind letztendlich asynchrone Rust Funktionen. Synchronisationsprimitiven wie Signale, Channels und Mutexes, ermöglichen es Tasks Daten auszutauschen. Damit erhält man Features wie in einem RTOS (Echtzeit-Betriebssystem) aber mit einem deutlich geringeren Footprint.

Im Gegensatz zu einem klassischen RTOS wie FreeRTOS oder Zephyr nutzt Embassy rein Rust-eigene Konzepte wie async/await, Channels, Signals und Timer. Natürlich gibt es Anwendungsfälle bei denen man nicht um ein RTOS herumkommt, da das kooperative Scheduling auch mit einer High-Priority Variante immer erst auf einen Task-Abschluss warten muss, so dass absolut harte Echtzeit-Anforderungen im ms Bereich gegebenfalls nicht gehalten werden können.

Nun gut, aber wie nutzt man Embassy?

Aufsetzen der Peripherie, Interrupts und Tasks

Embassy Nutzer können in der Main Funktion Tasks „spawnen“ und diese werden standardmäßig kooperativ in einem Scheduler ausgeführt. Für Echtzeitkritische Module ist der Einsatz eines weiteren High-Priority Schedulers möglich.

Aber lasst uns das Code-Snippet unten untersuchen. Die Main Funktion wird wie bei Tokio, nur diesmal mit #[embassy_executor::main], annotiert. Zuerst wird eine Representation der STM32 Peripherie mit einem init Aufruf erzeugt. Dann verkettet das Makro bind_interrupts! unsere Peripherie (USART, I2C) mit den entsprechenden Interrupt-Routinen von Embassy.

Bei usart handelt es sich um einen Treiber für die serielle Kommunikation mit einem Computer. Im Falle des NUCLEO Boards wird hierfür die USB-Verbindung verwendet. Mit der Funktion split können Empfänger und Transmitter an unterschiedliche Tasks übergeben werden. Falls mehr als ein Task senden oder Transmitten soll ist die Sicherstellung der Synchronisierung, z.B. mittels Mutex, nötig. Anderenfalls wird der Rust-Compiler eine Fehlermeldung ausgeben.

Wir konzentrieren uns in diesem Artikel Ausschnitte für mehr Details schaut euch den kompletten Code in GitHub an. Der Task uart_status_report_transmitter verwendet den uarts Transmitter tx um Status-Reports zu senden. Der zweite Task uart_receiver_and_cmd_forwarder wird eingesetzt um Nachrichten von einem Rechner zu empfangen. Bisher haben wir die Channel und Signale im Code ignoriert. Dabei sind diese, neben Timer, unsere Werkzeuge um Nebenläufigkeit zu verwenden,

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    //...
     // bind interrupts
    bind_interrupts!(struct Irqs {
        USART3 => embassy_stm32::usart::InterruptHandler<embassy_stm32::peripherals::USART3>;
        I2C1_EV => embassy_stm32::i2c::EventInterruptHandler<embassy_stm32::peripherals::I2C1>;
        I2C1_ER => embassy_stm32::i2c::ErrorInterruptHandler<embassy_stm32::peripherals::I2C1>;
    });
    let mut usart = setup_usart_developer_console!(p, Irqs, UsartConfig::default());
    // ...
    // spawn a task for uart sending and receiving each
    let (tx, rx) = usart.split();
    spawner.spawn(uart_receiver_and_cmd_forwarder(rx, CHANNEL_COMMANDS.sender())).unwrap();
    spawner.spawn(uart_status_report_transmitter(tx)).unwrap();
    // ...
    spawner.spawn(process_light_sensor(
        &LIGHT_SENSOR_SIGNAL,
        i2c
    )).unwrap();
    // ...
    loop {
        button.wait_for_rising_edge().await;
     CHANNEL_COMMANDS.sender().send(Commands::LightSensor(LightSensorCommands::SingleMeasurment)).await;
        Timer::after(Duration::from_millis(50)).await;
    }
}

Bisher wissen wir nur wie Tasks aufgesetzt werden, der Vorteil Asynchrone eingebettete Programmierung spielt sich jedoch durch die Interaktion der Tasks aus.

Interaktion zwischen Tasks

Mit der Hilfe von Channels und anderen Synchronisationsmechanismen, wie Signal, ist eine Event-artige Kommunikation zwischen den Tasks möglich. Weiterhin ist das Teilen von Daten, die mittels eines Mutexes synchronisiert werden, möglich.

Im Code-Block oben sind die Variablen CHANNEL_COMMANDS und LIGHT_SENSOR_SIGNAL für die Abgabe des Kontrollflusses und somit die Steuerung des Schedulers und der Kommunikation zwischen Tasks zuständig.

So werden Kommandos von dem uart Empfänger Task uart_receiver_and_command_forwarder an einem Task zur Verarbeitung von Kommandos weitergeleitet (nicht im Ausschnitt zu sehen). Es ist möglich mehrere Sender zu verwenden, wie unten in der Schleife, die prüft ob der User-Button betätigt wurde. Wenn das der Fall ist wird erst der Kommando Prozessor informiert, der dann ein LIGHT_SENSOR_SIGNAL auslöst um einen einzelnen Sensorwert zu ermitteln.

Im folgendem untersuchen wir wie LIGHT_SENSOR_SIGNAL die Abgabe des Programmflusses zurück an den Scheduler an im process_light_sensor Task umsetzt. Der Code-Block unten illustriert zwei Fälle: Im einfacheren Fall, wenn nur ein Messwert ausgelesen wird auf ein Signal gewartet.

Im zweiten Fall, wird entweder auf neue Werte auf dem I2C Bus gewartet oder reagiert wenn das LIGHT_SENSOR_SIGNAL ausgelöst wird. Weiter wird noch illustriert wie man Timer verwenden kann um den Kontrollfluss zurück an den Scheduler zu geben.

// in non-continuous modes:
LightSensorState::PowerOff | LightSensorState::SingleMeasurement => signal.wait().await,
// ...
// in continuous sending mode
let f1 = i2c.read(BH1750_ADDR_L, &mut rx_buf);
let f2 = signal.wait();
select(f1, f2).await 
// ...
Timer::after(Duration::from_millis(150)).await;

Ein Embassy Nutzer kann also dem Scheduler mittels await mitteilen, dass die aktuelle Funktion warten muss und so modularen Code aufgeteilt in verschiedene Tasks schreiben, die mittels Events wie Channels oder Signals untereinander kommunizieren.

Fazit

Rust und das Embassy Framework denken eingebettete Programmierung neu und machen so asynchrone eingebettete Programmierung Salon-fähig. Embassy ist Teil der no_std-Welt und setzt auf die Rust Embedded-HAL auf, um Treiber (I2C, SPI, UART) asynchron anzubinden.

Das Framework wird erfolgreich von der Firma Akiles eingesetzt und hat das Potenzial viele Anwendungsfälle in eingebetteten System besser umzusetzen als eine reine C-Lösung.

Für absolut harte Sicherheitsrelevante Echtzeitanforderungen im kleinen Millisekunden Bereich ist Embassy nicht geeignet und es wird ein echtes RTOS benötigt. Die meisten Use-Cases werden nicht so strikte Anforderungen haben und können dank Embassy Features eines RTOS mit kaum Overhead erhalten.

Schreibe einen Kommentar