Remove the CPU from the sample path entirely. In Lab 5.2 every conversion cost one interrupt; at tens of kHz that per-sample tax dominates the processor. Here the DMA controller moves each finished conversion straight into a circular buffer in RAM with zero CPU involvement per sample. The CPU is interrupted only twice per buffer — at the half-transfer and transfer-complete points — which lets you process the data in blocks (ping-pong / double-buffering) while the next block fills behind you. This is the architecture of essentially every real-time DSP system: continuous acquisition, block processing, no dropped samples. You will build it, prove it runs continuously, and measure the CPU load it leaves you to spend on actual DSP.
Recommended reading
Kuo — DMA-based data transfer and block (frame) processing in real-time DSP; the double-buffering / ping-pong pattern.
Lyons Ch. 1 — discrete sequences and systems: the continuous stream of samples as the input to a discrete-time system, and why uninterrupted, gap-free acquisition matters (a dropped sample is not a small error; it is a corrupted sequence).
Course 1 Lesson 49 — uniform sampling as an impulse train; the buffer is a finite window of that stream.
Equipment & parts
STM32 NUCLEO-L476RG + USB cable.
MCP4725 DAC (3.3 V powered) or the Lab 4.4 buffered output as the analog source.
Saleae Logic 8 for the CPU-load duty-cycle measurement.
Jumper wires; common ground.
Wiring & bench setup
The signal chain is physically identical to Lab 5.2 — DAC DC source into A0, Saleae on D7 (PA8) — only the pin’s role changes: it is now the LOAD (CPU-busy) flag rather than a per-conversion toggle.
flowchart LR DAC["MCP4725<br/>DC source<br/>(Lab 5.1 wiring)"] MCU["NUCLEO-L476RG<br/>TIM2 → ADC1 → DMA circular<br/>LOAD = D7 (PA8)"] SAL["Saleae Logic 8<br/>duty cycle = CPU load"] DAC -- "OUT → A0 (PA0)" --> MCU MCU -- "LOAD pin high during process_block" --> SAL
flowchart LR
DAC["MCP4725<br/>DC source<br/>(Lab 5.1 wiring)"]
MCU["NUCLEO-L476RG<br/>TIM2 → ADC1 → DMA circular<br/>LOAD = D7 (PA8)"]
SAL["Saleae Logic 8<br/>duty cycle = CPU load"]
DAC -- "OUT → A0 (PA0)" --> MCU
MCU -- "LOAD pin high during process_block" --> SAL
Breadboard and fly-leads are exactly as Lab 5.2 left them — nothing to rewire.
Safety & don’t-break-it
0 – 3.3 V on PA0, always. DMA does nothing to protect the pin — the same not-5 V-tolerant rule holds. Keep the MCP4725 on 3.3 V.
Size the buffer as uint16_t and match the DMA data width to the ADC (halfword). A width mismatch silently corrupts the buffer or faults the DMA; there’s no electrical damage, but you’ll chase a phantom bug for an hour.
DMA circular mode never stops on its own — it will keep overwriting RAM forever. Make sure your buffer is statically allocated and large enough that the CPU always finishes a block before DMA laps it (see Analysis), or you’ll process torn data.
Saleae ground + 3.3 V threshold as in Lab 5.2 for the load-marker pin.
Project & environment setup
Firmware — reuse firmware/m5-daq/ (TIM2 trigger + triggered ADC1 as configured in Lab 5.2). This lab adds the DMA path to the .ioc:
CubeMX page
Setting
Analog → ADC1 → DMA Settings
Add the ADC1 request (CubeMX assigns DMA1 Channel 1); Mode = Circular; Data Width = Half Word for both peripheral and memory; Memory address increment on
System Core → NVIC
DMA1 channel 1 global interrupt enabled — this delivers the HT and TC callbacks. The per-conversion ADC EOC interrupt from Lab 5.2 is no longer wanted (Procedure step 2)
System Core → GPIO
PA8 (D7) → GPIO_Output — now the LOAD pin (same physical pin as Lab 5.2’s marker)
Timers → TIM2
unchanged — PSC = 79 / ARR = 99 → \(f_s = 10\) kHz, the rate the deliverable table assumes; retarget from your chosen \(f_s\) as in Lab 5.2
Middleware → FREERTOS
RTOS variant only (section below): CMSIS_V2 + HAL timebase on a spare timer, per the FreeRTOS bullet in the setup essentials
Host — the block-mean printout is worth keeping as evidence of gap-free acquisition:
source venv/bin/activate # pyserial for the VCP logmkdir-p labs/lab-5-3/host labs/lab-5-3/captures
Log the DC-estimate prints with labs/lab-5-3/host/log_vcp.py (pyserial, same shape as Lab 5.1’s logger — you write it; ~10 lines), or paste from your serial terminal.
Where results go:
Artifact
Path
Bench note (block cadence + measured \(\rho\))
labs/lab-5-3/notes.md
Saleae LOAD-pin capture (duty cycle = \(\rho\))
labs/lab-5-3/captures/load-pin.sal
Block-mean VCP log
labs/lab-5-3/captures/dc-estimate.log
(RTOS variant) deadline-margin capture
labs/lab-5-3/captures/rtos-load.sal
Background
With timer-triggered ADC → DMA in circular mode, each conversion’s result is copied by hardware into successive slots of a buffer of length \(N\); when DMA reaches the end it wraps to slot 0 and continues. Two interrupts fire:
Half-transfer (HT): slots \([0, N/2)\) are full and stable — the CPU processes that half while DMA fills \([N/2, N)\).
Transfer-complete (TC): slots \([N/2, N)\) are full — the CPU processes that half while DMA wraps and refills \([0, N/2)\).
This is double-buffering: at any instant one half is being written by DMA and the other is being read by the CPU, so acquisition never pauses. The CPU sees a steady cadence of blocks of \(N/2\) samples, arriving every
\[T_\text{block} = \frac{N/2}{f_s}.\]
The processing budget per block is exactly \(T_\text{block}\). Define the CPU load as the fraction of that budget the block routine consumes:
If \(\rho \ge 1\) the CPU can’t keep up and DMA overruns the half being read — samples are lost. Keeping \(\rho\) comfortably below 1 is the real-time constraint every Module 6 filter must satisfy.
Procedure
Part A — Add DMA in CubeMX.
From your Lab 5.2 project (firmware/m5-daq/: TIM2 trigger, ADC1 IN5, 12-bit), open ADC1 → DMA Settings and Add a DMA request: Mode = Circular, Data Width = Half Word (peripheral and memory).
Enable the ADC1/DMA interrupt in NVIC. Turn off the per-conversion EOC interrupt from Lab 5.2 — DMA now handles every sample; you only want HT/TC. Generate code.
Part B — Start DMA and split the buffer (illustrative firmware).
Declare a buffer and start the DMA-fed, timer-triggered conversion once; the two callbacks process opposite halves:
// Illustrative only — you write the real project.#define N 512staticuint16_t adc_buf[N];HAL_TIM_Base_Start(&htim2);HAL_ADC_Start_DMA(&hadc1,(uint32_t*)adc_buf, N);// circular, hands-offvoid HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *h){// HT HAL_GPIO_WritePin(LOAD_GPIO_Port, LOAD_Pin, GPIO_PIN_SET); process_block(&adc_buf[0], N/2);// first half HAL_GPIO_WritePin(LOAD_GPIO_Port, LOAD_Pin, GPIO_PIN_RESET);}void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h){// TC HAL_GPIO_WritePin(LOAD_GPIO_Port, LOAD_Pin, GPIO_PIN_SET); process_block(&adc_buf[N/2], N/2);// second half HAL_GPIO_WritePin(LOAD_GPIO_Port, LOAD_Pin, GPIO_PIN_RESET);}
Note the LOAD pin is driven high for the whole time the CPU is inside process_block() — its duty cycle is the CPU load \(\rho\).
Part C — Prove continuity and measure load.
For a first process_block(), do something cheap and verifiable — e.g. compute the block mean (a running DC estimate) and occasionally print it over the VCP. Confirm the printed DC tracks the MCP4725 level, and that it never glitches — evidence of gap-free acquisition.
Put a Saleae channel on the LOAD pin (D7 = PA8 per Wiring & bench setup). Measure its duty cycle in Logic 2 — that is \(\rho\) directly. Also confirm the pin pulses twice per buffer at a rate of \(f_s/(N/2)\).
Increase the work in process_block() (e.g. a short dummy FIR, or just a busy loop of known length) and watch \(\rho\) climb. Push it until you see torn/dropped data, then back off — you’ve found the block-processing ceiling for this \(f_s\).
Deliverable & expected results
A note (labs/lab-5-3/notes.md) with the block cadence and measured CPU load, plus the Saleae capture of the LOAD pin.
Quantity
Predicted
Measured
Block size \(N/2\)
256 samples
—
Block rate at \(f_s = 10\) kHz
\(f_s/(N/2) = 39.06\) Hz
…
\(T_\text{block}\)
25.6 ms
…
CPU load \(\rho\) (block-mean only)
≪ 1 (a few %)
…
Analysis & reconciliation
Predict \(T_\text{block} = (N/2)/f_s\) and the block rate \(1/T_\text{block}\); the Saleae LOAD-pin period should match. Read \(\rho\) off the pin’s duty cycle and compare to your estimate of \(t_\text{proc}\) (measure process_block directly with the DWT cycle counter for a sharper number than the pin gives). The classic failure mode: if \(t_\text{proc} > T_\text{block}\) the DMA write pointer laps the half you’re reading and you process a mix of old and new samples — visible as glitches in the DC estimate. The fix is either less work per block, a larger \(N\) (more latency, same throughput), or a lower \(f_s\). This \(\rho\) is the headroom every Module 6 lab spends: an FIR of \(M\) taps costs ~\(M\) MACs/sample, so \(t_\text{proc} \approx M\,(N/2)/f_\text{MAC}\) — plug in and you can predict whether a given filter fits before you write it.
Bare-metal vs RTOS on the STM32
This is the canonical embedded-DSP-under-RTOS pattern, and it sets up Lab 7.2. Run this exact acquisition under FreeRTOS and compare its per-block behaviour against the bare-metal callbacks you just built.
Bare-metal (above): the DMA half-/full-transfer callback runs process_block()inline in ISR context — the whole block is filtered inside the interrupt.
FreeRTOS (C): the DMA callback now only signals block-ready and returns fast — osSemaphoreRelease(sem), or osMessageQueuePut(q, &half_index, 0, 0) to hand the ready half-buffer’s index over — on the ...FromISR path; a DSP task blocks on osSemaphoreAcquire/osMessageQueueGet and runs process_block() in thread context. This gives a clean double-buffer ownership handoff — DMA owns the filling half, the task owns the completed half — and keeps the ISR short. Setup: enable FreeRTOS (CubeMX FREERTOS → CMSIS_V2), move the HAL timebase to a spare timer per the setup essentials, then create the semaphore/queue and DSP task (osSemaphoreNew/osMessageQueueNew + osThreadNew). The cost is per-block context-switch overhead plus a new failure mode: if the task isn’t scheduled before the next block completes, DMA laps the half you still own — an overrun.
Rust (RTIC / Embassy): in RTIC, bind a hardware task to the DMA IRQ that spawns a lower-priority processing software task — statically scheduled, priorities checked at compile time; in Embassy, await the DMA transfer in an async task. Same signal-then-process structure.
What to measure: the per-block context-switch overhead, whether the block deadline \(T_\text{block} = (N/2)/f_s\) still holds once the switch is in the path, and the CPU headroom left over. As long as the DSP task wakes and finishes well inside \(T_\text{block}\), the deferral is free throughput-wise; the RTOS just buys you a clean place to grow the pipeline in Lab 7.2.
Build (same STM32)
Block latency / jitter
Deadline margin vs \(T_\text{block}\)
Predicted
Measured
Bare-metal, process in DMA callback
inline, minimal
full
tight
…
FreeRTOS, DMA→signal→DSP task
+ context switch (~µs)
reduced by switch
ample if \(\rho \ll 1\)
…
Rust RTIC, hw task→sw task
≈ FreeRTOS
≈ FreeRTOS
≈ FreeRTOS
…
NoteWhy there is no Jetson/Pi port of this lab
No on-chip ADC means no ADC-to-memory DMA to configure — but the pattern this lab teaches is the one part of Module 5 that ports everywhere: a hardware producer filling a circular buffer while software drains it in half-buffer blocks is exactly how ALSA period/buffer audio capture works on the Jetson and Pi in Modules 8–9 (the audio interface DMAs into a ring; your process wakes per period — the HT/TC callback with different names). When you get to Lab 9.2, map its period-size/overrun vocabulary back to this lab’s half-transfer/overrun vocabulary.
Going further
True ping-pong vs. HT/TC on one buffer. The single circular buffer with HT/TC is double-buffering; compare it conceptually to two separate buffers swapped by DMA and note they’re equivalent.
Cache/coherency isn’t an issue on the M4 (no data cache), but note where it would bite on an M7 — DMA’d buffers need cache maintenance. Good context for the edge boards in Module 8.
Free-running vs. timer-paced DMA. Try continuous-mode ADC (no timer) feeding DMA and measure the resulting rate — then explain why you still want the timer (deterministic \(f_s\) from Lab 5.2) for any real DSP.
Carry this exact acquisition skeleton straight into Lab 5.4 and Module 6 — it doesn’t change; only process_block() does.