Da wir im Nachgang zu unserem Vortrag zusammen mit dem VDE Bayern am 25.07.2024 oft auf die Folien angesprochen wurden, haben wir die Inhalte des Vortrags hier noch einmal zusammengefasst.


Einführung

In der Softwareentwicklung ist es leider alltäglich, dass Sicherheitslücken durch unsicheren Umgang mit Speicher entstehen. Dies zeigt sich besonders deutlich in der Verbreitung von Memory-Safety-Vulnerabilities (MSVs), welche einen erheblichen Teil der Sicherheitslücken in Software ausmachen. Statistiken von führenden Technologieunternehmen verdeutlichen dies:

Diese Zahlen unterstreichen die Notwendigkeit von Programmiersprachen, die Speicherfehler proaktiv verhindern. Memory-Safety ist oft der entscheidende Faktor zwischen einer sicheren und einer gefährdeten Software. Die Wahl einer sicheren Programmiersprache kann viele dieser Probleme bereits an ihrem Ursprung beheben.

Diagramm, das zeigt, dass 80 % der gängigen Schwachstellen durch Rust verhindert werden.

Die Vergangenheit

Die unsichere und unschöne Vergangenheit eingebetteter Systeme.

Historisch gesehen basieren viele eingebettete Systeme auf der Programmiersprache C. Obwohl C eine leistungsfähige und weit verbreitete Sprache ist, bringt sie erhebliche Herausforderungen mit sich. Die Komplexität von C führt häufig dazu, dass Entwickler jahrelange Erfahrung benötigen, um sicheren und stabilen Code zu schreiben. Dies stellt insbesondere im Kontext des aktuellen Fachkräftemangels ein großes Problem dar.

Das Resultat dieser Situation sind oft unsichere und unzuverlässige Systeme. Viele dieser Systeme sind anfällig für schwerwiegende Sicherheitslücken, was in einer zunehmend vernetzten Welt zu erheblichen Risiken führt.

Um diesen Risiken zu begegnen, haben Gesetzgeber auf der ganzen Welt begonnen, Maßnahmen zu ergreifen. Der EU Cyber Resilience Act und die Maschinenverordnung der EU sind nur zwei Beispiele dafür, wie Regierungen auf diese Bedrohungen reagieren. Auch das Weiße Haus drängt auf die Nutzung sicherer Programmiersprachen.

Einige Regierungen und Organisationen haben erkannt, dass Programmiersprachen mit Memory-Safety der Schlüssel sind, um die häufigsten Sicherheitslücken zu vermeiden.

"The clear north star for security against memory corruption exploits is the broad usage of Memory Safe Languages [...] Anything less is playing the whack-a-mole of exploit mitigation."
— CISA, U.S. Cybersecurity & Infrastrucure Agency

Die Zukunft eingebetteter Systeme

Natürlich mit Rust.

Bei der Systemscape GmbH liegt der Fokus auf Softwareentwicklung und Systems Engineering mit besonderer Ausrichtung auf Rust. Rust ist mehr als nur eine Programmiersprache; sie ist ein modernes Werkzeug, das Entwickler befähigt, zuverlässige und effiziente Software zu erstellen. Viele der Probleme, die in der Vergangenheit durch C verursacht wurden, können mit Rust behoben werden.

Rust bietet eine moderne, stark typisierte Syntax, die es ermöglicht, Code von sehr hoher Qualität zu schreiben. Zudem gibt es keine Garbage Collection, was Rust besonders effizient und geeignet für eingebettete Systeme macht. Das hervorragende Tooling und der hilfreiche Compiler machen es Entwicklern leicht, sicheren Code zu schreiben. Besonders hervorzuheben ist das Ownership-Modell von Rust, welches Speichersicherheit "by Design" sicherstellt.


Was ist Rust?

Rust hat sich in den letzten Jahren als die beliebteste Programmiersprache unter Entwicklern etabliert. Seit 2016 steht Rust kontinuierlich an der Spitze, während über die Hälfte der C- und C++-Nutzer angibt, dass sie C(++) lieber nicht weiter verwenden würden. Dies spiegelt die Herausforderungen und Frustrationen wider, die mit älteren Programmiersprachen einhergehen, und hebt die Vorteile von Rust hervor.

