Module 9 Lessons — Interrupts and Shared State

Back to the Course 2 syllabus · Practice: Module 9 exercises

This page is the module’s teaching text. It covers the one problem every embedded program has before it has an operating system: an interrupt handler and the main loop touching the same memory. The C half states the rules — what a data race is, what the C11 memory model promises, what volatile, critical sections, atomics, and single-producer rings each protect against and each fail at. The Rust half restates the same rules as types: why a static must be Sync, why static mut is banned, how Mutex<RefCell<T>> in a critical section and core::sync::atomic express the C patterns in a form the compiler checks, and where RTIC’s resources make the checking static. The Linux tier appears at the end, because a signal handler is the same problem wearing a different name. As with every lessons page in this course, it is AI-drafted teaching text reviewed by me; see the syllabus’s note on AI use. MC 33–35 and 45, Rust Book Ch 16, and the Embedded Rust Book’s Concurrency chapter remain available as optional deep-dives; nothing below requires them.


1 · The problem, stated once

An interrupt handler is not a thread. It has no stack of its own on a Cortex-M (it borrows the interrupted context’s, after the hardware pushes eight registers), it cannot block, and it runs to completion. But it creates exactly the hazard threads create: asynchronous interleaving of two instruction streams over one memory. Between any two instructions of main, the handler may run in full.

The failures come in four shapes, and every pattern in this module exists to prevent one of them:

Failure Mechanism Smallest example
Lost update Read-modify-write is three instructions; the ISR’s write lands between the read and the write count++ in main, count = 0 in the ISR — the reset vanishes
Torn access A value wider than one bus word is read or written in pieces A 64-bit timestamp read as two words; the ISR updates between them
Lost event A level flag can only say “at least once” Three UART bytes arrive before main polls a bool — two are gone
Broken protocol The flag is visible before the payload it announces ready = true reordered ahead of sample = adc — reader sees the flag and stale data

A single aligned word load or store on a Cortex-M is indivisible, which is why the first three shapes are about sequences of accesses, and the fourth is about ordering between accesses. Nothing else is guaranteed by the hardware; everything else is guaranteed, or not, by the language.

2 · The C11 memory model, in one page

Before C11, “what happens when an ISR and main share a variable” was compiler folklore. C11 made it precise, and the vocabulary transfers directly even though a handler is not a thread.

Data race. Two accesses to the same object, at least one a write, at least one non-atomic, not ordered by a happens-before relationship. A data race is undefined behavior — not “a wrong value sometimes” but a license for the optimizer to assume it never occurs, and to hoist, fold, or delete the accesses accordingly. This is the as-if rule of Module 7 applied to concurrency: the compiler may cache a plain variable in a register across the entire loop because nothing in the loop, as far as the abstract machine can see, changes it.

Happens-before is built from two things: program order within one stream, and synchronizes-with edges between streams. An atomic store with memory_order_release synchronizes with an atomic load of the same object with memory_order_acquire that reads the stored value. Everything before the release in the writer then happens-before everything after the acquire in the reader — the payload is visible when the flag is.

Ordering Meaning When it is enough
memory_order_relaxed The operation is indivisible; no ordering with anything else A lone counter; a flag with no payload
memory_order_release (store) / memory_order_acquire (load) Publishes everything before the store; the acquiring load sees it Flag + payload; ring-buffer indices
memory_order_seq_cst Acquire and release, plus one global order of all such operations The default; rarely needed on a single core

On a single-core Cortex-M there is no cache-coherence question and no second core to reorder against; the compiler is the only reorderer, so relaxed is often correct and release/acquire costs nothing at run time beyond a compiler barrier. On the Jetson’s multi-core A78, the same source needs the same orderings for a different reason — the hardware reorders too — which is why writing the correct ordering on the microcontroller is not pedantry: it is the version that survives the move to Linux (Module 11).

2.1 volatile is not part of this model

