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.
Recommended reading
Lyons Ch. 5 — FIR filters: the convolution sum, linear phase, the window design method, and why symmetric taps matter. This is the primary reading.
Kuo — FIR realization on a DSP/MCU: the MAC loop, circular buffering of the delay line, and Q15 fixed-point scaling and overflow. The implementation reference for this lab.
Course 1 Lessons 32–33 — the DTFT and the sampling theorem; the frequency response of an FIR filter is just the DTFT of its impulse response, so this is the math you are spending here.
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.
Wiring & bench setup
This is the recurring Module 6 chain — build it once here and Labs 6.2–6.5 reuse it: the MCP4725 (your Lab 3.3 waveform generator) synthesizes the test signal into the ADC at A0 = PA0, the firmware filters it, the on-chip DAC reconstructs the result on A2 = PA4, and the scope compares raw (CH1) against filtered (CH2) while the Saleae times the GPIO pulse on D7 = PA8.
Synthesize every test tone already biased to mid-scale (DAC code 2048 ± amplitude): the digital offset is the mid-rail bias, so the ADC sees 0–3.3 V by construction with no analog bias network.
Only one on-chip DAC pin is cleanly available (PA4 — DAC OUT2 is PA5, shared with LD2). If you’d rather use PA4 as the source, reconstruct the filtered output through the MCP4725 instead — one pin, one role.
Most MCP4725 breakouts carry on-board I²C pull-ups; a bare board needs 4.7 kΩ SCL→3V3 and SDA→3V3 (as in Lab 3.1).
The optional RC on PA4 (e.g. 1 kΩ + 10 nF, \(f_{-3\text{dB}} \approx 16\) kHz) smooths the DAC staircase for a cleaner CH2 view without touching the 2 kHz passband.
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.
Project & environment setup
Firmware — create the Module 6 projectfirmware/m6-dsp/ (Labs 6.2–6.9 reuse it). Start from the Lab 5.3 acquisition setup — copy/import the m5-daq.ioc peripheral config into the new project — then add what Module 6 needs:
CubeMX page
Setting
Software Packs → Select Components
add ARM CMSIS-DSP, define ARM_MATH_CM4, build hard-float — per the CMSIS-DSP bullet in the setup essentials; without it every arm_* call fails to link
Two scripts under labs/lab-6-1/host/ (you write them): design_taps.py — the Part A scipy.signal.firwin/freqz design plus export of the float and Q15 tap arrays as a C header; log_gains.py — pyserial capture of the sweep readings and the matplotlib overlay of measured gain points on the freqz curve.
Keep this lab’s reconciliation in labs/lab-6-1/host/analysis.ipynb — the notebook convention — and export final figures next to it.
Where results go:
Artifact
Path
Bench note
labs/lab-6-1/notes.md
Exported taps (float + Q15 C header)
firmware/m6-dsp/Core/Inc/fir_taps.h
Magnitude response + measured overlay
labs/lab-6-1/host/response-overlay.png
Scope CSV, tone + interferer in vs. out
labs/lab-6-1/captures/tone-interferer.csv
Saleae timing capture (PA8 pulse)
labs/lab-6-1/captures/fir-timing.sal
Serial sweep log (passband + stopband runs)
labs/lab-6-1/captures/sweep.log
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,
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
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:
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
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.
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 the transition-width estimate is \(N \approx 3.3 \cdot 16000 / 1000 \approx 53\). Hamming’s ~53 dB stopband only just clears the 50 dB spec, so add margin and choose an odd\(N = 63\) (odd length puts a true center tap at \((N-1)/2\), giving an exact integer-sample group delay).
In Python, build the taps and inspect the response:
import numpy as np, scipy.signal as sigfs, fc, N =16000, 2000, 63h = sig.firwin(N, cutoff=fc, fs=fs, window="hamming") # normalized, unity DC gainw, H = sig.freqz(h, worN=4096, fs=fs)# plot 20*log10(|H|): confirm ~0 dB passband, >=50 dB stopband
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).
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).
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;}
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 */
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.
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 arm_fir_q15 requires an even number of taps. Do not just prepend a single zero to the 63-tap set — that breaks the \(h[k]=h[N-1-k]\) symmetry and destroys the exact linear phase you verify in Part D (it also shifts the group delay by half a sample). Instead design the fixed-point filter with an even length from the start — e.g. firwin(64, …), a symmetric (Type-II) linear-phase FIR whose group delay is a clean 31.5 samples — and quantize that symmetric set. Keep the taps symmetric so linear phase survives quantization.
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.
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.
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.
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).
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.
Cross-platform ports & language variants
See the syllabus Implementation tracks for the framing; this is the FIR-specific version. The FIR straddles the taxonomy: the convolution sum is embarrassingly batchable (throughput-bound), yet the real-time lesson here is a strictly sequential, deterministic per-sample MAC budget — one output must land before the next sample arrives.
STM32 bare-metal (C, and Rust). In C the MAC loop is arm_fir_f32 (or arm_fir_q15), run per block in the DMA callback; every output costs the same \(N\) MACs, so the per-sample budget is bounded and jitter-free — at \(f_s = 16\) kHz that is a hard \(T_s = 62.5\,\mu\)s ceiling you verify with the DWT cycle counter (setup essentials). In Rust (#![no_std]) there is no CMSIS: you hand-write the MAC loop or pull a Rust DSP crate. The Q15 version is where Rust earns its keep — the accumulator overflow that C wraps silently becomes an explicit wrapping_mul / saturating_add / checked-arithmetic decision.
Raspberry Pi 5 (Linux userspace, C or NumPy). The same C MAC loop compiles unchanged and NEON auto-vectorizes it at -O3 (or hand-write vmlaq_f32 intrinsics); or just call scipy.signal.lfilter / np.convolve. Throughput is enormous, but per-sample latency is non-deterministic — scheduler preemption, cache/TLB misses, and page faults give a jittery p99. And because everything is float64, the Q15 overflow concern simply disappears — fixed-point discipline is an embedded-only artifact.
Jetson Orin Nano. A long FIR becomes FFT overlap-save via cuFFT, or a cuDNN / CuPy conv1d, batched across channels. For long filters and large blocks the \(O(N\log N)\) transform wins decisively — but the ~10–50 µs kernel-launch plus host↔︎device copy latency dominates small blocks, so it loses the single-tap real-time race it never entered.
Jetson Orin Nano — detailed procedure (embedded Linux)
This procedure builds the Module 6 Jetson harness that Labs 6.2–6.9 reuse (the way the STM32 labs all share firmware/m6-dsp/): same algorithm, same test signal, same pass/fail criteria as the STM32 build — different platform, and the differences become numbers. One-time board config: Jetson setup essentials. No bench wiring — the input is data, not volts (the Jetson has no ADC; its live-analog path is Lab 3.4’s ADS1115 at ≤ 860 SPS, far below this lab’s 16 kHz, so the fair comparison feeds both platforms the same digital signal).
The harness (set up once, in labs/lab-6-1/edge/):
mkdir -p labs/lab-6-1/edge, and build there with the standard CMake C++20 template from docs/edge-setup.md (develop over SSH or with the CLion remote toolchain, per the setup essentials).
Input: generate this lab’s exact test vectors with your existing Part A Python — the stepped single tones and the 1 kHz + 4 kHz tone-plus-interferer at \(f_s=16\) kHz — and save them (np.save) into labs/lab-6-1/edge/. The reference outputs come from scipy.signal.lfilter with the same taps; save those too. Prediction before measurement, in file form.
Timing convention: the Jetson counterpart of the DWT cycle counter is clock_gettime(CLOCK_MONOTONIC_RAW) around the per-block call, collected over thousands of blocks into p50 / p99 / max (a distribution, not one number — on Linux the tail is the measurement). Before every timing run: sudo nvpmodel -m 0 && sudo jetson_clocks, and run the binary under sudo chrt -f 80 pinned with taskset -c 3.
Verification convention: every implementation’s output is compared against the SciPy reference (max |error|, and error-in-dB relative to full scale) before its timing counts. Fast-but-wrong is wrong.
This lab’s runs.
CPU float: your C MAC loop from Part B compiles unchanged (it’s portable C); build -O3 and let NEON auto-vectorize. Run blocks of 64 (the STM32’s BLOCK) through the harness → per-block p50/p99. Then re-run with BLOCK = 4096 and watch per-sample cost drop as call overhead amortizes.
GPU: in Python, swap scipy.signal for cupyx.scipy.signal (lfilter/fftconvolve — the import swap is the whole port) and time the same blocks with cupyx events or wall-clock around a synchronized call. At BLOCK = 64 the kernel launch + copy dominates and the GPU loses to the CPU; push BLOCK and the number of parallel channels up until it wins. Find and record the crossover block size — that number is the lab’s Jetson deliverable.
Q15 does not port: note (in notes.md, explicitly) that the fixed-point build has no Jetson counterpart — float is free here, and the overflow discipline of Part C is an embedded-only artifact.
Save the timing distributions (CSV) and the verification report to labs/lab-6-1/edge/, and put the p50/p99 numbers in the comparison table below alongside the STM32 DWT cycle counts.
Raspberry Pi 5 differences: CPU rungs identical (set the performance governor instead of jetson_clocks); there is no GPU rung — the Pi’s ceiling is the NEON build, which makes the Jetson crossover measurement more interesting, not less.
Measure and compare (fill Measured on each platform):
Platform / build
Per-sample latency
Batched throughput
Predicted
Measured
STM32 bare-metal, C (DWT cycles)
bounded, \(\ll 62.5\,\mu\)s
n/a (streaming)
deterministic
…
STM32 bare-metal, Rust (Q15 explicit)
≈ same as C
n/a
≈ C
…
Pi 5, NEON C / NumPy
fast median, jittery tail
high
p99 ≫ p50
…
Jetson, cuFFT overlap-save (batched)
launch-overhead-bound
very high (long/many)
wins for long \(N\)
…
Note the Q15 overflow row exists only on the STM32 line — float64 on Pi/Jetson erases it.
Same STM32: bare-metal vs RTOS
The runtime axis has a middle rung worth measuring on the MCU itself: run the FIR under FreeRTOS and compare its per-block timing against the bare-metal build. This is where you feel what a scheduler costs on a block-streaming filter, and it sets up the pipelined architecture of Lab 7.2.
Bare-metal (above): the DMA half/full-transfer callback runs arm_fir_f32 on the freshly-filled half inline, in ISR context — the block is filtered before the callback returns, with nothing between acquisition and the MAC loop.
FreeRTOS (C): turn the DMA callback into a fast signal and move the filter into a prioritized DSP task. HAL_ADC_ConvHalfCpltCallback / ...ConvCpltCallback now only does osSemaphoreRelease(sem) (or queues the ready-buffer index with osMessageQueuePut) and returns; a high-priority task blocks on osSemaphoreAcquire(sem, osWaitForever), runs arm_fir_f32, and hands the output to a separate DAC/output task. Setup: enable FREERTOS → CMSIS_V2 and move the HAL timebase to a spare timer (TIM17) per the setup essentials, then osSemaphoreNew / osThreadNew. You pay one context switch per block, but the ISR is now short and acquisition / filtering / output become independently prioritized tasks.
Rust (RTIC / Embassy): in RTIC, bind the DMA-complete IRQ as a hardware task that spawns (or signals) a lower-priority software task carrying the buffer index; the software task runs the MAC loop. In Embassy, an async task awaits the DMA-transfer future and filters on wake. Same ISR→task deferral, statically scheduled.
What you’ll see: because the FIR’s per-block MAC work dwarfs the ~few-µs context switch, the RTOS overhead is a small fraction of the block budget — the RTOS is affordable here. The real payoff is structural: the filter task is cleanly separated from acquisition and output, which is exactly the Lab 7.2 pipeline. Measure the added per-block latency/jitter with the DWT counter and confirm the deadline margin barely moves.
Build (same STM32)
Per-block latency/jitter added
Deadline margin
Structural benefit
Measured
Bare-metal, inline in DMA callback
none (filters in ISR)
full
monolithic ISR
…
FreeRTOS, DMA→semaphore→DSP task
+ one context switch (small vs MAC work)
slightly reduced
acquisition / filter / output as separate prioritized tasks
…
Rust RTIC, DMA hw task→sw task
≈ FreeRTOS
slightly reduced
compile-time-checked task priorities
…
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; Lyons Ch. 5).
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.