Interrupts, Rings & Concurrency
The handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
Concurrency, Atomics, DMA, and Cache Ownership
Concurrency begins whenever more than one execution agent can access the same state: main and an ISR, multiple RTOS tasks, multiple cores, or a CPU and DMA engine. ISO C defines a data race between threads as undefined behavior and provides atomics and fences for compatible CPU agents. Embedded systems add platform rules for interrupt masking, memory-mapped device ordering, DMA descriptors, cache maintenance, and peripheral completion. Correctness comes from ownership: exactly one agent may mutate a buffer at a time, each transfer has evidence of completion, and the next owner performs whatever synchronization and visibility actions the platform requires.
How it is built
- Volatile preserves abstract accesses to an object but does not make a compound operation atomic, establish a happens-before relationship, flush a cache, or prevent the CPU from reordering device transactions. It is required for MMIO and may be part of an ISR flag contract, but it is not a portable queue primitive.
- C atomics define indivisible operations and memory ordering among participating CPU threads. Relaxed ordering protects atomicity only; release publishes earlier writes, acquire observes writes published by the matching release, and sequential consistency adds a single global order. On small targets, an atomic type may call a library routine or disable interrupts, so lock-free suitability must be verified before ISR use.
- A critical section prevents selected interrupt or task interleavings on one processor. Its scope must be bounded and priority-aware. Disabling interrupts does not stop DMA or another core, and an RTOS mutex is normally illegal inside an ISR.
- DMA has a descriptor and buffer ownership state machine: CPU_FREE, CPU_READY, DEVICE_OWNED, DEVICE_DONE, CPU_PROCESSING. Software writes payload and descriptor, performs required cache clean and barrier operations, publishes ownership, then rings the device. Completion transfers ownership back only after status and visibility requirements are satisfied.
- Caches operate in lines, not C objects. Cleaning writes dirty CPU lines to the point visible to the device; invalidating discards CPU copies so later reads fetch device-written data. Incorrect alignment can make maintenance affect unrelated neighbors, so DMA buffers often require cache-line alignment and exclusive line ownership.
- Peripheral completion has layers. A DMA transfer-complete flag can mean bytes reached a peripheral FIFO, while the UART shift register is still transmitting. Shutdown, direction control, and power transitions must wait for the final hardware-idle condition documented by the peripheral.
Design procedure
- List every agent and shared object. For each object write current owner, allowed reader/writer, atomic granularity, publication action, completion evidence, cache action, and next owner. If a cell is vague, the design is not complete.
- Prefer ownership transfer over shared mutation. Give ISR and task separate indexes in an SPSC ring, or pass whole buffers through a queue. Avoid several contexts updating a multi-field structure in place.
- Select the synchronization primitive from the language and platform. Verify width and lock freedom of atomics, interrupt priorities for critical sections, ISR-safe RTOS APIs, and device memory attributes for MMIO.
- For transmit DMA, fill the CPU-owned buffer, clean required cache lines, issue the platform barrier, program the descriptor, transfer ownership, and start DMA. Do not reuse the buffer until completion returns ownership.
- For receive DMA, invalidate according to platform rules only when CPU no longer has dirty bytes in those lines, wait for completion, perform any post-completion barrier, then parse within the actual received length.
- Test delayed and missing consumers, queue full, descriptor exhaustion, out-of-order interrupts, duplicate completion, wraparound, stale-cache simulation, cancellation, timeout, and reset during every ownership state. Instrument high-water and ownership violations.
Key terms
- data race
- Conflicting unsynchronized accesses where at least one is a write; undefined behavior in the ISO C thread model.
- happens-before
- The ordering relation that makes one execution agent's writes defined and visible to another participating agent.
- lock-free
- An atomic operation implemented without a blocking library lock; required by many ISR contracts but target-specific.
- ownership
- The exclusive right of one agent to mutate a resource until a defined handoff completes.
- cache clean
- Write dirty cache-line contents toward memory so another agent can observe CPU writes.
- cache invalidate
- Discard cached line contents so future CPU reads obtain memory updated by another agent.
Worked example
typedef enum { CPU_FREE, CPU_READY, DMA_OWNED, CPU_DONE } owner_t;
typedef struct CACHE_LINE_ALIGNED {
uint8_t data[256];
size_t length;
_Atomic owner_t owner;
} dma_block_t;
bool dma_submit(dma_block_t *b)
{
owner_t expected = CPU_READY;
cache_clean(b->data, b->length); /* platform operation */
device_write_barrier();
if (!atomic_compare_exchange_strong_explicit(
&b->owner, &expected, DMA_OWNED,
memory_order_release, memory_order_relaxed)) {
return false;
}
dma_program_and_start(b->data, b->length);
return true;
}Common pitfalls
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
Low-Power Modes, Wakeup, and the Watchdog
A battery-powered device spends nearly all its time asleep, and the difficult part is not entering a sleep mode but doing so without losing the event that was supposed to wake it. The race is structural: code checks whether there is work, finds none, and executes a sleep instruction - and if the interrupt arrives between the check and the instruction, its wakeup is consumed before the processor was asleep, so the device sleeps until the next unrelated event or forever. Every architecture provides a way to close that window, and using it is the whole of correct sleep code. The watchdog is the other half of the same subject: a timer that resets the device unless it is regularly serviced, which is the last defence against a hang - and which is defeated entirely by servicing it from a periodic interrupt that keeps running while the main loop is stuck.
How it is built
- The check-then-sleep race is the central problem. Reading a flag, finding no work, and then sleeping leaves a window in which an interrupt can set the flag and complete. The processor then sleeps with work pending. On ARM the sequence is to disable interrupts, re-check the condition, and execute a wait instruction that the pending interrupt wakes even though interrupts are masked - so the check and the sleep become effectively atomic.
- Sleep modes trade current against what stays alive and how long it takes to come back. A light mode stops the core and keeps peripherals and RAM, waking in microseconds. A deep mode stops most clocks and may lose peripheral configuration, waking in milliseconds. The deepest modes retain only a small always-on domain and a few bytes of backup storage, and returning from them resembles a reset more than a resume.
- What survives each mode is the property that decides the software structure. If RAM is retained, execution continues at the instruction after the sleep. If it is not, the device restarts and must distinguish a wake from a cold boot using a reset-cause register and whatever backup storage the always-on domain provides.
- Wake sources have to be configured before sleeping and are mode-dependent: a pin edge, an RTC alarm, a watchdog, or a peripheral that remains clocked. A source that is valid in a light mode may be gated in a deeper one, so entering the deeper mode with only that source configured is how a device fails to wake at all.
- The independent watchdog runs from its own oscillator so it survives a main-clock failure. It resets the device unless serviced within its window, and some variants also reset if serviced too early, which catches a runaway loop that services it continuously.
- Servicing the watchdog from a timer interrupt defeats it completely. The interrupt keeps running while the main loop is deadlocked, so the device stays reset-free while doing nothing. The service call belongs at one point in the main loop, gated on evidence that every task actually ran.
Design procedure
- Close the check-then-sleep race with the architecture's mechanism: mask interrupts, re-check the work condition, then execute the wait instruction that a pending interrupt still wakes.
- Choose the sleep mode from what has to stay alive, and write down which peripherals, which RAM and which wake sources survive it.
- Configure every wake source before entering the mode, and verify each one still functions in that specific mode rather than in the lighter one it was tested in.
- On modes that lose state, read the reset-cause register at startup and branch between cold boot and resume explicitly.
- Service the watchdog from exactly one place in the main loop, gated on flags that every periodic task sets, so a stuck task still causes a reset.
- Measure current in each mode with an instrument rather than trusting the datasheet figure, since a single misconfigured pin can dominate the total.
Key terms
- Check-then-sleep race
- An interrupt arriving between the work check and the sleep instruction, consuming the wakeup.
- Wait-for-interrupt
- The instruction that sleeps until an interrupt is pending, and which a pending interrupt wakes even when masked.
- Retention
- Which RAM and peripheral state survives a mode. Decides whether you resume or restart.
- Wake source
- What can end the sleep. Mode-dependent, and must be configured before entering.
- Reset cause
- The register that distinguishes cold boot, watchdog reset and wake from a state-losing mode.
- Independent watchdog
- Runs from its own oscillator, so it survives a main-clock failure.
- Windowed watchdog
- Resets if serviced too early as well as too late, catching a runaway service loop.
Worked example
/* The race, and the fix. */
/* WRONG: an interrupt between the check and the sleep is lost. */
while (1) {
if (!work_pending()) {
__WFI(); /* wakeup may already have happened */
}
do_work();
}
/* RIGHT: mask, re-check, then sleep. A pending interrupt still
wakes WFI while PRIMASK is set, so the window is closed. */
while (1) {
__disable_irq();
if (!work_pending()) {
__WFI();
}
__enable_irq(); /* the ISR runs here */
do_work();
watchdog_service_if_all_tasks_ran();
}
/* Watchdog that a stuck task cannot defeat: */
static uint32_t task_flags;
#define ALL_TASKS (TASK_A | TASK_B | TASK_C)
void watchdog_service_if_all_tasks_ran(void) {
if ((task_flags & ALL_TASKS) == ALL_TASKS) {
task_flags = 0;
IWDG_KR = IWDG_RELOAD; /* only when every task ran */
}
}
# Sleep modes, and what each costs to leave:
#
# mode current retains wake time
# run ~10 mA everything --
# sleep ~2 mA everything ~1 us
# stop ~10 uA RAM, some periph ~10 us
# standby ~1 uA backup only ~1 ms (resembles reset)
# Servicing the watchdog from a timer ISR defeats it entirely:
# the ISR keeps running while the main loop is deadlocked.Common 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
- Volatile RegistersMaster volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
- User-Defined TypesDeclare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.
- Embedded C + DSA 0 → 100One self-sufficient course connecting beginner C, Embedded C, hardware-facing APIs, bounded data structures, Embedded DSA practice, compiled code, diagnostics and production capstones.
- Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
- Types / PromotionUnderstand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
- Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
- Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
- Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
- Object Layout & StorageWhere an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.
- C Basics & the Translation UnitFrom source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
- FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
- Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.