volatile says every access is an observable side effect — it must be emitted, in source order relative to other volatile accesses. It is the right tool for a memory-mapped register (Module 8) and for nothing in this module’s table:

Property volatile provides it? An atomic provides it?
Access actually emitted Yes Yes
Indivisible read-modify-write (x++) No — still load, add, store Yes (atomic_fetch_add)
Ordering vs. non-volatile accesses No Yes, with acquire/release
Hardware barrier No — emits no DMB When the ordering requires one
Defined behavior under a race No — a race on a volatile is still UB Yes — atomics never race

A volatile bool flag with no payload, read and written as single words on a single core, works in practice and is UB in theory; atomic_bool with relaxed ordering compiles to the identical instructions and is correct on paper. There is no reason to prefer the first form in new code.

3 · The C toolkit, pattern by pattern

Each pattern is presented with what it costs and the failure it does not prevent. Exercise 9.1 compiles the first three and reads the difference in the disassembly.

3.1 The level flag

#include <stdatomic.h>
static atomic_bool sample_ready;          // one word, one bit of meaning

void ADC1_IRQHandler(void) {
    atomic_store_explicit(&sample_ready, true, memory_order_relaxed);
}

void main_loop(void) {
    if (atomic_exchange_explicit(&sample_ready, false, memory_order_relaxed)) {
        /* handle one event */
    }
}

Correct only when “has it happened at least once since I last looked” is the whole question. Prevents nothing about payloads and loses events. The atomic_exchange in the consumer is the detail that makes the clear race-free — a separate read-then-clear would lose an event that arrives between them.

3.2 The critical section

The general-purpose tool for multi-word state: mask interrupts, touch the state, unmask.

static struct { uint32_t seq; int16_t x, y, z; } imu_latest;   // multi-word

static inline uint32_t irq_save(void) {
    uint32_t primask = __get_PRIMASK();      // remember the caller's mask state
    __disable_irq();
    return primask;
}
static inline void irq_restore(uint32_t primask) { __set_PRIMASK(primask); }

void read_imu(int16_t out[3]) {
    uint32_t s = irq_save();
    out[0] = imu_latest.x; out[1] = imu_latest.y; out[2] = imu_latest.z;
    irq_restore(s);                          // RESTORE, never blindly enable
}

Two rules and one cost. Restore, don’t enable: the caller may already be running with interrupts masked — inside another critical section, inside a fault handler — and a blind __enable_irq() on exit breaks its section. Keep it short and non-blocking: the section adds its length to the latency of every interrupt on the core, including the ones that have nothing to do with this state (Course 3 Lab 2.2 measures exactly this). The cost is that latency, paid by everyone.

PRIMASK vs. BASEPRI. PRIMASK = 1 masks every configurable interrupt. BASEPRI = n masks only interrupts of priority n and lower-urgency (numerically ≥ n), leaving the most urgent ones free to run — the mechanism an RTOS uses so that its kernel critical sections never delay a motor-control interrupt that the kernel does not touch. The obligation is symmetrical: an interrupt that runs above BASEPRI must not touch the state the section protects, or the section protects nothing. That bookkeeping — which priorities share which state — is what RTIC automates (§6).

3.3 The atomic counter

static atomic_uint edge_count;

void EXTI0_IRQHandler(void) {
    atomic_fetch_add_explicit(&edge_count, 1u, memory_order_relaxed);
}

unsigned drain_edges(void) {
    return atomic_exchange_explicit(&edge_count, 0u, memory_order_relaxed);
}

No masking, no lost events, no latency added to other interrupts. On the Cortex-M4 the increment compiles to an LDREX/STREX retry loop: load-exclusive, add, store-exclusive, and if an exception occurred in between the store fails and the loop retries. That is the hardware’s answer to the lost update — the interrupted operation redoes itself.

