Lab 6.1 — FIR on Real ADC Data

Course 2 syllabus · Module 6 · Prev: « Lab 5.4 · Next: Lab 6.2 »

Goal

Take the live, DMA-buffered ADC stream from Module 5 and run it through a finite impulse response (FIR) low-pass filter in real time on the Cortex-M4F. You will design the filter on the host with the window method (a windowed sinc), export the taps, and implement the convolution three ways on the STM32: a naïve float loop, the CMSIS-DSP block function arm_fir_f32, and a Q15 fixed-point version with arm_fir_q15. Then you’ll prove on the scope that the filter passes what it should and stops what it should, that its phase is linear, and that the whole thing fits inside your per-sample time budget. This is the core skill of DSP firmware: turning a filter spec into taps, taps into a real-time MAC loop, and a MAC loop into a proven real-time budget.

Equipment & parts

  • STM32 Nucleo-64 (NUCLEO-L476RG) with the Module 5 timer-triggered ADC + DMA circular-buffer project as the starting point.
  • MCP4725 DAC (or the STM32’s on-chip DAC) to reconstruct the filtered signal for the scope.
  • A signal source into the ADC pin: the MCP4725 / STM32 DAC playing a synthesized tone or a tone + interferer sum (the base Siglent has no built-in generator — reuse the Module 3 DAC waveform generator).
  • Siglent SDS1104X-E scope, two channels: raw input on CH1, filtered output on CH2.
  • Host (M-series Mac) with numpy/scipy/matplotlib to design taps and with pyserial to log data over the ST-LINK VCP (USART2, PA2/PA3).
  • Breadboard, jumpers; an RC reconstruction low-pass on the DAC output if you want a clean analog view.

Safety & don’t-break-it

  • Keep every analog node inside 0–3.3 V. The signal you feed the ADC pin (from the DAC or op-amp) must never exceed V_DDA = 3.3 V or go below 0 V — the L476RG analog inputs are not 5 V tolerant. Bias and scale your test signal so its peaks land inside the rail with margin; clip it before the pin with a divider or a rail-to-rail buffer, not by hoping.
  • Share grounds. DAC breakout, STM32, and scope grounds must be common, or your “filtered” waveform is just a ground-loop artifact.
  • Q15 overflow is a silent killer, not a smoke event — but it destroys the measurement. A filter whose tap sum exceeds unity gain, or an input near full scale, can overflow the Q15 accumulator and produce wrap-around spikes. Scale taps for ≤ 0 dB DC gain and leave headroom (see Background). No hardware dies, but you’ll chase a phantom bug for an hour if you skip this.
  • Don’t hot-swap the DAC/probe while powered on the analog pin; settle habits from Module 3 apply.

Background

An FIR filter of length \(N\) computes each output sample as the convolution sum of the last \(N\) inputs with the tap set \(h[k]\):

\[ y[n] = \sum_{k=0}^{N-1} h[k]\,x[n-k]. \]

There are no feedback terms, so the impulse response is exactly \(\{h[0],\dots,h[N-1]\}\) and is finite — hence “FIR.” Its frequency response is the DTFT of the taps,

\[ H(e^{j\omega}) = \sum_{k=0}^{N-1} h[k]\,e^{-j\omega k}, \]

which is periodic in \(\omega\) with period \(2\pi\), where \(\omega = 2\pi f / f_s\) maps continuous frequency to the digital axis.

Windowed-sinc design. The ideal low-pass with cutoff \(f_c\) has the brick-wall response \(H_\text{ideal}(e^{j\omega}) = 1\) for \(|\omega| < \omega_c\) and \(0\) otherwise, whose inverse DTFT is the (infinite, non-causal) sinc

\[ h_\text{ideal}[k] = \frac{\sin(\omega_c k)}{\pi k}, \qquad \omega_c = 2\pi \frac{f_c}{f_s}. \]

We truncate it to \(N\) samples, shift it to be causal (center tap at \(k=(N-1)/2\)), and multiply by a window \(w[k]\) (Hamming, Blackman, Kaiser) to tame the truncation ripple:

\[ h[k] = w[k]\,\frac{\sin\!\big(\omega_c\,(k - \tfrac{N-1}{2})\big)}{\pi\,(k - \tfrac{N-1}{2})}. \]

Finally normalize so \(\sum_k h[k] = 1\) (unity DC gain, \(0\) dB passband).

