Fixed-Point Arithmetic & FIR Filters

Q-format numbers, overflow, rounding and saturation; multiply–accumulate on DSP slices; a pipelined FIR filter checked against Python.

1. Why fixed point on an FPGA?

Signal processing is mostly multiplying numbers between −1 and 1 and adding the results: filters, mixers, FFTs, control loops. On a PC you would use floating point. On an FPGA, a single-precision floating-point adder costs hundreds of LUTs and several cycles of latency; an integer adder is a carry chain, and an integer multiplier fits in one of the 220 dedicated DSP48E1 slices of the XC7Z020 (Chapter 6).

Fixed point gets the best of both: we store plain two's complement integers (Chapter 1), and agree that the binary point sits at a fixed position. The hardware never sees the point — it only adds and multiplies integers. Our job as designers is to track where the point is, how many bits each result needs, and what happens when a result does not fit. That bookkeeping is the whole subject of this chapter, and the payoff is a 15-tap filter whose output matches a Python model bit for bit.

2. The Qm.n format

A Qm.n number has \(m\) integer bits (including the sign bit) and \(n\) fraction bits,\(m + n\) bits in total. The stored integer \(I\) represents the value \(I / 2^{n}\):

\[ v = \frac{I}{2^{n}}, \qquad -2^{m-1} \;\le\; v \;\le\; 2^{m-1} - 2^{-n}, \qquad \text{resolution (1 LSB)} = 2^{-n} \]

The workhorse of DSP is Q1.15: 16 bits, a sign bit and 15 fraction bits, covering\([-1,\, 1 - 2^{-15}]\) in steps of \(2^{-15} \approx 3.05\times10^{-5}\). To convert, multiply by\(2^{15} = 32\,768\) and round: \(0.75 \to 24\,576\), \(-0.25 \to -8192\). Note the asymmetry:\(-1\) is representable (\(-32\,768\)), \(+1\) is not.

