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

Praxisteil & Übungen: Unsafe Rust und FFI (C-Anbindung)

In diesem Praxisteil wagen wir uns in Bereiche vor, in denen Rust seine strikte Kontrolle lockert, um mit der Außenwelt zu kommunizieren. Wir lernen, wie wir C-Bibliotheken anbinden und die Schnittstelle zwischen sicherem Rust-Code und unsicherem Fremdcode (Foreign Function Interface, FFI) sauber kapseln.

Wir werden ein praxisnahes Szenario durchspielen: Wir binden eine in C geschriebene mathematische Bibliothek zur Berechnung von Fakultäten (factorial) ein, kompilieren sie automatisch über eine Cargo-Build-Schnittstelle (build.rs) und schreiben einen absolut sicheren, idiotensicheren Rust-Wrapper darum herum.


1. Didaktische Analogien zur Veranschaulichung

Um Unsafe Rust und FFI richtig zu verstehen, helfen uns zwei Bilder aus der echten Welt:

Der Hochseilgarten ohne Sicherheitsnetz (Unsafe Rust)

Wenn Sie im normalen Rust programmieren, befinden Sie sich in einem perfekt abgesicherten Hochseilgarten. Sie tragen Klettergurte, doppelte Karabiner und unter Ihnen hängt ein riesiges Sicherheitsnetz (der Compiler). Selbst wenn Sie stolpern, können Sie nicht abstürzen. Ihr Programm ist speichersicher.

  • Das Schlüsselwort unsafe zu betreten bedeutet, den Klettergurt an einer bestimmten Stelle kurz auszuklinken. Sie tun dies, weil Sie an dieser Stelle eine artistische Übung ausführen müssen, die der Gurt einschränkt – zum Beispiel die direkte Kommunikation mit dem Betriebssystem oder mit Hardware-Registern.
  • Wichtig: unsafe schaltet den Borrow Checker nicht aus und macht Ihren Code nicht automatisch falsch. Es bedeutet lediglich: Der Compiler zieht das Sicherheitsnetz weg. Sie tragen nun die alleinige Verantwortung dafür, dass Sie keinen falschen Schritt machen (z. B. Null-Pointer dereferenzieren), da das Programm sonst abstürzt oder Sicherheitslücken bekommt.

Die Grenzstation und der Zoll (FFI)

C-Code und Rust-Code sind wie zwei unterschiedliche Länder mit verschiedenen Sprachen (Typen) und Bräuchen (Speicherverwaltung). C ist wild und unreguliert; Rust ist ordentlich und sicherheitsbewusst.

  • Das FFI (Foreign Function Interface) ist die Grenzstation zwischen diesen Welten.
  • Wenn C-Daten die Grenze nach Rust passieren, müssen wir Zölle deklarieren und Pässe kontrollieren. Das bedeutet: Wir müssen die C-Datentypen in Rust-kompatible Typen übersetzen (z. B. C-Integers in c_int). Wir müssen außerdem sicherstellen, dass keine ungültigen Daten (wie Null-Zeiger) eingeschmuggelt werden, die das Rust-System korrumpieren könnten.

2. Praxis-Szenario: Die C-Fakultätsbibliothek

Wir arbeiten an einem Performance-kritischen System. Ein älterer Teil unseres Systems besitzt eine hochoptimierte Fakultätsfunktion in C, die wir aus historischen Gründen oder Performance-Gründen einbinden müssen.

Unser Ziel

  1. Wir schreiben eine C-Datei src/math.c, die eine Funktion uint32_t factorial(uint32_t n) bereitstellt.
  2. Wir erstellen ein Cargo-Build-Skript (build.rs), das die C-Datei beim Aufruf von cargo build vollautomatisch mit dem Host-C-Compiler übersetzt und statisch in unsere Rust-Binärdatei linkt.
  3. Wir deklarieren die C-Funktion in Rust über eine extern "C"-Schnittstelle.
  4. Wir kapseln den unsicheren Aufruf in eine sichere Rust-Funktion safe_factorial(n: u32) -> Result<u32, &'static str>. Diese fängt Fehleingaben (wie einen mathematischen Überlauf bei $n > 12$) sicher ab, sodass der Aufrufer niemals mit fehlerhaften FFI-Zuständen konfrontiert wird.

Die Übungsaufgabe befindet sich im Verzeichnis:


3. Der große Unsafe- & FFI-Katalog: Konzepte und Typen

Für die Arbeit an der Systemgrenze benötigen wir ein klares Verständnis der folgenden Werkzeuge:

3.1 Die 5 Superkräfte von Unsafe Rust