Diagram, welches die Beliebtheit von Rust im Laufe der Zeit illustriert. Rust ist konstant äußerst beliebt, wohingegen die Beliebtheit von C++ sinkt.

Wer nutzt Rust und wofür?

Rust wird bereits von vielen etablierten Unternehmen genutzt, darunter:

Diese Unternehmen verwenden Rust sowohl für Low-Level-Anwendungen wie Bootloader und Gerätetreiber als auch für High-Level-Anwendungen wie Netzwerkdienste und Web-Apps. Ein beeindruckendes Beispiel ist Cloudflares neuer HTTP Proxy "Pingora", der täglich über 1 Billion Anfragen verarbeitet.

Wie sieht Rust aus?

Die Syntax von Rust ist modern und ausdrucksstark. Ein Beispiel für Enums und Pattern-Matching zeigt, wie Rust durch starke Typisierung und klare Syntax hilft, Fehler zu vermeiden und gleichzeitig leistungsstarken Code zu schreiben:

pub enum StringOrInt { A(String), B(u8) }

fn foo(val: StringOrInt, optional: Option<u8>) {
    match val {
        StringOrInt::A(s) => println!("String: {}", s),
        StringOrInt::B(i) => println!("Int: {}", i),
    }
    match optional {
        Some(i) => println!("Some: {}", i),
        None => println!("None!"),
    }
}

fn main() {
    foo(StringOrInt::A("Hello World".to_string()), Some(42));
    foo(StringOrInt::B(123), None);
}

Dieses Beispiel verdeutlicht, wie Rust Entwicklern ermöglicht, komplexe Logik auf einfache und sichere Weise auszudrücken.


Sicherheit und Zuverlässigkeit

Rust sorgt durch Memory Safety für mehr Sicherheit, indem potenzielle Speicherfehler bereits beim Kompilieren verhindert werden. Das Ownership- und Borrowing-Modell von Rust stellt sicher, dass jeder Wert einen klaren Besitzer hat, der für die Lebensdauer des Wertes im Speicher verantwortlich ist. Dadurch werden Fehler wie "Use After Free" oder "Double Free" vermieden. Zusätzlich gibt es strenge Regeln für Referenzen: Entweder gibt es nur eine mutable Referenz (&mut x), oder es können unendlich viele immutable Referenzen (&x) existieren. Dies verhindert sogenannte "data races", die zu unvorhersehbarem Verhalten führen können.

Zur Laufzeit prüft Rust weiterhin auf häufige Fehler wie Array-Längenüberschreitungen und Zugriffe auf ungültige Werte (None oder Err). Auch Überläufe bei Ganzzahlen werden im Debug-Modus erkannt und abgefangen.

Beispiel: Ownership

Das folgende Beispiel zeigt, wie Rust das Ownership-Modell durchsetzt. Wird ein Wert "gemoved", ist der ursprüngliche Wert danach nicht mehr verfügbar:

fn main() {
    let x = String::from("Hello World!");
    let y = x;
    println!("x: {}", x);
}

Nach dem "Move" von x nach y verhindert Rust den Zugriff auf x, da es nicht mehr gültig ist. Dies schützt vor den häufigsten Fehlern, die in anderen Sprachen auftreten.

Der Zugriff führt dann zu folgender Fehlermeldung: Screenshot des zugehörigen Rust Compilerfehlers

Thread Safety

Explizit statt implizit.

Rust verlangt, dass Datentypen explizit als threadsicher gekennzeichnet werden, was durch die beiden Marker-Traits Send und Sync erreicht wird:

Im Gegensatz dazu gibt es Typen, wie den Reference-Counted Pointer (Rc), die Send nicht implementieren und daher nicht sicher zwischen Threads verschoben werden können. Der folgende Code zeigt ein Beispiel dafür:

