Low-Level Systems Programming

Volatile Memory Operations

In ordinary memory accesses, the compiler assumes that memory behaves like passive RAM: if you write 0 and then 1 to the same address without reading in between, the optimizer deletes the dead write of 0; if you read the same address in a while loop without modifying it, the optimizer hoists the read out of the loop into a CPU register.

Volatile memory operations (core::ptr::read_volatile and core::ptr::write_volatile) instruct the compiler that the memory access itself is an observable side effect: the compiler must emit the exact machine load or store instruction once per call, in the exact program order relative to other volatile operations, without eliding, merging, or duplicating the access.

Ordinary Pointer Write (Dead Store Elimination!):
  *ptr = 0x01;   ---> Deleted by LLVM optimizer!
  *ptr = 0x02;   ---> Only `0x02` is written to memory.

Volatile Pointer Write (Hardware Pulse Preserved!):
  ptr::write_volatile(ptr, 0x01);  ---> Emits `str` / `mov` of 0x01
  ptr::write_volatile(ptr, 0x02);  ---> Emits `str` / `mov` of 0x02

Three Primary Use Cases for Volatile Operations

Use Case Why Ordinary Reads/Writes Fail How read_volatile / write_volatile Fixes It
Memory-Mapped Hardware Registers (MMIO) Polling a status register in a loop (while *status == 0 {}) is optimized into an infinite loop reading a cached register, and writing a command sequence (*cmd = RESET; *cmd = START;) drops RESET. Emits a fresh hardware bus read on every iteration and preserves every register write in sequence.
Zeroing Cryptographic Secrets Before Drop Zeroing a local key array (key.fill(0)) right before the array goes out of scope is deleted by the compiler as a "dead store" because nothing reads key afterward! write_volatile forces the zero bytes to be written to stack/heap RAM before freeing.
Shared Memory Mapped Across Untrusted Processes The compiler assumes a single read can be re-read from the shared buffer later (TOCTOU double-fetch bug). read_volatile copies the value once into a local stack variable so it cannot change mid-validation.

Securely Zeroing a Secret Key Buffer on Drop

If you zero a secret key buffer using self.bytes.fill(0) inside Drop::drop, LLVM's Dead Store Elimination (DSE) pass sees that self.bytes is never read again after drop() finishes and optimizes out the entire zeroing loop, leaving the secret key sitting in freed memory!

Using ptr::write_volatile on each byte guarantees that the compiler emits the zeroing stores:

use std::ptr;

pub struct SecretKey {
    bytes: [u8; 32],
}

impl SecretKey {
    pub fn new(bytes: [u8; 32]) -> Self {
        Self { bytes }
    }

    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.bytes
    }
}

impl Drop for SecretKey {
    fn drop(&mut self) {
        // Zero every byte using `write_volatile` so the compiler cannot optimize
        // away the wipe as a dead store!
        for byte_ref in &mut self.bytes {
            // SAFETY: `byte_ref` is a valid, aligned pointer to a `u8`.
            unsafe {
                ptr::write_volatile(byte_ref, 0);
            }
        }
        // Compiler fence prevents reordering surrounding accesses across the wipe
        std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
    }
}

fn main() {
    let key = SecretKey::new([0xAA; 32]);
    println!("First key byte while active: {:#x}", key.as_bytes()[0]);
    drop(key);
    println!("SecretKey securely zeroed via write_volatile on drop");
}
First key byte while active: 0xaa
SecretKey securely zeroed via write_volatile on drop

Critical Misconception: Volatile Is NOT for Multi-Threaded Synchronization!

Programmers coming from C#/Java (or legacy C) sometimes confuse volatile with thread synchronization. In Rust (as in C11/C++11):

  • Volatile operations do NOT prevent data races between CPU threads and do not emit CPU memory barrier instructions (dmb on ARM, mfence on x86).
  • Two threads concurrently reading and writing the same variable via read_volatile and write_volatile is still a data race and Undefined Behavior.
  • Always use core::sync::atomic::Atomic* types for inter-thread communication and synchronization!