· 7 min read ·

How Rust's Type System Enforces Hardware Contracts That C Can Only Comment On

Source: lobsters

When you access hardware from C, you write volatile. When you access hardware from Rust, the type system checks whether you are allowed to. That difference sounds small but it compounds across every layer of an embedded project, from raw register structs to high-level peripheral drivers.

The Ferrous Systems post on hardware access in Rust covers the foundational case: that C’s volatile qualifier is a compiler hint about memory access semantics, not a description of what a register permits. A read-only status register and a read-write data register both get volatile in C. The distinction lives only in the datasheet and, if you are lucky, in a comment.

Rust does not have a volatile keyword. Instead it has two functions in core::ptr: read_volatile and write_volatile. Those primitives behave the same way as C volatile, preventing the compiler from eliding or reordering the access. The difference is what you build on top of them.

The Register Type Layer

The volatile_register crate, maintained under the rust-embedded organization, provides three wrapper types: RO<T>, WO<T>, and RW<T>. Each wraps an UnsafeCell<T> and exposes only the methods the hardware permits.

use volatile_register::{RO, RW};

#[repr(C)]
pub struct UartRegisters {
    pub sr:  RO<u32>,  // Status Register — read-only
    pub dr:  RW<u32>,  // Data Register   — read-write
    pub brr: RW<u32>,  // Baud Rate Register
    pub cr1: RW<u32>,  // Control Register 1
}

fn get_uart() -> &'static UartRegisters {
    unsafe { &*(0x40011000 as *const UartRegisters) }
}

RO<u32> has a read() method and nothing else. There is no write() to call. Attempting to write to a read-only status register does not produce a silent hardware malfunction or a runtime assertion. It produces a compile error. The constraint stated in the datasheet now lives in the type, checked by the compiler on every build.

The unsafe in get_uart() is where the contract is made: the programmer asserts that 0x40011000 is a valid, properly aligned UART register block. That assertion is made once, in one place, by whoever writes the hardware support code. Every call site that uses the returned reference is safe.

Code Generation from SVD Files

Handwriting register structs for every peripheral on a microcontroller is impractical. ARM’s System View Description (SVD) format solves this: chip vendors publish XML files describing every peripheral, register, field, bit offset, width, and access permission. The svd2rust tool reads those files and generates a complete Peripheral Access Crate (PAC).

As of 2025, svd2rust is at version 0.33.x and generates PACs for most major microcontroller families. The stm32f4, rp2040-pac, nrf52840, and esp32-pac crates all come from this pipeline.

Generated PAC code goes further than the RO/RW split. Where the SVD enumerates valid field values, the PAC makes invalid values unrepresentable:

use stm32f4::stm32f407;

let dp = stm32f407::Peripherals::take().unwrap();

// .ue() is a typed setter generated from the SVD enum
// passing an arbitrary u32 requires unsafe; named variants do not
dp.USART1.cr1.modify(|_r, w| {
    w.ue().enabled()
     .te().enabled()
     .re().enabled()
});

// Read a typed field — txe() returns a typed wrapper, not a raw u32
if dp.USART1.sr.read().txe().bit_is_set() {
    dp.USART1.dr.write(|w| unsafe { w.dr().bits(b'A' as u32) });
}

Peripherals::take() uses a global atomic flag to ensure it returns Some exactly once. If two parts of your program try to take the peripherals independently, the second call returns None. Ownership of hardware is enforced at the API boundary, not by convention.

The Typestate Pattern: Encoding Configuration in the Type

Register access permissions are a static property of hardware. Pin configuration is not: a GPIO pin starts unconfigured, gets set to input or output, and the legal operations change depending on that configuration. In C, tracking this requires runtime flags, documentation, and discipline. In Rust, it goes into the type parameter.

The typestate pattern uses zero-sized marker types as phantom type parameters:

use core::marker::PhantomData;

pub struct Input<MODE>(PhantomData<MODE>);
pub struct Output<MODE>(PhantomData<MODE>);
pub struct Floating;
pub struct PushPull;

pub struct PA5<MODE> {
    _mode: PhantomData<MODE>,
}

impl PA5<Input<Floating>> {
    pub fn into_push_pull_output(self) -> PA5<Output<PushPull>> {
        // configure hardware registers here
        PA5 { _mode: PhantomData }
    }
    pub fn is_high(&self) -> bool { /* read IDR */ true }
}

impl PA5<Output<PushPull>> {
    pub fn set_high(&mut self) { /* write ODR */ }
    pub fn set_low(&mut self)  { /* write ODR */ }
}

