A complete software-defined-radio receive chain in SystemVerilog: NCO + mixer, CIC and FIR decimation, and FM demodulation - verified bit-exactly against a golden Python model, end to end.
The chain reuses the pipelined CORDIC from cordic-core at both ends, and both times it replaces a textbook block with something smaller:
- Mixer: rotation-mode CORDIC rotating each sample by −φ is the complex multiply by e^−jφ - NCO sin/cos table and four mixer multipliers collapse into one shift-add pipeline. The 32-bit phase accumulator gives fs/2^32 tuning resolution and advances only on accepted samples, so stream gaps never slip the LO.
- Demodulator: vectoring-mode CORDIC returns atan2(Q,I) in BAM units, where ±π wraps in plain 16-bit arithmetic - so FM demodulation (the phase derivative) is a single subtract with no unwrap logic. The magnitude output comes free and is exposed as RSSI.
Between them: an N=3 Hogenauer CIC (÷16, 28-bit wraparound arithmetic per the width bound, truncation of the exact 2^12 gain) and a 31-tap transposed FIR channel filter (÷2, round-half-up, saturating).
Everything below is produced by the bit-exact model - i.e. this is the RTL output, not a floating-point idealization:
python tools/gen_demo_plot.py
# -> group delay 8 output samples, demod-vs-sent rms 311.5 BAM
# (3.0% of the 10240 BAM deviation)ddc_mixer 400 random IQ samples, randomly tuned NCO - bit-exact,
including the phase-accumulator track through stream gaps
fir_decim 600 random samples with full-scale bursts - bit-exact
through the elastic pipeline, saturation and drain
cic_decim 640 full-scale samples with bursts at the rails - bit-exact
through the wraparound arithmetic
fm_demod wandering-frequency phasor across every phase wrap -
freq_out and mag_out bit-exact
fm_receiver 4096-sample FM signal through the whole chain - 128
outputs bit-exact, spectral peak on the message tone,
deviation amplitude within 15%, DC ~0 after exact tuning
TESTS=5 PASS=5 x three seeds (icarus, cocotb 2.0)
lint: verilator -Wall clean
All streams run under random input gaps and output backpressure with a per-cycle stability watcher; every stage is valid/ready and drains without new input.
Vivado out-of-context, Zynq-7020, 156.25 MHz, whole receiver:
| LUTs | FFs | DSPs | BRAM | WNS @156.25 MHz |
|---|---|---|---|---|
| 4422 | 4343 | 30 | 0 | +0.446 ns |
The first synthesis pass failed timing at −3.5 ns and the failure is
worth reading about: an unregistered x * coef + t cone that passes
OOC timing in isolation (where the input arrives from an untimed port)
does not survive being fed from a real upstream register. The FIR here
registers its products (DSP MREG) and folds the rounding constant into
the accumulation chain - see the header of
rtl/fir_decim.sv.
python tb/run.py # full regression (or: tb/run.py cic_decim)
python tools/gen_coeffs.py # regenerate taps -> svh + json + figure
python tools/gen_demo_plot.py # regenerate the demo figuref_lo = phase_inc / 2^32 · fs; freq_out is instantaneous frequency in BAM per output sample: f = freq_out / 65536 · fs_out.
MIT