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.

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

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.

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

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

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