Aus Linux-Magazin 11/2022

Rust-Programmierung für den Linux-Kernel

© gioiak2 / 123RF.com

Wegen seiner garantierten Speichersicherheit ist Rust ein heißer Kandidat für den Linux-Kernel, doch nicht nur deswegen. Rust schließt zahlreiche Fehler per se aus, was die Entwicklungsproduktivität steigert.

Der Code des Linux-Kernels wurde in einer Vielzahl von Programmiersprachen erstellt. Mit einem Anteil von deutlich über 90 Prozent aller Dateien ist C momentan unangefochtener Spitzenreiter. Daneben existiert jedoch Code in Sprachen wie Assembler, Python oder Perl; hinzu kommen Makefiles, Shell-Skripts und Konfigurationsdateien in verschiedenen Formaten. Das Projekt Rust for Linux [1] verfolgt das Ziel, dem Kernel Rust als weitere Sprache hinzuzufügen. Erst kürzlich erklärte Linus Torvalds während des Open Source Summits der Linux Foundation im texanischen Austin, dass er in naher Zukunft mit dem Merge von Rust-Code im Linux-Kernel rechnet.

Bedeuten diese Ankündigungen, dass es in absehbarer Zeit einen in Rust geschriebenen Linux-Kernel geben wird? Keinesfalls, denn das wirkt angesichts des Codeumfangs des Linux-Kernels unrealistisch. Das Ziel liegt vielmehr darin, künftige Kernel-Entwicklungen mit Rust zu ermöglichen. Dafür gilt es einerseits, Rust in den Compile-Prozess des Kernels zu integrieren, und andererseits muss es eine gute und stabile Schnittstelle zwischen dem C-Code des Linux-Kernels und Rust geben. Genau hier setzt das Team von Rust for Linux an. Falls Sie sich für mehr Details über den Aufbau der Rust-Integration in Linux interessieren, finden Sie weiterführende Informationen zum Beispiel im Vortrag “Rust for Linux” von Miguel Ojeda and Wedson Almeida Filho [2], der im Rahmen des Rust Linz Meetups [3] stattfand.

Es gibt gute Gründe dafür, Linux um Rust-Unterstützung zu erweitern. Dieser Artikel geht genau darauf ein und gibt einen Überblick über die wichtigsten Rust-Funktionen. Zudem klärt er, warum ihre Features die Programmiersprache zu einem guten Werkzeug für Erweiterungen des Linux-Kernels machen.

Rust und Speichersicherheit

Beginnen wir mit der wichtigsten Eigenschaft von Rust, die es für den Linux-Kernel so besonders spannend macht: garantierte Speichersicherheit. Viele Studien haben gezeigt, dass Fehler im Bereich der Speicherverwaltung die mit Abstand häufigste Quelle für Sicherheitslücken in Software sind. Probleme wie Use After Free, Double Free und Memory Leaks fürchten alle C-Entwickler. Rust macht damit Schluss.

Dazu greift die Programmiersprache aber nicht wie Go, Java oder C# auf verwalteten Speicher (Managed Memory) mit all seinen Nachteilen in Sachen Garbage Collection zurück. Rust gewährleistet Speichersicherheit, obwohl es sich nicht um eine verwaltete Sprache mit Garbage Collector handelt – ein enormer Vorteil beim Einsatz im Kern eines Betriebssystems. Listing 1 demonstriert, wie Rust Speichersicherheit garantiert. Dieses Codebeispiel [4] sowie alle folgenden finden Sie im Rust Playground und können sie selbst ausprobieren.

Listing 1

Speichersicherheit

#![allow(dead_code)]
#[derive(Clone, Debug)]
struct Vector2d {
  x: f64,
  y: f64,
}
fn main() {
  // Allocate resizeable array (called a Vector in Rust) on heap and
  // print its content as well as a pointer to it on stdout.
  let numbers = vec![1, 2, 3, 5, 8];
  println!("The array contains {:?} and is stored at {:p}", numbers, numbers.as_ptr());
  // The next line MOVES the OWNERSHIP of the array from numbers to
  // other_numbers.
  let other_numbers = numbers;
  println!("{:?}", other_numbers);
  // Now we cannot access numbers anymore because value was MOVED.
  // println!("{:?}", numbers); // -> does NOT COMPILE
  // If we copy the vector, no move of ownership happens.
  // Therefore, we can still access both vectors.
  let cloned_numbers = other_numbers.clone();
  println!("clone = {:?}, source = {:?}", cloned_numbers, other_numbers);
  // Let's try the same with a custom data structure.
  // Let's put a 2D vector on the heap (Box::new allocates the 2D
  // vector on the heap instead of the stack).
  let vec_on_heap = Box::new(Vector2d { x: 1.0, y: 2.0 });
  // Moving ownership again
  let other_vec = vec_on_heap;
  // println!("{:?}", vec_on_heap); // -> does NOT COMPILE. We can
  // clone the 2D Vector because it auto-implements the Clone trait.
  let yet_another_vec = other_vec.clone();
  println!("{:?} at {:p}", yet_another_vec, yet_another_vec);
  println!("{:?} at {:p}", other_vec, other_vec);
}

