Lab 5.3 — ADC + DMA Circular Buffer
← Course 2 syllabus · Module 5 · Prev: « Lab 5.2 · Next: Lab 5.4 »
Goal
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.
- O-DSP Ch. 4 — the continuous stream of samples as the input to a discrete-time system (why uninterrupted, gap-free acquisition matters).
- Course 1 Week 10 — 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.
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_tand 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.
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:
\[\rho = \frac{t_\text{proc}}{T_\text{block}} = \frac{t_\text{proc}\, f_s}{N/2}.\]
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 (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 512
static uint16_t adc_buf[N];
HAL_TIM_Base_Start(&htim2);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, N); // circular, hands-off
void 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. 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 (docs/lab-5-3.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.
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.