Tipps für Embedded-C-Entwickler
Dieses Kapitel versammelt eine Reihe von Tipps, die für erfahrene Embedded-C-Entwickler nützlich sein können, die mit der Programmierung in Rust beginnen möchten. Dabei wird insbesondere hervorgehoben, wie sich Dinge, die man aus C bereits kennt, in Rust unterscheiden.
Präprozessor
In der Embedded-C-Programmierung ist es sehr üblich, den Präprozessor für eine Vielzahl von Zwecken zu nutzen, wie zum Beispiel:
- Auswahl von Codeblöcken zur Kompilierzeit mittels
#ifdef - Zur Kompilierzeit festgelegte Array-Größen und Berechnungen
- Makros zur Vereinfachung häufiger Muster (um den Overhead von Funktionsaufrufen zu vermeiden)
In Rust gibt es keinen Präprozessor, weshalb viele dieser Anwendungsfälle anders gelöst werden. Im weiteren Verlauf dieses Abschnitts behandeln wir verschiedene Alternativen zur Verwendung des Präprozessors.
Code-Auswahl zur Kompilierzeit
Das Äquivalent zu #ifdef ... #endif in Rust sind Cargo-Features. Diese sind etwas formaler als der C-Präprozessor: Alle möglichen Features werden pro Crate explizit aufgelistet und können entweder aktiviert oder deaktiviert sein. Features werden eingeschaltet, sobald man ein Crate als Abhängigkeit angibt; sie sind zudem additiv: Wenn irgendein Crate im Abhängigkeitsbaum ein Feature für ein anderes Crate aktiviert, ist dieses Feature für alle Nutzer jenes Crates aktiviert.
Angenommen, du hast ein Crate, das eine Bibliothek von Grundbausteinen für die Signalverarbeitung bereitstellt. Jeder dieser Bausteine könnte zusätzliche Kompilierzeit beanspruchen oder eine umfangreiche Tabelle von Konstanten definieren, die du gerne vermeiden möchtest. Du könntest für jede Komponente in deiner Cargo.toml ein Cargo-Feature definieren:
[features]
FIR = []
IIR = []Verwenden Sie dann in Ihrem Code #[cfg(feature="FIR")], um zu steuern, was einbezogen wird.
/// In deiner lib.rs auf oberster Ebene
#[cfg(feature="FIR")]
pub mod fir;
#[cfg(feature="IIR")]
pub mod iir;
Sie können Codeblöcke analog dazu nur dann einfügen, wenn eine Funktion nicht aktiviert ist oder wenn eine beliebige Kombination von Funktionen aktiviert oder deaktiviert ist.
Darüber hinaus bietet Rust eine Reihe automatisch gesetzter Bedingungen, die Sie verwenden können, beispielsweise target_arch, um je nach Architektur unterschiedlichen Code auszuwählen. Ausführliche Informationen zur bedingten Kompilierung finden Sie im Kapitel Bedingte Kompilierung der Rust-Referenz.
Die bedingte Kompilierung bezieht sich nur auf die unmittelbar folgende Anweisung oder den folgenden Block. Kann im aktuellen Gültigkeitsbereich kein Block verwendet werden, muss das cfg-Attribut mehrfach eingesetzt werden. Es sei darauf hingewiesen, dass es meist besser ist, den gesamten Code einzubeziehen und den Compiler bei der Optimierung nicht mehr benötigten Code („Dead Code“) entfernen zu lassen: Dies ist für Sie und Ihre Nutzer einfacher, und der Compiler leistet im Allgemeinen gute Arbeit beim Entfernen von ungenutztem Code.
Größen und Berechnungen zur Kompilierzeit
Rust unterstützt const fn – Funktionen, die garantiert zur Kompilierzeit ausgewertet werden können und sich daher überall dort einsetzen lassen, wo Konstanten erforderlich sind, etwa bei der Größe von Arrays. Dies lässt sich mit den zuvor genannten Funktionen kombinieren, zum Beispiel:
const fn array_size() -> usize {
#[cfg(feature="use_more_ram")]
{ 1024 }
#[cfg(not(feature="use_more_ram"))]
{ 128 }
}
static BUF: [u32; array_size()] = [0u32; array_size()];Diese Funktionen sind seit Version 1.31 in Stable Rust verfügbar, weshalb die Dokumentation noch spärlich ist. Auch der für const fn verfügbare Funktionsumfang ist zum Zeitpunkt der Erstellung dieses Textes stark eingeschränkt; es ist jedoch zu erwarten, dass die in einer const fn zulässigen Operationen in künftigen Rust-Versionen erweitert werden.
Makros
Rust bietet ein äußerst leistungsfähiges Makrosystem. Während der C-Präprozessor fast direkt auf dem Text des Quellcodes arbeitet, operiert das Rust-Makrosystem auf einer höheren Ebene. Es gibt zwei Arten von Rust-Makros: Macros by Example (Makros nach Muster) und prozedurale Makros. Erstere sind einfacher und weiter verbreitet; sie sehen wie Funktionsaufrufe aus und können zu einem vollständigen Ausdruck, einer Anweisung, einem Element oder einem Muster expandiert werden. Prozedurale Makros sind komplexer, ermöglichen jedoch äußerst leistungsfähige Erweiterungen der Sprache Rust: Sie können beliebige Rust-Syntax in neue Rust-Syntax umwandeln.
Wenn Sie normalerweise ein C-Präprozessor-Makro verwenden würden, sollten Sie im Allgemeinen prüfen, ob stattdessen ein „Macro-by-Example“ (Makro-durch-Beispiel) die Aufgabe erfüllen kann. Solche Makros lassen sich in Ihrem Crate definieren und sowohl intern verwenden als auch für andere Nutzer exportieren. Beachten Sie jedoch, dass sie zu vollständigen Ausdrücken, Anweisungen, Elementen (Items) oder Mustern expandieren müssen; bestimmte Anwendungsfälle von C-Präprozessor-Makros funktionieren daher nicht – etwa Makros, die nur einen Teil eines Variablennamens oder unvollständige Listenelemente erzeugen.
Wie bei Cargo-Features lohnt es sich auch hier zu überlegen, ob das Makro überhaupt erforderlich ist. Oftmals ist eine gewöhnliche Funktion leichter verständlich und wird vom Compiler zu demselben Maschinencode expandiert (geinlined) wie ein Makro. Die Attribute #[inline] und #[inline(always)]-Attribute bieten Ihnen zusätzliche Kontrolle über diesen Vorgang, wobei jedoch Vorsicht geboten ist: Der Compiler inlined Funktionen aus demselben Crate ohnehin automatisch, wenn dies sinnvoll ist; ein erzwungenes Inlining in ungeeigneten Fällen kann die Leistung sogar verschlechtern.
Eine Erläuterung des gesamten Rust-Makrosystems würde den Rahmen dieser Tipps-Seite sprengen; für alle Einzelheiten sei daher auf die Rust-Dokumentation verwiesen.
Build System
Die meisten Rust-Crates werden mit Cargo erstellt (auch wenn dies nicht zwingend erforderlich ist). Cargo löst dabei viele der schwierigen Probleme, die mit herkömmlichen Build-Systemen verbunden sind. Dennoch kann es sinnvoll sein, den Build-Prozess individuell anzupassen. Hierfür stellt Cargo build.rs-Skripte bereit. Dabei handelt es sich um Rust-Skripte, die bei Bedarf mit dem Cargo-Build-System interagieren können.
Zu den häufigen Anwendungsfällen für Build-Skripte gehören:
- Informationen zum Build-Zeitpunkt bereitstellen, zum Beispiel durch das statische Einbetten des Build-Datums oder des Git-Commit-Hashs in die ausführbare Datei.
- Linker-Skripte zur Build-Zeit generieren, abhängig von ausgewählten Funktionen oder anderer Logik.
- die Cargo-Build-Konfiguration ändern
- Zusätzliche statische Bibliotheken für den Linkvorgang hinzufügen
Derzeit gibt es keine Unterstützung für Post-Build-Skripte, wie man sie üblicherweise für Aufgaben wie die automatische Erstellung von Binärdateien aus den Build-Objekten oder die Ausgabe von Build-Informationen verwendet hat.
Cross-Kompilierung
Die Verwendung von Cargo als Build-System vereinfacht auch die Cross-Kompilierung. In den meisten Fällen genügt es, Cargo die Option --target thumbv6m-none-eabi mitzugeben und die entsprechende ausführbare Datei unter target/thumbv6m-none-eabi/debug/myapp zu finden.
Für Plattformen, die nicht nativ von Rust unterstützt werden, müssen Sie libcore für das jeweilige Zielsystem selbst kompilieren. Auf solchen Plattformen lässt sich Xargo als Ersatz für Cargo verwenden, da es libcore automatisch für Sie erstellt.
Iteratoren vs. Array-Zugriff
In C sind Sie es wahrscheinlich gewohnt, direkt über den Index auf Arrays zuzugreifen:
int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
process(arr[i]);
}In Rust ist dies ein Anti-Muster: Indexzugriffe können langsamer sein (da eine Bereichsprüfung erforderlich ist) und verschiedene Compiler-Optimierungen verhindern. Dieser Unterschied ist wichtig und sollte wiederholt werden: Rust prüft bei manueller Array-Indizierung auf Zugriffe außerhalb der Grenzen, um Speichersicherheit zu gewährleisten, während C problemlos auf Bereiche außerhalb des Arrays zugreift.
Verwenden Sie stattdessen Iteratoren.
let arr = [0u16; 16];
for element in arr.iter() {
process(*element);
}Iteratoren bieten eine Vielzahl leistungsstarker Funktionen, die Sie in C manuell implementieren müssten, wie z. B. Verkettung, Zipping, Aufzählung, Minimum- und Maximumsuche, Summierung und vieles mehr. Iteratormethoden lassen sich ebenfalls verketten, was zu sehr lesbarem Code für die Datenverarbeitung führt.
Weitere Informationen finden Sie unter Iteratoren im Buch und Iterator-Dokumentation.
Referenzen vs. Zeiger
In Rust gibt es zwar Zeiger (sogenannte Raw Pointer), diese werden jedoch nur unter bestimmten Umständen verwendet, da ihre Dereferenzierung stets als unsafe gilt – Rust kann nämlich nicht die üblichen Garantien darüber geben, was sich hinter dem Zeiger befindet.
Meistens verwenden wir stattdessen Referenzen (gekennzeichnet durch das Symbol &) oder veränderbare Referenzen (gekennzeichnet durch &mut). Referenzen verhalten sich ähnlich wie Zeiger, da sie dereferenziert werden können, um auf die zugrundeliegenden Werte zuzugreifen; sie sind jedoch ein wesentlicher Bestandteil des Ownership-Systems von Rust: Rust stellt strikt sicher, dass zu jedem Zeitpunkt entweder nur eine veränderbare Referenz oder mehrere unveränderbare Referenzen auf denselben Wert existieren dürfen.
In der Praxis bedeutet dies, dass man genauer abwägen muss, ob man veränderbaren Zugriff auf Daten benötigt: Während in C Veränderbarkeit der Standard ist und const explizit angegeben werden muss, verhält es sich in Rust genau umgekehrt.
Eine Situation, in der man dennoch „Raw Pointer“ (rohe Zeiger) verwenden könnte, ist die direkte Interaktion mit Hardware (zum Beispiel beim Schreiben eines Zeigers auf einen Puffer in ein DMA-Peripherieregister); zudem kommen sie im Hintergrund bei allen Crates für den Peripheriezugriff zum Einsatz, um das Lesen und Schreiben speicherabgebildeter Register (memory-mapped registers) zu ermöglichen.
Volatiler (unsicherer) Zugriff
In C können einzelne Variablen mit volatile gekennzeichnet werden, um dem Compiler mitzuteilen, dass sich ihr Wert zwischen den Zugriffen ändern kann. Volatile Variablen werden häufig in eingebetteten Systemen für im Speicher abgebildete Register verwendet.
In Rust verwenden wir anstelle der Kennzeichnung einer Variable als volatile spezielle Methoden für den Zugriff auf unsichere Daten: core::ptr::read_volatile und core::ptr::write_volatile. Diese Methoden akzeptieren einen *const T oder einen *mut T (Rohzeiger, wie oben beschrieben) und führen einen flüchtigen Lese- bzw. Schreibvorgang durch.
In C könnten Sie zum Beispiel schreiben:
volatile bool signalled = false;
void ISR() {
// Signalisieren, dass der Interrupt aufgetreten ist
signalled = true;
}
void driver() {
while(true) {
// Schlafen bis zum Signal
while(!signalled) { WFI(); }
// Signalisierten Indikator zuruecksetzen
signalled = false;
// Fuehren Sie eine Aufgabe aus, die auf den Interrupt gewartet hat
run_task();
}
}
Das Äquivalent in Rust würde bei jedem Zugriff volatile Methoden verwenden:
static mut SIGNALLED: bool = false;
#[interrupt]
fn ISR() {
// Signalisieren, dass eine Unterbrechung aufgetreten ist
// (Im realen Code sollten Sie eine hoeherwertige primitive Datenstruktur,
// wie z. B. einen atomaren Datentyp, in Betracht ziehen).
unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}
fn driver() {
loop {
// Schlafen bis zum Signal
while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
// Signalisierten Indikator zuruecksetzen
unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
// Fuehren Sie eine Aufgabe aus, die auf den Interrupt gewartet hat
run_task();
}
}
Am Codebeispiel sind einige Dinge erwähnenswert:
- Wir können
&mut SIGNALLEDan die Funktion übergeben, die*mut Terwartet, da&mut Tautomatisch in*mut Tumgewandelt wird (und das Gleiche gilt für*const T). - Für die Methoden
read_volatileundwrite_volatilebenötigen wirunsafe-Blöcke, da es sich umunsafe-Funktionen handelt. Es liegt in der Verantwortung des Programmierers, für eine sichere Verwendung zu sorgen; weitere Einzelheiten sind der Dokumentation der jeweiligen Methoden zu entnehmen.
Es ist selten erforderlich, diese Funktionen direkt im eigenen Code zu verwenden, da sie üblicherweise von höherwertigen Bibliotheken für Sie übernommen werden. Bei speicherabgebildeten Peripheriekomponenten implementieren die entsprechenden „Peripheral Access Crates“ den volatilen Zugriff automatisch, während für Nebenläufigkeits-Primitive bessere Abstraktionen zur Verfügung stehen (siehe das Kapitel Nebenläufigkeit).
Gepackte und ausgerichtete Datentypen
In der Embedded-Programmierung mit C ist es üblich, dem Compiler vorzugeben, dass eine Variable eine bestimmte Ausrichtung (Alignment) aufweisen oder eine Struktur „gepackt“ (packed) statt ausgerichtet sein muss – meist, um spezifische Hardware- oder Protokollanforderungen zu erfüllen.
In Rust wird dies über das repr-Attribut an einer struct oder union gesteuert. Die Standarddarstellung macht keine Zusagen über das Speicherlayout und sollte daher nicht für Code verwendet werden, der mit Hardware oder C interagiert. Der Compiler kann die Reihenfolge der Strukturmitglieder ändern oder Füllbytes (Padding) einfügen, und das Verhalten kann sich in zukünftigen Rust-Versionen ändern.
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2
// Die Reihenfolge der Noten wurde auf x, z, y geaendert, um die Packdichte zu
// verbessern.
Um Layouts zu gewährleisten, die mit C interoperabel sind, verwenden Sie repr(C):
#[repr(C)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
// Die Reihenfolge bleibt erhalten und das Layout veraendert sich im Laufe der
// Zeit nicht.
// `z` ist auf Zwei-Byte-Grenzen ausgerichtet, sodass sich zwischen `y` und `z`
// ein Padding-Byte befindet.
Um eine kompakte Darstellung zu gewährleisten, verwenden Sie repr(packed):
#[repr(packed)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
// Referenzen muessen stets korrekt ausgerichtet sein; um also die
// Adressen der Struct-Felder zu ueberpruefen, verwenden wir
// `std::ptr::addr_of!()`, um einen Rohzeiger (Raw Pointer) zu erhalten,
// anstatt einfach `&v.x` auszugeben.
let px = std::ptr::addr_of!(v.x);
let py = std::ptr::addr_of!(v.y);
let pz = std::ptr::addr_of!(v.z);
println!("{:p} {:p} {:p}", px, py, pz);
}
// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// Zwischen `y` und `z` wurde kein Padding eingefuegt, daher ist `z` nun
// nicht ausgerichtet.
Beachten Sie, dass die Verwendung von repr(packed) die Ausrichtung des Typs ebenfalls auf 1 setzt.
Um eine bestimmte Ausrichtung festzulegen, verwenden Sie schließlich repr(align(n)), wobei n die Anzahl der auszurichtenden Bytes ist (und eine Zweierpotenz sein muss).
#[repr(C)]
#[repr(align(4096))]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
let u = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
}
// 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
// 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
// Die beiden Instanzen `u` und `v` wurden an 4096-Byte-Grenzen ausgerichtet,
// was an den `000` am Ende ihrer Adressen erkennbar ist.
Beachten Sie, dass wir repr(C) mit repr(align(n)) kombinieren können, um ein ausgerichtetes und C-kompatibles Layout zu erhalten. Die Kombination von repr(align(n)) mit repr(packed) ist nicht zulässig, da repr(packed) die Ausrichtung auf 1 setzt. Ebenso ist es nicht zulässig, dass ein repr(packed)-Typ einen repr(align(n))-Typ enthält.
Weitere Details zu Typ-Layouts finden Sie im Kapitel Typ-Layout der Rust-Referenz.