volatile in Embedded C: What It Guarantees for Memory-Mapped Registers
volatile stops the compiler from optimizing away register accesses, but it does not make them atomic or ordered. Here is how to use it safely and what to verify.
18 May 2026, 19:27 UTC

The bug that looks correct
You are writing a driver for a UART transmitter. The status register lives at a fixed address, and you need to set the transmit-enable bit:
#define UART_CR (*(volatile uint32_t *)0x40001000)
UART_CR |= 0x01; /* enable transmitter */
This compiles, runs, and the transmitter never starts. Later you discover the compiler removed the read-modify-write entirely, or merged it with a neighboring write, because it could not see the hardware side effect. The fix many engineers reach for is volatile. It helps, but only up to a point.
What volatile actually guarantees
Assuming a conforming C compiler, volatile tells the compiler that an object may change outside the normal flow of the program. For a memory-mapped register—an address in the processor's address space that triggers hardware behavior when read or written—this means the compiler must emit every access you write in source code. It cannot delete a seemingly redundant read, and it cannot combine two writes into one.
It is a compiler directive, not a CPU guarantee. The processor can still buffer writes, reorder instructions, or execute them speculatively unless the architecture and your code explicitly prevent it.
What volatile does not guarantee
- Atomicity.
UART_CR |= 0x01;is a read-modify-write sequence. On most CPUs it compiles to a load, an OR, and a store. An interrupt or another core can intervene between those steps. - Ordering. Writes to two different volatile registers can be reordered by the compiler or the CPU on weakly ordered architectures. You need explicit compiler barriers or CPU memory barriers when the hardware requires a specific sequence.
- Safe sharing with interrupt service routines. A
volatileflag shared between an ISR and main code can still be torn or cached unpredictably. Prefersig_atomic_t, an integer type guaranteed to be accessed atomically for signal handlers, C11_Atomictypes fromstdatomic.h, or interrupt-disabled critical sections.
Access width matters
Casting a volatile uint32_t * to volatile uint8_t * changes the number and size of bus transactions. Some device registers side-effect on byte access, others fault on anything but a full word. Match the access width to the datasheet.
Worked example: inspecting what the compiler emits
Create a header that describes the register with the correct type and qualifier:
/* platform.h - example only; verify addresses against your datasheet */
#include
#define GPIO_BASE 0x40020000UL
#define GPIO_ODR (*(volatile uint32_t *)(GPIO_BASE + 0x14UL))
Then write a function that toggles a bit:
void led_on(void) {
GPIO_ODR |= 0x00000001UL;
}
Compile to assembly and inspect it. On a Linux host with a cross-compiler, run:
$ arm-none-eabi-gcc -O2 -S -mcpu=cortex-m4 platform.c -o platform.s
Where to run: a terminal with your embedded toolchain installed. Permissions: none required for compilation. Placeholders: replace arm-none-eabi-gcc with your compiler, cortex-m4 with your target CPU, and platform.c with your source file.
Expected check: look for a load from the GPIO address, an OR operation, and a store back to the same address. If you remove volatile and recompile, the store may disappear entirely because the compiler assumes the value is never read back. Risk: do not assume the instruction mnemonics or register names in your output; they vary by toolchain and optimization level.
Practical verification and limitations
volatile can hurt performance because it prevents the compiler from keeping the value in a CPU register across accesses. Use it only for objects that truly represent external state, such as hardware registers or memory shared with an ISR.
Before shipping code:
- Read the device datasheet for required access size (byte, halfword, word) and any ordering requirements between registers.
- Match the C type and pointer cast to that access size.
- Inspect the generated assembly at your production optimization level to confirm each access appears as intended.
- If an ISR and main code share data, switch to
_Atomicor protect the section with interrupt masking instead of relying onvolatilealone.
There is no rollback step here because changing source code does not alter system state; if a new qualifier breaks behavior, revert the source edit and rebuild with the previous definition.
Bottom line
Use volatile to keep the compiler honest about memory-mapped register accesses, but do not treat it as a synchronization primitive. Verify access width and ordering in the datasheet, inspect the assembly at your optimization level, and use proper atomics or critical sections for data shared with interrupts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.