Course 2 — Embedded DSP: From the Bench to Real-Time Firmware

A fully hands-on lab course for DSP embedded software / firmware engineering

A lab-first course that takes someone who already has the mathematics (see the prerequisite below) and turns them into an engineer who can design, measure, and ship real-time digital signal processing on embedded hardware. Every idea is learned by building it on a breadboard, driving it from a microcontroller, and measuring it with real instruments, then reconciling the measurement against theory. The target is fluency for DSP embedded software / firmware engineering roles: sampling and quantization, mixed-signal I/O, FIR/IIR filters and the FFT running in real time on a Cortex-M, robust interrupt/DMA firmware, and, as a modern differentiator, on-device ML signal processing on edge accelerators.

Prerequisite: Course 1 — Mathematical & Theoretical Foundations is assumed as mastered. The linear algebra, real & complex analysis, probability, convex optimization, and distribution-theory/Fourier material there is applied here, never re-taught. Where a lab leans on a specific Course 1 result, it links back to the relevant section.

Note on AI use: The lab write-ups on this site are drafted with AI assistance so every lab has the same structure to work from. The substance is mine: every circuit is built by hand, every measurement is taken on my own bench, every line of firmware and analysis code is written and debugged by me, and every predicted-vs-measured reconciliation is my own. AI is used only to typeset the notes and keep the format consistent. You only learn this by building it.


How each lab is structured

Every lab file follows the same template so it can be worked as a self-contained bench session:

  1. Goal: what skill/concept the lab builds and why it matters for the role.
  2. Recommended reading: the specific chapters (from the books below and, where relevant, Course 1) to read before touching the bench.
  3. Equipment & parts: exactly which instruments and components from the inventory.
  4. Safety & don’t-break-it: the specific ways this setup can hurt you or destroy a part, and how to avoid them. Read this every time.
  5. Background: the minimum theory, with the key equations.
  6. Procedure: numbered, click-by-click steps including correct instrument operation.
  7. Deliverable & expected results: the capture/plot/measurement to produce and the numbers to expect.
  8. Analysis & reconciliation: predict by hand, then explain any gap between predicted and measured.
  9. Going further: optional extensions.

Bench inventory (already on hand)

Instruments

  • WANPTEK 30 V / 10 A regulated DC bench power supply — adjustable voltage with a settable current limit (OCP). The primary DC source for every powered lab.
  • Fluke 117 true-RMS digital multimeter — DC/AC volts, resistance, continuity (beeper), capacitance, frequency, and non-contact AC voltage (VoltAlert).
  • Siglent SDS1104X-E — 100 MHz, 4-channel, 1 GSa/s digital oscilloscope with FFT math, automatic measurements, and CSV export. Ships with 10× passive probes and a ~1 kHz probe-compensation output.
  • FNIRSI LC1020E LCR meter — R, L, C, ESR, and Q/D/θ at selectable test frequencies (100 Hz / 1 kHz / 10 kHz / 100 kHz).
  • Saleae Logic 8 — 8-channel USB logic analyzer (Logic 2 software) with built-in protocol decoders (I²C, SPI, UART) for timing and bus analysis.

Boards & mixed-signal parts

  • STM32 Nucleo-64 (NUCLEO-L476RG) — STM32L476RG Cortex-M4F @ 80 MHz with FPU, 12-bit ADC & DAC, timers, DMA, on-board ST-LINK debugger and USB virtual COM port. The real-time DSP target.
  • MCP6002 dual rail-to-rail op-amp, DIP-8 (1 MHz GBW, single 1.8–6 V supply).
  • MCP4725 12-bit I²C DAC breakout (rail-to-rail output, on-board EEPROM).
  • ADS1115 16-bit I²C ADC breakout (4 single-ended / 2 differential inputs, programmable gain, up to 860 SPS).
  • Coliao bi-directional I²C logic-level shifters (3.3 V ↔︎ 5 V).
  • Breadboards + jumper-wire kit + a 37-value / 480-piece R/C/component kit with an MB102 3.3 V/5 V power-supply module.

