Digital Design / Module 8: Arithmetic and datapaths
46. Fixed-point arithmetic
Represent fractions without an FPU.
See it move
The idea
Floating-point units are large and slow, so many chips handle fractions using fixed-point: ordinary integers with an agreed binary point. The hardware just adds and multiplies integers, and the designer keeps track of where the point lies.
Q format
Qm.n means m integer bits, including the sign, and n fractional bits. Q1.7 uses 8 bits, with a value of integer / 128, giving a range from -1 up to +0.992.
| Bits | As integer | Value in Q1.7 |
|---|---|---|
0100 0000 | 64 | 0.5 |
1000 0000 | -128 | -1.0 |
0111 1111 | 127 | 0.992 |
Addition and multiplication
Addition requires matching binary points, and it can overflow by one bit. Multiplication adds the formats: Q1.7 x Q1.7 gives Q2.14, using 16 bits. To return to Q1.7, shift right by 7 and keep the low 8 bits.
wire signed [15:0] wide = a * b; // Q2.14
wire signed [15:0] shifted = wide >>> 7;
assign y = (shifted > 127) ? 8'sd127 :
(shifted < -128) ? -8'sd128 :
shifted[7:0];Saturation
Only one case overflows here: -1 x -1 = +1, which Q1.7 can't represent. Without protection, the value wraps round to -1, which is the worst possible error. Saturation clamps to the largest value instead. This is a small error, and it's much less harmful in audio, video and neural-network computations.
Rounding
Plain shifting truncates, which adds a small negative bias over many operations. To round to nearest, add half of an output LSB (64 here) before shifting. It costs one small addition.
Common mistake
Using >> instead of >>> on a signed value, which fills in zeros and turns a negative value positive. Also check that both operands are declared signed, or the multiply itself is treated as unsigned.
Key ideas
- Qm.n sets integer and fractional bits.
- Products add fractional bits, so truncate deliberately.
- Saturate instead of wrapping where overflow would be harmful.
Your exercise
Find the bug
This code has one deliberate bug. Read the symptom, then click the line you think is wrong.
The fix
wire signed [15:0] s = wide >>> 7;
Why it failed
>> fills with zeros, so a negative product loses its sign when shifted. >>> copies the sign bit, and the value stays negative. This bug survives any test that uses only positive numbers.
Interview questions
coreQ1.7 x Q1.7 gives?
Q2.14, before scaling back.
basicEncode 0.375 and -0.75 in Q1.7.
In Q1.7 the stored integer is the value times 128. So 0.375 x 128 = 48 = 0011 0000, and -0.75 x 128 = -96, which in 8-bit two's complement is 256 - 96 = 160 = 1010 0000. The hardware only sees the integers 48 and -96, and the binary point exists only in the designer's head.
basicWhat range and resolution does a signed Q4.4 number have?
Q4.4 has 8 bits, 4 integer bits including the sign and 4 fractional bits, so the value is the integer divided by 16. The integers run from -128 to 127, so the range is -8 to +7.9375, in steps of 1/16 = 0.0625. More fractional bits give finer steps, and more integer bits give more range, for the same total width.
coreMultiply 0.5 by -0.75 in Q1.7 and return a Q1.7 result. Show the integers at each step.
0.5 is 64 and -0.75 is -96. Their product is -6144, which is in Q2.14, so -6144 / 16384 = -0.375. Shifting right by 7 with sign fill gives -48, and -48 / 128 = -0.375, which fits in Q1.7. Simulation gives -6144 and then -48.
corea and b are signed Q1.7 inputs, but the product wire was declared without signed. For 0.5 x -0.75, y comes out as 464 instead of -48. Why?
input signed [7:0] a, b; wire [15:0] wide = a * b; // not signed wire signed [15:0] y = wide >>> 7;
The arithmetic shift >>> only fills with the sign bit when its operand is signed. wide is unsigned here, so >>> behaves as a logical shift and fills with zeros. The product bits 1110 1000 0000 0000 shifted right by 7 become 464, a large positive number. Simulation gives 464. Declare wide as signed, and the same line gives -48.
coreIn a Q1.7 x Q1.7 multiply returned as Q1.7, which inputs overflow, and why saturate rather than wrap?
Only -1 x -1 overflows, because the true result +1 is just outside the Q1.7 range of -1 to 0.9921875. The shifted product is 128, and keeping the low 8 bits wraps it to -128, which is -1, the opposite sign and the worst possible error. Saturation clamps it to 127, which is 0.9921875, an error of one step. In audio, video or neural-network data that is far less harmful.
coreAdd 0.75 and 0.5 as Q1.7 values into an 8-bit signed result. What do you get, and how do you fix it?
0.75 is 96 and 0.5 is 64. Their sum, 160, doesn't fit in 8 signed bits, so it wraps to 160 - 256 = -96, which reads as -0.75. Simulation gives -96 in 8 bits and 160 in 9 bits. Adding two Qm.n numbers can need one more integer bit, so use a 9-bit Q2.7 result, or saturate back to 8 bits.
advancedYour Q1.7 multiplier truncates with >>> 7. How do you round to nearest instead, and what does that fix?
wire signed [15:0] wide = a * b; wire signed [15:0] rnd = (wide + 16'sd64) >>> 7;
Add half an output step before shifting: (wide + 64) >>> 7. An arithmetic right shift rounds toward minus infinity, so truncation errs negative by about half a step on average, and the bias builds up over long accumulations. In simulation, a product of 66 (0.52 of a step) truncated to 0 and rounded to 1, and a product of -1 truncated to -1 and rounded to 0. It costs one small adder. The rounded value can still overflow, so saturate after rounding.
advancedwide is signed and holds -6144. Why does y hold 464, not -48?
wire signed [15:0] wide = a * b; // -6144 wire signed [15:0] y = wide[15:7];
A part-select is always unsigned, even when taken from a signed vector. So wide[15:7] is a 9-bit unsigned value, 1 1101 0000 = 464, and it is zero-extended into y rather than sign-extended. Simulation gives 464, and $signed(wide[15:7]) gives -48. Wrap part-selects in $signed, or use an arithmetic shift on the whole signed vector.