let rc = std::rc::Rc::new(42); // Reference Counted Pointer
// error[E0277]: `Rc<i32>` cannot be sent between threads safely
std::thread::spawn(move || { println!("rc: {}", rc); });

Der Rust-Compiler verhindert hier die Verwendung von Rc zwischen Threads, da dieser Typ nicht Send ist und daher nicht threadsicher.

Unsafe Code

Kontrollierte Unsicherheit.

Obwohl Rust ein hohes Maß an Sicherheit bietet, gibt es Situationen, in denen potentiell unsicherer Code notwendig ist. Derartige Operationen müssen jedoch explizit als unsafe gekennzeichnet werden. Hier ein Beispiel, in welchem ein slice RAM "aus dem Nichts" erzeugt wird, was bei späterer Verwendung potentiell fatale Folgen haben kann:

// Example: Access arbitrary memory address
let ram_ptr: *mut u32 = sdram.init() as *mut _; // e.g. 0xC0000000
// Unsafe! You should know what you're doing...
let ram_slice = unsafe {
  // Convert raw pointer to slice
  core::slice::from_raw_parts_mut(ram_ptr, length_in_bytes)
};

// Use like ordinary Rust slice / array. Safe from here!
info!("RAM contents: {:x}", ram_slice[..]);

Durch unsafe wird darauf hingewiesen, dass hier potentiell unsichere Operationen stattfinden und Vorsichtsmaßnahmen getroffen werden sollten. Dementsprechend wird durch Rust sichergestellt, dass der unsichere Code leicht auffindbar und isoliert bleibt, sodass er nicht versehentlich in anderen Teilen des Programms verwendet wird.

Multi-core Nebenläufigkeit

Für Multi-Core-Systeme bietet Rust sichere und effiziente Möglichkeiten zur Nebenläufigkeit. Der Einsatz von atomaren referenzgezählten Zeigern (Arc) in Kombination mit Mutex stellt sicher, dass Daten sicher zwischen mehreren Threads geteilt werden können:

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter); // Get a copy of the pointer that can be passed to the thread
    let handle = thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle);
}

for handle in handles {
    handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());

Dieses Beispiel zeigt, wie Rust sicherstellt, dass mehrere Threads sicher auf eine gemeinsame Ressource zugreifen können, ohne dass es zu Datenkorruption kommt.

Typen und Hardware-Eigenschaften

Rust ermöglicht es, Hardware-Eigenschaften durch Typen exakt abzubilden. Ein Beispiel ist die Verwendung von Traits, um bestimmte Funktionen nur für spezifische Hardware-Komponenten verfügbar zu machen:

pub trait ClkPin { // Marker trait
    fn enable(&mut self) {
        println!("Enable ClkPin!");
    }
}
struct PA9; // Pin PA9
impl ClkPin for PA9{} // Mark PA9 as clock pin

// This function only works for Clock Pins
fn take_clk_pin(mut pin: impl ClkPin){
    pin.enable(); // Output: "Enable ClkPin!"
}

fn main() {
    take_clk_pin(PA9);
}

In diesem Beispiel wird sichergestellt, dass nur Pins, die als ClkPin gekennzeichnet sind, in der Funktion take_clk_pin verwendet werden können. Dies erhöht die Zuverlässigkeit, indem der Compiler überprüft, ob der richtige Typ verwendet wird und es keine unangenehmen Überraschungen zur Laufzeit gibt.


Performance ohne Kompromisse

Rust ermöglicht durch Zero-cost Abstractions, auf einem hohen Abstraktionsniveau zu programmieren, ohne Performance-Einbußen hinnehmen zu müssen. Dies ist besonders in eingebetteten Systemen wichtig, wo sowohl Effizienz als auch Sicherheit von größter Bedeutung sind.

Ein Beispiel zeigt, wie dies in der Praxis aussehen kann:

struct SPI1; // No data -> size = 0 bytes -> optimized away by compiler

fn spi1_driver(spi: SPI1) {
    // Freely manipulate any SPI1 registers, nobody else controls SPI1
}

