Embedded Memory Access: Volatile and Barriers

Memory access is not always ordinary storage. A register read can clear a hardware flag, and a DMA engine can access a buffer independently of the CPU. To reason about these cases, you need to distinguish what the compiler does from what the processor and memory system do at run time.

This article separates those concerns on ARM Cortex-M processors, using ARMv7-M and ARMv7E-M terminology where it matters. It also introduces the tools C and ARM provide for controlling them: volatile, memory attributes, and barriers.

Memory access transformations

A memory access is not a single event. The compiler may remove, combine, or move it before the program runs; the processor, memory system, and device may then affect how the resulting access is buffered, ordered, and completed. The first useful distinction is therefore between compile-time and run-time transformations.

Compile-time reordering

Before the program runs, the compiler chooses the instruction stream. It may remove, merge, split, or move memory operations, keep values in registers, and turn several source operations into one instruction. These transformations happen before the hardware sees the program: the hardware sees only the instructions that remain.

The C abstract machine defines the behavior the compiler must preserve, but it does not model memory-mapped I/O, peripheral registers, or other agents that access memory. That is why C programs rely on mechanisms such as volatile and compiler barriers to constrain compiler transformations.

Run-time transformations and completion

After compilation, the processor and memory system execute the generated instructions. This is a different problem from compiler reordering. Even with a fixed instruction stream, the CPU pipeline, caches, write buffers, memory interconnect, and peripheral devices can affect when an access becomes visible or complete.

Two concepts are useful here:

  • Ordering is the required relationship between accesses: when ordering is required, a later access must not become visible before an earlier one.
  • Completion means that the CPU waits for an earlier access to finish before continuing past a completion point.

Different memory paths can have different latencies, so two accesses to different destinations may complete in a different order from the order in which the processor issued them.

Compile-time controls

The compiler translates C source code into an instruction stream for the target processor. Compile-time controls constrain what it may remove, combine, or move; they do not control the processor, caches, write buffers, or peripheral bus.

volatile

volatile tells the compiler that each access to an object may be observable outside the current code. The access itself matters, not only the value it produces. When C reads a volatile object, the compiler must emit a read; when C writes one, it must emit a write. It cannot remove a required access or replace a later read with a value obtained earlier.

This matters for peripheral registers because they are interfaces to hardware, not ordinary storage. A read may return a new status or clear a flag, while a write may start or acknowledge an operation. Without volatile, the compiler may treat a register like ordinary memory, reuse an earlier read, or remove a write whose value is not used by the program:

while ((PERIPHERAL->STATUS & READY) == 0u) {
}

PERIPHERAL->COMMAND = START;

Vendor-provided peripheral definitions typically include volatile for this reason.

Volatile accesses that are sequenced by the C abstract machine remain ordered relative to one another. For example, two volatile writes in separate statements must be emitted as writes in that order.

This does not mean that each access becomes one particular CPU instruction; the compiler still chooses the instruction sequence for the target. Nor does volatile make accesses atomic or synchronize the CPU with another execution context. In particular, it does not:

  • guarantee that a read or write is completed before the next memory access;
  • flush buffered writes or make data visible to DMA; or
  • prevent a read-modify-write operation from being interrupted.

For the same reason, volatile is usually not enough to synchronize an ISR with the main loop. It ensures that both sides perform the accesses, but it does not make a multi-step update atomic or provide a complete handoff. Protect the shared operation with an appropriate interrupt-masking or critical-section mechanism.

Common pitfall: volatile is not an atomic operation and does not by itself establish synchronization or ordering with non-volatile accesses.

Compiler barriers

A compiler barrier prevents the compiler from moving memory accesses across a particular point. Accesses before the barrier must be emitted before accesses after it.

Compiler barriers affect only the translation from C to machine code. They do not necessarily emit an instruction, control the processor, caches, write buffers, or peripheral bus, or make an operation atomic. They therefore do not by themselves guarantee hardware ordering or completion.

The GCC "memory" clobber

