volatile in C — Compiler Optimisation, ISR Safety, and Hardware Registers
Volatile is a keyword in C language that explicitly tells the compiler that this variable can be modified from outside of program scope and thus prevents the compiler from optimizing that variable.
The first question is who can modify it — the answer is: by the operating system, by another thread of execution such as an interrupt routine or signal handler, or by hardware — anyone.
Now another question: what kind of optimization?
For example, whenever a variable is used frequently this leads to frequent read/write operations at that memory location, resulting in lower performance due to time cost. In such cases the compiler caches its value in a register to avoid memory access and improve performance.
This is a good approach for higher performance, but the problem is when another program/OS wants to access the fresh value from that memory location, they will never get it because the compiler has optimized that variable and placed it in a register.
Therefore declaring a variable as volatile tells the compiler that a specific type of behavior is intended, and that such code must not be optimized in such a way that it removes the intended functionality.
Let's see the hidden picture:
//Nonvolatile version of buffer loop
int buffer_full;
int read_stream(void)
{
int count = 0;
while (!buffer_full)
{
count++;
}
return count;
}
//Volatile version of buffer loop
volatile int buffer_full;
int read_stream(void)
{
int count = 0;
while (!buffer_full)
{
count++;
}
return count;
}
The use of the volatile keyword is illustrated in the two sample routines above. Both of these routines loop reading a buffer until a status flag buffer_full is true.
Their machine code is produced by the compiler for each of the samples, where the C code for each has been compiled using Optimization level two (O2).
//Nonvolatile version — ARM assembly
read_stream PROC
LDR r1, |L1.28|
MOV r0, #0
LDR r1, [r1, #0]
|L1.12|
CMP r1, #0
ADDEQ r0, r0, #1
BEQ |L1.12| ; infinite loop
BX lr
ENDP
|L1.28|
DCD ||.data||
AREA ||.data||, DATA, ALIGN=2
buffer_full
DCD 0x00000000
//Volatile version — ARM assembly
read_stream PROC
LDR r1, |L1.28|
MOV r0, #0
|L1.8|
LDR r2, [r1, #0] ; buffer_full — reloaded each iteration
CMP r2, #0
ADDEQ r0, r0, #1
BEQ |L1.8|
BX lr
ENDP
|L1.28|
DCD ||.data||
AREA ||.data||, DATA, ALIGN=2
buffer_full
DCD 0x00000000
In the disassembly of the nonvolatile form, LDR r0, [r0, #0] loads the value of buffer_full into register r0 outside the loop labeled |L1.12|. Because buffer_full is not declared as volatile, the compiler assumes that its value cannot be modified outside the program. Having already read the value of buffer_full into r0, the compiler omits to reload the variable when optimizations are enabled. The result is an infinite loop.
In the volatile form, the compiler assumes the value of buffer_full can change outside the program and performs no optimizations — the value of buffer_full is loaded into the register r0 inside the loop labeled |L1.8| each time.
In coding practice, we must declare a variable as volatile in the following cases:
- To access memory-mapped peripherals.
- Sharing global variables between multiple threads.
- Accessing global variables in an interrupt routine or signal handler.
The compiler will never optimize the variables you have declared as volatile.
What Happens Without volatile
Without volatile, the compiler is free to cache a variable's value in a register and never re-read it from memory. This is normally a useful optimisation — but it breaks code that depends on hardware or another thread changing memory behind the compiler's back.
/* status_reg is a hardware register at a fixed address */
uint32_t *status = (uint32_t *)0x40011000;
/* WITHOUT volatile — compiler may optimise the loop away entirely */
while (*status == 0) { } /* compiler sees: register never changes → infinite loop OR skip */
/* WITH volatile — compiler re-reads from address on every iteration */
volatile uint32_t *status = (volatile uint32_t *)0x40011000;
while (*status == 0) { } /* safe — hardware can change status */
Enable -O2 or higher and inspect the assembly — without volatile, the compiler often emits a single load and compares the cached value in a tight loop or eliminates the loop entirely.
The Three Legitimate Uses of volatile
1. Memory-mapped hardware registers
Any peripheral register that can change independently of program flow must be volatile. This includes status registers, data registers, and interrupt flag registers.
#include <stdint.h>
/* STM32-style UART register struct */
typedef struct {
volatile uint32_t SR; /* status register — hardware sets bits */
volatile uint32_t DR; /* data register — read clears RX flag */
volatile uint32_t BRR; /* baud rate — rarely changes */
volatile uint32_t CR1; /* control 1 */
} USART_TypeDef;
#define USART1 ((USART_TypeDef *)0x40011000)
void uart_send_byte(uint8_t b) {
while (!(USART1->SR & (1 << 7))) { } /* wait TXE bit */
USART1->DR = b;
}
2. Variables modified by an ISR (Interrupt Service Routine)
An ISR runs asynchronously — the main loop has no idea when it fires. Any variable shared between an ISR and the rest of the program must be volatile so the compiler always reads the latest value from memory.
#include <stdint.h>
#include <stdbool.h>
volatile bool uart_rx_ready = false;
volatile uint8_t uart_rx_byte = 0;
/* ISR — fires when UART receives a byte (called by hardware) */
void USART1_IRQHandler(void) {
uart_rx_byte = USART1->DR; /* read clears the flag */
uart_rx_ready = true;
}
/* Main loop */
int main(void) {
while (1) {
if (uart_rx_ready) { /* re-read from memory every iteration */
process(uart_rx_byte);
uart_rx_ready = false;
}
}
}
3. setjmp / longjmp variables
Variables that must survive a longjmp across a setjmp boundary must be volatile. Without it, the compiler may keep them in a register that longjmp restores to a stale value.
#include <setjmp.h>
jmp_buf env;
void error_path(void) { longjmp(env, 1); }
int main(void) {
volatile int counter = 0; /* survives longjmp */
if (setjmp(env) == 0) {
counter = 42;
error_path();
}
/* counter == 42 here because it is volatile */
return 0;
}
volatile const — Both Together
volatile and const are orthogonal and can be combined. The combination means: "I cannot write to it, but hardware can change it." Typical for read-only status registers.
/* Read-only hardware register — I can only read it, hardware can change it */ volatile const uint32_t *RO_REG = (volatile const uint32_t *)0x40012000; uint32_t val = *RO_REG; /* OK — reading allowed */ // *RO_REG = 0; /* ERROR — const prevents writing */
What volatile Does NOT Do
| Misconception | Reality |
|---|---|
| Makes multi-threaded code safe | No. volatile does not provide atomicity or memory ordering. Use stdatomic.h (_Atomic) or OS mutexes for thread safety. |
| Prevents all compiler optimisations | No. Only optimisations involving reads/writes to that specific variable are inhibited. Other code around it is still optimised normally. |
| Guarantees cache coherency on SMP | No. On multi-core systems you still need memory barriers (__DMB(), atomic_thread_fence()) to enforce ordering across cores. |
| Makes ISR variables automatically atomic | No. A 32-bit read on an 8-bit MCU may not be atomic. Use _Atomic or disable interrupts during access for true atomicity. |
volatiletells the compiler "do not optimise away reads and writes to this variable." It says nothing about ordering, atomicity, or visibility across CPU cores.
Quick Reference
| Scenario | Use volatile? | Also need |
|---|---|---|
| Memory-mapped hardware register | ✅ Yes | Nothing extra (single-core) |
| Variable shared with ISR | ✅ Yes | Disable interrupts for multi-byte reads |
| Variable shared between threads | ❌ Not sufficient | _Atomic or mutex |
| setjmp/longjmp surviving variable | ✅ Yes | Nothing extra |
| Read-only hardware register | ✅ Yes | const as well |
| Spin-delay loop | ✅ Yes | Nothing extra (prevents loop removal) |
| Normal local variable | ❌ No | — |