Lab 7.2 — FreeRTOS Real-Time Pipeline
← Course 2 syllabus · Module 7 · Prev: « Lab 7.1 · Next: Lab 7.3 »
Goal
Everything so far has run as a bare-metal super-loop with interrupts. Real embedded DSP products almost always run under an RTOS, because it lets you express a signal pipeline as independent tasks with explicit priorities, deadlines, and preemption — and reason about timing formally. This lab stands up CMSIS-RTOS2 on FreeRTOS on the STM32 and builds a three-stage pipeline — acquire → process → output — connected by queues, then instruments each stage with a GPIO toggle so a Saleae capture gives you real, measured end-to-end latency and jitter. Along the way you will see preemption happen live, and confront priority inversion: the classic RTOS failure mode where a high-priority DSP task misses its deadline because a low-priority task holds a shared resource. This is the firmware-architecture skill that DSP/embedded roles actually hire for.
Recommended reading
- Kuo Ch. 1 — the real-time system framing: hard vs. soft deadlines, sampling-rate-driven timing budgets, and why a DSP pipeline is a chain of deadline-bound stages. Read it as the requirements document for this lab.
- FreeRTOS documentation: tasks and the scheduler (fixed-priority preemptive scheduling), queues (
xQueueSend/xQueueReceive, blocking semantics), and mutexes with priority inheritance. Read the “priority inversion / priority inheritance” note specifically. - CMSIS-RTOS2 API (
osThreadNew,osMessageQueueNew,osMessageQueuePut/Get) — the wrapper CubeMX generates over FreeRTOS. - Course 1: none strictly required. The deadline \(\le\) sample-period constraint is just the real-time restatement of the sampling work in Course 1 Week 10; glance at it if you want the theory link.
Equipment & parts
- STM32 Nucleo-64 (NUCLEO-L476RG) + USB to ST-LINK.
- Saleae Logic 8 + Logic 2 software (for the per-stage timing capture). Share ground with the Nucleo.
- Three spare GPIO pins on the Morpho/Arduino headers, one per pipeline stage, wired to three Saleae channels.
- STM32CubeIDE with FreeRTOS (CMSIS-RTOS2) middleware enabled in the
.ioc. - Optional: USART2 VCP (
printf) for logging measured latencies; the on-board LD2 (PA5) as a liveness heartbeat.
Safety & don’t-break-it
- No power hazard — USB-powered board, logic-level signals only. The risks are timing/config, not electrical.
- Saleae input limits. Keep the toggled GPIOs at 3.3 V logic, set the Saleae threshold to 3.3 V, and share grounds (Nucleo GND ↔︎ Saleae GND) or your edges will be noise. Don’t connect a Saleae channel to anything above ~5 V.
- Stack sizing is the #1 RTOS footgun. Each task has its own stack; a too-small stack silently overflows into another task’s memory and manifests as a bizarre HardFault (tie this back to Lab 7.1). Enable
configCHECK_FOR_STACK_OVERFLOW = 2and implementvApplicationStackOverflowHook()while developing. Size DSP task stacks generously (FIR/FFT scratch lives on the stack). - HAL tick vs. RTOS tick collision. When FreeRTOS owns SysTick,
HAL_Delay()and the HAL time base can conflict. In CubeMX set the HAL time base to a basic timer (e.g. TIM6), not SysTick, so the two schedulers don’t fight. This is a required setup step, not optional. - Don’t call blocking RTOS APIs from an ISR. Use the
...FromISRvariants (xQueueSendFromISR) and honor thexHigherPriorityTaskWokenyield. Calling a plain blocking queue call from an interrupt will corrupt the scheduler. - Priorities: higher number = higher priority in FreeRTOS. Getting this backwards makes your “high-priority” acquire task the lowest — a common and confusing mistake.
Background
Tasks, priorities, preemption
FreeRTOS runs a fixed-priority preemptive scheduler: at every scheduling point it runs the highest-priority task that is ready. A higher-priority task that becomes ready (e.g. its queue receives data, or its delay expires) preempts whatever lower-priority task is running — immediately, at the next tick or the triggering event. Tasks of equal priority time-slice round-robin (if configUSE_TIME_SLICING is on). We map the DSP pipeline to three tasks connected by queues:
\[ \text{Acquire} \;\xrightarrow{\;Q_1\;}\; \text{Process} \;\xrightarrow{\;Q_2\;}\; \text{Output} \]
Acquire is the most timing-critical (it must service samples at the sample rate, or data is lost) so it gets the highest priority; Process (FIR/FFT) is next; Output (DAC/UART) is lowest and soaks up slack. A queue both moves data and provides backpressure — a full queue tells you the consumer can’t keep up.
Latency, jitter, and deadlines
For a stage that must run once per sample period \(T_s = 1/f_s\), its work \(C\) (worst-case execution time) plus any preemption/blocking delay must fit the deadline \(D\) (here \(D = T_s\) for a per-sample task, or a block period for block processing):
\[ C + B + I \;\le\; D, \qquad D = T_s = \frac{1}{f_s}. \]
where \(B\) is blocking time (waiting on a shared resource held by a lower-priority task) and \(I\) is interference (preemption by higher-priority tasks). End-to-end latency is the time from an input sample entering Acquire to the corresponding result leaving Output:
\[ L = t_\text{out} - t_\text{in} = \sum_\text{stages} (C_i + \text{queue wait}_i). \]
Jitter is the variation of a timing quantity across many iterations — the spread of measured periods or latencies:
\[ J = \max_k L_k - \min_k L_k, \qquad \sigma_L = \sqrt{\tfrac{1}{N}\textstyle\sum_k (L_k - \bar L)^2}. \]
For hard-real-time DSP, worst-case jitter (not the average) is what determines whether you ever miss a deadline. Measuring it directly with a pin toggle per stage is how you prove the pipeline meets timing rather than hoping it does.
Priority inversion (and the fix)
If Output (low priority) holds a mutex on a shared resource (say a shared UART or a printf lock) and gets preempted, and then Acquire (high priority) tries to take that same mutex, Acquire is blocked by a lower-priority task — and worse, a medium-priority task can run and starve Output indefinitely, so Acquire never gets the mutex back. That is unbounded priority inversion (the Mars Pathfinder bug). The fix is priority inheritance: while a low-priority task holds a mutex a high-priority task wants, the holder is temporarily boosted to the waiter’s priority so it finishes and releases quickly. FreeRTOS mutexes implement this; plain binary semaphores do not — that distinction is the whole point of this section.
Procedure
Part A — Bring up CMSIS-RTOS2 / FreeRTOS
- In CubeMX enable Middleware → FreeRTOS, interface CMSIS_V2. Set the HAL time base to TIM6 (System Core → SYS → Timebase Source = TIM6) so it doesn’t collide with the RTOS SysTick.
- Enable
configCHECK_FOR_STACK_OVERFLOW = 2andconfigUSE_MUTEXES = 1(with priority inheritance, which is the FreeRTOS default for mutexes). Turn on the run-time stats /configGENERATE_RUN_TIME_STATSif you want CPU-usage numbers later. - Configure three GPIO outputs (one per stage) and, optionally, USART2 for logging. Generate code.
Part B — Build the three-stage pipeline
Create three tasks and two queues (CMSIS-RTOS2 wrappers shown; illustrative only — you write the real firmware):
/* Priorities: higher number = higher priority. */ osThreadNew(AcquireTask, NULL, &acq_attr); /* osPriorityHigh */ osThreadNew(ProcessTask, NULL, &proc_attr); /* osPriorityNormal */ osThreadNew(OutputTask, NULL, &out_attr); /* osPriorityLow */ osMessageQueueId_t q1 = osMessageQueueNew(8, sizeof(Sample), NULL); osMessageQueueId_t q2 = osMessageQueueNew(8, sizeof(Block), NULL);Implement each task with a scope pin high on entry, low on exit so the Saleae sees the stage’s active window:
void ProcessTask(void *arg) { for (;;) { Sample s; osMessageQueueGet(q1, &s, NULL, osWaitForever); /* block on input */ HAL_GPIO_WritePin(GPIOx, STAGE2_PIN, GPIO_PIN_SET); Block b = run_fir(&s); /* the DSP payload */ HAL_GPIO_WritePin(GPIOx, STAGE2_PIN, GPIO_PIN_RESET); osMessageQueuePut(q2, &b, 0, osWaitForever); } }Acquire is fed by the Lab 5.2 timer-triggered ADC (or a timer ISR that does
xQueueSendFromISRintoq1); Output writes to the DAC/UART. Keep the toggle as the very first and very last statement in each stage so the pin edges bracket exactly the compute + I/O.Add an LD2 heartbeat in the idle hook or a lowest-priority task to confirm the scheduler is alive.
Part C — Capture per-stage timing on the Saleae
- Wire the three stage pins to Saleae channels D0–D2 (plus a common ground). In Logic 2 set the threshold to 3.3 V and sample at a rate well above your toggle rate (e.g. 10–50 MS/s for microsecond-scale stages).
- Run the pipeline at a fixed input rate (start modest, e.g. \(f_s = 8\text{ kHz}\) block-processed, or 1 kHz per-sample). Capture a few thousand cycles.
- Use Logic 2 timing markers / measurements to read, per stage: the active-high width (\(C_i\), execution time) and the period (cadence). Read the end-to-end latency as the gap from Acquire’s rising edge to the corresponding Output rising edge. Export the annotated capture.
Part D — Provoke preemption and observe it
- Deliberately create contention: make Process do a long FIR/FFT while Acquire fires on a fast timer. On the Saleae you should see Acquire’s pin interrupt Process’s active window — Process’s high pulse has a “notch” where the higher-priority task preempted it. Annotate this as the visible proof of preemption.
- Increase the input rate until a queue backs up (a
Putstarts timing out or a stage’s period exceeds its deadline). Note the rate at which the pipeline stops meeting timing — that is your measured throughput ceiling.
Part E — Demonstrate and fix priority inversion
- Introduce a shared resource guarded first by a binary semaphore: have Output (low priority) take it, do a long critical section, while Acquire (high priority) also needs it. Add a medium-priority “hog” task that just burns CPU. Capture: Acquire’s latency blows up whenever the hog runs while Output holds the semaphore — unbounded inversion, visible as huge outliers in Acquire’s latency.
- Swap the binary semaphore for a FreeRTOS mutex (priority inheritance on). Recapture: Output is briefly boosted, releases quickly, and Acquire’s worst-case latency collapses back to a bounded value. Record both latency distributions side by side — this is the headline result.
Deliverable & expected results
A bench note (docs/lab-7-2.md), the annotated Logic 2 captures, and the firmware/m7-rtos/ project. Record:
- Per-stage execution time \(C_i\) and the measured end-to-end latency \(L\), with min/mean/max.
- The measured jitter \(J = \max L_k - \min L_k\) and \(\sigma_L\) over \(N\) cycles.
- A Saleae screenshot showing Acquire preempting Process.
- The Acquire worst-case latency with a binary semaphore vs. with a mutex (the priority-inversion before/after).
- The input rate at which timing first breaks (throughput ceiling).
| Quantity | Predicted | Measured |
|---|---|---|
| Deadline per per-sample stage (\(f_s = 8\) kHz) | \(T_s = 125\ \mu\text{s}\) | … |
| End-to-end latency \(L\) (sum of stage times + queue waits) | order of a few \(\times C_i\) | … |
| Jitter \(J\) over \(N\) cycles | small vs. \(T_s\) (goal \(\ll D\)) | … |
| Acquire worst-case latency, binary semaphore | large / unbounded outliers | … |
| Acquire worst-case latency, mutex (priority inheritance) | bounded, \(\approx C_\text{Output critical section}\) | … |
| Throughput ceiling (\(f_s\) where a stage misses \(D\)) | when \(\sum C_i \to T_s\) | … |
Analysis & reconciliation
Predict each stage’s execution time from cycle counts (or a DWT cycle-counter reading) and the 80 MHz clock, then compare to the Saleae pulse widths. Expect measured \(C_i\) to run higher than a naive instruction count because of RTOS overhead: context-switch cost (save/restore the M4F register + FPU state), tick-handler time, and cache/flash wait states. Attribute the gap explicitly — the difference is the RTOS’s price, and quantifying it is the point.
For jitter, distinguish two sources: scheduling jitter (a higher-priority task or ISR preempting the stage — visible as the notches you captured) and queue-wait jitter (variable time blocked on osMessageQueueGet). If measured jitter approaches your deadline, the pipeline is not safe at that rate; either raise priority, shorten \(C\), or lower \(f_s\). Confirm the deadline test \(C + B + I \le T_s\) holds for the worst observed case, not the average.
The priority-inversion result is the reconciliation that matters most: with the binary semaphore, Acquire’s latency histogram has a long tail (or unbounded outliers) exactly when the medium hog runs; with the mutex, the tail disappears because inheritance bounds the blocking time \(B\) to Output’s critical-section length. If you don’t see the difference, verify (a) the hog task is genuinely medium priority and CPU-bound, and (b) configUSE_MUTEXES is on and you used a mutex, not a semaphore. Write the staff-level takeaway: use mutexes (not binary semaphores) for shared resources in a priority-based real-time system, and always bound worst-case blocking.
Going further
- Rate-monotonic analysis: compute the RM utilization bound \(\sum C_i/T_i \le n(2^{1/n}-1)\) for your task set and check your measured utilization against it — a formal schedulability argument to accompany the measured one.
- Double-buffering / zero-copy: replace value-copy queues with pointers to ping-pong buffers so Process works on one block while Acquire fills the next — measure the latency reduction.
vTaskGetRunTimeStats: turn on run-time stats to get per-task CPU percentage, and reconcile it against the Saleae duty cycles (independent CPU-headroom measurement, which Lab 7.3 needs).- Tickless idle: enable low-power tickless idle and measure the current draw drop on the WANPTEK/Fluke while still meeting deadlines — the real-time-vs-power tradeoff.
- Fold in the Lab 7.1 watchdog: refresh it from the lowest-priority task so a hang in any higher task (which would starve the low task) trips the dog — a design pattern that catches whole-system livelock.