Skip to content

RTL Design Patterns · Chapter 12 · Parameterized & Reusable RTL

Width-Generic Datapaths

A width-generic datapath is written once so a single WIDTH parameter sizes the whole thing, instead of copying the file and editing widths until you own four divergent copies. The subtlety is that a datapath is more than its ports: adding two WIDTH-bit numbers gives a WIDTH-plus-one-bit sum, a product is twice as wide, and an accumulator needs guard bits. Route any of those intermediates through a WIDTH-bit wire and the top bits fall off, a truncation that stays invisible until real data overflows in the field. This lesson treats width-genericity as a contract: derive every bus from one parameter, keep the control logic width-independent, size each intermediate for its true range, and sign-extend properly. Everything is verified across a swept WIDTH with overflowing and signed operands in SystemVerilog, Verilog, and VHDL.

Intermediate14 min readRTL Design PatternsWidth-GenericDatapathParameterized RTLSign ExtensionIntermediate Width Growth

Chapter 12 · Section 12.4 · Parameterized & Reusable RTL

1. The Engineering Problem

Your team shipped a multiply-accumulate engine — acc = a*b + c — at 16 bits, and it passed every test. Now three things happen at once. The audio path wants it at 24 bits. A sensor front-end wants it at 12. And a new accelerator wants a 64-bit version for address arithmetic. You have one engine and four required widths, and the deadline is the same for all of them.

The tempting answer is to copy the file three times and edit the widths until each compiles. That is the answer that ends careers slowly: now there are four modules that are supposed to be identical, a bug fixed in one lives on in the other three, and every review has to check four things instead of one. The reusable answer is to write the engine once, with a single WIDTH parameter, so a 24-bit instance and a 64-bit instance are the same source elaborated at two sizes. That much you learned in 12.1: one module, many sizes.

But there is a trap hiding in that engine that parameterizing the ports does not fix, and it is the whole reason this page exists. A datapath computes, and computation grows width. Add two WIDTH-bit numbers and the true sum is WIDTH+1 bits — the extra bit is the carry-out. Multiply two WIDTH-bit numbers and the true product is 2*WIDTH bits. Accumulate over N additions and the running total needs WIDTH + ceil(log2(N)) bits to never overflow. If any of those intermediate values passes through a bus you declared [WIDTH-1:0] — because WIDTH is the port width and it seemed natural — the high bits are silently discarded. And the ugliest property of that discard is that it is invisible at small values: at WIDTH=8, if your testbench happens to use operands whose sum stays under 256, the truncated bus and the correct one produce identical results. The bug ships. It surfaces months later, at a width and a data value nobody re-tested, as a result that is quietly, occasionally wrong.

the-need.v — one engine, many widths, and the intermediate that grows
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // One parameterized MAC, instantiated at whatever width the block needs:
   //   mac #(.WIDTH(24)) audio  (...);
   //   mac #(.WIDTH(12)) sensor (...);
   //   mac #(.WIDTH(64)) addr   (...);   // same SOURCE, four sizes
 
   // The trap: a*b is 2*WIDTH bits, and c added on needs a guard bit.
   //   wire [WIDTH-1:0] prod = a * b;     // WRONG — top WIDTH bits fall off
   //   wire [WIDTH-1:0] acc  = acc + prod;// WRONG — carry silently lost
   // Small test operands hide it; real data at scale exposes the dropped carry.
 
   // RIGHT: size each INTERMEDIATE for its true range, derived from WIDTH:
   //   wire [2*WIDTH-1:0]         prod = a * b;         // product is 2*WIDTH
   //   wire [2*WIDTH+GUARD-1:0]   acc  = acc + prod;    // + guard bits to accumulate
   // ...then round/saturate/truncate back to the output width ON PURPOSE.

2. Mental Model

3. Pattern Anatomy

A width-generic datapath has four moving parts: the one parameter, the derived widths, the width-growth budget, and the width-independent control that drives it.

Part 1 — the single WIDTH parameter, and localparams for everything derived. There is exactly one primary knob, WIDTH. Every other size is a localparam computed from it, so no dependent width can drift: SUMW = WIDTH+1 for an add, PRODW = 2*WIDTH for a multiply, ACCW = 2*WIDTH + GUARD for the accumulator, CNTW = clog2(N) for a loop counter. Typing a second literal width anywhere is how the trap gets in (12.1's magic-literal failure, now sized to hurt).

Part 2 — where the width GROWS, and by how much. This is the load-bearing table of the pattern. Every arithmetic operator has a known output-width rule:

  • Add / subtract: two WIDTH-bit operands produce a WIDTH+1-bit result. The extra bit is the carry-out (add) or the borrow/sign (subtract). Size the sum bus [WIDTH:0], never [WIDTH-1:0].
  • Multiply: a WIDTH-by-WIDTH product is 2*WIDTH bits. A 32-by-32 multiply is genuinely 64 bits; a [WIDTH-1:0] product bus keeps only the low half.
  • Accumulate over N terms: summing N values of width W needs W + ceil(log2(N)) bits so the running total never overflows. Those ceil(log2(N)) extra bits are the accumulator's guard bits. Skip them and the accumulator wraps after enough adds.

Part 3 — sign extension when widening (signed, done generically). When a signed value is placed into a wider field, the new high bits must be copies of its sign bit, not zeros. Zero-filling a negative number turns it into a large positive number — a silent sign-loss bug. Done generically, this is {{(NEW-OLD){x[OLD-1]}}, x} in SystemVerilog/Verilog (replicate the top bit of the narrow value) or resize(signed_value, NEW) in VHDL numeric_std — sign extension driven by the width difference, never wired for a fixed width. Choosing signed vs unsigned is a per-signal decision the RTL must make explicit, because the same bits widen differently.

Part 4 — the width-independent control, and the deliberate narrowing back. The controller (5.6) sequences the operations over a narrow interface — control down (enables, selects, load), status up (a ZERO or DONE flag) — and is written entirely in terms of those signals, so it is identical at every WIDTH. And because the wide intermediate eventually has to land in a WIDTH-bit (or fixed-output-width) register, the datapath must deliberately bring it back: round-to-nearest, saturate to the max/min representable, or truncate — a chosen policy (12.5's territory), never an accidental drop.

Width-generic datapath — one knob scales the data, the control stays fixed

data flow
Width-generic datapath — one knob scales the data, the control stays fixedWIDTH parameterthe one knob; all sizes derive from itoperands a, b, ceach WIDTH bits, derivedproduct a*bgrows to 2*WIDTH bitsaccumulate +guard2*WIDTH + ceil(log2 N) guard bitsround / saturatebackdeliberate narrow to output widthcontrol FSMwidth-INDEPENDENT: signals, not data
One WIDTH parameter sizes every data element (operands, product, accumulator, output), and the intermediates GROW along the way — the product to 2*WIDTH, the accumulator by guard bits — before a deliberate narrow brings the result back to the output width. The control FSM sits to the side driving enables and selects and branching on a status flag; it never touches a data bit, so it is identical at WIDTH=8 and WIDTH=64. Widen the datapath and every data box gets bigger while the control box is unchanged. This split is language-independent: it holds in SystemVerilog, Verilog, and VHDL.

The anatomy closes on the failure it prevents: the moment any intermediate box in that diagram is drawn WIDTH bits wide instead of its true WIDTH+1 / 2*WIDTH / WIDTH+GUARD size, the datapath stops being width-generic — it is correct only for operand values small enough that the missing high bits happen to be zero. That is the truncated-intermediate bug of §6 and §7, and the only way to catch it is to sweep both WIDTH and values that overflow.

4. Real RTL Implementation

This is the core of the page. We build two things and show each in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking testbench: (a) a width-generic multiply-accumulate datapath with correctly-sized intermediates (product 2*WIDTH, accumulator with guard bits) driven by a width-independent control FSM, verified across a swept WIDTH with overflowing and signed operands; and (b) the truncated-intermediate bug versus the correctly-sized fix, where the buggy version passes at small values and fails on overflow.

One discipline runs through every block: every intermediate width is derived from WIDTH by a localparam, sized for its true range, and the narrow-back to the output width is deliberate. The control never references a data width — that is what keeps it width-independent.

4a. Width-generic MAC — datapath + width-independent control

The engine computes acc = a*b + c. The product is 2*WIDTH bits (PRODW), and because we accumulate the product with c (and, in a looping variant, over several cycles) the accumulator carries GUARD extra bits (ACCW = PRODW + GUARD) so the running total never wraps. The output is then narrowed deliberately. The FSM sequences LOAD, MUL, ACC, DONE over a narrow interface and is written with no reference to WIDTH at all.

mac.sv — width-generic MAC: intermediates sized from WIDTH, width-independent FSM
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module mac #(
       parameter int WIDTH = 16,                          // the ONE knob
       parameter int GUARD = 4                             // accumulator guard bits (log2 of #adds)
   )(
       input  logic                clk,
       input  logic                rst,                    // synchronous, active-high
       input  logic                start,                  // pulse: begin a*b + c
       input  logic [WIDTH-1:0]    a, b, c,                // operands, each WIDTH bits
       output logic [WIDTH-1:0]    result,                 // deliberately narrowed output
       output logic                done
   );
       // Intermediate widths DERIVED from WIDTH — never typed independently:
       localparam int PRODW = 2*WIDTH;                     // a*b is 2*WIDTH bits
       localparam int ACCW  = PRODW + GUARD;               // accumulator + guard bits
 
       typedef enum logic [1:0] {IDLE, MUL, ACC, FIN} state_t;
       state_t state;                                      // control state: NO data width here
 
       logic [PRODW-1:0] prod;                             // full-width product register
       logic [ACCW-1:0]  acc;                              // full-width accumulator
 
       always_ff @(posedge clk) begin
           if (rst) begin
               state <= IDLE; done <= 1'b0; acc <= '0; prod <= '0;
           end else begin
               done <= 1'b0;
               case (state)                                // WIDTH-INDEPENDENT control
                   IDLE: if (start) state <= MUL;
                   MUL: begin
                       prod  <= a * b;                     // product sized 2*WIDTH — no truncation
                       state <= ACC;
                   end
                   ACC: begin
                       // c widened into the accumulator width before the add (unsigned here):
                       acc   <= prod + {{(ACCW-WIDTH){1'b0}}, c};
                       state <= FIN;
                   end
                   FIN: begin
                       done  <= 1'b1;                      // deliberate narrow: low WIDTH bits
                       state <= IDLE;                      // (round/saturate is 12.5 — truncate shown)
                   end
               endcase
           end
       end
 
       // Bring the wide accumulator back to the output width ON PURPOSE (truncate low bits):
       assign result = acc[WIDTH-1:0];
   endmodule

The two load-bearing lines are localparam int PRODW = 2*WIDTH and localparam int ACCW = PRODW + GUARD: they make the product and accumulator grow with WIDTH, so a 32-bit instance gets a genuine 64-bit product and a 68-bit accumulator without a single edited literal. The FSM mentions no data width, so it is byte-for-byte identical at WIDTH=8 and WIDTH=64 — that is width-independence. The testbench instantiates the MAC at several widths and, at each, drives operands that overflow a WIDTH-bit result, checking the wide acc against a full-precision reference computed in a wide integer.

mac_tb.sv — sweep WIDTH via generate; feed overflowing operands; check vs wide reference
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module mac_tb;
       // One generate loop instantiates the checker at each WIDTH in the sweep.
       genvar gi;
       generate
           for (gi = 0; gi < 4; gi++) begin : g_w
               localparam int W = (gi==0) ? 8 : (gi==1) ? 16 : (gi==2) ? 32 : 64;
               mac_checker #(.WIDTH(W)) chk ();          // each drives max-value operands
           end
       endgenerate
   endmodule
 
   module mac_checker #(parameter int WIDTH = 16, parameter int GUARD = 4);
       localparam int ACCW = 2*WIDTH + GUARD;
       logic clk = 0, rst, start, done;
       logic [WIDTH-1:0] a, b, c, result;
       int errors = 0;
 
       mac #(.WIDTH(WIDTH), .GUARD(GUARD)) dut
           (.clk(clk), .rst(rst), .start(start), .a(a), .b(b), .c(c), .result(result), .done(done));
 
       always #5 clk = ~clk;
 
       task run(input longint unsigned ia, ib, ic);
           logic [ACCW-1:0] ref_acc;
           a = ia[WIDTH-1:0]; b = ib[WIDTH-1:0]; c = ic[WIDTH-1:0];
           @(negedge clk) start = 1; @(negedge clk) start = 0;
           wait (done);
           // Full-precision reference in a WIDE accumulator — no truncation in the model:
           ref_acc = (a * b) + c;
           // The DUT's wide accumulator must match; result is the deliberate low-WIDTH slice.
           assert (result === ref_acc[WIDTH-1:0])
               else begin $error("W=%0d a=%h b=%h c=%h res=%h exp=%h", WIDTH, a, b, c, result, ref_acc[WIDTH-1:0]); errors++; end
       endtask
 
       initial begin
           rst = 1; start = 0; @(negedge clk) rst = 0;
           run(0, 0, 0);                                  // trivial
           run(1, 1, 0);                                  // trivial
           // OVERFLOWING operands: max*max exercises the product/carry growth path:
           run({WIDTH{1'b1}}, {WIDTH{1'b1}}, {WIDTH{1'b1}});
           run({WIDTH{1'b1}}, 2, {WIDTH{1'b1}});
           if (errors == 0) $display("PASS: mac correct at WIDTH=%0d incl. overflow", WIDTH);
           else             $display("FAIL: %0d mismatches at WIDTH=%0d", errors, WIDTH);
       end
   endmodule

The Verilog form is the same engine with reg/wire typing, an explicit state encoding, and $random-driven checks; the product and accumulator widths are still localparam-derived from WIDTH. Verilog cannot pass a longint reference around as cleanly, so the reference add is done in a wide reg sized 2*WIDTH+GUARD.

mac.v — width-generic MAC in Verilog (product 2*WIDTH, accumulator + guard bits)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module mac #(
       parameter WIDTH = 16,
       parameter GUARD = 4
   )(
       input  wire              clk,
       input  wire              rst,           // synchronous, active-high
       input  wire              start,
       input  wire [WIDTH-1:0]  a, b, c,
       output wire [WIDTH-1:0]  result,
       output reg               done
   );
       localparam PRODW = 2*WIDTH;             // product width derived from WIDTH
       localparam ACCW  = PRODW + GUARD;       // accumulator carries guard bits
       localparam IDLE=2'd0, MUL=2'd1, ACC=2'd2, FIN=2'd3;
 
       reg [1:0]       state;                  // control: no data width
       reg [PRODW-1:0] prod;
       reg [ACCW-1:0]  acc;
 
       always @(posedge clk) begin
           if (rst) begin
               state <= IDLE; done <= 1'b0; acc <= {ACCW{1'b0}}; prod <= {PRODW{1'b0}};
           end else begin
               done <= 1'b0;
               case (state)                    // WIDTH-INDEPENDENT
                   IDLE: if (start) state <= MUL;
                   MUL:  begin prod <= a * b; state <= ACC; end          // 2*WIDTH product
                   ACC:  begin
                       acc   <= prod + {{(ACCW-WIDTH){1'b0}}, c};        // widen c, then add
                       state <= FIN;
                   end
                   FIN:  begin done <= 1'b1; state <= IDLE; end
               endcase
           end
       end
       assign result = acc[WIDTH-1:0];         // deliberate narrow to output width
   endmodule
mac_tb.v — self-checking; loops over widths (instantiated) and overflowing operands
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module mac_tb;
       // Verilog cannot generate-loop a parameter through a task easily, so we
       // instantiate one checker per width; each self-checks against a wide reference.
       mac_checker #(.WIDTH(8))  c8  ();
       mac_checker #(.WIDTH(16)) c16 ();
       mac_checker #(.WIDTH(32)) c32 ();
   endmodule
 
   module mac_checker #(parameter WIDTH = 16, parameter GUARD = 4);
       localparam ACCW = 2*WIDTH + GUARD;
       reg               clk, rst, start;
       reg  [WIDTH-1:0]  a, b, c;
       wire [WIDTH-1:0]  result;
       wire              done;
       reg  [ACCW-1:0]   ref_acc;
       integer           errors;
 
       mac #(.WIDTH(WIDTH), .GUARD(GUARD)) dut
           (.clk(clk), .rst(rst), .start(start), .a(a), .b(b), .c(c), .result(result), .done(done));
 
       initial begin clk = 0; forever #5 clk = ~clk; end
 
       task run;
           input [WIDTH-1:0] ia, ib, ic;
           begin
               a = ia; b = ib; c = ic;
               @(negedge clk) start = 1; @(negedge clk) start = 0;
               wait (done);
               ref_acc = (a * b) + c;                    // wide reference, no truncation
               if (result !== ref_acc[WIDTH-1:0]) begin
                   $display("FAIL: W=%0d a=%h b=%h c=%h res=%h exp=%h",
                            WIDTH, a, b, c, result, ref_acc[WIDTH-1:0]);
                   errors = errors + 1;
               end
           end
       endtask
 
       initial begin
           errors = 0; rst = 1; start = 0; @(negedge clk) rst = 0;
           run({WIDTH{1'b0}}, {WIDTH{1'b0}}, {WIDTH{1'b0}});
           run({WIDTH{1'b1}}, {WIDTH{1'b1}}, {WIDTH{1'b1}});   // max*max: overflow path
           run({WIDTH{1'b1}}, 16'd2, {WIDTH{1'b1}});
           if (errors == 0) $display("PASS: mac correct at WIDTH=%0d incl. overflow", WIDTH);
           else             $display("FAIL: %0d mismatches at WIDTH=%0d", errors, WIDTH);
       end
   endmodule

VHDL expresses the width growth most explicitly through numeric_std: operands are unsigned, the product a*b is naturally 2*WIDTH bits, and widening c into the accumulator is a resize. Generics carry WIDTH and GUARD; the FSM is an enumerated type with no width reference.

mac.vhd — width-generic MAC in VHDL (numeric_std; product 2*WIDTH, resize for widening)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   entity mac is
       generic ( WIDTH : positive := 16; GUARD : positive := 4 );
       port (
           clk    : in  std_logic;
           rst    : in  std_logic;                                 -- synchronous, active-high
           start  : in  std_logic;
           a, b, c: in  std_logic_vector(WIDTH-1 downto 0);
           result : out std_logic_vector(WIDTH-1 downto 0);
           done   : out std_logic
       );
   end entity;
 
   architecture rtl of mac is
       constant PRODW : positive := 2*WIDTH;                       -- product width from WIDTH
       constant ACCW  : positive := PRODW + GUARD;                 -- accumulator + guard bits
       type state_t is (IDLE, MUL, ACC, FIN);
       signal state : state_t;                                     -- control: no data width
       signal prod  : unsigned(PRODW-1 downto 0);
       signal acc   : unsigned(ACCW-1 downto 0);
   begin
       process (clk)
       begin
           if rising_edge(clk) then
               if rst = '1' then
                   state <= IDLE; done <= '0';
                   acc <= (others => '0'); prod <= (others => '0');
               else
                   done <= '0';
                   case state is                                   -- WIDTH-INDEPENDENT
                       when IDLE => if start = '1' then state <= MUL; end if;
                       when MUL  =>
                           prod  <= unsigned(a) * unsigned(b);     -- 2*WIDTH product, no truncation
                           state <= ACC;
                       when ACC  =>
                           acc   <= prod + resize(unsigned(c), ACCW);  -- widen c, then add
                           state <= FIN;
                       when FIN  => done <= '1'; state <= IDLE;
                   end case;
               end if;
           end if;
       end process;
       -- Deliberate narrow back to the output width (truncate low bits):
       result <= std_logic_vector(acc(WIDTH-1 downto 0));
   end architecture;
mac_tb.vhd — self-checking; runs several widths, overflowing operands, assert vs wide reference
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   entity mac_tb is
   end entity;
 
   architecture sim of mac_tb is
       -- One generic component; the tb instantiates it at several widths below.
       component mac is
           generic ( WIDTH : positive; GUARD : positive );
           port ( clk, rst, start : in std_logic;
                  a, b, c : in std_logic_vector; result : out std_logic_vector; done : out std_logic );
       end component;
 
       constant WIDTH : positive := 8;                             -- swap 8/16/32/64 to re-verify
       constant GUARD : positive := 4;
       constant ACCW  : positive := 2*WIDTH + GUARD;
       signal clk   : std_logic := '0';
       signal rst, start, done : std_logic;
       signal a, b, c, result  : std_logic_vector(WIDTH-1 downto 0);
   begin
       clk <= not clk after 5 ns;
 
       dut : mac generic map (WIDTH => WIDTH, GUARD => GUARD)
           port map (clk => clk, rst => rst, start => start,
                     a => a, b => b, c => c, result => result, done => done);
 
       stim : process
           variable ref_acc : unsigned(ACCW-1 downto 0);
       begin
           rst <= '1'; start <= '0'; wait until rising_edge(clk); rst <= '0';
           -- OVERFLOWING operands: all-ones * all-ones exercises the product/carry growth:
           a <= (others => '1'); b <= (others => '1'); c <= (others => '1');
           wait until falling_edge(clk); start <= '1';
           wait until falling_edge(clk); start <= '0';
           wait until done = '1';
           -- Wide reference, no truncation:
           ref_acc := (unsigned(a) * unsigned(b)) + resize(unsigned(c), ACCW);
           assert unsigned(result) = ref_acc(WIDTH-1 downto 0)
               report "mac mismatch at this WIDTH (dropped carry?)" severity error;
           report "mac self-check complete" severity note;
           wait;
       end process;
   end architecture;

4b. The truncated-intermediate bug — buggy vs fixed, in all three HDLs

Here is the signature failure, shown as buggy vs fixed RTL. The buggy version routes the sum through a WIDTH-bit wire; the fix sizes it WIDTH+1. Both are latch-free and both pass when the operands are small enough that a+b fits in WIDTH bits — the bug only appears when the sum overflows.

addbug.sv — BUGGY (sum truncated to WIDTH) vs FIXED (sum sized WIDTH+1)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // BUGGY: the sum of two WIDTH-bit numbers is WIDTH+1 bits, but this bus is
   //        only WIDTH bits, so the carry-out is silently dropped. Passes when
   //        a+b < 2**WIDTH; wrong the moment a+b overflows.
   module add_bad #(parameter int WIDTH = 8) (
       input  logic [WIDTH-1:0] a, b,
       output logic [WIDTH-1:0] sum,          // <-- WIDTH bits: carry falls off
       output logic             carry         // ...and this carry is ALWAYS 0
   );
       assign sum   = a + b;                  // top bit truncated
       assign carry = 1'b0;                   // no way to recover it here
   endmodule
 
   // FIXED: size the sum for its TRUE range, WIDTH+1 bits, derived from WIDTH.
   module add_good #(parameter int WIDTH = 8) (
       input  logic [WIDTH-1:0] a, b,
       output logic [WIDTH:0]   sum           // <-- WIDTH+1 bits: carry preserved
   );
       // Zero-extend both operands to WIDTH+1 so the add keeps the carry-out:
       assign sum = {1'b0, a} + {1'b0, b};    // sum[WIDTH] IS the carry-out
   endmodule
addbug_tb.sv — small values PASS both; overflowing values expose the truncation
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module addbug_tb;
       localparam int WIDTH = 8;
       logic [WIDTH-1:0] a, b, sum_bad, carry_ignored;
       logic [WIDTH:0]   sum_good;
       int errors = 0;
 
       add_bad  #(.WIDTH(WIDTH)) bad  (.a(a), .b(b), .sum(sum_bad), .carry(carry_ignored));
       add_good #(.WIDTH(WIDTH)) good (.a(a), .b(b), .sum(sum_good));
 
       initial begin
           // SMALL values: a+b fits in WIDTH bits — BOTH match the reference.
           a = 8'd10; b = 8'd20; #1;
           assert (sum_bad === 8'd30 && sum_good === 9'd30);      // both pass, bug hidden
           // OVERFLOWING values: a+b = 510 needs 9 bits. Only the FIXED version is right.
           a = 8'd255; b = 8'd255; #1;
           if (sum_good !== 9'd510) begin $error("good wrong"); errors++; end
           if (sum_bad  === 8'd254) $display("EXPOSED: add_bad dropped the carry (254 != 510)");
           assert ({1'b0, sum_bad} !== sum_good)                 // buggy truncated, fixed did not
               else begin $error("bug not exposed"); errors++; end
           if (errors == 0) $display("PASS: fix preserves carry; bug exposed only at overflow");
           else             $display("FAIL: %0d mismatches", errors);
           $finish;
       end
   endmodule

The Verilog pair is identical in intent — the buggy sum is [WIDTH-1:0], the fixed sum is [WIDTH:0], and the testbench sweeps small operands (both pass) then max operands (only the fix is right).

addbug.v — BUGGY (truncated) vs FIXED (WIDTH+1) in Verilog
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // BUGGY: sum sized WIDTH → carry dropped, wrong on overflow.
   module add_bad #(parameter WIDTH = 8) (
       input  wire [WIDTH-1:0] a, b,
       output wire [WIDTH-1:0] sum
   );
       assign sum = a + b;                    // carry-out lost
   endmodule
 
   // FIXED: sum sized WIDTH+1 → carry preserved.
   module add_good #(parameter WIDTH = 8) (
       input  wire [WIDTH-1:0] a, b,
       output wire [WIDTH:0]   sum
   );
       assign sum = {1'b0, a} + {1'b0, b};    // sum[WIDTH] is the carry-out
   endmodule
addbug_tb.v — small values pass both; overflow exposes the dropped carry
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   module addbug_tb;
       parameter WIDTH = 8;
       reg  [WIDTH-1:0] a, b;
       wire [WIDTH-1:0] sum_bad;
       wire [WIDTH:0]   sum_good;
       integer          errors;
 
       add_bad  #(.WIDTH(WIDTH)) bad  (.a(a), .b(b), .sum(sum_bad));
       add_good #(.WIDTH(WIDTH)) good (.a(a), .b(b), .sum(sum_good));
 
       initial begin
           errors = 0;
           a = 8'd10; b = 8'd20; #1;                         // small: both correct
           if (sum_bad !== 8'd30)  begin $display("FAIL small bad");  errors=errors+1; end
           if (sum_good !== 9'd30) begin $display("FAIL small good"); errors=errors+1; end
           a = 8'd255; b = 8'd255; #1;                       // overflow: only fix correct
           if (sum_good !== 9'd510) begin $display("FAIL: good wrong on overflow"); errors=errors+1; end
           if (sum_bad === 8'd254)
               $display("EXPOSED: add_bad dropped carry (%0d != 510)", sum_bad);
           if (errors == 0) $display("PASS: fix keeps carry; bug shows only at overflow");
           else             $display("FAIL: %0d mismatches", errors);
           $finish;
       end
   endmodule

In VHDL the same bug is a resize (or truncating assignment) to WIDTH versus WIDTH+1. The fix resizes both operands to WIDTH+1 before adding, so the carry lands in the top bit. This also shows the signed case: resize on an unsigned zero-extends, but resize on a signed sign-extends — the correct widening for negative values.

addbug.vhd — BUGGY (resize to WIDTH) vs FIXED (resize to WIDTH+1) in VHDL
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   -- BUGGY: sum forced back to WIDTH bits → carry-out discarded.
   entity add_bad is
       generic ( WIDTH : positive := 8 );
       port ( a, b : in std_logic_vector(WIDTH-1 downto 0);
              sum  : out std_logic_vector(WIDTH-1 downto 0) );
   end entity;
   architecture rtl of add_bad is
   begin
       -- unsigned(a)+unsigned(b) is WIDTH+1 bits, truncated back to WIDTH here:
       sum <= std_logic_vector(resize(unsigned(a) + unsigned(b), WIDTH));  -- carry lost
   end architecture;
 
   -- FIXED: keep the full WIDTH+1-bit sum; for signed data use signed + resize
   -- (which SIGN-EXTENDS) instead of unsigned (which zero-extends).
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
   entity add_good is
       generic ( WIDTH : positive := 8 );
       port ( a, b : in std_logic_vector(WIDTH-1 downto 0);
              sum  : out std_logic_vector(WIDTH downto 0) );        -- WIDTH+1 bits
   end entity;
   architecture rtl of add_good is
   begin
       -- Resize BOTH operands to WIDTH+1 first, so the add cannot overflow the result:
       sum <= std_logic_vector(resize(unsigned(a), WIDTH+1) + resize(unsigned(b), WIDTH+1));
   end architecture;
addbug_tb.vhd — small values pass both; overflowing values expose the truncation
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   library ieee;
   use ieee.std_logic_1164.all;
   use ieee.numeric_std.all;
 
   entity addbug_tb is
   end entity;
 
   architecture sim of addbug_tb is
       constant WIDTH : positive := 8;
       signal a, b     : std_logic_vector(WIDTH-1 downto 0);
       signal sum_bad  : std_logic_vector(WIDTH-1 downto 0);
       signal sum_good : std_logic_vector(WIDTH downto 0);
   begin
       bad  : entity work.add_bad  generic map (WIDTH => WIDTH) port map (a => a, b => b, sum => sum_bad);
       good : entity work.add_good generic map (WIDTH => WIDTH) port map (a => a, b => b, sum => sum_good);
 
       stim : process
       begin
           -- SMALL values: sum fits in WIDTH bits — both match.
           a <= std_logic_vector(to_unsigned(10, WIDTH));
           b <= std_logic_vector(to_unsigned(20, WIDTH));
           wait for 1 ns;
           assert unsigned(sum_bad) = 30 and unsigned(sum_good) = 30
               report "small-value case should pass on both" severity error;
           -- OVERFLOWING values: 255+255 = 510 needs 9 bits — only the fix is right.
           a <= (others => '1'); b <= (others => '1');
           wait for 1 ns;
           assert unsigned(sum_good) = 510
               report "fixed adder wrong on overflow" severity error;
           assert unsigned(sum_bad) /= 510
               report "buggy adder unexpectedly kept the carry" severity note;
           report "addbug self-check complete (bug appears only at overflow)" severity note;
           wait;
       end process;
   end architecture;

Across all three languages the lesson is one sentence: size every intermediate for its true range — sum WIDTH+1, product 2*WIDTH, accumulator with guard bits — derived from WIDTH, then narrow back on purpose; a WIDTH-bit intermediate silently drops the high bits and passes every test whose values happen to fit.

5. Verification Strategy

A width-generic datapath has two dimensions to sweep, and the whole verification problem is that a bug can hide in the corner where both are extreme. Verifying at one width, or at one width with small values, proves almost nothing. The single invariant that captures a correct width-generic datapath is:

At every WIDTH, and for every operand value including the ones that overflow WIDTH bits, the datapath's full-precision result matches a wide reference model — and the deliberate narrow-back is the only place precision is lost.

Everything below is that invariant made checkable, and it maps onto the testbenches in §4.

  • Sweep WIDTH (1, 8, 16, 32, 64), self-checking at each. Instantiate the datapath at each width and run the same directed and random stimulus, asserting against a reference computed in a wider type so the model never truncates. The mac_checker in §4a does this — one instance per width, each self-checking. WIDTH=1 and WIDTH=64 are the extremes that catch off-by-one width math (a 2*WIDTH that was accidentally 2*WIDTH-1, a guard-bit count that underflows at WIDTH=1).
  • Feed max-value / overflowing operands, deliberately. This is the corner that exposes a truncated intermediate. Drive all-ones times all-ones so the product uses its full 2*WIDTH range, and drive operands whose sum exceeds 2^WIDTH so the carry path is exercised. A datapath tested only with small random values will pass with a truncated bus — the overflow stimulus is what makes the missing high bits matter. The §4b testbench pairs a small-value case (both buggy and fixed pass) with an overflow case (only the fixed passes), which is the exact shape of the real bug.
  • Check signed operations at each width. Widen a negative operand and confirm the result sign-extended correctly rather than zero-filled. A signed datapath that zero-extends passes for non-negative values and produces large wrong positives for negatives — so the signed corner needs its own directed vectors (a negative operand near the minimum representable at that width) at every WIDTH.
  • The full-precision reference is the oracle. The reference model must compute in a type wide enough to never itself overflow (a wide integer, or a 2*WIDTH+GUARD accumulator), then the assertion compares the DUT's wide intermediate to it, and separately checks that the narrowed output equals the chosen narrowing of the reference. This separates a genuine datapath bug (wide values disagree) from an intended narrowing (only the low bits are kept, on purpose).
  • Expected waveform. A correct run shows the control FSM stepping IDLE → MUL → ACC → FIN identically at every width — the state trace does not change with WIDTH — while the data buses simply get wider. The visual signature of the truncation bug is a wide reference value whose high bits are non-zero while the DUT's corresponding bus reads zero there: the carry that should have appeared never does.

6. Common Mistakes

Truncated intermediate — the signature bug. Summing two WIDTH-bit values into a [WIDTH-1:0] bus drops the carry-out; routing a WIDTH-by-WIDTH product through a [WIDTH-1:0] bus keeps only the low half. The result is wrong exactly when the operands overflow WIDTH — and invisible at the small values a first testbench uses. Size the sum [WIDTH:0], the product [2*WIDTH-1:0], the accumulator with guard bits, all derived from WIDTH; then narrow back deliberately. (Full post-mortem in §7.)

Zero-extending a signed value. Placing a signed number into a wider field by zero-filling the new high bits works for non-negative values and silently corrupts negatives — a negative number becomes a large positive. Widening a signed value must replicate its sign bit ({{(NEW-OLD){x[OLD-1]}}, x} in SV/Verilog, resize(signed(x), NEW) in VHDL). Choosing signed vs unsigned per signal is a decision the RTL must state, because the same bits widen differently.

A magic literal width instead of a WIDTH-derived one. Typing [15:0] or 32 anywhere inside a module you call parameterized is the 12.1 magic-literal failure, and here it is worse: the module passes at the width you built it and truncates or mis-sizes an intermediate at any other width. Every width is a localparam off WIDTH — PRODW = 2*WIDTH, ACCW = PRODW + GUARD — never a second independent literal.

A control signal or index that secretly depends on a fixed width. The control FSM must be width-independent; if a state, a branch condition, or a shift amount is written in terms of a hardcoded width (a comparison to a literal 16, an index [15] that means MSB), the controller silently breaks at other widths even though the datapath scaled. Control decisions reference status flags and derived indices ([WIDTH-1]), never a fixed number.

Not exercising overflow in the width sweep. Sweeping WIDTH with only small random operands catches width-math typos but not a truncated intermediate — because with small values the truncated bus and the correct bus agree. The overflow corner (max*max, sums past 2^WIDTH) is what makes the missing high bits observable. A width sweep without overflowing values lets the signature bug escape.

Forgetting the accumulator guard bits. An accumulator that adds N products but is only as wide as one product wraps after enough additions. Size it product-width + ceil(log2(N)) so the running total never overflows across the whole accumulation; drop the guard bits and it passes for a few adds and wraps in a long run.

7. DebugLab

The MAC that was right at 8 bits and wrong in the field — a dropped carry at scale

The engineering lesson: width-genericity is not just parameterizing the ports — every intermediate must be sized for its true range (add +1, multiply x2, accumulate + guard bits), and signed values must sign-extend, all derived from WIDTH. The tell is a bug that depends on both width and value: right at the small-value tests you wrote, wrong at a larger width with larger data in the field. That two-dimensional signature is the fingerprint of a truncated intermediate, and the only way to catch it before it ships is to sweep BOTH WIDTH and operand values that overflow, checking against a full-precision reference — while the control FSM, which touches no data bit, stays width-independent through all of it.

8. Interview Q&A

9. Exercises

Design and reasoning exercises — no syntax drills. Each rehearses the "size the intermediate for its true range" habit.

Exercise 1 — Size every intermediate

A width-generic datapath computes y = (a * b) + (c << 2) + d, where a, b, c, d are each WIDTH bits unsigned. State, in terms of WIDTH, the minimum correct width of: (i) the product a*b; (ii) c << 2; (iii) the full sum before any narrowing; and (iv) the guard bits you would add if this expression were accumulated over N samples. Then say where you would place the single deliberate narrow-back and name one policy for it.

Exercise 2 — Find the width that breaks it

An engineer's MAC passes at WIDTH=8 and WIDTH=16 with random operands but produces occasional wrong (too-low) results at WIDTH=32 on real data. The ports are all correctly parameterized. Describe (i) the most likely class of bug, (ii) why WIDTH=8/16 with random values did not catch it while WIDTH=32 with real data did, and (iii) the two-dimensional stimulus you would add to reproduce it deterministically — and explain why one dimension alone is insufficient.

Exercise 3 — Sign extension on paper

At WIDTH=4, the signed value -3 is 1101. Show what it becomes when widened to 8 bits by (i) zero extension and (ii) sign extension, and give the decimal value each represents. Then write, generically in terms of an OLD and a NEW width, the expression that sign-extends a signed vector x, and state the one place in a width-generic datapath where using zero extension instead would silently corrupt only the negative results.

Exercise 4 — Keep the control width-independent

You are handed a width-generic datapath and a controller, and you must widen the datapath from 16 to 48 bits without touching the FSM. List (i) two things in the datapath that must be derived from WIDTH for this to work, and (ii) two things that, if present in the controller, would secretly break at 48 bits even though the datapath scaled — and for each of those, how you would rewrite it so the control stays width-independent.

Continue in RTL Design Patterns (sibling links unlock as they ship):

  • Parameters & localparam Patterns — Chapter 12.1; the WIDTH knob and the localparam discipline this page sizes intermediates with — the direct prerequisite.
  • Generate & Replication Patterns — Chapter 12.2; replicating a width-generic slice across lanes, the structural companion to scaling one slice by width.
  • Configurable IP with Assertions — Chapter 12.5 sibling (this batch); guarding a parameterized block with assertions on its width relationships — unlocks as it ships.

In-track dependencies (the datapath this page scales):

  • Datapath + Control Separation — Chapter 5.6; the split that lets the control FSM stay width-independent while the datapath scales — the second prerequisite.
  • ALU Construction — Chapter 5.5; the width-generic compute block whose operations grow width.
  • Adders & Subtractors — the WIDTH+1 carry-out that is the smallest instance of intermediate width growth.
  • Multipliers — the 2*WIDTH product that is the classic width-growth trap.
  • Saturation & Rounding — Chapter 12.5; how to bring a wide intermediate back to the output width deliberately (round/saturate) rather than truncating by accident.
  • FSM + Datapath — the FSMD structure whose controller sequences the width-generic datapath.

Backward / method:

Verilog v1 prerequisites (the grammar this pattern transcribes into):

  • Arithmetic Operators — the add and multiply whose result widths this page sizes deliberately.
  • Bitwise Operators — the concatenation and replication used to zero- and sign-extend generically.

11. Summary

  • Width-genericity is a datapath property, not just a port property. One WIDTH parameter, and every operand, result, register, mux, and bus width derived from it by localparam — never a second independent literal. Turn the one knob and the whole datapath resizes consistently.
  • The control FSM stays width-INDEPENDENT. It sequences control signals and branches on status flags — it never touches a data bit — so the identical controller drives an 8-bit or a 64-bit datapath. That is the 5.6 split earning its keep.
  • Computation grows width — size every intermediate for its true range. A sum is WIDTH+1 bits (carry-out), a product is 2*WIDTH, an accumulator over N adds is WIDTH + ceil(log2(N)) guard bits. Route any of these through a WIDTH-bit bus and the high bits fall off — invisible at small values, a wrapped result at scale.
  • Sign-extend signed values when widening; narrow back on purpose. Widening a signed value replicates its sign bit (resize on signed, or top-bit replication), never zero-fills. Bringing a wide intermediate to the output width is a deliberate policy — round, saturate, or truncate — applied once, at the boundary, not an accidental drop mid-datapath.
  • You only catch a truncated intermediate by sweeping BOTH width and overflowing values. Small values across widths pass with a truncated bus; the overflow corner (max*max, sums past 2^WIDTH) is what makes the missing high bits observable. Verify against a full-precision reference across WIDTH = 1, 8, 16, 32, 64 with overflowing and signed operands — the two-dimensional sweep is the whole defense, and the self-checking testbenches here show it in SystemVerilog, Verilog, and VHDL.