Embedded HandbookApps
All sections › XV. C in depth
STM32F103, STM32F407 · level advanced · 10 min read
Open in the interactive handbook

124. volatile, const and atomicity: interrupts and C11

Firmware almost always lives in two threads at once: the main program and the interrupts. The data shared between them, flags, counters, buffers, is the most fragile place: code that is correct line by line loses events and hangs. Three words of C solve three different problems, and they are constantly confused: volatile means "read memory every time", atomicity means "do it in one indivisible step", const means "do not change it, and while you are at it, put it in flash memory". This section shows each of them by experiment: on the computer with a signal and threads, on the board in the instructions that armclang produces.

It builds on interrupts (section 7) and undefined behavior (section 123).

Terms used in the documentation

Term Meaning
volatile every access goes to memory
atomic operation an indivisible operation
read-modify-write read, change, write back
race condition the result depends on who gets there first
critical section code that runs with interrupts held off
exclusive access (LDREX/STREX) a load and store pair that detects interference
lock-free without locks
memory barrier (DMB, DSB) an instruction that enforces the order of memory accesses
memory order the ordering guarantee of an atomic operation
compare-and-swap write only if the value is still the expected one
sig_atomic_t a type that a signal handler writes in one piece

124.1 A flag from an interrupt: why volatile

A typical wait loop: the interrupt sets a flag, the main program waits.

while (!flag_plain) { }              /* флаг прочитан один раз: цикл без чтения */

The compiler sees the whole function and does not see the interrupt. To it, nobody changes the flag inside the loop, so the flag can be read once. On the computer that is exactly what happened: the flag is set by a signal handler (the analogue of an interrupt), but the loop built with -O2 never noticed the write, and the program was killed by a timeout.

On the board armclang went even further: it turned the whole wait function into a single BX lr instruction. A loop with a non-constant condition and no side effects, without volatile, atomic operations or input/output, may be assumed to terminate under C11 (this does not apply to an endless for (;;) { }), and the compiler threw it away: the function returns at once, without waiting. The bug is all the more dangerous because the device does not hang but simply moves on too early.

With volatile, every pass reads memory again:

while (!flag_volatile) { }           /* чтение памяти на каждом проходе */

and on the computer the program exited right after the signal, while on the board LDR instructions are visible in the loop.

A flag wait loop without volatile and with volatile: one memory read versus a read on every pass


124.2 What volatile promises and what it does not

volatile promises one thing: every access in the source is an access to memory, in the same order relative to other volatile accesses. That is why all peripheral registers in the CMSIS headers (__IO), interrupt flags and variables changed by DMA are marked with it.

What volatile does not promise:

The experiment shows this directly: two threads, each adding one a million times to a volatile counter, lost some of the additions, while the atomic counter gave exactly 2000000.


124.3 A race: read-modify-write

events++ is three instructions; an interrupt between the read and the write loses one event

events++;                            /* LDR, ADDS, STR — между ними может войти прерывание */

armclang turns this into three instructions: LDR (read), ADDS (add), STR (write). If an interrupt comes after LDR and itself adds to the same counter, the main program will then write the old value plus one, and the interrupt's event is gone. The bug is rare, depends on the moment the interrupt arrives and does not reproduce under a debugger: the classic "sometimes".

The rule: anything that the main program and an interrupt both change is changed indivisibly. There are three ways to do it.


124.4 Critical section: disabling interrupts

The simplest and most universal: for the duration of the read-modify-write, disable interrupts, then restore the previous state.

__asm volatile("mrs %0, primask" : "=r"(primask));
__asm volatile("cpsid i" ::: "memory");
events++;
__asm volatile("msr primask, %0" ::"r"(primask) : "memory");

It is important to restore the previous value of PRIMASK rather than simply enabling interrupts: the function may be called from inside another critical section. "memory" in the inline assembly is a compiler barrier: it will not move memory accesses across the disable. Sections must be short: while they last, every interrupt is delayed (section 7, entry latency).


124.5 C11 atomic operations

Since C11 there is stdatomic.h, and the compiler itself chooses how to make an operation indivisible:

atomic_fetch_add_explicit(&events_atomic, 1u, memory_order_relaxed);

On Cortex-M3 and M4 armclang built this into an LDREX/STREX loop: an exclusive read, the addition, an attempt to write; if someone interfered in between, STREX does not write and the loop repeats. Interrupts are not disabled.

Cortex-M0 has no exclusive instructions, and the same line turns into a call to the library function __sync_fetch_and_add_4, and the compiler warns that the operation is not "lock-free". On M0 atomicity is the same critical section, only hidden in a library, and the environment must provide its implementation.

Useful operations beyond addition: exchange (atomic_exchange) for a simple lock, atomic_fetch_or for flags from several interrupts in one word, and compare-and-swap (atomic_compare_exchange_strong) for lock-free queues. They all return the previous value: that is what lets you tell who got there first.


124.6 Barriers: ordering for DMA and a second core

Sometimes what matters is the order of writes as seen not by the processor but by the bus: the data for DMA must land in memory before the DMA is started.

dma_buf[0] = 0xA5;
atomic_thread_fence(memory_order_seq_cst);   /* превращается в DMB */
dma_start = 1;

On all three cores atomic_thread_fence became a DMB instruction: all memory accesses before it complete before the accesses after it. In CMSIS the same thing is __DMB(); __DSB() additionally waits for completion, __ISB() flushes the pipeline after a change to the core settings (section 7). On Cortex-M3/M4 with a simple bus the order of writes is usually preserved even without a barrier. On M7 (F7, H7) with the data cache enabled a barrier alone is not enough: the written bytes may stay in the cache, while DMA reads RAM directly and will send stale data. Before starting a transfer the buffer is cleaned from the cache with SCB_CleanDCache_by_Addr, and after a reception the cache over the buffer is invalidated with SCB_InvalidateDCache_by_Addr; the buffer itself is aligned to 32 bytes (the cache line) and made a multiple of 32. The other way is to place the DMA buffers in a region that the MPU marks as non-cacheable.


124.7 const: not only "cannot be changed"

const on a global table changes where it is placed. In the object file:

On a microcontroller with 20 KB of RAM a sine table or a font without const wastes memory. The rule: everything that does not change is const.

For registers there is the combination volatile const, "we only read it, but the value changes by itself": this is how read-only registers are described (__I in CMSIS).


124.8 What to use where

Task Tool
an "event happened" flag from an interrupt volatile, writing a single word
a counter changed by both sides an atomic operation or a critical section
several fields changed together a critical section
write order before starting DMA a DMB barrier; on M7 with cache, also a cache clean
a peripheral register volatile (already in the header)
a constant table const, kept in flash memory

A single read or write of an aligned 32-bit word on Cortex-M is indivisible by itself, which is why a flag that fits in one word only needs to be marked volatile. Anything longer, or anything made of a read and a write, is not.


124.9 The same in an RTOS

In FreeRTOS the same rules apply between tasks, only the role of the interrupt is played by the scheduler. The ready-made tools, queues, semaphores and taskENTER_CRITICAL (sections 26–30), are built the same way inside: a critical section or exclusive instructions. Home-made "queues on flags" without atomicity are a source of the same races.

On M3/M4 taskENTER_CRITICAL does not set PRIMASK but raises BASEPRI: interrupts with a priority above configMAX_SYSCALL_INTERRUPT_PRIORITY still run during the section, and FreeRTOS functions must not be called from them. The same technique works for your own sections instead of disabling all interrupts (124.4), when an urgent interrupt must not be delayed.


124.10 Pitfalls

A flag wait loop exits at once or hangs. The flag is not volatile: the compiler read it once or threw the loop away. Use volatile.

The event counter sometimes falls behind. ++ from the main program and from an interrupt: three instructions, with an interrupt between them. Use an atomic operation or a critical section.

After a critical section interrupts were enabled where they should not be. The section enabled them unconditionally instead of restoring PRIMASK.

On M0 an atomic operation does not build: __sync_fetch_and_add_4 is missing. M0 has no exclusive instructions; you need an implementation that disables interrupts.

DMA sent stale data. The start was moved before the buffer write: put a DMB barrier between them. On M7 with a data cache the buffer has also not been cleaned from the cache: call SCB_CleanDCache_by_Addr before the start.

RAM ran out because of tables. Tables without const are copied to RAM.


124.11 Summary

Full source code

vol124_arm.c contains the flag wait, a counter shared by the program and an interrupt, a barrier before DMA and a const table, built for Cortex-M.

/* Раздел 124: volatile, const и атомарность на Cortex-M — armclang для F103 и F407
 * (и для M0 — сравнить атомарность). _verify/c124_check.py смотрит в объектный файл:
 * во что превращается ожидание флага, прибавление к счётчику, барьер и таблица. */
#include <stdatomic.h>
#include <stdint.h>

/* ── ожидание флага, который ставит прерывание ─────────────────────────── */
int flag_plain;
volatile int flag_volatile;

void wait_plain(void)
{
    while (!flag_plain) { }              /* флаг прочитан один раз: цикл без чтения */
}

void wait_volatile(void)
{
    while (!flag_volatile) { }           /* чтение памяти на каждом проходе */
}

/* ── общий счётчик основной программы и прерывания ─────────────────────── */
volatile uint32_t events;
atomic_uint events_atomic;

void count_plain(void)
{
    events++;                            /* LDR, ADDS, STR — между ними может войти прерывание */
}

void count_atomic(void)
{
    atomic_fetch_add_explicit(&events_atomic, 1u, memory_order_relaxed);
}

/* Критическая секция: запрет прерываний на время чтения-изменения-записи. */
void count_masked(void)
{
    uint32_t primask;
    __asm volatile("mrs %0, primask" : "=r"(primask));
    __asm volatile("cpsid i" ::: "memory");
    events++;
    __asm volatile("msr primask, %0" ::"r"(primask) : "memory");
}

/* ── барьер: данные для DMA записаны до запуска передачи ───────────────── */
uint8_t dma_buf[16];
volatile uint32_t dma_start;

void start_dma(void)
{
    dma_buf[0] = 0xA5;
    atomic_thread_fence(memory_order_seq_cst);   /* превращается в DMB */
    dma_start = 1;
}

/* ── const: таблица остаётся во флеш-памяти, без const — ещё и копия в ОЗУ ─ */
const uint8_t table_const[256] = { 1, 2, 3 };
uint8_t table_ram[256] = { 1, 2, 3 };

uint8_t lookup(unsigned i)
{
    return (uint8_t)(table_const[i & 255u] + table_ram[i & 255u]);
}

Next: section 125 "Declarations and function pointers" — how to read void (*const handlers[])(void) and build handler tables.

Open in the interactive handbook