Ein einfacherer und sicherer Nachfolger von C/C++? Rust für Embedded-Systeme

Von Willi Flühmann, Noser Engineering AG *

In der Systemprogrammierung dominiert bislang C/C++. Damit eine fehlerarme und sichere Software zu entwickeln, stellt eine große Herausforderung dar. Die Sprache Rust ist als Nachfolger der einzig ernstzunehmende Kandidat. Sie setzt im Vergleich zu C++ viel konsequenter auf die Vermeidung von Programmierfehlern und das, ohne eine überbordende Komplexität mitzubringen.

Anbieter zum Thema

Die Popularitätvon Rust nimmt zu - und auch für Embedded-Anwendungen hält die junge Programmiersprache einige spannende Features und spezielle Bibliotheken bereit.
Die Popularitätvon Rust nimmt zu - und auch für Embedded-Anwendungen hält die junge Programmiersprache einige spannende Features und spezielle Bibliotheken bereit.
(Logo: Rust Foundation)

Braucht es schon wieder eine neue Programmiersprache? Das kann man sich auch bei der Sprache Rust fragen. Enthusiastische Verfechter einer neuen Technologie sehen bekanntlich vor allem die „coolen“ Features und verlieren gerne das große Ganze aus den Augen.

Doch beim Blick auf gängige Programmiersprachen-Rankings stellt sich heraus, dass es bereits eine Unmenge von Sprachen im breiten Einsatz gibt. Das ist darauf zurückzuführen, dass jede Sprache eine andere Zielgruppe, einen anderen Anwendungsbereich oder andere Qualitätsmerkmale hat. Es gibt also in der Softwareentwicklung durchaus Platz für viele Sprachen.

Im Falle von klassischen Embedded-Systemen geht es vor allem um Sprachen für die Systemprogrammierung, also die Entwicklung von low-level Software mit maximaler Kontrolle über die Ressourcen. Diese Software ist häufig „mission-critical“ und muss manchmal noch spezielle Anforderungen im Bereich Echtzeit oder Safety erfüllen.

Systemprogrammierung

In der Systemprogrammierung dominieren die Sprachen C und C++ schon seit Jahrzehnten. C ist schon fast 50 Jahre alt und bildet schon lange das Rückgrat vieler Systeme. Die gängigen Betriebssysteme, ihre inneren Komponenten und hardwarenahe Treiber wären auch heute noch undenkbar ohne in C geschriebene Software. Auch Embedded-Systeme bilden hier keine Ausnahme. Die Unterstützung für C bildet den kleinsten gemeinsamen Nenner auf allen Plattformen.

Mit der Weiterentwicklung in C++ konnten größere Software und komplexere Konzepte noch besser umgesetzt werden. Dennoch wurde der gesamte Sprachumfang von C übernommen und auch die damit verbundenen Herausforderungen. Es war schon immer so, dass ein C-Programmierer genau wissen muss, was er tut. Sorgfältigkeit und Disziplin sind unverzichtbar, um die vielen gefährlichen Stolperfallen zu umschiffen. Zwar prädestiniert die kaum vorhandene Abstraktion der Prozessorarchitektur und Speicherstruktur die Sprache für die Systemprogrammierung, aber daraus folgt leider auch die Fehleranfälligkeit, weil der Compiler sehr vieles durchgehen lässt.

Die bekannten Probleme von C wie Buffer-Overflows, Dangling-Pointers, Race-Conditions, uninitialisierter Speicher oder undefiniertes Verhalten, stellt man meistens erst zur Laufzeit fest und diese äußern sich häufig zufällig. Dies macht deren Aufdeckung durch das Testen schwieriger. Der Einsatz von C++ anstelle von C hilft nur bedingt, da die neuen Mittel für Abstraktion und Kapselung nur allzu leicht umgangen werden können und die Sprache zudem noch neue Stolperfallen hineinbringt (z.B. unerwünschte Copy-Konstruktoren), die ebenso gefährlich und dazu noch besser versteckt sind.

Bild 1: Vergleich der Komplexität verschiedener Sprachen (Länge der Sprachspezifikation in Seiten).
Bild 1: Vergleich der Komplexität verschiedener Sprachen (Länge der Sprachspezifikation in Seiten).
(Bild: Noser Engineering)

