Building Safe and Concurrent Memory-Mapped Register Access in Rust: The mmreg Journey

Introduction

In the world of embedded Linux and hardware programming, direct memory access is both a necessity and a minefield. Whether you’re working with custom FPGA designs, interfacing with peripheral devices, or building user-space drivers, you need to read and write to memory-mapped registers. But doing so safely, efficiently, and correctly across multiple processes is surprisingly challenging.

This is the story of mmreg – a Rust library and CLI tool that brings safety, concurrency, and simplicity to memory-mapped I/O (MMIO) register access from user-space on Linux systems. Note: mmreg is designed for user-space applications on embedded Linux (via /dev/mem), not for bare-metal or kernel-space development.

The Problem: Memory-Mapped I/O is Hard

What is Memory-Mapped I/O?

In modern computing systems, hardware devices don’t just communicate through dedicated I/O instructions. Instead, specific regions of the physical address space are mapped to hardware registers. When you write to address 0x40000000, you might actually be configuring a GPIO pin, starting a DMA transfer, or enabling an interrupt controller.

This approach, called Memory-Mapped I/O (MMIO), is elegant but fraught with dangers:

  1. Raw Pointer Arithmetic: You’re dealing with raw memory addresses, often requiring unsafe Rust code
  2. Concurrent Access: Multiple processes might try to access the same register simultaneously, leading to race conditions
  3. Page Alignment: Operating systems map memory in pages, requiring careful address alignment calculations
  4. Permission Issues: Accessing /dev/mem requires root privileges and proper error handling
  5. Platform Dependencies: User-space access to hardware registers varies across operating systems
  6. Kernel vs User-space: This approach works on Linux systems with /dev/mem access, not bare-metal environments

The Traditional Approach

Historically, developers have written custom, unsafe code for each project:

// Traditional C approach
int fd = open("/dev/mem", O_RDWR | O_SYNC);
void *mapped = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, 
                    MAP_SHARED, fd, base_address);
volatile uint32_t *reg = (uint32_t *)(mapped + offset);
*reg = value;  // Hope nothing goes wrong!

This works, but it’s:

  • Unsafe: No compile-time guarantees
  • Error-prone: Easy to miscalculate offsets or sizes
  • Non-portable: Platform-specific code scattered everywhere
  • Race-condition prone: No built-in concurrency safety

Enter mmreg: Safety Meets Hardware

mmreg was born from a simple question: Can we bring Rust’s safety guarantees to the inherently unsafe world of hardware register access?

Core Design Principles

1. Safety First, Performance Second

While direct memory access is inherently unsafe at the FFI boundary, mmreg encapsulates all unsafe code behind safe abstractions:

use mmreg::{read_register_at, write_register_at};

// Safe, ergonomic API
let value = read_register_at(0x4000_0000)?;
write_register_at(0x4000_0000, 0xDEADBEEF)?;

No raw pointers in user code. No manual mmap calculations. Just clean, idiomatic Rust.

2. Concurrency Through File Locking

One of mmreg’s killer features is safe concurrent access. Using the fs2 crate, mmreg automatically acquires exclusive locks before accessing memory:

// Process A
interface.map()?;  // Acquires lock
interface.write_register("control", 0x1)?;
interface.unmap();  // Releases lock

// Process B blocks here until Process A releases
interface.map()?;
let val = interface.read_register("status")?;

This prevents race conditions and data corruption when multiple programs access the same hardware.

3. Register Abstraction and Organization

Real hardware comes with dozens or hundreds of registers. mmreg provides structures to organize them logically:

use mmreg::{Interface, Register, SubRegister};

let mut axi_interface = Interface::new(
    "AXI_GPIO",
    0x4000_0000,
    0x1000,
    vec![
        Register::new("DATA", 0x00, vec![
            SubRegister::new("LED", 0, 8),
            SubRegister::new("SWITCH", 8, 8),
        ]),
        Register::new("CONTROL", 0x04, vec![]),
    ]
);