// In practice: obtained from HAL, e.g., `hal.peripherals.SPI1`
spi1_driver(SPI1);

// This won't work. No other driver can be created from the same SPI instance!
spi1_driver(SPI1); // error[E0382]: use of moved value: `spi`

In diesem Codebeispiel wird ein leerer Struct SPI1 verwendet. In einem echten Szenario würde dieser Struct von der Hardware Abstraction Layer (HAL) bereitgestellt werden. Beispielsweise gibt es für STM32-Chips wie z.B. den STM32F469 das Projekt stm32f4xx-hal. Dadurch wird sichergestellt, dass nur ein SPI1-Objekt existieren kann, wodurch versehentliche doppelte Zugriffe auf die gleiche Hardware vermieden werden.

Single-core Nebenläufigkeit

Neben der Performance bietet Rust auch Lösungen für Nebenläufigkeit auf Single-Core-Prozessoren. Mit Rust lassen sich Aufgaben asynchron abarbeiten, was besonders in eingebetteten Systemen nützlich ist.

#[task]
async fn blinky(led: PB7) {
    let mut led = Output::new(led, Level::High, Speed::Low);
    loop {
        led.toggle();
        Timer::after_millis(300).await;
    }
}

#[main]
async fn main(spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let mut button = ExtiInput::new(p.PC13, p.EXTI13, Pull::None);
    spawner.spawn(blinky(p.PB7));
    loop {
      button.wait_for_rising_edge().await;
    }
}

Dieses Beispiel zeigt, wie eine einfache LED-Blinker-Anwendung mit Rust realisiert werden kann. Der Code verwendet Asynchronität, um die CPU effizient zu nutzen, ohne komplexe Threading-Modelle oder Interrupts zu benötigen.


Produktivitätssteigerung

Rust bietet eine Vielzahl von Werkzeugen, die Entwicklern helfen, effizienter und produktiver zu arbeiten. Diese Tools machen es einfach, qualitativ hochwertigen Code zu schreiben und zu verwalten, ohne dass dabei die Sicherheit beeinträchtigt wird.

Compiler und Toolchain Manager

Der Rust-Compiler rustc bietet nicht nur leistungsstarke Optimierungen, sondern auch hilfreiche Fehlermeldungen, die es Entwicklern ermöglichen, Probleme schnell zu identifizieren und zu beheben. Mit dem Toolchain-Manager rustup lassen sich verschiedene Rust-Versionen und Zielarchitekturen verwalten, was besonders für Cross-Kompilierung wichtig ist.

Ein Beispiel zeigt, wie einfach es ist, die Entwicklungsumgebung für verschiedene Plattformen einzurichten:

$ rustup toolchain install 1.79.0-x86_64-apple-darwin # for macOS
$ rustup target add thumbv7em-none-eabihf # e.g. Cortex-M4F, Cortex-M7F (FPU)

Pakete, Formatierung, Dokumentation

Zusätzlich zu rustc und rustup bietet Rust eine Reihe weiterer nützlicher Tools, wie zum Beispiel den Paketmanager cargo. cargo ermöglicht es, Projekte zu bauen, zu formatieren und zu dokumentieren. Einheitliche Codeformatierung wird mit rustfmt bzw. cargo fmt erreicht, und HTML-Dokumentation kann aus Docstrings mit rustdoc bzw. cargo doc erstellt werden.

Um ein Projekt zu kompilieren und auszuführen, reicht folgendes Kommando:

cargo run

Ein Beispiel für eine einfache Cargo.toml-Konfigurationsdatei:

# "Cargo.toml" project file
[dependencies]
cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] }

Mit diesen Tools lässt sich der gesamte Entwicklungsprozess in Rust effizienter und sicherer gestalten.

Die HTML-Basierte Dokumentation sieht beispielsweise so aus: Screenshot von Dokumentation, die mittels rustdoc erzeugt wurde

Als Interaktives Beispiel könnte mitunter die Dokumentation der Rust Standardbibliothek dienen.

Tools für Embedded