Den wichtigsten Aspekt des Codebeispiels stellt das Ownership-Prinzip dar. Auf dem Heap allokierter Speicher besitzt immer genau einen Owner. Diese Regel prüft der Compiler bereits während der Übersetzung. Verstoßen Sie im Code dagegen, kommt es nicht zu einem Laufzeit-, sondern zu einem Compilerfehler.

In Listing 1 ist die Variable »numbers« der initiale Owner des Vektors mit Integer-Zahlen. Durch Zuweisen von »numbers« zu »other_numbers« entstehen nicht plötzlich zwei Referenzen auf denselben Speicherbereich. Stattdessen erfolgt eine Übergabe der Ownership. Die ursprüngliche Variable »numbers« lässt sich nicht mehr verwenden, da sie nicht mehr Owner ist. Zwar besteht die Möglichkeit, Speicher zu klonen. Das führt allerdings zu zwei getrennt allokierten Speicherbereichen, die jeweils einen Owner aufweisen.

Die Ownership-Regeln gelten nicht nur innerhalb einer Funktion, sondern auch über Funktionsaufrufe hinweg. Allokiert eine Funktion beispielsweise Speicher auf dem Heap und gibt ihn zurück, geht die Ownership an den Aufrufer der Funktion über. Umgekehrt verliert ein Aufrufer die Ownership über Speicher, sobald dieser an eine aufgerufene Funktion übergeben wird.

Da Rust bereits beim Kompilieren genau weiß, wer gerade der Owner eines Speicherbereichs ist, kann es sich automatisch um das Freigeben von Speicher kümmern. Verlassen Sie den Scope des Owners, gibt es den zugeordneten Speicher frei. Als Entwickler kann man das nicht vergessen, womit Memory Leaks der Vergangenheit angehören.

Borrowing

Gäbe es nur das Ownership-Prinzip, wäre Rust funktional viel zu stark eingeschränkt, um in der Praxis nützlich zu sein. Es braucht eine Möglichkeit, Speicher temporär weiterzugeben, ohne die Ownership zu verlieren. Genau dazu dient Borrowing: Hier verleihen Sie beim Aufrufen einer Funktion Speicher, auf den die aufgerufene Funktion lesend oder schreibend zugreifen kann.

Das Codebeispiel aus Listing 2 [5] veranschaulicht das Prinzip. Die Methode »main()« verleiht einen Vector einmal immutable (»calculate_sum«) und einmal mutable (»add_and_sum«). In beiden Fällen bleibt »numbers« in »main()« Owner des auf dem Heap allokierten Speicherbereichs.

Listing 2

Borrowing

fn main() {
  let mut numbers = vec![1, 2, 3, 5, 8];
  println!("The sum is {}", calculate_sum(&numbers)); \
    // Passes reference, keeps ownership
  println!("The sum is {}", add_and_sum(&mut numbers)); \
    // Mutable reference, keeps ownership
  println!("{:?}", numbers);
}
fn calculate_sum(numbers: &[i32]) -> i32 {
  // Numbers si read-only borrowed, cannot be mutated
  // numbers.push(42);  // -> does NOT COMPILE
  let sum: i32 = numbers.iter().sum();
  sum
}
fn add_and_sum(numbers: &mut Vec<i32>) -> i32 {
  numbers.push(42);
  calculate_sum(numbers)
}

Lifetimes

Neben Ownership und Borrowing bilden Lifetimes das dritte Konzept im Fundament der sicheren Speicherverwaltung von Rust. Das Codebeispiel aus Listing 3 [6] setzt Lifetimes in Zusammenhang mit der Datenstruktur »Friends« ein. Sie enthält Referenzen auf zwei Instanzen der Struktur »Hero«. Durch Angabe der Lifetimes bei »Friends« verhindern Sie in Rust eine Freigabe der referenzierten »Hero«-Objekte, solange noch eine Instanz »Friend« existiert, die auf sie verweist.