Innerhalb eines unsafe-Blocks dürfen Sie genau fünf Dinge tun, die im normalen Rust verboten sind:

  1. Rohe Zeiger (Raw Pointer) dereferenzieren.
  2. Unsafe Funktionen oder foreign Funktionen aufrufen.
  3. Ein veränderbares statisches Element (static mut) modifizieren.
  4. Ein Unsafe-Trait implementieren.
  5. Auf Felder einer union zugreifen.

3.2 Rohe Zeiger (Raw Pointer) vs. Referenzen

Rust unterscheidet strikt zwischen sicheren Referenzen und rohen Zeigern:

  • Sichere Referenzen (&T / &mut T): Garantieren stets, dass sie auf gültigen Speicher zeigen, niemals null sind und den Ownership-Regeln entsprechen.
  • Rohe Zeiger (*const T / *mut T):
    • Können Null-Pointer sein.
    • Können auf freigegebenen Speicher zeigen (Dangling Pointer).
    • Ignorieren den Borrow Checker (erlauben Aliasing von veränderbarem Speicher).
    • Das Erstellen eines rohen Zeigers ist sicher; das Dereferenzieren (den Wert dahinter lesen oder schreiben) erfordert zwingend einen unsafe-Block.

3.3 extern "C" und ABI

Das Application Binary Interface (ABI) legt fest, wie Funktionen auf Maschinenebene aufgerufen werden (wie Argumente in Register gelegt werden etc.). extern "C" teilt dem Rust-Compiler mit, dass er das Standard-C-Aufrufprotokoll verwenden soll.

3.4 C-Datentypen in Rust

Da die Bitbreite von Typen wie int oder long je nach Plattform und C-Compiler variiert, bietet Rust im Modul std::os::raw exakte Entsprechungen an:

  • c_int (entspricht int in C)
  • c_char (entspricht char in C, nützlich für Strings)
  • c_uint (entspricht unsigned int in C)

Für exakte Bitbreiten (wie uint32_t) können wir in Rust direkt die Standardtypen u32, i32 etc. nutzen, da diese plattformübergreifend binärkompatibel zu C sind.


4. Aufgabenstellung

  1. Erstellen Sie ein neues Rust-Projekt: cargo new --bin unsafe_ffi.
  2. Erstellen Sie eine Datei src/math.c und implementieren Sie die C-Funktion:
    #include <stdint.h>
    uint32_t factorial(uint32_t n) {
        uint32_t result = 1;
        for (uint32_t i = 1; i <= n; i++) {
            result *= i;
        }
        return result;
    }
    
  3. Fügen Sie in der Datei Cargo.toml die Crate cc unter [build-dependencies] hinzu.
  4. Erstellen Sie im Projekt-Wurzelverzeichnis die Datei build.rs, die das C-File kompiliert:
    fn main() {
        cc::Build::new()
            .file("src/math.c")
            .compile("mymath"); // Erzeugt libmymath.a
    }
  5. Binden Sie die FFI-Funktion in src/main.rs ein.
  6. Schreiben Sie einen sicheren Rust-Wrapper safe_factorial(n: u32) -> Result<u32, &'static str>, der:
    • Prüft, ob $n > 12$ ist (da $13!$ den Zahlenbereich von u32 übersteigt und zu einem stillen Überlauf führt). Falls ja, geben Sie einen Err zurück.
    • Den unsafe-Block kapselt und das Ergebnis als Ok(u32) zurückgibt.
  7. Rufen Sie die Funktion in main auf und testen Sie sowohl Normalwerte als auch die Fehlergrenze.

5. Detaillierte Code-Erklärung der Musterlösung

Hier sehen Sie den vollständigen Rust-Code für src/main.rs:

use std::os::raw::c_uint;

// 1. Deklaration der externen C-Funktion (die FFI-Grenzstation)
// Der Name muss exakt dem Namen in math.c entsprechen.
extern "C" {
    fn factorial(n: c_uint) -> c_uint;
}

// 2. Der sichere Wrapper (Safe Wrapper)
// Er kapselt die Unsafe-Schnittstelle vollständig und schützt den Aufrufer.
pub fn safe_factorial(n: u32) -> Result<u32, &'static str> {
    // Eingabevalidierung vor dem FFI-Aufruf:
    // 13! = 6.227.020.800 (passt nicht in einen u32: max 4.294.967.295)
    if n > 12 {
        return Err("Mathematischer Überlauf: n darf maximal 12 sein.");
    }

    // Übergabe an FFI. Da 'factorial' als FFI-Funktion deklariert ist,
    // ist jeder Aufruf prinzipiell unsafe.
    let result = unsafe {
        factorial(n as c_uint)
    };

    Ok(result as u32)
}

