This page is the module’s teaching text. Module 9 shared state between an interrupt and a main loop by hand; this module hands that job to a runtime. Three of them run on the same STM32: FreeRTOS, the C kernel Course 3’s Lab 7.2 uses under CMSIS-RTOS2; RTIC, a Rust framework that turns the interrupt controller itself into the scheduler; and Embassy, a Rust async executor that runs cooperative tasks without a stack per task. The running example throughout is Lab 7.2’s three-stage pipeline — acquire → process → output — because it exercises everything a runtime is for: priorities, blocking, hand-off from an ISR, a shared resource, and a deadline. 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. The FreeRTOS book, the RTIC book, the Embassy documentation, and Rust Book Ch 17 remain available as optional deep-dives; nothing below requires them.
1 · What a runtime buys, and what it costs
A bare-metal firmware is a super-loop: interrupts capture events, a while (1) polls flags and does the work in some order you chose by hand. It is the fastest thing that can run and the hardest thing to reason about once there are three activities with different deadlines, because the loop’s order is the priority scheme and every long job delays every short one.
A runtime replaces the loop with a scheduling model — a rule that decides which activity runs next, applied by code you did not write. The three models in this module differ in the rule:
Runtime
Unit of work
Who decides what runs
Preemption
Per-unit memory
FreeRTOS
Task: a function with its own stack, running “forever”
The kernel, at every tick and every blocking call — fixed-priority preemptive
Any task by any higher-priority task, at any instruction
A stack per task + a TCB
RTIC
Task: a function bound to an interrupt, or spawned onto a dispatcher interrupt
The NVIC — task priority is interrupt priority
Any task by any higher-priority task, at any instruction; same-priority tasks never preempt each other
One shared stack; a task’s frame exists only while it runs
Embassy
Task: an async fn — a state machine polled to completion
The executor, at every .await — cooperative within a priority level; preemptive between levels via interrupt executors
Only at .await points within a level
The task’s state machine, statically allocated; one stack per executor
The costs are the flip side of the same rows. FreeRTOS pays RAM for stacks and time for context switches (the whole register file, plus the FPU’s if the task used it). RTIC pays nothing in RAM beyond the interrupt frames but bounds the model to what a priority-ceiling analysis can express. Embassy pays nothing in stacks and nothing in context switches but requires every long computation to yield — a busy loop that never .awaits starves its whole priority level.
Every one of these exists on the site’s hardware, and the Course 3 firmware setup already has FreeRTOS wired into CubeMX (Middleware → FREERTOS → CMSIS_V2, with the HAL timebase moved off SysTick to a spare basic timer — see the FreeRTOS bullet of Course 3’s setup essentials and Lab 7.2’s own table, which uses TIM6). The Rust frameworks need no CubeMX at all: they are crates.
2 · FreeRTOS in C
2.1 Tasks, the tick, and the scheduler
A task is a C function that never returns, given a priority and a stack:
staticvoid acquire_task(void*arg){(void)arg; TickType_t last = xTaskGetTickCount();for(;;){/* one block of samples per period */ vTaskDelayUntil(&last, pdMS_TO_TICKS(1));/* ... read the ADC buffer, push to the queue ... */}}/* Priorities: HIGHER NUMBER = HIGHER PRIORITY. */xTaskCreate(acquire_task,"acq",256/* words */, NULL,3, NULL);
The scheduler’s rule is simple and worth stating exactly: at every scheduling point, run the highest-priority task that is Ready. Scheduling points are the tick interrupt (configTICK_RATE_HZ, typically 1 kHz), any kernel call that blocks or unblocks a task, and portYIELD_FROM_ISR at the end of an interrupt. Equal-priority Ready tasks time-slice round-robin if configUSE_TIME_SLICING is on. The idle task runs at tskIDLE_PRIORITY (zero) when nothing else can; it is where the idle hook and, on the L476, a WFI sleep belong.
Two delay functions look alike and are not. vTaskDelay(n) blocks for n ticks from now, so a loop of “work, then delay” drifts by the work’s duration every iteration. vTaskDelayUntil(&last, period) blocks until an absolute tick, so the loop runs at a fixed cadence regardless of how long the work took — the same distinction as clock_nanosleep with TIMER_ABSTIME on Linux (Module 11), and the only correct choice for a sampling loop.
The stack size is in words, not bytes, and a task that overflows it corrupts whatever the linker placed next. configCHECK_FOR_STACK_OVERFLOW = 2 installs a canary check at every context switch and calls vApplicationStackOverflowHook(task, name) — during development, that hook halts with the name visible in the debugger. uxTaskGetStackHighWaterMark(handle) reports the minimum free stack the task ever had, which is the number a stack-sizing table (Exercise 10.5) is built from.
2.2 Queues and the ISR → task hand-off
A queue copies fixed-size items between tasks and blocks the receiver until one arrives (or a timeout expires). It moves data and enforces backpressure — a full queue is the visible sign a consumer cannot keep up:
static QueueHandle_t q_blocks;/* items: struct block { int16_t s[64]; } *//* in the DMA half/complete callback — interrupt context */void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h){ BaseType_t woken = pdFALSE;struct block b;/* ... fill from the DMA buffer ... */ xQueueSendFromISR(q_blocks,&b,&woken); portYIELD_FROM_ISR(woken);/* switch NOW if the receiver outranks whoever was running */}/* in the process task */struct block b;if(xQueueReceive(q_blocks,&b, portMAX_DELAY)== pdTRUE){/* ... */}
Every kernel call has a FromISR twin, and the rule is absolute: inside an interrupt handler, only the FromISR forms may be called, and only from interrupts whose NVIC priority is numerically at or belowconfigMAX_SYSCALL_INTERRUPT_PRIORITY (CubeMX exposes this as configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY; on the Cortex-M4 a lower number is a higher urgency). Interrupts above that threshold never touch the kernel — they are for the handful of things that cannot wait even for a critical section. configASSERT catches the violation on a debug build; on a release build it is silent memory corruption.
The priority arithmetic behind that rule trips up every first FreeRTOS project, because the kernel and the NVIC count in opposite directions:
Scale
Direction
Range on the STM32L476
Example
FreeRTOS task priority
higher number = more important
0 (idle) … configMAX_PRIORITIES - 1
acquire 3, process 2, output 1
NVIC interrupt priority
lower number = more urgent
4 priority bits → 0 (most urgent) … 15
a “kernel-aware” ADC interrupt at 6, a never-touch-the-kernel motor-fault interrupt at 2
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY
the boundary: interrupts numerically below it (more urgent) may not call the kernel
CubeMX default 5
6…15 may call FromISR APIs; 0…4 may not
The kernel’s critical sections work by raising BASEPRI to that boundary, which masks only the interrupts at or below it in urgency — the ones that could call the kernel — and leaves the more-urgent ones running. That is why an interrupt above the boundary can never safely post to a queue: the kernel’s own data structures may be mid-update when it fires.
The pattern this produces — an ISR that does the minimum and posts, a task that does the work — is deferred interrupt processing, and it is the whole reason the acquire stage is a task rather than a longer callback. When the ISR only needs to wake a task with no data, a task notification (vTaskNotifyGiveFromISR / ulTaskNotifyTake) does it with less overhead than a semaphore, because the notification lives in the TCB.
2.3 Semaphores, mutexes, and priority inversion
FreeRTOS has three signalling objects that look similar and behave differently:
Object
Created with
Use it for
Priority inheritance
Binary semaphore
xSemaphoreCreateBinary
ISR → task signalling (“something happened”)
No
Counting semaphore
xSemaphoreCreateCounting
Counting events or pool slots
No
Mutex
xSemaphoreCreateMutex
Guarding a shared resource between tasks
Yes
The difference matters because of priority inversion. In Lab 7.2’s Part E, Output (low priority) holds a lock on a shared UART, gets preempted by a medium-priority “hog” task, and Acquire (high priority) then blocks on the lock — waiting, in effect, on the hog. With a binary semaphore that wait is unbounded. With a mutex, the kernel temporarily raises Output to Acquire’s priority while it holds the lock (priority inheritance), so it finishes and releases, and Acquire’s blocking time is bounded by the length of the critical section. A mutex must never be taken from an ISR (there is no FromISR form) and must be given by the task that took it.
The deadline arithmetic Lab 7.2 states — a stage meets its deadline when its work \(C\) plus its worst blocking \(B\) plus the interference \(I\) from higher-priority tasks fits in the period — is the reason all three numbers have to be bounded. A mutex bounds \(B\); the priority assignment bounds \(I\); only \(C\) is the code’s own.
The scenario, drawn once so the three runtimes can be compared against the same picture:
sequenceDiagram participant O as Output (prio 1) participant H as Hog (prio 2) participant A as Acquire (prio 3) O->>O: take lock (UART) Note over H: becomes Ready → preempts Output H->>H: burns CPU Note over A: timer fires → preempts Hog A->>O: take lock → BLOCKED (Output holds it) Note over H: Hog resumes: Acquire waits on Hog Note over O,A: mutex: Output inherits prio 3, finishes, releases → Acquire runs Note over O,A: binary semaphore: Output never runs while Hog is Ready → unbounded
sequenceDiagram
participant O as Output (prio 1)
participant H as Hog (prio 2)
participant A as Acquire (prio 3)
O->>O: take lock (UART)
Note over H: becomes Ready → preempts Output
H->>H: burns CPU
Note over A: timer fires → preempts Hog
A->>O: take lock → BLOCKED (Output holds it)
Note over H: Hog resumes: Acquire waits on Hog
Note over O,A: mutex: Output inherits prio 3, finishes, releases → Acquire runs
Note over O,A: binary semaphore: Output never runs while Hog is Ready → unbounded
A mutex also has a second property a semaphore lacks: ownership. Only the task that took it may give it, which is what makes inheritance possible — the kernel knows whom to boost — and which is why a mutex cannot be used for ISR → task signalling. The two objects are not interchangeable in either direction.
2.4 Software timers
A software timer (xTimerCreate, xTimerStart) runs a callback in the timer service task, not in an interrupt. It is the right tool for a slow periodic job (a heartbeat, a watchdog kick, a timeout on a protocol) and the wrong tool for anything with a real deadline, because it runs at the timer task’s priority behind whatever that task is doing. Anything sample-rate-bound is a task with vTaskDelayUntil, or an ISR.
2.5 Memory: static allocation and the heap schemes
FreeRTOS objects are allocated one of two ways, chosen by configuration:
Dynamic (configSUPPORT_DYNAMIC_ALLOCATION = 1): xTaskCreate, xQueueCreate, … call pvPortMalloc, which is one of the kernel’s five heap implementations — heap_1 (allocate-only, never frees), heap_2/heap_4 (free lists, heap_4 coalesces), heap_3 (wraps newlib’s malloc), heap_5 (several regions). CubeMX defaults to heap_4.
Static (configSUPPORT_STATIC_ALLOCATION = 1): xTaskCreateStatic, xQueueCreateStatic, xSemaphoreCreateMutexStatic take caller-supplied StaticTask_t/StackType_t[] buffers, so every object’s memory is a static in .bss that the map file can account for and that can never fail at runtime.
Module 6’s rule — no heap after init — is satisfied by either as long as every object is created before vTaskStartScheduler(); the static API makes the rule checkable, and it is what a safety-flavored project uses. When static allocation is on, the application must also supply the idle (and timer) task’s memory via vApplicationGetIdleTaskMemory.
TipFirmware rule
Create every task, queue, and mutex before the scheduler starts, from static buffers where the project allows it. A kernel object created on demand is a malloc in disguise.
2.6 What a context switch saves, and what a stack must hold
A FreeRTOS task’s stack holds three kinds of frames, and a stack-sizing prediction (Exercise 10.5) adds them up:
Frame
Pushed by
Contents on the Cortex-M4F
The task’s own call chain
The compiler
Every function frame on the deepest path — what -fstack-usage reports per function
The exception entry frame
The hardware, on any interrupt taken while the task runs
R0–R3, R12, LR, PC, xPSR (8 words); with the FPU active and lazy stacking, space for S0–S15, FPSCR is reserved (another 18 words) and filled only if the handler uses the FPU
The kernel’s context save
The PendSV handler
The callee-saved R4–R11 (and S16–S31 if the task used the FPU), plus EXC_RETURN
The FPU rows are why a task that does a single float multiply grows its worst-case stack by tens of words, and why configCHECK_FOR_STACK_OVERFLOW is on during every Course 3 RTOS-variant lab. PendSV is the lowest-priority exception, which is the mechanism: an ISR that wakes a higher task pends PendSV, and the switch happens after every other pending interrupt has drained — never in the middle of one.
2.7 CMSIS-RTOS2
CubeMX does not expose FreeRTOS directly; it generates a CMSIS-RTOS2 wrapper (cmsis_os2.h) over it — osThreadNew, osMessageQueueNew/Put/Get, osSemaphoreNew, osMutexNew with the osMutexPrioInherit attribute, osDelayUntil, osKernelStart. The mapping is one-to-one, the wrapper is a thin layer of the same calls, and the same rules apply: the FromISR distinction is handled inside the wrapper by checking whether it was called from an interrupt, which is convenient and hides the portYIELD_FROM_ISR you would otherwise write. Lab 7.2 is written against this API; reading the wrapper’s source next to the native calls is a short and worthwhile detour.
3 · RTIC — the interrupt controller as scheduler
3.1 The idea
The Cortex-M’s NVIC already implements fixed-priority preemptive scheduling — for interrupts. RTIC (Real-Time Interrupt-driven Concurrency) makes that the whole runtime: every task is either an interrupt handler or a function spawned onto a dispatcher interrupt that RTIC pends in software. There is no tick, no context switch beyond the exception entry the hardware does anyway, and one stack.
#[rtic::app(device =stm32l4::stm32l4x6, peripherals =true, dispatchers = [SPI1, SPI2])]mod app {useheapless::spsc::{Consumer, Producer, Queue};#[shared]struct Shared { uart_busy:bool}#[local]struct Local { prod: Producer<'static, [i16;64],4>, cons: Consumer<'static, [i16;64],4>}#[init(local = [q: Queue<[i16;64],4>=Queue::new()])]fn init(cx:init::Context) -> (Shared, Local) {let (prod, cons) = cx.local.q.split();// ... configure TIM2 to fire at the block rate ... (Shared { uart_busy:false}, Local { prod, cons })}#[task(binds = TIM2, priority =3, local = [prod])]fn acquire(cx:acquire::Context) {// hardware task: the TIM2 handler itselflet block = [0i16;64];// ... from the DMA buffer ...let _ = cx.local.prod.enqueue(block);let _ =process::spawn();}#[task(priority =2, local = [cons], shared = [uart_busy])]asyncfn process(mut cx:process::Context) {// software task, runs on a dispatcherwhileletSome(block) = cx.local.cons.dequeue() {// ... filter ... cx.shared.uart_busy.lock(|b|*b =true);}}#[idle]fn idle(_:idle::Context) ->!{loop{cortex_m::asm::wfi() }}}
The device argument names a PAC (Module 8); dispatchers lists interrupts the chip has but the application does not otherwise use — RTIC borrows them to run software tasks, one dispatcher per software priority level. A hardware task (binds = TIM2) is the interrupt handler; its priority is the NVIC priority. A software task is an async fn that RTIC schedules by pending its dispatcher; process::spawn() from anywhere makes it Ready, and it runs as soon as it is the highest-priority Ready thing. Resources are declared once, in #[shared] and #[local], and handed out in init’s return value; the framework generates a Context per task carrying exactly the resources that task declared.
3.2 The Stack Resource Policy
RTIC’s claim is that it is race-free and deadlock-free by construction, and it is worth understanding why rather than taking it on faith.
A #[local] resource belongs to one task and needs no protection — the type system prevents a second task from naming it. A #[shared] resource may be named by several tasks, and every access goes through lock(|r| …). RTIC computes, at compile time, each resource’s ceiling: the highest priority of any task that uses it. Inside the lock, the framework raises the current execution priority to that ceiling by writing BASEPRI — so no task that could touch the resource can preempt the critical section — and restores it after. This is the Stack Resource Policy (SRP), and it has three consequences:
No data races, because while a task holds a resource no competing task can run.
No deadlock, because a task that could block on a second resource is already running above every task that could hold it — there is never a cycle.
Bounded blocking, because the longest a high-priority task waits is the longest critical section of any lower task sharing a resource with it — the same bound priority inheritance gives FreeRTOS, but computed statically.
A task that is the only user of a shared resource at its ceiling, or the highest-priority user, gets the lock for free — lock compiles to the closure body with no BASEPRI write. Two tasks at the same priority sharing a resource can declare it #[lock_free] and skip lock entirely, because they can never preempt each other. What SRP cannot express is a task waiting for something inside a critical section — there is no blocking call, and there is no need for one, because any waiting is done by not being spawned yet.
3.3 Monotonics and message passing
RTIC 2 has no tick. Time comes from a monotonic — a timer wrapped by the rtic-monotonics crate (SysTick, or a hardware timer like TIM2) that provides Mono::now() and an awaitable Mono::delay(...). A periodic software task is then a loop that awaits the next instant rather than a vTaskDelayUntil:
#[task(priority =1)]asyncfn output(_:output::Context) {letmut next =Mono::now();loop{ next +=1.millis();Mono::delay_until(next).await;// ... write the DAC ...}}
Between tasks, rtic-sync provides bounded channels (make_channel!) whose sender and receiver halves live in #[local] resources, and heapless::spsc (Module 9) works as well from a hardware task to a software one because RTIC guarantees the single-producer/single-consumer split by ownership.
3.4 What RTIC does not do
There is no task stack to size — but there is also no way to have two tasks at the same priority take turns, because same-priority tasks run to completion in spawn order. A long computation at priority 2 delays every other priority-2 task until it finishes, which is exactly the super-loop problem again, one level up. The answer in RTIC is to split the work so that priorities express deadlines honestly, and to keep each task short.
4 · Embassy — async without stacks
4.1 async in one paragraph, then in no_std
An async fn returns a future: a value whose poll method advances it until it either completes (Poll::Ready(value)) or must wait (Poll::Pending), in which case it has arranged to be woken when the awaited thing happens. The whole contract is one trait, and it lives in core, not std:
Context carries a Waker — a callback the future stores wherever it is waiting (a timer queue, a channel’s wait list, an interrupt’s wake slot) so that whoever completes the wait can tell the executor “poll this task again”. An executor is nothing more than a loop that polls tasks whose wakers have fired. .await on a future inside an async fn is the compiler inserting “poll it; if Pending, return Pending from this function too, and resume here next time”.
What the compiler generates for the acquire task above is, schematically, an enum:
// What `async fn acquire()` desugars to — not code you writeenum AcquireFuture { Start, WaitingTick { tick: Ticker },// suspended at tick.next().await WaitingSend { tick: Ticker, block: [i16;64],/* … */},// suspended at BLOCKS.send().await Done,}
Each variant holds exactly the locals live across that .await, which is why a future’s size is a function of what is held across a wait, not of how much code the function has. The compiler turns the function body into a state machine — one variant per .await point, holding exactly the locals that live across that point. Because those locals can include references into the state machine’s own storage, a future that has been polled once must never move again; the Pin<&mut Self> receiver on poll is the type-level statement of that promise, and Unpin marks the types (most of them) that are safe to move anyway. Rust Book Ch 17 develops this on a desktop with a runtime that puts futures on the heap. On the Cortex-M there is no heap, and Embassy’s answer is the important one: every task’s future is statically allocated in a per-task pool at compile time (#[embassy_executor::task(pool_size = N)]), so it is pinned by construction and costs exactly its state-machine size in .bss.
4.2 The executor and tasks
#![no_std]#![no_main]useembassy_executor::Spawner;useembassy_stm32::gpio::{Level, Output, Speed};useembassy_sync::{blocking_mutex::raw::ThreadModeRawMutex,channel::Channel};useembassy_time::{Duration, Ticker, Timer};use{defmt_rtt as _, panic_probe as _};static BLOCKS: Channel<ThreadModeRawMutex, [i16;64],4>=Channel::new();#[embassy_executor::task]asyncfn acquire() {letmut tick =Ticker::every(Duration::from_millis(1));loop{ tick.next().await;// fixed cadence, like vTaskDelayUntil BLOCKS.send([0i16;64]).await;// blocks (yields) when the channel is full}}#[embassy_executor::task]asyncfn process(mut led: Output<'static>) {loop{let block = BLOCKS.receive().await;// yields until a block arriveslet _ = block;// ... filter ... led.toggle();}}#[embassy_executor::main]asyncfn main(spawner: Spawner) {let p =embassy_stm32::init(Default::default());let led =Output::new(p.PA5,Level::Low,Speed::Low);// LD2 on the NUCLEO spawner.must_spawn(acquire()); spawner.must_spawn(process(led));loop{Timer::after_millis(1000).await;}// heartbeat}
#[embassy_executor::main] builds a thread-mode executor and runs main as its first task; Spawner starts the others. The executor’s loop is: poll every task that has been woken; when none is Ready, WFI until an interrupt wakes one. Nothing preempts anything within the executor — acquire runs from tick.next() returning to BLOCKS.send(...) yielding, and only then does process get the core. That is the cooperative bargain: no locks are needed between tasks on the same executor because they never interleave except at .await, and a task that computes for a long time without awaiting delays every other task on that executor.
embassy-time supplies the clock: Timer::after_millis/after for a one-shot delay, Ticker for a drift-free period, with_timeout for a bounded wait, and Instant::now() — all driven by one hardware timer that embassy-stm32 configures (the time-driver-* feature) and shared by every task, so a hundred sleeping tasks cost no hardware timers.
4.3 Synchronization primitives
embassy-sync provides the objects that replace FreeRTOS’s, all no_std, all allocation-free, all usable as statics:
FreeRTOS
Embassy
Notes
Queue
Channel<M, T, N>
Bounded MPMC; send().await yields when full, receive().await when empty; try_send/try_receive from interrupts
Binary semaphore / notification
Signal<M, T>
Latest-value semantics: one slot, overwritten, wait().await
Mutex
Mutex<M, T> (async)
lock().await yields instead of blocking; holds across .await
Critical section
blocking_mutex::Mutex<M, T>
lock(|t| …), never yields; for state shared with interrupts
Event group
PubSubChannel
Broadcast to several receivers
The M parameter is the raw mutex kind, and it encodes the same concern Module 9 spent a page on: ThreadModeRawMutex is free because only thread-mode code (the executor) can touch the object; CriticalSectionRawMutex disables interrupts and is required if an interrupt handler or an interrupt executor also touches it; NoopRawMutex is for a single task’s private use. Choosing the cheapest correct one is the Embassy equivalent of choosing between lock and #[lock_free] in RTIC.
4.4 select, timeouts, and priority levels
embassy_futures::select::select(a, b).await polls two futures and returns Either::First/Either::Second for whichever completes first, dropping the other — the natural way to wait for “a block or a shutdown signal or a timeout”, and something FreeRTOS expresses only with queue sets or a timeout argument. embassy_time::with_timeout(d, fut) is select against a timer, returning Err(TimeoutError).
Cooperative scheduling within an executor is not the end of the story. Embassy provides interrupt executors: a second executor whose poll loop runs inside a chosen interrupt handler at a chosen NVIC priority. Tasks spawned onto it preempt the thread-mode executor exactly as an ISR would, and a CriticalSectionRawMutex channel carries data between the levels:
static EXECUTOR_HIGH: InterruptExecutor =InterruptExecutor::new();#[interrupt]unsafefn UART4() { EXECUTOR_HIGH.on_interrupt() }// an interrupt the app does not otherwise use// in main, before the low-priority tasks:interrupt::UART4.set_priority(Priority::P6);let high = EXECUTOR_HIGH.start(interrupt::UART4);high.must_spawn(acquire());
This is RTIC’s dispatcher mechanism, adopted: one interrupt per priority level, borrowed. With two or three levels, an Embassy application has preemptive priorities between levels and cooperative scheduling within them — which is, for a signal pipeline, usually exactly the shape wanted: acquire at a high level, everything else cooperative below it.
4.5 The HAL underneath: embassy-stm32
embassy_stm32::init(config) is the one call that replaces CubeMX’s clock-tree and pin-mux generation: it configures the RCC from the Config you pass (the default is the internal oscillator; the L476’s PLL to 80 MHz is a few fields in config.rcc), starts the time driver on the timer the crate’s time-driver-* feature names, and returns a Peripherals struct whose fields are the singletons Module 8 introduced — p.PA5 can be turned into an Output exactly once, and the compiler enforces it. The crate’s driver types come in blocking and async forms; the async ones (Uart::read, I2c::write_read, Adc with DMA) register their completion interrupt as a waker, which is how a task .awaiting a UART byte costs no CPU until the byte arrives. The memory-x feature emits the memory.x Module 0 wrote by hand, from the chip name in Cargo.toml (stm32l476rg).
4.6 What async costs and where it fails
A future’s size is the sum of everything live across its longest .await — a 512-sample buffer held across a send().await is 1 KB of .bss per task instance, forever. cargo size and Module 12’s ladder make this visible. The failure mode is subtler than a stack overflow: a task that never awaits (a tight DSP loop over a large buffer) does not crash — it silently starves its executor, and the symptom is that unrelated tasks stop running. The discipline is the same as RTIC’s: keep each task’s work short, and put deadline-bound work on a higher level.
5 · The three, side by side
5.1 One hand-off, three ways
The ISR → task hand-off is the operation every pipeline is built from; here it is in each runtime, so that the differences in §5.2’s table are anchored to code.
/* FreeRTOS: post from the ISR, wake the task if it outranks the current one */void TIM2_IRQHandler(void){ BaseType_t woken = pdFALSE; __HAL_TIM_CLEAR_IT(&htim2, TIM_IT_UPDATE); vTaskNotifyGiveFromISR(acquire_handle,&woken); portYIELD_FROM_ISR(woken);}/* in acquire_task: */ulTaskNotifyTake(pdTRUE, portMAX_DELAY);/* block here until notified */
// RTIC: the ISR is the task; the hand-off is a spawn#[task(binds = TIM2, priority =3, local = [tim, prod])]fn on_tim2(cx:on_tim2::Context) { cx.local.tim.clear_interrupt();let _ = cx.local.prod.enqueue(block);// heapless::spsc, single producer by ownershiplet _ =process::spawn();// pends the dispatcher; runs when it is highest Ready}
// Embassy: the ISR wakes a task through a Signal (or try_send on a Channel)static TICK: Signal<CriticalSectionRawMutex, ()>=Signal::new();#[interrupt]fn TIM2() {/* clear the flag via the PAC */ TICK.signal(());}#[embassy_executor::task]asyncfn acquire() {loop{ TICK.wait().await;/* … */}}
With embassy-stm32’s own timer and DMA drivers the interrupt handler in the third version is written by the HAL and the task simply .awaits a read — which is the idiom the framework is designed around, and the reason its ADC/UART/I²C drivers are async.
5.2 The table
FreeRTOS (C)
RTIC 2 (Rust)
Embassy (Rust)
Scheduling
Fixed-priority preemptive kernel with tick and time-slicing
Fixed-priority preemptive via NVIC; run-to-completion within a priority
Cooperative within an executor; preemptive between interrupt executors
Latency reasoning
Blocking bounded by mutex inheritance; interference by priorities; plus kernel overhead (tick, context switch)
SRP: blocking bounded statically by the longest lower-priority critical section; no kernel overhead beyond exception entry
Within a level, bounded by the longest run between .awaits of any task on that executor; between levels, as RTIC
Shared data
Mutex/semaphore/critical section by convention; races are runtime bugs
#[shared] + lock, ceilings computed at compile time; races are compile errors
Mutex/Channel typed by raw-mutex kind; same-executor tasks need nothing; races are compile errors
Memory
A stack + TCB per task; heap schemes or static API
One stack; resources are statics
Futures in static pools; one stack per executor
Allocation
Optional; static API available
None
None
Timing
Tick-based; vTaskDelayUntil; software timers
Monotonic timer, awaitable delays
One shared time driver; Timer, Ticker, with_timeout
ISR → task
FromISR calls + portYIELD_FROM_ISR
The ISR is a task; spawn()
try_send from an #[interrupt], or an interrupt executor
Long computation
Just runs; preempted by higher priorities
Delays same-priority tasks
Starves the executor unless split or moved up a level
Tooling
CubeMX, ST tools, FreeRTOS kernel-aware debugging, configASSERT, stack canaries
probe-rs, defmt, cargo size; the compiler’s checks
probe-rs, defmt, cargo size; the compiler’s checks
Ecosystem
ST HAL/LL, CMSIS-DSP, USB/FatFS/LwIP middleware, vendor support
PAC/HAL crates, embedded-hal drivers; smaller
embassy-stm32 HAL with async drivers (UART, I²C, SPI, ADC via DMA), embedded-hal-async
Choose when
The C project is ST-generated; middleware is needed; the team is C
Hard deadlines with a small number of well-understood priorities; no async drivers needed
Many I/O-bound activities; async HAL drivers; priorities expressible in two or three levels
Two rows of that table are the ones to internalize. Latency reasoning is different in kind, not degree: FreeRTOS gives a bound only after you have chosen the right primitive (mutex, not semaphore); RTIC gives it from the source; Embassy gives it only if every task is disciplined. And shared data is where the languages, not the runtimes, differ: in C the runtime provides the tool and the programmer supplies the discipline; in both Rust frameworks the missing discipline is a type error.
6 · What each side has that the other does not
The honest inventory, because the choice is rarely made on scheduling theory alone.
The CubeMX FreeRTOS project has: every peripheral of the L476 covered by ST’s HAL and LL drivers, with CubeMX generating the clock tree, pin mux, and DMA setup; CMSIS-DSP for the Course 3 kernels; vendor middleware (USB device, FatFS, LwIP) that simply exists; kernel-aware debugging in ST’s tools and in CLion’s FreeRTOS view; a large body of application notes; and a language (C17) the whole embedded industry reads. Its weaknesses are the ones this course exists to name: nothing checks that a queue item is not also touched by reference elsewhere, that a mutex is not taken from an ISR, that a FromISR call is not made above the syscall priority, or that a task’s stack is large enough — all of those are runtime discoveries, and configASSERT finds only the ones it was written for.
RTIC and Embassy have: resource sharing checked at compile time, so that the whole class of “works until it doesn’t” races is a build error; no stacks to size in RTIC and no context switches in either; async drivers in embassy-stm32 that make a UART receive with timeout a three-line function; defmt logging that costs almost nothing on the target; probe-rs flashing and RTT in one command; and cargo’s dependency handling, tests on the host, and Clippy. Their weaknesses: embassy-stm32 is younger than ST’s HAL and its API moves between releases (the course pins 0.6); RTIC’s model has to be bent to express anything that is not fixed-priority; there is no CMSIS-DSP equivalent with the same breadth (microdsp, cmsis-dsp bindings, or hand-written kernels — Module 12’s interop exercise puts a C kernel inside a Rust firmware for exactly this reason); and the debugging story for a wedged async task is a stack trace of the executor, not of the task.
What transfers, what doesn’t
Concept from the C/FreeRTOS side
In RTIC
In Embassy
Task priority
NVIC priority, directly
Executor level (thread mode or an interrupt executor); tasks within a level have none
Queue
rtic-sync channel or heapless::spsc
embassy_sync::channel::Channel
Binary semaphore / task notification
spawn()
Signal
Mutex with inheritance
#[shared] + lock — inheritance replaced by static ceilings
embassy_sync::mutex::Mutex (async) or a blocking_mutex — no inheritance needed within a level; between levels, the raw-mutex kind decides
Critical section
lock at ceiling, or critical_section::with
blocking_mutex with CriticalSectionRawMutex
vTaskDelayUntil
Mono::delay_until(next).await
Ticker::every(...).next().await
Software timer
A low-priority software task with a monotonic delay
A task with a Ticker
Queue set / wait on several
Not expressible — spawn separate tasks
select
Stack per task
Does not exist
Does not exist; the future’s size takes its place
configASSERT
assert!/debug_assert!, defmt::assert!
same
Stack-overflow hook
The single stack’s guard: flip-link (stack below .bss) or an MPU region
same, per executor
ImportantThe one rule that survives all three
Bound every wait. A queue receive with portMAX_DELAY, a lock inside a lock, a .await on a channel no one sends to, a computation that never yields — each is unbounded blocking wearing a different runtime’s clothes, and none of the three runtimes can bound it for you.