Typed Registers (MMIO without Unsafe)

Problem

Every Rust OS kernel uses unsafe for hardware register access. A typical kernel has hundreds to thousands of unsafe { write_volatile(...) } blocks scattered across driver code. Each one is a potential source of memory corruption if the address, width, or access pattern is wrong.

Solution

Rs introduces the #[register] attribute, which declares a memory-mapped I/O register as a typed, compiler-verified construct.

Syntax

#[register(base = 0x23B10_0000, bank_size = 0x1000, width = 32)]
mod aic {
    /// Interrupt controller enable register
    #[reg(offset = 0x010, access = "rw")]
    pub struct Enable {
        #[field(bits = 0..1)]
        pub enabled: bool,

        #[field(bits = 1..5)]
        pub target_cpu: u8,

        #[field(bits = 5..7)]
        pub mode: IrqMode,

        // bits 7..32 are reserved (implicit, compiler warns if accessed)
    }

    /// Interrupt status register
    #[reg(offset = 0x014, access = "ro")]
    pub struct Status {
        #[field(bits = 0..16)]
        pub pending_irq: u16,

        #[field(bits = 16..20)]
        pub source: IrqSource,
    }

    /// Interrupt clear register
    #[reg(offset = 0x018, access = "wo")]
    pub struct Clear {
        #[field(bits = 0..16)]
        pub irq_mask: u16,
    }

    #[repr(u8)]
    pub enum IrqMode {
        Edge = 0,
        Level = 1,
        Hybrid = 2,
        Reserved = 3,  // must cover all bit patterns (2 bits = 4 values)
    }

    #[repr(u8)]
    pub enum IrqSource {
        Timer = 0,
        Gpio = 1,
        Ipc = 2,
        External = 3,
    }
}

Bit Ranges

Field bit ranges use Rust's exclusive-end convention: bits = start..end covers bits [start, end). Width = end − start. Example: bits = 1..5 is 4 bits wide (bits 1, 2, 3, 4).

Register Width

The width parameter on #[register] sets the default register width in bits. Supported values: 8, 16, 32, 64. Default: 32. Individual registers can override with #[reg(... width = 64)]. The width determines the read_volatile/write_volatile pointer type (u8, u16, u32, u64).

Generated Code

The compiler generates:

// Auto-generated by rsc — user never writes this

impl aic::Enable {
    #[inline(always)]
    pub fn read() -> Self {
        let raw: u32 = unsafe {
            core::ptr::read_volatile(0x23B10_0010 as *const u32)
        };
        Self {
            enabled: (raw & 0x1) != 0,
            target_cpu: ((raw >> 1) & 0xF) as u8,
            mode: match ((raw >> 5) & 0x3) as u8 {
                0 => IrqMode::Edge,
                1 => IrqMode::Level,
                2 => IrqMode::Hybrid,
                3 => IrqMode::Reserved,
                _ => core::hint::unreachable_unchecked(), // bit mask guarantees exhaustive
            },
        }
    }

    #[inline(always)]
    pub fn write<F: FnOnce(&mut Self)>(f: F) {
        let mut val = Self::default();
        f(&mut val);
        let raw: u32 = (val.enabled as u32)
            | ((val.target_cpu as u32 & 0xF) << 1)
            | ((val.mode as u32 & 0x3) << 5);
        unsafe {
            core::ptr::write_volatile(0x23B10_0010 as *mut u32, raw);
        }
    }

    #[inline(always)]
    pub fn modify<F: FnOnce(&mut Self)>(f: F) {
        let mut val = Self::read();
        f(&mut val);
        let raw: u32 = /* ... pack fields ... */;
        unsafe {
            core::ptr::write_volatile(0x23B10_0010 as *mut u32, raw);
        }
    }
}

// Status is read-only: no write() or modify() generated
// Clear is write-only: no read() or modify() generated

Compile-Time Guarantees

Check Example Error
Read from write-only register error[RS001]: register aic::Clear is write-only
Write to read-only register error[RS002]: register aic::Status is read-only
Field exceeds register width error[RS003]: field target_cpu (bits 1..5) exceeds u32 width
Field value exceeds bit range error[RS004]: value 20 does not fit in 4-bit field target_cpu
Overlapping field bits error[RS005]: fields enabled and target_cpu overlap at bit 1
Enum variant exceeds field width error[RS006]: IrqMode has 5 variants but field mode is 2 bits (max 4)
Address outside declared bank error[RS007]: offset 0x2000 exceeds bank_size 0x1000
Enum does not cover all bit patterns error[RS008]: IrqMode has 3 variants but field mode is 2 bits (4 patterns) — add a variant for pattern 3

RS004 fires only for compile-time constants and literals that provably exceed the field width. For runtime values, the generated code applies bit masking (e.g. val.target_cpu as u32 & 0xF), silently truncating to the field width. This is consistent with the no-panic restriction in edition = "rs" — a runtime range check would require a panic or error path.

Usage

fn configure_interrupts() {
    // Fully safe — no unsafe anywhere in user code
    aic::Enable::write(|r| {
        r.enabled = true;
        r.target_cpu = 0;
        r.mode = IrqMode::Edge;
    });

    let status = aic::Status::read();
    if status.pending_irq > 0 {
        aic::Clear::write(|r| {
            r.irq_mask = status.pending_irq;
        });
    }

    // Compile error: Status is read-only
    // aic::Status::write(|r| { r.pending_irq = 0; });
}

Unsafe Accounting

The unsafe blocks exist only inside compiler-generated code. They are:

  • Up to 2 per register (one read_volatile for readable registers, one write_volatile for writable registers)
  • Generated from verified attribute metadata
  • Not visible to or writable by the user
  • Auditable in compiler source (~200 lines of codegen)

User-facing code contains zero unsafe.

Reserved Bits

Bits not covered by any #[field] are reserved. The write() function builds from Self::default(), which zeroes reserved bits. The modify() function performs read-modify-write, preserving reserved bits. Use modify() when hardware requires reserved bits to retain their read-back values. Use write() only when zeroing reserved bits is safe (consult the hardware datasheet).

Concurrent Access

Register access is inherently non-atomic at the bus level. The generated read() and write() functions perform a single read_volatile/write_volatile — they do not provide synchronization. If multiple contexts (interrupt handlers, cores) access the same register, the caller must provide synchronization (spinlock, critical section, per-core banking). The modify() function performs a read-modify-write sequence and is subject to TOCTOU if not externally synchronized.

Fallback Compatibility

In standard Rust mode (non-Rs edition), #[register] can be implemented as a proc-macro crate that generates the same code. This means Rs register declarations are valid Rust with the right dependency — they just lack the compiler-level verification.

Error Reference

See errors/registers.md for detailed descriptions of RS001–RS008.

Dimensions

rs/reference/errors/registers
Register Errors (RS001–RS008) [Back to Error Catalog](/rs-reference-errors) | Spec: [registers.md](/rs-reference-registers) Enforcement: proc-macro (`#[register]` attribute). RS001: Read from write-only register Write-only registers (access = "wo") have no `read()` method. Attempting to read a…
rs/macros/src/registers
registers

Local Graph