Edge-ML accelerators (bonus module)

  • Raspberry Pi 5 — quad-core CPU host for on-device inference and comparison.
  • Jetson Orin Nano — CUDA GPU edge accelerator for real-time / learned signal processing.
  • Audio input for Modules 8–9 — a live-audio front end is needed once (Labs 8.1–8.4): a USB microphone (simplest, plug-and-play on the Pi/Jetson), an I²S MEMS mic (e.g. INMP441/SPH0645) for a lower-level path, or the ADS1115 as a line-in for a mic-preamp/analog source. Labs 9.1–9.5 stream from files, so no mic is required there.
  • Optional for Lab 8.4 (vibration anomaly) — an IMU (BNO055 / ICM-20948, I²C) or a piezo disc; both are optional, and the mic “audio proxy” path is the simplest way to run that lab without them.

Global bench safety & instrument-care primer

These rules apply to every lab; individual labs add their own. Read them once, carefully, before Module 0.

  • Set the power-supply current limit first. On the WANPTEK, before connecting anything: set the target voltage, briefly short the leads (or turn the current knob into CC), and set a sane current limit (start at ~100 mA for small breadboard circuits). The current limit is what protects your parts when you wire something wrong: it turns “released magic smoke” into “supply goes into constant-current and nothing happens.”
  • The DMM current jacks are a dead short. The Fluke’s A input is a near-zero-ohm path. Never put the meter in current mode across a voltage source or in parallel with a component: you will blow the fuse or worse. Measure current only in series, and default the dial to volts when you’re done.
  • Never measure resistance, capacitance, or continuity on a powered circuit. Ω/continuity/capacitance modes source their own small current and assume the circuit is de-energized. Power down and, for capacitors, discharge before measuring.
  • Respect the 3.3 V world. The STM32 ADC/GPIO pins are not 5 V tolerant on analog inputs: never drive an STM32 input above 3.3 V (V_DDA). Use the level shifter or a divider when crossing between the 5 V and 3.3 V domains.
  • Never exceed a chip’s supply on its inputs. The ADS1115 and MCP6002 inputs must stay within roughly (V−0.3 V) to (V++0.3 V). Clamp, divide, or bias signals into range before they reach an input pin.
  • Common grounds. Every instrument and board sharing a signal must share a ground. The scope probe ground clip is earth-referenced: clip it only to your circuit ground, never to a node at a different potential.
  • Discharge capacitors (especially electrolytics) before handling or LCR-measuring them. Observe electrolytic polarity: reversed or over-volted electrolytics can vent.
  • ESD care with the bare DIP chips (MCP6002): handle by the body, ground yourself, and insert/remove only with power off.
  • Double-check polarity and pin 1 on every IC and breakout before applying power. One reversed rail usually kills the part.

Toolchain & software setup

# --- Host (M-series Mac): analysis, plotting, instrument scripting ---
python3 -m venv venv && source venv/bin/activate
pip install numpy scipy matplotlib jupyter pyserial

# --- STM32 firmware ---
# STM32CubeIDE (free, ST) — HAL/LL drivers, on-board ST-LINK debug, SWV trace.
# Serial console over the Nucleo's ST-LINK virtual COM port (USART2 @ PA2/PA3).

# --- Logic analyzer ---
# Saleae Logic 2 (free) — capture + I2C/UART/SPI protocol decoders.

# --- Edge-ML boxes (bonus Module 8) ---
# Raspberry Pi 5: numpy/scipy/sounddevice + onnxruntime / tflite-runtime.
# Jetson Orin Nano: JetPack (CUDA/cuDNN/TensorRT) + PyTorch, CuPy.

Optional but recommended “third opinion” for the analog labs: predict by hand → simulate in LTspice (free, runs natively on Apple silicon) → measure on the bench → reconcile the three numbers.

STM32 firmware setup essentials (do these once)

Three settings recur in every Module 5–7 lab; get them right once and the per-lab predicted numbers line up:

  • 80 MHz system clock. New CubeMX projects often boot on the 4 MHz MSI / 16 MHz HSI. In Clock Configuration, drive the PLL from HSI16 (or MSI), select PLLCLK on the System Clock Mux, and target HCLK = 80 MHz (the L476’s maximum); CubeMX sets the flash wait-states automatically. Every cycle-budget and sample-rate figure in Modules 5–7 assumes 80 MHz.

  • CMSIS-DSP library. Modules 6 and 9 call arm_fir_f32, arm_rfft_fast_f32, arm_biquad_cascade_df1_*, etc. Enable it once — in the .ioc Software Packs → Select Components, add ARM CMSIS-DSP (or add the pack and link libarm_cortexM4lf_math), define ARM_MATH_CM4 and __FPU_PRESENT=1, and build hard-float (-mfpu=fpv4-sp-d16 -mfloat-abi=hard). Without this the DSP calls won’t link — the single most common Module 6 stall.

  • DWT cycle counter for timing. A GPIO toggle + Saleae is fine for millisecond-scale block timing, but per-sample DSP costs are sub-µs, below the Saleae’s ~10 ns resolution floor. For cycle-accurate numbers use the Cortex-M DWT counter, and keep the GPIO only as a coarse scope cross-check:

    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;        // enable trace
    DWT->CYCCNT = 0;  DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // start cycle counter
    uint32_t t0 = DWT->CYCCNT;   /* ... code under test ... */
    uint32_t cycles = DWT->CYCCNT - t0;                    // / 80e6 → seconds
  • FreeRTOS (for the RTOS-variant labs). Several labs offer an RTOS build alongside the bare-metal one, to measure what the scheduler costs. Enable it once in CubeMX: Pinout & Configuration → Middleware → FREERTOS → Interface = CMSIS_V2, which pulls in the kernel and generates MX_FREERTOS_Init(). Create tasks with osThreadNew, and for the ISR/DMA→task handoff use a binary semaphore (osSemaphoreNew) or a message queue (osMessageQueueNew): the ISR signals via osSemaphoreRelease / osMessageQueuePut and the task blocks on osSemaphoreAcquire / osMessageQueueGet. Critical gotcha: FreeRTOS must own SysTick, so in SYS set the HAL Timebase Source to a spare basic timer (e.g. TIM17), not SysTick — otherwise HAL and the scheduler fight over it and time bases drift. Also set configCHECK_FOR_STACK_OVERFLOW = 2 while developing. The Rust equivalents (RTIC, Embassy) are in the Rust-on-STM32 block below.

Optional: Rust on the STM32 (second implementation track)

Many of the firmware labs offer a Rust build alongside the C/HAL one — same peripheral, same measurement, different language and memory-safety model. One-time setup:

rustup target add thumbv7em-none-eabihf     # Cortex-M4F, hard-float
cargo install probe-rs-tools                 # flash + RTT/defmt logging over the on-board ST-LINK
# Cargo.toml deps: cortex-m, cortex-m-rt, panic-probe, defmt-rtt,
#   stm32l4xx-hal (implements the embedded-hal traits), and ONE concurrency model:
#   - rtic         (interrupt-driven, static-priority tasks — the "bare-metal RTOS")
#   - embassy-stm32 + embassy-executor  (async/await, RTOS-style)

probe-rs run flashes and streams logs over the same ST-LINK you already use. The embedded-hal traits expose the same ADC/DAC/I²C/timer operations as the C HAL; RTIC or Embassy stand in for FreeRTOS in the concurrency labs. Rust’s ownership model turns several classic embedded bugs (the Q15 aliasing in Lab 6.1, the torn DMA double-buffer read in Lab 9.1, the priority-inversion data race in Lab 7.2) into compile-time errors — comparing where that helps and where it just adds ceremony is itself part of the lesson.