The scope limit is the width of the exclusive monitor: 1-, 2-, and 4-byte objects are lock-free; ARMv7-M has no 64-bit exclusive pair, so an _Atomic uint64_t lowers to a library call that takes a lock — which, inside an ISR, is a deadlock waiting to happen. Never assume: atomic_is_lock_free(&obj) at run time, ATOMIC_LLONG_LOCK_FREE at compile time (its value 1 means “sometimes”, which for firmware means “no”), and the disassembly as the final word. Exercise 9.4 builds that table by prediction on both machines.

3.4 The single-producer / single-consumer ring

The workhorse for streams — UART bytes, ADC samples, log records — and the reason Course 3’s pipelines need no critical sections at all:

#define CAP 64u                              // power of two: indices are masked, never compared
static uint16_t ring[CAP];
static atomic_uint head;                     // written only by the producer (ISR)
static atomic_uint tail;                     // written only by the consumer (main)

bool ring_push(uint16_t v) {                 // ISR side
    unsigned h = atomic_load_explicit(&head, memory_order_relaxed);
    unsigned t = atomic_load_explicit(&tail, memory_order_acquire);
    if (h - t == CAP) return false;          // full: the counted, testable failure
    ring[h & (CAP - 1u)] = v;                // payload first…
    atomic_store_explicit(&head, h + 1u, memory_order_release);   // …then publish
    return true;
}

bool ring_pop(uint16_t *out) {               // main side
    unsigned t = atomic_load_explicit(&tail, memory_order_relaxed);
    unsigned h = atomic_load_explicit(&head, memory_order_acquire);
    if (h == t) return false;                // empty
    *out = ring[t & (CAP - 1u)];
    atomic_store_explicit(&tail, t + 1u, memory_order_release);
    return true;
}

The invariants that make it work: each index is written by exactly one side; the indices are free-running unsigned counters (so h - t is the occupancy even across wraparound — defined unsigned arithmetic, Module 7); the release on head publishes the payload, the acquire on head in pop sees it. Two sides, one direction, no masking. The moment there are two producers, this is a different data structure, and that is the line at which an RTOS queue (Module 10) earns its cost.

3.5 The double buffer

For a DMA engine or a block-processing ISR, the unit of sharing is a whole buffer, and the protocol is ownership: at any moment each buffer belongs to exactly one party, and ownership changes hands only at the interrupt.

sequenceDiagram
    participant CPU as main
    participant ISR as half/complete IRQ
    participant DMA as DMA engine
    Note over DMA: fills buffer A
    CPU->>CPU: process buffer B (owned by CPU)
    DMA-->>ISR: half-transfer: A is full
    ISR->>CPU: publish "A ready" (release)
    Note over DMA: fills buffer B
    CPU->>CPU: process A (acquire, then own it)
    Note over CPU,DMA: the CPU must finish A before the DMA wraps back to it

sequenceDiagram
    participant CPU as main
    participant ISR as half/complete IRQ
    participant DMA as DMA engine
    Note over DMA: fills buffer A
    CPU->>CPU: process buffer B (owned by CPU)
    DMA-->>ISR: half-transfer: A is full
    ISR->>CPU: publish "A ready" (release)
    Note over DMA: fills buffer B
    CPU->>CPU: process A (acquire, then own it)
    Note over CPU,DMA: the CPU must finish A before the DMA wraps back to it

The deadline is structural: the consumer has one buffer-time to finish. Nothing in the language enforces the ownership — in C it is a comment and a discipline; the compiler cannot see the DMA’s writes at all, which is why the buffer is not merely volatile but not touched while the DMA owns it, and why the handoff needs an ordering point (the interrupt itself, or a DMB). Course 3 Lab 5.3 is this diagram running continuously; Exercise 9.5 writes it down in both languages.

3.6 Two fences, two jobs

Fence Constrains Cortex-M reality
atomic_signal_fence(order) Compiler reordering of memory accesses relative to an asynchronous handler on the same core Emits no instruction and is still load-bearing
atomic_thread_fence(order) Participates in the atomic model between threads (or cores); may emit a hardware barrier Emits DMB for acquire/release/seq_cst