Ein weiteres Problem ist Sprachkomplexität von C++, die mit jeder neuen Version stetig zunimmt und andere Mainstream-Sprachen schon lange hinter sich gelassen hat (siehe Bild 1). Es ist also immer mehr Erfahrung nötig, um C++ zu beherrschen, und genügend Disziplin, um damit fehlerarme Software zu schreiben. In der modernen Welt, wo Software eine immer größere Rolle spielt und kein Weg mehr an Cybersecurity vorbeiführt, ist die Umsetzung von Software mit wenig Fehlern in akzeptabler Zeit essenziell geworden. Es drängt sich also eine Nachfolgersprache für die Systemprogrammierung auf.

Anforderungen an einen Nachfolger von C/C++

Einfache, offensichtliche Bugs können beim Testen leicht entdeckt werden. Schwieriger zu entdeckende Bugs, welche sich eher zufällig äußern, kommen oft aus dem Bereich der Memory-Safety, Thread-Safety oder undefiniertem Verhalten. Gerade hier ist großer Schritt nach vorn nötig. Die Programmiersprache sollte an kritischen Stellen möglichst wenig Fehler zulassen. Symptombekämpfung (Review, Testing, Update) reicht bei schwierigen Bugs nicht mehr.

Da es hier immer noch um Embedded-Systeme und damit um Systemprogrammierung geht, wäre ein High-Level-Ansatz für die Vermeidung schwieriger Bugs verfehlt (z.B. Garbage-Collector, automatische Parallelisierung, starke Abstraktion). Eine zwingend erforderliche Laufzeitumgebung, die Overhead und viele Abhängigkeiten mit sich bringt, passt nicht zu einer hardwarenahen Programmierung von Bare-Metal-Software, wie es bei Embedded-Systemen häufig vorkommt.

Weitere Anforderungen an einen Nachfolger von C/C++ wären natürlich die Unterstützung moderner Paradigmen (u.a. funktional), die einfache Integration mit bestehendem C-Code und das Mitbringen eines reichhaltigen Ökosystems, d.h. Tools, Entwicklungsumgebungen und eine Community.

Seminar-Tipp: Embedded Programmierung mit modernem C++

Im Seminar „Embedded Programmierung mit modernem C++“ erlernen Sie die Vorteile von modernem C++ in der Embedded-Programmierung. Dabei lernen Sie zuerst die Anforderungen an die Embedded-Programmierung und anschließend die Antworten darauf in C++ . Die Anforderungen sind sicherheitskritische Systeme, hohe Performanz, eingeschränkte Ressourcen und mehrere Aufgaben gleichzeitig. Das Seminar wendet sich an jeder, der über Grundkenntnisse in C++ verfügt und diese mit modernem C++ erweitern möchte.

Seminar: Embedded Programmierung mit modernem C++

Kandidaten

Bild 2: Vergleich möglicher Kandidaten eines Nachfolgers für C/C++.
Bild 2: Vergleich möglicher Kandidaten eines Nachfolgers für C/C++.
(Bild: Noser Engineering)

Werden aus dem Programmiersprachen-Ranking des IEEE Spectrum Magazins nur Sprachen für die Systemprogrammierung betrachtet, dann bleiben nur wenige übrig (siehe Bild 2). C und C++ befinden sich übrigens auf Rang 3 und 4, auf den Rängen 1 und 2 befinden sich Python und Java.

Ist die Abwesenheit eines Garbage-Collectors ein zwingendes Kriterium, dann fallen auch noch die Sprachen Go und D aus der Liste heraus. Als einzig ernstzunehmender Kandidat bleibt damit Rust übrig.

Es stellt sich also für die Systemprogrammierung nur die Frage, ob man auf der bewährten C/C++-Schiene bleiben will mit all ihren bekannten Vor- und Nachteilen, oder ob man einen Schritt nach vorn wagt und Rust eine Chance gibt.

Die Programmiersprache Rust

