
Every embedded developer learns about volatile early on — it tells the compiler not to optimize away reads or writes to a variable. But there is a persistent misconception that volatile guarantees memory ordering at the hardware level. It does not. On an ARM Cortex-M processor — even though the core itself executes instructions in order — the memory system (write buffers, bus bridges, and interconnects) can reorder or buffer memory accesses so that external observers see them in a different order than the program specified. volatile is only half the story. The other half is memory barriers.
volatile solves exactly two problems:
That second point is critical: the compiler respects volatile ordering only at the compiler level. Once the instructions are emitted, the hardware memory system is free to buffer or reorder them depending on the memory type of the target region.
The scope of volatile can be visualized as a layer that only covers the compiler’s transformation:
+================================================================+|C SOURCE CODE || ||buffer[0] = payload; dma_flag = 1; |+----------------------------------------------------------------+| v || volatile prevents compiler reordering || v |+================================================================+|COMPILER OUTPUT (ASM) || ||str r0, [buffer] ; str r1, [dma_flag] |+----------------------------------------------------------------+| v || NO volatile protection! Memory system can reorder || (for Normal memory — see "ARM Memory Types" below) || v |+================================================================+|HARDWARE EXECUTION || ||[dma_flag] may reach memory BEFORE [buffer] if both are ||in Normal (SRAM) memory and no barrier is used |+================================================================+
The compiler faithfully emits the store to buffer before the store to dma_flag. But between the compiler output and actual hardware execution, the memory system’s write buffer can reorder the two stores — if they target Normal memory (such as SRAM). This is precisely the gap dmb fills.
Consider a scenario where the CPU prepares data in SRAM for a DMA controller:
volatile uint32_t dma_flag; // in SRAM at 0x20001000uint32_t payload_buffer[64]; // in SRAM at 0x20002000void prepare_for_dma(uint32_t value) {payload_buffer[0] = value; // Write data to Normal memory__DMB(); // Ensure the data write is observed before the flagdma_flag = 1; // Signal the DMA that data is ready}
Without the __DMB(), the write buffer could reorder the SRAM stores, and the DMA controller might see dma_flag == 1 while payload_buffer still holds stale data. The __DMB() ensures that any observer (the DMA controller, another core) sees the data write before the flag write.
Note: If both addresses were in the peripheral region (0x40000000–0x5FFFFFFF), which the default Cortex-M memory map marks as Device memory, the hardware already guarantees ordering. See the ARM Memory Types section below.
Before discussing the barrier instructions, it is essential to understand the ARM memory type system. This is the foundation for deciding when barriers are actually needed.
The ARM architecture defines several memory types, each with different ordering and buffering rules. On Cortex-M, the default memory map assigns these types automatically:
| Memory Type | Reordering Allowed? | Buffering Allowed? | Speculative Reads? | Typical Region |
|---|---|---|---|---|
| Normal | Yes | Yes | Yes | SRAM (0x20000000), Flash (0x00000000) |
| Device (nGnRnE) | No | No | No | Most restrictive — equivalent to Strongly-Ordered |
| Device (nGnRE) | No | Yes (early write ack) | No | Default for peripherals (0x40000000–0x5FFFFFFF) |
| Strongly-Ordered | No | No | No | System control (0xE0000000–0xE00FFFFF) |
Key rules from the ARM Architecture Reference Manual:
The practical consequence: writes to peripheral registers within the same Device memory region do not require a DMB for ordering. Barriers become necessary when:
DSB).ISB).ARM Cortex-M provides three distinct memory barrier instructions:
| Barrier | Full Name | What It Does |
|---|---|---|
dmb | Data Memory Barrier | Ensures all explicit memory accesses before the dmb are observed before any explicit memory accesses after it — an ordering guarantee |
dsb | Data Synchronization Barrier | Ensures all explicit memory accesses and cache/TLB maintenance operations complete before any instruction after it executes — a completion guarantee |
isb | Instruction Synchronization Barrier | Flushes the processor pipeline so that all instructions after it are fetched fresh from memory or cache |
The critical distinction is ordering vs. completion: DMB ensures writes are seen in order by other observers, while DSB ensures writes have actually finished at the destination.
In embedded C, you typically access these via compiler intrinsics or CMSIS wrappers:
#include <cmsis_gcc.h> // or <cmsis_armcc.h>__DMB(); // Data Memory Barrier__DSB(); // Data Synchronization Barrier__ISB(); // Instruction Synchronization Barrier
Use dmb when you need to ensure one memory write is observed before another, particularly when the writes target different memory types or different Device regions. The classic example is preparing data in Normal memory (SRAM) before signaling a peripheral or DMA controller:
uint32_t tx_buffer[64]; // Normal memory (SRAM)void start_uart_dma(const uint32_t *data, size_t len) {// Copy data into the DMA source buffer (Normal memory)memcpy(tx_buffer, data, len * sizeof(uint32_t));__DMB(); // Ensure SRAM writes are observed before peripheral writes// Configure and trigger the DMA (Device memory)DMA1_Channel4->CMAR = (uint32_t)tx_buffer;DMA1_Channel4->CNDTR = len;DMA1_Channel4->CCR |= DMA_CCR_EN;}
dmb is the lightest barrier — it only orders explicit memory accesses. It does not wait for them to complete and does not flush the pipeline.
Use dsb when you need to know that a memory access has fully completed before proceeding. The most common case is waiting for a peripheral write to take effect before entering a low-power state or checking status:
// Trigger a DMA transferDMA1_Channel1->CCR |= DMA_CCR_EN;__DSB(); // Wait until the enable write has physically reached the peripheral// Now it's safe to check status or trigger another operationwhile (!(DMA1->ISR & DMA_ISR_TCIF1)) { /* wait */ }
dsb is heavier than dmb — it stalls the processor until all preceding memory accesses have completed. On Cortex-M7, this also covers cache maintenance operations. Use it when the completion of a write matters, not just its ordering relative to other writes.
Use isb after modifying system configuration that affects instruction execution — for example, after changing the vector table offset, modifying MPU regions, or writing to the CONTROL register:
// Relocate vector tableSCB->VTOR = new_vector_table_addr;__DSB(); // Ensure the write completes__ISB(); // Ensure subsequent instructions use the new vector table
Without the isb, the processor pipeline might still contain instructions fetched under the old configuration. The isb flushes the pipeline so all subsequent instructions are fetched with the new settings.
Consider a shared flag between the CPU and a DMA controller, both operating on Normal memory (SRAM):
+----------------------------------------------------------+|CPU Shared SRAM ||+----------+ +------------------+ ||| | <------> | (flag, data) | ||+----------+ +--------+---------+ || | || +------v------+ || | DMA | || | Controller | || +-------------+ |+----------------------------------------------------------+
The DMA writes a completion flag in shared SRAM. The CPU polls it:
volatile uint32_t done_flag; // in SRAM — Normal memoryuint32_t rx_data[256]; // in SRAM — Normal memory, filled by DMA// Start DMA transfer...start_dma_transfer();// Poll for completionwhile (done_flag == 0) {// Spin — volatile ensures the compiler re-reads from memory each iteration}__DMB(); // Ensure subsequent reads see the data the DMA wroteprocess_data(rx_data);
Here, volatile ensures the compiler actually reads done_flag from memory each iteration rather than caching it in a register. The __DMB() after the loop ensures that when process_data() reads from rx_data, all the memory writes the DMA made are observed in the correct order — the data writes are guaranteed to be visible after the flag write.
Without the __DMB(), the memory system could satisfy a read of rx_data with data that was buffered or fetched before the flag read completed, resulting in stale data.
The most common barrier mistake in embedded code is not between writes to the same peripheral (those are ordered by the Device memory type), but between writes to different memory types. Consider configuring a DMA descriptor in SRAM and then enabling the DMA peripheral:
// BROKEN: no barrier between Normal memory write and Device memory writevoid start_transfer(uint32_t *src, uint32_t len) {// These writes target Normal memory (SRAM) — can be buffered/reordereddma_descriptor.src_addr = (uint32_t)src;dma_descriptor.length = len;dma_descriptor.control = DMA_DESC_VALID;// This write targets Device memory (peripheral register)DMA1_Channel1->CCR |= DMA_CCR_EN; // Enable DMA}
The compiler emits the descriptor writes before the enable, but the memory system may reorder the Normal memory writes relative to the Device memory write. The DMA controller could start reading the descriptor before all fields are written. The fix:
// CORRECT: barrier between Normal and Device memory writesvoid start_transfer(uint32_t *src, uint32_t len) {dma_descriptor.src_addr = (uint32_t)src;dma_descriptor.length = len;dma_descriptor.control = DMA_DESC_VALID;__DMB(); // Ensure all SRAM writes are observed before the peripheral writeDMA1_Channel1->CCR |= DMA_CCR_EN;}
Another subtle case: clearing an interrupt flag in a peripheral and then immediately returning from the ISR. Without a DSB, the write to clear the flag may still be in the write buffer when the processor unstacks the exception, causing the ISR to fire again spuriously:
void TIM2_IRQHandler(void) {TIM2->SR &= ~TIM_SR_UIF; // Clear the update interrupt flag__DSB(); // Ensure the flag-clear write completes before ISR return// ... handle the interrupt}
| Scenario | Barrier Needed | Why |
|---|---|---|
| Ordering SRAM writes before a peripheral write | __DMB() | Normal-to-Device ordering |
| Ordering writes to different peripherals | __DMB() | Cross-Device region ordering |
| Waiting for a peripheral write to take effect | __DSB() | Completion guarantee |
| After changing VTOR / MPU / CONTROL register | __DSB() + __ISB() | Completion + pipeline flush |
| Polling a DMA / shared memory flag (Normal memory) | __DMB() after the poll | Ensures subsequent reads see DMA data |
| Clearing an interrupt flag before ISR return | __DSB() | Prevents spurious re-entry |
| Context switch in RTOS | __DSB() + __ISB() | Full synchronization |
| Writes to the same peripheral’s registers | None (if Device memory) | Hardware guarantees ordering |
volatile and memory barriers solve different problems. volatile prevents the compiler from optimizing away or reordering memory accesses. Memory barriers (dmb, dsb, isb) control the order and completion of those accesses at the hardware level.
The decision of whether to use a barrier depends on the ARM memory type of the regions involved:
volatile alone is sufficient for ordering — the hardware guarantees that accesses to the same Device region are observed in program order.volatile and __DMB() to ensure the Normal memory writes are observed before the Device memory write.__DSB() to guarantee the write has physically reached the destination.__DSB() + __ISB() to ensure the new configuration takes effect for subsequent instructions.When in doubt, consult the ARM memory type of your target addresses and the ARM Architecture Reference Manual’s ordering rules for that type.
volatile is not a synchronization primitive and how compilers treat it.__DMB(), __DSB(), and __ISB() intrinsic wrappers used throughout this article.Quick Links
Legal Stuff