Listing 3

Lifetimes

struct Hero {
  name: String,
}
// Note that embedded Heroes have
// a LIFETIME. That means that an
// instance of Friends CANNOT
// outlive the referenced Heroes.
struct Friends<'a, 'b> {
  hero_1: &'a Hero,
  hero_2: &'b Hero,
}
fn main() {
  let batman = Box::new(Hero {
    name: String::from("Batman"),
  });
  let batman_and_robin;
  {
      let robin = Box::new(Hero {
      name: String::from("Robin"),
    });
    // The following variable
    // references batman and robin
    batman_and_robin = Friends {
      hero_1: &batman,
      hero_2: &robin,
    };
  // Robin goes out of scope here
  // while batman_and_robin still
  // exists -> Problem because we
  // would have a dangling pointer
  // to robin. -> Does NOT work,
  // COMPILE TIME ERROR.
  }
  println!(
    "{} and {} are friends",
    batman_and_robin.hero_1.name, batman_and_robin.hero_2.name
  );
}

Im Codebeispiel zeigt »robin« auf einen Speicherbereich, der innerhalb eines Codeblocks allokiert wird, der am Ende »robin« freigibt. Innerhalb des Codeblocks legen Sie eine Referenz auf »robin« im Objekt »batman_and_robin« ab, das jedoch über den Codeblock hinaus existiert, in dem »robin« sich befindet. Diese Kombination würde zu einem Use-after-Free-Fehler führen. Rust erkennt das und lässt Sie den Code daher nicht kompilieren.

Um den Fehler zu beseitigen, müssen Sie sicherstellen, dass »robin« mindestens so lange lebt wie »batman_and_robin«. Das erreichen Sie beispielsweise, indem Sie den getrennten Codeblock entfernen und den gesamten Code in der Methode »main« belassen.

Unsafe Code

Gerade bei systemnaher Programmierung lässt sich die Notwendigkeit für das direkte Manipulieren von Speicher oder Pointern nicht verhindern. Rust-Code können Sie dafür als »unsafe« markieren. Die zuvor beschriebenen Speicherverwaltungsregeln gelten in »unsafe«-Codeblöcken nicht, was Ihnen in solchen Codeabschnitten mehr Möglichkeiten einräumt als in Safe Rust. Listing 4 [7] zeigt exemplarisch, wie Pointer-Arithmetik in Rust funktioniert.

Listing 4

Pointer-Arithmetik

fn main() {
  let num1 = 42;
  let num2 = 43;
  let mut r = &num1 as *const i32;
  // Pointer arithmetic is only
  // possible in an unsafe block.
  // Calling offset outside of an
  // unsafe block leads to a
  //  compile time error because
  // offset is an unsafe function.
  unsafe {
    r = r.offset(1);
    // Following statement shows a
    // value of 43 for '*r'.
    println!("num1: {num1}\nnum2: {num2}\nr: {}", *r);
  }
}

Für den Einsatz von Rust im Linux-Kernel ist es wichtig, als »safe« und »unsafe« deklarierten Rust-Code klar zu trennen. Dadurch reduziert sich der Aufwand bei Code Reviews deutlich. Als »safe« markierter Code enthält garantiert keine Speicherfehler. So bleibt mehr Zeit für ausführlichere Reviews des »unsafe«-Codes.

Möchten Sie mehr über die Verwendung von Unsafe Rust zur Entwicklung sicherer Rust-Abstraktionen für den Linux-Kernel erfahren, sollten Sie sich den Vortrag “Rust For Linux: Writing Safe Abstractions & Drivers” [8] ansehen.

Zero-Cost Abstractions

Rust gehört zu den funktionsreichen Programmiersprachen. Damit unterscheidet sie sich deutlich von der im Container-Umfeld bekannten und beliebten Programmiersprache Go, die ganz bewusst auf Minimalismus setzt. Für systemnahe Programmierung spielt dabei eine große Rolle, dass Rust das Prinzip von Zero-Cost Abstractions verfolgt. Dabei stehen Ihnen mächtige Abstraktionen zur Verfügung, die Sie zur Laufzeit jedoch nicht mit ineffizienterem Code bezahlen. Trotzdem wird Rust nie gänzlich die Performance von sorgfältig bis ins Kleinste optimiertem C- oder gar Assembler-Code erreichen. In der Praxis hält die Sprache allerdings in Sachen Performance durchaus mit C++ und sogar mit üblichem C-Code mit.