Rust hat vor über 10 Jahren als internes Projekt bei Mozilla begonnen. Sie ist als Systemprogrammiersprache konzipiert, hat also den gleichen Anwendungsbereich wie C/C++. Ein wichtiger Aspekt sind „Zero Cost Abstractions“ und die Transparenz darüber, welche Sprachfeatures mit Overhead verbunden sind. Weiter hat die Sprache einen starken Fokus auf die Korrektheit. Diese Vorliebe für die defensive Programmierung zieht sich überall durch. Das bedeutet zum Beispiel, dass alle Variablen standardmäßig unveränderlich sind (analog zum const bei C/C++), außer es wird explizit anders deklariert. Null-Referenzen sind nicht möglich, sondern werden über einen Option-Typ dargestellt (der übrigens so wenig Overhead wie ein Null-Pointer hat!). Gefährliche Operationen (z.B. Verwendung einfacher Pointer oder Unions), die normalerweise kaum nötig sind, müssen mit dem unsafe-Schlüsselwort markiert werden.

Das bekannteste Alleinstellungsmerkmal von Rust besteht aber darin, dass für alle Daten im Speicher stets bekannt ist, welcher Teil des Codes diese gerade „besitzt“ oder eine Referenz zum Lesen oder Schreiben darauf hat. Damit kann der Compiler durch statische Analyse des Codes garantieren, dass jede Referenz immer auf gültige Daten zeigt und es keine problematischen gleichzeitigen Zugriffe auf die Daten gibt. Der Programmierer kontrolliert immer noch selbst, wie lange die Daten leben und wo sie gelesen und geschrieben werden, während der Compiler prüft, ob dabei etwas falsch gemacht wird.

Die Sprache garantiert also die Memory-Safety und Thread-Safety, ohne auf einen Garbage-Collector angewiesen zu sein. Damit gelingt der einzigartige Spagat zwischen fehlerarmer Programmierung und wenig Overhead zur Laufzeit.

Weiter hat Rust die sinnvollsten Konzepte aus der funktionalen Programmierung und der objektorientierten Programmierung (in Form von Traits, ohne Implementationsvererbung) übernommen. Generics, async/await und Makros (high-level) sind ebenfalls an Bord.

Um Software-Komponenten zu verteilen, steht ein Rust-eigenes Paketformat bereit. Die Pakete werden „Crates“ genannt und können in einem öffentlichen Repository publiziert werden, wo sie auch anderen Entwicklern zur Verfügung stehen. Sind in einem Software-Projekt Abhängigkeiten zu anderen Crates deklariert, dann lädt das Build-System „cargo“ diese automatisch herunter und bindet sie in die eigene Software ein.

Rust kommt mit einer gut strukturierten Standard-Bibliothek, die alles Nötige enthält (Collections, Multithreading, I/O, plattformspezifische Funktionen) und in der Regel statisch zum Programm gelinkt wird. Ein fertiges Programm ist damit auf der Ziel-Plattform eine eigenständige ausführbare Datei, ohne weitere Abhängigkeiten.

Als Mankos von Rust gelten momentan der langsame Compiler und das Fehlen einer formalen Sprachspezifikation. Zudem benötigt der explizite Umgang mit der Lebensdauer von Daten und den damit verbundenen Konzepten Ownership und Borrowing einiges an Einarbeitung.

Rust auf Embedded-Systemen

Der Rust-Compiler basiert auf der LLVM-Infrastruktur. Mit dieser Infrastruktur ist die Unterstützung für viele gängige Plattformen wie ARM, x86, MIPS, PowerPC, RISC-V und WebAssembly möglich. Mit dem Installer „rustup“ können andere Toolchain-Versionen und Unterstützung für weitere Ziel-Plattformen nachinstalliert werden. Das Arbeiten mit einer fix vorgegebenen Compiler-Version und ein Cross-Compiling funktionieren damit auf Anhieb.

Es gibt im Rust-Projekt eine eigene Arbeitsgruppe für Embedded-Systeme, welche u.a. dafür gesorgt hat, dass die Unterstützung für die verschiedenen ARM-basierten Mikrocontroller sehr gut ist und dass es eine standardisierte Abstraktionsschicht für verschiedene Hardware-Plattformen gibt (Crate „embedded-hal“).

Mit der Deklaration von Attributen im Code lassen sich auch grundsätzliche Dinge konfigurieren wie der Verzicht auf die Rust-Standardbibliothek oder die klassische Main-Funktion. Zusammen mit maßgeschneiderten Linker-Scripts ist es damit prinzipiell möglich, neben der Applikation sogar den ganzen Aufstart-Code (Interruptvektor-Tabelle, Initialisierung statischer Variablen, Bootloader, usw.) oder ein Betriebssystem in Rust zu implementieren.

