RTL Design Patterns · Chapter 5 · Arithmetic & Datapath Patterns
Saturation, Rounding & Clamping
Every arithmetic block eventually produces a value that does not fit where it has to go: a sum that spills past the top bit, an accumulator that grows without bound, a wide multiplier product squeezed into a narrow sample word. What you do at that boundary decides whether the datapath degrades gracefully or emits garbage. Two disciplines govern it. When a value overflows the target range you must saturate, clamping it to the nearest rail, never letting it wrap modulo, because a plain adder turns one slightly-too-loud sample into a full-scale glitch, a pop in audio or a blown-out pixel in video. And when you narrow precision you must round rather than truncate, because always chopping the low bits biases every sample the same way and injects a DC offset. This lesson builds saturating add and accumulate, clamp-to-range, and rounding modes as the hygiene of fixed-point datapaths in SystemVerilog, Verilog, and VHDL.
Intermediate16 min readRTL Design PatternsSaturationRoundingClampingFixed-PointDSP DatapathOverflow
Chapter 5 · Section 5.4 · Arithmetic & Datapath Patterns
1. The Engineering Problem
You are building the output stage of a small audio mixer. Several signed sample streams arrive each cycle; you sum them into an accumulator and drive the result to a 16-bit DAC word. The arithmetic is trivial — acc = acc + sample. The problem is what happens at the edges of the format. Sixteen signed bits hold -32768 to +32767. During a quiet passage every sum lands comfortably inside that window and the mixer sounds perfect. Then a loud passage arrives, two near-full-scale samples add, and the true sum is +40000 — a value that does not fit in 16 signed bits.
A plain adder does the only thing plain binary arithmetic can do: it wraps. +40000 in 16-bit two's-complement is not "as loud as possible" — it is 40000 - 65536 = -25536, a large negative number. So the loudest moment of the track, which should have been a slightly-clipped-but-still-loud sample, becomes a near-full-scale swing to the opposite rail. In audio that is an audible pop; in a video pixel path the same wrap turns a just-too-bright pixel black; in a control loop it turns a saturated command into a full-reverse command. One sample out of range, and the output is not slightly wrong — it is catastrophically, audibly wrong, and only on the loud inputs your quiet test never drove.
The same boundary bites in the other direction when you narrow. Your multiplier produces a 32-bit product but the sample bus is 16 bits; you must drop 16 low bits. The lazy way — just truncate, keep the top 16 — throws away the fractional part of every sample by rounding it toward negative infinity. Each sample loses on average half an LSB, always in the same direction, so the stream acquires a small DC bias that a truncating filter chain accumulates into an offset you can measure.
So the real engineering task at every arithmetic boundary is two decisions, made on purpose: on overflow, saturate to the rail instead of wrapping, and on narrowing, round instead of truncating. Both are cheap, both are built from primitives you already have, and skipping either produces a circuit that is green in a gentle simulation and broken in the field.
// A mixer accumulator. Plain add — WRAPS on a loud passage:
wire signed [15:0] acc, sample;
// assign acc_next = acc + sample; // +40000 -> -25536: a POP, not a clip
// A 32-bit product narrowed to 16 bits by plain truncation — BIASED:
wire signed [31:0] prod;
// assign sample_out = prod[31:16]; // drops low 16 bits toward -inf: DC bias
// RIGHT: saturate the sum to the signed rail, and ROUND before dropping bits.
// (the two patterns this page builds — detect+clamp, and add-then-truncate)2. Mental Model
3. Pattern Anatomy
Three related structures share one idea — a value that will not fit is decided about rather than silently mangled.
Saturating add / accumulate — overflow-detect then clamp to the rail. Start from §5.1's insight that adding two N-bit numbers produces an N+1-bit result: the extra bit is the overflow information. A saturating adder computes the wide sum, asks "did it leave the target range?", and if so replaces the wrapped value with the appropriate rail:
- Unsigned. Compute
a + binN+1bits. If the top bit is set (sum > 2^N - 1), the result overflowed high → clamp toMAXV = 2^N - 1; unsigned cannot underflow on an add, so there is no low rail for a two-operand add (an accumulator that subtracts also needs the0floor). - Signed. Compute the sign-extended sum. Overflow is the
Vflag from §5.1 —V = (a[N-1] == b[N-1]) && (sum[N-1] != a[N-1]), equivalently carry-into-MSB XOR carry-out-of-MSB. OnV, the sign of the operands tells you which rail: two positives that overflowed →MAXV = 2^(N-1) - 1; two negatives that overflowed →MINV = -2^(N-1). Use the operand sign, not the (already-wrong) result sign, to pick the rail.
An accumulator is this saturating add closed into a register (§2.1): acc <= sat_add(acc, sample). Saturating at each step is what stops a long run of same-sign inputs from wrapping around the register and back — the accumulator sticks at the rail instead of rolling over.
Clamp-to-range — compare against lo and hi, then select. A clamp is the general case and pure §1.5: given a value and two bounds LO, HI, output LO if the value is below LO, HI if above HI, else the value unchanged. It is two comparators feeding a mux — y = (v < LO) ? LO : (v > HI) ? HI : v. Saturation is the special case where LO/HI are the format rails; a clamp lets you pin to an arbitrary window (a headroom limit, a legal DAC range, a pixel [16,235] studio-swing bound). Note the ordering when LO/HI can be reached by rounding: clamp after you round.
Rounding modes — where the bias lives. When you drop the low IN_W - OUT_W bits of a wide word, the discarded bits are a fraction; how you resolve that fraction is the rounding mode:
- Truncate (round toward −∞). Just drop the low bits. Cheapest — no adder. But it always rounds down, so over many samples it injects a DC bias of about −½ LSB. Fine for a throwaway value, wrong for a signal you will filter or accumulate.
- Round-half-up (round-to-nearest, ties up). Add a rounding constant equal to half the dropped weight — a
1in the most-significant dropped bit, i.e.+ (1 << (IN_W - OUT_W - 1))— then truncate. Now values above the midpoint go up and below go down; the bias is gone except for the exact-half case, which always rounds up (a tiny residual bias of the tie term). - Round-to-nearest-even / convergent. Round to the nearest, but break an exact half-way tie toward the neighbour whose LSB is
0(even). Ties split evenly up and down instead of always up, so even the residual bias of round-half-up disappears. This is the DSP default (and IEEE-754's default) precisely because it is unbiased over a stream — the property that matters when errors accumulate.
Whichever mode you pick, remember the rounding add can overflow the target: rounding 0x7F... up can carry into a value the narrow word cannot hold, so a correct narrowing rounder rounds and then re-clamps (round → saturate).
Q-format — where the binary point sits (the framing). All of the above lives inside a fixed-point convention: a Qm.n number is an integer with an implied binary point n bits from the right — m integer bits, n fractional bits. Nothing in the hardware marks the point; it is a bookkeeping agreement. Saturation is about the integer range (m bits set the rails); rounding is about the fractional bits you drop when you requantize from one Qm.n to a narrower one. Keeping the Q-format explicit is what tells you which bits are the low bits to round and what the rails are — get the format wrong and both the rounding constant and the rail land in the wrong place.
The requantize boundary — round the low bits, then clamp the high end to the rail
data flowThe anatomy closes on one discipline: saturation and rounding compose, in a fixed order — round first, clamp last. Rounding decides the value; clamping decides whether that value is allowed. Reverse them and a rounding carry can escape the rail you thought you enforced.
4. Real RTL Implementation
This is the core of the page. We build three patterns — (a) a WIDTH-generic saturating adder, unsigned and signed, (b) a rounding-on-narrowing block with truncate, round-half-up, and round-to-nearest-even, and (c) the wrap-vs-saturate bug (buggy vs fixed) — and each is shown in SystemVerilog, Verilog, and VHDL, with an RTL block and a self-checking testbench. The idea is identical in all three; the syntax differs.
One discipline runs through every block: compute in enough width to see the overflow, then decide. A saturating add needs the N+1-bit sum (or the V flag) before it can clamp; a rounding narrow needs the full-width value plus the rounding constant before it truncates. Throw the extra bits away too early and the decision is already lost.
4a. The saturating adder — detect overflow, clamp to the rail (unsigned and signed)
Start with the two-operand saturating add. The unsigned form widens by one bit, and if that bit is set the sum exceeded 2^N - 1, so it clamps to MAXV. The signed form computes the V flag from §5.1 and uses the operand sign to choose MAXV or MINV. Both are WIDTH-generic and pure combinational.
module sat_add #(
parameter int WIDTH = 16 // datapath width
)(
input logic [WIDTH-1:0] a, // unsigned operands
input logic [WIDTH-1:0] b,
input logic [WIDTH-1:0] as, // signed operands
input logic [WIDTH-1:0] bs,
output logic [WIDTH-1:0] sum_sat_u, // unsigned saturated sum
output logic [WIDTH-1:0] sum_sat_s // signed saturated sum
);
// Unsigned rails: MAXV = all-ones (2^WIDTH - 1), floor is 0 (add can't underflow).
localparam logic [WIDTH-1:0] UMAX = '1;
// Signed rails: MAX = 0111..1 = 2^(WIDTH-1) - 1 ; MIN = 1000..0 = -2^(WIDTH-1).
localparam logic [WIDTH-1:0] SMAX = {1'b0, {(WIDTH-1){1'b1}}};
localparam logic [WIDTH-1:0] SMIN = {1'b1, {(WIDTH-1){1'b0}}};
// ---- unsigned: widen by one bit; the carry-out IS the overflow ----
logic [WIDTH:0] uext;
always_comb begin
uext = {1'b0, a} + {1'b0, b}; // WIDTH+1-bit sum exposes overflow
sum_sat_u = uext[WIDTH] ? UMAX // overflowed high -> clamp to MAX
: uext[WIDTH-1:0]; // in range -> pass through
end
// ---- signed: V flag from 5.1; operand sign picks the rail ----
logic [WIDTH-1:0] ssum;
logic v;
always_comb begin
ssum = as + bs; // wrapped signed sum
// V: same-signed operands producing a wrong-signed result.
v = (as[WIDTH-1] == bs[WIDTH-1]) && (ssum[WIDTH-1] != as[WIDTH-1]);
// On overflow, the OPERAND sign (not the wrapped result) selects the rail.
sum_sat_s = v ? (as[WIDTH-1] ? SMIN : SMAX) : ssum;
end
endmoduleThe testbench checks each output against an independent reference computed in a wider integer, and deliberately drives the corners where a plain add would wrap — overflow-high, overflow-low, and the -1 + 1 case that must not clamp.
module sat_add_tb;
localparam int WIDTH = 8;
logic [WIDTH-1:0] a, b, as, bs, yu, ys;
int errors = 0;
sat_add #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .as(as), .bs(bs),
.sum_sat_u(yu), .sum_sat_s(ys));
// Reference models in a wide signed int, then clamped to the rails.
function automatic int ref_u(input int x, input int y);
int s = x + y; return (s > 255) ? 255 : s; // UMAX=255
endfunction
function automatic int ref_s(input int x, input int y);
int s = x + y; return (s > 127) ? 127 : (s < -128) ? -128 : s;
endfunction
task automatic check(input int ua, ub, sa, sb);
a = ua[WIDTH-1:0]; b = ub[WIDTH-1:0];
as = sa[WIDTH-1:0]; bs = sb[WIDTH-1:0];
#1;
assert (yu === ref_u(ua, ub)[WIDTH-1:0])
else begin $error("U: %0d+%0d yu=%0d exp=%0d", ua, ub, yu, ref_u(ua,ub)); errors++; end
assert ($signed(ys) === ref_s(sa, sb))
else begin $error("S: %0d+%0d ys=%0d exp=%0d", sa, sb, $signed(ys), ref_s(sa,sb)); errors++; end
endtask
initial begin
check(200, 100, 100, 100); // U overflows high (clamp 255); S +100+100 overflows (clamp +127)
check(10, 20, -100, -100); // U in range; S -100-100 overflows LOW (clamp -128)
check(255, 1, -1, 1); // U wraps->clamp 255; S -1 + 1 = 0 must NOT clamp
check(0, 0, 127, 0); // both in range, rail-adjacent, no clamp
if (errors == 0) $display("PASS: sat_add clamps to the exact rail (no wrap)");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog form is identical in intent; the differences are wire/reg typing, explicit signed on the operands, and $display instead of assert. The overflow logic is the same widen-and-inspect (unsigned) / V-flag (signed).
module sat_add #(
parameter WIDTH = 16
)(
input wire [WIDTH-1:0] a, b, // unsigned
input wire signed [WIDTH-1:0] as, bs, // signed
output reg [WIDTH-1:0] sum_sat_u,
output reg signed [WIDTH-1:0] sum_sat_s
);
localparam [WIDTH-1:0] UMAX = {WIDTH{1'b1}};
localparam signed [WIDTH-1:0] SMAX = {1'b0, {(WIDTH-1){1'b1}}}; // 2^(W-1)-1
localparam signed [WIDTH-1:0] SMIN = {1'b1, {(WIDTH-1){1'b0}}}; // -2^(W-1)
reg [WIDTH:0] uext;
reg signed [WIDTH-1:0] ssum;
reg v;
always @(*) begin
// unsigned: widen by one; MSB of the wide sum is the overflow bit.
uext = {1'b0, a} + {1'b0, b};
sum_sat_u = uext[WIDTH] ? UMAX : uext[WIDTH-1:0];
// signed: V = same-signed operands, wrong-signed result; operand sign picks rail.
ssum = as + bs;
v = (as[WIDTH-1] == bs[WIDTH-1]) && (ssum[WIDTH-1] != as[WIDTH-1]);
sum_sat_s = v ? (as[WIDTH-1] ? SMIN : SMAX) : ssum;
end
endmodule module sat_add_tb;
parameter WIDTH = 8;
reg [WIDTH-1:0] a, b;
reg signed [WIDTH-1:0] as, bs;
wire [WIDTH-1:0] yu;
wire signed [WIDTH-1:0] ys;
integer errors, su, ss, eu, es;
sat_add #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .as(as), .bs(bs),
.sum_sat_u(yu), .sum_sat_s(ys));
task do_check;
input integer ua, ub, sa, sb;
begin
a = ua; b = ub; as = sa; bs = sb;
#1;
su = ua + ub; eu = (su > 255) ? 255 : su; // unsigned ref
ss = sa + sb; es = (ss > 127) ? 127 : (ss < -128) ? -128 : ss; // signed ref
if (yu !== eu[WIDTH-1:0]) begin
$display("FAIL U: %0d+%0d yu=%0d exp=%0d", ua, ub, yu, eu); errors = errors + 1;
end
if (ys !== es[WIDTH-1:0]) begin
$display("FAIL S: %0d+%0d ys=%0d exp=%0d", sa, sb, ys, es); errors = errors + 1;
end
end
endtask
initial begin
errors = 0;
do_check(200, 100, 100, 100); // U clamp 255 ; S clamp +127 (overflow high)
do_check(10, 20, -100, -100); // U in range ; S clamp -128 (overflow low)
do_check(255, 1, -1, 1); // U clamp 255 ; S -1+1=0 no clamp
do_check(0, 0, 127, 0); // no clamp either
if (errors == 0) $display("PASS: sat_add clamps to exact rail, no wrap");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the numeric_std signed/unsigned types and resize make the widen-and-inspect explicit. resize grows the operands by a bit so the overflow is visible; the signed V flag and rail selection are the same reasoning.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sat_add is
generic ( WIDTH : positive := 16 );
port (
a, b : in unsigned(WIDTH-1 downto 0); -- unsigned operands
as, bs : in signed(WIDTH-1 downto 0); -- signed operands
sum_sat_u : out unsigned(WIDTH-1 downto 0);
sum_sat_s : out signed(WIDTH-1 downto 0)
);
end entity;
architecture rtl of sat_add is
constant UMAX : unsigned(WIDTH-1 downto 0) := (others => '1'); -- 2^W - 1
constant SMAX : signed(WIDTH-1 downto 0) -- 2^(W-1)-1
:= '0' & (WIDTH-2 downto 0 => '1');
constant SMIN : signed(WIDTH-1 downto 0) -- -2^(W-1)
:= '1' & (WIDTH-2 downto 0 => '0');
begin
process (all)
variable uext : unsigned(WIDTH downto 0); -- widen by one bit
variable ssum : signed(WIDTH-1 downto 0);
variable v : boolean;
begin
-- unsigned: resize to WIDTH+1; the top bit is the overflow.
uext := resize(a, WIDTH+1) + resize(b, WIDTH+1);
if uext(WIDTH) = '1' then sum_sat_u <= UMAX; -- clamp high
else sum_sat_u <= uext(WIDTH-1 downto 0);
end if;
-- signed: V flag; operand sign selects the rail.
ssum := as + bs;
v := (as(WIDTH-1) = bs(WIDTH-1)) and (ssum(WIDTH-1) /= as(WIDTH-1));
if v then
if as(WIDTH-1) = '1' then sum_sat_s <= SMIN; -- two negatives -> MIN
else sum_sat_s <= SMAX; -- two positives -> MAX
end if;
else
sum_sat_s <= ssum;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sat_add_tb is
end entity;
architecture sim of sat_add_tb is
constant WIDTH : positive := 8;
signal a, b : unsigned(WIDTH-1 downto 0);
signal as, bs : signed(WIDTH-1 downto 0);
signal sum_sat_u : unsigned(WIDTH-1 downto 0);
signal sum_sat_s : signed(WIDTH-1 downto 0);
begin
dut : entity work.sat_add
generic map (WIDTH => WIDTH)
port map (a => a, b => b, as => as, bs => bs,
sum_sat_u => sum_sat_u, sum_sat_s => sum_sat_s);
stim : process
procedure do_check(ua, ub, sa, sb : integer) is
variable su, eu, ss, es : integer;
begin
a <= to_unsigned(ua, WIDTH); b <= to_unsigned(ub, WIDTH);
as <= to_signed(sa, WIDTH); bs <= to_signed(sb, WIDTH);
wait for 1 ns;
su := ua + ub;
if su > 255 then eu := 255; else eu := su; end if;
ss := sa + sb;
if ss > 127 then es := 127;
elsif ss < -128 then es := -128;
else es := ss; end if;
assert to_integer(sum_sat_u) = eu
report "unsigned saturating add mismatch" severity error;
assert to_integer(sum_sat_s) = es
report "signed saturating add mismatch" severity error;
end procedure;
begin
do_check(200, 100, 100, 100); -- U clamp 255 ; S clamp +127
do_check(10, 20, -100, -100); -- U in range ; S clamp -128
do_check(255, 1, -1, 1); -- U clamp 255 ; S -1+1=0 no clamp
do_check(0, 0, 127, 0); -- no clamp
report "sat_add self-check complete" severity note;
wait;
end process;
end architecture;4b. Rounding on narrowing — truncate vs round-half-up vs round-to-nearest-even
Now the requantizer: take a wide IN_W-bit signed value and produce an OUT_W-bit result, dropping SHIFT = IN_W - OUT_W low bits. Three outputs show the modes side by side: truncate (drop, biased), round-half-up (add half the dropped weight, then drop), and round-to-nearest-even (add half, but if exactly half, force the LSB even). Each rounds then re-clamps, so a rounding carry cannot escape the range.
module round_narrow #(
parameter int IN_W = 32, // wide input
parameter int OUT_W = 16, // narrow output
localparam int SHIFT = IN_W - OUT_W // dropped low bits
)(
input logic signed [IN_W-1:0] din,
output logic signed [OUT_W-1:0] trunc_out, // round toward -inf (biased)
output logic signed [OUT_W-1:0] round_out, // round-half-up
output logic signed [OUT_W-1:0] rne_out // round-to-nearest-even
);
localparam signed [OUT_W-1:0] OMAX = {1'b0, {(OUT_W-1){1'b1}}};
localparam signed [OUT_W-1:0] OMIN = {1'b1, {(OUT_W-1){1'b0}}};
// Rounding constant = half the dropped weight = a 1 in the top dropped bit.
localparam signed [IN_W-1:0] HALF = (SHIFT > 0) ? (1 <<< (SHIFT-1)) : 0;
// Clamp a wide rounded value into the OUT_W range (round -> saturate order).
function automatic logic signed [OUT_W-1:0] clamp(input logic signed [IN_W-1:0] w);
logic signed [IN_W-1:0] q = w >>> SHIFT; // arithmetic shift keeps sign
if (q > OMAX) clamp = OMAX;
else if (q < OMIN) clamp = OMIN;
else clamp = q[OUT_W-1:0];
endfunction
logic sticky_half; // are the dropped bits EXACTLY half?
always_comb begin
trunc_out = clamp(din); // just shift+clamp: biased toward -inf
round_out = clamp(din + HALF); // add half, then shift+clamp
// nearest-even: add half; if the pre-round value was an EXACT half AND the
// resulting LSB would be odd, subtract one weight to land on the even neighbour.
sticky_half = (SHIFT > 0) && (din[SHIFT-1:0] == {1'b1, {(SHIFT-1){1'b0}}});
if (sticky_half && ((din >>> SHIFT) & 1)) // exact .5 and odd -> force even (down)
rne_out = clamp(din + HALF - (1 <<< SHIFT));
else
rne_out = clamp(din + HALF);
end
endmoduleThe testbench checks each mode against a reference, and — the point of rounding — measures the mean error over many samples to show that truncation is biased low while the rounded modes are not. It also drives explicit .5 cases so the tie behaviour is directly asserted.
module round_narrow_tb;
localparam int IN_W = 32, OUT_W = 16, SHIFT = IN_W - OUT_W;
logic signed [IN_W-1:0] din;
logic signed [OUT_W-1:0] t, r, e;
int errors = 0;
longint sum_t = 0, sum_r = 0, n = 0; // accumulate error to expose bias
round_narrow #(.IN_W(IN_W), .OUT_W(OUT_W)) dut
(.din(din), .trunc_out(t), .round_out(r), .rne_out(e));
initial begin
// Directed .5 cases: value = k*2^SHIFT + 2^(SHIFT-1) is EXACTLY k + 0.5 LSB.
din = (3 <<< SHIFT) + (1 <<< (SHIFT-1)); #1; // 3.5 -> half-up=4, nearest-even=4
assert (r === 4 && e === 4) else begin $error("3.5: r=%0d e=%0d", r, e); errors++; end
din = (4 <<< SHIFT) + (1 <<< (SHIFT-1)); #1; // 4.5 -> half-up=5, nearest-even=4 (even)
assert (r === 5 && e === 4) else begin $error("4.5: r=%0d e=%0d", r, e); errors++; end
// Bias sweep: many samples; truncation should drift NEGATIVE, rounding ~0.
for (int k = 0; k < 4000; k++) begin
din = $urandom; // full-range wide value
#1;
sum_t += (t - (din >>> SHIFT)); // trunc == shift, our baseline
sum_r += (r - (din >>> SHIFT));
n++;
end
// Rounded mean-error magnitude must be smaller than truncation's (bias removed).
assert ((sum_r*sum_r) <= (sum_t*sum_t))
else begin $error("rounding did not reduce bias: sum_t=%0d sum_r=%0d", sum_t, sum_r); errors++; end
if (errors == 0) $display("PASS: round modes correct; rounding removes truncation bias");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog requantizer uses the same add-half-then-shift, with >>> arithmetic shift on signed operands and an explicit clamp. Nearest-even uses the same exact-half detection.
module round_narrow #(
parameter IN_W = 32,
parameter OUT_W = 16,
parameter SHIFT = 16 // = IN_W - OUT_W
)(
input wire signed [IN_W-1:0] din,
output reg signed [OUT_W-1:0] trunc_out,
output reg signed [OUT_W-1:0] round_out,
output reg signed [OUT_W-1:0] rne_out
);
localparam signed [OUT_W-1:0] OMAX = {1'b0, {(OUT_W-1){1'b1}}};
localparam signed [OUT_W-1:0] OMIN = {1'b1, {(OUT_W-1){1'b0}}};
localparam signed [IN_W-1:0] HALF = {{(IN_W-SHIFT){1'b0}}, 1'b1, {(SHIFT-1){1'b0}}};
reg signed [IN_W-1:0] q_t, q_r, q_e, w_e;
// clamp helper inlined per output: arithmetic shift, then bound to OUT_W rails.
always @(*) begin
q_t = din >>> SHIFT; // truncate = shift toward -inf
trunc_out = (q_t > OMAX) ? OMAX : (q_t < OMIN) ? OMIN : q_t[OUT_W-1:0];
q_r = (din + HALF) >>> SHIFT; // half-up
round_out = (q_r > OMAX) ? OMAX : (q_r < OMIN) ? OMIN : q_r[OUT_W-1:0];
// nearest-even: exact-half AND odd -> nudge down to the even neighbour.
if ((din[SHIFT-1:0] == {1'b1, {(SHIFT-1){1'b0}}}) && ((din >>> SHIFT) & 1'b1))
w_e = din + HALF - (1 <<< SHIFT);
else
w_e = din + HALF;
q_e = w_e >>> SHIFT;
rne_out = (q_e > OMAX) ? OMAX : (q_e < OMIN) ? OMIN : q_e[OUT_W-1:0];
end
endmodule module round_narrow_tb;
parameter IN_W = 32, OUT_W = 16, SHIFT = 16;
reg signed [IN_W-1:0] din;
wire signed [OUT_W-1:0] t, r, e;
integer errors, k;
integer sum_t, sum_r, base;
round_narrow #(.IN_W(IN_W), .OUT_W(OUT_W), .SHIFT(SHIFT)) dut
(.din(din), .trunc_out(t), .round_out(r), .rne_out(e));
initial begin
errors = 0; sum_t = 0; sum_r = 0;
din = (3 <<< SHIFT) + (1 <<< (SHIFT-1)); #1; // 3.5 -> half-up 4, nearest-even 4
if (r !== 4 || e !== 4) begin $display("FAIL 3.5: r=%0d e=%0d", r, e); errors = errors + 1; end
din = (4 <<< SHIFT) + (1 <<< (SHIFT-1)); #1; // 4.5 -> half-up 5, nearest-even 4 (even)
if (r !== 5 || e !== 4) begin $display("FAIL 4.5: r=%0d e=%0d", r, e); errors = errors + 1; end
for (k = 0; k < 4000; k = k + 1) begin
din = {$random, $random}; // wide random value
#1;
base = din >>> SHIFT; // truncation baseline
sum_t = sum_t + (t - base);
sum_r = sum_r + (r - base);
end
// rounding should have |mean error| no larger than truncation's (bias removed)
if ((sum_r*sum_r) > (sum_t*sum_t)) begin
$display("FAIL: rounding did not reduce bias sum_t=%0d sum_r=%0d", sum_t, sum_r);
errors = errors + 1;
end
if (errors == 0) $display("PASS: round modes correct; rounding removes truncation bias");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the requantizer uses numeric_std signed, shift_right for the arithmetic shift, and resize to add the rounding constant in full width before narrowing. The clamp is an explicit range check against the OUT_W rails.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity round_narrow is
generic (
IN_W : positive := 32;
OUT_W : positive := 16
);
port (
din : in signed(IN_W-1 downto 0);
trunc_out : out signed(OUT_W-1 downto 0);
round_out : out signed(OUT_W-1 downto 0);
rne_out : out signed(OUT_W-1 downto 0)
);
end entity;
architecture rtl of round_narrow is
constant SHIFT : natural := IN_W - OUT_W;
constant OMAX : integer := 2**(OUT_W-1) - 1;
constant OMIN : integer := -(2**(OUT_W-1));
-- clamp a full-width value (already rounded) down to the OUT_W rails.
function clamp(w : signed) return signed is
variable q : integer := to_integer(shift_right(w, SHIFT)); -- arithmetic shift
begin
if q > OMAX then return to_signed(OMAX, OUT_W);
elsif q < OMIN then return to_signed(OMIN, OUT_W);
else return to_signed(q, OUT_W);
end if;
end function;
begin
process (all)
variable half : signed(IN_W-1 downto 0);
variable we : signed(IN_W-1 downto 0);
variable low : signed(SHIFT-1 downto 0);
begin
half := to_signed(2**(SHIFT-1), IN_W); -- 1 in the top dropped bit
trunc_out <= clamp(din); -- shift only: biased toward -inf
round_out <= clamp(din + half); -- half-up
-- nearest-even: exact-half AND odd quotient -> nudge to even neighbour.
low := din(SHIFT-1 downto 0);
if (low = ('1' & (SHIFT-2 downto 0 => '0'))) and (shift_right(din, SHIFT)(0) = '1') then
we := din + half - to_signed(2**SHIFT, IN_W);
else
we := din + half;
end if;
rne_out <= clamp(we);
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity round_narrow_tb is
end entity;
architecture sim of round_narrow_tb is
constant IN_W : positive := 32;
constant OUT_W : positive := 16;
constant SHIFT : natural := IN_W - OUT_W;
signal din : signed(IN_W-1 downto 0);
signal trunc_out : signed(OUT_W-1 downto 0);
signal round_out : signed(OUT_W-1 downto 0);
signal rne_out : signed(OUT_W-1 downto 0);
begin
dut : entity work.round_narrow
generic map (IN_W => IN_W, OUT_W => OUT_W)
port map (din => din, trunc_out => trunc_out,
round_out => round_out, rne_out => rne_out);
stim : process
begin
-- value = k*2^SHIFT + 2^(SHIFT-1) is exactly k + 0.5 LSB.
din <= to_signed(3*(2**SHIFT) + 2**(SHIFT-1), IN_W); -- 3.5
wait for 1 ns;
assert to_integer(round_out) = 4 and to_integer(rne_out) = 4
report "3.5 rounding wrong" severity error;
din <= to_signed(4*(2**SHIFT) + 2**(SHIFT-1), IN_W); -- 4.5
wait for 1 ns;
assert to_integer(round_out) = 5 and to_integer(rne_out) = 4 -- nearest-even ties to 4
report "4.5 rounding wrong (nearest-even should tie to even)" severity error;
report "round_narrow self-check complete" severity note;
wait;
end process;
end architecture;4c. The wrap-vs-saturate bug — buggy vs fixed, in all three HDLs
Pattern (c) is the signature failure, shown as buggy vs fixed RTL, then dramatized narratively in §7. The bug is the same everywhere: a signal-path adder is written as a plain add, so on a value that leaves the range the result wraps modulo — a near-full-scale value flips to the opposite rail. The fix is the §4a saturating add: detect overflow, clamp to the rail.
// BUGGY: a mixer accumulator with a PLAIN add. On a loud passage the signed
// sum leaves the range and WRAPS: +40000-ish flips to a large negative
// -> a full-scale POP instead of a graceful clip.
module mix_bad #(parameter int WIDTH = 16) (
input logic clk, rst_n,
input logic signed [WIDTH-1:0] sample,
output logic signed [WIDTH-1:0] acc
);
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) acc <= '0;
else acc <= acc + sample; // plain add -> WRAPS on overflow
endmodule
// FIXED: saturate every step. Detect signed overflow (V), clamp to MAX/MIN by
// operand sign, so the accumulator STICKS at the rail (graceful clip).
module mix_good #(parameter int WIDTH = 16) (
input logic clk, rst_n,
input logic signed [WIDTH-1:0] sample,
output logic signed [WIDTH-1:0] acc
);
localparam signed [WIDTH-1:0] SMAX = {1'b0, {(WIDTH-1){1'b1}}};
localparam signed [WIDTH-1:0] SMIN = {1'b1, {(WIDTH-1){1'b0}}};
logic signed [WIDTH-1:0] s;
logic v;
always_comb begin
s = acc + sample;
v = (acc[WIDTH-1] == sample[WIDTH-1]) && (s[WIDTH-1] != acc[WIDTH-1]);
end
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) acc <= '0;
else acc <= v ? (acc[WIDTH-1] ? SMIN : SMAX) : s; // clamp, don't wrap
endmodule module mix_bug_tb;
localparam int WIDTH = 16;
localparam signed [WIDTH-1:0] SMAX = {1'b0, {(WIDTH-1){1'b1}}};
logic clk = 0, rst_n = 0;
logic signed [WIDTH-1:0] sample, acc;
int errors = 0;
always #5 clk = ~clk;
// Point at mix_good to PASS; at mix_bad to watch the accumulator wrap.
mix_good #(.WIDTH(WIDTH)) dut (.clk(clk), .rst_n(rst_n), .sample(sample), .acc(acc));
initial begin
sample = '0; @(posedge clk); rst_n = 1;
// Drive a run of large POSITIVE samples: the sum must saturate at +MAX,
// and must NEVER go negative (a wrap would flip it to the MIN rail).
sample = 16'sd30000;
repeat (10) begin
@(posedge clk); #1;
assert (acc >= 0) // a wrap would make acc negative
else begin $error("acc wrapped negative: %0d (should clamp +MAX)", acc); errors++; end
end
assert (acc === SMAX)
else begin $error("acc=%0d, expected clamp to +MAX=%0d", acc, SMAX); errors++; end
if (errors == 0) $display("PASS: loud run clamps at +MAX, never wraps");
else $display("FAIL: %0d mismatches (wrap-on-overflow)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story with reg outputs and an explicit signed accumulator. Driving a run of loud samples and asserting the accumulator never goes negative is what exposes the wrap.
// BUGGY: plain signed accumulate -> wraps on a loud passage.
module mix_bad #(parameter WIDTH = 16) (
input wire clk, rst_n,
input wire signed [WIDTH-1:0] sample,
output reg signed [WIDTH-1:0] acc
);
always @(posedge clk or negedge rst_n)
if (!rst_n) acc <= {WIDTH{1'b0}};
else acc <= acc + sample; // WRAPS on overflow
endmodule
// FIXED: saturating accumulate -> detect V, clamp to MAX/MIN.
module mix_good #(parameter WIDTH = 16) (
input wire clk, rst_n,
input wire signed [WIDTH-1:0] sample,
output reg signed [WIDTH-1:0] acc
);
localparam signed [WIDTH-1:0] SMAX = {1'b0, {(WIDTH-1){1'b1}}};
localparam signed [WIDTH-1:0] SMIN = {1'b1, {(WIDTH-1){1'b0}}};
reg signed [WIDTH-1:0] s;
reg v;
always @(*) begin
s = acc + sample;
v = (acc[WIDTH-1] == sample[WIDTH-1]) && (s[WIDTH-1] != acc[WIDTH-1]);
end
always @(posedge clk or negedge rst_n)
if (!rst_n) acc <= {WIDTH{1'b0}};
else acc <= v ? (acc[WIDTH-1] ? SMIN : SMAX) : s; // clamp, not wrap
endmodule module mix_bug_tb;
parameter WIDTH = 16;
localparam signed [WIDTH-1:0] SMAX = {1'b0, {(WIDTH-1){1'b1}}};
reg clk, rst_n;
reg signed [WIDTH-1:0] sample;
wire signed [WIDTH-1:0] acc;
integer i, errors;
// Bind to mix_good to PASS; to mix_bad to observe the wrap.
mix_good #(.WIDTH(WIDTH)) dut (.clk(clk), .rst_n(rst_n), .sample(sample), .acc(acc));
always #5 clk = ~clk;
initial begin
errors = 0; clk = 0; rst_n = 0; sample = 0;
@(posedge clk); rst_n = 1;
sample = 30000; // loud positive run
for (i = 0; i < 10; i = i + 1) begin
@(posedge clk); #1;
if (acc < 0) begin // a wrap makes acc negative
$display("FAIL: acc wrapped negative: %0d", acc); errors = errors + 1;
end
end
if (acc !== SMAX) begin
$display("FAIL: acc=%0d expected +MAX=%0d", acc, SMAX); errors = errors + 1;
end
if (errors == 0) $display("PASS: loud run clamps at +MAX, never wraps");
else $display("FAIL: %0d mismatches (wrap-on-overflow)", errors);
$finish;
end
endmoduleIn VHDL the same bug appears as a plain acc <= acc + sample on a signed accumulator; the fix is the V-flag clamp. numeric_std addition wraps modulo just like the others, so the graceful-clip behaviour has to be built explicitly.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: plain signed accumulate -> wraps on a loud passage.
entity mix_bad is
generic ( WIDTH : positive := 16 );
port ( clk, rst_n : in std_logic;
sample : in signed(WIDTH-1 downto 0);
acc : out signed(WIDTH-1 downto 0) );
end entity;
architecture rtl of mix_bad is
signal a : signed(WIDTH-1 downto 0) := (others => '0');
begin
process (clk, rst_n) begin
if rst_n = '0' then a <= (others => '0');
elsif rising_edge(clk) then a <= a + sample; -- WRAPS on overflow
end if;
end process;
acc <= a;
end architecture;
-- FIXED: saturating accumulate -> detect V, clamp to MAX/MIN.
entity mix_good is
generic ( WIDTH : positive := 16 );
port ( clk, rst_n : in std_logic;
sample : in signed(WIDTH-1 downto 0);
acc : out signed(WIDTH-1 downto 0) );
end entity;
architecture rtl of mix_good is
constant SMAX : signed(WIDTH-1 downto 0) := '0' & (WIDTH-2 downto 0 => '1');
constant SMIN : signed(WIDTH-1 downto 0) := '1' & (WIDTH-2 downto 0 => '0');
signal a : signed(WIDTH-1 downto 0) := (others => '0');
begin
process (clk, rst_n)
variable s : signed(WIDTH-1 downto 0);
variable v : boolean;
begin
if rst_n = '0' then a <= (others => '0');
elsif rising_edge(clk) then
s := a + sample;
v := (a(WIDTH-1) = sample(WIDTH-1)) and (s(WIDTH-1) /= a(WIDTH-1));
if v then
if a(WIDTH-1) = '1' then a <= SMIN; else a <= SMAX; end if; -- clamp
else
a <= s;
end if;
end if;
end process;
acc <= a;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mix_bug_tb is
end entity;
architecture sim of mix_bug_tb is
constant WIDTH : positive := 16;
constant SMAX : integer := 2**(WIDTH-1) - 1;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal sample : signed(WIDTH-1 downto 0) := (others => '0');
signal acc : signed(WIDTH-1 downto 0);
begin
-- Bind to mix_good to PASS; to mix_bad to observe the wrap.
dut : entity work.mix_good
generic map (WIDTH => WIDTH)
port map (clk => clk, rst_n => rst_n, sample => sample, acc => acc);
clk <= not clk after 5 ns;
stim : process
begin
wait until rising_edge(clk); rst_n <= '1';
sample <= to_signed(30000, WIDTH); -- loud positive run
for i in 0 to 9 loop
wait until rising_edge(clk);
wait for 1 ns;
assert acc >= 0 -- a wrap makes acc negative
report "acc wrapped negative (should clamp +MAX)" severity error;
end loop;
assert to_integer(acc) = SMAX
report "acc did not clamp to +MAX" severity error;
report "mix self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: in a signal path a plain add wraps on overflow, and wrapping is a full-scale glitch — detect the overflow (§5.1) and clamp to the rail (§1.5) so the datapath sticks at the wall instead of jumping to the opposite one.
5. Verification Strategy
Saturation and rounding are combinational transforms with a small, checkable specification, and the good news is that the specification is the reference model: a correct saturating/rounding block equals a wider-precision computation clamped or rounded to the target format. The single invariant that captures the whole page is:
The output is the true value if it fits the target format; otherwise it is the nearest legal value — the rail (saturation/clamp) or the correctly-rounded neighbour (rounding) — and never a wrapped value that is farther from the truth than the value it replaced.
Everything below is that invariant, made checkable, and it maps directly onto the testbenches in §4.
- Drive overflow-high AND overflow-low; assert the exact rail (self-checking). Compute the true sum in a wider integer reference, clamp the reference to the format rails, and compare. Drive operands that overflow the high rail and, for signed, operands that overflow the low rail — then assert the output equals
MAXV/MINVexactly, not merely "large." The §4a testbenches do this with200+100,-100-100, and the crucial-1 + 1 = 0case that must not clamp — the corner a naive positive-only sim skips. A wrap shows up as an output on the opposite side of the range from the truth. - Signed vs unsigned, both. The rails and the overflow flag differ by signedness (
carry-outfor unsigned,Vfor signed;2^N-1vs2^(N-1)-1), so verify each independently. A block that clamps correctly for unsigned can clamp to the wrong rail — or on the wrong flag — for signed; drive both operand types (the §4a TBs carrya/bandas/bsside by side). - Rounding: drive the
.5cases and assert the mode. The tie is where the modes diverge. Construct exact-half inputs (value = k·2^SHIFT + 2^(SHIFT-1)is exactlyk + 0.5LSB) and assert round-half-up givesk+1while round-to-nearest-even gives the even neighbour (4.5 → 4,3.5 → 4). This directly proves the tie behaviour rather than sampling around it. - Check truncation bias vs the rounded mean over many samples. The reason rounding exists is statistical, so verify it statistically: accumulate the error
(out − true)over thousands of random samples for truncate and for a rounded mode, and assert the truncation error drifts negative (the −½ LSB DC bias) while the rounded error stays near zero. This is the property no single-sample check can catch — the §4b testbenches sum the error and compare magnitudes. - Round-then-clamp ordering. Drive a value near the top rail whose rounding constant carries it over the format range, and assert the result is the rail, not a wrapped value — proving the rounder re-clamps after it rounds. If your block clamps before rounding, this corner exposes the escaped carry.
- Parameter-generic verification. Re-run the saturating add for several
WIDTHand the requantizer for severalIN_W/OUT_W(andOUT_W = 1, the degenerate narrow). A form that passes at one width can be off by one on the rail at another ifMAXV/MINVwere hardcoded. - Expected waveform. For the saturating accumulator, "correct" on a waveform is unmistakable: as a loud run drives the sum toward a rail,
accclimbs and then flattens atMAXV, holding there — a plateau. The buggy version instead shows a cliff:accjumps from near-MAXVstraight to near-MINVon the overflowing cycle. Plateau = saturate; cliff = wrap. That single visual difference is the §7 bug.
6. Common Mistakes
Wrapping instead of saturating on a signal path. The signature bug. A plain a + b (or acc + sample) on an audio, video, or control datapath wraps modulo on overflow — a value one past the top of the range reappears near the bottom, so a slightly-too-loud sample becomes a full-scale swing to the opposite rail: a pop, a black pixel, a full-reverse command. The fix is cheap and structural — detect the overflow (carry-out or V from §5.1) and clamp to the rail (a §1.5 compare-and-select). Plain arithmetic is correct for an address counter that is meant to wrap; it is a bug on anything a human or a control loop will perceive. (Full post-mortem in §7.)
Always truncating — the DC bias. Dropping low bits with a plain shift (or a bit-slice like prod[31:16]) rounds every value toward −∞, so a whole stream loses on average half an LSB in the same direction. One sample it is invisible; through a filter or an accumulator it integrates into a measurable DC offset. Add the rounding constant (a 1 in the top dropped bit) before truncating, or use round-to-nearest-even where the bias must be provably zero.
Rounding that itself overflows the target. Adding the rounding constant to a value already near the top of the format can carry it past the range — 0x7FFF rounded up is 0x8000, which in the narrow signed word is the minimum. A rounder that does not re-clamp after it rounds turns a near-max value into a min-rail glitch. Always round then saturate, in that order.
Saturating to the wrong rail for signed vs unsigned. The overflow flag and the rail both depend on signedness. Clamping a signed overflow using the unsigned carry-out (the §5.1 mistake) fires on the wrong operands; clamping to 2^N - 1 (the unsigned MAX) when the format is signed pins to a value the signed word reads as -1. Pick the flag (V for signed, carry-out for unsigned) and the rail (2^(N-1)-1 / -2^(N-1) signed, 2^N-1 / 0 unsigned) to match the declared signedness.
Off-by-one on the rail value. The signed maximum is 2^(N-1) - 1, not 2^(N-1) — the latter is out of range and, as a bit pattern (1000…0), is actually the minimum. Writing MAXV = 1 << (N-1) saturates positives to the most-negative value: the worst possible off-by-one. Build the rails from the bit patterns (0111…1 for signed MAX, 1000…0 for signed MIN, all-ones for unsigned MAX) so the constant cannot drift.
Using the wrong sign to pick the rail. On a signed overflow the result sign is already wrong (that is what overflow means), so selecting the rail from the wrapped result's MSB clamps to the opposite rail. Select from the operand sign — two positives that overflowed go to MAXV, two negatives to MINV — which is why the §4a code tests as[WIDTH-1], not ssum[WIDTH-1].
7. DebugLab
The mixer that popped on the loud parts — a plain add wrapped where it should have saturated
The engineering lesson: in a signal path, overflow must saturate, not wrap — a plain add has no overflow policy, and its default (modulo wrap) turns one out-of-range sample into a full-scale glitch on the opposite rail. The tell is a bug that scales with amplitude ("it only pops on the loud parts"): amplitude-dependence in a datapath that was fine at low levels means the arithmetic is wrapping at the range edge your gentle tests never reached. Two habits make it impossible, identical across SystemVerilog, Verilog, and VHDL: give every signal-path adder an explicit overflow policy — detect (carry-out / V) and clamp to the rail — and verify at the edges of the range, driving values that overflow high and low and asserting the output sticks at the exact rail instead of jumping to the other one. And beware the model-vs-DUT trap: a reference that also uses a plain add will agree with the bug — the reference must clamp.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "decide at the boundary" habit.
Exercise 1 — Saturate or wrap?
For each quantity, state whether its arithmetic should saturate or wrap, in one line: (a) the running sum in an audio mixer accumulator; (b) a circular DMA descriptor ring-buffer read pointer; (c) an 8-bit RGB pixel value after a brightness gain; (d) a phase accumulator in a DDS/NCO sine generator; (e) a PID controller's integral term feeding an actuator. (Hint: does the value live on a line or on a circle?)
Exercise 2 — Get the rails right
For a signed 12-bit fixed-point word, write the exact numeric values (and the 12-bit hex bit patterns) of MAXV and MINV. Then explain, in one sentence each, what goes wrong if an engineer writes MAXV = 2^11 and what goes wrong if they select the saturation rail from the result sign instead of the operand sign. Finally, give the unsigned 12-bit rails and say why an unsigned two-operand add needs no low rail but an unsigned accumulator that subtracts does.
Exercise 3 — Round the .5 cases by hand
You are narrowing a Q4.4 signed word (8 bits) to Q4.0 (drop the 4 fractional bits). For the input values +3.5, +4.5, −2.5, and −3.5 LSB, give the output under (i) truncation, (ii) round-half-up, and (iii) round-to-nearest-even. Then state the average error of each mode over these four inputs and explain why nearest-even is the one with zero mean.
Exercise 4 — Order the requantize pipeline
A DSP block multiplies two Q1.15 samples to a Q2.30 product and must emit a Q1.15 result with round-to-nearest and saturation. (i) State the sequence of operations (round, truncate/shift, clamp) in the correct order and justify why clamp must be last. (ii) Give a concrete Q2.30 product value that is in range before rounding but out of range after the rounding add, and show what the output must be. (iii) Explain what a wrong ordering (clamp-before-round) would output for that value and why it is a glitch.
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- Barrel Shifters — Chapter 5; the mux-built shift/rotate primitive, and the arithmetic-shift-right that a signed requantizer uses to drop low bits while preserving sign.
- ALU Construction — Chapter 5; where the saturating add and the requantizer sit in a real datapath — the output stage that clamps and rounds the ALU result before it leaves the unit.
- Datapath–Control Split — Chapter 5; saturation and rounding are pure datapath transforms; this is where the control that configures them (rounding mode, saturation enable) is separated out.
Backward / in-track dependencies (the primitives this pattern is built from):
- Adders & Subtractors — Chapter 5.1; the source of the overflow flags this page acts on — carry-out (unsigned) and the signed
Vflag — and the width-grows-by-one rule that makes the overflow visible. - Comparators — Chapter 1.5; the compare-and-select that a clamp is — two bounds, a mux, a verdict — and the signedness reasoning that decides which rail is which.
- Multipliers — Chapter 5.2; the classic source of a value that must be narrowed — a
2N-bit product squeezed back into anN-bit sample word, which is exactly where rounding earns its keep. - Multiplexers — Chapter 1.1; the selection primitive underneath every saturate and clamp — the overflow flag steers a mux between the true value and the rail.
- Encoding & Conversions — Chapter 5; the two's-complement, sign-extension, and Q-format framing that decide where the binary point sits and what the rails and the rounding constant actually are.
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses — saturation/rounding are pure datapath transforms with an explicit policy (the overflow decision).
- What Is an RTL Design Pattern? — Chapter 0.1; why "saturate, don't wrap" and "round, don't truncate" are patterns — recurring problems, reusable forms, and signature failures.
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Arithmetic Operators — the
+/-and shift operators behind the saturating add and the narrowing round, and how width and signedness govern their result. - Relational Operators — the
</>/>=comparisons a clamp uses to test a value against itsLO/HIbounds. - Bitwise Operators — the masks, concatenations, and replications used to build the rail constants and the rounding constant.
- Physical Data Types — vector widths,
signedvs unsigned nets, and truncation/extension on assignment — the mechanics of where the bits go when you widen to detect overflow or narrow to requantize.
11. Summary
- At every arithmetic boundary, decide — don't let the default decide for you. A value that will not fit the target format is a decision point: on overflow, saturate to the rail; on narrowing, round to the nearest. Plain arithmetic wraps and plain narrowing truncates, and both are silently wrong on the inputs a gentle sim never drives.
- Saturation is detect-then-select, built from primitives you already have. Overflow detection is the adder's carry-out (unsigned) or
Vflag (signed) from §5.1; choosing the rail is a comparator-driven mux from §1.5. A saturating adder just wires them together — one extra bit and a mux, no extra cycle. - The rails depend on signedness, and the classic bugs are on them. Signed
N-bit:MAXV = 2^(N-1) - 1(0111…1),MINV = -2^(N-1)(1000…0); unsigned:2^N - 1and0. Never writeMAXV = 1 << (N-1)(that is the minimum), and pick the rail from the operand sign, not the wrapped result's sign. - Truncation is biased; rounding removes the bias. Always chopping the low bits rounds every sample toward −∞ — a −½ LSB DC offset a filter integrates. Round-half-up (add half, then truncate) removes most of it; round-to-nearest-even splits ties toward the even neighbour and is provably unbiased — the DSP default.
- Round then clamp — and verify at the edges. Rounding can carry a value past the rail, so re-clamp after rounding. Prove it correct by driving overflow-high and overflow-low and asserting the exact rail (never a wrap), driving
.5cases for the rounding mode, and checking truncation bias vs the rounded mean over many samples — self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL. On a waveform, saturate is a plateau at the rail; wrap is a cliff to the opposite one.