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öglichBorrow-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 Wartenzero-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 FehlercodesZustandsmaschinen 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-BusJede 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-gnueabihfDas Binary liegt danach unter:
target/armv7-unknown-linux-gnueabihf/release/libbbb_hal.so
Das |
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 FehlercodesRessourcenverwaltung ü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.
Nächster Post in der Serie: Die REST API in Go — GPIO, I2C und UART über HTTP