ExamplesBy LevelBy TopicLearning Paths
717 Fundamental

volatile memory

Functional Programming

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

  • • The specific unsafe feature demonstrated: volatile memory
  • • When this feature is necessary vs when safe alternatives exist
  • • How to use it correctly with appropriate SAFETY documentation
  • • The invariants that must be maintained for the operation to be sound
  • • Real-world contexts: embedded systems, OS kernels, C FFI, performance-critical code
  • 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

  • Safety model: Rust requires explicit unsafe for these operations; OCaml achieves safety through the GC and type system without explicit unsafe regions.
  • FFI approach: Rust uses raw C types directly with extern "C"; OCaml uses ctypes which wraps C types in OCaml values.
  • Memory control: Rust allows complete control over memory layout (#[repr(C)], custom allocators); OCaml's GC manages memory layout automatically.
  • Auditability: Rust unsafe regions are syntactically visible and toolable; OCaml unsafe operations (Obj.magic, direct C calls) are also explicit but less common.
  • OCaml Approach

    OCaml's GC and type system eliminate most of the need for these unsafe operations. The equivalent functionality typically uses:

  • • C FFI via the ctypes library for external function calls
  • Bigarray for controlled raw memory access
  • • The GC for memory management (no manual allocators needed)
  • Bytes.t for mutable byte sequences
  • OCaml 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(&reg 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);
        }
    }
    ✓ Tests Rust test suite
    #[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(&reg 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

    ConceptOCamlRust
    MMIO region type(int32, int32_elt, c_layout) Array1.t[u32; 8] (or *mut u32)
    Volatile writeregs.{offset} <- value (Bigarray)ptr::write_volatile(ptr, val)
    Volatile readregs.{offset} (Bigarray)ptr::read_volatile(ptr)
    Bit manipulationInt32.logor, Int32.logand\|, &, ! (native operators)
    Safety boundaryRuntime (GC manages all memory)unsafe block with explicit invariant

    Key Insights

  • **OCaml has no volatile keyword.** The closest analog is Bigarray, whose
  • element 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.

  • **Rust 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.

  • **volatileatomic.** Volatile suppresses compiler optimisations; atomics
  • additionally provide CPU-level ordering guarantees visible to other threads. For single-core MMIO with no DMA, volatile alone is sufficient.

  • Safe wrapper pattern. The 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.

  • Every access must be volatile. A single non-volatile read in a polling loop
  • 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

  • Minimize unsafe: Find the smallest possible unsafe region in the source and verify that all safe code is outside the unsafe block.
  • Safe alternative: Identify if a safe alternative exists for the demonstrated technique (e.g., bytemuck for transmute, CString for FFI strings) and implement it.
  • SAFETY documentation: Write a complete SAFETY comment for each unsafe block listing preconditions, invariants, and what would break if violated.
  • Open Source Repos