Why Your Embedded C Code Needs Volatile Variables for Hardware Registers
When accessing hardware registers in embedded C, compiler optimizations can silently break your code. The volatile keyword ensures the compiler reads the register each time. This article explains why volatile is essential, shows a GPIO ISR example, and discusses trade‑offs.
31 Jul 2026, 14:59 UTC

The Problem: Compiler Optimizations Breaking Hardware Access
When you write embedded C code that reads a hardware status register, you might assume the compiler will execute your code exactly as written. But modern compilers are aggressive optimizers that can silently break hardware interactions.
Consider this common scenario: you're polling a UART status register waiting for data:
while ((UART_STATUS & TX_READY) == 0) {
// Wait for transmitter ready
}
// Send data
UART_DATA = 'A';
The problem? If the compiler determines UART_STATUS hasn't changed in your loop, it might optimize away the repeated reads entirely, creating an infinite loop that never detects the hardware state change.
The Solution: Volatile Keyword
The volatile keyword tells the compiler that a variable's value can change at any time without any explicit action in the program. This prevents three critical optimizations:
- Common subexpression elimination - compiler won't cache repeated reads
- Loop optimization - compiler won't remove "redundant" memory accesses
- Register allocation - compiler won't keep values in registers instead of memory
Here's the correct approach:
#define UART_STATUS (*((volatile uint32_t*)0x40001000))
#define UART_DATA (*((volatile uint32_t*)0x40001004))
while ((UART_STATUS & TX_READY) == 0) {
// Compiler will now read the register each iteration
}
UART_DATA = 'A';
Worked Example: GPIO Interrupt Handler
In a real interrupt handler, volatile is essential for hardware registers and shared variables:
#include
// Hardware register definitions
#define GPIO_BASE (0x40020000)
#define GPIO_ODR (*(volatile uint32_t*)(GPIO_BASE + 0x0C)) // Output Data Register
#define GPIO_IDR (*(volatile uint32_t*)(GPIO_BASE + 0x08)) // Input Data Register
#define GPIO_EXTI_CR1 (*(volatile uint32_t*)(GPIO_BASE + 0x14)) // External Interrupt Control
// Shared variable between ISR and main code
volatile bool button_pressed = false;
void EXTI0_IRQHandler(void) {
// Clear interrupt flag
if (GPIO_IDR & (1 << 0)) {
button_pressed = true;
GPIO_ODR |= (1 << 13); // Toggle LED
}
}
int main(void) {
// Configure GPIO
GPIO_EXTI_CR1 |= (1 << 0); // Enable interrupt on pin 0
while (1) {
if (button_pressed) {
// Process button press
button_pressed = false;
}
}
return 0;
}
Without volatile on button_pressed, the compiler might optimize the main loop to never check the variable again after the first read, causing the button press to be missed.
Trade-offs and Limitations
While volatile is essential for hardware access, it comes with important limitations:
Not a Thread‑Safety Solution
volatile does not provide atomicity or memory ordering guarantees. In RTOS environments with interrupts disabled, you may still need critical sections.
Example of a critical section using __disable_irq() and __enable_irq():
volatile int counter;
__disable_irq();
counter++;
__enable_irq();
This ensures the update is not interrupted, but it is a coarse lock that disables all interrupts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.