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 possibleBorrow 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 waitingZero-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 codesState 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 busEach 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-gnueabihfThe binary is then located at:
target/armv7-unknown-linux-gnueabihf/release/libbbb_hal.so
The |
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 codesResource 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.
Next post in the series: The REST API in Go — GPIO, I2C and UART over HTTP