Alle Sprachfunktionen Rusts vollständig zu beschreiben würde den Rahmen eines Artikels hoffnungslos sprengen, zu diesem Thema gibt es dicke Bücher. Wir picken uns veranschaulichend eine Rust-Datenstruktur heraus, an der Sie den Funktionsreichtum von Rust und Zero-Cost Abstractions speziell im Vergleich zu C gut erkennen können: den Enumerated Type »Option<T>«.

Enumerated Types

Enumerated Types (kurz Enum) kennen Sie vermutlich bereits aus C oder anderen Sprachen. Es handelt sich um benutzerdefinierte Datentypen, die definierte Werte annehmen können. Intern werden Enums in C durch Integer-Werte repräsentiert. Listing 5 demonstriert eine einfache Enum in C.

Ein großer Nachteil von Enums in C und anderen Sprachen wie C# besteht darin, dass der Compiler nicht explizit definierte Werte zulässt. Im Beispiel aus Listing 5 könnten wir mithilfe eines Type Casts »h« den Wert »3« zuweisen. Der Compiler würde keinen der »case«-Zweige im Statement »switch« ausführen und uns auch nicht warnen, dass wir den Zweig »default« vergessen haben.

Listing 5

Enum in C

#include <stdio.h>
enum hero { BATMAN, ROBIN };
int main() {
  hero h = BATMAN;
  switch (h) {
    case BATMAN:
      printf("Batman (%d)\n", h);
      break;
    case ROBIN:
      printf("Robin (%d)\n", h);
      break;
  }
  return 0;
}

Rust Enums

In Rust bieten Enums viel mehr Funktionen als in C. Sehen wir uns dazu zunächst Listing 6 an [9], das inhaltlich in etwa dem zuvor gezeigten Enum-Beispiel in C entspricht.

Der erste wesentliche Unterschied liegt darin, dass Sie in Rust der Variablen »h« keinen Wert außer »Hero::Batman« und »Hero::Robin« zuweisen können. Der Rust-Compiler weiß also ganz genau, welche Werte »h« annehmen kann, und klopft Ihnen auf die Finger, falls Sie im Ausdruck »match« eine Ausprägung von »Hero« vergessen und keinen Default-Wert angeben. Das Verhalten von Rust erschwert so das Entstehen undefinierter Zustände.

Listing 6

Enum in Rust

enum Hero {
  Batman,
  Robin,
}
fn main() {
  let h = Hero::Batman;
    println!(
    "We have {} ({:?})",
    match h {
      Hero::Batman => "Batman",
      Hero::Robin => "Robin",
    },
    h as i32
  );
}

Als zweite Besonderheit können die Varianten einer Enum in Rust Parameter haben. Als Beispiel fügen wir dem Code aus Listing 6 einige Zeilen hinzu, um das Batmobil zu modellieren. Einer der Helden kann es fahren, oder es ist autonom unterwegs. Listing 7 enthält den Code, der eine solche Datenstruktur repräsentiert [10].

Listing 7

Batmobil modellieren

#[derive(Debug)]
enum Hero {
  Batman,
  Robin,
}
enum BatmobileDriver {
  Some(Hero),
  Nobody,
}
fn main() {
  let m = BatmobileDriver::Some(Hero::Batman);
  println!(
    "Batmobile is driven by {}",
    match m {
      BatmobileDriver::Some(driver) => format!("{driver:?}"),
      BatmobileDriver::Nobody => "nobody".to_string(),
    }
  );
}

Der dritte, große Unterschied von Enums in Rust besteht darin, dass sie genauso wie Strukturen generisch sein, Funktionen enthalten und Traits (ähnlich Interfaces in anderen Programmiersprachen) implementieren können. Sie sehen das am Enum »Hero« aus Listing 7: Er bindet den Trait »Debug« ein, damit Sie die Werte des Enum für Debug-Ausgaben in ihre Textrepräsentation umwandeln können.

Die Option-Enum in Rust

Enums in Rust gehen also weit über das hinaus, was Sie vielleicht aus anderen Sprachen kennen. Es verwundert daher kaum, dass Enums das Fundament für wichtige Themen wie optionale Rückgabewerte und die Rückgabe von Fehlerzuständen bilden. Wir fokussieren uns auf optionale Rückgabewerte, da sich an ihnen besonders schön die Idee von Zero-Cost Abstractions beschreiben lässt.

