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}\):
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.
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:
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.
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.
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`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
endmodulea= 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.)
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.
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:
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:
| k | 0, 14 | 1, 13 | 2, 12 | 3, 11 | 4, 10 | 5, 9 | 6, 8 | 7 |
|---|---|---|---|---|---|---|---|---|
| c_k (Q1.15) | −118 | −133 | 0 | 696 | 2205 | 4257 | 6075 | 6803 |
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.
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.
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(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.
# 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')`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# 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()))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.
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.
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.
Click Run to execute the Python code
Code will be executed with Python 3 on the server
Practice Problems
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?fir15, given its coefficients? Why does the design still saturate the output?