Linear phase. If the taps are symmetric, \(h[k] = h[N-1-k]\), the frequency response factors into a real magnitude times a pure linear-phase term:

\[ H(e^{j\omega}) = A(\omega)\,e^{-j\omega (N-1)/2}, \]

so every frequency is delayed by exactly the same group delay,

\[ \tau_g = \frac{N-1}{2}\ \text{samples} = \frac{N-1}{2 f_s}\ \text{seconds}. \]

Constant group delay means the filter does not distort waveform shape — the whole reason to prefer FIR when phase matters. You will confirm this on the scope by measuring the input-to-output delay and checking it is frequency-independent.

Q15 fixed-point. In Q15 each number lives in \([-1, 1)\) as a signed 16-bit integer scaled by \(2^{15}\). A product of two Q15 values is Q30; CMSIS-DSP accumulates in a 32-bit (or 64-bit) accumulator and shifts back. Because unity-gain taps sum to 1, the output stays in range if the input does — but transient ringing can exceed \(|1|\), so leave a bit of headroom (design for, say, \(-1\) dB passband, or pre-scale the input). Quantizing the taps themselves to 16 bits perturbs the stopband floor; expect the fixed-point stopband to be a few dB worse than the float one.

Real-time budget. The filter must finish before the next sample arrives. At sample rate \(f_s\) the budget per sample is \(T_s = 1/f_s\). A direct FIR costs \(N\) multiply-accumulates (MACs) per output. The Cortex-M4F does a single-precision MAC in a couple of cycles (and the DSP extension does a Q15 MAC in one), so at core clock \(f_\text{clk}\) the rough ceiling is

\[ N_\text{max} \approx \frac{f_\text{clk}}{f_s \cdot c_\text{MAC}}, \]

with \(c_\text{MAC}\) the cycles per tap (load + multiply + accumulate overhead). At \(f_\text{clk} = 80\) MHz and \(f_s = 16\) kHz you have \(80{,}000{,}000 / 16{,}000 = 5000\) cycles per sample — room for a few-hundred-tap FIR with margin. Process in blocks (the DMA half/full-buffer callbacks from Lab 5.3) to amortize call overhead.

Procedure

Part A — Design the taps on the host.

  1. Pick a spec: sample rate \(f_s = 16\) kHz, passband edge \(f_c = 2\) kHz, stopband edge \(\approx 3\) kHz, stopband attenuation \(\ge 50\) dB. Estimate length: a Hamming window gives ~53 dB and a transition width \(\Delta f \approx 3.3\,f_s/N\), so \(N \approx 3.3 \cdot 16000 / 1000 \approx 53\); round to an odd \(N = 63\) for a symmetric, integer-sample group delay.

  2. In Python, build the taps and inspect the response:

    import numpy as np, scipy.signal as sig
    fs, fc, N = 16000, 2000, 63
    h = sig.firwin(N, cutoff=fc, fs=fs, window="hamming")  # normalized, unity DC gain
    w, H = sig.freqz(h, worN=4096, fs=fs)
    # plot 20*log10(|H|): confirm ~0 dB passband, >=50 dB stopband
  3. Confirm symmetry (np.allclose(h, h[::-1])) and that h.sum() ≈ 1. Export the taps as a C array for the float build, and a Q15 version (np.round(h * 32768).clip(-32768, 32767).astype(np.int16)) for the fixed-point build.

Part B — Float FIR on the STM32 (naïve, then CMSIS-DSP).

  1. Start from the Lab 5.3 DMA circular-buffer project. In the DMA half- and full-transfer callbacks you get a block of new samples; convert the 12-bit ADC codes to a signed, zero-centered float in \([-1,1)\) (subtract mid-scale 2048, divide by 2048).

  2. First implement the convolution by hand to feel the MAC loop and the delay line:

    /* delay line of the last N-1 inputs kept between blocks */
    for (uint32_t n = 0; n < BLOCK; n++) {
        float acc = 0.0f;
        for (uint32_t k = 0; k < NTAPS; k++)
            acc += h[k] * xline[n + (NTAPS-1) - k];  /* x[n-k] */
        y[n] = acc;
    }
  3. Replace it with the library block function, which manages the state (delay line) for you:

    arm_fir_instance_f32 S;
    static float32_t state[BLOCK + NTAPS - 1];
    arm_fir_init_f32(&S, NTAPS, (float32_t*)h, state, BLOCK);
    /* per block, in the DMA callback: */
    arm_fir_f32(&S, xblock, yblock, BLOCK);   /* CMSIS-DSP, FPU-accelerated */
  4. Send each output sample to the DAC (rescale \([-1,1) \to\) code \(0..4095\)) so the scope sees the filtered analog waveform.

