Interrupts, Atomicity & Publication
Interrupt context, latency, shared state, atomic width, critical sections, ordering, publication, and deferred work.
Interrupts in C: volatile, Atomics, and Publication Order
An interrupt handler is a second thread of execution that can preempt the main loop between any two instructions, and C's rules for what that means are narrower than most people expect. volatile keeps a shared flag from being cached in a register, which is necessary and nowhere near sufficient: it provides no atomicity and no ordering against ordinary memory. C11 atomics provide both, and on a single-core microcontroller the relaxed and acquire-release orderings compile to ordinary loads and stores with only a compiler barrier - so the correct code is usually no slower than the incorrect code. This chapter is about that boundary: what may be shared, what makes a write visible, and what the compiler is allowed to move. The structure that most often sits across the boundary - the ring buffer, and the single-producer single-consumer queue - is the fourth stage of Embedded DSA at /dsa/queues, with the implementation and a step trace.
How it is built
- An ISR shares state with the main loop but not a stack frame, so every shared object must be static or global and must be declared in a way that survives optimisation. A local that the ISR cannot see is not shared; a global the compiler cached in a register is shared and broken.
- volatile guarantees the access is emitted and is ordered against other volatile accesses. It does not make a read-modify-write atomic, so incrementing a shared counter from both the ISR and the main loop loses updates. It also does not order a volatile write against an ordinary one, which is exactly the guarantee a producer needs when publishing data.
- C11 atomics supply what volatile does not. An atomic load with acquire ordering and a store with release ordering establish a happens-before relationship: everything the producer wrote before the release store is visible to a consumer that observes it with an acquire load. On a single-core part this usually costs nothing but a compiler barrier.
- Publication order is the rule that matters most. Write the payload, then publish the index or flag that tells the other side the payload is ready. Reversing the two exposes a slot before it is filled, and because the compiler may reorder ordinary stores freely, the ordering has to be expressed with a release store rather than assumed from the source order.
- Disabling interrupts is the blunt alternative and is sometimes correct. It gives mutual exclusion for a read-modify-write that has no atomic equivalent, at the cost of adding latency to every other interrupt in the system. The critical section must be as short as possible and must not contain anything that can block.
- An ISR must be bounded and must not block. No unbounded loop, no allocation, no waiting on a peripheral flag without a timeout, no calling into code whose worst case is unknown. The handler's job is to move the minimum amount of data and set a flag; everything else belongs in the main loop.
Design procedure
- Declare every object shared with an ISR as _Atomic where it is read-modify-written, or volatile where it is a single-writer flag, and write down which side owns it.
- Give each side sole ownership of its own index or flag. A counter written by both is a race no qualifier fixes.
- Publish with a release store and observe with an acquire load, so the payload written before the publication is guaranteed visible after it.
- Use a critical section only where no atomic operation expresses the requirement, keep it to a few instructions, and never call into unbounded code inside it.
- Bound the handler: no allocation, no unbounded loops, no blocking waits. Move data and set a flag.
- For the queue that carries the data across the boundary, use /dsa/queues - it has the SPSC implementation, the full/empty conventions and the ownership argument.
Key terms
- volatile
- The access is emitted and ordered against other volatile accesses. Not atomic, not ordered against ordinary memory.
- _Atomic
- Indivisible access plus a choice of memory ordering. What a shared counter actually needs.
- Release store
- Everything written before it becomes visible to a consumer that acquires the same object.
- Acquire load
- The matching half: after it, the producer's earlier writes are visible.
- Publication order
- Payload first, then the flag or index that announces it. The rule that makes a lock-free handoff correct.
- Critical section
- Interrupts disabled. Correct, blunt, and it adds latency to every other interrupt.
- Bounded handler
- No allocation, no unbounded loop, no blocking wait. The ISR moves data and returns.
Worked example
#include <stdatomic.h>
#include <stdint.h>
/* Broken: volatile is not atomic. Both sides read-modify-write. */
static volatile uint32_t dropped;
void ISR(void) { dropped++; } /* load, add, store - interruptible */
void main_loop(void) { dropped++; } /* updates get lost */
/* Correct: the operation is indivisible. */
static _Atomic uint32_t dropped_ok;
void ISR_ok(void) { atomic_fetch_add_explicit(&dropped_ok, 1,
memory_order_relaxed); }
/* Publication order, which is the whole of a lock-free handoff: */
static uint8_t slot[N];
static _Atomic size_t head;
void producer(uint8_t v) {
const size_t h = atomic_load_explicit(&head, memory_order_relaxed);
slot[h] = v; /* 1. payload first */
atomic_store_explicit(&head, h + 1, memory_order_release);
} /* 2. publish - release orders */
/* the payload write before */
/* it becomes visible */
# What each tool actually gives you:
#
# emitted atomic orders ordinary writes
# plain no no no
# volatile YES no no
# _Atomic relaxed YES YES no
# _Atomic acq/rel YES YES YES
# interrupts off YES YES YES (and adds latency)
# The structure that goes across this boundary: /dsa/queuesCommon pitfalls
The interrupt is a second thread you did not declare
An interrupt handler runs between two arbitrary instructions of the main program, on the same core, sharing the same memory. That makes every object they both touch a concurrency problem with no thread library in sight, and it is why single-core embedded code has races at all. The main program cannot see where it will be interrupted, so it must be correct at every instruction boundary rather than at the ones it chose.
How it is built
- The handler preempts at an instruction boundary, not a statement boundary, so a single line of C can be interrupted partway through.
- Anything the compiler cached in a register is invisible to the handler and vice versa, which is what volatile addresses.
- Any read-modify-write is at least three instructions, so a counter increment shared with a handler loses updates.
- Any access wider than the core's word is several instructions, so a 64-bit timestamp can be read half-old and half-new.
- Priority decides whether a handler can itself be preempted; on Cortex-M a higher-priority interrupt nests inside a lower one.
Design procedure
- List every object shared between the main program and each handler; that list is the concurrency design.
- Keep each shared object to a single word where possible, since a single aligned word access is atomic on Cortex-M.
- Protect anything wider or any read-modify-write with a critical section, an atomic, or a lock-free structure.
- Keep handlers short: set a flag, push to a queue, clear the source, return. Work belongs in the main loop.
- Check the priority assignment, because a handler that can be preempted by another that shares its data has the same problem one level up.
Key terms
- Preemption
- A handler interrupting code at an arbitrary instruction boundary.
- Critical section
- A region with interrupts disabled, making a multi-instruction sequence indivisible.
- Nesting
- A higher-priority interrupt preempting a running handler.
- Atomic access
- One indivisible bus operation. A single aligned word on Cortex-M.
- Handler latency
- Time from the hardware event to the handler's first instruction; lengthened by every critical section.
Worked example
The three shapes, and which are safe:
volatile uint32_t ticks; // handler writes, main reads
uint32_t t = ticks; // SAFE: one aligned word load
volatile uint32_t events;
events++; // UNSAFE: load, add, store
// handler between them loses one
volatile uint64_t us; // 32-bit core
uint64_t t = us; // UNSAFE: two loads, and an
// interrupt between them gives a
// value that never existed
The fix for each:
events: uint32_t s = __get_PRIMASK(); __disable_irq();
events++;
__set_PRIMASK(s); // restore, do not force-enable
us: read high, low, high again; retry while high changed
Saving and restoring PRIMASK matters: an unconditional __enable_irq() at the
end of a nested critical section enables interrupts inside the outer one.Common pitfalls
The lock-free ring buffer, and the conditions it needs
A single-producer, single-consumer ring buffer is the standard structure for moving data between a handler and the main loop, and it needs no lock at all - but only under specific conditions. Exactly one context writes the head, exactly one writes the tail, and each reads the other's index. Break either condition and it is no longer lock-free, it is just broken in a way that shows up rarely.
How it is built
- The producer owns the head index and the consumer owns the tail; neither writes the other's.
- Empty is head == tail; full is the next head equal to tail, which costs one slot and removes the ambiguity between empty and full.
- With a power-of-two capacity the wrap is a mask rather than a modulo, which matters because a division is slow and not atomic.
- Both indices must be volatile, or the compiler caches one context's view of the other's index.
- It is safe for exactly one producer and one consumer. Two producers need a lock, and the structure gives no warning.
Design procedure
- Size the buffer to a power of two and mask rather than take a modulo.
- Write the data before advancing the index, so the consumer never sees an index pointing at a slot that is not yet written.
- Read the index before reading the data, for the mirror-image reason.
- Add a memory barrier between the data write and the index update on any core that reorders stores.
- Decide what a full buffer does - drop the newest, drop the oldest, or block - and make it explicit, since silence here means dropping whatever the code happens to drop.
Key terms
- SPSC
- Single producer, single consumer. The condition under which no lock is needed.
- Head / tail
- Write and read indices. Each written by exactly one context.
- Power-of-two capacity
- Lets the wrap be a bitwise mask, which is fast and indivisible.
- Sacrificed slot
- Leaving one slot unused so full and empty are distinguishable without a separate count.
- Store barrier
- An ordering instruction ensuring the data write is visible before the index update.
Worked example
The whole structure, and the ordering that makes it work:
#define CAP 256 // power of two
static volatile uint16_t head, tail;
static uint8_t buf[CAP];
// PRODUCER - handler only
bool push(uint8_t b) {
uint16_t next = (head + 1) & (CAP - 1);
if (next == tail) return false; // full: drop newest
buf[head] = b; // 1. write the DATA
__DMB(); // 2. order it
head = next; // 3. then publish
return true;
}
// CONSUMER - main loop only
bool pop(uint8_t *out) {
if (tail == head) return false; // empty
*out = buf[tail];
tail = (tail + 1) & (CAP - 1);
return true;
}
Reversing steps 1 and 3 is the classic bug: the consumer sees the new head,
reads the slot, and gets whatever was there before the producer wrote it. It
fails only when the interrupt lands in that one-instruction window, so it can
run for weeks before appearing.Common pitfalls
DMA: a third party with its own view of memory
A DMA controller moves data without the CPU, which means it writes memory the CPU is not watching and reads memory the CPU may not have flushed. On a part with no cache that is a synchronisation problem; on a part with one it is a coherency problem as well. The buffer is shared between two masters that have no automatic agreement about its contents.
How it is built
- The CPU and the DMA controller are independent bus masters, so a transfer proceeds while the CPU runs unrelated code.
- A buffer the CPU wrote may still be in the data cache, so the DMA reads stale main memory unless the cache is cleaned first.
- A buffer the DMA wrote is stale in the CPU's cache, so the CPU reads old data unless the cache is invalidated after.
- Cache maintenance operates on whole lines, so a buffer sharing a line with another object corrupts that object when the line is invalidated.
- The completion interrupt says the controller finished, not that the data is visible to the CPU - the invalidate still has to happen.
Design procedure
- Align every DMA buffer to a cache line and round its size up to a multiple of one.
- Clean the cache before a DMA read of your buffer, and invalidate after a DMA write into it.
- Place the barrier between the buffer write and the register write that starts the transfer, since volatile does not order them.
- Treat the completion flag as volatile and check it rather than assuming a delay is enough.
- Confirm the buffer is in memory the DMA controller can actually reach; some controllers cannot see all RAM banks or the tightly-coupled memories.
Key terms
- Bus master
- A device that can initiate transfers. The CPU and each DMA controller are separate masters.
- Cache clean
- Writing dirty cache lines back to memory, so another master sees them.
- Cache invalidate
- Discarding cached lines, so the next read fetches from memory.
- Line granularity
- Maintenance affects whole cache lines, which is why buffer alignment matters.
- Reachability
- Whether the controller's bus can address the memory. Not all RAM is visible to all masters.
Worked example
The full sequence on a cached core, where three mechanisms all matter:
// TRANSMIT - CPU writes, DMA reads
memcpy(tx, data, n); // may sit in D-cache
SCB_CleanDCache_by_Addr(tx, n); // push it to memory
__DMB(); // order against what follows
DMA->CR |= DMA_EN; // volatile: hand it over
// RECEIVE - DMA writes, CPU reads
while (!done) { } // done is volatile
SCB_InvalidateDCache_by_Addr(rx, n); // discard stale lines
use(rx); // now the data is real
And the alignment trap, which corrupts something unrelated:
uint8_t other = 42;
uint8_t rx[64]; // may share a cache line
// with `other`
InvalidateDCache_by_Addr(rx, 64);
discards the whole line, including the cached value of `other`
-> `other` reverts to whatever is in main memory
__attribute__((aligned(32))) uint8_t rx[64]; // fixedCommon pitfalls
Low power: what actually stops, and what wakes it
A low-power mode is a set of things that stop: the core clock, the peripheral clocks, the oscillators, the regulators. Which ones stop decides both the current draw and what is still able to wake the part, and those two move in opposite directions. The deeper the mode, the less can wake you and the more state you lose - so choosing a mode is choosing what you are willing to give up.
How it is built
- Sleep stops the core clock and leaves peripherals running, so any interrupt wakes it and RAM and registers are intact.
- Stop modes halt most clocks and often the main oscillator; wake-up is limited to specific sources and takes longer because the oscillator must restart.
- Standby powers down most of the device: current falls to microamps, most state is lost, and wake-up is effectively a reset.
- Every mode has an exit latency, and it grows with depth because oscillators and regulators need time to stabilise.
- A watchdog must be serviced or deliberately stopped before entering a mode whose duration exceeds its timeout.
Design procedure
- Choose the shallowest mode that meets the current budget, since the deeper ones cost state and latency.
- Enumerate the wake sources the mode still supports, and confirm the one you rely on is among them.
- Configure the wake source before entering the mode, and check for a pending event afterwards to avoid sleeping through it.
- Handle the watchdog explicitly: either it runs in the mode and must be serviced, or it must be stopped and restarted.
- Measure the actual current with the debugger detached, since a connected probe keeps clocks running and hides the whole effect.
Key terms
- Sleep
- Core clock stopped, peripherals running. Any interrupt wakes it.
- Stop
- Most clocks halted, limited wake sources, longer exit latency.
- Standby
- Nearly everything powered down. Wake is close to a reset and state is lost.
- Exit latency
- Time from wake event to the first instruction, dominated by oscillator start-up.
- Independent watchdog
- A watchdog on its own oscillator, which keeps running in modes where the main clock does not.
Worked example
The trade, on a typical Cortex-M part:
mode current wakes from state exit
run 10 mA - all -
sleep 2 mA any interrupt all instant
stop 10 uA EXTI, RTC, watchdog RAM ~100 us
standby 1 uA wake pin, RTC none reset
A design that needs a UART interrupt to wake it cannot use stop,
because the UART clock is not running to detect the edge. That is
a design constraint, not a configuration one.
The race that loses an event, and the fix:
if (!pending) { // checked here...
// ...interrupt fires HERE, sets pending
enter_stop(); // ...and we sleep through it
}
uint32_t s = __get_PRIMASK(); __disable_irq();
if (!pending) enter_stop(); // WFI wakes on a pending
__set_PRIMASK(s); // interrupt even while masked
On Cortex-M, WFI wakes on a pending interrupt even with PRIMASK set, which is
precisely what makes this pattern work.Common pitfalls
Priorities, nesting, and the inversion they create
On Cortex-M every interrupt has a configurable priority, and a higher-priority interrupt preempts a running lower-priority handler. That is what makes a fast interrupt fast, and it is also what turns a shared resource into a priority inversion: a low-priority handler holding something a high-priority one needs blocks it for as long as it holds it, whatever the priority numbers say.
How it is built
- Lower numeric value means higher priority on Cortex-M, which is the opposite of most people's first assumption.
- Only some of the priority bits are implemented - typically three or four - so writing values that differ in the low bits produces equal priorities.
- Priority grouping splits the field into preemption priority and sub-priority; only the preemption part decides nesting.
- A critical section that disables all interrupts blocks the highest-priority handler for its whole duration, which is the worst case for latency.
- BASEPRI masks interrupts below a chosen level rather than all of them, so a critical section can protect data without delaying the most urgent handler.
Design procedure
- Assign priorities from deadlines: the shortest deadline gets the highest priority, not the most important-sounding peripheral.
- Write priorities through the vendor's macro, so the unimplemented low bits are handled correctly.
- Keep the highest-priority handler free of any shared resource, so nothing can block it.
- Prefer BASEPRI to a full disable where a hard real-time handler exists, and document the level chosen.
- Measure worst-case latency as the longest critical section plus the deepest nesting, rather than assuming the priority order is sufficient.
Key terms
- Preemption priority
- The part of the priority field that decides whether one interrupt can nest inside another.
- Implemented bits
- The subset of priority bits the part actually has. Writing finer distinctions than exist silently ties them.
- BASEPRI
- A register masking interrupts below a level, leaving higher ones live.
- Priority inversion
- A high-priority handler blocked by a lower-priority one holding a shared resource.
- Worst-case latency
- The longest delay before a handler runs: the longest mask plus higher-priority handler time.
Worked example
The bits that are not there, and the ordering that surprises people:
A part with 4 implemented priority bits ignores the low 4:
NVIC_SetPriority(IRQ_A, 0); // -> 0x00
NVIC_SetPriority(IRQ_B, 1); // -> 0x10 distinct
writing the register directly, unshifted:
NVIC->IP[IRQ_A] = 0; // 0x00
NVIC->IP[IRQ_B] = 1; // 0x01 -> low bits dropped
// both become 0x00: EQUAL
Two handlers that were meant to nest now cannot preempt each
other, and the symptom is a latency spike rather than an error.
And the inversion, which priority numbers do not prevent:
low-priority handler: __disable_irq(); long_operation(); ...
high-priority handler: blocked for the whole long_operation
BASEPRI fixes it if the high-priority handler does not touch the
shared data:
__set_BASEPRI(0x20); // mask priority 0x20 and below
shared_update(); // priority 0x00 and 0x10 still run
__set_BASEPRI(0);Common pitfalls
Debugging a race you cannot reproduce
Concurrency bugs in embedded code share a signature: rare, non-deterministic, and altered or hidden by the act of observing them. A breakpoint changes the timing, a printf changes it more, and the failure that happened hourly stops for a day. The productive approach is to stop trying to catch it live and instead make the system record enough that the failure explains itself afterwards.
How it is built
- A breakpoint stops the core while interrupts keep arriving, so the state you inspect is not the state at the failure.
- printf is slow and frequently not reentrant, so calling it from a handler both changes the timing and can corrupt its own output.
- A ring-buffer trace written from anywhere and read after the fact costs a few instructions and does not change the timing meaningfully.
- A GPIO toggled at entry and exit of a handler gives exact timing on a scope with essentially no overhead.
- The fault registers on Cortex-M record why a fault occurred, and the stacked frame records where - both survive into the handler.
Design procedure
- Add a lightweight trace buffer early: an index, a timestamp and an event code, written with no locking beyond an atomic index increment.
- Toggle a spare GPIO around each handler and look at the timing on a scope rather than inferring it.
- Read the fault status registers in the fault handler and record them, rather than sitting in a while loop.
- Reproduce under load rather than at idle, since most races need contention to appear.
- When a bug disappears under the debugger, treat that as evidence of a timing dependency rather than as a fluke.
Key terms
- Heisenbug
- A defect altered or hidden by observing it. Characteristic of timing-dependent faults.
- Trace buffer
- A ring of event records written cheaply from any context and read after a failure.
- CFSR
- Configurable Fault Status Register: why the fault happened.
- Stacked frame
- The registers the core pushes on exception entry, including the faulting PC.
- GPIO timing
- Toggling a pin at known points and measuring on a scope. Near-zero overhead.
Worked example
The trace buffer that costs almost nothing:
typedef struct { uint32_t t; uint16_t ev; uint16_t arg; } Rec;
static Rec log[256];
static volatile uint8_t idx; // wraps naturally
static inline void trace(uint16_t ev, uint16_t arg) {
uint8_t i = idx++; // one byte, atomic
log[i] = (Rec){ DWT->CYCCNT, ev, arg };
}
Three stores and a cycle counter read. Safe from a handler, does
not allocate, does not block, and the last 256 events survive the
failure for you to read in the debugger.
And the fault handler that says something:
void HardFault_Handler(void) {
uint32_t cfsr = SCB->CFSR; // WHY
uint32_t *sp = (uint32_t *)__get_MSP();
uint32_t pc = sp[6]; // WHERE
trace(EV_FAULT, (uint16_t)cfsr);
for (;;) { }
}
A bare `for(;;){}` fault handler discards both. Recording them turns a lock-up
into an address you can look up in the map file.Common pitfalls
More in Embedded C
- Structs, Unions, Enums & TypedefStructs, unions, enums, typedef aliases, bit-fields, designated initialization, tagged variants, and opaque handles.
- Practice, Interviews & CapstonesDiagnostics, focused C practice, contextual DSA bridges, compiled examples, production projects, and design-defense prompts.
- Types, Promotion & ArithmeticInteger widths, signedness, promotion, conversions, overflow, qualifiers, floating point, size, and alignment.
- MMIO, Volatile & Access SemanticsMemory-mapped I/O, volatile guarantees, read and write side effects, read-modify-write hazards, barriers, and assembly evidence.
- Functions, Modules & ContractsFunction declarations, parameters, callbacks, linkage, reentrancy, error contracts, opaque modules, HAL seams, and failure states.
- Compiler WorkbenchEdit C, compile for embedded targets, inspect diagnostics, assembly, sections, symbols, code size, and run a host-side test harness.
- Bits, Fields, Endianness & EncodingsSafe mask construction, field extraction and insertion, shifts, Gray code, endianness, packing, and representation boundaries.
- State Machines, Dispatch & TimeoutsExplicit state, events, transition tables, function-pointer dispatch, timers, timeouts, invalid states, and exhaustive tests.
- Arrays, Strings & Bounded BuffersArray decay, explicit extents, C-string termination, spans, checked size arithmetic, and bounded buffer APIs.
- Fixed-Point Range, Scaling & SaturationQ formats, range and resolution, intermediate width, rounding, saturation, accumulated error, and integer DSP implementation.
- Serialization, Framing & Defensive ParsingExplicit byte order, field encoding, frame boundaries, resynchronization, checked arithmetic, transactional output, and hostile-input parsing.
- Stack, Pools, Arenas & AllocationBounded memory planning with stack budgets, fixed pools, arenas, fragmentation models, allocation failure, and instrumentation.