RTL Design Patterns · Chapter 1 · Combinational Building Blocks
Comparators
Every datapath eventually has to make a decision: has the counter hit its terminal value, is the FIFO almost full, does this address match the tag? Each question is a comparator, a small combinational block that reduces two multi-bit operands to a one-bit verdict of equal, less-than, or greater-than. It is the primitive that turns raw arithmetic into control. This lesson treats a comparator as two distinct pieces of hardware: equality as a bitwise XNOR reduced with AND, and magnitude as a subtraction whose result you read from the carry or sign bit. It then drives at the one thing that silently turns a correct comparator into a shipped bug, signedness. The same bit pattern compares as a small negative or a huge positive depending on one declared choice. Built and self-check-verified in SystemVerilog, Verilog, and VHDL.
Foundation14 min readRTL Design PatternsComparatorEqualityMagnitudeSigned vs UnsignedDatapath
Chapter 1 · Section 1.5 · Combinational Building Blocks
1. The Engineering Problem
You are building the control around a datapath, and everywhere you look, the datapath has to decide something. A counter must know when it has reached the top so it can wrap: count == N-1. A FIFO must raise an almost-full flag before it overflows: occupancy >= threshold. An address decoder must know whether an incoming address hits this peripheral's window: addr == BASE. A saturating accumulator must clamp when it crosses a limit: acc > MAX. None of these is arithmetic for its own sake — each is a question whose answer is a single bit that steers control.
The block that answers those questions is the comparator, and it comes in exactly two flavours. Equality asks "are these two operands the same?" and produces one bit. Magnitude asks "is a less than, equal to, or greater than b?" and produces the ordered verdict — usually as three mutually-exclusive bits eq, lt, gt. Almost every sequential pattern in the rest of this track contains one: the counter's terminal count is an equality comparator, the FIFO's flags are magnitude comparators, the threshold clamp is a magnitude comparator. If the mux (§1.1) is the datapath's selection primitive, the comparator is its decision primitive.
And here is the trap that makes this a Chapter 1 topic rather than a footnote: the same block of bits compares differently depending on signedness. The 8-bit pattern 8'hFF is 255 if you declare it unsigned and -1 if you declare it signed. Ask "is a < b?" for a = 8'hFF and b = 8'h01 and you get opposite answers — 255 < 1 is false, -1 < 1 is true — from the same silicon inputs, differing only by a declaration you may not have thought about. Get it wrong and the comparator is not "a little off"; it is confidently, silently wrong on exactly the operands (negatives) your positive-only testbench never drove.
// Four everyday datapath decisions — every one is a comparator:
wire at_top = (count == N-1); // counter terminal count (equality)
wire near_full= (occ >= ALMOST); // FIFO almost-full flag (magnitude)
wire tag_hit = (addr == BASE); // address match (equality)
wire clamp = (acc > MAX); // saturation threshold (magnitude)
// The trap lives in the magnitude ones. Is acc "greater than" MAX?
// if acc is UNSIGNED, 8'hFF means 255 -> 255 > 127 is TRUE
// if acc is SIGNED, 8'hFF means -1 -> -1 > 127 is FALSE
// Same bits, opposite verdict. Signedness is the whole design decision.2. Mental Model
3. Pattern Anatomy
The comparator family is two structures with one shared danger.
The equality comparator — XNOR then reduce-AND. Given two WIDTH-bit operands a and b, eq = &(a ~^ b) — XNOR each bit pair (giving a 1 wherever the bits agree), then reduce-AND the result to a single bit that is 1 only when all bits agree. Equivalently eq = ~|(a ^ b) (NOR of the XOR — 1 only when no bit differs). It is a balanced tree of AND gates, so its delay grows as log2(WIDTH); it is purely combinational, stateless, and completely indifferent to signedness — bit-agreement is bit-agreement whether the bits mean -1 or 255.
The magnitude comparator — subtract and inspect. To produce lt/gt, the hardware computes a - b and reads the result. This is a ripple-borrow chain (or a carry-lookahead structure for speed): each bit position feeds a borrow into the next, so the critical path — and thus the delay — grows linearly with WIDTH in the naive form (a good synthesis library gives you a faster carry structure, but the dependency is fundamentally on width, not log2 like equality). The three outputs are derived from one subtract:
eq— all bits agree (the same equality reduction as above);lt/gt— read from the subtraction, and this is where signedness lives.
Unsigned magnitude reads the carry/borrow-out of a - b: for unsigned operands, a < b exactly when the subtraction borrows past the MSB. Signed magnitude reads the sign of the result corrected for overflow: a < b when the true difference is negative, which in two's-complement is result_sign XOR overflow. The two use different bits of the same subtractor — the whole difference between a signed and an unsigned comparator is which flag you route to lt/gt, and getting that wrong is the bug this page exists to prevent.
The eq/lt/gt contract. For a single pair of operands under a single interpretation, exactly one of eq, lt, gt is true — they are mutually exclusive and exhaustive. That contract is a free, powerful checker: any input where zero or two of them fire is a broken comparator, and verification (§5) leans on it.
The comparator family — one equality tree, one subtractor, two ways to read the sign
data flowWidth, delay, and the shared danger. Equality is log2(WIDTH)-deep and cheap; a full magnitude comparator is a subtractor and scales with WIDTH (carry-chain delay), so if you only need equality, don't pay for a magnitude compare. The shared danger sits on the magnitude side: because signed and unsigned magnitude are the same gates read two different ways, the wrong declaration doesn't fail loudly — it produces a comparator that is exactly correct for the bottom half of the range (small non-negative values) and exactly wrong for the top half, which is precisely the region a positive-only testbench never exercises.
4. Real RTL Implementation
This is the core of the page. We build three patterns — (a) a parameterized equality comparator, (b) a parameterized magnitude comparator emitting eq/lt/gt for BOTH unsigned and signed operands, and (c) the signed-as-unsigned 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; the syntax for declaring signedness differs, and seeing them side by side is exactly what fixes the signedness reasoning in your head.
One discipline runs through every magnitude block: signedness is stated explicitly, never inherited by accident. In SystemVerilog and Verilog that means $signed(...) (or signed port/reg declarations); in VHDL it means routing the operands through numeric_std's signed or unsigned types — the type is the declaration.
4a. Parameterized equality comparator — XNOR-reduce, three ways
Start with equality, because it is the simple, signedness-free half. eq is 1 only when every bit of a agrees with the matching bit of b. The reduction &(a ~^ b) (reduce-AND of the bitwise XNOR) says exactly that, and because it is a balanced tree it costs log2(WIDTH) gate delays regardless of what the bits mean.
module eq_cmp #(
parameter int WIDTH = 8 // any operand width
)(
input logic [WIDTH-1:0] a,
input logic [WIDTH-1:0] b,
output logic eq // 1 iff every bit of a agrees with b
);
// XNOR gives a 1 wherever the two operands AGREE; reduce-AND is 1 only when
// ALL bits agree. Equality does not care about sign: 8'hFF == 8'hFF whether
// those bits mean 255 or -1. So there is exactly one equality circuit.
assign eq = &(a ~^ b); // equivalently: ~|(a ^ b)
endmoduleThe testbench drives directed equal/unequal pairs plus random pairs, and checks eq against a trivial reference model (a == b). It deliberately includes the all-ones pattern to prove equality is unaffected by whether that pattern would be read as -1 or 255.
module eq_cmp_tb;
localparam int WIDTH = 8;
logic [WIDTH-1:0] a, b;
logic eq;
int errors = 0;
eq_cmp #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .eq(eq));
task check; begin
#1; // let it settle
// Reference model: bit-equality is signedness-independent.
assert (eq === (a == b))
else begin $error("a=%h b=%h eq=%b exp=%b", a, b, eq, (a==b)); errors++; end
end endtask
initial begin
a = 8'h00; b = 8'h00; check(); // equal (zero)
a = 8'hFF; b = 8'hFF; check(); // equal (all-ones: -1 or 255, same eq)
a = 8'hFF; b = 8'h01; check(); // unequal
a = 8'h80; b = 8'h7F; check(); // unequal across the sign boundary
for (int t = 0; t < 40; t++) begin
a = $urandom; b = (t[0]) ? a : $urandom; // half the time force a==b
check();
end
if (errors == 0) $display("PASS: eq_cmp matches (a==b) on all vectors");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog form is identical in intent — only wire/reg typing and $random differ. Reduction operators (&, ~^) are core Verilog, so the RTL is line-for-line the same idea.
module eq_cmp #(
parameter WIDTH = 8
)(
input wire [WIDTH-1:0] a,
input wire [WIDTH-1:0] b,
output wire eq
);
// Same reduction: 1 iff every bit agrees. Signedness is irrelevant to equality.
assign eq = &(a ~^ b);
endmodule module eq_cmp_tb;
parameter WIDTH = 8;
reg [WIDTH-1:0] a, b;
wire eq;
integer t, errors;
eq_cmp #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .eq(eq));
task check;
reg exp;
begin
#1;
exp = (a == b); // reference model
if (eq !== exp) begin
$display("FAIL: a=%h b=%h eq=%b exp=%b", a, b, eq, exp);
errors = errors + 1;
end
end
endtask
initial begin
errors = 0;
a = 8'h00; b = 8'h00; check; // equal
a = 8'hFF; b = 8'hFF; check; // all-ones equal
a = 8'hFF; b = 8'h01; check; // unequal
for (t = 0; t < 40; t = t + 1) begin
a = $random; b = (t[0]) ? a : $random; // half forced equal
check;
end
if (errors == 0) $display("PASS: eq_cmp matches (a==b) on all vectors");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL, equality of two std_logic_vector operands is the built-in = operator; because we only ask "are the bit patterns identical," we don't need numeric_std at all — a clean demonstration that equality is signedness-free. eq is '1' when a = b, else '0', via a concurrent conditional assignment.
library ieee;
use ieee.std_logic_1164.all;
entity eq_cmp is
generic ( WIDTH : positive := 8 ); -- generic == Verilog parameter
port (
a : in std_logic_vector(WIDTH-1 downto 0);
b : in std_logic_vector(WIDTH-1 downto 0);
eq : out std_logic -- '1' iff bit patterns identical
);
end entity;
architecture rtl of eq_cmp is
begin
-- Equality compares bit patterns only, so no numeric_std / no signedness needed.
eq <= '1' when a = b else '0';
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity eq_cmp_tb is
end entity;
architecture sim of eq_cmp_tb is
constant WIDTH : positive := 8;
signal a, b : std_logic_vector(WIDTH-1 downto 0);
signal eq : std_logic;
procedure check(signal aa, bb : in std_logic_vector; signal q : in std_logic) is
variable exp : std_logic;
begin
wait for 1 ns;
if aa = bb then exp := '1'; else exp := '0'; end if; -- reference model
assert q = exp report "eq_cmp mismatch" severity error;
end procedure;
begin
dut : entity work.eq_cmp generic map (WIDTH => WIDTH)
port map (a => a, b => b, eq => eq);
stim : process
begin
a <= x"00"; b <= x"00"; check(a, b, eq); -- equal
a <= x"FF"; b <= x"FF"; check(a, b, eq); -- all-ones equal (-1 or 255)
a <= x"FF"; b <= x"01"; check(a, b, eq); -- unequal
a <= x"80"; b <= x"7F"; check(a, b, eq); -- unequal across sign boundary
report "eq_cmp self-check complete" severity note;
wait;
end process;
end architecture;4b. Parameterized magnitude comparator — eq/lt/gt for BOTH unsigned and signed
Now the hard half. One module, one pair of operands, and six magnitude outputs: lt_u/gt_u (unsigned) and lt_s/gt_s (signed), plus the shared eq. The whole point of emitting both is to make the signedness decision visible: the raw bits are identical, and the only difference between the unsigned and signed verdicts is how you tell the tool to interpret the operands.
module mag_cmp #(
parameter int WIDTH = 8
)(
input logic [WIDTH-1:0] a,
input logic [WIDTH-1:0] b,
output logic eq, // signedness-independent
output logic lt_u, gt_u, // UNSIGNED: a,b read as 0..2^WIDTH-1
output logic lt_s, gt_s // SIGNED: a,b read as two's-complement
);
// Equality: same for both interpretations.
assign eq = (a == b);
// UNSIGNED compare: the default for plain vectors. 8'hFF is 255 here, so
// 8'hFF > 8'h01. This reads the borrow-out of a - b as an unsigned number.
assign lt_u = (a < b);
assign gt_u = (a > b);
// SIGNED compare: $signed() DECLARES the operands two's-complement, so the
// MSB is a sign bit. Now 8'hFF is -1, so 8'hFF < 8'h01. Same bits, opposite
// verdict — the $signed() cast is the entire difference.
assign lt_s = ($signed(a) < $signed(b));
assign gt_s = ($signed(a) > $signed(b));
endmoduleTwo lines carry the whole lesson: lt_u = (a < b) compares the raw vectors (unsigned, because plain logic vectors are unsigned in SystemVerilog), while lt_s = ($signed(a) < $signed(b)) re-interprets the same bits as two's-complement. For a = 8'hFF, b = 8'h01 the unsigned answer is gt (255 > 1) and the signed answer is lt (-1 < 1) — from one set of input wires. The testbench drives the boundary values that separate them (-1, 0, max, min) and checks all six outputs against independent reference models, one unsigned, one signed.
module mag_cmp_tb;
localparam int WIDTH = 8;
logic [WIDTH-1:0] a, b;
logic eq, lt_u, gt_u, lt_s, gt_s;
int errors = 0;
mag_cmp #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .eq(eq),
.lt_u(lt_u), .gt_u(gt_u), .lt_s(lt_s), .gt_s(gt_s));
task check; begin
#1;
// Independent reference models — one unsigned, one signed.
assert (eq === (a == b)) else begin $error("eq a=%h b=%h", a, b); errors++; end
assert (lt_u === (a < b)) else begin $error("lt_u a=%h b=%h", a, b); errors++; end
assert (gt_u === (a > b)) else begin $error("gt_u a=%h b=%h", a, b); errors++; end
assert (lt_s === ($signed(a) < $signed(b))) else begin $error("lt_s a=%h b=%h", a, b); errors++; end
assert (gt_s === ($signed(a) > $signed(b))) else begin $error("gt_s a=%h b=%h", a, b); errors++; end
// Mutual-exclusion & exhaustiveness under EACH interpretation.
assert ((eq + lt_u + gt_u) == 1) else begin $error("unsigned eq/lt/gt not one-hot: a=%h b=%h", a, b); errors++; end
assert ((eq + lt_s + gt_s) == 1) else begin $error("signed eq/lt/gt not one-hot: a=%h b=%h", a, b); errors++; end
end endtask
initial begin
// Directed corners: the operands where signed and unsigned DISAGREE.
a = 8'hFF; b = 8'h01; check(); // -1 vs 1 : signed lt, unsigned gt
a = 8'h80; b = 8'h00; check(); // min(-128) vs 0 : signed lt, unsigned gt
a = 8'h7F; b = 8'h80; check(); // max(127) vs min(-128): signed gt, unsigned lt
a = 8'h00; b = 8'h00; check(); // equal (zero)
a = 8'hFF; b = 8'hFF; check(); // equal (-1 or 255)
// Random sweep across the full range, both interpretations at once.
for (int t = 0; t < 200; t++) begin a = $urandom; b = $urandom; check(); end
if (errors == 0) $display("PASS: mag_cmp correct for unsigned AND signed on all vectors");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog magnitude comparator is the same design. Verilog's $signed(...) system function performs the identical re-interpretation; plain vectors compare unsigned, so a < b is the unsigned answer and $signed(a) < $signed(b) is the signed one. (Declaring the ports input signed would achieve the same for the whole module; here we keep the vectors unsigned and cast per-comparison so both verdicts live in one module.)
module mag_cmp #(
parameter WIDTH = 8
)(
input wire [WIDTH-1:0] a,
input wire [WIDTH-1:0] b,
output wire eq,
output wire lt_u, gt_u,
output wire lt_s, gt_s
);
assign eq = (a == b); // equality: signedness-independent
assign lt_u = (a < b); // UNSIGNED: raw vectors, 8'hFF = 255
assign gt_u = (a > b);
assign lt_s = ($signed(a) < $signed(b)); // SIGNED: $signed() => 8'hFF = -1
assign gt_s = ($signed(a) > $signed(b));
endmodule module mag_cmp_tb;
parameter WIDTH = 8;
reg [WIDTH-1:0] a, b;
wire eq, lt_u, gt_u, lt_s, gt_s;
integer t, errors;
mag_cmp #(.WIDTH(WIDTH)) dut (.a(a), .b(b), .eq(eq),
.lt_u(lt_u), .gt_u(gt_u), .lt_s(lt_s), .gt_s(gt_s));
task check;
begin
#1;
if (eq !== (a == b)) begin $display("FAIL eq a=%h b=%h", a, b); errors=errors+1; end
if (lt_u !== (a < b)) begin $display("FAIL lt_u a=%h b=%h", a, b); errors=errors+1; end
if (gt_u !== (a > b)) begin $display("FAIL gt_u a=%h b=%h", a, b); errors=errors+1; end
if (lt_s !== ($signed(a) < $signed(b))) begin $display("FAIL lt_s a=%h b=%h", a, b); errors=errors+1; end
if (gt_s !== ($signed(a) > $signed(b))) begin $display("FAIL gt_s a=%h b=%h", a, b); errors=errors+1; end
// eq/lt/gt must be one-hot under each interpretation.
if ((eq + lt_u + gt_u) !== 1) begin $display("FAIL unsigned not one-hot a=%h b=%h", a, b); errors=errors+1; end
if ((eq + lt_s + gt_s) !== 1) begin $display("FAIL signed not one-hot a=%h b=%h", a, b); errors=errors+1; end
end
endtask
initial begin
errors = 0;
a = 8'hFF; b = 8'h01; check; // -1 vs 1
a = 8'h80; b = 8'h00; check; // min vs 0
a = 8'h7F; b = 8'h80; check; // max vs min
a = 8'h00; b = 8'h00; check; // equal
a = 8'hFF; b = 8'hFF; check; // all-ones equal
for (t = 0; t < 200; t = t + 1) begin a = $random; b = $random; check; end
if (errors == 0) $display("PASS: mag_cmp correct for unsigned AND signed");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleVHDL makes the signedness decision the most explicit of the three, because the type carries it. numeric_std defines two distinct types — unsigned and signed — each with its own overloaded <, >, =. To compare, you cast the vector into the type you mean: unsigned(a) < unsigned(b) is the unsigned verdict, signed(a) < signed(b) is the signed one. There is no "default" to trip over — you literally cannot compare without naming the interpretation, which is exactly why VHDL is the clearest teacher here.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all; -- brings in signed/unsigned + their <,>,=
entity mag_cmp is
generic ( WIDTH : positive := 8 );
port (
a : in std_logic_vector(WIDTH-1 downto 0);
b : in std_logic_vector(WIDTH-1 downto 0);
eq : out std_logic;
lt_u : out std_logic; gt_u : out std_logic; -- UNSIGNED verdict
lt_s : out std_logic; gt_s : out std_logic -- SIGNED verdict
);
end entity;
architecture rtl of mag_cmp is
begin
-- Equality: bit-pattern compare, no numeric interpretation needed.
eq <= '1' when a = b else '0';
-- UNSIGNED: cast the vectors to numeric_std.unsigned, use its overloaded <, >.
lt_u <= '1' when unsigned(a) < unsigned(b) else '0';
gt_u <= '1' when unsigned(a) > unsigned(b) else '0';
-- SIGNED: cast the SAME vectors to numeric_std.signed. The cast is the whole
-- decision — signed() reads the MSB as a sign bit, so "FF" is -1 not 255.
lt_s <= '1' when signed(a) < signed(b) else '0';
gt_s <= '1' when signed(a) > signed(b) else '0';
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mag_cmp_tb is
end entity;
architecture sim of mag_cmp_tb is
constant WIDTH : positive := 8;
signal a, b : std_logic_vector(WIDTH-1 downto 0);
signal eq, lt_u, gt_u, lt_s, gt_s : std_logic;
procedure check(signal av, bv : in std_logic_vector;
signal qe, qlu, qgu, qls, qgs : in std_logic) is
variable e, lu, gu, ls, gs : std_logic;
begin
wait for 1 ns;
-- Independent reference models via numeric_std.
if av = bv then e := '1'; else e := '0'; end if;
if unsigned(av) < unsigned(bv) then lu := '1'; else lu := '0'; end if;
if unsigned(av) > unsigned(bv) then gu := '1'; else gu := '0'; end if;
if signed(av) < signed(bv) then ls := '1'; else ls := '0'; end if;
if signed(av) > signed(bv) then gs := '1'; else gs := '0'; end if;
assert qe = e report "eq mismatch" severity error;
assert qlu = lu report "lt_u mismatch" severity error;
assert qgu = gu report "gt_u mismatch" severity error;
assert qls = ls report "lt_s mismatch" severity error;
assert qgs = gs report "gt_s mismatch" severity error;
end procedure;
begin
dut : entity work.mag_cmp generic map (WIDTH => WIDTH)
port map (a => a, b => b, eq => eq, lt_u => lt_u, gt_u => gt_u,
lt_s => lt_s, gt_s => gt_s);
stim : process
begin
a <= x"FF"; b <= x"01"; check(a, b, eq, lt_u, gt_u, lt_s, gt_s); -- -1 vs 1
a <= x"80"; b <= x"00"; check(a, b, eq, lt_u, gt_u, lt_s, gt_s); -- min vs 0
a <= x"7F"; b <= x"80"; check(a, b, eq, lt_u, gt_u, lt_s, gt_s); -- max vs min
a <= x"00"; b <= x"00"; check(a, b, eq, lt_u, gt_u, lt_s, gt_s); -- equal
a <= x"FF"; b <= x"FF"; check(a, b, eq, lt_u, gt_u, lt_s, gt_s); -- all-ones equal
report "mag_cmp self-check complete" severity note;
wait;
end process;
end architecture;4c. The signed-as-unsigned 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 saturating accumulator whose data is signed (it can go negative), but whose clamp comparator was written with a plain unsigned compare. The intent is "if the accumulator exceeds a positive threshold, clamp it." The bug: a negative accumulator (MSB set, e.g. -1 = all-ones) reads as a large unsigned number, so it satisfies acc > THRESH and gets clamped when it should not, while a large positive value below the unsigned interpretation may slip through. It passes every positive-only simulation and produces wrong behaviour the moment real signed data goes negative.
// BUGGY: acc is SIGNED data, but the clamp uses a plain (unsigned) compare.
// For acc = 8'hFF (-1), the unsigned view is 255 > THRESH => it CLAMPS
// a small negative number as if it were huge. Silent in positive sims.
module sat_clamp_bad #(parameter int WIDTH = 8, parameter int THRESH = 100) (
input logic [WIDTH-1:0] acc,
output logic [WIDTH-1:0] y
);
// acc compared UNSIGNED: 8'hFF reads as 255, not -1 -> wrong clamp.
assign y = (acc > THRESH) ? THRESH[WIDTH-1:0] : acc;
endmodule
// FIXED: declare the comparison SIGNED so the MSB is a sign bit. Now -1 < THRESH
// and only genuinely-large POSITIVE accumulations clamp.
module sat_clamp_good #(parameter int WIDTH = 8, parameter int THRESH = 100) (
input logic [WIDTH-1:0] acc,
output logic [WIDTH-1:0] y
);
// $signed() makes the compare two's-complement: 8'hFF is -1, so -1 > 100 is false.
assign y = ($signed(acc) > $signed(THRESH)) ? THRESH[WIDTH-1:0] : acc;
endmodule module sat_clamp_tb;
localparam int WIDTH = 8, THRESH = 100;
logic [WIDTH-1:0] acc, y;
logic [WIDTH-1:0] exp;
int errors = 0;
// Bind to sat_clamp_good to PASS; to _bad to watch -1 get wrongly clamped.
sat_clamp_good #(.WIDTH(WIDTH), .THRESH(THRESH)) dut (.acc(acc), .y(y));
task check;
input signed [WIDTH:0] sacc; // signed reference value
begin
acc = sacc[WIDTH-1:0];
#1;
// Correct behaviour is SIGNED: clamp only when the true value > THRESH.
exp = ($signed(acc) > THRESH) ? THRESH[WIDTH-1:0] : acc;
assert (y === exp)
else begin $error("acc=%h (%0d) y=%h exp=%h", acc, $signed(acc), y, exp); errors++; end
end
endtask
initial begin
check(0); check(50); check(99); // below threshold, positive
check(101); check(127); // above threshold, must clamp
check(-1); check(-50); check(-128); // NEGATIVE — the buggy version wrongly clamps these
if (errors == 0) $display("PASS: sat_clamp clamps only genuinely-large positive acc");
else $display("FAIL: %0d mismatches (signed data compared unsigned?)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same story. The bug is a plain acc > THRESH on signed data; the fix is $signed(acc) > $signed(THRESH). Driving negative values (patterns with the MSB set) in the testbench is what surfaces it — a positive-only sweep never does.
// BUGGY: acc holds signed data, but the compare is unsigned. 8'hFF (-1) reads as
// 255 > THRESH and gets clamped wrongly.
module sat_clamp_bad #(parameter WIDTH = 8, parameter THRESH = 100) (
input wire [WIDTH-1:0] acc,
output wire [WIDTH-1:0] y
);
assign y = (acc > THRESH) ? THRESH[WIDTH-1:0] : acc; // UNSIGNED compare -> wrong
endmodule
// FIXED: $signed makes the compare two's-complement; -1 > 100 is false.
module sat_clamp_good #(parameter WIDTH = 8, parameter THRESH = 100) (
input wire [WIDTH-1:0] acc,
output wire [WIDTH-1:0] y
);
assign y = ($signed(acc) > $signed(THRESH)) ? THRESH[WIDTH-1:0] : acc;
endmodule module sat_clamp_tb;
parameter WIDTH = 8, THRESH = 100;
reg [WIDTH-1:0] acc;
wire [WIDTH-1:0] y;
reg [WIDTH-1:0] exp;
integer errors;
// Bind to _good to PASS; to _bad to observe -1 being clamped.
sat_clamp_good #(.WIDTH(WIDTH), .THRESH(THRESH)) dut (.acc(acc), .y(y));
task check;
input signed [WIDTH:0] sacc;
begin
acc = sacc[WIDTH-1:0];
#1;
exp = ($signed(acc) > THRESH) ? THRESH[WIDTH-1:0] : acc; // signed reference
if (y !== exp) begin
$display("FAIL: acc=%h (%0d) y=%h exp=%h", acc, $signed(acc), y, exp);
errors = errors + 1;
end
end
endtask
initial begin
errors = 0;
check(0); check(50); check(99); // positive, below threshold
check(101); check(127); // positive, must clamp
check(-1); check(-50); check(-128); // NEGATIVE — exposes the unsigned bug
if (errors == 0) $display("PASS: sat_clamp clamps only large positive acc");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the bug wears its cause on its sleeve: the buggy version casts acc to unsigned before comparing, the fixed version casts it to signed. Because you must choose a type to compare in numeric_std, the fix is not "add a cast" but "cast to the right type" — the wrong type is a deliberate-looking mistake, which is exactly why code review catches it faster in VHDL.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: signed data compared as UNSIGNED. unsigned("FF") = 255 > THRESH => clamps -1.
entity sat_clamp_bad is
generic ( WIDTH : positive := 8; THRESH : natural := 100 );
port ( acc : in std_logic_vector(WIDTH-1 downto 0);
y : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of sat_clamp_bad is
begin
y <= std_logic_vector(to_unsigned(THRESH, WIDTH))
when unsigned(acc) > to_unsigned(THRESH, WIDTH) -- WRONG interpretation
else acc;
end architecture;
-- FIXED: compare as SIGNED. signed("FF") = -1, so -1 > THRESH is false; only
-- genuinely large positive acc clamps.
entity sat_clamp_good is
generic ( WIDTH : positive := 8; THRESH : integer := 100 );
port ( acc : in std_logic_vector(WIDTH-1 downto 0);
y : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of sat_clamp_good is
begin
y <= std_logic_vector(to_signed(THRESH, WIDTH))
when signed(acc) > to_signed(THRESH, WIDTH) -- RIGHT interpretation
else acc;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity sat_clamp_tb is
end entity;
architecture sim of sat_clamp_tb is
constant WIDTH : positive := 8;
constant THRESH : integer := 100;
signal acc : std_logic_vector(WIDTH-1 downto 0);
signal y : std_logic_vector(WIDTH-1 downto 0);
procedure check(constant v : in integer;
signal accs : out std_logic_vector;
signal ys : in std_logic_vector) is
variable exp : std_logic_vector(WIDTH-1 downto 0);
begin
accs <= std_logic_vector(to_signed(v, WIDTH));
wait for 1 ns;
-- Correct behaviour is SIGNED: clamp only when the true value > THRESH.
if v > THRESH then exp := std_logic_vector(to_signed(THRESH, WIDTH));
else exp := std_logic_vector(to_signed(v, WIDTH)); end if;
assert ys = exp report "sat_clamp mismatch (signed data compared unsigned?)" severity error;
end procedure;
begin
-- Bind to sat_clamp_good to PASS; to _bad to observe -1 being clamped.
dut : entity work.sat_clamp_good generic map (WIDTH => WIDTH, THRESH => THRESH)
port map (acc => acc, y => y);
stim : process
begin
check(0, acc, y); check(50, acc, y); check(99, acc, y); -- positive, below
check(101, acc, y); check(127, acc, y); -- positive, clamp
check(-1, acc, y); check(-50, acc, y); check(-128, acc, y); -- NEGATIVE — exposes bug
report "sat_clamp self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: a magnitude comparator has no default signedness worth trusting — declare $signed/signed/numeric_std.signed on purpose, or the tool compares your signed data as unsigned and quietly returns the wrong verdict on every negative operand.
5. Verification Strategy
A comparator is combinational, so its correctness is a truth table — but unlike a mux, that table is too large to sweep exhaustively for realistic widths (2^(2*WIDTH) operand pairs). The strategy is therefore directed corners plus randomized comparison against a reference model, with the signedness question front and centre. The single invariant that captures a correct magnitude comparator is:
Under a declared interpretation, exactly one of
eq,lt,gtis true, and it agrees with an independent reference model computed under that same interpretation.
Everything below is that invariant made checkable, and it maps directly onto the testbenches in §4.
- Signed AND unsigned reference models (self-checking). Compute the expected verdict two ways — once unsigned, once signed — from an independent model, and check the DUT's
lt_u/gt_uagainst the unsigned model andlt_s/gt_sagainst the signed model. The threemag_cmptestbenches in §4b do exactly this: SVassert (lt_s === ($signed(a) < $signed(b))), Verilog theif (lt_s !== …) $display("FAIL")form, VHDLassert qls = ls report … severity error. Comparing against both models on every vector is what proves you built the interpretation you meant. - Boundary values (max, min, zero, -1). The interesting operands are the ones where signed and unsigned disagree — everything with the MSB set. Drive, at minimum:
0,-1(all-ones), unsignedmax(all-ones — the same pattern as-1, which is the whole point), signedmax(0111…1), signedmin(1000…0), and pairs that straddle the sign boundary such asmaxvsmin. Fora = -1, b = 1the unsigned model saysgt, the signed model sayslt— a vector where a single-interpretation testbench would silently pass a wrong DUT. - The equal case explicitly. Equality is easy to under-test. Drive
a == bdeliberately (including the all-ones pattern, to proveeqis signedness-blind) and confirmeqfires while bothltandgtstay low under both interpretations. Half the random stimulus should forcea == b(as the §4a testbenches do withb = t[0] ? a : $random), because random pairs almost never collide. - eq/lt/gt mutually exclusive and exhaustive. For each interpretation, assert
eq + lt + gt == 1on every vector — a free, powerful invariant that catches a whole class of bugs (a comparator that fires two verdicts, or none) without a reference model. The §4b testbenches check(eq + lt_u + gt_u) == 1and(eq + lt_s + gt_s) == 1on every stimulus. - Parameter-generic verification (WIDTH = 1, 8, 32). A parameterized comparator must be verified generic. Re-run the corner + random suite for
WIDTH = 1(the degenerate case — one bit, where signedminis-1and the signed/unsigned split is starkest),WIDTH = 8, andWIDTH = 32(where the reference model itself must be careful not to truncate). A comparator that passes at 8 bits can still be wrong at 32 if a reference-model width was left implicit. - Failure mode and expected waveform. The failure this ties to is §7's signed-as-unsigned escape: the tell is that the DUT agrees with the unsigned model even on operands you declared signed. On a waveform, a correct signed comparator shows
ltrising asasweeps from0down through-1towardminwhilebstays at1; a broken (unsigned) one showsgtthere instead — the visual signature of the wrong interpretation. Because there is no clock, "correct" is immediate: as the operands change, exactly one verdict bit tracks them within the propagation delay, and it never shows two verdicts at once.
6. Common Mistakes
The signed/unsigned mismatch — the signature comparator bug. Comparing signed data with an unsigned operator (or vice versa) produces a silently wrong verdict on every operand whose MSB is set. In SystemVerilog/Verilog, plain vectors compare unsigned by default, so a < b on signed data reads -1 as 2^WIDTH - 1 (a huge positive) — the comparison is confidently, quietly wrong. The fix is to declare the interpretation: $signed(a) < $signed(b), or declare the ports signed, or in VHDL cast to numeric_std.signed. This is the one comparator mistake that ships to silicon because a positive-only testbench never exercises the operands where it differs.
Mixing signed and unsigned in one expression. In Verilog/SystemVerilog, if any operand of a comparison is unsigned, the whole comparison is evaluated unsigned — a single unsigned term silently demotes a signed compare. Writing $signed(a) < b (where b is a plain unsigned vector) does not give you a signed comparison; b drags the whole expression unsigned and a is re-read as unsigned too. Both operands must be signed for a signed compare: $signed(a) < $signed(b). This is the subtle cousin of the mismatch above and is even easier to miss because one operand looks correctly cast.
Width-mismatch zero-extension surprises. Comparing operands of different widths (or an operand against a literal) triggers implicit extension, and the kind of extension depends on signedness: unsigned operands zero-extend, signed operands sign-extend. Compare a signed 8'shFF (-1) against a wider unsigned value and the narrower one may be zero-extended to 00FF (255) instead of sign-extended to FFFF (-1), flipping the answer. Match widths deliberately, and be explicit about signedness before the widths are reconciled — never let the tool pick the extension for you.
Paying for a magnitude compare when you only need equality. a == b is a cheap log2(WIDTH)-deep XNOR-AND tree; a < b is a subtractor whose delay grows with width. If the question is genuinely "are these equal?" (an address match, a tag hit, a terminal count == N-1), use equality — don't reach for <=/>= out of habit and synthesize a full subtractor where an XNOR tree would do.
Chained redundant compares where one subtract suffices. Writing if (a < b) … else if (a == b) … else … as three independent relational operators can synthesize three comparators (three subtracts) when one subtraction already produces eq, lt, and gt together. A single magnitude comparator emits all three verdicts from one carry chain; deriving them from one subtract (as §4b does) is smaller and faster than three separate relational expressions the tool may or may not merge.
Assuming >=/<= are "free" extra comparators. a >= b is just !(a < b) and a <= b is !(a > b) — the same subtractor, one inverter. They are not separate hardware, but they inherit the same signedness trap: a >= b on signed data compared unsigned is exactly as wrong as a > b was. Declaring signedness fixes all the relational operators at once, because they all read the one subtract.
7. DebugLab
The saturating datapath that never clamped — signed data compared as unsigned
The engineering lesson: signedness is a declared decision, not a default — and a magnitude comparator that inherits its default is silently wrong on exactly the operands your positive-only tests never drive. The tell is a bug that appears only when a value goes negative: the very same silicon that is correct on non-negative inputs flips its verdict the instant the MSB is set, because the tool chose "unsigned" for you and read the sign bit as a value bit. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: declare the interpretation explicitly at every magnitude compare ($signed/signed/numeric_std.signed, both operands), and verify with negative and boundary operands against a signed reference model so no signed value goes untested. Equality never has this bug; magnitude always can.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "which comparator, which signedness?" habit.
Exercise 1 — Pick the comparator for the decision
For each datapath decision, state whether you'd use an equality or a magnitude comparator, and (for magnitude) whether the operands are signed or unsigned, in one line each: (a) a down-counter's terminal count reaching zero; (b) a FIFO raising almost_full when occupancy passes three-quarters depth; (c) a peripheral's address decoder matching an incoming address to its base; (d) a signed audio sample saturating when it exceeds a positive clip level. (Hint: "hit exactly this value" vs "crossed a level," and "is the data itself allowed to be negative?")
Exercise 2 — Predict the verdict, both ways
Two 8-bit operands are a = 8'hF0 and b = 8'h10. State the value of eq, lt, gt (i) when both are read as unsigned and (ii) when both are read as signed, showing the decimal values you compared in each case. Then explain in one sentence why the two interpretations give opposite lt/gt answers even though the input bits never changed, and identify which single bit of a is responsible.
Exercise 3 — Find the signedness trap
An engineer writes a signed threshold check as assign over = ($signed(sample) > LIMIT);, where sample is a signed 12-bit sample and LIMIT is a plain (unsigned) 12-bit parameter. The block passes on positive samples and misbehaves on negative ones. (i) State precisely why the $signed cast did not make this a signed comparison. (ii) Give the corrected line. (iii) Name the general Verilog rule this violates, and describe the one class of stimulus that would have caught it in simulation.
Exercise 4 — One subtract, three verdicts
You need eq, lt, and gt for a pair of signed 16-bit operands. Engineer A writes three independent relational expressions (==, <, >). Engineer B computes a - b once and derives all three from the result's zero flag and overflow-corrected sign. (i) Describe how B derives each of the three verdicts from the single subtraction. (ii) Explain why B's approach is typically smaller and faster, and (iii) state the one situation in which A's version might synthesize to the same hardware anyway (hint: what the synthesis tool is allowed to do with common subexpressions).
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- Adders & Subtractors — Chapter 1.6; the subtractor a magnitude comparator is — carry/borrow chains, overflow, and the sign logic this page reads for
lt/gt. - Up/Down Counters — Chapter 3; the terminal-count equality comparator (
count == N-1,== 0) in its natural home. - Mod-N Counters — Chapter 3; wrap-at-N built on an equality compare against the modulus.
- FIFO Full/Empty Logic — Chapter 7; the almost-full/almost-empty magnitude comparators, and why occupancy must be compared unsigned with a deliberate pointer width.
- Encoding & Conversions — Chapter 5; sign-extension and signed/unsigned conversion, the width-and-interpretation rules that feed a comparator's operands.
Backward / method:
- Multiplexers (2:1, N:1, one-hot) — Chapter 1.1; the datapath's selection primitive — the comparator's
eq/lt/gtverdict is exactly the kind of one-bit control that steers a mux. - Decoders — Chapter 1.2; an address-match decoder is a bank of equality comparators against fixed constants.
- Encoders — Chapter 1.3; the converse structure that turns a one-hot verdict back into an index.
- Priority Encoders — Chapter 1.4; ordered selection over multiple comparisons, the priority cousin of a parallel verdict.
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this page applied to the comparator (pure datapath, no state — its verdict feeds control).
- What Is an RTL Design Pattern? — Chapter 0.1; why the comparator earns the name "pattern" — a recurring decision, a reusable form, a synthesis reality, and a signature failure (signedness).
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Relational Operators — the
<,>,<=,>=,==operators behind every comparator, and how they evaluate. - Arithmetic Operators — the subtraction a magnitude comparator reads its verdict from, and the mixed-signedness evaluation rule.
- Bitwise & Reduction Operators — the XNOR (
~^) and reduction-AND (&) that build the equality comparator's tree. - Physical Data Types (signed/unsigned) — where
signed/unsigned declaration lives, the source of the signedness decision this whole page turns on. - Continuous Assignments — the
assign/ dataflow form every comparator in §4 is written in.
11. Summary
- A comparator is the datapath's decision primitive. It reduces two multi-bit operands to a one-bit verdict —
eq,lt, orgt— and hides inside almost every sequential pattern: a counter's terminal count, a FIFO's flags, a threshold clamp. It turns arithmetic into control. - Two structures, one danger. Equality is XNOR-then-reduce-AND (
eq = &(a ~^ b)), a balancedlog2(WIDTH)tree that is cheap and signedness-blind. Magnitude is a subtractor —a - bwith the difference thrown away — read from the carry (unsigned) or the overflow-corrected sign (signed); its delay scales withWIDTH. Use equality when you only need "equal?"; don't pay for a subtractor. - eq/lt/gt are mutually exclusive and exhaustive. Under one interpretation exactly one fires — a free verification invariant (
eq + lt + gt == 1) that catches broken comparators without a reference model. - Signedness is a declared decision, never a default. The same bits compare as a small negative or a huge positive depending only on
$signed/signed/numeric_std.signed. Plain vectors compare unsigned, and in Verilog any unsigned operand demotes the whole expression — so both operands must be declared signed for a signed compare. Width mismatches inherit the same trap through zero- vs sign-extension. - The signature bug is signed-data-compared-unsigned — and it ships. A magnitude compare with no declared signedness reads
-1as2^WIDTH - 1, silently wrong on every negative operand and invisible to a positive-only sim (§7). Verify with negative and boundary operands (-1,0, signedmax/min) against a signed reference model, parameter-generic (WIDTH = 1, 8, 32) — self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL.