Embedded Systems · Bare-Metal Programming
What volatile Guarantees in Memory-Mapped Register Access
volatile forces a compiled access to happen, but it does not make the access atomic, ordered, or visible to the device in the order the source implies.
A device register is memory whose value can change without any instruction storing to it, and whose read can have an effect of its own: reading a status register may clear a flag, reading a data register may pop a byte out of a hardware queue. A compiler that models the program as a function from memory contents to memory contents is free to delete a load whose result is unused, to merge two loads from the same address into one, and to move a store past an unrelated one. Every one of those transformations is correct for RAM and wrong for a register. volatile is the language’s narrow answer to that problem.
The transformations volatile forbids
A volatile-qualified lvalue is one the implementation must access exactly as written. In the abstract machine of the C standard, an access to a volatile object is part of the program’s observable behaviour, which means the number of accesses, their order relative to one another, and their presence at all are fixed points the optimiser may not move. A poll loop that reads a status bit repeatedly keeps every read. A write to a control register is not eliminated because nothing in the program reads it. Two successive volatile stores to the same address are not collapsed into one.
That is the entire guarantee in the language. It is a statement about the generated sequence of memory accesses, and it says nothing about buses, caches, write buffers, or the peripheral on the other side of the interconnect. The distinction matters because most register-access bugs are not optimiser bugs; they are assumptions about ordering and atomicity that the qualifier was never able to satisfy.
Four things that are not promised
Atomicity. A volatile access is performed once, but “once” means once in the instruction sequence the compiler generates for the abstract machine, not once on the bus. A 64-bit store compiled for a 32-bit core becomes two stores, and a device that latches the register on the second write can observe an intermediate value. A read-modify-write sequence on a volatile register is three accesses, and an interrupt that modifies the same register between the load and the store loses one of the two updates. Sharing a register between an interrupt handler and the main flow needs the mechanisms in critical sections without an operating system.
Ordering against the hardware. Even when the compiler emits stores in the desired order, the core’s write buffer may retire them out of order, or the store may still be in flight when the next instruction executes. A device that requires a configuration write to land before a start bit is set needs a barrier instruction or a read-back of the written register, not a pair of volatile stores.
Ordering against non-volatile accesses. The qualifier orders volatile accesses with respect to each other. A compiler is entitled to move an ordinary memory operation across a volatile one, so a shared buffer filled with ordinary stores and then announced by a volatile write may still be incomplete in the writer’s memory ordering when the reader acts on the notification. The same applies to a memory-mapped DMA descriptor table: the descriptor contents must be ordered before the doorbell write by something stronger than the qualifier.
Width and alignment. A volatile access to a type wider than the bus, or to a structure with padding, generates whatever the compiler’s data layout dictates. The register map and the compiler’s layout must be made to agree, and the agreement is not checked by the language.
Register blocks as qualified structures
The usual idiom is a structure describing a device’s register block, accessed through a pointer to volatile.
typedef struct {
volatile uint32_t CR; /* control */
volatile uint32_t SR; /* status: changes without a store */
volatile const uint32_t ID; /* identification: read-only */
uint32_t RESERVED0[5]; /* 5 words of address space with no register */
volatile uint32_t DR; /* data */
} uart_regs_t;
#define UART0 ((uart_regs_t *)0x40001000u)
void uart_write(uart_regs_t *dev, uint8_t b)
{
while ((dev->SR & (1u << 5)) == 0) { /* one load per iteration, guaranteed */
}
dev->DR = b;
}
Three details carry weight. The pointer type must be a pointer to volatile structure, not a const address cast to a plain structure pointer; making the pointed-to type volatile is what qualifies every member. The reserved words are not decoration: the structure’s layout is the register map, so omitting an unused register shifts every member after it. And volatile const means the hardware may change the value while software may not write it, which is the accurate description of a read-only identification register.
Because the layout is a contract with the hardware rather than with the compiler, it is worth asserting the offsets at compile time with a _Static_assert from C11, comparing the offset of each member against the value from the device documentation. The assert costs nothing in the image and turns a silent misalignment into a build error.
Barriers, and where the qualifier stops
Where the hardware needs ordering that the compiler cannot express, the barrier has to be explicit. On the ARM Cortex-M profile, data memory barriers order memory accesses as seen by other observers while data synchronisation barriers additionally wait for them to complete; an instruction synchronisation barrier flushes the pipeline so that a subsequent change to system configuration takes effect. A device whose registers require a written value to be committed before the next access uses one of these, or reads the register back — a read of a register with no side effect forces the store ahead of it to complete on most bus interfaces.
The C language offers a second mechanism that is not volatile at all: atomics with explicit memory order. Volatile says an access happens; atomics say what other threads or bus masters may observe about the order of accesses. For registers shared with another core or with a DMA engine, the atomic type with release and acquire ordering expresses the guarantee directly, and the volatile qualifier does not.
A related trap is a volatile bit-field. The standard’s requirements for volatile accesses to bit-fields are weak enough that implementations differ in whether they perform a single access of the declared width or a narrow access of the field. GCC documents a -fstrict-volatile-bitfields option controlling which behaviour applies, with the default depending on the target ABI. When a register must be written with a defined transaction width, an explicit read-modify-write of a whole word is clearer than a bit-field assignment whose access pattern the implementation chooses.
What the qualifier costs
Volatile accesses cannot be cached in registers, so a loop that reads a volatile operand in its condition reloads it on every iteration, which is the point. The cost appears when the qualifier is applied more widely than the device requires: a volatile pointer to a buffer prevents the compiler from keeping elements in registers, defeats vectorisation, and can multiply the code size of the loop. The rule that follows is narrow qualification — the register block is volatile, the data buffer is not.
The qualifier also fails in a specific way that produces no diagnostic. Casting a volatile pointer to a non-volatile pointer discards it, and the resulting accesses are optimisable again, so a helper function that takes uint32_t * and is called with the address of a control register may read the register once and reuse the value. The same applies to a plain structure assignment: copying a register block with *dest = *source is legal C that the compiler may implement with grouped loads and stores rather than the register-by-register accesses the device expects.
Architectures where registers are not addresses
Memory-mapped I/O is a choice, not a law, and the alternatives need different language machinery. RISC-V control and status registers are not part of the address space: they are read and written by dedicated instructions, so they are reached through inline assembly rather than through a pointer, and the compiler cannot mis-optimise an access it does not generate. The x86 architecture has a separate port address space for legacy devices, reached only by in and out instructions. Cores with a coprocessor interface expose some registers through move instructions between the core and the coprocessor. In each case the qualifier is irrelevant because the interface is an instruction with a defined side effect, and the job that volatile does for a memory-mapped register — telling the compiler the access must occur — falls to the construct that expresses the instruction.
A partially memory-mapped system mixes both worlds and is the source of subtle bugs. A device whose data registers are memory-mapped but whose control path is a processor-internal register cannot be described by a single volatile structure, and an ordering requirement that spans the two has to be expressed with the strongest of the two mechanisms. Registers that are not simply stored to are the subject of read-modify-write hazards, where the idiom that works on ordinary memory is actively harmful.
References
- ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
- GCC documentation: Common Variable Attributes — GNU Compiler Collection
- GCC documentation: Code Generation Options — GNU Compiler Collection
- ARMv7-M Architecture Reference Manual (ARM DDI 0403) — Arm
- ARM Cortex-M Programming Guide to Memory Barrier Instructions (Application Note 321) — Arm