With GCC-compatible extended inline assembly, a compiler barrier is typically implemented with an empty assembly statement with a "memory" clobber. The clobber tells the compiler that the assembly may read or write any memory, so it must not keep memory values in registers or move ordinary memory accesses across the statement.

asm volatile ("" ::: "memory");

The empty assembly emits no hardware instruction. The "memory" clobber only constrains the compiler: accesses before the barrier must be emitted before accesses after it.

Runtime controls

Run-time controls apply after the compiler has generated the instruction stream. They determine how the processor and memory system handle those instructions, including whether accesses may be cached, speculated, buffered, or reordered. The main tools are ARM memory attributes, which describe memory regions, and barrier instructions, which impose ordering or completion at a specific point.

ARM memory attributes

The C type describes how the compiler treats an access; the ARM memory type describes how the processor and bus treat it. Cortex-M memory is normally divided into regions with attributes such as:

  • Normal memory: RAM and code. The processor may buffer, reorder, or speculate accesses according to the memory model. Accesses to Normal memory may be cached.
  • Device memory: The usual type for peripheral registers. Accesses have restrictions appropriate for devices, are not speculatively fetched like Normal memory, and preserve stronger ordering rules. Device memory can still be shareable, bufferable, or non-bufferable depending on the MPU and the core. This is the standard memory type for peripherals.
  • Strongly-ordered memory: An ARM memory type with stricter ordering and access rules than Device memory. On ARMv7-M, the System Control Space (SCS) is mapped as Strongly-ordered memory; most peripheral windows use Device memory instead.

An MPU configuration can change these memory attributes.

Cortex-M barriers

Barriers solve a different problem from volatile: they control how memory accesses relate to the processor pipeline and bus.

  • DMB (__DMB()): A data memory barrier. It orders explicit accesses before the barrier before explicit accesses after it, without necessarily waiting for earlier accesses to complete. Use it when publishing data or ordering a peripheral access.
  • DSB (__DSB()): A data synchronization barrier. The processor waits until earlier explicit memory accesses have completed before continuing. Use it when subsequent instructions must not execute until an earlier access has completed, such as before enabling sleep, after changing MPU or cache settings, or when initiating reset/system-control operations. DSB does not wait for a peripheral operation triggered by a register write to finish; use the peripheral’s status or completion mechanism for that.
  • ISB (__ISB()): An instruction synchronization barrier. The pipeline is flushed and subsequent instructions are fetched again. Use it after changing execution-affecting state, such as the MPU, or vector-table configuration.

A barrier is not a lock and does not make a multi-step memory operation atomic.

Common access patterns

Standard peripheral access

For ordinary memory-mapped peripheral access, use the vendor’s CMSIS device header and register definitions. These definitions normally include volatile. In most cases, volatile plus Device memory is sufficient, but you should always follow the peripheral manual for the exact access rules and sequencing it expects. Memory accesses to the same peripheral are observed in order.

	MY_PERIPHERAL->DATA = value;
	MY_PERIPHERAL->CONTROL = MY_PERIPHERAL_CONTROL_ENABLE;

Use __DSB() when subsequent instructions depend on earlier explicit accesses having completed. To determine whether a peripheral operation itself has finished, use the peripheral’s status or completion mechanism.

ISR synchronization

When main code communicates with an ISR through ordinary data, the compiler must not move the data accesses across the flag that publishes the data. A compiler barrier provides that compiler-level ordering. The handoff must also be atomic: if an update can be interrupted halfway through, protect it with a critical section.

A critical-section primitive that disables interrupts must also act as a compiler barrier. When it does, no additional compiler barrier is needed around the critical section. CMSIS provides this property for __disable_irq() and __enable_irq(); it must come from the primitive’s implementation, not from disabling interrupts alone.

The following example shows a single-slot handoff: the producer writes the data before publishing a flag, and the ISR reads the flag before consuming the data. A newer sample may replace an unread one. On a single-core Cortex-M, the critical section prevents the ISR from observing sample during the update. The example assumes that only the main context publishes samples; use a queue or another ownership protocol when every sample must be preserved.

uint32_t sample;
volatile bool sample_ready;

