volatile memory
Tutorial
The Problem
This example covers a specific aspect of Rust's unsafe programming model: raw memory manipulation, FFI interop, allocator customization, or soundness principles. These topics are essential for systems programming — writing OS components, device drivers, game engines, and any code that must interact with C libraries or control memory layout precisely. Rust's unsafe system is designed to confine unsafety to small, auditable regions while maintaining safety in the surrounding code.
🎯 Learning Outcomes
Code Example
use std::ptr;
pub struct MmioDevice { regs: [u32; 8] }
impl MmioDevice {
pub fn new() -> Self { Self { regs: [0u32; 8] } }
pub fn write(&mut self, reg: usize, val: u32) {
assert!(reg < self.regs.len());
unsafe { ptr::write_volatile(self.regs.as_mut_ptr().add(reg), val); }
}
pub fn read(&self, reg: usize) -> u32 {
assert!(reg < self.regs.len());
unsafe { ptr::read_volatile(self.regs.as_ptr().add(reg)) }
}
pub fn set_bits(&mut self, reg: usize, mask: u32) {
let v = self.read(reg);
self.write(reg, v | mask);
}
}Key Differences
unsafe for these operations; OCaml achieves safety through the GC and type system without explicit unsafe regions.extern "C"; OCaml uses ctypes which wraps C types in OCaml values.#[repr(C)], custom allocators); OCaml's GC manages memory layout automatically.OCaml Approach
OCaml's GC and type system eliminate most of the need for these unsafe operations. The equivalent functionality typically uses:
ctypes library for external function callsBigarray for controlled raw memory access Bytes.t for mutable byte sequencesOCaml programs rarely need operations equivalent to these Rust unsafe patterns.
Full Source
#![allow(clippy::all)]
//! 717 — Volatile Reads/Writes for Memory-Mapped I/O
//!
//! `read_volatile` / `write_volatile` prevent the compiler from eliding,
//! reordering, or merging accesses to memory-mapped I/O registers.
//! Every access is treated as observable side-effecting I/O.
use std::ptr;
// ── Register offsets ──────────────────────────────────────────────────────────
pub const REG_STATUS: usize = 0;
pub const REG_DATA: usize = 1;
pub const REG_CTRL: usize = 2;
// ── Status-register bit masks ─────────────────────────────────────────────────
pub const TX_READY: u32 = 0x01;
pub const RX_READY: u32 = 0x02;
// ── Control-register bit masks ────────────────────────────────────────────────
pub const CTRL_ENABLE: u32 = 0x01;
pub const CTRL_RESET: u32 = 0x80;
/// Simulated MMIO device with 8 × u32 registers.
///
/// In real embedded code the struct would hold a raw pointer to a fixed
/// hardware address obtained from a linker script or `mmap`. Here we own
/// the backing array so that we can run tests in userspace without special
/// privileges or hardware.
pub struct MmioDevice {
regs: [u32; 8],
}
impl MmioDevice {
/// Create a zeroed device.
pub fn new() -> Self {
Self { regs: [0u32; 8] }
}
/// Volatile write: every call reaches the "hardware", no elision.
///
/// # Safety contract (internal)
/// `reg < 8` is asserted before the pointer arithmetic, so the pointer
/// is always valid and properly aligned for `u32`.
pub fn write(&mut self, reg: usize, val: u32) {
assert!(reg < self.regs.len(), "register index out of bounds");
// SAFETY: pointer derived from a live `[u32; 8]` at a checked offset.
unsafe {
ptr::write_volatile(self.regs.as_mut_ptr().add(reg), val);
}
}
/// Volatile read: the compiler may not cache the result in a register.
pub fn read(&self, reg: usize) -> u32 {
assert!(reg < self.regs.len(), "register index out of bounds");
// SAFETY: pointer derived from a live `[u32; 8]` at a checked offset.
unsafe { ptr::read_volatile(self.regs.as_ptr().add(reg)) }
}
/// Set individual bits in a register (read-modify-write, both volatile).
pub fn set_bits(&mut self, reg: usize, mask: u32) {
let current = self.read(reg);
self.write(reg, current | mask);
}
/// Clear individual bits in a register (read-modify-write, both volatile).
pub fn clear_bits(&mut self, reg: usize, mask: u32) {
let current = self.read(reg);
self.write(reg, current & !mask);
}
/// Drain the TX FIFO: poll TX_READY, then send each byte as a u32.
///
/// Returns the number of bytes written.
/// Demonstrates the canonical volatile-poll pattern used in real drivers.
pub fn send_bytes(&mut self, bytes: &[u8]) -> usize {
bytes
.iter()
.filter(|&&b| {
// Every status read goes through read_volatile — the compiler
// cannot hoist it out of this loop even if it appears invariant.
let status = self.read(REG_STATUS);
if status & TX_READY != 0 {
self.write(REG_DATA, u32::from(b));
true
} else {
false
}
})
.count()
}
}
impl Default for MmioDevice {
fn default() -> Self {
Self::new()
}
}
// ── Standalone volatile helpers (no wrapper struct) ───────────────────────────
/// Write a u32 to an arbitrary raw pointer using `write_volatile`.
///
/// # Safety
/// The caller must ensure `ptr` is valid, aligned, and exclusively accessible.
pub unsafe fn mmio_write(ptr: *mut u32, val: u32) {
ptr::write_volatile(ptr, val);
}
/// Read a u32 from an arbitrary raw pointer using `read_volatile`.
///
/// # Safety
/// The caller must ensure `ptr` is valid, aligned, and not concurrently mutated.
pub unsafe fn mmio_read(ptr: *const u32) -> u32 {
ptr::read_volatile(ptr)
}
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_and_read_roundtrip() {
let mut dev = MmioDevice::new();
dev.write(REG_DATA, 0xDEAD_BEEF);
assert_eq!(dev.read(REG_DATA), 0xDEAD_BEEF);
}
#[test]
fn test_multiple_registers_are_independent() {
let mut dev = MmioDevice::new();
dev.write(REG_STATUS, 0x01);
dev.write(REG_DATA, 0xFF);
dev.write(REG_CTRL, 0x80);
assert_eq!(dev.read(REG_STATUS), 0x01);
assert_eq!(dev.read(REG_DATA), 0xFF);
assert_eq!(dev.read(REG_CTRL), 0x80);
}
#[test]
fn test_set_and_clear_bits() {
let mut dev = MmioDevice::new();
// Set TX_READY and RX_READY
dev.set_bits(REG_STATUS, TX_READY | RX_READY);
assert_eq!(dev.read(REG_STATUS), TX_READY | RX_READY);
// Clear only TX_READY
dev.clear_bits(REG_STATUS, TX_READY);
assert_eq!(dev.read(REG_STATUS), RX_READY);
}
#[test]
fn test_send_bytes_when_tx_ready() {
let mut dev = MmioDevice::new();
// Simulate hardware signalling TX ready
dev.write(REG_STATUS, TX_READY);
let sent = dev.send_bytes(b"hello");
assert_eq!(sent, 5);
// Last byte written should be b'o'
assert_eq!(dev.read(REG_DATA), u32::from(b'o'));
}
#[test]
fn test_send_bytes_when_tx_not_ready() {
let mut dev = MmioDevice::new();
// TX_READY bit is 0 — no bytes should be transmitted
dev.write(REG_STATUS, 0x00);
let sent = dev.send_bytes(b"hi");
assert_eq!(sent, 0);
}
#[test]
fn test_standalone_mmio_helpers() {
let mut reg: u32 = 0;
unsafe {
mmio_write(&mut reg as *mut u32, 0xCAFE_BABE);
let val = mmio_read(® as *const u32);
assert_eq!(val, 0xCAFE_BABE);
}
}
#[test]
fn test_ctrl_enable_reset_sequence() {
let mut dev = MmioDevice::new();
// Assert reset, then release and enable
dev.write(REG_CTRL, CTRL_RESET);
assert!(dev.read(REG_CTRL) & CTRL_RESET != 0);
dev.clear_bits(REG_CTRL, CTRL_RESET);
dev.set_bits(REG_CTRL, CTRL_ENABLE);
assert_eq!(dev.read(REG_CTRL), CTRL_ENABLE);
}
#[test]
fn test_overwrite_same_register_twice() {
// Both writes must survive — volatile prevents the first from being
// optimised away as "dead" even though nothing reads between them.
let mut dev = MmioDevice::new();
dev.write(REG_DATA, 0x1111_1111);
dev.write(REG_DATA, 0x2222_2222);
assert_eq!(dev.read(REG_DATA), 0x2222_2222);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_and_read_roundtrip() {
let mut dev = MmioDevice::new();
dev.write(REG_DATA, 0xDEAD_BEEF);
assert_eq!(dev.read(REG_DATA), 0xDEAD_BEEF);
}
#[test]
fn test_multiple_registers_are_independent() {
let mut dev = MmioDevice::new();
dev.write(REG_STATUS, 0x01);
dev.write(REG_DATA, 0xFF);
dev.write(REG_CTRL, 0x80);
assert_eq!(dev.read(REG_STATUS), 0x01);
assert_eq!(dev.read(REG_DATA), 0xFF);
assert_eq!(dev.read(REG_CTRL), 0x80);
}
#[test]
fn test_set_and_clear_bits() {
let mut dev = MmioDevice::new();
// Set TX_READY and RX_READY
dev.set_bits(REG_STATUS, TX_READY | RX_READY);
assert_eq!(dev.read(REG_STATUS), TX_READY | RX_READY);
// Clear only TX_READY
dev.clear_bits(REG_STATUS, TX_READY);
assert_eq!(dev.read(REG_STATUS), RX_READY);
}
#[test]
fn test_send_bytes_when_tx_ready() {
let mut dev = MmioDevice::new();
// Simulate hardware signalling TX ready
dev.write(REG_STATUS, TX_READY);
let sent = dev.send_bytes(b"hello");
assert_eq!(sent, 5);
// Last byte written should be b'o'
assert_eq!(dev.read(REG_DATA), u32::from(b'o'));
}
#[test]
fn test_send_bytes_when_tx_not_ready() {
let mut dev = MmioDevice::new();
// TX_READY bit is 0 — no bytes should be transmitted
dev.write(REG_STATUS, 0x00);
let sent = dev.send_bytes(b"hi");
assert_eq!(sent, 0);
}
#[test]
fn test_standalone_mmio_helpers() {
let mut reg: u32 = 0;
unsafe {
mmio_write(&mut reg as *mut u32, 0xCAFE_BABE);
let val = mmio_read(® as *const u32);
assert_eq!(val, 0xCAFE_BABE);
}
}
#[test]
fn test_ctrl_enable_reset_sequence() {
let mut dev = MmioDevice::new();
// Assert reset, then release and enable
dev.write(REG_CTRL, CTRL_RESET);
assert!(dev.read(REG_CTRL) & CTRL_RESET != 0);
dev.clear_bits(REG_CTRL, CTRL_RESET);
dev.set_bits(REG_CTRL, CTRL_ENABLE);
assert_eq!(dev.read(REG_CTRL), CTRL_ENABLE);
}
#[test]
fn test_overwrite_same_register_twice() {
// Both writes must survive — volatile prevents the first from being
// optimised away as "dead" even though nothing reads between them.
let mut dev = MmioDevice::new();
dev.write(REG_DATA, 0x1111_1111);
dev.write(REG_DATA, 0x2222_2222);
assert_eq!(dev.read(REG_DATA), 0x2222_2222);
}
}
Deep Comparison
OCaml vs Rust: Volatile Memory Reads and Writes
Side-by-Side Code
OCaml (Bigarray — closest analog to volatile MMIO)
open Bigarray
type reg32 = (int32, int32_elt, c_layout) Array1.t
let make_mmio_region size_words : reg32 =
Array1.create int32 c_layout size_words
let status_reg = 0
let data_reg = 1
let ctrl_reg = 2
let tx_ready = Int32.of_int 0x01
let rx_ready = Int32.of_int 0x02
(* Bigarray read — compiler does not cache across accesses *)
let mmio_read (regs : reg32) offset = regs.{offset}
(* Bigarray write — side-effecting, not optimised away *)
let mmio_write (regs : reg32) offset value = regs.{offset} <- value
(* Set bits: read-modify-write *)
let set_bits regs offset mask =
let current = mmio_read regs offset in
mmio_write regs offset (Int32.logor current mask)
let () =
let regs = make_mmio_region 8 in
mmio_write regs data_reg 0xDEADBEEFl;
assert (mmio_read regs data_reg = 0xDEADBEEFl);
set_bits regs status_reg tx_ready;
assert (Int32.logand (mmio_read regs status_reg) tx_ready = tx_ready);
print_endline "ok"
Rust (idiomatic — safe wrapper around write_volatile / read_volatile)
use std::ptr;
pub struct MmioDevice { regs: [u32; 8] }
impl MmioDevice {
pub fn new() -> Self { Self { regs: [0u32; 8] } }
pub fn write(&mut self, reg: usize, val: u32) {
assert!(reg < self.regs.len());
unsafe { ptr::write_volatile(self.regs.as_mut_ptr().add(reg), val); }
}
pub fn read(&self, reg: usize) -> u32 {
assert!(reg < self.regs.len());
unsafe { ptr::read_volatile(self.regs.as_ptr().add(reg)) }
}
pub fn set_bits(&mut self, reg: usize, mask: u32) {
let v = self.read(reg);
self.write(reg, v | mask);
}
}
Rust (functional/recursive — standalone volatile helpers)
use std::ptr;
/// Write to an MMIO address without a wrapper type.
pub unsafe fn mmio_write(ptr: *mut u32, val: u32) {
ptr::write_volatile(ptr, val);
}
/// Read from an MMIO address without caching.
pub unsafe fn mmio_read(ptr: *const u32) -> u32 {
ptr::read_volatile(ptr)
}
// Poll-loop pattern: reads must not be hoisted by the optimiser.
pub fn poll_until_ready(ptr: *const u32, mask: u32) {
loop {
if unsafe { ptr::read_volatile(ptr) } & mask != 0 { break; }
}
}
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| MMIO region type | (int32, int32_elt, c_layout) Array1.t | [u32; 8] (or *mut u32) |
| Volatile write | regs.{offset} <- value (Bigarray) | ptr::write_volatile(ptr, val) |
| Volatile read | regs.{offset} (Bigarray) | ptr::read_volatile(ptr) |
| Bit manipulation | Int32.logor, Int32.logand | \|, &, ! (native operators) |
| Safety boundary | Runtime (GC manages all memory) | unsafe block with explicit invariant |
Key Insights
volatile keyword.** The closest analog is Bigarray, whoseelement accesses are backed by C-level memory and are not subject to the same reordering that the OCaml GC might introduce for ordinary heap values. This is a convention rather than a language guarantee.
write_volatile / read_volatile are first-class language primitives.**They emit a barrier to the compiler (not the CPU) that prevents the access from being eliminated, merged, or reordered with adjacent accesses to the same address. This is exactly the guarantee MMIO drivers require.
volatile ≠ atomic.** Volatile suppresses compiler optimisations; atomicsadditionally provide CPU-level ordering guarantees visible to other threads. For single-core MMIO with no DMA, volatile alone is sufficient.
MmioDevice struct encapsulates the unsafe raw pointer operations behind a checked, bounds-safe API. Users call dev.write()
and dev.read() — idiomatic Rust that is impossible to misuse at the call site.
lets the compiler cache the value in a register and loop forever. In Rust this
is explicit: if you forget read_volatile and use *ptr instead, the bug is
visible in the source. In C, volatile is a type qualifier that propagates
automatically through the type system — Rust makes the choice per call-site.
When to Use Each Style
**Use the safe wrapper (MmioDevice):** for any production driver or HAL crate
where you want the unsafe isolated in one place and the public API to be
entirely safe.
**Use the standalone mmio_read / mmio_write helpers:** when writing low-level
board support code where you work directly with linker-provided addresses and do
not want the overhead of a struct abstraction.
Use Bigarray (OCaml): when prototyping driver logic in OCaml or writing tools that parse firmware memory dumps — not for real MMIO access in production embedded software, which is almost exclusively done in C or Rust.
Exercises
bytemuck for transmute, CString for FFI strings) and implement it.