Part C — Q15 fixed-point FIR.

  1. Swap in arm_fir_q15 with the Q15 taps and q15_t I/O (convert the ADC code to Q15 by (code - 2048) << 4). Keep the same block size. Note CMSIS-DSP requires an even number of taps for the Q15 variant on some cores — pad with a leading zero tap or use the documented layout.
  2. Watch for overflow: if the output shows wrap-around spikes, reduce input amplitude or verify the taps sum to ≤ 1 in Q15.

Part D — Verify on the bench.

  1. Drive the ADC with a swept or stepped single tone from the DAC (e.g., 500 Hz, 1 kHz, 1.8 kHz in passband; 2.5 kHz, 3 kHz, 5 kHz in stopband). At each frequency, measure input Vpp on CH1 and output Vpp on CH2 and compute gain in dB. Plot measured gain vs. frequency and overlay the host freqz prediction.
  2. Feed a tone + interferer (e.g., 1 kHz signal + 4 kHz interferer summed in the DAC). Confirm the 4 kHz component is suppressed at the output while the 1 kHz survives; check with the scope’s FFT math.
  3. Linear phase check: with a mid-passband tone, measure the input-to-output time delay with the scope’s cursors/measurements and compare to \(\tau_g = (N-1)/(2 f_s)\). Repeat at a second passband frequency and confirm the time delay is the same (constant group delay).
  4. Budget check: toggle a GPIO high at the start of arm_fir_f32 and low at the end; measure the pulse width per block on the Saleae or scope. Divide by BLOCK to get per-sample compute time and compare to \(T_s = 62.5\,\mu s\).

Deliverable & expected results

Capture: the host magnitude-response plot with the measured gain points overlaid; a scope shot of the tone+interferer input vs. filtered output; the GPIO timing pulse; and a serial log of a passband and a stopband run. Record the measured per-tap cost.

Quantity Predicted Measured
Filter length \(N\) for 50 dB / 1 kHz transition (Hamming) 63 taps
Passband gain at 1 kHz 0 dB
Stopband attenuation at 4 kHz (float) ≥ 50 dB
Stopband attenuation at 4 kHz (Q15) ~ 45–48 dB (a few dB worse)
Group delay \(\tau_g = (N-1)/(2 f_s)\) \(31/16000 = 1.94\) ms
Per-block compute, arm_fir_f32, BLOCK=64 order ~few×\(N\)×BLOCK cycles
Per-sample time vs. budget \(T_s\) \(\ll 62.5\ \mu\)s

Analysis & reconciliation

Compute the predicted magnitude response from the taps (freqz) and compare, point by point, to the measured gains — expect agreement within a dB or so in the passband; larger scatter in the deep stopband because the output there is small and dominated by ADC/DAC noise and quantization, so you may not actually measure 50 dB of rejection (you’ll hit the converter noise floor first — a genuine, instructive limit you’ll quantify in Lab 6.4). Reconcile the float vs. Q15 stopband: the extra few dB of floor is tap-quantization noise, predictable from the 16-bit tap resolution. Confirm the group delay matches \((N-1)/(2 f_s)\) and — crucially — that it is the same time at every passband frequency; any frequency dependence means your taps aren’t symmetric (a bug). Finally, reconcile the timing: if arm_fir_f32 is far faster than the naïve loop, it’s the FPU + loop unrolling; if the Q15 version is faster still, it’s the single-cycle DSP MAC (SMLAD) doing two taps at once.

Going further

  • Redesign with a Kaiser window for a prescribed stopband attenuation and compare the length needed for the same spec (Kaiser is near-optimal for a given ripple; O-DSP Ch. 7).
  • Try a decimating FIR (arm_fir_decimate_f32) to combine anti-alias filtering with sample-rate reduction and halve your downstream MAC load — the classic Module 5 → Module 6 optimization.
  • Implement a high-pass and a band-pass from the same window method (spectral inversion / modulation of the low-pass taps) and re-run the verification sweep.
  • Push \(N\) up until the GPIO timing pulse approaches \(T_s\) and observe the real-time boundary directly; then recover margin by processing larger blocks.