Linux SBC setup (Pi 5 / Jetson — the OS side of the comparison)

Beyond the Module 8 ML stack, the ports in Modules 2/3/6/7 need userspace I²C/GPIO and (for the timing labs) a real-time kernel:

# I2C / GPIO userspace access (both boards):
sudo apt install i2c-tools libgpiod-dev python3-libgpiod   # i2cdetect, gpioset/gpioget
# Python: smbus2 (I2C), gpiod, numpy/scipy
# Rust on the SBC:  rustup default stable
#   crates: rppal (Pi 5 GPIO/I2C/SPI/PWM) or linux-embedded-hal (portable embedded-hal on Linux),
#           plus plain Rust for the DSP kernels
# Real-time comparison labs:  a PREEMPT_RT kernel + rt-tests (cyclictest) to
#   measure scheduler latency against the MCU's deterministic ISR timing

Implementation tracks & cross-platform comparison

A central theme of this course is that the same signal-processing idea behaves differently depending on what it runs on. Many labs are therefore worked along three independent axes, and the value is in measuring the differences — latency, jitter, throughput, determinism, and how the code itself has to change:

  • Runtime — bare-metal → RTOS → Linux. The first implementation runs on the STM32 with no operating system (a while(1) loop or an interrupt/DMA-driven pipeline). The second adds an RTOS (FreeRTOS in C, or RTIC/Embassy in Rust) for scheduled, prioritized tasks. The third runs the same algorithm as a Linux userspace program on a Raspberry Pi 5 / Jetson, on top of a full preemptive scheduler. Each step trades determinism for convenience and throughput; you quantify exactly what.
  • Language — C/C++ ↔︎ Rust. Where a lab writes firmware in C with the ST HAL/LL, it can also be written in Rust (embedded-hal + stm32l4xx-hal, RTIC or Embassy) — and on the Linux side in Rust with rppal/linux-embedded-hal. Same peripheral, same measurement; compare ergonomics, generated-code size, and which bugs the compiler catches. (Setup for both is in the toolchain section above.)
  • Target — STM32 (Cortex-M4F) ↔︎ Pi 5 (Cortex-A76 CPU) ↔︎ Jetson Orin Nano (CUDA GPU). The MCU owns the deterministic real-time front end; the SBCs own throughput and learned models. Not every lab maps to every target (the Pi/Jetson have no on-chip ADC/DAC and no analog front end, so the bench/analog labs stay MCU-only), but wherever it does, the comparison is explicit.

Which algorithms favor which platform. A recurring result, made concrete in Module 6 and formalized in the benchmark of Lab 8.5:

Class Examples Wins on Why
Sequential / feedback / latency-bound IIR biquad (6.2), Goertzel (6.5), Kalman (6.6), per-sample control STM32 bare-metal Tight recurrences don’t parallelize; a bounded, jitter-free per-sample loop is exactly what an MCU gives and an OS scheduler erodes. Fixed-point/overflow discipline only exists here.
Block / batched / throughput-bound FFT (6.3), Welch PSD (6.4), long FIR (6.1), STFT (9.3) Jetson GPU / Pi O(N log N) batched transforms and large blocks amortize kernel-launch and host↔︎device copy; float64 removes the quantization worries.
Streaming with a hard deadline timer-triggered ADC+DMA (5.x), real-time filter (6.1), RTOS pipeline (7.2) STM32 (RTOS if multi-task) Deterministic sampling instant and bounded worst-case latency; Linux userspace jitters unless PREEMPT_RT, and even then loses to the MCU’s p99.
Learned / data-driven keyword CNN (8.2), denoiser (8.3), anomaly AE (8.4), video CNN (9.5) Jetson (TensorRT) / Pi Needs the memory, the accelerators, and a training pipeline the MCU can’t host — the modern differentiator.

