Using volatile Correctly for Hardware Register Access in Embedded C
How and why to use volatile for memory-mapped hardware registers in embedded C, with a worked register-map example, assembly-level verification, and the limits of what volatile guarantees.
09 Oct 2026, 20:10 UTC

The short answer
When your C code reads or writes a memory-mapped hardware register, mark the access volatile. Without it, the compiler is allowed to cache the value in a CPU register, reorder accesses, or delete reads it considers useless — all of which break hardware interaction. volatile tells the compiler: every read and write in the source must appear as a real load or store in the generated code, in order. That is all it does, and understanding that limit matters as much as using it.
Why hardware registers are different from memory
Normal RAM behaves the way the C abstract machine assumes: a value stays put until you write it, and reading it has no side effects. Hardware registers violate both assumptions. A status register can change on its own (a UART sets a flag when a byte arrives), and the act of reading a register can itself clear a flag or pop a FIFO entry.
Consider polling a status bit:
#define STATUS (*(uint32_t *)0x40000000u)
while ((STATUS & 0x1u) == 0u) {
/* wait for hardware */
}With optimization enabled, the compiler sees no write to STATUS inside the loop, concludes the condition cannot change, and hoists the read out of the loop. The result is either an infinite loop or a single check — never the polling you intended. This is well-established compiler behavior, not a bug: the optimizer is correct about ordinary memory, and the register simply isn't ordinary memory.
A worked configuration
The idiomatic fix is a volatile-qualified pointer to a fixed address. The placement of the qualifiers matters:
#include <stdint.h>
/* Read the pointer as: pointer is const, pointed-to data is volatile */
static volatile uint32_t * const GPIO_ODR =
(volatile uint32_t *)0x40020014u;
void led_on(void)
{
*GPIO_ODR |= (1u << 5); /* guaranteed load, modify, store */
}
uint32_t status_word(void)
{
return *GPIO_ODR; /* guaranteed fresh load each call */
}Reading the declaration from the identifier outward: GPIO_ODR is a const pointer (the address never changes) to volatile uint32_t (the data at that address can change outside the program's control). Both qualifiers are doing different jobs; dropping volatile reintroduces the caching problem, while dropping const just loses a safety check.
For a whole peripheral, group registers in a struct that mirrors the datasheet's memory map:
typedef struct {
volatile uint32_t MODER; /* offset 0x00 */
volatile uint32_t OTYPER; /* offset 0x04 */
volatile uint32_t RESERVED; /* keep alignment per datasheet */
volatile uint32_t ODR; /* offset 0x0C */
} GPIO_Regs;
#define GPIOA ((GPIO_Regs *)0x40020000u)
void led_on2(void)
{
GPIOA->ODR |= (1u << 5);
}Here volatile goes on the members, not the struct pointer, so every field access is a real load/store. Verify the offsets against the reference manual — a wrong offset compiles fine and fails silently on hardware.
How to check the compiler actually emitted the access
Don't trust the source alone; inspect the output. On your development machine, with your cross-compiler (for example arm-none-eabi-gcc), generate assembly:
arm-none-eabi-gcc -O2 -S -o led.s led.cThis needs no special permissions and changes nothing on the target. Open led.s and confirm that each access to the register appears as a distinct ldr/str (on ARM) at the expected address, and that the polling loop actually re-loads the value each iteration. If you see the load hoisted above a loop label, volatile is missing or applied in the wrong place.
On hardware, a practical end-to-end check is toggling a GPIO in a loop and watching it with a logic analyzer or oscilloscope: the observed toggle rate and regularity confirm the writes are reaching the pin. Static analysis tools can also flag memory-mapped addresses accessed without volatile.
Limits: what volatile does not do
volatile is not a synchronization primitive. It does not make operations atomic, does not insert memory barriers, and does not order one thread's accesses against another's. Specific consequences:
*REG |= mask;is a read-modify-write and can still race with an interrupt handler writing the same register. Use a mutex, disable interrupts briefly, or use hardware bit-banding/set-clear registers where the MCU provides them.- For data shared between an ISR and main code,
volatileis necessary but not sufficient for multi-byte objects: a 32-bit flag on a 16-bit MCU can be read half-updated. Use C11 atomics or critical sections for anything wider than the naturally atomic word size. - On cores with caches or write buffers,
volatiledoes not flush them. Memory-mapped regions should be configured as device/non-cacheable memory in the MPU/MMU, which is a separate step.
Common mistakes
- Slapping volatile on ordinary variables for "performance" or "safety." It does the opposite: it forces extra memory traffic and blocks optimizations. Use it only where the value can change outside the compiler's view — hardware registers, ISR-shared data, and
sig_atomic_t-style flag handoffs. - Wrong qualifier placement.
volatile uint32_t * p(data volatile) is what you want for registers;uint32_t * volatile pmakes the pointer volatile, which is rarely useful. - Assuming volatile orders accesses to different registers. Volatile accesses are ordered relative to each other, but not relative to non-volatile memory accesses around them — the compiler may move ordinary accesses across them.
- Struct padding surprises. When a register map has gaps, compilers insert padding that may not match the datasheet layout. Insert explicit reserved fields and, where supported, check with
_Static_assert(offsetof(GPIO_Regs, ODR) == 0x0C, "map mismatch");.
The decision rule is simple: if the address is a hardware register or the value is written by an interrupt or signal handler, qualify the pointed-to object volatile; then verify with -S output or a logic analyzer that the accesses you expect are the accesses the target performs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.