🧬 Types & Qualifiers, In Depth
volatile: "this memory has a mind of its own"
▶ Open the interactive lesson — free, no signupPicture a program that should stop when you press Ctrl+C — but it spins forever, because the compiler "helpfully" optimized away the check you wrote. volatile is the one-word fix. It's also the keyword that lets C code on a tiny board like an Arduino talk to real buttons and LEDs — which is why you'll find it all over embedded code.
Optimizing compilers are aggressive. If your code reads the same variable twice without writing it, the compiler thinks: "same value — I'll read it once and keep it in a register" (a tiny, super-fast storage slot inside the CPU). Usually that's brilliant. But some memory changes behind the compiler's back: addresses wired directly to hardware devices, or variables written by a signal handler — the function that jumps in when you press Ctrl+C. For those, the "optimization" is a bug — and volatile is the fix.
What volatile actually means: every read and write of this object in your source code must really happen in the machine code — the instructions the CPU actually runs — in source order. No caching in registers, no deleting "redundant" accesses, no reordering accesses to volatile objects relative to each other.
Watch the optimizer break your loop
#include <stdio.h>
int flag = 0; /* set from "outside": signal/ISR */
void wait_for_flag(void) {
while (flag == 0) {
/* spin, waiting for the outside world */
}
puts("flag is up!");
}Looks fine, right? Now put on the compiler's glasses: inside the loop, nothing writes flag. Nothing in this translation unit can change it mid-loop (as far as the compiler can prove). So at -O2 it hoists the read out of the loop:
wait_for_flag:
mov eax, DWORD PTR flag[rip] ; read flag ONCE
test eax, eax
jne .Ldone
.Lspin: jmp .Lspin ; while(1) — forever!
.Ldone:
; ... puts("flag is up!")🤔 Was the compiler wrong to do this?
Think first
No — you were. By the rules of the abstract machine, an ordinary int can only change via code the compiler can see. You never told it this one is special. The optimization is 100% legal; the program was incorrect the moment it relied on invisible modification without volatile.
Declare it volatile int flag; and the compiler must re-read memory on every iteration — the loop works again.
🧠 Checkpoint: What does volatile guarantee?
- The variable is stored in the CPU cache
- Every source-level access becomes a real memory access, in order
- Other threads always see updates instantly
- The variable cannot be modified
Show answer
Every source-level access becomes a real memory access, in order — That is the whole contract: no eliding, no caching in registers, no reordering among volatile accesses. It says nothing about threads, atomicity, or caches — those need _Atomic and friends.
Legit use #1: memory-mapped hardware
On microcontrollers (and inside every OS kernel), devices are controlled by reading and writing special addresses. That memory isn't RAM — a "read" might pop a byte out of a UART's receive queue, and two reads of the same address can return different values.
▶ This spot has an interactive memgrid widget — open the interactive lesson to play with it.
#include <stdint.h>
#define UART_STATUS (*(volatile uint32_t *)0x40021000)
#define UART_DATA (*(volatile uint32_t *)0x40021004)
#define RX_READY 0x01u
char uart_getc(void) {
while ((UART_STATUS & RX_READY) == 0) {
/* volatile: STATUS is genuinely re-read each pass */
}
return (char)UART_DATA; /* volatile read pops the byte */
}Without volatile, the compiler would happily read STATUS once and spin forever, or "optimize" two writes to DATA into one — deleting a character you meant to transmit.
🧠 Checkpoint: Why must hardware register accesses be volatile?
- Hardware memory is slower than RAM
- It makes the access atomic
- The register can change (or act!) independently of the program, so no access may be cached, merged, or deleted
- C forbids casting integers to pointers otherwise
Show answer
The register can change (or act!) independently of the program, so no access may be cached, merged, or deleted — Reads can return new values each time and writes can have side effects (transmit a byte, clear an interrupt). Deleting or merging them changes behavior — volatile forbids the compiler from doing so.
Legit use #2: signal handlers (and setjmp)
A signal handler can run between any two instructions of your program — it's exactly the "changes behind your back" scenario. The standard blesses precisely one pattern for sharing a flag with a handler:
#include <signal.h>
#include <stdio.h>
volatile sig_atomic_t got_sigint = 0;
void handler(int sig) {
(void)sig;
got_sigint = 1; /* only safe kind of shared write */
}
int main(void) {
signal(SIGINT, handler);
puts("Working... press Ctrl+C to stop.");
while (!got_sigint) {
/* do work */
}
puts("Caught SIGINT — shutting down cleanly.");
return 0;
}The type volatile sig_atomic_t is the portable contract: volatile so the main loop really re-reads it, sig_atomic_t so reads and writes can't be torn halfway by a signal. Similarly, local variables modified between setjmp and longjmp must be volatile, or their values after the jump are indeterminate — try it in the exercise below.
What volatile is NOT
volatile is not a threading tool. It gives you no atomicity (a 64-bit volatile write can still be torn into two 32-bit stores), no memory barriers (the CPU can still reorder what other cores observe), and no happens-before relationship. Two threads touching a volatile int without synchronization is a data race — undefined behavior. For threads, use _Atomic, mutexes, or condition variables (Part 5). Volatile is for hardware and signals, full stop.
🧠 Checkpoint: Two threads communicate through a volatile int done; flag with no other synchronization. This is…
- Fine — that is what volatile is for
- A data race, i.e. undefined behavior — use
_Atomicor a mutex - Fine on x86, UB elsewhere
- A compile error since C11
Show answer
A data race, i.e. undefined behavior — use _Atomic or a mutex — volatile constrains the COMPILER, not the CPU or the memory model. Concurrent unsynchronized access to a non-atomic object is a data race and UB per C11. (It may "work" on x86 today — that is the worst kind of bug.)
Why the weird name? Think of a "volatile" chemical that evaporates when you're not watching. The variable's value is similarly unstable — the compiler must never assume it "stays put".
▶ This spot has an interactive editor widget — open the interactive lesson to play with it.
From a qualifier that changes how code is generated, we move to a keyword that generates no code at all — it just gives types better names: typedef.