Mit dem Kernel von C nach Rust – ein Projektbericht zu Effizienz und Sicherheit

Systemnahe Programmierung erfordert eine effiziente und sichere Sprache. Der Erfahrungsbericht beschreibt den Wechsel eines Kernelprojekts von C nach Rust.

In Pocket speichern vorlesen Druckansicht 49 Kommentare lesen

(Bild: Matt Antonino/Shutterstock.com)

Lesezeit: 14 Min.
Von
  • Dr. Stefan Lankes
Inhaltsverzeichnis

Rust ist den vergangenen Jahren immer populärer geworden, und viele Projekte wechseln zu der noch jungen, als sehr sicher geltenden Programmiersprache, sogar der Linux-Kernel verwendet es in Teilen. Der Android-Hersteller Google hat analysiert, dass die Hauptursache für Android-Schwachstellen die fehlerhafte Verwendung von Speicher ist. Zwar existieren andere Programmiersprachen, die gegenüber C die Sicherheit erhöhen, aber im Bereich der systemnahen Programmierung scheint Rust am verbreitetsten zu sein.

Stefan Lankes

(Bild: 

Fotostudio Jünger

)

Stefan Lankes ist Akademischer Direktor am Institute for Automation of Complex Power Systems der RWTH Aachen. Im Rahmen seiner Tätigkeit bietet er Vorlesungen und Praktika im Bereich der systemnahen Programmierung an. Zudem forscht er im Bereich der Systemsoftware für das Hochleistungsrechnen und des Cloud Computings

Das hat einen Grund: Rust bietet ein sicheres, effizientes Speichermodell an, das aber auf Garbage Collection, wie es vbeispielsweise von Java eingesetzt wird, verzichtet. Garbage Collection verursacht Overhead zur Laufzeit, da es nicht länger benötigte Speicherbereiche im Betrieb identifiziert und freigibt. Ziel einer Systemsoftware ist jedoch hohe Effizienz, die zusätzlichen Overhead vermeiden will. Rusts Weg ist in diesem Punkt geeigneter, denn es bestimmt zur Kompilierzeit, welche Bereiche wann freizugeben sind. Hierzu führt Rust die Konzepte Ownership (Besitz) und Borrowing (Ausleihen) ein, mit denen immer nur ein Besitzer das Recht hat, ein Objekt freizugeben. Aber durch das Erstellen von Referenzen lässt sich das Objekt verleihen. Dieses Konzept setzt voraus, dass dem Compiler der gesamte Quellcode zur Analyse vorliegt. Ansonsten kann er nicht garantieren, dass es nicht weitere Referenzen gibt und folglich mehrere Besitzer existieren.

Immer wieder finden sich Beispiele, wo Rust Sicherheitslücken vermieden hätte. Beim Aufdecken eines Heap-Overflows im Programm curl stellte dessen Autor Daniel Stenberg zum Beispiel fest, fest, dass das Problem mit Rust nicht aufgetreten wäre, aber eine Umstellung von C nach Rust eine Mammutaufgabe bedeuten würde. Damit hat er völlig recht. Deswegen möchte ich von unseren Erfahrungen beim Wechsel von C nach Rust in unserem systemnahen Kernel-Projekt berichten und hoffe, dass andere damit besser evaluieren können, ob ein Umstieg für ihr Projekt sinnvoll ist.

Ausgegangen sind wir vom Betriebssystem HermitCore, das wir im Jahr 2015 in C geschrieben hatten. HermitCore war eine Mischung aus einem Multikernel und einem Unikernel. Bei einem Multikernel läuft auf jedem Prozessorkern ein eigener Betriebssystemkern. So würden bei einem Achtkern-Prozessor acht unabhängige Betriebssystemkerne laufen, die sich nicht gegenseitig stören. Solche Konzepte wurden u.a. im Bereich des Hochleistungsrechnens verwendet, um Seiteneffekte zu vermeiden und so die Leistung zu erhöhen.