fn main() {
    // Testlauf 1: Gültiger Wert
    let n1 = 5;
    match safe_factorial(n1) {
        Ok(res) => println!("Ergebnis aus C: {}! = {}", n1, res),
        Err(e) => println!("Fehler: {}", e),
    }

    // Testlauf 2: Grenzwert-Überschreitung
    let n2 = 13;
    match safe_factorial(n2) {
        Ok(res) => println!("Ergebnis aus C: {}! = {}", n2, res),
        Err(e) => println!("Fehler beim Berechnen von {}: {}", n2, e),
    }
}

Anatomische Zeilenzerlegung der Lösung

  • Zeile 1: use std::os::raw::c_uint; – Wir importieren den C-kompatiblen Typ für vorzeichenlose Ganzzahlen. Auch wenn u32 auf fast allen modernen Plattformen identisch zu c_uint ist, sichert uns dieser Import plattformübergreifend gegen abweichende Compiler-Architekturen ab.
  • Zeile 5: extern "C" { fn factorial(n: c_uint) -> c_uint; } – Dies ist ein Deklarationsblock. Wir sagen Rust: „Es gibt irgendwo im System (oder in der statischen Bibliothek, die Cargo hinzulinkt) eine Funktion namens factorial mit dieser Signatur. Vertrau uns, dass sie existiert.“
  • Zeile 13: if n > 12 { return Err(...); }Dies ist das wichtigste Entwurfsprinzip für FFI! C-Code fängt Überläufe meist nicht ab, sondern liefert fehlerhafte Restwerte zurück (Undefined Behavior / Wrap-around). Indem wir den Fehler in Rust vor dem Grenzübertritt abfangen, verhindern wir, dass ungültige Zustände in unser Programm gelangen.
  • Zeile 19: let result = unsafe { factorial(...) }; – Hier betreten wir den unsafe-Bereich. Warum ist der FFI-Aufruf unsafe? Rust kann nicht überprüfen, was im C-Code passiert. Der C-Code könnte ungültige Zeiger verwenden, den Stack überschreiben oder abstürzen. Der unsafe-Block signalisiert dem Compiler: „Wir haben die Schnittstelle geprüft und bürgen für die Sicherheit dieser Operation.“

6. Typische Compilerfehler & Fehlerbehebung (CDD-Ansatz)

Beim Arbeiten mit FFI und Unsafe-Code treten häufig Linker-Fehler oder Speicherprobleme auf.

Fehler 1: Unresolved External Symbol (Linker-Fehler)

error: linking with `cc` failed: exit status: 1
  = note: /usr/bin/ld: main.o: in function `main`:
          undefined reference to `factorial`
  • Ursache: Der Rust-Compiler weiß zwar durch das extern "C", dass die Funktion existiert, aber beim Zusammenbauen des finalen Programms findet der Linker die kompilierte C-Bibliothek nicht.
  • Lösung (CDD):
    1. Prüfen Sie, ob Ihre build.rs im Wurzelverzeichnis des Projekts (neben der Cargo.toml) liegt und nicht versehentlich im Ordner src/ gelandet ist.
    2. Prüfen Sie, ob in der Cargo.toml die Build-Abhängigkeit eingetragen ist:
      [build-dependencies]
      cc = "1.0"
      
    3. Stellen Sie sicher, dass in build.rs der Dateiname der C-Datei exakt angegeben ist.

Fehler 2: Aufruf einer externen Funktion ohne Unsafe-Block

#![allow(unused)]
fn main() {
let res = factorial(5); // COMPILER-FEHLER!
}
  • Ursache: Alle FFI-Funktionen, die über extern "C" deklariert wurden, gelten in Rust automatisch als unsafe. Der Compiler zwingt uns, das Risiko bewusst einzugrenzen.
  • Lösung: Platzieren Sie den Aufruf zwingend in einem unsafe {}-Block oder kapseln Sie ihn in eine sichere Wrapper-Funktion.

Fehler 3: Stille Speicherkorruption durch falsche C-Signaturen

#![allow(unused)]
fn main() {
// In C:   uint64_t calculate(uint64_t x);
// In Rust fälschlicherweise deklariert als:
extern "C" {
    fn calculate(x: u32) -> u32; // LAUFZEIT-FEHLER (Speicher-Müll)!
}
}
  • Ursache: Dies ist ein tückischer Fehler. Der Compiler glaubt Ihren Angaben im extern "C"-Block blind! Wenn die Bitbreiten nicht übereinstimmen (z. B. 32-Bit-Integer in Rust deklariert, aber der C-Code liest und schreibt 64 Bit auf dem Stack), kommt es zu schleichender Speicherkorruption.
  • Lösung: Verifizieren Sie die Signaturen zwischen C-Code und Rust-Code doppelt. Nutzen Sie bei komplexen APIs Werkzeuge wie bindgen, um die Rust-Anbindungen vollautomatisch aus den C-Headerdateien generieren zu lassen.