A single-core ISR protocol needs only the first: the hardware does not reorder against itself. The CMSIS intrinsics __DMB()/__DSB()/__ISB() are architectural barriers for peripherals and DMA, not language fences — neither substitutes for the other. Exercise 9.6 confirms the first compiles to nothing and the second to a DMB.

TipFirmware rule

Match the pattern to the shape of the shared state: one bit → atomic flag; one word that counts → atomic counter; several words that must be consistent → critical section, restored not enabled; a stream → SPSC ring; a block → ownership handoff at the interrupt. volatile is for registers.

4 · The same rules as types

Rust does not add a mechanism C lacks. Every pattern above exists unchanged — critical sections, atomics, rings, ownership. What changes is who checks: the compiler refuses the programs that would race, and the programs it accepts are the C patterns with their invariants written into the types.

4.1 Send, Sync, and why a static is the whole question

Rust Book Ch 16 defines the two marker traits: a type is Send if ownership of a value can be transferred to another thread, and Sync if a shared reference &T can be held from several threads at once — equivalently, T: Sync iff &T: Send. The compiler derives both automatically for any type built from Send/Sync parts, and withholds them from the types that carry interior mutability without synchronization (Cell, RefCell, raw pointers, Rc).

In firmware there are no threads, but the rule applies verbatim because of one decision the language makes: an interrupt handler is treated as another thread. Everything a handler and main both reach must be a static, and a static item must be Sync. That single constraint, enforced at compile time, is the whole C11 data-race rule:

use core::cell::Cell;
static COUNT: Cell<u32> = Cell::new(0);   // error[E0277]: `Cell<u32>` cannot be shared
                                          // between threads safely (`Sync` is not implemented)

The compiler is saying: this object can be mutated through a shared reference, and you have put it where two contexts can reach it, and nothing synchronizes them. Exactly the C situation, refused before it links.

4.2 static mut: the escape hatch, and why it is banned

static mut COUNTER: u32 = 0;

#[interrupt]
fn TIM2() { unsafe { COUNTER = 0; } }

fn main_loop() { unsafe { COUNTER += 1; } }     // compiles — and is a data race

static mut opts out: every access is unsafe, and the obligation of “no two contexts access this concurrently” is entirely yours, exactly as in C. The 2024 edition additionally denies taking references to a static mut (static_mut_refs), because a &mut to it is instantly aliasable — which leaves only direct reads and writes, and those are the lost-update example of §1. This course’s mcu and qemu crates deny static mut outright by lint; the three replacements below cover every case it was used for.

4.3 The critical section, as a token

critical-section 1.2 is the portable form of __disable_irq()/restore. critical_section::with disables interrupts, runs a closure, and restores — the blind-re-enable bug of §3.2 is impossible because the restore is in the library’s Drop, not in your code path. The closure receives a CriticalSection token, and the token is the proof:

use core::cell::RefCell;
use critical_section::Mutex;

struct Imu { seq: u32, x: i16, y: i16, z: i16 }

static IMU: Mutex<RefCell<Imu>> = Mutex::new(RefCell::new(Imu { seq: 0, x: 0, y: 0, z: 0 }));

#[interrupt]
fn SPI1() {
    critical_section::with(|cs| {
        let mut imu = IMU.borrow_ref_mut(cs);     // exclusive access, proven by `cs`
        imu.x = read_x(); imu.y = read_y(); imu.z = read_z();
        imu.seq = imu.seq.wrapping_add(1);
    });
}

fn snapshot() -> [i16; 3] {
    critical_section::with(|cs| {
        let imu = IMU.borrow_ref(cs);
        [imu.x, imu.y, imu.z]
    })
}

Read it against the C of §3.2. Mutex<T> is Sync for any T: Send — it is the wrapper that makes a RefCell (not Sync) legal in a static — and it hands out access only through .borrow(cs), which requires the token, which exists only inside with. The RefCell then enforces at run time that the borrow is unique (a nested borrow_mut inside the same section panics rather than aliases). The whole C discipline — “touch this only with interrupts masked” — has become a function signature.