Für die Embedded-Entwicklung gibt es spezielle Tools, die Rust besonders gut unterstützen:

$ probe-rs run --chip STM32F469NIHx target/thumbv7em-none-eabi/release/myapp
$ cargo run # Alternative with config in .cargo/config.toml
defmt::error!("This is very bad!");
// Output:
0.000000 ERROR  This is very bad!
└─ myapp::____embassy_main_task::{async_fn#0} @ src/bin/myapp.rs:100

Aktuelle Herausforderungen

... und wann Rust nicht die beste Wahl ist.

Trotz der vielen Vorteile von Rust gibt es auch einige Herausforderungen, die berücksichtigt werden sollten.

Komplexität und Lernkurve

Das Verständnis des Typensystems sowie des Ownership-Modells ist notwendig, um effizient in Rust zu arbeiten. Dies kann anfangs überwältigend wirken, besonders für Entwickler, die von weniger strikten Sprachen wie C oder Python kommen. Rust setzt hohe Maßstäbe für den geschriebenen Code und der Compiler agiert eher als strenger Wächter denn als stiller Helfer:

error [E0277]: `Rc<i32>` cannot be sent between threads safely

Auch wenn dies den Lernprozess auf den Ersten Blick erschwert, führt es letztendlich zu sichererem und robusterem Code.

Interoperabilität

Die Integration von Rust mit bestehendem C/C++-Code kann mühsam sein. Es gibt wenig offiziellen Support durch Chiphersteller und Entwickler müssen zwei Sprachen beherrschen, um komplexe Projekte umzusetzen. Auch gibt es einige Treiber, die trotz aktiver Community noch nicht in Rust verfügbar sind.

Dennoch können dank Rusts Foreign Function Interface (FFI) auch C Bibliotheken aufgerufen werden, sodass immer die Möglichkeit besteht, auf bestehenden C-Code aufzubauen.

Wann Rust nicht die beste Wahl ist

Es gibt Fälle, in denen Rust möglicherweise nicht die beste Wahl ist. Dies ist zum Beispiel der Fall, wenn spezifische Treiber nur in C/C++ verfügbar sind oder wenn bestimmte Zertifizierungen notwendig sind, wie sie im Automobilbereich, der Luftfahrt oder im Militär gefordert werden.


Praxisbeispiele

Rust wird bereits in einer Vielzahl von Embedded-Systemen eingesetzt und hat sich als vielseitige und zuverlässige Sprache etabliert:

Diese Beispiele zeigen, dass Rust nicht nur eine theoretische Option ist, sondern in der Praxis bereits weit verbreitet und erfolgreich eingesetzt wird. Immer wieder dringen Informationen über große Firmen durch, die ebenfalls Rust Embedded verwenden, aber nicht sehr viel darüber sprechen.


Rust lernen / Unterstützung

Für das Lernen von Rust stehen viele Ressourcen zur Verfügung:

Falls professionelle Unterstützung erforderlich ist, bieten verschiedene Anbieter Trainings und Beratung an, darunter Ferrous Systems GmbH und Tweede Golf B.V.. Auch die Systemscape GmbH bietet maßgeschneiderte Trainings und Entwicklungsdienstleistungen für Embedded Rust an.


Abschluss

Rust in a Nutshell

Rust liefert:

Für die Entwicklung bedeutet das:


Nächste Schritte

Um mehr über Rust zu erfahren und direkt in die Praxis einzusteigen, empfehlen sich folgende Schritte:

  1. Die Folien herunterladen und die wichtigsten Punkte noch einmal durchgehen.
  2. Die LinkedIn Seite von Systemscape abonnieren, um über die neuesten Entwicklungen und Angebote informiert zu bleiben.
  3. Unsere Einführung in Embedded Rust lesen und ein Beispiel ausprobieren, um direkt in die Praxis einzutauchen.
  4. Sichere und zuverlässige eingebettete Systeme mit Rust entwickeln.

Kontakt

Bei Fragen und Anmerkungen bzw. Lob und Kritik stehen wir jederzeit gerne zur Verfügung.

Systemscape GmbH