Dieser Post ist Teil der Serie über mein privates BeagleBone-Black-Projekt. Dort habe ich die 5-Schicht-Architektur und den Sprachenmix vorgestellt — hier gehe ich auf einen konkreten Aspekt ein, der oft Fragen aufwirft: Warum überhaupt Rust im HAL, wenn C dort schon seit Jahrzehnten funktioniert?

Warum Rust in der HAL?

Die ehrliche Antwort: weil ich es ausprobieren wollte. Privat betriebene Projekte sind genau dafür da. Im Berufsalltag treffe ich Technologieentscheidungen unter Zeitdruck, mit Rücksicht auf das Team und die bestehende Codebasis. Rust hatte dort bisher keinen Platz.

Hier schon. Dieser Drang, etwas Neues wirklich zu verstehen, nicht nur darüber gelesen zu haben, sondern es eingebaut, debuggt und zum Laufen gebracht zu haben, ist einer der Hauptgründe warum dieses Projekt existiert. Rust in der HAL ist mein Lernlabor für eine Sprache, die mich seit Jahren interessiert aber beruflich noch nicht gefordert hat.

Und dann gibt es noch die technischen Gründe, die das Experiment rechtfertigen.

Das Problem mit C im HAL

C gibt dir volle Kontrolle — und volle Verantwortung. Ein falsch gesetzter Pointer, ein vergessenes free, ein Off-by-one beim Auslesen eines I2C-Puffers: der Compiler sagt nichts, das System schweigt bis es zur Laufzeit abstürzt. In einem privaten Projekt ist das verkraftbar. In einem sicherheitskritischen System wäre es ein Incident-Bericht.

Was Rust anders macht

Rust erzwingt Korrektheit zur Compile-Zeit statt zur Laufzeit:

  • Ownership — jede Ressource hat genau einen Besitzer, kein Double-Free möglich

    let buf = vec![0u8; 64]; // buf ist Eigentümer des Speichers
    let buf2 = buf;          // Ownership wird übertragen
    // buf hier zu verwenden ist ein Compile-Fehler — kein Double-Free möglich
  • Borrow-Checker — gleichzeitiger lesender und schreibender Zugriff wird verhindert

    let mut reg = 0u8;
    let r = ®      // lesende Referenz
    // reg = 1;        // Compile-Fehler: schreibender Zugriff während Borrow aktiv
    println!("{}", r);
  • Kein Garbage Collector — deterministische Ressourcenfreigabe, kein Pausieren

    {
        let fd = open_i2c_bus(1); // Ressource wird geöffnet
        // ... I2C-Operationen ...
    } // Drop wird hier exakt aufgerufen — kein GC, kein unbestimmtes Warten
  • zero-cost abstractions — Iteratoren, Traits, Generics — ohne Runtime-Overhead

    // Dieser Iterator-Chain kompiliert zu derselben Maschinenkode wie eine handgeschriebene Schleife
    let sum: u32 = readings.iter()
        .filter(|&&v| v > 0)
        .map(|&v| v as u32)
        .sum();

Für Hardware-Code bedeutet das konkret: ein Registerwert der nur einmal geschrieben werden darf, ein I2C-Bus der nicht gleichzeitig von zwei Threads genutzt werden soll — Rust macht solche Invarianten zur Typebene, nicht zur Konvention.

Rust ist kein Allheilmittel. Der Borrow-Checker schützt vor Speicherfehlern, nicht vor Logikfehlern. Falscher I2C-Befehl, falsche Register-Adresse — das findet er nicht.

Abgrenzung zu C

Das Projekt hat zwei Schichten unterhalb der Services:

┌─────────────────────────────┐
│    Hardware Abstraction     │  ← Rust (diese Schicht)
├─────────────────────────────┤
│         Treiber             │  ← C (Linux sysfs / ioctl)
├─────────────────────────────┤
│         Hardware            │  ← AM335x / Linux
└─────────────────────────────┘

C bleibt in der Treiberschicht:

  • Direkter Zugriff auf Linux-Kernel-APIs (ioctl, open, read, write)

  • /dev/i2c-, /sys/class/gpio/, /dev/ttyO*

  • Minimale, gut verstandene Strukturen

Rust übernimmt die HAL-Logik:

  • Typgesichertes API über die rohen Treiberfunktionen

  • Fehlerbehandlung mit Result<T, E> statt roher Fehlercodes

  • Zustandsmaschinen für Peripherieprotokolle

Die Grenze ist pragmatisch: wo der Linux-Kernel-Aufruf aufhört, fängt Rust an.

Rust-FFI zu C

Rust kann C-Funktionen direkt aufrufen — aber nicht ohne Aufwand. Das Schlüsselwort ist unsafe und das Attribut extern "C".

C-Treiber einbinden

Der C-Treiber für I2C stellt diese Funktion bereit:

// i2c_driver.h
int i2c_read(int bus_fd, uint8_t addr, uint8_t reg, uint8_t *buf, size_t len);

In Rust wird die Signatur deklariert:

use std::os::raw::{c_int, c_uchar, c_size_t};

extern "C" {
    fn i2c_read(
        bus_fd: c_int,
        addr:   c_uchar,
        reg:    c_uchar,
        buf:    *mut c_uchar,
        len:    c_size_t,
    ) -> c_int;
}

Der Aufruf selbst ist unsafe — Rust kann die Korrektheit der C-Funktion nicht prüfen:

pub fn read_register(bus_fd: i32, addr: u8, reg: u8) -> Result<u8, HalError> {
    let mut buf: u8 = 0;
    let ret = unsafe {
        i2c_read(bus_fd, addr, reg, &mut buf as *mut u8, 1)
    };
    if ret < 0 {
        Err(HalError::I2cReadFailed(ret))
    } else {
        Ok(buf)
    }
}

Das unsafe-Block ist bewusst minimal gehalten. Die öffentliche API nach außen ist vollständig safe.

Linker-Konfiguration

Die C-Bibliothek muss beim Build eingebunden werden. In build.rs:

fn main() {
    println!("cargo:rustc-link-search=native=../c/lib");
    println!("cargo:rustc-link-lib=static=bbb_drivers");
}

Rust als Bibliothek für Go

Die Services-Schicht ist in Go geschrieben. Go kann C-kompatible Bibliotheken einbinden (cgo) — aber kein Rust direkt. Der Trick: Rust kompiliert als C-kompatible Shared Library.

cbindgen — Header automatisch generieren

cbindgen liest den Rust-Code und erzeugt daraus einen C-Header:

# cbindgen.toml
language = "C"
include_guard = "BBB_HAL_H"

Für jede pub extern "C"-Funktion in Rust:

#[no_mangle]
pub extern "C" fn hal_i2c_read(
    bus_fd: i32,
    addr:   u8,
    reg:    u8,
    out:    *mut u8,
) -> i32 {
    match read_register(bus_fd, addr, reg) {
        Ok(val) => { unsafe { *out = val }; 0 }
        Err(_)  => -1,
    }
}

erzeugt cbindgen automatisch:

// bbb_hal.h (auto-generated)
int32_t hal_i2c_read(int32_t bus_fd, uint8_t addr, uint8_t reg, uint8_t *out);

cgo-Integration in Go

package hal

// #cgo LDFLAGS: -L../../rust/target/armv7-unknown-linux-gnueabihf/release -lbbb_hal
// #include "bbb_hal.h"
import "C"
import "fmt"

func I2CRead(busFd int, addr, reg uint8) (uint8, error) {
    var out C.uint8_t
    ret := C.hal_i2c_read(C.int32_t(busFd), C.uint8_t(addr), C.uint8_t(reg), &out)
    if ret < 0 {
        return 0, fmt.Errorf("i2c read failed: %d", ret)
    }
    return uint8(out), nil
}

Konkretes Beispiel: I2C-Read End-to-End

Der vollständige Pfad eines I2C-Lesevorgangs durch alle Schichten:

Go (API-Handler)
    → hal.I2CRead(bus, addr, reg)          [Go, cgo-Call]
        → hal_i2c_read(bus_fd, addr, reg)  [Rust, pub extern "C"]
            → read_register(...)           [Rust, safe wrapper]
                → i2c_read(...)            [C, unsafe-Block]
                    → ioctl(I2C_RDWR)      [Linux Kernel]
                        → AM335x I2C-Bus

Jede Schicht fügt genau eine Abstraktion hinzu: * C: roher Kernel-Aufruf * Rust: Result-basierte Fehlerbehandlung * Go: idomatisches Go-Interface

Cross-Compilation für ARMv7

Der Build läuft auf x86, das Binary muss auf dem ARM Cortex-A8 laufen.

Rust-Target installieren

rustup target add armv7-unknown-linux-gnueabihf

.cargo/config.toml

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

Build-Befehl

cargo build \
  --release \
  --target armv7-unknown-linux-gnueabihf

Das Binary liegt danach unter: target/armv7-unknown-linux-gnueabihf/release/libbbb_hal.so

Das cross-Tool kapselt Cross-Compilation in einem Docker-Container — weniger Setup, dafür Container-Dependency. Ich verwende die manuelle Toolchain weil sie besser in die Drone-CI-Pipeline passt.

Ownership und Lifetimes im Hardware-Kontext

Hardware-Ressourcen sind von Natur aus exklusiv. Ein I2C-Bus kann nicht gleichzeitig von zwei Threads beschrieben werden. Ein GPIO-Pin hat genau einen Treiber.

Rust’s Ownership-System modelliert das direkt:

pub struct I2cBus {
    fd: i32,
}

impl I2cBus {
    pub fn open(bus: u8) -> Result<Self, HalError> {
        // ...
        Ok(I2cBus { fd })
    }
    pub fn read(&mut self, addr: u8, reg: u8) -> Result<u8, HalError> {
        read_register(self.fd, addr, reg)
    }
}

impl Drop for I2cBus {
    fn drop(&mut self) {
        unsafe { libc::close(self.fd) };
    }
}

I2cBus ist nicht Clone und nicht Copy. Es kann genau eine Instanz geben, die den Bus besitzt. Wenn die Instanz aus dem Scope fällt, wird der File-Descriptor automatisch geschlossen.

Das ist kein Pattern das ich mir ausgedacht habe — es ist wie Rust Hardware-Zugriff idiomatisch modelliert.

Fazit

Rust im HAL ist mehr Aufwand als C — das ist ehrlich. FFI-Bindings, cbindgen, Cross-Compilation-Setup, Borrow-Checker der manchmal mit dem ARM-Linker aneinandergerät.

Aber der Gewinn ist real:

  • Compile-Zeit-Garantien statt Laufzeit-Crashes

  • Explizite Fehlerbehandlung mit Result<T, E> statt roher Fehlercodes

  • Ressourcenverwaltung über Ownership statt manuelles close()

Für ein professionelles System wo Speicherfehler im HAL einen Rückruf bedeuten würden, ist das kein Overkill sondern Minimum. Für ein privates Projekt ist es die beste Gelegenheit, das Handwerkszeug zu üben bevor man es braucht.