115−11142⁻¹0132⁻²1122⁻³01101009080706050403020100binary point… down to 2⁻¹⁵ = 1 LSB1101 0000 0000 0000 = 0xD000 = −12288 → −12288 / 32768 = −1 + 0.5 + 0.125 = −0.375
Q1.15: the same 16 bits as an integer and as a fraction. Bit 15 has weight −1 (two's complement sign), bit k < 15 has weight 2^(k−15).

3. How wide are sums and products?

Adding two Qm.n numbers needs one more integer bit: \(0.75 + 0.5 = 1.25\) does not fit in Q1.15 but fits in Q2.15. Adding \(K\) numbers needs \(\lceil \log_2 K \rceil\) extra bits. Multiplying adds the formats together:

\[ \text{Q}m_1.n_1 \times \text{Q}m_2.n_2 = \text{Q}(m_1{+}m_2).(n_1{+}n_2) \qquad\Rightarrow\qquad \text{Q1.15} \times \text{Q1.15} = \text{Q2.30 (32 bits)} \]

The product of two Q1.15 numbers lies in \([-1, 1]\), so you might expect Q1.30 to suffice. It almost does — the only product that needs the second integer bit is \((-1)\times(-1) = +1\). To get back to Q1.15 you keep bits 30 down to 15 of the 32-bit product and watch for that one case.

313015140kept: the Q1.15 result (16 bits)discarded fraction bits (15)overflowguardtruncate: drop them · round: add bit 14 first
Q1.15 × Q1.15 → Q2.30. Keeping bits 30…15 returns to Q1.15; bit 31 only differs from bit 30 for (−1)×(−1); bits 14…0 are below the new LSB and must be rounded away or dropped.

4. Rounding, truncation, saturation and wrap-around

Two decisions arise every time a result is narrowed:

  • Low end — truncation or rounding. Dropping the low bits of a two's complement number is free, but it computes \(\lfloor v \rfloor\): every result moves towards \(-\infty\), on average by half an LSB. That bias accumulates in filters and integrators. Adding half an LSB (\(2^{14}\) in a Q2.30 product) before dropping the bits rounds to the nearest value, with almost zero mean error, for the cost of one adder.
  • High end — wrap-around or saturation. If the result is too large, dropping its top bits makes it wrap: \(0.75 + 0.5\) becomes \(-0.75\). In audio that is a loud click; in a control loop it can be a disaster. Saturation clamps the result to the largest or smallest representable value instead, at the cost of two comparators and a multiplexer.
q15_ops — Q1.15 add and multiply, with both overflow and both rounding policies
module q15_ops (
    input  wire signed [15:0] a,          // Q1.15
    input  wire signed [15:0] b,          // Q1.15
    output wire signed [15:0] sum_wrap,   // a + b, overflow wraps around
    output wire signed [15:0] sum_sat,    // a + b, clamped to the Q1.15 range
    output wire signed [31:0] prod,       // a * b, exact, Q2.30
    output wire signed [15:0] prod_trunc, // back to Q1.15 by dropping bits
    output wire signed [15:0] prod_round  // back to Q1.15, rounded + saturated
);
    localparam signed [15:0] MAXV = 16'sh7FFF, MINV = 16'sh8000;

    // Addition: one extra bit holds every possible result exactly
    wire signed [16:0] sum = a + b;                     // Q2.15
    assign sum_wrap = sum[15:0];                        // just drop the top bit
    assign sum_sat  = (sum > MAXV) ? MAXV :
                      (sum < MINV) ? MINV : sum[15:0];

    // Multiplication: 16 x 16 bits -> 32 bits, Q1.15 x Q1.15 = Q2.30
    assign prod       = a * b;
    assign prod_trunc = prod[30:15];                    // floor: biased low
    wire signed [32:0] rnd = prod + 33'sd16384;         // add half an LSB
    wire signed [17:0] rq  = rnd[32:15];                // then drop 15 bits
    assign prod_round = (rq > MAXV) ? MAXV : rq[15:0];  // only -1 x -1 overflows
endmodule
q15_ops_tb — six cases that show the differences
`timescale 1ns/1ps
module q15_ops_tb;
    reg  signed [15:0] a, b;
    wire signed [15:0] sw, ss, pt, pr;
    wire signed [31:0] p;

    q15_ops dut (.a(a), .b(b), .sum_wrap(sw), .sum_sat(ss),
                 .prod(p), .prod_trunc(pt), .prod_round(pr));

    task show(input signed [15:0] x, input signed [15:0] y);
        begin
            a = x; b = y; #1;
            $display("a=%6d b=%6d | wrap=%6d sat=%6d | prod=%11d trunc=%6d round=%6d",
                     a, b, sw, ss, p, pt, pr);
        end
    endtask

    initial begin
        show(16'sd24576, 16'sd16384);    //  0.75 + 0.5  -> overflow
        show(-16'sd24576, -16'sd16384);  // -0.75 - 0.5  -> overflow
        show(16'sd24576, -16'sd8192);    //  0.75 x -0.25 = -0.1875
        show(16'sd3, 16'sd16384);        //  3 LSB x 0.5 = 1.5 LSB
        show(-16'sd3, 16'sd16384);       // -3 LSB x 0.5 = -1.5 LSB
        show(-16'sd32768, -16'sd32768);  // -1 x -1 = +1 (not representable)
        $finish;
    end
endmodule
Output (identical in both simulators)
a= 24576 b= 16384 | wrap=-24576 sat= 32767 | prod=  402653184 trunc= 12288 round= 12288
a=-24576 b=-16384 | wrap= 24576 sat=-32768 | prod=  402653184 trunc= 12288 round= 12288
a= 24576 b= -8192 | wrap= 16384 sat= 16384 | prod= -201326592 trunc= -6144 round= -6144
a=     3 b= 16384 | wrap= 16387 sat= 16387 | prod=      49152 trunc=     1 round=     2
a=    -3 b= 16384 | wrap= 16381 sat= 16381 | prod=     -49152 trunc=    -2 round=    -1
a=-32768 b=-32768 | wrap=     0 sat=-32768 | prod= 1073741824 trunc=-32768 round= 32767

Read the lines as real numbers. Line 1: \(0.75 + 0.5\) wraps to \(-0.75\) but saturates to\(0.99997\). Lines 4 and 5: \(\pm 1.5\) LSB truncate to \(1\) and \(-2\) (both downwards) but round to \(2\) and \(-1\) (both to the nearest value, half going up). Line 6:\((-1)\times(-1)\) truncated gives \(-1\) — the worst possible answer — while the rounding path saturates to \(+0.99997\). (GHDL may print “metavalue detected” warnings at time 0, before the first inputs are applied; --ieee-asserts=disable-at-0 silences them.)

Common mistake: losing the sign in Verilog
Verilog arithmetic is unsigned unless every operand is signed. Declaring the ports signed is essential, and a single unsigned operand (an unsized constant is fine, but a plain wire [15:0] or a part-select like a[15:0], which is always unsigned) silently turns the whole expression unsigned. In VHDL, numeric_std.signed makes the intent explicit and the compiler refuses mixed types.

5. The DSP48E1 slice: a multiply-accumulate engine

The operation at the heart of DSP is the multiply-accumulate (MAC):\(p \leftarrow p + a \times b\). Each DSP48E1 slice implements it in hardware: a 25 × 18-bit signed multiplier, a 48-bit adder/accumulator, a pre-adder in front of the multiplier, and optional pipeline registers at every stage. Slices in a column can pass their 48-bit result directly to the next one through a dedicated cascade path, without using general routing.

A (25)D (25)B (18)+pre-adder×25 × 18+48-bit ALUP (48)feedback: accumulatePCIN (cascade)PCOUTregM regP reg
Simplified DSP48E1. Pre-adder (A+D), 25×18 multiplier and 48-bit post-adder/accumulator, each followed by an optional register. PCIN/PCOUT cascade a column of slices.

You rarely instantiate a DSP48E1 by hand. If you write acc <= acc + a * b in a clocked process with operands that fit (up to 25 and 18 bits signed), Vivado maps it to a slice and absorbs your registers into the slice's internal ones. The more registers you give it, the higher the clock frequency it can reach — a fully pipelined DSP48E1 in a -1 speed grade runs well above our 125 MHz.

6. A FIR filter, designed in Python

A finite impulse response (FIR) filter computes each output as a weighted sum of the last\(N\) input samples:

\[ y[n] \;=\; \sum_{k=0}^{N-1} c_k\, x[n-k] \]

The weights \(c_k\) are the coefficients, and they are also the filter's impulse response: feed a single 1 followed by zeros and the coefficients come out one by one. Choosing them is filter design; we let scipy.signal.firwin do it: 15 taps, a low-pass cutoff at 0.2 × the Nyquist frequency (half the sample rate), Hamming window. Then we quantise to Q1.15 by multiplying by \(2^{15}\) and rounding:

k0, 141, 132, 123, 114, 105, 96, 87
c_k (Q1.15)−118−13306962205425760756803

The coefficients are symmetric, which gives the filter a linear phase: every frequency is delayed by the same 7 samples, so the waveform shape is preserved. They sum to 32 767, a DC gain of 0.99997. The Python program in Section 9 shows that 16-bit coefficients reproduce the floating-point frequency response within a fraction of a decibel, while 8-bit ones visibly degrade the stop band.

7. The transposed-form FIR in HDL

The obvious hardware for the sum — a delay line and an adder tree — has a long combinational path through all the adders. The transposed form rearranges it: the new input sample is multiplied by every coefficient at once, and each product is added to a partial sum that moves one register towards the output each sample. Every register is fed by exactly one multiply-add, which is exactly a DSP48E1 with its cascade path.

x[n]×c14×c13+×c1+×c0+· · ·· · ·acc[14]acc[2]acc[1]acc[0]→ round + saturate → y[n]
Transposed-form FIR. The input sample is broadcast to all taps; partial sums flow right to left through registers. Each tap is one multiply-add — one DSP48E1. The output stage rounds and saturates the Q6.30 accumulator back to Q1.15.

The HDL writes this as a loop over taps: acc[i] <= acc[i+1] + x_in * c_i. All registers update only when in_valid is high, so the filter works at any sample rate up to one sample per clock (a clock enable again, as in Chapter 14). The accumulators are 36 bits wide (Q6.30), more than the 32 bits the worst-case sum needs (Problem 4). The output stage then adds half an LSB, drops 15 bits and saturates, and out_valid marks each result. There are two register stages, so a sample captured on one clock edge produces its output on y_out after the next edge.

fir15 — 15-tap transposed-form FIR, Q1.15 in and out
module fir15 #(
    parameter ACCW = 36                   // accumulator: Q6.30, room to grow
) (
    input  wire               clk,
    input  wire               rst,
    input  wire               in_valid,   // one new sample when high
    input  wire signed [15:0] x_in,       // Q1.15
    output reg                out_valid = 1'b0,
    output reg  signed [15:0] y_out = 0   // Q1.15, rounded and saturated
);
    localparam N = 15;

    // Low-pass, cutoff 0.2 x Nyquist (scipy.signal.firwin), in Q1.15
    function signed [15:0] coef(input integer k);
        case (k)
            0, 14:   coef = -16'sd118;
            1, 13:   coef = -16'sd133;
            2, 12:   coef =  16'sd0;
            3, 11:   coef =  16'sd696;
            4, 10:   coef =  16'sd2205;
            5, 9:    coef =  16'sd4257;
            6, 8:    coef =  16'sd6075;
            default: coef =  16'sd6803;   // centre tap, k = 7
        endcase
    endfunction

    // Transposed form: every tap multiplies the SAME input sample and adds
    // the partial sum coming from its right-hand neighbour.
    reg signed [ACCW-1:0] acc [0:N-1];
    reg acc_valid = 1'b0;                 // acc[0] holds a new output
    integer i;
    initial for (i = 0; i < N; i = i + 1) acc[i] = 0;

    always @(posedge clk) begin
        acc_valid <= in_valid && !rst;
        if (rst) begin
            for (i = 0; i < N; i = i + 1) acc[i] <= 0;
        end else if (in_valid) begin
            for (i = 0; i < N - 1; i = i + 1)
                acc[i] <= acc[i+1] + x_in * coef(i);   // multiply-accumulate
            acc[N-1] <= x_in * coef(N - 1);
        end
    end

    // Output stage: Q6.30 -> Q1.15, round half up, then saturate
    wire signed [ACCW-1:0]  rnd = acc[0] + (1 <<< 14);
    wire signed [ACCW-16:0] q   = rnd >>> 15;

    always @(posedge clk) begin
        out_valid <= acc_valid;
        if (acc_valid) begin
            if (q > 32767)       y_out <= 16'sh7FFF;
            else if (q < -32768) y_out <= 16'sh8000;
            else                 y_out <= q[15:0];
        end
    end
endmodule
Key idea: decide the policy once, model it exactly
Every narrowing step in the HDL — here only one, at the output — has a precisely defined rounding and overflow rule. Write the same rule in Python with integers ((acc + 2**14) >> 15, then clip) and the two must agree to the last bit. Any difference is a bug, not “numerical noise”.

8. Bit-exact verification against Python

The testbench reads 400 input samples from x.txt (one integer per line), feeds them on two clock cycles out of three to exercise the enable, and writes each output sample to a file. The inputs contain an impulse, full-scale steps (which force saturation), a pass-band tone, a stop-band tone and random full-range samples.

make_vectors.py — generates x.txt
# Writes x.txt: 400 test samples in Q1.15, one decimal integer per line
import numpy as np
rng = np.random.default_rng(3)
n = np.arange
x = np.concatenate([
    [32767], np.zeros(20, int),                     # impulse: reproduces the taps
    np.full(30, 32767), np.full(30, -32768),        # full-scale steps: saturation
    np.round(20000 * np.sin(2 * np.pi * 0.03 * n(120))).astype(int),   # pass band
    np.round(20000 * np.sin(2 * np.pi * 0.35 * n(100))).astype(int),   # stop band
    rng.integers(-32768, 32768, 99)])               # random full-range samples
np.savetxt('x.txt', x, fmt='%d')
print(len(x), 'samples written')
fir15_tb — file in, file out
`timescale 1ns/1ps
module fir15_tb;
    reg                clk = 0, rst = 1, in_valid = 0;
    reg  signed [15:0] x_in = 0;
    wire               out_valid;
    wire signed [15:0] y_out;
    integer fin, fout, r, v, n_in = 0, n_out = 0, cyc = 0;

    fir15 dut (.clk(clk), .rst(rst), .in_valid(in_valid), .x_in(x_in),
               .out_valid(out_valid), .y_out(y_out));

    always #4 clk = ~clk;

    // Write every output sample, one decimal integer per line
    always @(posedge clk)
        if (out_valid) begin
            $fdisplay(fout, "%0d", y_out);
            n_out = n_out + 1;
        end

    initial begin
        fin  = $fopen("x.txt", "r");
        fout = $fopen("y_verilog.txt", "w");
        @(negedge clk) rst = 0;
        // Feed one sample on 2 cycles out of 3, to exercise the enable
        r = $fscanf(fin, "%d\n", v);
        while (r == 1) begin
            @(negedge clk);
            cyc = cyc + 1;
            if (cyc % 3 != 0) begin
                x_in = v; in_valid = 1; n_in = n_in + 1;
                r = $fscanf(fin, "%d\n", v);
            end else
                in_valid = 0;
        end
        @(negedge clk) in_valid = 0;
        repeat (3) @(negedge clk);
        $fclose(fin); $fclose(fout);
        $display("DONE: %0d samples in, %0d samples out -> y_verilog.txt", n_in, n_out);
        $finish;
    end
endmodule
compare.py — checks both HDL outputs against the integer model
# Reads x.txt and the HDL outputs, compares them with the integer model
import numpy as np
from scipy import signal
h = signal.firwin(15, 0.2)
c = np.round(h * 2**15).astype(np.int64)
x = np.loadtxt('x.txt', dtype=np.int64)
acc = np.convolve(x, c)[:len(x)]                   # exact sums, Q.30
y_model = np.clip((acc + (1 << 14)) >> 15, -32768, 32767)   # round, saturate
for name in ('y_verilog.txt', 'y_vhdl.txt'):
    y = np.loadtxt(name, dtype=np.int64)
    print(f"{name}: max |HDL - integer model| = {np.abs(y - y_model).max()} LSB")
y_float = np.convolve(x / 2**15, h)[:len(x)] * 2**15
inside = np.abs(y_float) < 32767                   # ideal output fits in Q1.15
print(f"integer model vs float filter (unsaturated samples): "
      f"max {np.abs(y_model - y_float)[inside].max():.2f} LSB")
print("samples where saturation was needed:", int((~inside).sum()))
Results
DONE: 400 samples in, 400 samples out -> y_verilog.txt
DONE: 400 samples in, 400 samples out -> y_vhdl.txt

y_verilog.txt: max |HDL - integer model| = 0 LSB
y_vhdl.txt: max |HDL - integer model| = 0 LSB
integer model vs float filter (unsaturated samples): max 1.62 LSB
samples where saturation was needed: 28

Both HDL versions match the integer model exactly. The comparison with the floating-point filter is different in kind: it measures the cost of quantising the coefficients and the output — 1.6 LSB at most, i.e. about \(5\times10^{-5}\) of full scale — and where the ideal output exceeded ±1 the hardware saturated as designed.

On the board: where this filter goes next

Without a data path to the PC, a filter on the board has nothing to filter. Two routes, both built later or from earlier chapters: feed it bytes through the UART and FIFO of Chapters 16–17 (two bytes per sample), or — the main road — wrap it in an AXI-Stream interface and stream sample buffers from Python through DMA in Part VII (ol.axi_dma_0.sendchannel.transfer(buf)). Either way, keep compare.py: the same bit-exact check then runs on the hardware's output. After synthesis, open the utilization report and look at the DSP count to see how Vivado mapped the multiply-adds.

Common mistake: comparing against the floating-point design
If you compare the HDL output with scipy.signal.lfilter(h, 1, x) using the float coefficients, you will see errors of an LSB or two and have to pick a tolerance — and a real bug of one LSB will pass unnoticed. Compare against an integer model with the same coefficients, the same widths and the same rounding, and demand zero.

9. Python: design, quantise and inspect the filter

This is the program that designed the coefficients. It prints the Q1.15 values, compares the frequency responses with floating-point, 16-bit and 8-bit coefficients, shows the impulse response, and runs the bit-exact integer model on a signal made of a pass-band tone (0.06 × Nyquist) and a stop-band tone (0.70 × Nyquist): the high tone is attenuated by a factor of about 300.

Python
script.py67 lines

Click Run to execute the Python code

Code will be executed with Python 3 on the server

Practice Problems

Progress: 0/5 (0%)
1
⭐ Easy
Give the range and resolution of Q1.15, Q4.12 and Q8.8 (all 16-bit signed). Write \(-0.375\) in Q1.15 as a decimal integer and as 16-bit hex.
2
⭐ Easy
A Q2.14 number is multiplied by a Q1.15 number. What is the format of the exact product, how many bits does it have, and which bits do you keep to get a Q2.14 result?
3
⭐⭐ Medium
Using q15_ops, the products \(3 \times 0.5\) LSB and \(-3 \times 0.5\) LSB gave trunc = 1 and \(-2\). (a) Explain these values. (b) If you truncate a million random products, what average error do you expect, and what does rounding give?
4
⭐⭐ Medium
What is the smallest accumulator width that can never overflow in fir15, given its coefficients? Why does the design still saturate the output?
5
⭐⭐⭐ Hard
Our 15 coefficients are symmetric (\(c_k = c_{14-k}\)). Describe how a direct-form FIR could use this to need only 8 multipliers, and which part of the DSP48E1 helps. Why is this harder in the transposed form used here?

Problem Set Statistics

Total Problems
5
Attempted
0
Completion
0%
Share:XRedditLinkedIn
Rate this chapter: