This post is part of the series about my private BeagleBone Black project. There I introduced the 5-layer architecture and the language mix — here I go into a concrete aspect that often raises questions: Why use Rust in the HAL at all, when C has been working there for decades?

Why Rust in the HAL?

The honest answer: because I wanted to try it. Privately run projects are exactly what they are for. In my day job I make technology decisions under time pressure, with consideration for the team and the existing codebase. Rust had no place there so far.

Here it does. This urge to truly understand something new — not just to have read about it, but to have built it, debugged it, and gotten it working — is one of the main reasons this project exists. Rust in the HAL is my learning lab for a language that has interested me for years but has not been required professionally yet.

And then there are the technical reasons that justify the experiment.

The Problem with C in the HAL

C gives you full control — and full responsibility. A wrongly set pointer, a forgotten free, an off-by-one when reading an I2C buffer: the compiler says nothing, the system stays silent until it crashes at runtime. In a private project that is manageable. In a safety-critical system it would be an incident report.

What Rust Does Differently

Rust enforces correctness at compile time instead of runtime:

  • Ownership — every resource has exactly one owner, no double-free possible

    let buf = vec![0u8; 64]; // buf owns the memory
    let buf2 = buf;          // ownership is transferred
    // using buf here is a compile error — no double-free possible
  • Borrow checker — simultaneous read and write access is prevented

    let mut reg = 0u8;
    let r = ®      // read reference
    // reg = 1;        // compile error: write access while borrow is active
    println!("{}", r);
  • No garbage collector — deterministic resource release, no pausing

    {
        let fd = open_i2c_bus(1); // resource is opened
        // ... I2C operations ...
    } // Drop is called here exactly — no GC, no indeterminate waiting
  • Zero-cost abstractions — iterators, traits, generics — without runtime overhead

    // This iterator chain compiles to the same machine code as a handwritten loop
    let sum: u32 = readings.iter()
        .filter(|&&v| v > 0)
        .map(|&v| v as u32)
        .sum();

For hardware code this means concretely: a register value that may only be written once, an I2C bus that should not be used by two threads simultaneously — Rust makes such invariants part of the type system, not a convention.

Rust is not a silver bullet. The borrow checker protects against memory errors, not logic errors. Wrong I2C command, wrong register address — it won’t catch those.

Boundary with C

The project has two layers below Services:

┌─────────────────────────────┐
│    Hardware Abstraction     │  ← Rust (this layer)
├─────────────────────────────┤
│         Drivers             │  ← C (Linux sysfs / ioctl)
├─────────────────────────────┤
│         Hardware            │  ← AM335x / Linux
└─────────────────────────────┘

C stays in the driver layer:

  • Direct access to Linux kernel APIs (ioctl, open, read, write)

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

  • Minimal, well-understood structures

Rust takes over HAL logic:

  • Type-safe API over the raw driver functions

  • Error handling with Result<T, E> instead of raw error codes

  • State machines for peripheral protocols

The boundary is pragmatic: where the Linux kernel call ends, Rust begins.

Rust FFI to C

Rust can call C functions directly — but not without effort. The keyword is unsafe and the attribute extern "C".

Binding a C Driver

The C driver for I2C exposes this function:

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

In Rust the signature is declared:

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;
}

The call itself is unsafe — Rust cannot verify the correctness of the C function:

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)
    }
}

The unsafe block is kept deliberately minimal. The public API exposed to the outside is fully safe.

Linker Configuration

The C library must be linked in at build time. In build.rs:

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

Rust as a Library for Go

The services layer is written in Go. Go can link C-compatible libraries (cgo) — but not Rust directly. The trick: Rust compiles as a C-compatible shared library.

cbindgen — Auto-generate Headers

cbindgen reads the Rust code and generates a C header from it:

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

For every pub extern "C" function 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,
    }
}

cbindgen automatically generates:

// 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
}

Concrete Example: I2C Read End-to-End

The complete path of an I2C read through all layers:

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

Each layer adds exactly one abstraction: * C: raw kernel call * Rust: Result-based error handling * Go: idiomatic Go interface

Cross-Compilation for ARMv7

The build runs on x86, the binary must run on the ARM Cortex-A8.

Install Rust Target

rustup target add armv7-unknown-linux-gnueabihf

.cargo/config.toml

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

Build Command

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

The binary is then located at: target/armv7-unknown-linux-gnueabihf/release/libbbb_hal.so

The cross tool wraps cross-compilation in a Docker container — less setup, but a container dependency. I use the manual toolchain because it fits better into the Drone CI pipeline.

Ownership and Lifetimes in a Hardware Context

Hardware resources are exclusive by nature. An I2C bus cannot be written to by two threads simultaneously. A GPIO pin has exactly one driver.

Rust’s ownership system models this directly:

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 is neither Clone nor Copy. There can be exactly one instance that owns the bus. When the instance goes out of scope, the file descriptor is closed automatically.

This is not a pattern I invented — it is how Rust idiomatically models hardware access.

Conclusion

Rust in the HAL is more work than C — that is honest. FFI bindings, cbindgen, cross-compilation setup, a borrow checker that occasionally clashes with the ARM linker.

But the gain is real:

  • Compile-time guarantees instead of runtime crashes

  • Explicit error handling with Result<T, E> instead of raw error codes

  • Resource management via ownership instead of manual close()

For a professional system where memory errors in the HAL would mean a recall, this is not overkill but a minimum. For a private project it is the best opportunity to practice the craft before you need it.