Bild 3: Crates für die Unterstützung eines STM32F3DISCOVERY Boards.
Bild 3: Crates für die Unterstützung eines STM32F3DISCOVERY Boards.
(Bild: Noser Engineering)

Damit man bei der Software-Entwicklung für eine bestimmte Hardware-Plattform nicht bei Null beginnen muss, gibt es für viele Plattformen bereits eine fertige Unterstützung in Form von Crates. Diese Crates sind meist modular aufgebaut, wie in Bild 3 zu sehen ist. So bleibt einem die Wahl, direkt auf die Peripherieregister zuzugreifen oder eine Hardwareabstraktion zu verwenden.

Wer hingegen nicht alles von Grund auf neu in Rust programmieren will, kann seine bestehende C-basierte Toolchain weiterverwenden und den Rust-Code zunächst nur punktuell verwenden und als Bibliothek dazulinken. Rust bietet eine gute Unterstützung, um von C-Code aufgerufen zu werden, bzw. um selbst externen C-Code aufzurufen.

Wenn auf einem Bare-Metal-System die Rust-Standardbibliothek nicht eingebunden ist, kann über eine separate Crate (z.B. „alloc-cortex-m“) die Unterstützung für dynamischen Speicher dennoch hinzugefügt werden. Wer ganz ohne dynamischen Speicher auskommen will, für den ist wiederum die Crate „heapless“ nützlich, welche ein paar Collections implementiert, die auf statisch alloziertem Speicher aufbauen (Memory-Pools).

Wenn Echtzeitprogrammierung ein Thema ist, dann kann ein Rust-Wrapper für FreeRTOS oder das interruptbasierte Echtzeit-Framework RTIC verwendet werden. Direkt in Rust entwickelte Echtzeitbetriebssysteme (Drone OS oder Tock) scheinen noch nicht ausgereift zu sein.

Zusätzlich gibt es noch ganz viele weitere Rust-Features, über die sich Embedded-Entwickler freuen werden, z.B. forciertes Inlining, Beeinflussung des Speicherlayouts von Structs, Volatile-Zugriff auf Daten oder einstellbare Optimierungsstufen des Compilers. Eine Liste an Ressourcen, die sich gezielt an Embedded- und Low-Level-Programming-Anwendungen von Rust richtet, findet sich ein einem speziellen Github-Repository.

Ausblick

Zu Beginn des Jahres 2021 wurde das Rust-Projekt aus Mozilla herausgelöst und lebt nun unter dem Dach der eigens gegründeten Rust-Foundation weiter. Als Gründungsmitglieder sind große Player wie AWS, Google und Microsoft dabei, was große Hoffnungen für die Zukunft macht.

Die Rust-Toolchain bekommt laufend kleinere Updates (momentan liegt die Versionsnumer bei 1.56). Zusätzlich gibt es alle drei Jahre eine neue Rust-Edition, so dass die Sprache sich weiterentwickeln kann, ohne dass die Lauffähigkeit bestehender Programme gefährdet wird (welche für eine ältere Edition geschrieben wurden). Die neueste Rust-Edition 2021 steht gerade in den Startlöchern.

Die Sprache bietet aufgrund ihrer Eigenschaften viele Vorteile für safetykritische Sys-teme. Damit sie dort noch besser Fuß fassen kann, gibt es eine Initiative namens „Ferrocene“ mit dem Ziel, einer formalen Sprachspezifikation und eines zertifizierten Compilers mit Langzeit-Support.

Willi Flühmann, Noser Engineering AG.
Willi Flühmann, Noser Engineering AG.
(Bild: Noser Engineering)

Im Übrigen wird gerne und immer wieder betont, dass Rust in der Stack Overflow Survey seit Jahren die Liste der meistgeliebten Sprachen anführt. Wer also Rust einmal gelernt hat, der wendet sie sehr gerne an.

Der Autor

* Willi Flühmann ist als Software-Architekt bei der Noser Engineering tätig. Als Elektroingenieur mit 19 Jahren Software-Erfahrung blickt er auf zahlreiche Projekte im Embedded- und im Desktop-Bereich zurück. Parallel dazu verfolgt er Themen im Bereich Internet of Things und Security. Momentan ist er Architekt einer Software im Medizinbereich.

Dieser Beitrag stammt mit freundlicher Genehmigung des Autors aus dem Tagungsband des ESE Kongress 2021..

(ID:48026191)