Für optionale Werte sieht Rust in der Standardbibliothek den Enum »Option<T>« vor. Er funktioniert wie »BatmobileDriver« aus Listing 7. Tatsächlich können Sie den Code vereinfachen, indem Sie »BatmobileDriver« durch »Option« ersetzen, wie Listing 8 [11] demonstriert.

Listing 8

<Option>

#[derive(Debug)]
enum Hero {
  Batman,
  Robin,
}
fn main() {
  let m: Option<Hero> = Some(Hero::Batman);
  println!(
    "Batmobile is driven by {}",
    match m {
      Some(driver) => format!("{driver:?}"),
      None => "nobody".to_string(),
    }
  );
}

Besonders interessant wird es, wenn Sie sich das Layout von Rust-Enums im Speicher ansehen. In Listing 9 [12] liefert die Methode »get_hero()« als Rückgabewert entweder einen auf dem Stack allokierten »Hero« oder nichts, falls die übergebene »hero_id« unbekannt ist. Der optionale Rückgabewert wurde mithilfe des Enum »Option« modelliert. Wie sähe so etwas in C aus?

Listing 9

Rust-Enums im Speicher

#[derive(Debug)]
struct Hero {
  name: &'static str,
}
const BATMAN: u8 = 1;
const ROBIN: u8 = 2;
// Get hero based on hero id
fn get_hero(hero_id: u8) -> Option<Box<Hero>> {
  match hero_id {
    BATMAN => Some(Box::new(Hero { name: "Batman" })),
    ROBIN => Some(Box::new(Hero { name: "Robin" })),
     _ => None, // Return None in case of unknown hero id
  }
}
fn main() {
  // Get Batman and print debug representation
  println!("{:?}", get_hero(BATMAN));
  // Get Robin and use 'unwrap' to remove 'Option'.
  println!("{}", get_hero(ROBIN).unwrap().name);
  // If we try to get an unknown hero, we will get 'None'.
  println!("{:?}", get_hero(3));
  // The next line would panik because None cannot be unwrapped.
  // println!("{:?}", get_hero(3).unwrap());
  // This time, unwrapping works as we provide a default value.
  const UNKNOWN: Hero = Hero { name: "Unknown" };
  println!("{}",get_hero(3).unwrap_or_else(||Box::new(UNKNOWN)).name);
  // Internally, an Option<Box<T>> is like a pointer that can be Null.
  // In case of None, the pointer is null. In case of Some, the
  // pointer points to the hero. Therefore, we can reinterpret the
  // bits of Option<Box<T>> to be a pointer to a hero in unsafe Rust.
  unsafe {
    let h: Option<Box<Hero>> = get_hero(BATMAN);
    // Here the reinterpretation of the bits happens:
    let h: &Hero = std::mem::transmute(h);
    println!("{:?}", *h);
  }
}

Eine speichereffiziente Möglichkeit gäbe einen Pointer auf eine Instanz »Hero« zurück, der »NULL« ist, falls es keinen Rückgabewert gibt. Tatsächlich führt der Rust-Compiler »Option<Box<T>>« genau auf einen solchen Mechanismus zurück. Die Größe von »Option<Box<T>>« entspricht deswegen der von »Box<T>«.

Das Prinzip, dass Entwickler in Rust intelligente Abstraktionen wie »Option« nutzen können, ohne dafür zur Laufzeit mit Effizienzverlust bezahlen zu müssen, heißt Zero-Cost Abstractions – ein wesentliches Merkmal von Rust.

Fazit

Die Programmiersprache Rust genießt in Entwicklerkreisen zu Recht steigende Popularität. Sie bietet einen großen Funktionsreichtum, ohne bei systemnaher Programmierung ähnlich an Performance einzubüßen wie die üblichen Sprachen. Der Rust-Compiler zeichnet sich durch umfangreiche Logikprüfungen aus, die undefinierte Zustände und Laufzeitfehler weitgehend vermeiden. Auch die garantierte Speichersicherheit von Rust-Code trägt zur steigenden Beliebtheit der Sprache bei.

Alle diese Faktoren haben dazu geführt, dass Rust auch für die Entwicklung des Linux-Kernels stetig an Relevanz gewinnt. Das Projekt Rust for Linux ermöglicht, Module und Treiber in Zukunft in Rust zu schreiben. Das gibt zur Hoffnung Anlass, dass dadurch die Sicherheit des Linux-Kernels weiter steigt. Gleichzeitig verspricht es, die Entwicklungsproduktivität zu fördern, da Rust viele Fehler von Haus aus vermeidet. (csi/jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben