Nebenläufigkeit
Nebenläufigkeit tritt immer dann auf, wenn verschiedene Teile Ihres Programms zu unterschiedlichen Zeiten oder in einer anderen als der vorgesehenen Reihenfolge ausgeführt werden können. Im Kontext eingebetteter Systeme umfasst dies:
- Interrupt-Handler, die immer dann ausgeführt werden, wenn der zugehörige Interrupt auftritt,
- verschiedene Formen des Multithreadings, bei denen Ihr Mikroprozessor regelmäßig zwischen Teilen Ihres Programms wechselt,
- und in einigen Systemen Mehrkern-Mikroprozessoren, bei denen jeder Kern gleichzeitig und unabhängig voneinander einen anderen Teil Ihres Programms ausführen kann.
Da viele eingebettete Programme mit Interrupts umgehen müssen, spielt Nebenläufigkeit früher oder später meist eine Rolle – und genau hier können auch viele schwer zu findende und komplexe Fehler auftreten. Glücklicherweise bietet Rust eine Reihe von Abstraktionen und Sicherheitsgarantien, die uns dabei helfen, korrekten Code zu schreiben.
Keine Nebenläufigkeit
Die einfachste Form der Nebenläufigkeit für ein Embedded-Programm ist der Verzicht darauf: Die Software besteht aus einer einzigen Hauptschleife, die kontinuierlich durchlaufen wird, und es kommen keinerlei Interrupts zum Einsatz. Manchmal ist genau dieser Ansatz für die vorliegende Aufgabenstellung ideal! Typischerweise liest die Schleife Eingabewerte ein, führt Berechnungen oder Verarbeitungen durch und gibt Ergebnisse aus.
#[entry]
fn main() {
let peripherals = setup_peripherals();
loop {
let inputs = read_inputs(&peripherals);
let outputs = process(inputs);
write_outputs(&peripherals, outputs);
}
}Da keine Nebenläufigkeit vorliegt, müssen Sie sich keine Gedanken über die gemeinsame Datennutzung zwischen verschiedenen Programmteilen oder die Synchronisierung des Zugriffs auf Peripheriegeräte machen. Wenn ein solch einfacher Ansatz ausreicht, kann dies eine hervorragende Lösung sein.
Globale veränderliche Daten
Im Gegensatz zu Rust-Anwendungen außerhalb des Embedded-Bereichs haben wir meist nicht den Luxus, Speicher auf dem Heap zu reservieren und Referenzen auf diese Daten an einen neu erstellten Thread zu übergeben. Stattdessen können unsere Interrupt-Handler jederzeit aufgerufen werden und müssen wissen, wie sie auf den jeweils genutzten gemeinsamen Speicher zugreifen können. Auf unterster Ebene bedeutet dies, dass wir über statisch reservierten, veränderbaren Speicher verfügen müssen, auf den sowohl der Interrupt-Handler als auch der Hauptprogrammcode zugreifen können.
In Rust ist der Lese- oder Schreibzugriff auf solche static mut-Variablen stets als unsafe (unsicher) eingestuft. Ohne besondere Vorsichtsmaßnahmen könnte es nämlich zu einer sogenannten Race Condition (Wettlaufsituation) kommen: Der Zugriff auf die Variable wird mitten im Vorgang durch einen Interrupt unterbrochen, der seinerseits ebenfalls auf diese Variable zugreift.
Um zu veranschaulichen, wie dieses Verhalten subtile Fehler in Ihrem Code verursachen kann, betrachten Sie ein eingebettetes Programm, das die steigenden Flanken eines Eingangssignals innerhalb jedes Ein-Sekunden-Intervalls zählt (einen Frequenzzähler):
static mut COUNTER: u32 = 0;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// GEFAHR – Nicht wirklich sicher! Koennte zu Datenwettlaeufen
// fuehren.
unsafe { COUNTER += 1 };
}
last_state = state;
}
}
#[interrupt]
fn timer() {
unsafe { COUNTER = 0; }
}
Sekündlich setzt der Timer-Interrupt den Zähler auf 0 zurück. Währenddessen misst die Hauptschleife kontinuierlich das Signal und erhöht den Zähler, sobald ein Wechsel von „Low“ auf „High” erkannt wird. Wir mussten das Schlüsselwort unsafe verwenden, um auf COUNTER zuzugreifen, da es als static mut deklariert ist; damit versichern wir dem Compiler, dass wir kein undefiniertes Verhalten verursachen. Erkennst du die Race Condition? Die Inkrementierung von COUNTER ist nicht garantiert atomar – tatsächlich wird sie auf den meisten Embedded-Plattformen in die Schritte Laden, Inkrementieren und Speichern aufgeteilt. Würde der Interrupt nach dem Laden, aber vor dem Speichern ausgelöst, ginge das Zurücksetzen auf 0 nach der Rückkehr aus dem Interrupt verloren – und wir würden für diesen Zeitraum doppelt so viele Signalwechsel zählen.
Kritische Abschnitte
Was können wir also gegen Data Races unternehmen? Ein einfacher Ansatz ist die Verwendung von kritischen Abschnitten – also Bereichen, in denen Interrupts deaktiviert sind. Indem wir den Zugriff auf COUNTER in der Funktion main in einen kritischen Abschnitt einbetten, stellen wir sicher, dass der Timer-Interrupt erst ausgelöst wird, nachdem wir das Inkrementieren von COUNTER abgeschlossen haben:
static mut COUNTER: u32 = 0;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// Ein neuer kritischer Abschnitt gewaehrleistet den
// synchronisierten Zugriff auf COUNTER.
cortex_m::interrupt::free(|_| {
unsafe { COUNTER += 1 };
});
}
last_state = state;
}
}
#[interrupt]
fn timer() {
unsafe { COUNTER = 0; }
}
In diesem Beispiel verwenden wir cortex_m::interrupt::free, doch auch andere Plattformen verfügen über ähnliche Mechanismen, um Code in einem kritischen Abschnitt auszuführen. Dies entspricht im Grunde dem Deaktivieren von Interrupts, der Ausführung von Code und dem anschließenden erneuten Aktivieren der Interrupts.
Beachten Sie, dass wir innerhalb des Timer-Interrupts keinen kritischen Abschnitt benötigten; dies hat zwei Gründe:
- Das Schreiben von 0 in
COUNTERkann nicht von einer Race-Condition betroffen sein, da wir den Wert nicht lesen. - Es wird ohnehin niemals vom
main-Thread unterbrochen werden.
Wenn COUNTER von mehreren Interrupt-Handlern gemeinsam genutzt würde, die sich gegenseitig unterbrechen könnten, müsste jeder von ihnen ebenfalls einen kritischen Abschnitt verwenden.
Dies löst zwar unser unmittelbares Problem, doch wir müssen weiterhin viel unsicheren Code schreiben, dessen Verhalten wir sorgfältig analysieren müssen; zudem setzen wir möglicherweise unnötigerweise kritische Abschnitte ein. Da jeder kritische Abschnitt die Interrupt-Verarbeitung vorübergehend aussetzt, entstehen Kosten in Form von zusätzlichem Codeumfang sowie höherer Interrupt-Latenz und stärkerem Jitter (die Verarbeitung von Interrupts kann länger dauern, und die Zeit bis zur Verarbeitung schwankt stärker). Ob dies ein Problem darstellt, hängt vom jeweiligen System ab, doch im Allgemeinen sollten wir dies vermeiden.
Es ist wichtig zu beachten: Auch wenn ein kritischer Abschnitt garantiert, dass keine Interrupts ausgelöst werden, bietet er auf Mehrkernsystemen keine Exklusivitätsgarantie! Der andere Rechenkern könnte – selbst ohne Interrupts – problemlos auf denselben Speicher zugreifen wie Ihr eigener Kern. Wenn Sie mehrere Rechenkerne nutzen, benötigen Sie daher leistungsfähigere Synchronisationsmechanismen.
Atomarer Zugriff
Auf einigen Plattformen stehen spezielle atomare Befehle zur Verfügung, die Garantien für Lese-Modifizier-Schreib-Operationen (Read-Modify-Write) bieten. Speziell für Cortex-M gilt: thumbv6 (Cortex-M0, Cortex-M0+) bietet lediglich atomare Lade- und Speicherbefehle, während thumbv7 (Cortex-M3 und höher) vollständige „Compare-and-Swap“-Befehle (CAS) bereitstellt. Diese CAS-Befehle bieten eine Alternative zur drastischen Maßnahme, sämtliche Interrupts zu deaktivieren: Man kann versuchen, den Inkrementierungsvorgang durchzuführen – was meistens gelingt –, doch sollte eine Unterbrechung auftreten, wird der gesamte Vorgang automatisch erneut versucht. Diese atomaren Operationen sind auch in Mehrkernsystemen sicher.
use core::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// Verwenden Sie `fetch_add`, um 1 atomar zu COUNTER zu addieren.
COUNTER.fetch_add(1, Ordering::Relaxed);
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// Verwenden Sie `store`, um 0 direkt in COUNTER zu schreiben.
COUNTER.store(0, Ordering::Relaxed)
}
Diesmal ist COUNTER eine sichere static-Variable. Dank des Typs AtomicUsize kann COUNTER sowohl vom Interrupt-Handler als auch vom Hauptthread aus sicher geändert werden, ohne Interrupts zu deaktivieren. Wenn möglich, ist dies die bessere Lösung – wird aber möglicherweise von Ihrer Plattform nicht unterstützt.
Hinweis zur Ordering: Diese beeinflusst, wie Compiler und Hardware Anweisungen neu anordnen, und hat auch Auswirkungen auf die Cache-Sichtbarkeit. Bei einer Einkernplattform ist die Option Relaxed ausreichend und in diesem Fall die effizienteste Wahl. Eine strengere Befehlsreihenfolge führt dazu, dass der Compiler Speicherbarrieren um die atomaren Operationen erzeugt. Je nachdem, wofür Sie atomare Operationen verwenden, benötigen Sie dies möglicherweise nicht. Die genauen Details des atomaren Modells sind komplex und werden am besten an anderer Stelle beschrieben.
Weitere Informationen zu atomaren Operationen und deren Reihenfolge finden Sie im nomicon.
Abstraktionen, Send und Sync
Keine der oben genannten Lösungen ist wirklich zufriedenstellend. Sie erfordern unsichere Blöcke, die sehr sorgfältig geprüft werden müssen und nicht ergonomisch sind. In Rust geht das doch bestimmt besser!
Wir können unseren Zähler in eine sichere Schnittstelle abstrahieren, die überall im Code sicher verwendet werden kann. In diesem Beispiel verwenden wir den Zähler für kritische Abschnitte, aber mit atomaren Operationen ließe sich etwas sehr Ähnliches realisieren.
use core::cell::UnsafeCell;
use cortex_m::interrupt;
// Unser Zaehler ist lediglich ein Wrapper um `UnsafeCell<u32>`, das Herzstueck
// der „Interior Mutability“ in Rust. Dank dieses Konzepts können wir `COUNTER`
// als `static` statt als `static mut` definieren und dennoch den Zaehlerwert
// veraendern.
struct CSCounter(UnsafeCell<u32>);
const CS_COUNTER_INIT: CSCounter = CSCounter(UnsafeCell::new(0));
impl CSCounter {
pub fn reset(&self, _cs: &interrupt::CriticalSection) {
// Indem wir die Uebergabe einer `CriticalSection` voraussetzen, wissen
// wir, dass wir uns innerhalb einer `CriticalSection` befinden; daher
// koennen wir bedenkenlos diesen `unsafe`-Block verwenden (der für den
// Aufruf von `UnsafeCell::get` erforderlich ist).
unsafe { *self.0.get() = 0 };
}
pub fn increment(&self, _cs: &interrupt::CriticalSection) {
unsafe { *self.0.get() += 1 };
}
}
// Erforderlich, um ein statisches CSCounter zu ermoeglichen. Siehe Erlaeuterung
// unten.
unsafe impl Sync for CSCounter {}
// COUNTER ist nicht mehr `mut`, da es „Interior Mutability“ verwendet; daher
// sind fuer den Zugriff auch keine `unsafe`-Bloecke mehr erforderlich.
static COUNTER: CSCounter = CS_COUNTER_INIT;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// No unsafe here!
interrupt::free(|cs| COUNTER.increment(cs));
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// Wir muessen hier tatsaechlich einen kritischen Abschnitt betreten, nur um
// ein gueltiges CS-Token zu erhalten, auch wenn wir wissen, dass kein
// anderer Interrupt diesen unterbrechen koennte.
interrupt::free(|cs| COUNTER.reset(cs));
// Wir koennten „unsicheren“ Code (unsafe code) verwenden, um eine
// gefaelschte CriticalSection zu erzeugen, falls wir das wirklich wollten,
// und so den Overhead vermeiden:
// let cs = unsafe { interrupt::CriticalSection::new() };
}
Wir haben unseren unsafe-Code in unsere sorgfältig entworfene Abstraktion verlagert; nun enthält unser Anwendungscode keine unsafe-Blöcke mehr.
Dieser Entwurf setzt voraus, dass die Anwendung ein CriticalSection-Token übergibt: Da diese Tokens nur sicher durch interrupt::free erzeugt werden können, stellen wir durch die Anforderung eines solchen Tokens sicher, dass wir uns innerhalb eines kritischen Abschnitts befinden, ohne die Sperre selbst implementieren zu müssen. Diese Garantie wird statisch vom Compiler gewährleistet; es entsteht also kein Laufzeit-Overhead durch cs. Hätten wir mehrere Zähler, könnten diese alle dasselbe cs verwenden, ohne dass mehrere verschachtelte kritische Abschnitte erforderlich wären.
Dies führt zu einem wichtigen Thema der Nebenläufigkeit in Rust: den Traits Send und Sync. Um das „Rust Book“ zusammenzufassen: Ein Typ ist Send, wenn er sicher in einen anderen Thread verschoben werden kann, während er Sync ist, wenn er sicher zwischen mehreren Threads geteilt werden kann. Im Embedded-Kontext betrachten wir Interrupts als Prozesse, die in einem vom Anwendungscode getrennten Thread ausgeführt werden; daher müssen Variablen, auf die sowohl ein Interrupt als auch der Hauptcode zugreift, Sync implementieren.
Für die meisten Typen in Rust werden diese beiden Traits automatisch vom Compiler für dich abgeleitet. Da CSCounter jedoch eine UnsafeCell enthält, ist der Typ nicht Sync; folglich konnten wir keinen static CSCounter definieren, denn static-Variablen müssen Sync sein, da von mehreren Threads aus auf sie zugegriffen werden kann.
Um dem Compiler mitzuteilen, dass wir sichergestellt haben, dass der CSCounter tatsächlich gefahrlos zwischen Threads geteilt werden kann, implementieren wir das Sync-Trait explizit. Wie schon bei der früheren Verwendung kritischer Abschnitte ist dies nur auf Single-Core-Plattformen sicher; bei mehreren Kernen wäre ein deutlich höherer Aufwand erforderlich, um die Sicherheit zu gewährleisten.
Mutexe
Wir haben eine nützliche, speziell auf unser Zählerproblem zugeschnittene Abstraktion entwickelt; es gibt jedoch viele gängige Abstraktionen für Nebenläufigkeit.
Ein solches Synchronisationsprimitiv ist der Mutex (kurz für „mutual exclusion“, also gegenseitiger Ausschluss). Diese Konstrukte gewährleisten den exklusiven Zugriff auf eine Variable, wie etwa unseren Zähler. Ein Thread kann versuchen, den Mutex zu sperren (oder zu erwerben); dabei ist er entweder sofort erfolgreich, blockiert, bis die Sperre verfügbar ist, oder erhält eine Fehlermeldung, dass der Mutex nicht gesperrt werden konnte. Solange der Thread die Sperre hält, hat er Zugriff auf die geschützten Daten. Ist der Thread fertig, entsperrt (oder gibt) er den Mutex frei, sodass ein anderer Thread ihn sperren kann. In Rust würden wir die Freigabe üblicherweise mithilfe des Drop-Traits implementieren; so ist sichergestellt, dass der Mutex immer freigegeben wird, sobald er den Gültigkeitsbereich (Scope) verlässt.
Die Verwendung eines Mutex in Interrupt-Handlern kann tückisch sein: Normalerweise darf ein Interrupt-Handler nicht blockieren. Besonders fatal wäre es, wenn er blockieren würde, während er darauf wartet, dass der Haupt-Thread eine Sperre (Lock) freigibt, da dies zu einem Deadlock führen würde (der Haupt-Thread würde die Sperre niemals freigeben, da die Ausführung im Interrupt-Handler verbleibt). Ein Deadlock gilt nicht als „unsicher“ (unsafe): Er ist selbst in sicherem Rust möglich.
Um dieses Verhalten vollständig zu vermeiden, könnten wir einen Mutex implementieren, der für das Sperren eine kritische Sektion erfordert – genau wie in unserem Zähler-Beispiel. Solange die Dauer der kritischen Sektion der Dauer der Sperre entspricht, ist sichergestellt, dass wir exklusiven Zugriff auf die gekapselte Variable haben, ohne den Sperrstatus des Mutex explizit nachverfolgen zu müssen.
Genau dies wird uns bereits durch die cortex_m-Crate abgenommen! Wir hätten unseren Zähler auch unter Verwendung dieser Crate implementieren können:
use core::cell::Cell;
use cortex_m::interrupt::Mutex;
static COUNTER: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
interrupt::free(|cs|
COUNTER.borrow(cs).set(COUNTER.borrow(cs).get() + 1));
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// Wir muessen hier noch einen kritischen Abschnitt betreten, um die
// Mutex-Bedingung zu erfuellen.
interrupt::free(|cs| COUNTER.borrow(cs).set(0));
}
Wir verwenden nun Cell; dieses bietet – ebenso wie sein Pendant RefCell – eine sichere Form der „Interior Mutability“ (internen Veränderbarkeit). Wir haben bereits UnsafeCell kennengelernt, die unterste Ebene der internen Veränderbarkeit in Rust: Sie ermöglicht es, mehrere veränderbare Referenzen auf den enthaltenen Wert zu erhalten, allerdings nur mittels unsafe-Code. Eine Cell ähnelt einer UnsafeCell, stellt jedoch eine sichere Schnittstelle bereit: Sie erlaubt lediglich das Kopieren des aktuellen Werts oder dessen Austausch, nicht jedoch den Zugriff über eine Referenz; zudem ist sie nicht Sync und kann daher nicht zwischen Threads geteilt werden. Aufgrund dieser Einschränkungen ist ihre Verwendung sicher, allerdings lässt sie sich nicht direkt in einer static-Variablen einsetzen, da static-Elemente die Eigenschaft Sync erfüllen müssen.
Warum funktioniert das obige Beispiel? Der Mutex<T> implementiert Sync für jedes T, das Send ist – wie beispielsweise eine Cell. Dies ist sicher möglich, da der Zugriff auf den Inhalt nur während eines kritischen Abschnitts gewährt wird. Dadurch erhalten wir einen sicheren Zähler ohne jeglichen unsicheren Code!
Das ist ideal für einfache Typen wie den u32 unseres Zählers. Was aber ist mit komplexeren Typen, die nicht Copy sind? Ein sehr häufiges Beispiel in eingebetteten Systemen ist eine Peripheriestruktur, die in der Regel nicht Copy ist. Für diesen Fall können wir RefCell verwenden.
Gemeinsame Nutzung von Peripheriegeräten
Mit svd2rust und ähnlichen Abstraktionen erzeugte Device-Crates ermöglichen einen sicheren Zugriff auf Peripheriekomponenten, indem sie sicherstellen, dass zu jedem Zeitpunkt nur eine einzige Instanz der entsprechenden Peripherie-Struktur existiert. Dies gewährleistet zwar die Sicherheit, erschwert jedoch den gleichzeitigen Zugriff auf eine Peripheriekomponente sowohl aus dem Haupt-Thread als auch aus einem Interrupt-Handler heraus.
Um den Zugriff auf Peripheriekomponenten sicher zu teilen, können wir den bereits bekannten Mutex verwenden. Zudem benötigen wir RefCell; dieses stellt mittels einer Laufzeitprüfung sicher, dass jeweils nur eine einzige Referenz auf eine Peripheriekomponente ausgegeben wird. Dies ist zwar mit einem höheren Overhead verbunden als bei einem einfachen Cell, doch da wir Referenzen anstatt Kopien weitergeben, müssen wir sicherstellen, dass zu jedem Zeitpunkt nur eine einzige Referenz existiert.
Schließlich müssen wir auch berücksichtigen, wie die Peripheriekomponente in die gemeinsam genutzte Variable übertragen werden kann, nachdem sie im Hauptcode initialisiert wurde. Hierfür können wir den Typ Option verwenden, der zunächst mit None initialisiert und später auf die Instanz der Peripheriekomponente gesetzt wird.
use core::cell::RefCell;
use cortex_m::interrupt::{self, Mutex};
use stm32f4::stm32f405;
static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
Mutex::new(RefCell::new(None));
#[entry]
fn main() -> ! {
// Die Peripherie-Singletons abrufen und konfigurieren.
// Dieses Beispiel stammt aus einer mit svd2rust erstellten Crate, die
// meisten Crates für eingebettete Geräte sind jedoch ähnlich.
let dp = stm32f405::Peripherals::take().unwrap();
let gpioa = &dp.GPIOA;
// Eine Art Konfigurationsfunktion.
// Gehen wir davon aus, dass es PA0 als Eingang und PA1 als Ausgang
// konfiguriert.
configure_gpio(gpioa);
// Speichere GPIOA im Mutex und verschiebe es dabei.
interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));
// Wir koennen `gpioa` oder `dp.GPIOA` nicht mehr verwenden, sondern
// muessen stattdessen ueber den Mutex darauf zugreifen.
// Achten Sie darauf, den Interrupt erst nach dem Setzen von MY_GPIO zu
// aktivieren:
// Andernfalls koennte der Interrupt ausgeloest werden, solange MY_GPIO
// noch None enthaelt, was – wie implementiert (mit `unwrap()`) – zu einer
// Panic fuehren wuerde.
set_timer_1hz();
let mut last_state = false;
loop {
// Wir lesen den Status nun als digitalen Eingang ueber den Mutex aus.
let state = interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().idr.read().idr0().bit_is_set()
});
if state && !last_state {
// Setze PA1 auf High, wenn an PA0 eine steigende Flanke erkannt
// wurde.
interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// Diesmal setzen wir im Interrupt lediglich PA0 zurueck.
interrupt::free(|cs| {
// Wir koennen `unwrap()` verwenden, da wir wissen, dass der Interrupt
// erst aktiviert wurde, nachdem `MY_GPIO` gesetzt war; andernfalls
// muessten wir den moeglichen Fall eines `None`-Werts behandeln.
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().clear_bit());
});
}
Das ist eine ganze Menge, die man erst einmal verarbeiten muss – schauen wir uns also die wichtigen Zeilen genauer an.
static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
Mutex::new(RefCell::new(None));Unsere gemeinsam genutzte Variable ist nun ein Mutex, der einen RefCell umschließt, welcher wiederum ein Option enthält. Der Mutex stellt sicher, dass der Zugriff nur innerhalb eines kritischen Abschnitts erfolgt, und macht die Variable dadurch Sync – auch wenn ein einfacher RefCell dies nicht wäre. Der RefCell ermöglicht uns „Interior Mutability“ (interne Veränderbarkeit) bei Verwendung von Referenzen, was wir für unseren GPIOA benötigen. Das Option erlaubt es uns, die Variable zunächst leer zu initialisieren und den eigentlichen Wert erst später hineinzubewegen. Da wir nicht statisch, sondern nur zur Laufzeit auf das Peripherie-Singleton zugreifen können, ist dieses Vorgehen erforderlich.
interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));Innerhalb eines kritischen Abschnitts können wir borrow() auf dem Mutex aufrufen, was uns eine Referenz auf die RefCell liefert. Anschließend rufen wir replace() auf, um unseren neuen Wert in die RefCell zu verschieben.
interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});Schließlich verwenden wir MY_GPIO auf sichere und nebenläufige Weise. Der kritische Abschnitt verhindert wie üblich das Auslösen von Interrupts und ermöglicht es uns, den Mutex auszuleihen. Die RefCell liefert uns dann ein &Option<GPIOA> und überwacht die Dauer der Ausleihe; sobald die Referenz ihren Gültigkeitsbereich verlässt, wird der Status der RefCell aktualisiert, um anzuzeigen, dass sie nicht mehr ausgeliehen ist.
Da wir das GPIOA-Objekt nicht aus dem &Option herausverschieben können, müssen wir es mittels as_ref() in ein &Option<&GPIOA> umwandeln. Dieses können wir schließlich mit unwrap() auflösen, um das &GPIOA zu erhalten, das uns den Zugriff zur Modifikation der Peripherieeinheit ermöglicht.
Wenn wir eine veränderbare Referenz auf eine gemeinsam genutzte Ressource benötigen, sollten stattdessen borrow_mut und deref_mut verwendet werden. Der folgende Code zeigt ein Beispiel unter Verwendung des TIM2-Timers.
use core::cell::RefCell;
use core::ops::DerefMut;
use cortex_m::interrupt::{self, Mutex};
use cortex_m::asm::wfi;
use stm32f4::stm32f405;
static G_TIM: Mutex<RefCell<Option<Timer<stm32::TIM2>>>> =
Mutex::new(RefCell::new(None));
#[entry]
fn main() -> ! {
let mut cp = cm::Peripherals::take().unwrap();
let dp = stm32f405::Peripherals::take().unwrap();
// Eine Art Timer-Konfigurationsfunktion.
// Angenommen, sie konfiguriert den TIM2-Timer sowie dessen NVIC-Interrupt
// und startet schließlich den Timer.
let tim = configure_timer_interrupt(&mut cp, dp);
interrupt::free(|cs| {
G_TIM.borrow(cs).replace(Some(tim));
});
loop {
wfi();
}
}
#[interrupt]
fn timer() {
interrupt::free(|cs| {
if let Some(ref mut tim)) = G_TIM.borrow(cs).borrow_mut().deref_mut() {
tim.start(1.hz());
}
});
}
Puh! Das ist zwar sicher, aber auch etwas unhandlich. Können wir sonst noch etwas tun?
RTIC
Eine Alternative ist das RTIC-Framework (kurz für Real Time Interrupt-driven Concurrency). Es erzwingt statische Prioritäten und überwacht Zugriffe auf static mut-Variablen („Ressourcen“), um statisch sicherzustellen, dass auf gemeinsam genutzte Ressourcen stets sicher zugegriffen wird – ohne den Overhead, der durch das ständige Betreten kritischer Abschnitte oder die Verwendung von Referenzzählung (wie bei RefCell) entstünde. Dies bietet eine Reihe von Vorteilen, wie etwa die Garantie von Deadlock-Freiheit sowie einen extrem geringen Zeit- und Speicheraufwand.
RTIC bietet einen asynchronen Executor, sodass Ihre Software-Tasks als async-Funktionen implementiert sind; darin können Sie neben herkömmlichen synchronen APIs auch async-APIs nutzen.
Das Framework umfasst zudem Funktionen wie Message Passing – was den Bedarf an explizit gemeinsam genutztem Zustand (Shared State) verringert – sowie die Möglichkeit, Tasks für einen bestimmten Zeitpunkt zu planen, womit sich beispielsweise periodische Aufgaben realisieren lassen. Weitere Informationen finden Sie in der Dokumentation!
Embassy
Embassy ist ein Ökosystem von Bibliotheken, die sich auf die Nutzung der in Rust enthaltenen async/await-Syntax für Nebenläufigkeit konzentrieren. Das Herzstück von Embassy ist sein asynchroner Executor, der die gängigsten MCU-Architekturen unterstützt.
Embassy verfolgt zudem einen „Batteries-included“-Ansatz und bietet viele weitere Komponenten an, zum Beispiel:
- Zeit-Bibliothek
- Verschiedene HAL-Bibliotheken, die auch Unterstützung für die Zeitbibliothek bieten.
- embassy-sync für Synchronisationsprimitive
Weitere Informationen finden Sie auf der Website und im Buch.
Echtzeitbetriebssysteme
Ein weiteres verbreitetes Modell für Nebenläufigkeit in eingebetteten Systemen ist das Echtzeitbetriebssystem (RTOS). Während diese in Rust bislang weniger stark erforscht sind, kommen sie in der klassischen Entwicklung eingebetteter Systeme häufig zum Einsatz. Zu den Open-Source-Beispielen zählen FreeRTOS und ChibiOS. Solche Echtzeitbetriebssysteme unterstützen die Ausführung mehrerer Anwendungsthreads, zwischen denen die CPU wechselt – entweder wenn die Threads die Kontrolle freiwillig abgeben (kooperatives Multitasking) oder gesteuert durch Timer bzw. Interrupts (präemptives Multitasking). Typischerweise stellen RTOS Mechanismen wie Mutexe und andere Synchronisationsprimitive bereit und interagieren häufig mit Hardwarefunktionen wie DMA-Controllern.
Zum jetzigen Zeitpunkt gibt es noch nicht viele Beispiele für Rust-RTOS, auf die man verweisen könnte; es handelt sich jedoch um ein interessantes Gebiet – bleiben Sie also dran!
Mehrere Kerne
Es wird immer üblicher, eingebettete Prozessoren mit zwei oder mehr Kernen auszustatten, was die Parallelverarbeitung zusätzlich verkompliziert. Alle Beispiele mit kritischen Abschnitten (einschließlich cortex_m::interrupt::Mutex) gehen davon aus, dass der einzige weitere Ausführungsthread der Interrupt-Thread ist. Auf einem Mehrkernsystem trifft dies jedoch nicht mehr zu. Stattdessen benötigen wir Synchronisierungsprimitive, die speziell für Mehrkernsysteme (auch SMP, für symmetrisches Multiprocessing, genannt) entwickelt wurden.
Diese verwenden typischerweise die bereits erwähnten atomaren Befehle, da das Verarbeitungssystem die Atomarität über alle Kerne hinweg gewährleistet.
Eine detaillierte Behandlung dieser Themen würde den Rahmen dieses Buches sprengen, die allgemeinen Muster sind jedoch dieselben wie im Einzelkernfall.