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_0000might represent a device register.
The type:
*volatile u32means:
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:
| Use | Example |
|---|---|
| Memory-mapped I/O | Hardware registers |
| Device drivers | UART, timers, GPUs |
| Embedded systems | Microcontrollers |
| Shared external memory | DMA 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:
| Situation | Tool |
|---|---|
| Hardware register | volatile |
| Thread synchronization | atomics |
| Shared mutable state | mutexes or atomics |
| Ordinary variables | plain 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.