volatile: What It Is, What It Isn't, and Real-World Scenarios
C++ volatile: What It Is, What It Isn’t, and Real-World Scenarios
volatile tells the compiler an object can change “outside normal code flow” and must not be optimized away, cached in registers, or have accesses elided/reordered (as-if within the same thread). It is NOT a synchronization primitive and does NOT make code thread-safe.
- Guarantees each read/write is an observable side-effect at the abstract machine level
- Prevents certain compiler optimizations on that object
- Does NOT provide atomicity, inter-thread happens-before, or fences
Use std::atomic<T> for inter-thread synchronization; use volatile for hardware/OS-driven changes like memory-mapped I/O or signal handlers.
Scenario 1: Memory-Mapped I/O (MMIO)
#include <cstdint>
static volatile uint32_t* const UART_STATUS = reinterpret_cast<volatile uint32_t*>(0x4000'0000);
static volatile uint32_t* const UART_TX = reinterpret_cast<volatile uint32_t*>(0x4000'0004);
void uartWrite(uint8_t byte) {
// Spin until TX empty
while ( (*UART_STATUS & 0x01u) == 0 ) {
// Each read must hit the device register, not a cached value
}
*UART_TX = byte; // write-through to the device
}
Why volatile: Device registers can change independently of your code. The compiler must not eliminate polling loops or cache previous values.
Scenario 2: DMA/Shared Buffer Status Flags from Hardware
struct DmaDesc { volatile uint32_t status; volatile uint32_t length; };
bool isComplete(volatile DmaDesc* d) {
return (d->status & 0x1u) != 0; // always re-read hardware-updated status
}
Note: Volatile ensures each access is made, but cache coherency is a separate hardware concern.
Scenario 3: Spin on a Flag Set by a Signal Handler (same process)
#include <csignal>
#include <atomic>
// Preferred: atomic for signal-safe flags
volatile sig_atomic_t g_stop = 0; // portable signal-safe integer type
void onSigint(int){ g_stop = 1; }
int main(){
std::signal(SIGINT, onSigint);
while (!g_stop) {
// do work, loop exits when signal handler flips flag
}
}
Use sig_atomic_t with volatile in signal handlers. For thread coordination, prefer std::atomic<bool>.
Scenario 4: Prevent Elision of Busy-Wait Delays (device timing)
static volatile int sink;
void shortDelay(volatile int cycles){
for (volatile int i = 0; i < cycles; ++i) {
sink = i; // side effect prevents total loop elimination
}
}
Note: Prefer precise timers; this is for low-level bring-up/testing.
Scenario 5: Contrast — Wrong Use for Thread Communication
#include <thread>
#include <cassert>
volatile bool ready = false; // WRONG for threads
int data;
void producer(){ data = 42; ready = true; }
void consumer(){ while(!ready){} /* spin */ assert(data == 42); }
int main(){ std::thread a(producer), b(consumer); a.join(); b.join(); }
This has a data race and undefined behavior. volatile does not create a happens-before edge. Use std::atomic:
#include <atomic>
std::atomic<bool> ready{false};
int data;
void producer(){ data = 42; ready.store(true, std::memory_order_release); }
void consumer(){ while(!ready.load(std::memory_order_acquire)){} assert(data == 42); }
Scenario 6: Inline Assembly and Volatile Accesses
// Inform compiler that memory could be clobbered by asm
int foo(int* p){
int x;
#if defined(__GNUC__)
__asm__ volatile ("" : "=r"(x) : "0"(*p) : "memory");
#else
x = *p;
#endif
return x;
}
volatile on the asm statement plus the memory clobber prevents reordering across the barrier.
Scenario 7: const volatile for Read-Only Changing Locations
extern const volatile uint32_t RTC_SECONDS; // hardware RTC seconds register
uint32_t now(){ return RTC_SECONDS; } // always read from the register
const volatile means your code cannot modify it, but reads cannot be optimized away.
Scenario 8: Bitfields Backed by Device Register
struct Reg {
volatile unsigned READY : 1;
volatile unsigned ERROR : 1;
volatile unsigned RESERVED : 30;
};
static volatile Reg* const STATUS = reinterpret_cast<volatile Reg*>(0x5000'0000);
bool deviceReady(){ return STATUS->READY; }
Be careful: Bitfield layout is implementation-defined. Prefer masks/shifts on integral volatiles for portability.
Scenario 9: setjmp/longjmp or Exception Boundaries with Volatile Locals
#include <csetjmp>
std::jmp_buf jb;
void g(){ std::longjmp(jb, 1); }
int f(){
volatile int critical = 7; // force store/load around non-local jump
if (setjmp(jb) == 0) { g(); }
return critical; // value is reloaded
}
Volatile ensures the variable remains observable across non-local control flow changes. Use sparingly.
Scenario 10: Compiler Barriers vs Volatile
volatileconstrains optimization of accesses to that object- A compiler barrier (e.g.,
std::atomic_signal_fence, inline asm withmemoryclobber) constrains instruction motion - A hardware fence (
std::atomic_thread_fence) constrains CPU reordering
Choose the right tool for the job.
Quick Checklist
- Use
volatilefor: memory-mapped I/O, signal-handler flags (sig_atomic_t), special timing/polling, read-only changing registers - Do NOT use
volatilefor: thread synchronization, protecting shared data, ensuring atomicity or ordering across threads - Prefer
std::atomic, mutexes, and condition variables for concurrency
References
- C++ Standard (volatile, memory model)
- ISO C++ FAQ: volatile
- Compiler docs on
volatilesemantics and inline assembly barriers