Ein Unikernel stellt ein Library Operating System dar, das den Kernel fest an eine Anwendung bindet und direkt auf der Hardware bootet. Solche Systeme können nur ein Programm mit seinen Threads verwalten, Multi-Processing ist nicht möglich. Library Operating Systems werden häufig in virtualisierten Umgebungen eingesetzt, in denen nur eine Anwendung in einer virtuellen Maschine (VM) läuft. Dies schont Ressourcen und mehrere VMs können gleichzeitig arbeiten. Ein typischer Vertreter dieser Gattung ist Unikraft. HermitCore fand im Bereich des technisch-wissenschaftlichen Rechnens seinen Einsatz.

Geschrieben haben wir HermitCore in Assembler und C, der dominierenden Sprache für systemnahe Programmierung. Dennis Ritchie, Ken Thompson und Brian W. Kernighan hatten sie schließlich genau dafür erfunden. Die Nachteile waren uns bekannt, aber getreu dem Konzept "Never change a running system" rüttelten wir anfangs nicht daran.

Struktur des Unikernels HermitCore (Abb. 1)

(Bild: Stefan Lankes)

Zum Kernel lieferten wir Systembibliotheken mit, um Anwenderinnen und Anwendern später einen leichten Einstieg zu ermöglichen. Wie die Abbildung 1 verdeutlicht, benötigen Programmierer zum Entwickeln eines einfachen Web-Servers schon eine C Library, eine pthread-Library, einen IP-Stack und einen Cross-Compiler. OpenMP und C++-Support wären auch wünschenswert. Da wir als Kernelentwickler nicht die Anwendungsschicht neu erfinden wollten, griffen wir auf existierende Projekte zurück: zum Beispiel Newlib als C Library, LwIP als IP Stack und die GNU Compiler.

Das alles sind etablierte, aber auch alte Projekte, die teilweise unterschiedliche Build-Systeme (make, automake, cmake, …), Konfigurationsarten und Programmiersprachen verwendeten. Die Maintainer pflegten die verschiedenen Tools mehr oder weniger per Hand. Gab es zum Beispiel eine neue Version, aktualisierten die Betreiber die Git-Repositories, und wir testeten sie mit dem Kernel. Aus heutiger Sicht wäre eine Automatisierung dieser Schritte möglich, aber angesichts der Größe und des Alters der Projekte wäre das keine triviale Aufgabe.

Die Motivation von C zu Rust zu wechseln, entstand eher aus Frust über das Build-System als aus dem Wunsch, eine sichere Programmiersprache zu verwenden. Dies klingt naiv, aber Programmiererinnen und Programmierer reden sich gerne ein, dass ihr Code korrekt ist, und sie sind eher über das Drumherum frustriert. Nach kurzer Evaluierung fiel 2017 die Wahl auf Rust. Ein Grund dafür war die saubere Trennung zwischen betriebssystemabhängigen und -unabhängigen Teilen. Der grüne Block in Abbildung 2 zeigt die Core-Bibliothek von Rust. Sie enthält den betriebssystemunabhängigen Teil und kann keinen Speicher anfordern, da das typischerweise die Unterstützung des Betriebssystems braucht.

Das heißt, dass wir alle dynamischen Datenstrukturen nicht in der Core-Bibliothek realisieren konnten, sondern in die Alloc-Bibliothek auslagerten (gelb), die eine Speicherverwaltung voraussetzt. Beide gehören zu den Standard-Bibliotheken (libstd, blau) und werden für diese mit Thread-, Prozesserzeugung, Primitive zur Interprozesskommunikation und Synchronisation ergänzt. Typischerweise schreiben Entwickler die betriebssystemabhängigen Teile mithilfe der externen C-Library des verwendeten Betriebssystems, bei Linux also in der Regel der GNU C Library (rot).

Aufbau der Standard-Bibliotheken (Abb. 2)

(Bild: Stefan Lankes)

Eine Stärke von Rust ist das Konzept der Crates. Wie die deutsche Übersetzung Kiste oder Schachtel verdeutlicht, wird darin Software beim Ausliefern in logische Einheiten verpackt. Konkret handelt es sich um eine Menge von Dateien, die Rust zusammen kompiliert hat und die sich von anderen Komponenten importieren lassen. Das Erstellen und Verwenden solcher Kisten ist eng mit dem Paketmanager Cargo verwoben.

