Why is the volatile keyword essential in embedded C for hardware registers and ISR-shared variables?
The volatile keyword tells the compiler that a variable's value can change at any time without any action being taken by the code the compiler is aware of. This prevents the compiler from optimizing away reads or caching the value in a register.
Why it is needed:
Hardware registers: A memory-mapped I/O register can change value between two reads because the hardware updates it. Without
volatile, the compiler may read it once, cache the value in a CPU register, and reuse the stale value.ISR-shared variables: A variable modified in an Interrupt Service Routine and read in the main loop. Without
volatile, the compiler may hoist the read out of the loop (it sees no write in the loop body and assumes the value never changes).Memory-mapped status polling: Spinning on a status register waiting for a bit to change. Without
volatile, the compiler optimizes the loop into an infinite loop (reads the register once, sees the bit is not set, loops forever without re-reading).
What volatile does NOT do:
- It does NOT provide atomicity. A
volatile uint32_tread/write is still non-atomic on 8-bit MCUs. - It does NOT provide memory barrier semantics. It prevents the compiler from optimizing away the access, but does not prevent CPU reordering (though on most embedded MCUs, memory ordering is strict).
- It does NOT replace mutexes or disable interrupts for data consistency.
Without volatile:
while (STATUS_REG & BUSY_BIT); /* compiler reads once, loops forever */
With volatile:
while (*(volatile uint32_t *)STATUS_ADDR & BUSY_BIT);
/* compiler re-reads from memory on every iteration */
ISR pattern:
volatile int flag = 0;
ISR: flag = 1;
main: while (!flag); /* works because volatile forces re-read */
#include <stdio.h>
#include <stdint.h>
/* Example 1: Hardware register polling */
#define STATUS_REG (*(volatile uint32_t *)0x40001000)
#define DATA_REG (*(volatile uint32_t *)0x40001004)
#define BUSY_BIT (1U << 0)
void wait_for_ready(void) {
/* Without volatile, compiler may optimize this to:
if (STATUS_REG & BUSY_BIT) while(1); */
while (STATUS_REG & BUSY_BIT)
; /* spin until hardware clears BUSY */
}
/* Example 2: ISR-shared variable */
volatile int data_ready = 0;
volatile uint8_t rx_buffer[64];
/* Called from interrupt context */
void UART_ISR(void) {
/* Read hardware, fill buffer */
rx_buffer[0] = DATA_REG & 0xFF;
data_ready = 1; /* Signal main loop */
}
void main_loop(void) {
while (1) {
/* Without volatile, compiler may read data_ready once
and never re-check it */
if (data_ready) {
/* Process rx_buffer */
data_ready = 0;
}
}
}
/* Example 3: Volatile pointer to non-volatile data vs
non-volatile pointer to volatile data */
void pointer_qualifiers(void) {
volatile uint32_t *hw_reg; /* pointer to volatile data */
/* Reads through hw_reg always go to memory */
uint32_t * volatile ptr; /* volatile pointer to non-volatile data */
/* The pointer itself may change (e.g., DMA updates it) */
}
int main(void) {
/* Demonstrate volatile prevents optimization */
volatile int sensor_value = 0;
/* This loop will re-read sensor_value each iteration */
while (sensor_value == 0) {
/* In real embedded code, hardware changes sensor_value */
break; /* for demo only */
}
return 0;
}
- •volatile prevents the compiler from caching a variable in a register
- •Required for hardware registers -- they change without software action
- •Required for variables shared between ISR and main code
- •volatile does NOT provide atomicity -- you still need critical sections
- •volatile does NOT provide memory barrier semantics on most architectures
- •volatile does not make accesses atomic -- a 32-bit volatile read on an 8-bit MCU is still 4 separate byte reads
- •volatile does not prevent compiler reordering of OTHER accesses around the volatile access (use compiler barriers for that)
- •Over-using volatile hurts performance -- only apply it where truly needed
- •A common interview mistake: claiming volatile replaces mutexes or makes code thread-safe -- it does not