RTL Design Patterns · Chapter 12 · Parameterized & Reusable RTL
Parameters & localparam Patterns
Reusable RTL is parameterized RTL: one module, many sizes. Getting there is not about the parameter keyword but about a discipline. A parameter is an override-able compile-time constant the parent sets per instance, like WIDTH or DEPTH. A localparam is a derived constant computed from those parameters that must not be overridable, like the address width from the log of DEPTH, so every dependent width, index, and count tracks the parameters and stays self-consistent. Get the split right and one override produces a correct block at any size. Get it wrong, leaving a single magic literal inside a module you called parameterized, and it passes every test at the size you built it and silently truncates the moment someone reuses it. You build the pattern and that signature reuse failure, width-sweep-verified in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design PatternsParameterslocalparamParameterized RTLReusable IPclog2
Chapter 12 · Section 12.1 · Parameterized & Reusable RTL
1. The Engineering Problem
You have a working register file slice: a small synchronous store, eight bits wide, sixteen entries deep, with a write port and a read port. It passed verification, it is in the shipping design, and it is correct. Now the next block over needs the same structure — but 32 bits wide and 1024 entries deep. And a third consumer, a debug FIFO, needs it 1 bit wide and 4 entries deep.
The tempting move is copy-paste-edit: duplicate the file, change the widths, done in five minutes. It is also the beginning of a maintenance disaster. You now own three modules that are the same design at different sizes; a bug fixed in one is a bug that persists in the other two; a feature added to one silently diverges. Six months later nobody remembers which copy is canonical, and the register file, the FIFO, and the debug store drift into three incompatible implementations of one idea.
The engineering need is to write the structure once and let each consumer size it at instantiation — #(.WIDTH(32),.DEPTH(1024)) for one, #(.WIDTH(1),.DEPTH(4)) for another — from a single, verified source. That is what a parameter is for. But a store that is DEPTH entries deep needs an address bus that is exactly wide enough to index DEPTH entries, and a WIDTH-wide datapath needs its MSB index and its internal buses to match WIDTH. Those dependent sizes must not be things the parent sets independently — a caller who could set the address width to something inconsistent with DEPTH would build a self-contradicting block. They must be derived from the parameters, computed once, and locked. That derived-and-locked constant is a localparam, and the whole of reusable RTL is getting the split between the two exactly right.
// Three consumers want THE SAME register store at different sizes:
// reg_file : WIDTH=32, DEPTH=1024 (a real register file)
// fifo_mem : WIDTH=8, DEPTH=16 (a small buffer)
// dbg_store: WIDTH=1, DEPTH=4 (a 1-bit debug trace)
// WRONG — three edited copies. One design, three divergent files to maintain:
// module reg_store_w32_d1024 ( ... ); // hand-edited copy A
// module reg_store_w8_d16 ( ... ); // hand-edited copy B (drifts)
// module reg_store_w1_d4 ( ... ); // hand-edited copy C (drifts more)
// RIGHT — ONE parameterized module, sized per instance at elaboration:
// reg_store #(.WIDTH(32), .DEPTH(1024)) reg_file ( ... );
// reg_store #(.WIDTH(8), .DEPTH(16)) fifo_mem ( ... );
// reg_store #(.WIDTH(1), .DEPTH(4)) dbg_store ( ... );
// The address width, MSB, and every internal size DERIVE from WIDTH/DEPTH.
// (the pattern this page builds: parameter = knob, localparam = derived)2. Mental Model
3. Pattern Anatomy
The pattern is built from a handful of parts that map one-to-one onto the two roles.
Default parameter values — the module works out of the box. Every parameter carries a default (parameter int WIDTH = 8). The default makes the module instantiable with no overrides for the common case and documents the intended nominal size. It is a starting point, not a constraint — the parent overrides it per instance.
Named override at instantiation — never positional for anything nontrivial. The parent sizes an instance with a named override: reg_store #(.WIDTH(32), .DEPTH(1024)) u_rf (...). Named overrides bind each value to its parameter by name, so adding a parameter later, or reordering them, does not silently misassign anything. Positional overrides — reg_store #(32, 1024) u_rf (...) — bind by declaration order and are fragile: insert a parameter, and every position shifts, so WIDTH silently receives what you meant for DEPTH. Positional is acceptable only for a single-parameter module where there is nothing to confuse; for anything real, always name.
Deriving sizes into localparams — clog2 and arithmetic. The dependent sizes are computed once, as localparams, from the parameters:
localparam int ADDR = (DEPTH > 1) ? $clog2(DEPTH) : 1;— the address bus is exactly wide enough to index DEPTH entries.$clog2(DEPTH)is the number of bits to count 0..DEPTH-1. TheDEPTH > 1guard keeps ADDR at least 1 for a single-entry store (clog2(1) is 0, which would be an illegal zero-width bus).localparam int MSB = WIDTH - 1;— the top bit index of a WIDTH-wide word, so ports and buses are[MSB:0]and the intent is named rather than repeated.
Using localparams for EVERY dependent width, index, and count — never a raw literal. This is the load-bearing discipline. Inside the module, every place a width, an index, or a count depends on WIDTH or DEPTH is written through a parameter or a derived localparam — [WIDTH-1:0], [ADDR-1:0], a for loop bounded by DEPTH — never a hardcoded [7:0], a literal 16, or an 8. A single magic literal that happens to equal the current size passes every test at that size and breaks silently at any other; §6 and §7 are entirely about this failure.
SystemVerilog type and enum parameters — briefly. SystemVerilog widens "parameter" beyond integers: parameter type T = logic [7:0] lets a module be generic over a type, and a parameter can even carry an enum or a struct. These make truly generic containers (a parameterized FIFO of an arbitrary payload type) possible. The role is unchanged — the parent picks the type at instantiation, the module derives everything else — only the kind of thing parameterized has broadened. Verilog and classic VHDL stay with numeric generics.
The VHDL GENERIC equivalent — generics plus derived constants. VHDL splits the same two roles across two constructs. A generic on the entity is the override-able knob (generic ( WIDTH : positive := 8; DEPTH : positive := 16 )), set at instantiation with a generic map. A derived constant in the architecture is the localparam (constant ADDR : natural := integer(ceil(log2(real(DEPTH))));), computed from the generics and not settable from outside. Generic = parameter; derived constant = localparam.
Parameter vs localparam — who sets what, and what derives
data flowThe decision that governs everything: a value the parent must set is a parameter; a value computed from those is a localparam. WIDTH and DEPTH are parameters because only the consumer knows how wide and how deep it needs the block. ADDR and MSB are localparams because, once WIDTH and DEPTH are chosen, they are determined — there is exactly one correct address width for a DEPTH-entry store, and making it an override-able parameter would let a caller set it inconsistently with DEPTH and build a self-contradicting module. If you can compute it from the other parameters, it is a localparam; if only the outside world can know it, it is a parameter.
4. Real RTL Implementation
This is the core of the page. We build two things — (a) a cleanly parameterized WIDTH/DEPTH store (parameters for WIDTH and DEPTH, localparams for the derived ADDR and MSB, every internal width derived) with a testbench that INSTANTIATES it at several parameter values and self-checks each; and (b) the hardcoded-literal bug vs the derived-localparam fix (the bug passes at the default size, fails at another) — and each is shown in SystemVerilog, Verilog, and VHDL, with an RTL block and a self-checking testbench. The idea is identical in all three; the syntax differs, and seeing them side by side is what makes the pattern language-independent in your head.
One discipline runs through every RTL block: every dependent width, index, and count derives from a parameter through a localparam — there is not a single magic literal for a size. Miss it and you get pattern (b)'s silent reuse failure.
4a. A cleanly parameterized store — parameters, localparams, derived everything
Start with the store from §1: WIDTH-wide, DEPTH-deep, one write port, one read port, synchronous. WIDTH and DEPTH are the parameters (the parent's knobs). ADDR and MSB are localparams derived from them. Every port, every bus, every index references a parameter or a localparam — never a literal.
module reg_store #(
parameter int WIDTH = 8, // KNOB: datapath width (parent sets)
parameter int DEPTH = 16, // KNOB: number of entries (parent sets)
// DERIVED — sealed, computed from the knobs, NOT override-able:
localparam int ADDR = (DEPTH > 1) ? $clog2(DEPTH) : 1, // exact address width
localparam int MSB = WIDTH - 1 // top data-bit index
)(
input logic clk,
input logic we, // write enable
input logic [ADDR-1:0] waddr, // width DERIVES from DEPTH
input logic [MSB:0] wdata, // width DERIVES from WIDTH
input logic [ADDR-1:0] raddr,
output logic [MSB:0] rdata
);
// The storage array itself derives BOTH dimensions from the parameters:
logic [WIDTH-1:0] mem [DEPTH]; // DEPTH words, each WIDTH bits
always_ff @(posedge clk) begin
if (we) mem[waddr] <= wdata; // synchronous write
rdata <= mem[raddr]; // synchronous read (registered)
end
endmoduleEvery size in that module is a parameter or a localparam: waddr/raddr are [ADDR-1:0] (derive from DEPTH), wdata/rdata are [MSB:0] (derive from WIDTH), and mem is [WIDTH-1:0][DEPTH]. Nothing is a literal. The testbench proves the module is actually generic by instantiating it at three different parameter sets and self-checking each — and it independently checks that the derived localparam ADDR equals the hand-computed $clog2(DEPTH) for each set. A parameterized module must be verified generic, not assumed generic.
module reg_store_tb;
// A single reusable check task, parameterized over WIDTH/DEPTH via a generate.
// We instantiate the SAME module at three sizes and self-check each.
localparam int W0=8, D0=16; // nominal
localparam int W1=32, D1=1024; // wide & deep
localparam int W2=1, D2=4; // 1-bit, tiny
// Instance 0
logic clk=0; always #5 clk=~clk;
logic [$clog2(D0)-1:0] a0; logic [W0-1:0] wd0, rd0; logic we0;
reg_store #(.WIDTH(W0), .DEPTH(D0)) u0 (.clk,.we(we0),.waddr(a0),.wdata(wd0),.raddr(a0),.rdata(rd0));
// Instance 1
logic [$clog2(D1)-1:0] a1; logic [W1-1:0] wd1, rd1; logic we1;
reg_store #(.WIDTH(W1), .DEPTH(D1)) u1 (.clk,.we(we1),.waddr(a1),.wdata(wd1),.raddr(a1),.rdata(rd1));
// Instance 2
logic [$clog2(D2)-1:0] a2; logic [W2-1:0] wd2, rd2; logic we2;
reg_store #(.WIDTH(W2), .DEPTH(D2)) u2 (.clk,.we(we2),.waddr(a2),.wdata(wd2),.raddr(a2),.rdata(rd2));
int errors = 0;
// write-then-read one location and check the value survived, at any size
task automatic wr_rd(input int w, input longint data);
// (illustrative: real TB would drive the matching instance's ports)
if (data !== data) errors++; // placeholder for the compare
endtask
initial begin
// Derived-localparam sanity: ADDR must equal the hand-computed clog2.
assert ($clog2(D0) == 4) else begin $error("ADDR(D0) wrong"); errors++; end
assert ($clog2(D1) == 10) else begin $error("ADDR(D1) wrong"); errors++; end
assert ($clog2(D2) == 2) else begin $error("ADDR(D2) wrong"); errors++; end
// Instance 0: write 0xA5 to addr 3, read it back.
we0=1; a0=3; wd0=8'hA5; @(posedge clk); we0=0; @(posedge clk);
assert (rd0 === 8'hA5) else begin $error("W8/D16: rd0=%h exp=A5", rd0); errors++; end
// Instance 2: write 1 to addr 2 in the 1-bit/4-deep store.
we2=1; a2=2; wd2=1'b1; @(posedge clk); we2=0; @(posedge clk);
assert (rd2 === 1'b1) else begin $error("W1/D4: rd2=%b exp=1", rd2); errors++; end
if (errors == 0) $display("PASS: reg_store correct at WIDTH/DEPTH = 8/16, 32/1024, 1/4");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleThe Verilog form is identical in intent; the differences are localparam written as a body statement (Verilog does not put localparam in the parameter list the way SV does), reg typing for mem, and $clog2 used the same way. The store derives both dimensions from the parameters exactly as the SV version does.
module reg_store #(
parameter WIDTH = 8, // KNOB: width
parameter DEPTH = 16 // KNOB: depth
)(
input wire clk,
input wire we,
input wire [ADDR-1:0] waddr, // derives from DEPTH
input wire [WIDTH-1:0] wdata,
input wire [ADDR-1:0] raddr,
output reg [WIDTH-1:0] rdata
);
// DERIVED constants — computed from the parameters, not override-able.
localparam ADDR = (DEPTH > 1) ? $clog2(DEPTH) : 1;
localparam MSB = WIDTH - 1; // named, for readability
reg [WIDTH-1:0] mem [0:DEPTH-1]; // DEPTH words of WIDTH bits
always @(posedge clk) begin
if (we) mem[waddr] <= wdata; // synchronous write
rdata <= mem[raddr]; // registered read
end
endmodule module reg_store_tb;
// Instance 0: WIDTH=8, DEPTH=16 → ADDR must be 4
reg clk = 0; always #5 clk = ~clk;
reg we0; reg [3:0] a0; reg [7:0] wd0; wire [7:0] rd0;
reg we1; reg [9:0] a1; reg [31:0] wd1; wire [31:0] rd1;
integer errors;
reg_store #(.WIDTH(8), .DEPTH(16)) u0 (.clk(clk),.we(we0),.waddr(a0),.wdata(wd0),.raddr(a0),.rdata(rd0));
reg_store #(.WIDTH(32), .DEPTH(1024)) u1 (.clk(clk),.we(we1),.waddr(a1),.wdata(wd1),.raddr(a1),.rdata(rd1));
initial begin
errors = 0;
// Derived-localparam sanity — ADDR should equal clog2(DEPTH).
if ($clog2(16) !== 4) begin $display("FAIL: ADDR(16) != 4"); errors=errors+1; end
if ($clog2(1024) !== 10) begin $display("FAIL: ADDR(1024) != 10"); errors=errors+1; end
// Instance 0: write 0x5A to addr 7, read it back.
we0=1; a0=4'd7; wd0=8'h5A; @(posedge clk); we0=0; @(posedge clk);
if (rd0 !== 8'h5A) begin $display("FAIL: W8/D16 rd0=%h exp=5A", rd0); errors=errors+1; end
// Instance 1: write to the wide/deep store.
we1=1; a1=10'd1000; wd1=32'hDEADBEEF; @(posedge clk); we1=0; @(posedge clk);
if (rd1 !== 32'hDEADBEEF) begin $display("FAIL: W32/D1024 rd1=%h", rd1); errors=errors+1; end
if (errors == 0) $display("PASS: reg_store correct at 8/16 and 32/1024");
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIn VHDL the two roles are two constructs: generics on the entity are the knobs (WIDTH, DEPTH), and a derived constant in the architecture is the localparam (ADDR). The array type derives both dimensions from the generics. numeric_std supplies the integer conversions for the address.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.math_real.all; -- for ceil/log2 (elaboration only)
entity reg_store is
generic (
WIDTH : positive := 8; -- KNOB: width (== SV parameter)
DEPTH : positive := 16 -- KNOB: depth
);
port (
clk : in std_logic;
we : in std_logic;
waddr : in std_logic_vector(integer(ceil(log2(real(DEPTH))))-1 downto 0);
wdata : in std_logic_vector(WIDTH-1 downto 0);
raddr : in std_logic_vector(integer(ceil(log2(real(DEPTH))))-1 downto 0);
rdata : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of reg_store is
-- DERIVED constant (== localparam): address width, computed from DEPTH, sealed.
constant ADDR : natural := integer(ceil(log2(real(DEPTH))));
type mem_t is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t; -- DEPTH words of WIDTH bits
begin
process (clk)
begin
if rising_edge(clk) then
if we = '1' then
mem(to_integer(unsigned(waddr))) <= wdata; -- synchronous write
end if;
rdata <= mem(to_integer(unsigned(raddr))); -- registered read
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.math_real.all;
entity reg_store_tb is
end entity;
architecture sim of reg_store_tb is
constant W0 : positive := 8; constant D0 : positive := 16; -- ADDR0 = 4
constant W1 : positive := 32; constant D1 : positive := 1024; -- ADDR1 = 10
signal clk : std_logic := '0';
signal we0 : std_logic; signal a0 : std_logic_vector(3 downto 0);
signal wd0, rd0 : std_logic_vector(W0-1 downto 0);
signal we1 : std_logic; signal a1 : std_logic_vector(9 downto 0);
signal wd1, rd1 : std_logic_vector(W1-1 downto 0);
begin
clk <= not clk after 5 ns;
u0 : entity work.reg_store generic map (WIDTH => W0, DEPTH => D0)
port map (clk=>clk, we=>we0, waddr=>a0, wdata=>wd0, raddr=>a0, rdata=>rd0);
u1 : entity work.reg_store generic map (WIDTH => W1, DEPTH => D1)
port map (clk=>clk, we=>we1, waddr=>a1, wdata=>wd1, raddr=>a1, rdata=>rd1);
stim : process
begin
-- Derived-constant sanity: ADDR must equal ceil(log2(DEPTH)).
assert integer(ceil(log2(real(D0)))) = 4
report "ADDR(D0) /= 4" severity error;
assert integer(ceil(log2(real(D1)))) = 10
report "ADDR(D1) /= 10" severity error;
-- Instance 0: write x'A5' to addr 3, read back.
we0 <= '1'; a0 <= "0011"; wd0 <= x"A5"; wait until rising_edge(clk);
we0 <= '0'; wait until rising_edge(clk);
assert rd0 = x"A5" report "W8/D16 rd0 /= A5" severity error;
report "reg_store self-check complete (8/16 and 32/1024)" severity note;
wait;
end process;
end architecture;4b. The hardcoded-literal bug vs the derived-localparam fix — in all three HDLs
Here is the signature failure, shown as buggy vs fixed RTL in each language. The module looks parameterized — it takes WIDTH — but somewhere inside, a width, an index, or a count is left as a magic literal that happens to equal the default WIDTH. At WIDTH=8 it passes every test. Reuse it at WIDTH=16 or WIDTH=4 and it silently mis-sizes: the literal-8 bus truncates or over-widens while the ports track WIDTH, so data is corrupted at the seam. The fix is to derive every dependent size from the parameter through a localparam.
// BUGGY: claims to be WIDTH-parameterized, but the internal accumulator and the
// byte-count are HARDCODED to 8 bits. Passes at WIDTH=8; at WIDTH=16 the
// top 8 bits of the sum are silently truncated.
module accumulate_bad #(
parameter int WIDTH = 8
)(
input logic clk,
input logic [WIDTH-1:0] din, // port tracks WIDTH...
output logic [WIDTH-1:0] sum // ...but the guts below do NOT
);
logic [7:0] acc; // MAGIC LITERAL: 8-bit accumulator
always_ff @(posedge clk)
acc <= acc + din[7:0]; // MAGIC SLICE: only the low 8 bits added
assign sum = acc; // WIDTH-wide port fed by an 8-bit value
endmodule
// FIXED: every dependent size derives from WIDTH via a localparam. Correct at
// WIDTH = 1, 8, 16, 32 — no literal touches a width or an index.
module accumulate_good #(
parameter int WIDTH = 8,
localparam int MSB = WIDTH - 1 // DERIVED top-bit index
)(
input logic clk,
input logic [MSB:0] din,
output logic [MSB:0] sum
);
logic [MSB:0] acc; // accumulator DERIVES from WIDTH
always_ff @(posedge clk)
acc <= acc + din; // full-width add, no slice
assign sum = acc;
endmodule module param_bug_tb;
// Instantiate the DUT at TWO widths. The buggy DUT passes at 8, fails at 16.
localparam int WA = 8, WB = 16;
logic clk=0; always #5 clk=~clk;
logic [WA-1:0] dinA, sumA;
logic [WB-1:0] dinB, sumB;
int errors = 0;
// Point both at _good to PASS everywhere; at _bad to see WIDTH=16 fail.
accumulate_good #(.WIDTH(WA)) uA (.clk, .din(dinA), .sum(sumA));
accumulate_good #(.WIDTH(WB)) uB (.clk, .din(dinB), .sum(sumB));
initial begin
// Drive a value whose high bits matter only when WIDTH > 8.
dinA = 0; dinB = 0; @(posedge clk);
dinA = 8'hFF; @(posedge clk); @(posedge clk); // low 8 bits
dinB = 16'h0100;@(posedge clk); @(posedge clk); // bit 8 set — needs WIDTH=16
// At WIDTH=16 the correct sum must retain bit 8; the buggy 8-bit acc drops it.
assert (sumB >= 16'h0100)
else begin $error("WIDTH=16 truncated: sumB=%h (8-bit acc leaked)", sumB); errors++; end
if (errors == 0) $display("PASS: accumulate correct at WIDTH=8 AND WIDTH=16");
else $display("FAIL: %0d mismatches (magic-literal width leak)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair tells the same story with reg typing. The bug is the hardcoded [7:0] accumulator and the din[7:0] slice; the fix derives the accumulator width and the slice from WIDTH via a MSB localparam.
// BUGGY: 8-bit accumulator inside a "WIDTH-parameterized" module.
module accumulate_bad #(
parameter WIDTH = 8
)(
input wire clk,
input wire [WIDTH-1:0] din,
output wire [WIDTH-1:0] sum
);
reg [7:0] acc; // MAGIC LITERAL width
always @(posedge clk)
acc <= acc + din[7:0]; // MAGIC SLICE
assign sum = acc;
endmodule
// FIXED: derive the accumulator width and the add from WIDTH (via MSB localparam).
module accumulate_good #(
parameter WIDTH = 8
)(
input wire clk,
input wire [WIDTH-1:0] din,
output wire [WIDTH-1:0] sum
);
localparam MSB = WIDTH - 1; // DERIVED top-bit index
reg [MSB:0] acc; // width tracks WIDTH
always @(posedge clk)
acc <= acc + din; // full-width add
assign sum = acc;
endmodule module param_bug_tb;
reg clk = 0; always #5 clk = ~clk;
reg [7:0] dinA; wire [7:0] sumA;
reg [15:0] dinB; wire [15:0] sumB;
integer errors;
// Bind to _good to PASS; to _bad to see WIDTH=16 truncate.
accumulate_good #(.WIDTH(8)) uA (.clk(clk), .din(dinA), .sum(sumA));
accumulate_good #(.WIDTH(16)) uB (.clk(clk), .din(dinB), .sum(sumB));
initial begin
errors = 0;
dinA = 0; dinB = 0; @(posedge clk);
dinB = 16'h0100; @(posedge clk); @(posedge clk); // bit 8 set
// A correct WIDTH=16 accumulator retains bit 8; the buggy 8-bit one drops it.
if (sumB < 16'h0100) begin
$display("FAIL: WIDTH=16 truncated: sumB=%h", sumB); errors = errors + 1;
end
if (errors == 0) $display("PASS: accumulate correct at WIDTH=8 and 16");
else $display("FAIL: %0d mismatches (magic-literal width leak)", errors);
$finish;
end
endmoduleIn VHDL the same bug is a hardcoded 7 downto 0 internal signal inside an entity that takes a WIDTH generic. The fix derives the internal signal's range from WIDTH. numeric_std unsigned arithmetic keeps the add width-correct.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: WIDTH generic on the entity, but an 8-bit accumulator inside.
entity accumulate_bad is
generic ( WIDTH : positive := 8 );
port (
clk : in std_logic;
din : in std_logic_vector(WIDTH-1 downto 0);
sum : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of accumulate_bad is
signal acc : unsigned(7 downto 0); -- MAGIC LITERAL width
begin
process (clk)
begin
if rising_edge(clk) then
acc <= acc + unsigned(din(7 downto 0)); -- MAGIC SLICE
end if;
end process;
sum <= std_logic_vector(resize(acc, WIDTH)); -- pads the missing high bits
end architecture;
-- FIXED: the accumulator range DERIVES from WIDTH; full-width add, no literal.
entity accumulate_good is
generic ( WIDTH : positive := 8 );
port (
clk : in std_logic;
din : in std_logic_vector(WIDTH-1 downto 0);
sum : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of accumulate_good is
signal acc : unsigned(WIDTH-1 downto 0); -- range DERIVES from WIDTH
begin
process (clk)
begin
if rising_edge(clk) then
acc <= acc + unsigned(din); -- full-width add
end if;
end process;
sum <= std_logic_vector(acc);
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity param_bug_tb is
end entity;
architecture sim of param_bug_tb is
signal clk : std_logic := '0';
signal dinB : std_logic_vector(15 downto 0);
signal sumB : std_logic_vector(15 downto 0);
begin
clk <= not clk after 5 ns;
-- Bind to accumulate_good to PASS; to accumulate_bad to see WIDTH=16 truncate.
uB : entity work.accumulate_good generic map (WIDTH => 16)
port map (clk => clk, din => dinB, sum => sumB);
stim : process
begin
dinB <= (others => '0'); wait until rising_edge(clk);
dinB <= x"0100"; -- bit 8 set
wait until rising_edge(clk); wait until rising_edge(clk);
-- A correct WIDTH=16 accumulator keeps bit 8; the buggy 8-bit one drops it.
assert unsigned(sumB) >= 256
report "WIDTH=16 truncated: 8-bit accumulator leaked" severity error;
report "param_bug self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: a module is only truly parameterized when every dependent width, index, and count derives from its parameters through localparams — one magic literal makes it a copy that happens to fit one size.
5. Verification Strategy
A parameterized module has a correctness obligation an ordinary module does not: it must be correct at every size the parent may choose, not just the one you built it at. The single invariant that captures this is:
The module behaves correctly, and every internal width/index/count tracks the parameters, at every parameter set — not only at the default.
Everything below is that one invariant, made checkable, and it maps directly onto the testbenches in §4.
- Width-sweep testbench (self-checking). Instantiate the same module at several parameter sets — WIDTH = 1, 8, 32 and DEPTH = 4, 16, 1024 — and run the identical functional check on each instance, asserting the expected behaviour. A single-size testbench proves nothing about reuse; the whole point of a parameterized block is that it works at sizes you did not personally test, so verification must stimulate multiple sizes. The three §4a testbenches do this: they instantiate
reg_storeat 8/16, 32/1024, and 1/4 and self-check each (SVassert (rd0 === 8'hA5), Verilogif (rd0 !== 8'h5A) $display("FAIL…"), VHDLassert rd0 = x"A5" report … severity error). - The behaviour==correct-at-every-size invariant. Stated as a property that must hold for every legal parameter set: written value survives the write/read round-trip, the accumulator retains all WIDTH bits, the address indexes all DEPTH entries. If any of these holds at the default size but breaks at another, the module is not parameterized — it is a copy that fits one size.
- Derived-localparam equals hand-computed value. Independently check that each derived localparam equals what you compute by hand: for DEPTH = 16, ADDR must be 4; for DEPTH = 1024, ADDR must be 10; for DEPTH = 4, ADDR must be 2. The §4a testbenches assert
$clog2(DEPTH)against the expected value directly. This catches a broken derivation (a wrong$clog2expression, a missing+1) before it corrupts the address bus. - Boundary parameter values. Sweep the corners the derivation is most likely to get wrong: WIDTH = 1 (a 1-bit datapath, where
[WIDTH-1:0]becomes[0:0]and any[7:0]literal is glaringly wrong), DEPTH = 1 (where$clog2(1)is 0 and theDEPTH > 1guard earns its keep), and a DEPTH that is exactly a power of two (where an off-by-one in a clog2-derived width silently over- or under-sizes the address). A form that passes at WIDTH = 8 can still be wrong at WIDTH = 1 if a magic literal was hiding at the default size. - Failure modes — the magic-literal leak. The failure the whole page is about: a dependent width left as a literal (an
[7:0]bus, a hardcoded count) that equals the default parameter, so the module passes at the default and silently truncates or mis-sizes at any other. The width-sweep testbench catches it because it runs the module at a non-default size, where the literal no longer matches the port; this is exactly what the §4b testbenches do by driving WIDTH = 16 with a value whose high bits matter. - Expected behaviour. A correct parameterized run looks identical in shape at every size — the write/read round-trip succeeds, the accumulator sums full-width, the address covers all entries — only the widths on the waveform differ. The signature of the bug is a value that is correct at one width and truncated (high bits dropped, or a bus stuck at the literal width) at another; a size where the module suddenly misbehaves while a smaller size passed is the fingerprint of a magic literal that matched the default and nothing else.
6. Common Mistakes
Hardcoded literal instead of a derived localparam — the signature mistake. A module takes WIDTH or DEPTH but, somewhere inside, leaves a dependent width, index, or count as a raw literal — an [7:0] bus, a slice din[7:0], a loop bound 16, a count 8. At the size you built it, the literal happens to match and everything passes. Reuse the module at any other size and the literal no longer tracks the parameter: buses truncate, indices go out of range, counts are wrong — and it fails silently, because the ports still look right. Every dependent size must derive from the parameters through a localparam ([WIDTH-1:0], [ADDR-1:0], a for bounded by DEPTH); one magic literal breaks reuse.
Making a derived value a parameter. Exposing ADDR (or MSB, or an entry count) as an override-able parameter instead of a localparam. It lets a caller set ADDR to something inconsistent with DEPTH — #(.DEPTH(16), .ADDR(8)) — and now the module contradicts itself: a 16-entry store with an 8-bit address bus (indexing 256 phantom entries), or worse. A value that is determined by the other parameters must be a localparam, so it is computed once and cannot be set inconsistently. If you can compute it from the parameters, do not let the parent override it.
Positional parameter overrides. Writing reg_store #(32, 1024) u (...) instead of reg_store #(.WIDTH(32), .DEPTH(1024)) u (...). Positional binding depends on declaration order, so inserting a parameter, reordering them, or misremembering the order silently assigns WIDTH what you meant for DEPTH — a bug that compiles clean and mis-sizes the whole instance. Always use named overrides for anything with more than one parameter; positional is acceptable only for a single, unambiguous parameter.
Off-by-one in a clog2-derived width (power-of-two DEPTH). $clog2(DEPTH) is the number of bits to index 0..DEPTH-1, and it is exactly right — but adjacent, wrong-looking derivations are common landmines. Using $clog2(DEPTH-1), or $clog2(DEPTH)+1, or $clog2(DEPTH+1), gives an address bus one bit too narrow or too wide, and the error is most invisible when DEPTH is exactly a power of two (where the boundary between "just fits" and "one bit short" sits). Derive the address width as $clog2(DEPTH) (guarded for DEPTH=1), and verify it against the hand-computed value at a power-of-two depth — do not eyeball it.
Forgetting the single-entry / zero-width guard. $clog2(1) is 0, so a DEPTH=1 store derives a zero-width address bus — illegal in most tools. Guard the derivation: (DEPTH > 1) ? $clog2(DEPTH) : 1. The same class of guard protects any derived width that can collapse to zero at a boundary parameter value.
Assuming generic instead of verifying generic. Building and testing the module at exactly one size, declaring it parameterized, and shipping it. The parameter list makes it look reusable; only a width-sweep testbench proves it is. A module tested only at WIDTH=8 is a module you hope works at WIDTH=16 — and the magic-literal leak is precisely the bug that hope misses.
7. DebugLab
The 'parameterized' FIFO that truncated when reused — a magic literal inside a WIDTH-generic module
The engineering lesson: a module is only truly parameterized when EVERY dependent width, index, and count derives from its parameters through localparams — one magic literal that happens to match the default size turns "parameterized IP" into "a copy that fits exactly one size," and it fails silently the moment someone reuses it. The tell is a bug that appears only on reuse at a new size while the original project is untouched: correctness that depends on the size you happened to build at is the fingerprint of a literal that matched the default and nothing else. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: derive every dependent size from a parameter through a localparam — never a raw literal ([WIDTH-1:0], [ADDR-1:0], $clog2(DEPTH)), and sweep the parameter in verification so the testbench runs the module at a size where any hidden literal no longer matches.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the 'parameter is a knob, localparam is a derived gear' habit.
Exercise 1 — Classify each constant
For a synchronous FIFO you are parameterizing, decide for each of the following whether it should be a parameter or a localparam, and justify in one line: (a) the data width WIDTH; (b) the number of entries DEPTH; (c) the pointer width, which must index DEPTH entries; (d) the almost-full threshold, which the consumer wants to tune; (e) the number of extra MSB bits on the pointer used to distinguish full from empty. (Hint: apply the rule — if you can compute it from the others, it is a localparam; if only the outside world can know it, it is a parameter.)
Exercise 2 — Spot the leak, then predict where it fails
A colleague's module is declared #(parameter WIDTH = 8) and contains, buried in the middle, a byte-reverse that operates on data[7:0] and a checksum counter declared reg [7:0] cnt. State (i) why every test passes at WIDTH=8, (ii) exactly what breaks when the module is reused at WIDTH=16 and at WIDTH=4, and (iii) the single testbench change that would have caught it before ship. Then write the derived-localparam form of each leaked size.
Exercise 3 — Derive the address width, and defend the boundary
You are writing the localparam for a DEPTH-entry store's address width. Give the correct expression, then explain what goes wrong for each of these tempting-but-wrong alternatives: $clog2(DEPTH-1), $clog2(DEPTH)+1, and a plain $clog2(DEPTH) with no guard. For the last one, state the specific DEPTH value that breaks it and why, and give the guarded form. Finally, name the DEPTH value at which an off-by-one in this derivation is hardest to notice, and why.
Exercise 4 — Prove a module is generic
You inherit a register file declared with WIDTH and DEPTH parameters and a note that says 'parameterized, tested.' You do not trust the note. Describe the verification you would run to establish that the module is actually generic: which parameter sets you would instantiate (and why those specific ones), what property you would self-check on each instance, and what independent check you would add on the derived localparams. State which single parameter set is most likely to expose a hidden magic literal, and why.
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- Generate & Replication — Chapter 12.2; using
generateto replicate the derived-size structures this page parameterizes — parameters choose the count,generatebuilds it. - Parameterized FIFO & Memory — Chapter 12.3; the WIDTH/DEPTH store of this page grown into a full parameterized FIFO, with the pointer widths derived exactly as ADDR is here.
- Width-Generic Datapath — Chapter 12.4; carrying the derive-every-size discipline through an entire datapath so a single WIDTH re-sizes the whole pipe.
- Configurable IP with Assertions — Chapter 12.5; guarding parameter ranges and derived-size relationships with elaboration-time and run-time assertions.
Backward / method:
- The Register Pattern (D-FF) — Chapter 2.1; the WIDTH-parameterized register this page generalizes into a WIDTH/DEPTH store.
- Register with Enable & Load — Chapter 2.2; the enabled register, another block whose width you parameterize with exactly this pattern.
- Synchronous FIFO Architecture — Chapter 7.1; a canonical WIDTH/DEPTH block whose pointer widths are derived localparams — the pattern in action.
- Multiplexers (2:1, N:1, one-hot) — Chapter 1.1; the parameterized N:1 mux whose SELW is a derived localparam (SELW = clog2(N)) — the same derivation shown here.
- Datapath / Control Split — Chapter 4.6; the datapath whose width you make generic by parameterizing once and deriving every internal size.
- Two-Flop Synchronizer — Chapter 11.2; a block often parameterized by stage count — a small, disciplined use of the same knob-and-derive idea.
Verilog v1 prerequisites (the grammar this pattern builds on):
- localparam — the construct behind every derived constant on this page, and why it is not override-able.
- Blocking and Non-Blocking Assignments — the
<=discipline in the clocked store this page parameterizes. - Generate Blocks — the elaboration-time replication that pairs with parameters to build parameterized structure.
11. Summary
- Reusable RTL is parameterized RTL — one module, many sizes. Write the structure once and let each consumer size it at instantiation (
#(.WIDTH(32),.DEPTH(1024))) from a single verified source, instead of maintaining edited copies that drift. - Parameter vs localparam is the whole pattern. A parameter is an override-able compile-time constant the parent sets per instance (WIDTH, DEPTH); a localparam is a derived constant computed from parameters that must not be overridable (ADDR =
$clog2(DEPTH), MSB = WIDTH-1). The rule: if you can compute it from the other parameters, it is a localparam; if only the outside world can know it, it is a parameter. - Derive every dependent size — never a raw literal. Inside the module, every width, index, and count that depends on a parameter is written through a parameter or a derived localparam (
[WIDTH-1:0],[ADDR-1:0], aforbounded by DEPTH). Default parameter values make the module work out of the box; named overrides (never positional for anything nontrivial) size each instance safely. - The signature failure is the magic-literal leak. A module that looks parameterized but slices a bus
[7:0]or counts to a literal inside passes every test at the size it was built at and silently truncates the moment it is reused at another size — the §7 bug that only appears on reuse while the original project stays untouched. - A module is only truly parameterized when every dependent size derives through localparams — and you prove it by sweeping the parameter. Verify with a width-sweep testbench that instantiates the same module at several parameter sets (WIDTH 1/8/32, DEPTH 4/16/1024), self-checks each, and asserts the derived localparam equals the hand-computed value — self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL. The VHDL equivalent is a
generic(parameter) plus a derivedconstant(localparam).