Using volatile for Memory‑Mapped Register Access in Bare‑Metal Embedded C
A concise architecture note that shows when and how to apply the volatile qualifier to hardware registers, what the compiler guarantees, where extra barriers are needed, and how to verify the generated code.
08 Sept 2025, 12:24 UTC

Requirements
In bare‑metal firmware the CPU must read and write hardware registers exactly as the peripheral expects. The compiler may otherwise:
- Eliminate repeated reads that appear to have no effect.
- Reorder accesses across unrelated variables.
- Cache a register value in a general‑purpose register and reuse it.
These optimisations break polling loops, state‑machine updates, and multi‑byte register accesses. The design must guarantee that each access translates to a real bus transaction.
Smallest Suitable Design
Declare every hardware register as a volatile‑qualified pointer to the appropriate integer type. All reads and writes go through that pointer; no intermediate copies are made.
/* Example: 32‑bit timer control register at 0x40001000 */
volatile uint32_t * const TIMER_CTRL = (volatile uint32_t *)0x40001000;
/* Start the timer */
*TIMER_CTRL = 0x00000001;
/* Poll until the done flag is set */
while ((*TIMER_CTRL & 0x80000000) == 0) {
/* intentional empty body */
}
The volatile keyword tells the compiler that the object may change outside the current thread of execution, inhibiting the optimisations listed above.
Trust / Data Boundaries
The compiler is trusted to:
- Emit a load or store instruction for each
volatileaccess. - Respect the address given by the pointer constant.
It is not trusted to:
- Guarantee atomicity for accesses wider than the natural bus width.
- Preserve ordering between two distinct
volatileobjects. - Prevent tearing when an interrupt service routine updates a multi‑byte register concurrently.
These boundaries define where extra synchronisation (memory barriers, interrupt disabling, or atomic primitives) is required.
Operational Checks
Verify that the generated assembly matches the intent:
- Compile with optimisation enabled (e.g.,
-O2) and request the assembly output:
# Run in a build environment with the cross‑toolchain installed
# Replace with your target (e.g., arm-none-eabi)
-gcc -O2 -S -mcpu=cortex-m4 firmware.c -o firmware.s
- Inspect
firmware.sfor each register access. You should see a load (ldr) or store (str) instruction that uses the exact address constant, with no intermediate moves that reuse a register across multiple accesses.
Example snippet (illustrative only):
ldr r3, =0x40001000 @ address of TIMER_CTRL
ldr r2, [r3] @ volatile read
and r2, r2, #0x80000000
cmp r2, #0
beq .L2 @ loop if not set
If you see the address loaded once and then reused (mov or add without another ldr/str), the access has been cached and the design is insufficient.
Additional verification on hardware:
- Use a debugger or JTAG to watch the bus; confirm that each loop iteration generates a bus read at the expected address.
- Run the polling loop on silicon; if the loop never exits when the peripheral sets the flag, the read was likely optimised away.
- Optimised‑away polling: A non‑volatile read of a status register may be eliminated, causing an infinite loop that never observes hardware updates.
- Reordering between registers: The compiler may move a write to a control register after a write to a data register, breaking the peripheral’s required sequencing.
- Torn multi‑byte reads: On a 16‑bit bus, a 32‑bit register read via two separate
volatileaccesses can interleave with an ISR update, yielding a mixed old/new value. - Write combining: Some architectures merge consecutive stores to the same address; if the peripheral expects individual strobes, the combined write can be ignored.
- Multiple cores or DMA controllers share the same register.
- The target architecture has a weak memory model (e.g., ARMv8‑A with
LDXR/STXRrequirements) and ordering between distinct registers matters. - Atomic updates of multi‑byte registers are required.
- Architecture‑specific atomic primitives (
__atomic_load,__ldrex/__strex), - Explicit memory barriers (
__DSB(),__ISB()on ARM,_mm_mfence()on x86), or - A driver layer that disables interrupts around the critical section.
- Provide atomicity for >word‑size registers.
- Replace hardware‑level memory ordering requirements.
- Guard against compiler bugs that incorrectly treat
volatileas non‑volatile (rare, but possible with aggressive link‑time optimisation). - Build with
-O2 -flto(link‑time optimisation) and examine the final assembly; if the volatile accesses remain as loads/stores, the design holds under LTO. - Run the firmware under a hardware‑in‑the‑loop test that toggles the register from an external stimulus (e.g., a GPIO‑driven signal) and verify the CPU observes the change within the expected time.
Failure Modes
Design Change Conditions
Upgrade the design when any of the following apply:
In those cases replace plain volatile accesses with:
Re‑verify the assembly after each change to ensure the additional instructions appear as expected.
Limitations and Practical Verification
The volatile qualifier solves the basic problem of preventing optimisation‑elimination and reordering of single accesses, but it does not:
A practical way to check the result on a given build is to:
If either check fails, revisit the trust boundaries and consider adding the appropriate barriers or atomic operations.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.