// Access by name, not magic numbers
axi_interface.write_subregister("DATA", "LED", 0xFF)?;

No more hunting through datasheets to remember that bit 3 of offset 0x08 controls the interrupt enable!

4. Bitfield Manipulation Made Easy

Hardware registers are often packed with multiple fields. SubRegisters provide clean bitfield access:

// Instead of: raw = (raw & !mask) | ((value << shift) & mask)
interface.write_subregister("STATUS", "ERROR_CODE", error_code)?;

// Instead of: value = (raw & mask) >> shift
let code = interface.read_subregister("STATUS", "ERROR_CODE")?;

The library handles all the masking, shifting, and read-modify-write operations.

Architecture: Inside mmreg

Module Structure

mmreg is organized into focused modules:

  • memregs.rs: Low-level mmap/munmap wrappers (Linux-specific, unsafe)
  • register.rs: Register and SubRegister abstractions
  • interface.rs: High-level interface for grouped registers with locking
  • lib.rs: Public API and convenience functions
  • main.rs: Command-line interface

The Memory Mapping Flow

  1. Address Alignment: Calculate page-aligned base address
  2. File Locking: Acquire exclusive lock on /tmp/mmreg.lock
  3. mmap System Call: Map physical memory to process virtual space
  4. Volatile Access: Use std::ptr::{read_volatile, write_volatile}
  5. Cleanup: Munmap and release lock on drop

Cross-Compilation Strategy

Embedded developers need to build for multiple architectures. mmreg’s build system is designed for this:

# Single source of truth: targets.txt
x86_64-unknown-linux-gnu
armv7-unknown-linux-gnueabihf
aarch64-unknown-linux-gnu
riscv64gc-unknown-linux-gnu
# ... and more

# Makefile reads targets.txt
make all  # Builds for all targets using 'cross'

The GitHub Actions CI automatically builds and tests against all targets in targets.txt, ensuring compatibility across platforms.

Real-World Use Cases

Embedded Linux Systems Development

Working with FPGA soft-core processors or custom IP blocks on embedded Linux (e.g., Zynq, Raspberry Pi, or custom SoCs)? mmreg provides a clean interface:

// Configure AXI GPIO
let mut gpio = Interface::new("GPIO", 0x40000000, 0x1000, registers);
gpio.write_register("DIRECTION", 0xFF)?;  // All outputs
gpio.write_register("DATA", 0xAA)?;        // Alternating pattern

User-Space Driver Development

Building Linux device drivers in user-space (using UIO, /dev/mem, or similar mechanisms):

// Safe DMA configuration
let mut dma = Interface::new("DMA", DMA_BASE, 0x10000, dma_regs);
dma.write_subregister("CTRL", "ENABLE", 1)?;
dma.write_register("SRC_ADDR", source as u32)?;
dma.write_register("DST_ADDR", dest as u32)?;
dma.write_subregister("CTRL", "START", 1)?;

// Poll status
while dma.read_subregister("STATUS", "BUSY")? == 1 {
    std::thread::sleep(Duration::from_millis(1));
}

Hardware Testing and Validation

The CLI tool is perfect for quick hardware tests:

# Read device ID register
mmreg read 0x40000000

# Write test pattern
mmreg write 0x40000004 0xDEADBEEF

# Script automated tests
for addr in $(seq 0x40000000 4 0x400000FF); do
    mmreg write $addr 0x12345678
    value=$(mmreg read $addr)
    echo "0x$(printf %X $addr): $value"
done

Quality Assurance: CI/CD Pipeline

mmreg takes quality seriously with a comprehensive GitHub Actions workflow:

Three-Stage Pipeline

  1. Lint Stage: Code formatting (cargo fmt) and Clippy warnings
  2. Check Stage: Compilation verification and tests
  3. Build Stage: Cross-compilation for all supported targets (only runs if lint and check pass)
jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - run: make lint
  
  check:
    runs-on: ubuntu-24.04
    steps:
      - run: make check
  
  build:
    needs: [lint, check]
    steps:
      - run: make all  # Builds all targets from targets.txt