set_high() does not exist on PA5<Input<Floating>>. It is not a runtime error, not a panic, not an assertion. The method is absent from the type. The compiler error message tells you exactly what state the pin is in and what you need to call first.

All PhantomData fields are zero-sized. The type parameters vanish at code generation. There is no runtime overhead: this abstraction compiles to exactly the same machine code as the equivalent C, with none of C’s missing compile-time checks.

The embedded-hal Trait Layer

The typestate-aware HAL crates each implement the embedded-hal traits, which define a portable interface for common hardware operations. Version 1.0.0 shipped in January 2024 after several years of design work, and the ecosystem has been migrating from 0.2.x throughout 2024.

The traits are straightforward:

pub trait OutputPin {
    fn set_high(&mut self) -> Result<(), Self::Error>;
    fn set_low(&mut self)  -> Result<(), Self::Error>;
}

pub trait I2c {
    type Error;
    fn write(&mut self, address: u8, bytes: &[u8]) -> Result<(), Self::Error>;
    fn read(&mut self, address: u8, buffer: &mut [u8]) -> Result<(), Self::Error>;
    fn write_read(&mut self, address: u8, bytes: &[u8], buffer: &mut [u8]) -> Result<(), Self::Error>;
}

A driver written against these traits runs on any hardware that implements them:

pub struct Bme280<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C: embedded_hal::i2c::I2c> Bme280<I2C> {
    pub fn read_temperature(&mut self) -> Result<f32, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.address, &[0xFA], &mut buf)?;
        Ok(parse_temperature(&buf))
    }
}

No #ifdef, no platform-specific includes, no conditional compilation. The trait bound expresses the hardware requirement. The same driver crate installs on an STM32, an nRF52, an RP2040, or an ESP32 by choosing a different HAL implementation. The C equivalent requires forking or elaborate preprocessor gymnastics.

embedded-hal-async ships alongside embedded-hal and provides async versions of every trait. The Embassy framework builds on this to provide interrupt-driven async I/O without an RTOS, letting you write peripheral drivers as async functions and compose them with Rust’s standard async/await without any thread synchronization overhead.

Where the unsafe Actually Lives

A common misconception about Rust embedded is that safe hardware access means no unsafe. It does not. Every embedded program has unsafe somewhere; the question is where it is and who is responsible for it.

In C, unsafety is implicit throughout. There is no marker on a function that does arbitrary pointer arithmetic or writes to a misidentified register address.

In Rust embedded, unsafe is pushed to the lowest layer: the raw pointer cast that maps a PAC struct to a base address, and the bit-field writes where the SVD does not enumerate valid values. The PAC author writes that unsafe once and proves its correctness by matching the register layout to the datasheet. The HAL author writes unsafe code inside implementations and exports safe methods. Application code, in a well-structured project, contains no unsafe at all.

This is the boundary model: define the hardware contract at the metal, prove it once, and let the entire stack above rely on it. In C, every layer that touches a peripheral re-enters the same implicit unsafe territory with no compile-time support for getting it right.

The Broader Ecosystem in 2025

svd2rust 0.33.x, cortex-m 0.7.x, embedded-hal 1.0.0, and Embassy’s HAL crates (embassy-stm32, embassy-nrf, embassy-rp) now cover the most common embedded targets. Espressif’s esp-hal has matured into a production-grade option for all ESP32 variants. The defmt crate provides efficient deferred-format logging that moves format string processing to the host machine, sending only indices and raw values over RTT, which matters on devices where a single printf equivalent costs hundreds of bytes of flash.

Ferrocene, Ferrous Systems’ safety-qualified Rust toolchain, received qualification for ISO 26262 (automotive) and IEC 61508 (industrial) in 2023 and is commercially available. That development matters because it puts Rust into embedded domains that previously required MISRA C or Ada, with a toolchain that makes the same type-system arguments described above legally auditable.

What This Changes in Practice

The practical shift is not just about catching bugs earlier, though it does that. It is about where knowledge lives. In a C embedded project, the datasheet facts that matter, such as which registers are read-only, which bit combinations are invalid, which initialization sequence is required before a peripheral can be used, live in comments, in naming conventions, in team memory. They are checked only at code review, if at all.

In a Rust embedded project, that same knowledge is progressively encoded into types at the PAC, HAL, and driver layers. The SVD-to-PAC pipeline automates much of it from the vendor’s own specification. What remains in comments is genuinely non-mechanical: timing constraints, power sequencing, board-specific errata. Everything the type system can express, it expresses, and the compiler enforces on every build.

That is a different class of guarantee than documentation, and it compounds. Every driver that builds on a correct HAL inherits its guarantees without re-checking them.

Was this interesting?