On a Cortex-M the implementation is the critical-section-single-core feature of the cortex-m crate: PRIMASK save, disable, restore. The older cortex_m::interrupt::free and cortex_m::interrupt::Mutex are the same thing under an earlier name; new code uses the critical_section crate directly so that the same driver builds for a target with a different critical-section implementation — an RTOS, or Linux, where the “critical section” is a real lock.

RefCell is for state that is not Copy. For a plain word, Mutex<Cell<u32>> with .get()/.set() avoids the borrow flag; and for a plain word that only needs to be indivisible, the next section removes the critical section entirely.

4.4 Atomics

use core::sync::atomic::{AtomicU32, AtomicBool, Ordering};

static EDGES: AtomicU32 = AtomicU32::new(0);
static READY: AtomicBool = AtomicBool::new(false);

#[interrupt]
fn EXTI0() { EDGES.fetch_add(1, Ordering::Relaxed); }

fn drain_edges() -> u32 { EDGES.swap(0, Ordering::Relaxed) }
fn take_ready() -> bool { READY.swap(false, Ordering::Relaxed) }

core::sync::atomic is the <stdatomic.h> of §3.3 with the same orderings (Relaxed, Release, Acquire, AcqRel, SeqCst) and the same instruction selection — fetch_add on thumbv7em is the LDREX/STREX loop. The atomic types are Sync, so they live in a plain static with no unsafe anywhere.

The width limit is expressed in the type system instead of at run time: on thumbv7em-none-eabihf AtomicU64 does not existcore::sync::atomic simply does not define it, and code that names it fails to compile. The cfg predicates target_has_atomic = "64" / "32" / "ptr" let a crate say what it needs; on thumbv6m (Cortex-M0) only target_has_atomic_load_store holds and fetch_add is absent too, which is the portable-driver problem the portable-atomic crate exists to paper over. Exercise 9.4’s Rust column is these predicates.

4.5 The SPSC ring, with the halves in different contexts

heapless::spsc::Queue<T, N> is §3.4 as a library type, with the one-producer/one-consumer invariant encoded in two types: split() returns a Producer and a Consumer, each usable from exactly one place.

use heapless::spsc::{Queue, Producer, Consumer};
use static_cell::StaticCell;

static QUEUE: StaticCell<Queue<u16, 64>> = StaticCell::new();
static TX: Mutex<RefCell<Option<Producer<'static, u16, 64>>>> = Mutex::new(RefCell::new(None));

#[entry]
fn main() -> ! {
    let (producer, mut consumer) = QUEUE.init(Queue::new()).split();
    critical_section::with(|cs| TX.borrow_ref_mut(cs).replace(producer));
    enable_systick();                       // only AFTER the producer is in place
    loop {
        while let Some(sample) = consumer.dequeue() { process(sample); }
        cortex_m::asm::wfi();
    }
}

#[exception]
fn SysTick() {
    critical_section::with(|cs| {
        if let Some(p) = TX.borrow_ref_mut(cs).as_mut() {
            let _ = p.enqueue(read_sample());   // Err(v) when full: the counted failure
        }
    });
}

Three things to notice. The queue is allocated once through StaticCell — a static that is initialized at run time exactly once and hands out a &'static mut — so both halves have the 'static lifetime an interrupt handler needs. The Producer moves into the handler’s static through a critical section, and the Consumer stays in main as a local: the type system now knows there is one of each. And the critical section in SysTick exists only to reach the producer, not to protect the queue — enqueue and dequeue are lock-free by construction, exactly as the C indices were. (heapless::spsc stores at most N - 1 elements — the classic one-slot-empty full/empty distinction; size the capacity accordingly.)

4.6 Ownership as the double-buffer protocol

