Preventing MMIO Write Elimination in C
In the C programming language, optimizing compilers employ Dead Store
Elimination to discard memory writes that appear to have no subsequent
reads. However, when interfacing with memory-mapped input/output (MMIO),
memory addresses correspond to physical hardware registers rather than
standard RAM, making every write operation critical. This article
explains how the volatile type qualifier, compiler memory
barriers, and the C abstract machine's definition of observable behavior
prevent compilers from optimizing away MMIO writes.
The "As-If" Rule and Dead Store Elimination
C compilers adhere to the "as-if" rule, defined in the ISO C standard. This rule allows a compiler to perform any transformation, reordering, or omission of code as long as the program’s observable behavior remains identical to the source code.
Under normal circumstances, observable behavior includes:
- Executing reads and writes to standard input/output streams.
- Halting program execution.
- Accessing objects qualified as
volatile.
When a program writes to a standard memory address without subsequently reading from that same location before it is overwritten or before the program terminates, the compiler considers the write a "dead store." Because normal RAM has no external side effects, the compiler eliminates the write instruction entirely to boost performance.
The Primary
Defense: The volatile Type Qualifier
The definitive mechanism in the C standard to prevent the elimination
of MMIO writes is the volatile keyword. Marking an object
or pointer target as volatile informs the compiler that the
value can change via means outside the program's control (such as
hardware logic) and that any access to it carries external side
effects.
According to the C standard, an access (read or write) to a
volatile-qualified lvalue constitutes an observable side
effect. Because the "as-if" rule strictly forbids the elimination of
observable behavior:
- Writes cannot be omitted: The compiler must emit
machine instructions to execute every write to a
volatileobject, even if no software read follows it. - Writes cannot be cached in registers: The value cannot simply stay in a CPU register; it must be flushed to the designated memory address via a bus transaction.
- Writes cannot be consolidated: Multiple consecutive writes to the same address cannot be merged into a single write. If a hardware FIFO expects five consecutive writes to the same register, five distinct store instructions will be generated.
MMIO Pointer Implementation
To protect MMIO registers, systems developers define register
pointers with the volatile qualifier applied to the
dereferenced type:
#include <stdint.h>
// Correct: The data pointed to is volatile
#define UART0_DATA_REG ((volatile uint32_t *)0x4000C000)
void uart_send_byte(uint8_t byte) {
// The compiler is forced to issue a real store instruction
*UART0_DATA_REG = (uint32_t)byte;
}If the definition omitted volatile (e.g.,
(uint32_t *)0x4000C000), a compiler optimizing at
-O2 or -O3 could determine that the store has
no local impact and discard the memory write completely.
Compiler Barriers and Reordering Constraints
While volatile guarantees that a write will not be
deleted, optimizing compilers may still attempt to reorder independent
memory accesses across sequence points. If an MMIO operation requires a
strict sequence—such as setting a configuration register before pulsing
a trigger register—a compiler-level optimization might reorder
non-volatile accesses around volatile accesses.
To prevent the compiler from moving memory operations across critical MMIO writes, developers utilize compiler memory barriers:
// GCC / Clang compiler barrier
__asm__ __volatile__("" ::: "memory");This instruction produces no CPU instructions, but it signals to the compiler's optimizer that all memory might have been read or altered, forcing the compiler to flush pending writes and prevent reordering across the barrier boundary.
Distinguishing Compiler Behavior from Hardware Behavior
Preventing compiler elimination guarantees that the compiler emits
the necessary store instruction (such as STR on ARM or
MOV on x86). However, this only governs compiler
optimization.
At the hardware level, out-of-order execution engines, write buffers,
and bus interconnects can still delay, merge, or reorder bus cycles
unless hardware-level memory barriers (such as ARM
DMB/DSB instructions or x86
MFENCE) are used alongside the volatile
qualifier.