RTL Design Patterns · Chapter 5 · Arithmetic & Datapath Patterns
Adders & Subtractors
The adder is the arithmetic core of every datapath, and subtraction is not a separate machine. In two's-complement, a minus b equals a plus not-b plus 1, so one add/subtract structure with a single mode bit does both operations. This lesson builds that structure as a ripple-carry chain, shows how a carry-in lets you chain adders for multi-word math, and explains why the sum width grows by one bit so nothing is lost. It then targets the classic silent bug: carry-out and signed overflow answer different questions, unsigned versus signed, and using one where you meant the other corrupts a saturate or a branch. Every pattern is built and self-check-verified in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design PatternsAdderSubtractorTwo's ComplementCarry & OverflowDatapath
Chapter 5 · Section 5.1 · Arithmetic & Datapath Patterns
1. The Engineering Problem
You are building the arithmetic stage of a small processor's datapath, and everywhere you look, something has to add. The program counter advances: pc + 4. A load computes its effective address: base + offset. The ALU executes an ADD and, on the next instruction, a SUB. An accumulator sums a stream of signed samples. A loop counter counts down toward zero. Not one of these is arithmetic for decoration — each is the moment raw data becomes a new number that the rest of the machine depends on.
The block that produces those numbers is the adder, and its close twin the subtractor. At first they look like two separate machines you must build and maintain. They are not. In two's-complement — the representation every modern datapath uses for signed integers — subtraction is addition of the negation, and negation is invert-and-add-one: a - b = a + (~b + 1) = a + ~b + 1. So a single add/subtract structure, with one mode bit that chooses whether to add b or ~b and whether to inject a 1, computes both operations. That single insight is why this is one pattern, not two.
But the sum is only part of the story. Two more outputs come off that structure and they are where the bugs live. A carry-out tells you the unsigned result did not fit — it wrapped past the top of the unsigned range. A signed overflow (V) tells you the signed result did not fit — it wrapped past the top or bottom of the two's-complement range. These are different questions with different answers, and the number-one adder bug in real silicon is using one flag where you meant the other: reading the unsigned carry-out to decide whether a signed saturate or a signed branch should fire. It is correct on the positive half of the range your positive-only testbench exercised, and wrong the moment real signed data goes negative.
// Everywhere the datapath produces a new number, it is this structure:
wire [31:0] next_pc = pc + 32'd4; // increment (+)
wire [31:0] addr = base + offset; // effective address (+)
wire [31:0] diff = a - b; // a - b = a + ~b + 1 (-)
// ONE add/sub with a mode bit `sub` does both: op = sub ? (a - b) : (a + b)
// ...and it emits TWO overflow flags that answer DIFFERENT questions:
// cout : did the UNSIGNED result wrap? (carry past the MSB)
// ovf : did the SIGNED result wrap? (V = cin_msb XOR cout_msb)
// Using `cout` where you meant `ovf` (or vice-versa) is the classic silent bug.2. Mental Model
3. Pattern Anatomy
The add/subtract family is one structure — a chain of full-adders — read four ways.
The ripple-carry chain (the default synthesized structure). A full-adder takes three bits — a[i], b[i], and a carry-in c[i] — and produces a sum bit sum[i] = a[i] ^ b[i] ^ c[i] and a carry-out c[i+1] = (a[i] & b[i]) | (c[i] & (a[i] ^ b[i])). Chain N of them, wiring each stage's carry-out into the next stage's carry-in, and you have an N-bit ripple-carry adder — the structure a synthesis tool infers from a plain a + b. Its defining property is its delay: bit N-1's sum cannot settle until the carry has rippled through all N-1 stages below it, so the critical path grows linearly with width. That linear carry chain is exactly what fast/prefix adders (carry-lookahead, Brent-Kung, Kogge-Stone — a forward pattern) exist to break, trading area for a log2(N)-depth carry. For now the mental picture is: the carry is a wave that has to cross the whole word.
Add/subtract via XOR-invert and carry-in. To fold subtraction into the same chain, put an XOR gate on each b bit controlled by the sub mode bit, and drive the chain's bottom carry-in with sub too:
b_eff[i] = b[i] ^ sub— whensub=0this isb(add); whensub=1it is~b(the invert half of two's-complement negate).cin = sub— whensub=1this injects the+1half of the negate; whensub=0it is the normal carry-in0.
The result is a + (b ^ {N{sub}}) + sub, which is a + b for sub=0 and a + ~b + 1 = a - b for sub=1. One row of XORs and a carry-in turn the adder into an add/subtract unit — and the b-vs-~b selection is itself a 2:1 mux (§1.1) with sub as its select.
Carry-out vs signed-overflow detection — the two different alarms. From the top of the chain come two distinct flags, and this is the crux of the whole page:
- Carry-out (
cout) is simplyc[N], the carry out of the most-significant stage. It is the unsigned overflow flag: forsub=0,cout=1meansa + bexceeded2^N - 1; forsub=1,coutis the borrow-complement of the unsigned subtract. It answers "did the unsigned result wrap?" - Signed overflow (
V) isV = c[N] XOR c[N-1]— the carry into the MSB XORed with the carry out of the MSB. It fires exactly when two same-signed operands produce a wrong-signed result (positive + positive = negative, ornegative + negative = positive). It answers "did the signed result wrap?" Equivalently,V = (a[N-1] == b_eff[N-1]) && (sum[N-1] != a[N-1]).
The two flags disagree constantly. -1 + 1 (0xFF + 0x01 at 8 bits) produces 0x00 with cout=1 — the unsigned add wrapped — but there is no signed overflow, because -1 + 1 = 0 is perfectly representable. Conversely 0x7F + 0x01 (127 + 1) produces 0x80 with V=1 (signed overflow: +128 doesn't fit) — a signed disaster — while its unsigned interpretation 127 + 1 = 128 fits fine and sets no carry-out. Same machine, two alarms, and they are not interchangeable.
Carry-in and the width-grows-by-one rule. A carry-in on the bottom of the chain is not just for subtraction — it is how you chain adders for multi-word arithmetic. To add two 64-bit numbers on a 32-bit datapath, add the low words with cin=0, capture the cout, and feed it as the cin of the high-word add: the carry ripples across the word boundary exactly as it ripples across bits. That is why a general adder always exposes a carry-in. And the width rule closes the anatomy: adding two N-bit numbers yields an N+1-bit result — {cout, sum} is the full answer. If you declare sum as N bits you have chosen to discard the carry-out; that is fine only if you captured cout separately. Truncating the sum and dropping the carry is the width off-by-one bug of §6.
The add/subtract family — one ripple chain, an XOR row, and two different overflow flags
data flowThe whole anatomy reduces to four decisions you make on every adder: is it add or subtract (the sub bit and XOR row), do you need a carry-in (chaining, or the subtract +1), do you keep the carry-out (the N+1-th bit), and — the one that bites — which overflow do you mean, unsigned (cout) or signed (V). Get the last one wrong and the datapath is correct in simulation and wrong in the field.
4. Real RTL Implementation
This is the core of the page. We build two patterns — (a) a parameterized add/subtract unit with a sub mode bit, a carry-in, a carry-out, and a correct signed-overflow flag, WIDTH-generic and correct for both signed and unsigned interpretation; and (b) the carry-out-vs-overflow 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 across all three; only the syntax for declaring signedness and slicing the extra bit differs, and seeing them side by side is what fixes the carry-vs-overflow reasoning in your head.
One discipline runs through every block: the sum is computed one bit wider than the operands, so the carry-out is a real bit and not a dropped one; and the overflow flag is computed as V, never read off the carry-out. In SystemVerilog and Verilog that means an N+1-bit intermediate {cout, sum} and an explicit ovf = cin_to_msb ^ cout; in VHDL it means widening via numeric_std ('0' & a) and the same XOR of the two top carries.
4a. Parameterized add/subtract unit — one structure, both operations, correct flags
Start with the workhorse: one module that adds or subtracts under a sub bit, accepts a carry-in for chaining, and emits sum, cout (unsigned overflow), and ovf (signed overflow). The trick that makes the flags correct is to compute the sum one bit wider than the operands — extend a and b_eff with a leading 0, add, and the extra top bit is the carry-out. The signed-overflow flag is then V = (carry into MSB) XOR (carry out of MSB).
module addsub #(
parameter int WIDTH = 8
)(
input logic [WIDTH-1:0] a,
input logic [WIDTH-1:0] b,
input logic sub, // 0 = a + b, 1 = a - b
input logic cin, // carry-in for chaining multi-word adds
output logic [WIDTH-1:0] sum,
output logic cout, // UNSIGNED overflow (carry out of the MSB)
output logic ovf // SIGNED overflow (V = cin_msb ^ cout_msb)
);
// Two's-complement subtract = add the inverted b with an injected +1:
// a - b = a + ~b + 1. XOR each b bit with sub (sub=1 => ~b), and fold the
// +1 into the bottom carry-in. So one adder + an XOR row does BOTH ops.
logic [WIDTH-1:0] b_eff = b ^ {WIDTH{sub}}; // b (add) or ~b (subtract)
logic cin0 = cin ^ sub; // subtract injects the +1
// Compute the sum ONE BIT WIDER so the carry-out is a real bit, not dropped.
// {1'b0, a} + {1'b0, b_eff} + cin0 is an (WIDTH+1)-bit ripple-carry add; the
// top bit of the result IS the carry-out.
logic [WIDTH:0] ext = {1'b0, a} + {1'b0, b_eff} + cin0;
assign sum = ext[WIDTH-1:0];
assign cout = ext[WIDTH]; // unsigned overflow / borrow-complement
// Signed overflow V = carry INTO the MSB XOR carry OUT of the MSB. The carry
// into the MSB is recoverable as sum[MSB] ^ a[MSB] ^ b_eff[MSB]. This is a
// DIFFERENT question than cout and must be computed, never read off cout.
logic cin_msb = sum[WIDTH-1] ^ a[WIDTH-1] ^ b_eff[WIDTH-1];
assign ovf = cin_msb ^ cout;
endmoduleTwo lines carry the whole lesson. cout = ext[WIDTH] is the unsigned overflow — the bit that would be lost if you truncated the sum to WIDTH. ovf = cin_msb ^ cout is the signed overflow V, a genuinely different computation. The testbench proves both against independent reference models and hammers the boundary operands where the two flags disagree — max+max, min-min, -1+1, 0x7F+0x01 — plus a random sweep across the full range.
module addsub_tb;
localparam int WIDTH = 8;
logic [WIDTH-1:0] a, b, sum;
logic sub, cin, cout, ovf;
int errors = 0;
addsub #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .sub(sub), .cin(cin),
.sum(sum), .cout(cout), .ovf(ovf));
task check; begin
#1; // settle combinationally
// Reference: do the arithmetic in a WIDTH+1-bit space, then derive flags.
logic [WIDTH:0] ext_ref;
logic [WIDTH-1:0] beff_ref;
logic cin0_ref, exp_cout, exp_ovf;
logic signed [WIDTH:0] s_ref; // signed reference
beff_ref = sub ? ~b : b;
cin0_ref = cin ^ sub;
ext_ref = {1'b0, a} + {1'b0, beff_ref} + cin0_ref;
exp_cout = ext_ref[WIDTH]; // unsigned overflow
// Signed reference: true signed result must fit in WIDTH bits or V=1.
s_ref = $signed(a) + (sub ? -$signed(b) : $signed(b)) + $signed({1'b0, cin});
exp_ovf = (s_ref > (2**(WIDTH-1) - 1)) || (s_ref < -(2**(WIDTH-1)));
assert (sum === ext_ref[WIDTH-1:0]) else begin $error("sum a=%h b=%h sub=%b", a, b, sub); errors++; end
assert (cout === exp_cout) else begin $error("cout a=%h b=%h sub=%b", a, b, sub); errors++; end
assert (ovf === exp_ovf) else begin $error("ovf a=%h b=%h sub=%b got=%b exp=%b", a, b, sub, ovf, exp_ovf); errors++; end
end endtask
initial begin
// Directed corners where cout and ovf DISAGREE (the whole lesson).
sub=0; cin=0; a=8'hFF; b=8'h01; check(); // -1 + 1: cout=1 but NO signed ovf
sub=0; cin=0; a=8'h7F; b=8'h01; check(); // 127 + 1: signed ovf=1, unsigned fits
sub=0; cin=0; a=8'hFF; b=8'hFF; check(); // max+max unsigned (255+255)
sub=1; cin=0; a=8'h80; b=8'h01; check(); // min - 1: signed underflow -> ovf
sub=1; cin=0; a=8'h00; b=8'h80; check(); // 0 - min: signed ovf (+128 doesn't fit)
sub=0; cin=1; a=8'hFF; b=8'h00; check(); // carry-in chaining path
// Random sweep over both operations and carry-in.
for (int t = 0; t < 400; t++) begin
a = $urandom; b = $urandom; sub = t[0]; cin = t[1]; check();
end
if (errors == 0) $display("PASS: addsub sum/cout/ovf correct on all vectors");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog form is identical in intent — only wire/reg typing, $random, and $signed system-function casts differ. The WIDTH+1-bit intermediate and the ovf = cin_msb ^ cout computation are line-for-line the same idea.
module addsub #(
parameter WIDTH = 8
)(
input wire [WIDTH-1:0] a,
input wire [WIDTH-1:0] b,
input wire sub, // 0 = add, 1 = subtract
input wire cin,
output wire [WIDTH-1:0] sum,
output wire cout, // UNSIGNED overflow
output wire ovf // SIGNED overflow (V)
);
// a - b = a + ~b + 1: invert b with the sub bit, inject the +1 via the carry-in.
wire [WIDTH-1:0] b_eff = b ^ {WIDTH{sub}};
wire cin0 = cin ^ sub;
// One-bit-wider add so the carry-out is a real bit and never truncated away.
wire [WIDTH:0] ext = {1'b0, a} + {1'b0, b_eff} + cin0;
assign sum = ext[WIDTH-1:0];
assign cout = ext[WIDTH]; // unsigned overflow
// Carry INTO the MSB, recovered from the sum bit; V = cin_msb ^ cout.
wire cin_msb = sum[WIDTH-1] ^ a[WIDTH-1] ^ b_eff[WIDTH-1];
assign ovf = cin_msb ^ cout; // signed overflow, NOT cout
endmodule module addsub_tb;
parameter WIDTH = 8;
reg [WIDTH-1:0] a, b;
reg sub, cin;
wire [WIDTH-1:0] sum;
wire cout, ovf;
integer t, errors;
addsub #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .sub(sub), .cin(cin),
.sum(sum), .cout(cout), .ovf(ovf));
task check;
reg [WIDTH:0] ext_ref;
reg [WIDTH-1:0] beff_ref;
reg cin0_ref, exp_cout, exp_ovf;
reg signed [WIDTH+1:0] s_ref; // wide signed reference
begin
#1;
beff_ref = sub ? ~b : b;
cin0_ref = cin ^ sub;
ext_ref = {1'b0, a} + {1'b0, beff_ref} + cin0_ref;
exp_cout = ext_ref[WIDTH]; // unsigned overflow
s_ref = $signed(a) + (sub ? -$signed(b) : $signed(b)) + cin;
exp_ovf = (s_ref > (2**(WIDTH-1) - 1)) || (s_ref < -(2**(WIDTH-1)));
if (sum !== ext_ref[WIDTH-1:0]) begin $display("FAIL sum a=%h b=%h sub=%b", a, b, sub); errors=errors+1; end
if (cout !== exp_cout) begin $display("FAIL cout a=%h b=%h sub=%b", a, b, sub); errors=errors+1; end
if (ovf !== exp_ovf) begin $display("FAIL ovf a=%h b=%h sub=%b got=%b exp=%b", a, b, sub, ovf, exp_ovf); errors=errors+1; end
end
endtask
initial begin
errors = 0;
sub=0; cin=0; a=8'hFF; b=8'h01; check; // -1 + 1: cout=1, no signed ovf
sub=0; cin=0; a=8'h7F; b=8'h01; check; // 127 + 1: signed ovf, unsigned fits
sub=0; cin=0; a=8'hFF; b=8'hFF; check; // max + max (unsigned wrap)
sub=1; cin=0; a=8'h80; b=8'h01; check; // min - 1: signed underflow
sub=1; cin=0; a=8'h00; b=8'h80; check; // 0 - min: signed overflow
sub=0; cin=1; a=8'hFF; b=8'h00; check; // carry-in chaining
for (t = 0; t < 400; t = t + 1) begin
a = $random; b = $random; sub = t[0]; cin = t[1]; check;
end
if (errors == 0) $display("PASS: addsub sum/cout/ovf correct on all vectors");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleVHDL makes the widening and the signedness the most explicit of the three, because numeric_std gives you distinct unsigned and signed types and forces you to name the interpretation. The WIDTH+1-bit unsigned add produces sum and cout; the signed-overflow flag is the same V = cin_msb XOR cout computation, built from std_logic bits.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity addsub is
generic ( WIDTH : positive := 8 );
port (
a : in std_logic_vector(WIDTH-1 downto 0);
b : in std_logic_vector(WIDTH-1 downto 0);
sub : in std_logic; -- '0' add, '1' subtract
cin : in std_logic; -- carry-in for chaining
sum : out std_logic_vector(WIDTH-1 downto 0);
cout : out std_logic; -- UNSIGNED overflow
ovf : out std_logic -- SIGNED overflow (V)
);
end entity;
architecture rtl of addsub is
signal b_eff : std_logic_vector(WIDTH-1 downto 0);
signal cin0 : std_logic;
signal ext : unsigned(WIDTH downto 0); -- one bit wider
begin
-- a - b = a + ~b + 1: XOR-invert b under sub, fold +1 into the carry-in.
b_eff <= b xor (WIDTH-1 downto 0 => sub);
cin0 <= cin xor sub;
-- Widen both operands by a leading '0' and add as UNSIGNED; the extra top
-- bit is the carry-out. numeric_std makes the interpretation explicit.
ext <= ('0' & unsigned(a)) + ('0' & unsigned(b_eff))
+ ("" & unsigned'("" & cin0)); -- add cin0 as a 1-bit unsigned
sum <= std_logic_vector(ext(WIDTH-1 downto 0));
cout <= ext(WIDTH); -- unsigned overflow
-- Signed overflow: carry INTO the MSB XOR carry OUT of the MSB. The carry into
-- the MSB is sum(MSB) XOR a(MSB) XOR b_eff(MSB); V is that XOR cout.
ovf <= (ext(WIDTH-1) xor a(WIDTH-1) xor b_eff(WIDTH-1)) xor ext(WIDTH);
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity addsub_tb is
end entity;
architecture sim of addsub_tb is
constant WIDTH : positive := 8;
signal a, b, sum : std_logic_vector(WIDTH-1 downto 0);
signal sub, cin, cout, ovf : std_logic;
procedure check(signal av, bv, sv : in std_logic_vector;
signal sb, ci, co, ov : in std_logic;
signal subm, cinm : in std_logic) is
variable beff : unsigned(WIDTH-1 downto 0);
variable extr : unsigned(WIDTH downto 0);
variable sref : integer;
variable eco : std_logic;
variable eov : std_logic;
begin
wait for 1 ns;
-- Unsigned reference in a WIDTH+1-bit space.
if subm = '1' then beff := not unsigned(bv); else beff := unsigned(bv); end if;
extr := ('0' & unsigned(av)) + ('0' & beff)
+ ("" & (cinm xor subm));
if extr(WIDTH) = '1' then eco := '1'; else eco := '0'; end if;
-- Signed reference: true value must fit in WIDTH bits or V='1'.
if subm = '1' then
sref := to_integer(signed(av)) - to_integer(signed(bv));
else
sref := to_integer(signed(av)) + to_integer(signed(bv));
end if;
sref := sref + (character'pos('0') - character'pos('0')); -- (cin omitted from signed ref)
if sref > (2**(WIDTH-1) - 1) or sref < -(2**(WIDTH-1)) then eov := '1'; else eov := '0'; end if;
assert sv = std_logic_vector(extr(WIDTH-1 downto 0)) report "sum mismatch" severity error;
assert co = eco report "cout mismatch" severity error;
assert ov = eov report "ovf mismatch" severity error;
end procedure;
begin
dut : entity work.addsub generic map (WIDTH => WIDTH)
port map (a => a, b => b, sub => sub, cin => cin, sum => sum, cout => cout, ovf => ovf);
stim : process
begin
cin <= '0';
sub <= '0'; a <= x"FF"; b <= x"01"; check(a,b,sum,sub,cin,cout,ovf,sub,cin); -- -1 + 1
sub <= '0'; a <= x"7F"; b <= x"01"; check(a,b,sum,sub,cin,cout,ovf,sub,cin); -- 127 + 1: V
sub <= '0'; a <= x"FF"; b <= x"FF"; check(a,b,sum,sub,cin,cout,ovf,sub,cin); -- max+max
sub <= '1'; a <= x"80"; b <= x"01"; check(a,b,sum,sub,cin,cout,ovf,sub,cin); -- min - 1
sub <= '1'; a <= x"00"; b <= x"80"; check(a,b,sum,sub,cin,cout,ovf,sub,cin); -- 0 - min
report "addsub self-check complete" severity note;
wait;
end process;
end architecture;4b. The carry-out-vs-overflow bug — buggy vs fixed, in all three HDLs
Now the signature failure, shown as buggy vs fixed RTL in each language, then dramatized narratively in §7. The setting is a signed saturating add: two signed operands are summed, and the result must clamp to the signed max/min if it overflows the range. The correct overflow condition is the signed flag V. The bug uses the adder's carry-out as the overflow indicator instead — a plausible mistake, because on unsigned data carry-out is the overflow. On signed data it is the wrong question: -1 + 1 sets carry-out (so the buggy block wrongly saturates a perfectly-valid 0), while some genuine signed overflows do not set the carry-out the way the code assumed — so the block saturates when it must not and passes through when it must clamp.
// BUGGY: signed saturating add that uses the UNSIGNED carry-out as its overflow
// flag. For -1 + 1 (0xFF + 0x01) carry-out is 1, so it clamps a valid 0.
// Silent in positive-only sims; wrong the moment operands go negative.
module sat_add_bad #(parameter int WIDTH = 8) (
input logic signed [WIDTH-1:0] a,
input logic signed [WIDTH-1:0] b,
output logic signed [WIDTH-1:0] y
);
logic [WIDTH:0] ext = {a[WIDTH-1], a} + {b[WIDTH-1], b}; // sign-extended add
wire cout = ext[WIDTH]; // UNSIGNED carry — WRONG flag
// Clamps on carry-out, which is the unsigned question, not the signed one.
assign y = cout ? (a[WIDTH-1] ? {1'b1, {(WIDTH-1){1'b0}}} // clamp to signed MIN
: {1'b0, {(WIDTH-1){1'b1}}}) // clamp to signed MAX
: ext[WIDTH-1:0];
endmodule
// FIXED: saturate on the SIGNED overflow flag V = carry-in-MSB XOR carry-out-MSB.
// Now -1 + 1 = 0 passes through, and only true signed overflows clamp.
module sat_add_good #(parameter int WIDTH = 8) (
input logic signed [WIDTH-1:0] a,
input logic signed [WIDTH-1:0] b,
output logic signed [WIDTH-1:0] y
);
logic [WIDTH-1:0] sum = a + b;
// V = the two same-signed operands produced a wrong-signed result.
wire ovf = (a[WIDTH-1] == b[WIDTH-1]) && (sum[WIDTH-1] != a[WIDTH-1]);
// On overflow, clamp toward the sign of the operands; else pass the sum.
assign y = ovf ? (a[WIDTH-1] ? {1'b1, {(WIDTH-1){1'b0}}} // signed MIN
: {1'b0, {(WIDTH-1){1'b1}}}) // signed MAX
: sum;
endmodule module sat_add_tb;
localparam int WIDTH = 8;
logic signed [WIDTH-1:0] a, b, y, exp;
int errors = 0;
// Bind to sat_add_good to PASS; to _bad to watch -1 + 1 get wrongly clamped.
sat_add_good #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .y(y));
task check;
input signed [WIDTH+1:0] ia, ib;
logic signed [WIDTH+1:0] s;
begin
a = ia[WIDTH-1:0]; b = ib[WIDTH-1:0];
#1;
// Correct saturating reference: clamp the TRUE signed sum to the range.
s = $signed(a) + $signed(b);
if (s > (2**(WIDTH-1) - 1)) exp = (2**(WIDTH-1) - 1); // signed MAX
else if (s < -(2**(WIDTH-1))) exp = -(2**(WIDTH-1)); // signed MIN
else exp = s[WIDTH-1:0];
assert (y === exp)
else begin $error("a=%0d b=%0d y=%0d exp=%0d", a, b, y, exp); errors++; end
end
endtask
initial begin
check(0, 0); check(10, 20); // no overflow
check(-1, 1); check(-1, -1); // NEGATIVE: buggy clamps -1+1 wrongly
check(127, 1); check(100, 100); // signed positive overflow -> MAX
check(-128,-1);check(-100,-100); // signed negative overflow -> MIN
if (errors == 0) $display("PASS: sat_add clamps on SIGNED overflow only");
else $display("FAIL: %0d mismatches (carry-out used as overflow?)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story. The bug is clamping on the sign-extended add's carry-out; the fix is clamping on the signed-overflow condition (a[MSB]==b[MSB]) && (sum[MSB]!=a[MSB]). Driving signed operands whose sum crosses the range — and especially the -1 + 1 case the carry-out mishandles — is what surfaces it.
// BUGGY: saturating add clamps on the UNSIGNED carry-out of a sign-extended add.
// -1 + 1 sets carry-out and gets wrongly clamped; a signed corruption.
module sat_add_bad #(parameter WIDTH = 8) (
input wire signed [WIDTH-1:0] a,
input wire signed [WIDTH-1:0] b,
output wire signed [WIDTH-1:0] y
);
wire [WIDTH:0] ext = {a[WIDTH-1], a} + {b[WIDTH-1], b};
wire cout = ext[WIDTH]; // WRONG flag for signed
assign y = cout ? (a[WIDTH-1] ? {1'b1, {(WIDTH-1){1'b0}}} // MIN
: {1'b0, {(WIDTH-1){1'b1}}}) // MAX
: ext[WIDTH-1:0];
endmodule
// FIXED: clamp on the SIGNED overflow flag V, not the carry-out.
module sat_add_good #(parameter WIDTH = 8) (
input wire signed [WIDTH-1:0] a,
input wire signed [WIDTH-1:0] b,
output wire signed [WIDTH-1:0] y
);
wire [WIDTH-1:0] sum = a + b;
wire ovf = (a[WIDTH-1] == b[WIDTH-1]) && (sum[WIDTH-1] != a[WIDTH-1]);
assign y = ovf ? (a[WIDTH-1] ? {1'b1, {(WIDTH-1){1'b0}}} // MIN
: {1'b0, {(WIDTH-1){1'b1}}}) // MAX
: sum;
endmodule module sat_add_tb;
parameter WIDTH = 8;
reg signed [WIDTH-1:0] a, b;
wire signed [WIDTH-1:0] y;
reg signed [WIDTH-1:0] exp;
reg signed [WIDTH+1:0] s;
integer errors;
// Bind to _good to PASS; to _bad to observe -1 + 1 being wrongly clamped.
sat_add_good #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .y(y));
task check;
input signed [WIDTH+1:0] ia, ib;
begin
a = ia[WIDTH-1:0]; b = ib[WIDTH-1:0];
#1;
s = $signed(a) + $signed(b); // true signed sum
if (s > (2**(WIDTH-1) - 1)) exp = (2**(WIDTH-1) - 1);
else if (s < -(2**(WIDTH-1))) exp = -(2**(WIDTH-1));
else exp = s[WIDTH-1:0];
if (y !== exp) begin
$display("FAIL: a=%0d b=%0d y=%0d exp=%0d", a, b, y, exp);
errors = errors + 1;
end
end
endtask
initial begin
errors = 0;
check(0, 0); check(10, 20); // no overflow
check(-1, 1); check(-1, -1); // NEGATIVE: exposes the bug
check(127, 1); check(100, 100); // positive overflow -> MAX
check(-128,-1); check(-100,-100); // negative overflow -> MIN
if (errors == 0) $display("PASS: sat_add clamps on signed overflow only");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the mistake wears its cause on its sleeve: the buggy version reads the carry-out of an unsigned widen, the fixed version computes V from the sign bits. Because numeric_std forces you to name signed vs unsigned, the wrong-flag bug looks deliberate in review — which is exactly why it is caught faster in VHDL.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: signed saturating add that clamps on the UNSIGNED carry-out. For
-- -1 + 1 the carry-out is '1' and a valid 0 is wrongly saturated.
entity sat_add_bad is
generic ( WIDTH : positive := 8 );
port ( a, b : in std_logic_vector(WIDTH-1 downto 0);
y : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of sat_add_bad is
signal ext : unsigned(WIDTH downto 0);
begin
ext <= ('0' & unsigned(a)) + ('0' & unsigned(b)); -- unsigned widen
process (ext, a)
begin
if ext(WIDTH) = '1' then -- carry-out = WRONG flag
if a(WIDTH-1) = '1' then
y <= '1' & (WIDTH-2 downto 0 => '0'); -- signed MIN
else
y <= '0' & (WIDTH-2 downto 0 => '1'); -- signed MAX
end if;
else
y <= std_logic_vector(ext(WIDTH-1 downto 0));
end if;
end process;
end architecture;
-- FIXED: clamp on the SIGNED overflow flag V (same-signed operands, wrong-signed
-- result). -1 + 1 = 0 now passes; only true signed overflow clamps.
entity sat_add_good is
generic ( WIDTH : positive := 8 );
port ( a, b : in std_logic_vector(WIDTH-1 downto 0);
y : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of sat_add_good is
signal sum : signed(WIDTH-1 downto 0);
signal ovf : std_logic;
begin
sum <= signed(a) + signed(b);
ovf <= '1' when (a(WIDTH-1) = b(WIDTH-1)) and (sum(WIDTH-1) /= a(WIDTH-1))
else '0'; -- V, the SIGNED question
process (ovf, a, sum)
begin
if ovf = '1' then
if a(WIDTH-1) = '1' then
y <= '1' & (WIDTH-2 downto 0 => '0'); -- signed MIN
else
y <= '0' & (WIDTH-2 downto 0 => '1'); -- signed MAX
end if;
else
y <= std_logic_vector(sum);
end if;
end process;
end architecture;Across all three languages the lesson is one sentence: carry-out and overflow are different alarms — carry-out is the unsigned question, V = c[N] XOR c[N-1] is the signed question — so on signed data you must saturate and branch on V, never on the carry-out.
5. Verification Strategy
An add/subtract unit is combinational, so its correctness is a function you can check against a reference model — the great advantage being that the reference (integer arithmetic in the testbench, done in a wider space) is trivially correct, so you can hammer it with volume. The single invariant that captures a correct unit is:
In an
N+1-bit space,{cout, sum} == a + (sub ? ~b : b) + (cin ^ sub); the signed-overflow flagovfis true exactly when the true signed result does not fit inNbits; andcoutis true exactly when the unsigned result does not fit — two separate checks, never conflated.
Everything below is that invariant made checkable, and it maps directly onto the §4 testbenches.
- Random + boundary self-check against a reference (self-checking TB). Drive random
a,b,sub,cinand compute the golden result in a wider integer space (WIDTH+1bits or a signedinteger), then assertsum,cout, andovfeach against their own reference. Because the reference arithmetic is independent of the DUT's structure, a match is real evidence. The three §4a testbenches do exactly this: SVassert (sum === ...)/assert (cout === ...)/assert (ovf === ...), Verilogif (... !== ...) $display("FAIL"), VHDLassert ... report ... severity error. - Verify carry-out (unsigned) AND overflow (signed) SEPARATELY. This is the point of the whole page, so it is the point of verification: never derive one flag's expectation from the other. Compute the expected
coutfrom an unsignedWIDTH+1-bit add and the expectedovffrom a signed range check (true_signed > MAX || true_signed < MIN). A testbench that only checks the sum, or that checksovfby readingcout, cannot catch the §7 bug — it would contain the bug in its own model. - Boundary operands are mandatory, including the disagreement cases. Exercise
max + max,min - min,min - 1,0 - min,-1 + 1,0x7F + 1, and bothmaxandminof the signed range. The-1 + 1and0x7F + 1cases are the crown jewels:-1 + 1setscoutbut notovf, and0x7F + 1setsovfbut the unsigned add fits — any TB that passes both has genuinely separated the two flags. - The eq-of-flags invariant is a free checker. For a single operation,
coutandovfare independent bits; four combinations{cout, ovf}are all reachable, and a correct model reproduces each. If your reference ever ties them together (ovf := cout), that is a bug in the checker, and the directed corners above will not expose the DUT bug — a reminder to keep the two reference computations structurally separate. - Parameter-generic verification (
WIDTH = 4, 8, 16, 32). A parameterized unit must be verified generic, not assumed generic. Re-run the random + boundary suite at severalWIDTHs; the boundary values (max,min,-1) are recomputed fromWIDTH, so the same testbench code covers every width. A unit that passes atWIDTH=8can still be wrong atWIDTH=32if a slice or a sign-extension was hardcoded — width-genericity is a property you prove. - Expected waveform. Because the unit is combinational, "correct" on a waveform is immediate: as
a/b/sub/cinchange,sum,cout, andovfsettle within the propagation delay to the reference values. The visual signature of the §7 bug is a saturate/branch output that fires on the wrong operands — clamping on-1 + 1(a valid0) or failing to clamp on0x7F + 1— a mismatch you see the instant a negative operand appears.
6. Common Mistakes
Confusing carry-out with signed overflow. The signature bug of this page. Carry-out answers "did the unsigned result wrap?"; signed overflow V answers "did the signed result wrap?" They are different bits (V = carry-into-MSB XOR carry-out-of-MSB) and they disagree constantly — -1 + 1 sets carry-out but not V; 0x7F + 1 sets V but the unsigned add fits. Using the unsigned carry-out to drive a signed saturate or a signed branch (or vice-versa) produces a circuit that is correct in a positive-only sim and wrong on negative data. This is the exact signedness trap of the comparator (§1.5) reappearing on the adder: compute the overflow you mean. (Full post-mortem in §7; buggy-vs-fixed RTL for all three HDLs in §4b.)
Truncating the sum and dropping the carry (the width off-by-one). Adding two N-bit numbers yields an N+1-bit result — {cout, sum}. Assigning that into an N-bit wire discards the top bit, which is the carry-out. If you did not capture cout separately, you have silently lost information: 0xFF + 0xFF becomes 0xFE with the carry gone. The fix is to compute the sum one bit wider ({1'b0, a} + {1'b0, b}) and take cout from the extra bit, or to declare the destination N+1 bits. Width in arithmetic is never accidental — decide whether you keep the carry.
Wrong two's-complement subtract (forgetting the +1 / carry-in). Subtraction is a + ~b + 1, and the +1 is not optional — it is what completes the two's-complement negate. Inverting b but injecting cin=0 computes a + ~b, which is a - b - 1 (off by one on every subtract). The correct add/subtract folds the +1 into the bottom carry-in (cin0 = cin ^ sub), so sub=1 supplies it automatically. Any subtractor whose results are uniformly one too small has dropped the negate's +1.
Sign-extension mistakes on mixed widths. When you add operands of different widths, or a narrow signed value into a wider accumulator, the narrow operand must be sign-extended (replicate its MSB), not zero-extended — zero-extending a negative number turns it into a large positive one, exactly the -1-becomes-255 error from the comparator. Symmetrically, an unsigned narrow operand must be zero-extended. The rule follows the operand's declared signedness: sign-extend signed, zero-extend unsigned. Mixing them silently corrupts the arithmetic on the top half of the narrow operand's range.
7. DebugLab
The saturating adder that clamped valid data — carry-out used where signed overflow was meant
The engineering lesson: carry-out and signed overflow are two different alarms on the same adder — one asks whether the unsigned result wrapped, the other whether the signed result wrapped — and they disagree on exactly the operands (negatives, sign boundaries) a positive-only testbench never drives. The tell is a saturate or a branch that fires on the wrong data in a sign-dependent way: if a signed decision behaves correctly on positive operands and wrongly on negative ones, suspect that an unsigned flag (carry-out) is steering a signed choice. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: compute V = c[N] XOR c[N-1] (or (a[MSB]==b[MSB]) && (sum[MSB]!=a[MSB])) for the signed overflow and route that into signed saturates/branches, and in verification, check carry-out and overflow against separate reference models and drive the corners where they disagree (-1 + 1, 0x7F + 1, min - 1, 0 - min).
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "which overflow do I mean?" and "one structure, both operations" habits.
Exercise 1 — Predict the two flags
For an 8-bit add/subtract unit, fill in sum, cout, and ovf (signed V) for each operation and explain which flag matters: (a) 0xFF + 0x01 (add); (b) 0x7F + 0x01 (add); (c) 0x80 - 0x01 (subtract, i.e. min - 1); (d) 0x00 - 0x80 (subtract, i.e. 0 - min); (e) 0x40 + 0x40 (add). For each, state whether an unsigned user and a signed user would each see an overflow, and why the two can differ. (Hint: compute {cout, sum} in a 9-bit space, then apply V = c[8] XOR c[7].)
Exercise 2 — Turn the adder into a subtractor on paper
You are given only an N-bit adder with a carry-in (no subtract mode). Show precisely how to compute a - b using it: what you feed to the b input, what you set the carry-in to, and why the combination equals a - b in two's-complement. Then explain what the adder's carry-out means for the subtract (borrow vs no-borrow) and how it differs from the carry-out's meaning for an add. Finally, describe the one row of gates you would add to fold add and subtract into a single mode-bit-controlled unit.
Exercise 3 — Design the saturating unit correctly
Design (in words + RTL sketch) a signed saturating adder that clamps to signed MAX/MIN on overflow, for parameter WIDTH. State the exact overflow condition you gate the clamp on and why carry-out is the wrong condition. Then list the four boundary vectors your self-checking testbench must include to prove the clamp fires on — and only on — genuine signed overflow, and name for each vector which of cout/ovf is set. Explain why -1 + 1 and 0x7F + 1 are the two most important vectors.
Exercise 4 — Chain and extend
(a) Using two N-bit add/subtract units, show how to build a 2N-bit adder: what connects between them, and what the low unit's carry-out becomes. (b) Your datapath must add a 12-bit signed sensor reading into a 32-bit signed accumulator every cycle. Describe the extension you apply to the 12-bit value before the add, why zero-extension would be wrong, and what the accumulator's own overflow flag should be computed from. (c) State how the answer to (b) would change if the 12-bit reading were unsigned.
10. Related Tutorials
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- Multipliers — Chapter 5.2; the next arithmetic primitive — a multiply is a tree of these adders (partial-product summation), and its width grows by
2N, notN+1. - Barrel Shifters — Chapter 5; the third datapath primitive, built from muxes rather than adders — shift/rotate by any amount in one level.
- Saturation & Rounding — Chapter 5; the correct signed-overflow flag from this page is exactly what a saturating datapath clamps on — the §7 bug, generalized.
- Fast / Prefix Adders — Chapter 5; the carry-lookahead and parallel-prefix structures that break the ripple-carry chain's width-linear delay.
- ALU Construction — Chapter 5; this add/subtract unit is the arithmetic heart of the ALU, sharing its flags (carry, overflow, zero, negative) with the logic and shift units.
In-track dependencies (patterns this one builds on):
- Multiplexers (2:1, N:1, one-hot) — Chapter 1.1; the
b/~bmode-bit selection is a 2:1 mux withsubas its select — selection meets arithmetic. - Comparators — Chapter 1.5; a magnitude comparator is a subtract-and-inspect, and it carries the same signedness trap this page reappears with on the overflow flag.
- Up/Down Counters — Chapter 3; a counter is an add/subtract unit (
+1/-1) feeding a register — where this arithmetic core meets state. - FSM + Datapath — Chapter 4; the adder is the datapath the control FSM sequences, and its overflow flag is a status bit the FSM branches on.
- Encoding & Conversions — Chapter 5; two's-complement, sign-extension, and the negate that make subtraction-as-addition work.
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this page applied to the adder (pure datapath, its flags feed control).
- What Is an RTL Design Pattern? — Chapter 0.1; why the adder earns the name "pattern" — a recurring need, a reusable form, a synthesis reality (ripple vs prefix), and a signature failure (carry vs overflow).
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Arithmetic Operators — the
+and-that infer the adder/subtractor, and the width rules that make the sumN+1bits. - Physical Data Types (signed/unsigned) — the declared signedness that decides whether the overflow you care about is carry-out or
V. - Relational Operators — the comparisons a saturating clamp or a branch uses on the sum and its flags.
- Bitwise Operators — the XOR-invert (
^) row and the reductions behind the two's-complement negate and theVcomputation.
11. Summary
- Subtraction is addition in disguise, so it is one pattern.
a - b = a + ~b + 1, so a single add/subtract structure with onesubmode bit does both:subXOR-invertsb(b_eff = b ^ {N{sub}}) and doubles as the bottom carry-in that injects the two's-complement+1. Theb/~bchoice is a 2:1 mux (§1.1); you build and verify one structure, not two. - The default structure is ripple-carry, and its delay grows with width. A chain of
Nfull-adders whose carry has to ripple across the whole word — delay linear inN. That linear carry chain is exactly what fast/prefix adders (carry-lookahead, Brent-Kung, Kogge-Stone) exist to break, trading area for alog2(N)-depth carry. - The result is
N+1bits wide. Adding twoN-bit numbers yields{cout, sum}; the carry-out is the extra top bit. Truncate the sum intoNbits without capturingcoutand you silently drop it (the width off-by-one). Compute the sum one bit wider, or declare the destinationN+1bits, on purpose. - Carry-out and signed overflow are DIFFERENT alarms. Carry-out is the unsigned overflow (carry out of the MSB); signed overflow
V = c[N] XOR c[N-1](or(a[MSB]==b[MSB]) && (sum[MSB]!=a[MSB])) is the signed overflow. They disagree constantly —-1 + 1sets carry-out but notV;0x7F + 1setsVbut the unsigned add fits — and using one where you meant the other silently corrupts a saturate or a branch on signed data (§7). Compute the overflow you mean. - A carry-in composes and negates; signedness governs extension. The carry-in chains adders for multi-word arithmetic and supplies the subtract
+1. When mixing widths, sign-extend signed operands and zero-extend unsigned ones — the declared type, stated explicitly, decides. Verify by checkingsum,cout, andovfagainst separate reference models across random + boundary operands (max+max,min-min,-1+1,0x7F+1), parameter-generic inWIDTH— self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL.