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_ta 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.
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:
- indivisibility:
++on avolatilecounter is still three instructions; - order relative to ordinary variables: the compiler may move a non-volatile write across a volatile one;
- order at the bus level: that is what barriers are for (124.6).
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++; /* 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:
const uint8_t table_const[256]is in a read-only section, that is, in flash memory, and takes no RAM;uint8_t table_ram[256]withoutconstis in.data: 256 bytes of flash memory hold the initial values, and another 256 bytes of RAM are filled by the startup code at reset (section 11).
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
volatilemeans "read memory every time": without it the flag from the handler was never seen, and armclang turned the wait loop intoBX lr.volatiledoes not make an operation indivisible: two threads lost some of their additions, the atomic counter gave exactly 2000000.events++isLDR,ADDS,STR; indivisible means a critical section that restoresPRIMASK, oratomic_fetch_add(LDREX/STREXon M3/M4,__sync_fetch_and_add_4on M0).- Ordering for DMA needs a barrier:
atomic_thread_fencebecameDMB; on M7 with a data cache the buffer is also cleaned from the cache before DMA starts. constmoves a table into flash memory: without it the 256 bytes also go to RAM.
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.