Er verlangt beim Bauen eines Programmes oder eines Crates eine Spezifikation mit einer Liste von Abhängigkeiten in der Datei Cargo.toml. Meist stehen die Abhängigkeiten ebenfalls als Crate mit Quellcode online zur Verfügung und sind zudem bei Crates.io registriert. Cargo wird den Quellcode aller Abhängigkeiten herunterladen, kompilieren und anschließend zum Programm binden. Das gilt nicht nur für einfache Programme, sondern auch für die Rust-Standard-Bibliothek, die wir nicht nur für den Bau des Kernels benötigten, sondern auch als Ausstattung zu der späteren Anwendung. Über Crates haben wir die Schnittstelle zwischen Rusts Standard-Bibliothek und unserem Unikernel HermitOS ("Rusty Hermit"), dem Nachfolger des HermitCore, integriert (rot in Abbildung 3: hermit-abi).

Der folgende Ausschnitt aus der Spezifikationsdatei der Rust Standard-Bibliothek verdeutlicht, dass beim Target (Zielbetriebssystem für den Compiler) hermit das Crate hermit-abi eingebunden wird, das die Schnittstelle zum Unikernel darstellt. Hierzu registrierten wir unseren Target-Namen beim Rust-Projekt, sodass der Rust-Compiler Objektcode für HermitOS erzeugen konnte. Zudem hinterlegten wir den Quellcode der hermit-abi-Schnittstelle zum Kernel bei Crates.io, sodass beim Erzeugen der Standard-Bibliothek der Quellcode automatisch heruntergeladen und mitgebaut wird. Die Zeile [target.'cfg(target_os = "hermit")'.dependencies] spezifiziert, dass die nachfolgenden Zeilen nur für HermitOS gültig sind.

[package]
name = "std"
version = "0.0.0"
license = "MIT OR Apache-2.0"
repository = "https://github.com/rust-lang/rust.git"
description = "The Rust Standard Library"
edition = "2021"

[target.'cfg(target_os = "hermit")'.dependencies]
hermit-abi = { version = "0.3", features = ['rustc-dep-of-std'] }

Wie die Abbildung 3 verdeutlicht, bindet das Crate hermit-abi den Kernel ein. Dieser verwendet wieder die Core- und die Alloc-Bibliothek, da beide Bibliotheken auf derselben Code-Basis aufbauen. Im Unterschied zu der Standard-Bibliothek libstd wird im Kernel auch die Speicherverwaltung realisiert. Dies bedeutet, dass libhermit seine Speicherverwaltung bei der libstd registriert. Durch diese Vorgehensweise ist der Kernel sehr ähnlich zu einem normalen Rust-Programm aufgebaut. Es verwendet die gleichen Datenstrukturen, wie z.B. Vektoren und Warteschlangen.

RustyHermit Integration (Abb. 3)

(Bild: Stefan Lankes)

Wie die Abbildung 3 auch verdeutlicht, kann der Kernel wiederum von anderen Crates abhängen, Beispiele sind smoltcp (IP-Stack) und x86_64 (Interrupt Controllers). Die Wiederverwendung von Code aus anderen Projekten vereinfacht die Kernel-Entwicklung erheblich. Diese Pakete dürfen allerdings nicht wiederum von der Rust Standard-Bibliothek abhängen, da sonst eine gegenseitige Abhängigkeit entstünde.

Zudem besteht die Architektur aus Abbildung 3 nur aus quelloffenem Rust-Code, und der Compiler könnte ihn theoretisch entsprechend komplett analysieren. Allerdings würden über hermit-abi Rusts Standard-Bibliotheken abhängig vom Kernel und dessen Abhängigkeiten (x86_64, smoltcp) werden. Solche Szenarien will die Rust Community vermeiden, um nicht das ganze Projekt von Crates abhängig zu machen, die eventuell von ihren Maintainern nicht mehr gepflegt werden.

Daher sieht das Hermit-Konzept vor, den Kernel und seine Abhängigkeiten als statische Bibliothek anzulegen und später an die Anwendung anzubinden. Die Funktionen für diese Bindung verwenden die C Calling Convention, die definiert, wie C-Funktionen ihre Argumente und Rückgabewerte übergeben. Durch die C Calling Convention kann binär-kompatibilität garantiert werden, und der Kernel kann Rust-fremde Laufzeitumgebungen unterstützen. So ist mit der C Calling Convention doch noch etwas C in unserem Projekt übrig geblieben.