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:

  1. Writes cannot be omitted: The compiler must emit machine instructions to execute every write to a volatile object, even if no software read follows it.
  2. 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.
  3. 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.