The ownership handoff of §3.5 is the one pattern Rust checks statically rather than at run time, because it is what ownership means. A DMA driver that takes a buffer takes it by value or as &'static mut [u16; N]; while the transfer object lives, the buffer is unreachable from main; when the transfer completes, the driver hands the buffer back. In Embassy’s STM32 HAL, a DMA read is an object that owns its buffer and yields it on completion; in a hand-rolled driver, the same holds if the API is designed so that the only way to get the buffer back is from the completion. The “must not touch it while the DMA owns it” comment of the C version becomes a compile error if you try. Exercise 9.5 designs that API without the DMA hardware, so that the ownership argument stands on its own.

4.7 What transfers, what doesn’t

Concept C Rust What changed
Data race UB, undetected Refused: a shared object must be Sync Checked at compile time
Critical section __get_PRIMASK/__disable_irq/__set_PRIMASK by hand critical_section::with(\|cs\| …); restore is automatic Blind re-enable impossible; the token proves the context
Multi-word state static struct + discipline Mutex<RefCell<T>>; .borrow_ref_mut(cs) Unique access enforced (run-time flag)
Word counter/flag atomic_uint/atomic_bool AtomicU32/AtomicBool Identical code; no unsafe
64-bit atomic on M4 Library call — silent Type absent — compile error Failure moved to the build
SPSC ring Two indices + acquire/release by hand heapless::spsc; Producer/Consumer halves Single-producer invariant is a type
Buffer handoff Comment Ownership move Checked at compile time
Priority bookkeeping (which ISRs share what) Comment Still a comment — until RTIC §6

The last row is honest: critical_section::with masks all interrupts, like PRIMASK. A finer scheme — BASEPRI, only the priorities that share the state — is correct only if the sharing set is known, and plain Rust does not know it any more than C does.

5 · NVIC priorities: “can this ISR preempt that one?”

Every pattern above assumed two contexts: main and one handler. With several handlers the question becomes which can interrupt which, and the answer is the NVIC’s priority table. On the Cortex-M4, a lower numeric priority is more urgent; an interrupt preempts a running handler only if its group priority is numerically lower; equal priorities never preempt each other, and pending interrupts of equal priority run in vector-number order. The STM32L476 implements four priority bits, and CubeMX’s default NVIC_PRIORITYGROUP_4 makes all four preemption bits.

The consequence for shared state: two handlers at the same priority cannot interleave, so state shared only between them needs no protection at all; a handler and main always can, so their shared state always does; and a handler at priority 2 sharing state with one at priority 0 needs a critical section that masks at least priority 0 — BASEPRI = 0 masks nothing, so on this core that means PRIMASK. Writing this table down for a firmware — every shared object, the set of contexts that touch it, the highest priority among them — is the design step that C leaves to the engineer, and that RTIC computes.

6 · RTIC’s resources: the principled answer (a preview of Module 10)

RTIC 2 takes the table of §5 as input and generates the critical sections. A #[shared] resource declares its state once; each task declares which resources it uses; RTIC’s Stack Resource Policy assigns every resource a ceiling — the highest priority of any task that uses it — and lock raises BASEPRI to exactly that ceiling for the duration of the closure:

#[rtic::app(device = stm32l4::stm32l4x6, peripherals = true)]
mod app {
    #[shared] struct Shared { imu: Imu }
    #[local]  struct Local {}

    #[task(binds = SPI1, priority = 3, shared = [imu])]
    fn spi1(mut cx: spi1::Context) {
        cx.shared.imu.lock(|imu| { imu.x = read_x(); imu.seq = imu.seq.wrapping_add(1); });
    }

    #[task(binds = TIM2, priority = 1, shared = [imu])]
    fn tim2(mut cx: tim2::Context) {
        let snap = cx.shared.imu.lock(|imu| [imu.x, imu.y, imu.z]);
        consume(snap);
    }
}

