Accessing Hardware Registers with volatile in Embedded C
Declare memory-mapped peripheral register pointers as volatile so the compiler emits every read and write exactly as written.
02 Sept 2026, 20:19 UTC

The Problem: the Compiler Can Delete Your Register Access
In embedded C, a peripheral register lives at a fixed physical address. If you access it through an ordinary pointer, the compiler is allowed to treat that address like normal RAM. It may keep the last value in a CPU register, combine several writes into one, or delete a write whose result the program never reads. On a hardware register, those transformations are wrong: the value can change without the program knowing it, and a write can trigger a side effect such as clearing an interrupt flag or toggling a pin.
Useful takeaway: declare every memory-mapped register pointer with volatile. This tells the compiler to emit each read and write exactly as it appears in the source, with no caching, merging, or elimination.
Worked Example: LED Output Data Register
The address below is illustrative only. Replace it with the address, width, and field definition from your MCU reference manual before building for real hardware.
#include <stdint.h>
/* Illustrative address: confirm against the reference manual. */
#define LED_ODR (*(volatile uint32_t *)0x4002001CU)
void led_on(void)
{
LED_ODR |= (1U << 5); /* set bit 5 */
uint32_t state = LED_ODR; /* read back */
(void)state;
}
LED_ODR |= (1U << 5) is a read-modify-write: the compiler reads the register, sets bit 5, and writes the result back. Because the pointer is volatile, neither the read nor the write can be optimized away, even if state is never used.
How volatile Changes the Compiler's View
Without volatile, the compiler assumes memory at that address behaves like ordinary C memory. If two consecutive writes target the same address, it may keep only the last one. If a read follows a write to the same location, it may substitute the value it just wrote instead of performing a bus transaction. For a memory-mapped register, both assumptions can fail: an external device may have changed the register between the two accesses, and the write may have side effects that a later read must observe.
volatile forces the compiler to treat every access as an observable side effect. It does not, by itself, control the CPU pipeline, the bus fabric, or the peripheral.
Limits: Atomicity, Ordering, and Caches
- Not atomic. A read-modify-write such as
LED_ODR |= (1U << 5)compiles to separate bus transactions. An interrupt or another core can run between the read and the write, causing a lost update. Use a critical section, atomic instruction, or hardware bit-banding for shared registers. - Not a memory barrier.
volatiledoes not order this access relative to other memory operations. DMA controllers and multi-core systems need architecture-specific barriers and cache maintenance. - Not for ordinary shared variables. Using
volatileas a substitute for mutexes or atomics is a common mistake; it does not provide inter-thread synchronization.
Pointer-to-volatile Versus Volatile Pointer
These declarations protect different things:
| Declaration | Meaning | What is protected |
|---|---|---|
volatile uint32_t *p | Pointer to volatile data | The object *p may change unexpectedly; every access is preserved. |
uint32_t * volatile p | Volatile pointer | The pointer variable p itself may change unexpectedly; reads and writes of p are preserved. |
For memory-mapped registers, you almost always want the first form: a pointer to volatile data.
Verify with Assembly Output
Compile on a host terminal. No special permissions are required. Replace register_example.c with your source file name.
arm-none-eabi-gcc -mcpu=cortex-m4 -O2 -S register_example.c -o register_example.s
Open register_example.s and confirm that load and store instructions for the register address are present. Then remove volatile from the source, recompile to a second file, and compare:
diff register_example.s register_example_no_volatile.s
Expected check: with volatile, the assembly should contain distinct load/store instructions for the register. Without it, those instructions may disappear, merge, or be replaced by a cached register value. Risk: compiling is safe, but flashing an incorrect register address to a board can cause a Hard Fault, silent peripheral misconfiguration, or unexpected pin toggling.
Practical Check on Hardware
Toggle a GPIO output data register bit from C and observe the pin with a logic analyzer or oscilloscope. Each source-level write should produce a corresponding bus transaction and pin transition. If the pin does not move, verify the register map, access width, clock enable, and pin multiplexing in the reference manual before blaming the compiler.
Common Mistakes
- Using
volatileon large buffers, which forces uncached access and hurts performance. - Assuming
volatilemakes a register access visible across CPU cores; it does not. - Copying a register address from a different MCU variant or datasheet revision.
- Relying on
volatilefor atomic bit manipulation in an interrupt handler.
Always cross-check the register address, width, and side effects against the MCU reference manual. Treat fixed addresses in examples as placeholders until verified for your specific device.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.