// pass a sample to the interrupt service routing
void publish_sample(uint32_t value)
{
	__disable_irq();	// enter critical section
	sample = value;
	sample_ready = true;
	__enable_irq();		// exit critical section
}

// interrupt service routine
void timer_isr(void)
{
	if (sample_ready) {
		consume_sample(sample);
		sample_ready = false;
	}
}

The critical section in this example must provide both interrupt exclusion and the required compiler ordering.

During peripheral initialization, interrupts are typically already disabled, so a critical section is not needed just to initialize data that the ISR will later use. An explicit compiler barrier can keep those initializations before the code that enables the peripheral interrupt:

#define COMPILER_BARRIER asm volatile ("" ::: "memory") // gcc/clang specific

void my_driver_init(void) {
    isr_state = INIT;
    isr_data = 0;
    COMPILER_BARRIER;
    peripheral_enable_interrupt();

If enabling the peripheral also requires a processor or bus-level ordering guarantee, use the appropriate hardware barrier as well.

DMA engine

There are two distinct cases.

If the buffer is in uncached memory, the software must write the buffer contents before starting the DMA operation. In that case, a __DMB() before the trigger write is the usual ordering primitive:

void dma_start() {
	DMA->CTRL = DMA_CTRL_START;
}

void start_transfer(volatile uint32_t *trigger,
					const uint8_t *buffer,
					uint32_t length)
{
	dma_configure_source(buffer, length);
	__DMB();
	dma_start();
}

Here, __DMB() orders the buffer writes before the trigger write. It does not clean dirty cache lines, and it is not sufficient when the buffer resides in cacheable memory.

If the buffer is in cached memory, the required step is cache maintenance, not just a barrier. A dirty cache line can write stale CPU-owned data back to memory later and overwrite DMA-written contents. The correct general rule is: before DMA takes ownership of a buffer, make the region coherent; after DMA completes, invalidate the relevant cache lines before the CPU reads the buffer.

For a CPU-to-DMA transfer, clean the relevant cache lines before starting the transfer so the DMA engine sees the updated contents:

void dma_tx(uint8_t *buffer, size_t length)
{
    // Fill the buffer with the data to send.
    for (size_t i = 0; i < length; ++i) {
        buffer[i] = i;
    }

    // Make the buffer coherent before DMA starts consuming it.
    SCB_CleanDCache_by_Addr(buffer, length);

    // Start the DMA operation.
    dma_start();
}

For a DMA-to-CPU transfer, ensure that the RX buffer does not contain dirty cache lines that could be written back while or after DMA is running. After DMA finishes, invalidate the corresponding cache lines before the CPU reads the buffer:

void dma_rx(uint8_t *buffer, size_t length)
{
    // Start the DMA operation.
    dma_start();

    // Wait for DMA to finish writing the buffer.
    while (dma_busy()) {
    }

    // Invalidate the cache before the CPU reads the data.
    SCB_InvalidateDCache_by_Addr(buffer, length);

    // Now the CPU can safely read the newly received data.
    for (size_t i = 0; i < length; ++i) {
        process_byte(buffer[i]);
    }
}

On Cortex-M devices with cache support, the CMSIS cache-maintenance helpers are the normal API for this. These helpers include the necessary barrier behavior internally, so they are the right primitive to use rather than a separate, redundant __DSB() in the same sequence.

In either case, wait for the DMA completion indication before consuming the buffer, and do not let the CPU access the buffer while DMA still owns it.

Practical guidance

  • Use volatile for MMIO registers and ISR-visible flags, but do not treat it as synchronization.
  • Use a critical section, atomic operation, or documented handoff protocol when a multi-step update must be atomic.
  • Remember that ARM memory types matter: peripheral windows are typically Device memory, while RAM and flash are usually Normal memory.
  • Use __DMB(), __DSB(), and __ISB() only for the specific ordering or completion guarantees they provide.
  • For DMA and cache-enabled systems, follow the cache-maintenance and ownership rules for the underlying core and memory region.
  • Use a compiler barrier only when the compiler itself must be prevented from reordering memory accesses.