Lab 5.3 — Fixed-Point NCO Mixer RTL¶
Goal¶
Implement a compact fixed-point NCO-based IQ mixer in RTL and verify it against deterministic Python-generated reference vectors.
This lab connects Block 4 fixed-point digital mixing with an executable Verilog implementation.
Executable HDL package¶
| File | Purpose |
|---|---|
blocks/block_05_fpga_hdl_flow/rtl/nco_mixer_iq.v |
executable Q1.15 NCO IQ mixer RTL block |
blocks/block_05_fpga_hdl_flow/tb/tb_nco_mixer_iq.v |
self-checking Verilog testbench |
blocks/block_05_fpga_hdl_flow/python/generate_nco_mixer_iq_vectors.py |
deterministic reference-vector generator |
blocks/block_05_fpga_hdl_flow/tb/nco_mixer_iq_input_vectors.txt |
generated input vectors |
blocks/block_05_fpga_hdl_flow/tb/nco_mixer_iq_expected_vectors.txt |
generated expected output vectors |
Run from the repository root:
python blocks/block_05_fpga_hdl_flow/python/generate_nco_mixer_iq_vectors.py
iverilog -g2012 \
-o blocks/block_05_fpga_hdl_flow/tb/tb_nco_mixer_iq.out \
blocks/block_05_fpga_hdl_flow/rtl/nco_mixer_iq.v \
blocks/block_05_fpga_hdl_flow/tb/tb_nco_mixer_iq.v
vvp blocks/block_05_fpga_hdl_flow/tb/tb_nco_mixer_iq.out
Expected result:
PASS: nco_mixer_iq test completed without errors
The GitHub Actions workflow .github/workflows/block5_hdl.yml generates vectors and runs this simulation automatically.
Engineering question¶
How does a fixed-point digital mixer become a clocked RTL block with an NCO, LUT, complex multiplier, rounding and saturation?
DSP equation¶
For complex input:
x[n] = I[n] + jQ[n]
w[n] = cos(phi[n]) + j sin(phi[n])
y[n] = x[n] * w[n]
The RTL implements:
y_i = I*cos - Q*sin
y_q = I*sin + Q*cos
NCO model¶
The educational RTL uses a compact phase accumulator:
phase[n+1] = phase[n] + phase_increment
For this lab:
PHASE_W = 4
LUT size = 16 samples
PHASE_INC = 1
This is intentionally small so students can inspect all LUT values and waveform transitions. A production SDR mixer should use a wider phase accumulator and a higher quality oscillator implementation.
Fixed-point formats¶
| Signal | Format | Comment |
|---|---|---|
| Input I/Q | Q1.15 | signed 16-bit samples |
| sin/cos LUT | Q1.15 | signed 16-bit oscillator values |
| Product | Q2.30 | multiplication result |
| Accumulator | 40-bit educational accumulator | safe for sum/difference |
| Output I/Q | Q1.15 | rounded and saturated |
RTL datapath¶
flowchart LR
PHASE[Phase accumulator] --> LUT[sin/cos LUT]
LUT --> CMUL[Complex multiplier]
IN[Input IQ] --> CMUL
CMUL --> ROUND[Round to Q1.15]
ROUND --> SAT[Saturate]
SAT --> OUT[Output IQ]
Testbench strategy¶
The testbench verifies:
- reset behaviour;
out_validalignment;- phase advance only on valid input;
- signed complex multiplication;
- rounding to Q1.15;
- saturation bounds;
- sample-by-sample agreement with Python reference vectors.
Scaling toward real SDR designs¶
| Educational lab | Production SDR version |
|---|---|
| 4-bit phase accumulator | 24–48 bit phase accumulator |
| 16-entry LUT | large LUT, interpolated LUT or CORDIC |
| no tready | AXI-Stream valid/ready |
| compact unpipelined multiplier | pipelined DSP-slice complex multiplier |
| vector testbench | constrained/random and file-based regression |
What to expect¶
PASS: nco_mixer_iq test completed without errors
The NCO uses a 16-entry sine table and PHASE_INC = 1, so the mixer rotates the input by +22.5 degrees per valid sample (a shift of +fs/16). The Python reference uses the same table, rounding and saturation, so the comparison is bit-exact.
The course runner generates the vectors, compiles, simulates and turns any FAIL line or non-zero simulator exit into an error:
python tools/run_block5_hdl_smoke.py --test tb_nco_mixer_iq
On the course FPGA (Vivado 2021.1 out-of-context synthesis, xc7z020clg400-2, 100 MHz; python tools/generate_block5_vivado_reports.py) this mixer takes 110 LUT, 37 FF and 4 DSP48E1, and misses 100 MHz: WNS -0.807 ns, 16 of 36 endpoints failing, 15 logic levels, an estimate of about 92.6 MHz. The LUT lookup, the complex multiply, the rounding and the saturation all happen in one clock, the same pattern as the FIR of Lab 5.2 (see its exercise 4 for what pipelining that pattern does). After placement and routing, with the port paths timed (report), it is WNS -0.361 ns (110 LUT, 43 FF, about 96.5 MHz): closer, because post-synthesis timing only estimates the wires, but still a miss.
Exercises¶
Each exercise below is a deliberate one-line RTL mutation. Make it, run the bench, read the messages, then restore the file (git checkout -- <file>). The quoted outputs were observed with Icarus Verilog 12.0.
- Conjugate the mixer (rotate the other way):
acc_i = I*cos + Q*sin,acc_q = -I*sin + Q*cos. The bench reports 8 errors with the Q sign flipped, for exampleout=(11087,-4592) expected=(11087,4592). This is the same wrong-sign error as in Lab 3.3: the signal lands at -fs/16 instead of +fs/16. - Replace rounding with truncation:
round_q15 = value >>> SHIFT;. The bench reports 8 errors, for exampleout=(11999,0) expected=(12000,0). Note that the first one happens at phase 0, where cos = 32767, not 32768: 12000 * 32767 / 32768 = 11999.63, which rounds to 12000 but truncates to 11999. Q1.15 cannot represent +1.0 exactly. - Estimate the spurious-free dynamic range of a 16-entry, 16-bit table and compare with the "about 6 dB per phase-address bit" rule measured in Lab 3.3. What would you change first to improve it: table length or table word width?
Answers (checked against the RTL and the bench)
- The observed output is in the exercise: the conjugate mixer moves the signal to −fs/16, as in Lab 3.3.
- The observed output is in the exercise. Note the cause: Q1.15 cannot represent +1.0, so
cos = 32767, and truncation instead of rounding turns 11 999.63 into 11 999. - In this RTL the phase accumulator is the 4-bit table address (
PHASE_W = 4,PHASE_INC = 1): the phase steps exactly through all 16 entries and nothing is ever truncated, so the only error is the 16-bit amplitude rounding; a 16-entry, 16-bit table stepped this way has spurs at about −100 dBc, the same result as theFs/16case of Lab 3.3. The price is frequency resolution: only multiples offs/16exist. Driven from a wider accumulator truncated to 4 address bits, the "6 dB per address bit" rule predicts about −24 dBc. To improve a general-purpose NCO, lengthen the table (more address bits) first; 16-bit words are already far below the phase-truncation spurs.
Report checklist¶
- State input, LUT, product, accumulator and output formats.
- Explain phase accumulator behaviour.
- Explain why the LUT is intentionally small.
- Run the testbench and record PASS output.
- Inspect the VCD waveform.
- State measured latency.
- Explain how to scale the design to a real SDR NCO.
Engineering conclusion template¶
The NCO mixer uses Q1.15 input samples and Q1.15 oscillator values.
The educational phase accumulator is ____ bits and advances only when in_valid is asserted.
The output is rounded and saturated back to Q1.15. The simulation passes against
Python-generated reference vectors, so the next step is widening the phase accumulator
and replacing the compact LUT with a production oscillator architecture.