Skip to content

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 answers255 < 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.

the-need.v — the same question, four comparators, one signedness trap
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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 flow
The comparator family — one equality tree, one subtractor, two ways to read the signoperands a, bWIDTH bits each, a declared typeequality: XNOR +ANDeq = &(a ~^ b), signedness-blindmagnitude: a - bripple/borrow, delay ~ WIDTHread carry-outUNSIGNED lt/gt (borrow past MSB)read sign XOR ovfSIGNED lt/gt (corrected sign bit)eq / lt / gtmutually exclusive & exhaustive
Equality is a balanced XNOR-AND tree (delay ~log2(WIDTH)) and never depends on sign. Magnitude is a single subtractor (delay ~WIDTH) whose result feeds TWO different readings: unsigned lt/gt reads the carry/borrow-out, signed lt/gt reads the overflow-corrected sign bit. The declared type of the operands is the ONLY thing that selects which reading is correct — the exact same subtractor bits give opposite verdicts on the top half of the range. This is language-independent: the same two structures and the same signedness choice exist in SystemVerilog, Verilog, and VHDL.

Width, 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.

eq_cmp.sv — parameterized equality comparator (XNOR-reduce, signedness-blind)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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)
   endmodule

The 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.

eq_cmp_tb.sv — self-checking: eq === (a == b), incl. the all-ones pattern
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

eq_cmp.v — parameterized equality comparator in Verilog (reduce-XNOR)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
eq_cmp_tb.v — self-checking Verilog testbench (compare, print PASS/FAIL)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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.

eq_cmp.vhd — parameterized equality comparator in VHDL (std_logic_vector '=')
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
eq_cmp_tb.vhd — self-checking VHDL testbench (assert report severity)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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.

mag_cmp.sv — magnitude comparator emitting eq/lt/gt for unsigned AND signed
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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));
   endmodule

Two 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.

mag_cmp_tb.sv — self-checking: signed AND unsigned corners incl. -1/0/max/min
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.)

mag_cmp.v — magnitude comparator in Verilog (unsigned raw, signed via $signed)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
mag_cmp_tb.v — self-checking; drives -1/0/max/min + random, checks all six
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

VHDL 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.

mag_cmp.vhd — magnitude comparator in VHDL (numeric_std unsigned vs signed types)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
mag_cmp_tb.vhd — self-checking; -1/0/max/min corners (assert report severity)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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.

sat_clamp.sv — BUGGY (unsigned compare on signed data) vs FIXED ($signed)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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
sat_clamp_tb.sv — the NEGATIVE vector exposes the bug the positive sim missed
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

sat_clamp.v — BUGGY vs FIXED in Verilog
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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
sat_clamp_tb.v — self-checking; negative vectors catch the unsigned-compare bug
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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.

sat_clamp.vhd — BUGGY (unsigned cast) vs FIXED (signed cast)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
sat_clamp_tb.vhd — self-checking; negative corners (assert report severity)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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, gt is 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_u against the unsigned model and lt_s/gt_s against the signed model. The three mag_cmp testbenches in §4b do exactly this: SV assert (lt_s === ($signed(a) < $signed(b))), Verilog the if (lt_s !== …) $display("FAIL") form, VHDL assert 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), unsigned max (all-ones — the same pattern as -1, which is the whole point), signed max (0111…1), signed min (1000…0), and pairs that straddle the sign boundary such as max vs min. For a = -1, b = 1 the unsigned model says gt, the signed model says lt — 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 == b deliberately (including the all-ones pattern, to prove eq is signedness-blind) and confirm eq fires while both lt and gt stay low under both interpretations. Half the random stimulus should force a == b (as the §4a testbenches do with b = t[0] ? a : $random), because random pairs almost never collide.
  • eq/lt/gt mutually exclusive and exhaustive. For each interpretation, assert eq + lt + gt == 1 on 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) == 1 and (eq + lt_s + gt_s) == 1 on 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 signed min is -1 and the signed/unsigned split is starkest), WIDTH = 8, and WIDTH = 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 lt rising as a sweeps from 0 down through -1 toward min while b stays at 1; a broken (unsigned) one shows gt there 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).

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/gt verdict 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):

11. Summary

  • A comparator is the datapath's decision primitive. It reduces two multi-bit operands to a one-bit verdict — eq, lt, or gt — 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 balanced log2(WIDTH) tree that is cheap and signedness-blind. Magnitude is a subtractora - b with the difference thrown away — read from the carry (unsigned) or the overflow-corrected sign (signed); its delay scales with WIDTH. 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 -1 as 2^WIDTH - 1, silently wrong on every negative operand and invisible to a positive-only sim (§7). Verify with negative and boundary operands (-1, 0, signed max/min) against a signed reference model, parameter-generic (WIDTH = 1, 8, 32) — self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL.