Build the reusable pipe that the rest of Module 9 depends on: a framed binary block protocol that streams a real media file from the laptop to the embedded target and back. In this first lab the device does nothing but echo blocks unmodified (a loopback), so the only thing under test is the transport — framing, integrity, ordering, and flow control. The host reads a WAV file, chops it into fixed-size blocks, ships them over the STM32’s USB CDC virtual COM port, receives the echoes, reassembles an output WAV, and asserts it is bit-exact to the input. Getting a lossless, back-pressured, self-checking link working first is exactly how real embedded media-DSP firmware is developed: you prove the plumbing with an identity transform before you trust any DSP result that flows through it. This harness is reused verbatim in Labs 9.2–9.4.
Recommended reading
Kuo Ch. 1 — real-time DSP system architecture: I/O, double-buffering, DMA, and the block-processing model that underlies everything in this module. Read the sections on real-time I/O and buffering closely.
Lyons Ch. 1 — discrete sequences and sampling: what an int16 PCM sample is, sample rate, and why a WAV file is just a uniformly-sampled sequence.
The STM32 reference-manual sections on USART with DMA and the USB device (CDC) class, plus the Nucleo VCP wiring (USART2 on PA2/PA3 routed to ST-LINK). See the syllabus hardware notes.
No new Course 1 dependency; this is a transport/systems lab.
Equipment & parts
STM32 Nucleo-L476RG and its USB cable. Important wiring note: the board’s onboard Micro-B connector goes to the ST-LINK, which exposes only the USART2 virtual COM port (PA2/PA3) — it is not the STM32’s native USB. The L476RG’s own USB FS device is on PA11/PA12, which are not routed to that connector, so “USB CDC” on a Nucleo-64 requires wiring PA11 (DM)/PA12 (DP) to a separate micro-USB breakout and a second cable. For Labs 9.1–9.4 the USART2 VCP (up to ~921600 baud) is enough; only reach for native USB CDC if you actually need its higher throughput.
Host: M-series Mac, Python venv with numpy scipy pyserial (already installed per the syllabus toolchain).
A short reference WAV file — e.g. 2–5 s of 16-bit mono PCM at 44.1 kHz or 48 kHz. Any real recording works; keep the first one small.
No breadboard, no analog parts. This is a data lab.
Wiring & bench setup
No breadboard and no instruments — the whole hookup is one USB cable, and the “signal chain” is a file making a round trip through it:
one cable: power + USART2 VCP, /dev/tty.usbmodem* at the baud set below
PA11 (USB DM) / PA12 (USB DP) + GND
separate micro-USB breakout D− / D+ / GND
optional native-USB-CDC path only (see Equipment) — not needed for Labs 9.1–9.4
Labs 9.2–9.4 use this same single-cable hookup unchanged.
Safety & don’t-break-it
This lab has almost no electrical hazard — the only power is USB 5 V through the Nucleo’s regulators, and the STM32 stays in its own 3.3 V world. The failure modes here are software, and they are just as capable of wasting a day:
Buffer overrun is the real hazard. If the host sends faster than the device drains its RX buffer, DMA silently overwrites unprocessed bytes and you get corrupted or dropped blocks that look like a DSP bug later. The flow-control handshake below exists to prevent exactly this — do not skip it.
Respect the DMA double-buffer boundaries. Never let the CPU read the half of the buffer the DMA is currently writing. Use the half-transfer + transfer-complete interrupts to hand off ownership cleanly; a torn read is a Heisenbug.
Bound every allocation. Fixed block size, fixed number of buffers, no malloc in the ISR path. Reject any frame whose declared length exceeds your block size instead of trusting it — a bad length field must never index past your buffer.
Match the VCP baud and line settings on both ends. A silent baud mismatch produces framing errors that masquerade as CRC failures.
Standard bench note only: keep the 3.3 V and 5 V domains straight; the Nucleo handles this internally over USB, so there is nothing to mis-wire here.
Project & environment setup
Firmware — create the Module 9 project (firmware/m9-media/; CubeMX → board NUCLEO-L476RG → Toolchain/IDE = CMake, per the project workflow). Labs 9.1–9.4 share this one project; this lab creates it:
CubeMX page
Setting
Connectivity → USART2
Asynchronous, 921600 8-N-1 (PA2/PA3 → ST-LINK VCP; the host script must open the same baud)
DMA (USART2 requests)
RX = Circular (feeds the double buffer), TX = Normal
NVIC
USART2 global interrupt (IDLE-line = frame boundary) + both DMA channel IRQs enabled
only if you wired PA11/PA12 to a breakout (see Wiring)
Host — course venv (see Toolchain). This lab also fixes the module’s media-file convention, used through Lab 9.6: inputs under media/in/, device-returned outputs under media/out/:
source venv/bin/activate # pyserial (the link), numpy (framing + bit-exact compare),# scipy — scipy.io.wavfile for WAV read/write (soundfile also works)mkdir-p labs/lab-9-1/host labs/lab-9-1/captures media/in media/out
Write the streamer as a reusable module, labs/lab-9-1/host/harness.py (open port, frame + CRC-16, credit window, WAV I/O via scipy.io.wavfile.read/write — you write it; Labs 9.2–9.4 import it unchanged). The snippets’ in.wav / out.wav are media/in/ref.wav / media/out/loopback.wav.
Keep this lab’s reconciliation in labs/lab-9-1/host/analysis.ipynb — the notebook convention — and export final figures next to it.
A WAV file’s data chunk is just a sampled sequence \(x[n]\) of 16-bit signed integers. Streaming it losslessly means the reassembled sequence \(y[n]\) must satisfy
\[y[n] = x[n] \quad \text{for all } n,\]
i.e. bit-exact equality, not “close.” Any single dropped, duplicated, reordered, or corrupted byte breaks this, which makes the identity round-trip a strong test of the transport.
Framing. A raw byte stream has no message boundaries, so we impose them. Each block is sent as a self-describing frame:
| SOF (0x55AA) | seq (u16) | len (u16) | payload[len] | CRC-16 (u16) |
seq — a monotonic sequence number so the host can detect a dropped or reordered block.
len — payload length in bytes (bounded by the agreed block size), so the receiver knows exactly how many bytes to expect.
CRC-16 (CCITT) over seq‖len‖payload — catches corruption. With a good CRC the residual undetected-error probability is \(\approx 2^{-16}\) per bad frame, which for a clean USB link means effectively never, but the check is what lets you trust the bit-exact assertion.
Flow control. The device sends a one-byte ACK (or a credit count) after it has finished draining a frame into its double buffer. The host waits for outstanding credits before sending the next block. This is back-pressure: it bounds how far ahead the host can run to exactly the buffer depth, so the device’s RX DMA never overruns regardless of host speed.
Throughput. With payload size \(B\) bytes per frame and framing/ACK overhead \(H\) bytes per frame, the useful (goodput) fraction is
\[\eta = \frac{B}{B + H},\]
and the sustained data rate is bounded by the link. USB CDC on full-speed USB has a raw ceiling far above a UART VCP; a USART2 VCP at baud \(R\) (8N1) carries at most
\[\text{bytes/s} \le \frac{R}{10}\]
(10 bits on the wire per 8 data bits). Measuring achieved bytes/s against this ceiling tells you how much the framing, ACK round-trips, and firmware copy loop cost you.
Procedure
Part A — Define the frame and the firmware loopback.
Fix a block size, e.g. BLOCK = 512 bytes (256 int16 samples). Define the frame struct above with a CRC-16-CCITT.
On the STM32, configure the transport. Two options — pick one and keep it for the whole module:
USART2 → ST-LINK VCP with DMA RX (circular, into a double buffer) and DMA TX. Simple, ~921600 baud realistic, uses the cable you already have. Recommended for this module.
USB CDC (the L476RG USB FS device on PA11/PA12) for much higher throughput — but remember (Equipment) that this needs a separate micro-USB breakout wired to PA11/PA12; the ST-LINK connector will not do native USB. More setup; only worth it if the VCP throughput is actually the limit.
Illustrative RX side (HAL/LL, structure only — you write the real thing):
/* Double buffer: DMA fills one half while the app drains the other. */staticuint8_t rx_buf[2][BLOCK + FRAME_OVERHEAD];staticvolatileuint8_t ready[2];/* On frame complete (IDLE-line + DMA, or USB CDC RxCplt): */void on_frame(uint8_t*f,uint16_t nbytes){ frame_t *fr =(frame_t *)f;if(fr->len > BLOCK)return nak(fr->seq);// reject over-lengthif(crc16(f +2, fr->len +4)!= fr->crc)return nak(fr->seq);/* loopback: echo the exact same payload back with the same seq */ tx_frame(fr->seq, fr->payload, fr->len); ack(fr->seq);// credit the host}
Keep the ISR short: validate, hand the buffer to the TX path, send one ACK. Do the work in a main-loop consumer if the transport needs it.
Part B — Host streamer.
Read the WAV and frame it. Illustrative host code (structure only):
import numpy as np, serial, structfrom scipy.io import wavfilefs, x = wavfile.read("in.wav") # x: int16 PCM, monoassert x.dtype == np.int16payload = x.tobytes()BLOCK =512ser = serial.Serial("/dev/tty.usbmodemXXXX", 921600, timeout=1)def frame(seq, data): body = struct.pack("<HH", seq, len(data)) + datareturnb"\x55\xAA"+ body + struct.pack("<H", crc16(body))out =bytearray()credits = DEPTH # flow-control windowfor seq, off inenumerate(range(0, len(payload), BLOCK)):while credits ==0: credits += drain_acks(ser) ser.write(frame(seq, payload[off:off+BLOCK])); credits -=1 out += read_echo(ser) # verify seq + CRC on the way back
Reassemble out into int16 and write out.wav with the same fs.
If it fails, print the first differing sample index and the seq numbers seen — that localizes a drop vs. a corruption vs. a reorder.
Part D — Throughput.
Time the whole transfer; compute achieved bytes/s = total payload bytes / elapsed. Repeat with DEPTH = 1 (stop-and-wait) vs. a larger credit window to see flow control’s effect on throughput.
Deliverable & expected results
out.wav, proven np.array_equal(y, x) — a green bit-exact round-trip.
A throughput number vs. the link ceiling, and a note on how the credit window changed it.
For a USART2 VCP at 921600 baud, 8N1, BLOCK = 512, frame overhead \(H = 8\) bytes (SOF 2 + seq 2 + len 2 + CRC 2), plus a 1-byte ACK per frame:
(For USB CDC the ceiling is set by the CDC bulk endpoints, not baud; predict from measured pyserial write throughput and expect the goodput fraction to dominate.)
Analysis & reconciliation
If bit-exactness holds, the transport is trustworthy and every later lab’s “vs. reference” comparison is meaningful. If throughput falls well short of ceiling·\(\eta\), the usual culprits are: stop-and-wait ACKs (each frame pays a full RTT — widen the credit window), per-byte host writes (batch the frame into one write), or a firmware copy that blocks the RX DMA (drain in the main loop, not the ISR). Reconcile the achieved bytes/s against \(R/10\cdot\eta\); the gap is the RTT and copy cost. Confirm the flow-control window is doing its job by temporarily removing it and watching the bit-exact assertion fail under overrun — then put it back.
Cross-platform ports & language variants
See the syllabus Implementation tracks. The harness is the module’s plumbing, so its port defines how every later lab reaches the SBCs: same frames, same CRC, same bit-exact assertion — different byte pipe.
Jetson Orin Nano — detailed procedure (embedded Linux)
Make the Jetson a second device under the identical protocol, so Labs 9.2–9.4 can target either device by changing one constructor argument. The byte transport becomes a TCP socket over the LAN (the transport Lab 9.5 standardizes on — prefer wired Ethernet); the framing, sequencing, CRC, and credits ride on top unchanged. Board config: Jetson setup essentials.
Refactor for it once: split harness.py’s byte transport behind a tiny interface — SerialTransport (pyserial, the STM32 path) and TcpTransport (a socket to jetson:PORT) with the same read/write surface. The framing/CRC/credit logic must not know which is underneath; that separation is the actual lesson of this port.
Device-side echo server on the Jetson (labs/lab-9-1/edge/): a listener that accepts one connection and echoes valid frames back with the same seq, NAKing bad CRCs — the same on_frame contract as the firmware, in userspace (Python first; the C++ port of the server slots into the same edge/ CMake project when 9.4-scale throughput wants it). Structure only — write your own; note TCP_NODELAY, or Nagle’s algorithm will batch your small ACK frames and wreck the RTT.
Run the same Part B–C round-trip against the Jetson: same WAV, same BLOCK, output to media/out/loopback-jetson.wav, same np.array_equal assertion. Bit-exact is bit-exact on every transport.
Throughput: repeat Part D. Predict the ceiling from the link (GbE is ~10⁴× the VCP’s 92 kB/s ceiling; even Wi-Fi dwarfs it) and measure the achieved goodput and per-frame RTT histogram. The interesting reconciliation flips: on the STM32 the wire was the bottleneck; here it’s RTTs and syscalls — widen the credit window and watch goodput scale until it saturates something else.
Flow control still earns its keep: TCP already guarantees ordering and delivery, so deliberately note which harness features became redundant (drop/reorder detection) and which did not (CRC end-to-end catches app-layer corruption; credits bound device memory). One paragraph in notes.md.
(Optional, literal-serial variant) With a USB-UART adapter into header pins 8/10 (/dev/ttyTHS*, per the Lab 2.3 Jetson procedure), the unmodifiedSerialTransport talks to the Jetson too — same baud math, same \(R/10\) ceiling, a true apples-to-apples serial comparison if you have the adapter on hand.
Raspberry Pi 5 differences: none that matter — same sockets, same scripts; the Pi is just another hostname.
Going further
Inject a deliberate bit-flip in one echoed frame on the device and confirm the host’s CRC check catches it (assertion should localize the bad seq).
Add a tiny stereo WAV path (interleaved L/R int16) and confirm the round-trip still holds — this is what Lab 9.2 filters per channel.
Port the transport from USART2 VCP to USB CDC and re-measure throughput; keep both behind the same host API so 9.2–9.5 don’t care which is underneath.
Log per-frame RTT and plot the histogram — you’ll reuse this latency tooling for the video pipeline in Lab 9.5.