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.

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 = 2 and implement vApplicationStackOverflowHook() 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 ...FromISR variants (xQueueSendFromISR) and honor the xHigherPriorityTaskWoken yield. 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

  1. 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.
  2. Enable configCHECK_FOR_STACK_OVERFLOW = 2 and configUSE_MUTEXES = 1 (with priority inheritance, which is the FreeRTOS default for mutexes). Turn on the run-time stats / configGENERATE_RUN_TIME_STATS if you want CPU-usage numbers later.
  3. Configure three GPIO outputs (one per stage) and, optionally, USART2 for logging. Generate code.

Part B — Build the three-stage pipeline

  1. 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);
  2. 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 xQueueSendFromISR into q1); 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.

  3. 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

  1. 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).
  2. 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.
  3. 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

  1. 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.
  2. Increase the input rate until a queue backs up (a Put starts 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

  1. 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.
  2. 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.