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
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 meaningvoid 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.
staticstruct{uint32_t seq;int16_t x, y, z;} imu_latest;// multi-wordstaticinlineuint32_t irq_save(void){uint32_t primask = __get_PRIMASK();// remember the caller's mask state __disable_irq();return primask;}staticinlinevoid 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).
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 comparedstaticuint16_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 sideunsigned h = atomic_load_explicit(&head, memory_order_relaxed);unsigned t = atomic_load_explicit(&tail, memory_order_acquire);if(h - t == CAP)returnfalse;// full: the counted, testable failure ring[h &(CAP -1u)]= v;// payload first… atomic_store_explicit(&head, h +1u, memory_order_release);// …then publishreturntrue;}bool ring_pop(uint16_t*out){// main sideunsigned t = atomic_load_explicit(&tail, memory_order_relaxed);unsigned h = atomic_load_explicit(&head, memory_order_acquire);if(h == t)returnfalse;// empty*out = ring[t &(CAP -1u)]; atomic_store_explicit(&tail, t +1u, memory_order_release);returntrue;}
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:
usecore::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
staticmut 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:
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.
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-eabihfAtomicU64 does not exist — core::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.
useheapless::spsc::{Queue, Producer, Consumer};usestatic_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 placeloop{whileletSome(sample) = consumer.dequeue() { process(sample);}cortex_m::asm::wfi();}}#[exception]fn SysTick() {critical_section::with(|cs|{ifletSome(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:
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.
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