Skip to content

Volatile Memory

Most memory in a program behaves normally.

Most memory in a program behaves normally.

If a value is written:

x = 10;

and then immediately overwritten:

x = 20;

the compiler may remove the first write because it has no observable effect.

This is an important optimization.

But some memory locations are special.

A write to a hardware register may start a device.

A read from a status register may clear a flag.

A memory location shared with another processor may change independently of the current thread.

In such cases, reads and writes must happen exactly as written in the program.

This is called volatile memory.

A volatile pointer is declared like this:

const reg: *volatile u32 = @ptrFromInt(0x4000_0000);

The address:

0x4000_0000

might represent a device register.

The type:

*volatile u32

means:

This pointer refers to a u32.

Reads and writes through the pointer are observable side effects.

The compiler must not remove, merge, or reorder them incorrectly.

Writing to the register:

reg.* = 1;

must generate a real store instruction.

Reading from the register:

const value = reg.*;

must generate a real load instruction every time.

Without volatile, the compiler might cache the value in a register or eliminate repeated accesses.

Volatile memory is mainly used for:

UseExample
Memory-mapped I/OHardware registers
Device driversUART, timers, GPUs
Embedded systemsMicrocontrollers
Shared external memoryDMA buffers

Ordinary program variables should almost never be volatile.

For example:

var counter: volatile u32 = 0;

is usually wrong.

Volatile does not make code thread-safe.

Volatile does not provide synchronization.

Volatile does not replace atomics.

If multiple threads access shared memory concurrently, use atomic operations or synchronization primitives.

For example:

const std = @import("std");

var counter: u32 = 0;

pub fn main() void {
    _ = &counter;
}

Making counter volatile would not prevent data races.

Use atomics instead.

const std = @import("std");

var counter: u32 = 0;

pub fn increment() void {
    _ = @atomicRmw(u32, &counter, .Add, 1, .seq_cst);
}

Volatile controls compiler optimization of memory access. Atomics control synchronization between threads and processors.

The two concepts solve different problems.

Volatile pointers are often combined with packed structures.

const Registers = packed struct {
    control: u32,
    status: u32,
};

const regs: *volatile Registers =
    @ptrFromInt(0x4000_0000);

This maps a struct directly onto hardware registers.

Then:

regs.control = 1;

writes to the hardware control register.

And:

const s = regs.status;

reads the hardware status register.

Such code depends completely on the exact memory layout.

This style is common in kernels, firmware, bootloaders, and embedded systems.

Volatile can also appear on many-item pointers.

const buffer: [*]volatile u8 =
    @ptrFromInt(0x5000_0000);

Each byte access is treated as observable.

A volatile access affects only the operation itself.

const a = reg.*;
const b = reg.*;

These are two distinct loads.

The compiler cannot assume the second value equals the first.

This matters because hardware can change the register between accesses.

Volatile does not freeze surrounding code motion completely. It only constrains the accesses themselves.

Precise memory ordering between CPUs requires atomics and fences.

A useful rule is:

SituationTool
Hardware registervolatile
Thread synchronizationatomics
Shared mutable statemutexes or atomics
Ordinary variablesplain memory

Volatile should remain local and explicit.

Good style:

const uart: *volatile u8 =
    @ptrFromInt(0x1000_0000);

Poor style:

var everything: volatile SomeStruct = ...;

The first marks exactly the memory that requires special handling. The second spreads volatile semantics across unrelated operations.

Most Zig programs never need volatile memory. When it appears, the program is usually interacting directly with hardware or low-level runtime code.

Exercise 19-17. Declare a *volatile u32 using @ptrFromInt.

Exercise 19-18. Explain why volatile does not make a counter thread-safe.

Exercise 19-19. Write a packed struct representing two device registers.

Exercise 19-20. Explain why repeated reads from a volatile pointer cannot be optimized into one read.