Labs that carry a Cross-platform ports & language variants section spell out, for that specific algorithm, how the port is written on each target and what the measured latency/jitter/throughput gap teaches.


Module & lab map

The course is 8 core modules plus two bonus modules (edge ML, and host-in-the-loop audio/image/video). Each lab is its own page; work them in order.

Module 0 · Bench setup & safety

Learn to power, measure, and not destroy things, before any active circuit.

Module 1 · Oscilloscope & first analog signals

Module 2 · Digital I/O & timing

Module 3 · Mixed-signal I/O: I²C, DAC, ADC

Module 4 · Op-amps & analog signal conditioning

Module 5 · STM32 data acquisition

Module 6 · Real-time DSP on the STM32

Module 7 · Robust real-time firmware

Bonus Module 8 · Edge ML signal processing (Pi 5 + Jetson Orin Nano)

The modern differentiator: take the same signals and run learned DSP on edge accelerators, then compare against the classical STM32 pipeline.

Bonus Module 9 · Host-in-the-loop audio, image & video processing

Real media, real files, verifiable results. The laptop streams a real audio/image/video file to the embedded target over a framed serial/USB link; the device processes it in real time (block by block); the result is streamed back and the host writes the processed file and checks it against a reference (SciPy/OpenCV), a reproducible, testable workflow that mirrors how embedded media-DSP firmware is actually developed and regression-tested. Covers audio, image, and video signal processing.


Repository structure

embedded-dsp-course/
  README.md
  docs/                       # staff-level lab notes, predicted-vs-measured write-ups
  firmware/                   # STM32CubeIDE projects, one per module
    m5-daq/  m6-dsp/  m7-rtos/
  host/                       # Python analysis, plotting, serial capture
  captures/                   # Siglent CSVs, Saleae .sal sessions, LCR logs
  edge/                       # Module 8: Pi 5 + Jetson inference code, models
  media/                      # Module 9: host streamer, reference impls, in/out WAV/PNG/MP4
  hardware/                   # breadboard photos, schematics, datasheets

What you’ll be able to do at the end

  • Operate a full bench (PSU, DMM, LCR meter, oscilloscope, logic analyzer) safely and correctly, and trust your measurements.
  • Design and measure analog signal conditioning (buffers, gain, active anti-alias filters) around real op-amps.
  • Bring up mixed-signal I/O (I²C DAC/ADC, level shifting) and reason about quantization, references, and full-scale range.
  • Write interrupt- and DMA-driven real-time acquisition firmware on a Cortex-M4F and prove its timing with a logic analyzer.
  • Implement and validate FIR/IIR filters, the FFT, PSD/noise-floor estimation, and Goertzel tone detection in real time, including fixed-point effects.
  • Build robust firmware (watchdog, fault handling, an RTOS pipeline) and ship an end-to-end capture → process → output system.
  • Deploy and benchmark learned, on-device signal processing on edge GPU/CPU accelerators against the classical embedded pipeline.
  • Process real audio, image, and video on an embedded target with a host-in-the-loop workflow: stream a file from the laptop, process it on-device in real time, return it, and verify the result against a reference implementation.

Relationship to Course 1

Course 1 is the theory this course spends. A few of the tightest links:

  • Sampling & aliasing (Modules 3, 5) apply Course 1 Section 10 (distributions, the Dirac comb, and the Fourier transform of tempered distributions), the rigorous reason the sampling theorem is true.
  • FIR/IIR filters and the z-transform (Module 6) apply Course 1 Section 9 (complex analysis: poles, the region of convergence, stability from pole locations).
  • PSD, noise floor, and adaptive/learned processing (Modules 6, 8) apply Course 1 Sections 5–6 (probability, expectation, limit theorems) and Sections 7–8 (convex optimization: the training objective behind the edge-ML models).
  • Least-squares filter design and calibration apply Course 1 Sections 1–3 (linear algebra, QR, conditioning).