volatile
volatile prevents the compiler from caching or eliding accesses to memory that changes outside program control — essential for MMIO, not a substitute for std::atomic.
volatilesince C++98A type qualifier that forces the compiler to treat every read and write to the qualified object as an observable side effect — preventing register caching, dead-store elimination, and reordering of volatile accesses relative to one another within a single thread.
Overview
The compiler's optimizer assumes that memory it writes to only changes when the program explicitly writes to it. For hardware registers, DMA buffers, and signal-handler flags, that assumption is false. volatile breaks it: every access to a volatile object must appear in the emitted object code, in source order relative to other volatile accesses.
The C++ standard (since C++98) categorizes a volatile access as an observable side effect of the abstract machine. Side effects cannot be elided or reordered against other side effects in the same thread.
What volatile guarantees — all at the compiler level:
- No dead-store elimination — every write is emitted, even if the value is immediately overwritten
- No load caching — every read fetches from the memory location, not a register copy
- Relative ordering of volatile accesses is preserved within a single thread
What volatile does not guarantee:
- Atomicity — a
volatile uint64_ton a 32-bit platform may still be two separate 32-bit operations - Thread-safety — no happens-before relationship is established between threads
- Hardware memory ordering — on weakly-ordered architectures (ARM, POWER), the CPU can still reorder stores past other cores' loads absent explicit barrier instructions
The last point is decisive. volatile is entirely a compiler directive. It does not emit fence instructions. On x86 (Total Store Order), this is often invisible. On ARM or POWER, it is a correctness bug waiting to trigger.
Syntax
volatile int sensor; // volatile-qualified variable
volatile uint32_t* reg; // pointer to volatile uint32_t
const volatile uint32_t* status; // pointer to const volatile — read-only hardware register
uint32_t* volatile vptr; // volatile pointer (rarely useful)
const volatile uint32_t* const creg = ...; // const pointer to const volatileMember functions can be volatile-qualified, analogous to const member functions. A volatile member function may only be called through a volatile object, pointer, or reference:
struct Register {
volatile uint32_t value;
void write(uint32_t v) volatile { value = v; }
uint32_t read() const volatile { return value; }
};Examples
Memory-Mapped I/O
Hardware registers change state independently of the CPU — without volatile, the compiler may cache a register's value in a CPU register and never re-read from the actual peripheral address.
#include <cstdint>
// UART peripheral layout (STM32F4xx / ARM Cortex-M)
struct UART_t {
volatile uint32_t SR; // status — hardware clears bits on read/write
volatile uint32_t DR; // data — read to receive, write to transmit
volatile uint32_t BRR; // baud rate
volatile uint32_t CR1; // control 1
};
constexpr uintptr_t UART1_BASE = 0x4001'1000;
volatile UART_t* const UART1 = reinterpret_cast<UART_t*>(UART1_BASE);
constexpr uint32_t SR_TXE = 1u << 7; // TX buffer empty
constexpr uint32_t SR_RXNE = 1u << 5; // RX data available
void uart_send(char c) {
// Without volatile on SR, optimizer may hoist the read outside the loop
while (!(UART1->SR & SR_TXE)) {}
UART1->DR = static_cast<uint32_t>(c);
}
char uart_recv() {
while (!(UART1->SR & SR_RXNE)) {}
return static_cast<char>(UART1->DR & 0xFF); // read clears RXNE flag
}The double-write pattern shows dead-store prevention — some peripherals require a specific write sequence:
volatile uint32_t* ctrl = reinterpret_cast<volatile uint32_t*>(0x4000'0010);
*ctrl = 0x00; // without volatile: compiler sees a dead store and may elide this
*ctrl = 0xFF; // with volatile: both writes are emitted, in orderconst volatile — Read-Only Hardware Registers
Status and ID registers are written by hardware but should never be written by software. const volatile encodes both constraints, making an accidental write a compile error:
const volatile uint32_t* const CHIP_ID =
reinterpret_cast<const volatile uint32_t*>(0x1FFF'7A10);
uint32_t read_chip_id() {
return *CHIP_ID; // volatile: must actually load; const: write would not compile
}Signal Handlers
POSIX signals interrupt program flow asynchronously. Only volatile sig_atomic_t is guaranteed by the C standard to be safe for read/write from a signal handler without a data race:
#include <csignal>
#include <cstdio>
volatile std::sig_atomic_t g_interrupted = 0; // C++98 and later
extern "C" void handle_sigint(int /*sig*/) {
g_interrupted = 1; // sig_atomic_t assignment is atomic w.r.t. async signals
}
int main() {
std::signal(SIGINT, handle_sigint);
while (!g_interrupted) {
// do work — volatile prevents caching g_interrupted in a register
}
std::puts("shutting down");
}setjmp / longjmp
The standard specifies that local variables modified between setjmp and the longjmp that returns to it must be volatile to have defined values after the jump:
#include <csetjmp>
std::jmp_buf env;
void recover() {
volatile int step = 0; // value survives longjmp
int cached = 0; // value is indeterminate after longjmp — C++98 §18.7
if (setjmp(env) != 0) {
// step reliably holds the value assigned before longjmp
// cached may be anything
return;
}
step = 1;
// ... deep call stack that may longjmp ...
step = 2;
}volatile vs std::atomic
This is the most consequential distinction in modern C++. C++11 introduced std::atomic and a formal memory model that makes volatile's inadequacy for threading explicit in the standard.
| Property | volatile | std::atomic<T> (C++11) |
|---|---|---|
| Suppresses register caching | Yes | Yes (as a side effect) |
| Suppresses dead-store elision | Yes | Yes |
| Indivisible read-modify-write | No | Yes |
| Synchronization / happens-before | No | Yes |
| Compiler fence | No | Yes |
| Hardware fence (where required) | No | Yes |
| Correct for MMIO | Yes | Generally no |
| Correct for inter-thread flags | No | Yes |
// WRONG: volatile is not thread-safe — undefined behavior per C++11 memory model
volatile bool ready = false;
int shared = 0;
// Thread A
shared = 42;
ready = true; // compiler preserves order relative to other volatile accesses,
// but no barrier instruction — ARM hardware may reorder the store
// Thread B
while (!ready) {}
int x = shared; // may read 0 on ARM; data race is UB regardless of architecture
// CORRECT: std::atomic with explicit ordering (C++11)
std::atomic<bool> ready{false};
int shared = 0;
// Thread A
shared = 42;
ready.store(true, std::memory_order_release); // release barrier: shared visible before ready
// Thread B
while (!ready.load(std::memory_order_acquire)) {}
int x = shared; // guaranteed to observe 42 — acquire/release establishes happens-beforeThe rare case where both are needed — a hardware-atomic semaphore accessible via MMIO — volatile std::atomic<T> is valid C++11:
// Hardware-atomic test-and-set register at a fixed physical address
volatile std::atomic<uint32_t>* hw_sem =
reinterpret_cast<volatile std::atomic<uint32_t>*>(0x4000'FF00);
uint32_t expected = 0;
hw_sem->compare_exchange_strong(expected, 1u, std::memory_order_acquire);Best Practices
- Apply
volatileto the pointee type, not the pointer itself:volatile uint32_t* reg(pointer to volatile), notuint32_t* volatile reg(volatile pointer to non-volatile — almost never what you want) - Use
const volatilefor hardware registers the software should never write - Group peripheral registers into
volatilestructs rather than scattering individual volatile variables across the codebase - In C++11 and later, use
std::atomicfor any flag or value shared between threads — even on x86, it is UB to rely on volatile for this - Prefer
std::atomic_signal_fence(std::memory_order_seq_cst)(C++11) over volatile as a compiler barrier in signal-handler-adjacent code
Common Pitfalls
Assuming volatile implies atomicity. On a 32-bit ARM reading a volatile uint64_t may emit two separate LDR instructions. A concurrent writer between them produces a torn read. Only std::atomic<uint64_t> guarantees indivisibility.
Working fine on x86, breaking on ARM. x86's Total Store Order memory model masks volatile's thread-safety deficiencies. Code that uses volatile for inter-thread flags frequently passes all x86 tests and fails deterministically on ARM or POWER under load. This is undefined behavior in C++11 regardless of the platform.
Casting away volatile with const_cast. The syntax is legal, but writing through the resulting non-volatile pointer to an object that is genuinely volatile (e.g., an MMIO register) is undefined behavior — and the compiler may elide the write entirely once the volatile qualifier is gone.
Over-relying on volatile for secure memory erasure. Writing volatile to a local buffer before it goes out of scope prevents the compiler from eliding the stores in practice, but the standard's guarantee is thin. For clearing sensitive memory, prefer explicit_bzero (POSIX.1-2024), SecureZeroMemory (Windows), or writing through a volatile pointer cast of the buffer address specifically to defeat the optimizer with documented intent.
See Also
std::atomic— lock-free atomic operations with configurable memory ordering (C++11)std::memory_order— synchronization semantics for atomic operations (C++11)std::sig_atomic_t— integer type guaranteed safe for signal handler read/writestd::atomic_signal_fence— compiler-only fence between signal handler and hosting thread (C++11)const_cast— the only cast that can add or removevolatilequalification