What this buys over §4.3: the lock in the priority-3 task compiles to nothing — it already runs at the ceiling, so no other user of imu can preempt it; the priority-1 task’s lock raises BASEPRI to 3, not to “everything”, so an unrelated priority-4 interrupt still runs on time; a task that does not name imu in its shared list cannot touch it — that is a compile error, not a comment; and the analysis proves the result deadlock-free. It is the C design table of §5, enforced. The trade-off — the table must be static, the tasks must be declared up front — is what Module 10 weighs against FreeRTOS and Embassy.

7 · The Linux tier: the same problem, called a signal

On the Jetson, an asynchronous signal handler (SIGINT, SIGALRM, SIGIO) is delivered on top of whatever the thread was doing — the interrupt model exactly, minus the NVIC. POSIX’s rules are the ones this module has been building:

  • The only object type a handler may touch without a race is volatile sig_atomic_t, or a C11 lock-free atomic; everything else is the §1 table.
  • The handler may call only async-signal-safe functions — write, _exit, sem_post — not printf, not malloc, not anything that takes a lock the interrupted code might hold (the deadlock of §4.3’s RefCell panic, now real).
  • The mask is sigprocmask/pthread_sigmask — a per-thread PRIMASK, with the same save-and-restore obligation.
static volatile sig_atomic_t stop_requested;
static void on_sigint(int sig) { (void)sig; stop_requested = 1; }
/* main: sigaction(SIGINT, &(struct sigaction){ .sa_handler = on_sigint }, NULL);
         while (!stop_requested) { … } */

In Rust, nix::sys::signal::sigaction takes an extern "C" fn handler with the same restrictions, and the idiomatic shared state is again a static AtomicBool. The better answer on Linux is to stop taking signals asynchronously at all: signalfd turns a signal into a file descriptor read from the event loop, which Module 11 builds. The pattern table is the same; the OS just gives you a way to demote the interrupt to a message.

8 · The decision table

Shape of the shared state C Rust (bare metal) Rust (RTIC)
“It happened” (one bit) atomic_bool, atomic_exchange to consume static AtomicBool, swap same, or a software task spawned from the ISR
Event count (one word) atomic_uint, relaxed fetch_add / exchange(0) static AtomicU32 same
Multi-word snapshot PRIMASK save/disable/restore Mutex<RefCell<T>> + critical_section::with #[shared] + lock, ceiling-priority masking
A Copy word that needs no RMW volatile-free atomic load/store Mutex<Cell<T>> or atomic #[shared]
Stream, one producer, one consumer Two atomic indices, release/acquire, power-of-two mask heapless::spsc::Queue, halves split across contexts same, or a heapless channel with a software task
Stream, several producers RTOS queue (Module 10) RTOS/Embassy channel Multiple tasks spawning one consumer
Block owned by DMA Ownership comment + DMB at handoff &'static mut moved into the transfer same
A peripheral used by ISR and main Global handle + discipline Mutex<RefCell<Option<P>>> #[local] (one owner) or #[shared]
Only same-priority handlers share it Nothing needed — document why Still Sync-checked: use an atomic or Mutex anyway RTIC proves it and emits no lock
64-bit value Critical section (never _Atomic) Mutex<Cell<u64>>AtomicU64 absent #[shared]

9 · Lesson → exercise map

Section Exercise it feeds
§1 failure shapes, §2 memory model 9.1 (three ways in C), 9.7 (Linux signal)
§3.1–3.3 flag, critical section, atomic 9.1
§3.3, §4.4 lock-free widths 9.4 (the lock-free table)
§3.4, §4.5 SPSC ring 9.3 (ring across SysTick in QEMU)
§3.5, §4.6 double buffer 9.5 (DMA hand-off in both languages)
§3.6 fences 9.6 (fence forensics)
§4.1–4.4 Sync, static mut, Mutex<RefCell> 9.2 (the versions that fail to compile)
§5–6 priorities, RTIC preview 9.3 (the preemption variant), Module 10
§7 signals 9.7