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.
// 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 aWIDTH+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-WIDTHproduct is2*WIDTHbits. 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
Nvalues of widthWneedsW + ceil(log2(N))bits so the running total never overflows. Thoseceil(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 flowThe 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.
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];
endmoduleThe 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.
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
endmoduleThe 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.
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 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
endmoduleVHDL 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.
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; 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.
// 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 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
endmoduleThe 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).
// 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 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
endmoduleIn 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.
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; 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_checkerin §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 (a2*WIDTHthat was accidentally2*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*WIDTHrange, and drive operands whose sum exceeds2^WIDTHso 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+GUARDaccumulator), 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.
10. Related Tutorials
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:
- The RTL Design Mindset — Chapter 0.2; the Datapath vs Control lens this page applies to scaling by width.
- What Is an RTL Design Pattern? — Chapter 0.1; why width-genericity is a reusable pattern with a signature failure.
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
WIDTHparameter, 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+1bits (carry-out), a product is2*WIDTH, an accumulator over N adds isWIDTH + ceil(log2(N))guard bits. Route any of these through aWIDTH-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 (
resizeon 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.