Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 19 - Hardware-Sicht: Unsafe Rust und FFI unter der Lupe von CPU und RAM

Hallo Thorsten! Nachdem wir uns mit der Syntax von unsafe und den Integrationsmustern mit C beschäftigt haben, werfen wir jetzt einen Blick auf die physische Realität.

Als Systemprogrammierer gibst du dich nicht mit der Erklärung „Es ist unsicher“ zufrieden. Du willst wissen: Wie sieht ein roher Zeiger im Register der CPU aus? Was bedeutet extern "C" auf der Ebene des Stack Pointers? Und wie optimiert der Compiler den Code unter der Annahme, dass kein undefiniertes Verhalten existiert?

Schnapp dir einen Kaffee – wir steigen tief in die Hardware- und ABI-Ebene ein!


1. Rohe Zeiger und der virtuelle Adressraum

Auf Hardware-Ebene gibt es keinen Unterschied zwischen einer sicheren Referenz (&T) und einem rohen Zeiger (*const T). Beide sind im Wesentlichen nichts anderes als eine 64-Bit-Zahl (auf modernen 64-Bit-CPUs), die eine physische Speicheradresse im virtuellen Adressraum des Prozesses speichert.

Der Unterschied im CPU-Register:

Wenn Sie eine sichere Referenz nutzen, garantiert der Compiler, dass die Adresse im Register immer auf gültigen RAM zeigt. Bei einem rohen Zeiger lädt die CPU die Adresse blind in ein Adressregister (z. B. rax oder rbx) und führt einen Speicherzugriffsbefehl (z. B. MOV) aus:

MOV EAX, [RCX] ; Lese Wert an der Adresse, die in RCX steht

Wenn die Adresse in RCX nun 0 (Null) oder eine ungültige Adresse außerhalb des dem Prozess zugewiesenen Adressraums ist, fängt die Memory Management Unit (MMU) der CPU den Zugriff ab. Sie signalisiert dem Kernel einen Hardware-Interrupt (Page Fault). Das Betriebssystem beendet Ihr Programm daraufhin sofort mit einem Segmentation Fault (Speicherzugriffsfehler).


2. Calling Conventions (ABIs) und Register-Belegungen

Wenn Sie eine externe C-Funktion über FFI aufrufen (extern "C"), müssen Rust und C sich darauf einigen, wie Parameter übergeben werden. Dies regelt das Application Binary Interface (ABI), genauer gesagt die Calling Convention (Aufrufkonvention).

Auf x86_64-Systemen unter Linux/macOS gilt das System V AMD64 ABI. Es schreibt vor:

  1. Register-Übergabe: Die ersten sechs ganzzahligen Parameter werden direkt in den CPU-Registern in dieser Reihenfolge übergeben:
    • rdi (1. Parameter)
    • rsi (2. Parameter)
    • rdx (3. Parameter)
    • rcx (4. Parameter)
    • r8 (5. Parameter)
    • r9 (6. Parameter)
  2. Stack-Nutzung: Ab dem 7. Parameter müssen die Werte auf den Stack gelegt werden.
  3. Rückgabewert: Der Rückgabewert der Funktion wird im Register rax hinterlegt.

Wenn Sie extern "C" schreiben, generiert der Rust-Compiler Maschinencode, der sich exakt an diese Registerbelegungen hält. Passt das ABI nicht zusammen (z. B. weil die C-Funktion ein anderes ABI als Rust erwartet), liest die CPU die Parameter aus den falschen Registern – Ihr Programm stürzt ab oder verarbeitet Müllwerte.


3. Undefiniertes Verhalten (UB) und LLVM-Optimierungen

Der Rust-Compiler nutzt LLVM im Backend zur Code-Optimierung. LLVM optimiert den Maschinencode unter einer strikten Prämisse: Es wird davon ausgegangen, dass im Code niemals undefiniertes Verhalten (UB) auftritt.

Tritt es doch auf, kann LLVM absurden Maschinencode generieren. Ein bekanntes Beispiel betrifft das Aliasing (zwei Zeiger zeigen auf denselben Speicher).

In sicherer Rust-Umgebung garantiert das Typsystem, dass ein veränderlicher Zeiger exklusiven Zugriff hat. LLVM nutzt diese Information:

#![allow(unused)]
fn main() {
// Der Compiler geht davon aus, dass 'a' und 'b' NIEMALS auf dieselbe Adresse zeigen!
unsafe fn optimierungs_beispiel(a: &mut i32, b: &i32) -> i32 {
    *a = 10;
    let wert = *b; // Da 'a' und 'b' nicht überlappen, muss 'b' nicht neu aus dem RAM gelesen werden!
    wert
}
}

LLVM optimiert die Funktion so, dass *b direkt aus dem CPU-Register gelesen wird, anstatt einen langsamen RAM-Zugriff durchzuführen. Wenn Sie nun über unsafe die Regeln brechen und dafür sorgen, dass a und b doch auf dieselbe Adresse zeigen, liefert die Funktion zur Laufzeit einen veralteten Wert zurück, da LLVM den echten Speicherzugriff wegoptimiert hat!


4. Debugging mit Miri

Da solche Fehler zur Laufzeit extrem schwer zu finden sind, steht uns der MIR-Interpreter Miri zur Verfügung. Miri führt Ihren Code in einer virtuellen Sandbox aus und überwacht jede Speicheradresse auf Bit-Ebene.

Installation und Ausführung:

rustup component add miri
cargo miri test

Miri erkennt sofort:

  • Aliasing-Verletzungen (Verstoß gegen das Stacked-Borrows-Modell).
  • Use-After-Free (Zugriff auf deallokierten Speicher).
  • Lesezugriffe auf uninitialisierten Speicher.