Automated Publishing

Publishing to crates.io is as simple as creating a git tag:

git tag v0.1.2
git push origin v0.1.2
# GitHub Actions automatically publishes to crates.io

Performance Considerations

Volatile Access

All register reads and writes use volatile operations, ensuring the compiler never optimizes away hardware accesses:

pub(crate) fn read_u32_mapped(ptr: *mut u8, offset: isize, reg_offset: u32) -> u32 {
    let reg_ptr = unsafe { ptr.offset(offset + reg_offset as isize) } as *const u32;
    unsafe { std::ptr::read_volatile(reg_ptr) }
}

Zero-Copy Design

mmreg maps memory directly into the process address space – no intermediate buffers or copies. Reads and writes are as fast as direct hardware access.

Lock Overhead

File locking adds minimal overhead (microseconds) compared to the actual hardware access time (typically nanoseconds to milliseconds depending on the device).

Future Roadmap

mmreg is actively developed with exciting features planned:

Near-Term

  • Multi-width Registers: Support for 8, 16, 64-bit registers (currently 32-bit only)
  • Batch Operations: Read/write multiple registers atomically
  • Better Error Types: Replace String errors with proper enum types
  • Integration Tests: Automated testing with QEMU or hardware simulators

Long-Term

  • Async API: Non-blocking register access for high-performance scenarios
  • Windows Support: Memory-mapped I/O on Windows platforms
  • Register Discovery: Auto-generate register definitions from device tree or IP-XACT

Getting Started

Installation

Add mmreg to your Cargo.toml:

[dependencies]
mmreg = "0.1"

Or install the CLI tool:

cargo install mmreg

Basic Example

use mmreg::{Interface, Register};

fn main() -> Result<(), String> {
    // Define your registers
    let registers = vec![
        Register::new("ID", 0x00, vec![]),
        Register::new("CONTROL", 0x04, vec![]),
    ];
    
    // Create interface
    let mut device = Interface::new(
        "MyDevice",
        0x40000000,
        0x1000,
        registers
    );
    
    // Read device ID
    let id = device.read_register("ID")?;
    println!("Device ID: 0x{:08X}", id);
    
    // Enable device
    device.write_register("CONTROL", 0x1)?;
    
    Ok(())
}

CLI Usage

# Read register
sudo mmreg read 0x40000000

# Write register
sudo mmreg write 0x40000000 0xDEADBEEF

Community and Contributing

mmreg is open source under the MIT license. Contributions are welcome!

Contributing Guidelines

  1. Add tests: Especially for bitfield operations
  2. Update targets.txt: If you add a new cross-compilation target
  3. Follow Rust conventions: Code passes cargo fmt and cargo clippy
  4. Document public APIs: All public functions need doc comments

Conclusion: Safe Hardware Access for the Modern Era

mmreg demonstrates that systems programming doesn’t have to sacrifice safety for performance. By leveraging Rust’s type system and careful API design, we can build abstractions that are:

  • Safe: No undefined behavior in user code
  • Fast: Zero-overhead abstractions over raw hardware access
  • Ergonomic: Clean, intuitive APIs that reduce cognitive load
  • Concurrent: Multi-process safe by design
  • Portable: Cross-compiles to major embedded platforms

Whether you’re building embedded Linux applications, writing user-space drivers, or testing hardware on Linux systems, mmreg provides the foundation for reliable, maintainable memory-mapped I/O code.

Important: mmreg is designed for user-space applications on Linux systems with /dev/mem access (embedded Linux, standard Linux with appropriate permissions). For bare-metal or RTOS development, consider embedded-hal or platform-specific PACs (Peripheral Access Crates).

The future of user-space hardware programming is safe, concurrent, and written in Rust. Welcome to mmreg.


Ready to try mmreg? Install it today:

cargo install mmreg

Or add it to your project:

[dependencies]
mmreg = "0.1"

Star the project on GitHub and join the community of safe systems programmers!