CodeRigor

FREE LESSON

Critical sections and ISR-safe data

Sharing data between interrupt handlers and main code: torn reads, lost updates, PRIMASK guards that nest, what std::atomic compiles to on a Cortex-M4, and a lock-free queue between an ISR and the main loop.

On a microcontroller the "other thread" is an interrupt handler. It can run between any two instructions of your main code, it runs to completion, and main can't run until it returns. That asymmetry shapes everything in this lesson: main must protect itself from the ISR, but the ISR (at a single priority level) never has to protect itself from main.

All snippets run on the emulated Cortex-M4 board (mps2-an386). The emulator executes one instruction per nanosecond of virtual time and the peripherals count at 25 MHz, so a TIMER0 period of 50 cycles is about 2000 instructions — and every run is identical.

1. Three ways shared data breaks

A lost update: the ISR runs between main's load and store
count = count + 1 is three instructions. The ISR's increment happens in the gap, and main's str overwrites it with a value computed from the old count.
A lost update: the ISR runs between main's load and store
count = count + 1 is three instructions. The ISR's increment happens in the gap, and main's str overwrites it with a value computed from the old count.
  1. Lost update — a read-modify-write (x = x + 1, x |= bit) split by the interrupt. volatile doesn't help: it guarantees the ldr and str happen, not that nothing happens between them.
  2. Torn read — data wider than one load (a 64-bit counter, a struct, a timestamp + value pair) read in two parts with the ISR's update in between: a value that never existed.
  3. Publish-before-ready — main sets "item available" before the item is written; the ISR consumes garbage.
A torn read of a 64-bit counter
The counter is 0x0000_0000_ffff_ffff. Main reads the low word, the ISR increments (carrying into the high word), main reads the high word: 0x0000_0001_ffff_ffff — a value the counter never held.
A torn read of a 64-bit counter
The counter is 0x0000_0000_ffff_ffff. Main reads the low word, the ISR increments (carrying into the high word), main reads the high word: 0x0000_0001_ffff_ffff — a value the counter never held.
C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

volatile unsigned count = 0, isr_runs = 0;

extern "C" void TIMER0_IRQHandler() {
    mps2::timer0().intstatus = 1;
    count = count + 1;
    isr_runs = isr_runs + 1;
}

int main() {
    auto& t = mps2::timer0();
    t.reload = 50; t.value = 50; t.ctrl = 1 | 8;
    mps2::nvic_enable(mps2::irq::timer0);
    for (int i = 0; i < 20000; ++i) count = count + 1;
    t.ctrl = 0;
    mps2::nvic_disable(mps2::irq::timer0);
    std::printf("expected %u, got %u\n", 20000 + isr_runs, count);
}

2. Critical sections with PRIMASK

cpsid i sets PRIMASK: every configurable interrupt is held pending (not lost) until cpsie i. Between them, main code can't be interrupted, so a read-modify-write or a multi-word read becomes indivisible.

PRIMASK is one bit, not a counter. A guard that blindly re-enables on exit breaks nesting — and any caller that had interrupts masked for its own reasons (including code running inside an ISR). So save, mask, restore:

C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

class CriticalSection {
public:
    CriticalSection() : was_masked_(mps2::primask() & 1u) { mps2::irq_disable(); }
    ~CriticalSection() { if (!was_masked_) mps2::irq_enable(); }
    CriticalSection(const CriticalSection&) = delete;
    CriticalSection& operator=(const CriticalSection&) = delete;
private:
    bool was_masked_;
};

int main() {
    {
        CriticalSection outer;
        { CriticalSection inner; }
        std::printf("after inner: masked=%lu\n", mps2::primask() & 1);
    }
    std::printf("after outer: masked=%lu\n", mps2::primask() & 1);
}
Save, mask, restore: why nesting works
The inner guard sees PRIMASK already set, so its destructor leaves it set; only the outer guard, which found it clear, unmasks. The program prints masked=1, then masked=0.
Save, mask, restore: why nesting works
The inner guard sees PRIMASK already set, so its destructor leaves it set; only the outer guard, which found it clear, unmasks. The program prints masked=1, then masked=0.

Rules of thumb:

  • Keep it short. Every instruction inside adds to the worst-case latency of every interrupt. Copy data out, then work on the copy outside the section.
  • No waiting inside. Polling a flag that an ISR sets, with interrupts masked, waits forever.
  • Mask only what you must. On ARMv7-M, BASEPRI masks only interrupts at or below a priority, so urgent handlers that don't share the data still run. PRIMASK is the simple, blunt tool; BASEPRI the precise one.

3. What std::atomic compiles to on the M4

ARMv7-M has no read-modify-write-to-memory instruction. It has exclusive loads and stores:

Assembly
bump:                       @ n.fetch_add(1, relaxed)
    mov   r3, r0
.L2:
    ldrex r0, [r3]          @ load, and arm the exclusive monitor
    adds  r2, r0, #1
    strex r1, r2, [r3]      @ store only if the monitor is still armed; r1 = 0 on success
    cmp   r1, #0
    bne   .L2               @ an interrupt in between cleared the monitor: retry
    bx    lr

Exception entry and return clear the monitor, so an interrupt between ldrex and strex makes the strex fail and the loop retry — the update is never split, and interrupts are never masked. That works for 8-, 16- and 32-bit atomics (ldrexb/ldrexh/ldrex). There's no 64-bit exclusive pair on ARMv7-M: std::atomic<std::uint64_t> is not lock-free and every operation becomes a call to __atomic_*_8, which a bare-metal library implements by masking interrupts. For a 64-bit counter you'll usually write the critical section yourself.

ldrex/strex: an interrupt makes the store fail, not the update
Exception entry and return clear the exclusive monitor, so strex returns 1 and the loop runs again with the ISR's value. Interrupts are never masked.
ldrex/strex: an interrupt makes the store fail, not the update
Exception entry and return clear the exclusive monitor, so strex returns 1 and the loop runs again with the ISR's value. Interrupts are never masked.

Ordering: a relaxed load or store is a plain ldr/str; acquire adds a dmb ish after the load, release a dmb ish before the store, seq_cst both. On a single in-order core the CPU never reorders accesses as seen by its own interrupt handlers — what you need there is to stop the compiler reordering, which any atomic order (or std::atomic_signal_fence) does. The dmb matters once another bus master is involved: a DMA controller reading a buffer you just filled, or a second core.

4. A lock-free queue between main and an ISR

The classic structure is a ring buffer where each index has exactly one writer:

Main ↔ ISR ring: one writer per index
The consumer writes tail, the producer writes head. N = 8, holding up to N − 1 = 7. size = head − tail; empty is head == tail, full is size == N − 1 — the one free slot keeps the two apart. Slot = index % N.
Main ↔ ISR ring: one writer per index
The consumer writes tail, the producer writes head. N = 8, holding up to N − 1 = 7. size = head − tail; empty is head == tail, full is size == N − 1 — the one free slot keeps the two apart. Slot = index % N.
  • Producer: check not full, write the slot, then publish head + 1 with a release store.
  • Consumer: acquire-load head; if not empty, read the slot, then release it with tail + 1.

Indices count up forever; unsigned subtraction makes head − tail right even after they wrap. Keeping one slot empty means "full" and "empty" never look alike — and the producer can never reach the slot the consumer is still reading. No masking anywhere: it works whichever side is the ISR (a UART transmit queue has main as producer; a receive queue has the ISR as producer).

5. Choosing

shared data main side ISR side
one word, written by one side plain volatile or relaxed atomic same
one word, read-modify-write by both atomic RMW (ldrex/strex) or a critical section same
multi-word value critical section (or hi–lo–hi retry for counters) none needed at one priority
stream of items SPSC ring buffer, lock-free lock-free

The build task asks for all three tools: a nesting guard, a 64-bit counter that never tears or loses an add, and a lock-free queue whose push the grader checks contains no cpsid.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Race conditions between main code and ISRs, and the classic fix: briefly disabling interrupts.

Building an interrupt-safe ring buffer and reasoning about which side may be interrupted where.

Shared state between an interrupt handler and the main loop on a real machine.