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
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.- Lost update — a read-modify-write (
x = x + 1,x |= bit) split by the interrupt.volatiledoesn't help: it guarantees theldrandstrhappen, not that nothing happens between them. - 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.
- Publish-before-ready — main sets "item available" before the item is written; the ISR consumes garbage.
#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:
#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);
}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,
BASEPRImasks only interrupts at or below a priority, so urgent handlers that don't share the data still run. PRIMASK is the simple, blunt tool;BASEPRIthe 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:
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 lrException 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.
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:
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 + 1with a release store. - Consumer: acquire-load
head; if not empty, read the slot, then release it withtail + 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.