Module 7: Advanced RTL Design Techniques · 7.1 Parameterized & Scalable Modules

Why Parameterize Everything

Module 6 finished the testbench material, so from here on I will assume you can verify what you write. This module is about writing RTL that survives beyond the one project it was written for, and the first step is parameterization.

The scenario is familiar. You write an 8-bit adder and it works. Three weeks later another part of the project needs a 16-bit version, so you copy the file, rename it and change every [7:0] to [15:0] by hand. A month later the audio path needs 32 bits, same drill. Then someone finds a bug in the carry logic in one of the three copies, and now you have to work out whether the other two have it too and whether anyone has already worked around it somewhere. That is the default outcome of width-hardcoded RTL, and it is what parameter exists to prevent.

You know the keyword, this is the discipline

In Module 4 we used parameter and localparam to avoid magic numbers. That undersells them. The payoff comes when everything that depends on a width is expressed in terms of the parameter: not only the ports, but every internal register, every reset value, every loop bound. Then changing one number at instantiation gives you a correct module at any size without editing anything.

// Width-hardcoded: works at 8 bits, and only 8 bits
module adder8 (
    input  wire [7:0] a, b,
    input  wire       cin,
    output wire [7:0] sum,
    output wire        cout
);
    assign {cout, sum} = a + b + cin;
endmodule
// Parameterized: works at any width, including 8
module adder #(parameter WIDTH = 8) (
    input  wire [WIDTH-1:0] a, b,
    input  wire             cin,
    output wire [WIDTH-1:0] sum,
    output wire             cout
);
    assign {cout, sum} = a + b + cin;
endmodule

Instantiate the second version at 8, 16 or 32 bits and you get a correct adder from one source file. Find a bug in the carry logic and you fix it once; every instance picks up the fix on the next build.

Overriding parameters at instantiation

The override syntax is the #(...) form from Module 4:

adder #(.WIDTH(32)) u_audio_adder (
    .a(audio_a), .b(audio_b), .cin(1'b0),
    .sum(audio_sum), .cout(audio_cout)
);

adder #(.WIDTH(4)) u_nibble_adder (
    .a(nib_a), .b(nib_b), .cin(1'b0),
    .sum(nib_sum), .cout(nib_cout)
);

Two widths, one source file, and one round of verification. The last part matters more than the typing you save: you are not re-verifying the same logic at every width you need it.

Parameterize the internals, not only the ports

The mistake that throws away half the benefit is stopping at the port list. Internal registers, loop bounds and derived constants need to scale with the ports:

module shift_reg #(parameter WIDTH = 8) (
    input  wire             clk, rst_n, serial_in,
    output reg  [WIDTH-1:0] shift_reg   // internal width also parameterized
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) shift_reg <= {WIDTH{1'b0}};   // reset value scales too
        else        shift_reg <= {shift_reg[WIDTH-2:0], serial_in};
    end
endmodule

Note {WIDTH{1'b0}}, a replication sized by the parameter rather than a hardcoded 8'b0. For every literal inside a parameterized module, ask whether the number depends on WIDTH. If it does, it must be an expression in WIDTH, not the number you had in mind for the one case you were thinking about.

Deriving address widths with $clog2

A common task: given a memory depth, how many address bits do you need? The answer is $\lceil \log_2(\text{DEPTH}) \rceil$, and Verilog-2005 has a system function that computes it at compile time, $clog2.

module simple_mem #(
    parameter DATA_WIDTH = 8,
    parameter DEPTH      = 256
) (
    input  wire                        clk,
    input  wire                        we,
    input  wire [$clog2(DEPTH)-1:0]    addr,
    input  wire [DATA_WIDTH-1:0]       data_in,
    output reg  [DATA_WIDTH-1:0]       data_out
);
    reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];

    always @(posedge clk) begin
        if (we) mem[addr] <= data_in;
        data_out <= mem[addr];
    end
endmodule

$clog2(256) evaluates to 8 at compile time. Change DEPTH to 512 and the address bus widens to 9 bits everywhere it is used, with no manual arithmetic and no chance of forgetting to update it. The FIFOs in Section 7.4 lean on exactly this: a FIFO’s depth and its pointer width are tied together by this relationship.

One thing I want to stress: a module is only as reusable as its least parameterized dependency. If your adder takes a WIDTH but the memory model that uses it hardcodes [7:0] on the data bus, you have not gained anything. You have moved the copy-paste problem one level up the hierarchy. Parameterize all the way through or the discipline does not pay.

Practice Exercise

Take the parameterized adder above and write a testbench that instantiates it three times, at 4, 8 and 16 bits. For each instance apply an addition that overflows its width and check that the carry-out fires at the right boundary.

Solution
`timescale 1ns / 1ps
`default_nettype none

module adder #(parameter WIDTH = 8) (
    input  wire [WIDTH-1:0] a, b,
    input  wire             cin,
    output wire [WIDTH-1:0] sum,
    output wire             cout
);
    assign {cout, sum} = a + b + cin;
endmodule

module tb_adder_multi_width;
    // 4-bit instance
    reg  [3:0] a4, b4;
    wire [3:0] sum4;
    wire       cout4;
    adder #(.WIDTH(4)) u_adder4 (.a(a4), .b(b4), .cin(1'b0), .sum(sum4), .cout(cout4));

    // 8-bit instance
    reg  [7:0] a8, b8;
    wire [7:0] sum8;
    wire       cout8;
    adder #(.WIDTH(8)) u_adder8 (.a(a8), .b(b8), .cin(1'b0), .sum(sum8), .cout(cout8));

    // 16-bit instance
    reg  [15:0] a16, b16;
    wire [15:0] sum16;
    wire        cout16;
    adder #(.WIDTH(16)) u_adder16 (.a(a16), .b(b16), .cin(1'b0), .sum(sum16), .cout(cout16));

    initial begin
        $dumpfile("tb.vcd");
        $dumpvars(0, tb_adder_multi_width);

        // Overflow at each width's boundary
        a4 = 4'hF; b4 = 4'h1;               // 15 + 1 = 16, overflows 4 bits
        a8 = 8'hFF; b8 = 8'h01;             // 255 + 1 = 256, overflows 8 bits
        a16 = 16'hFFFF; b16 = 16'h0001;     // overflows 16 bits
        #5;

        $display("4-bit:  sum=%h cout=%b (expect 0, 1)", sum4, cout4);
        $display("8-bit:  sum=%h cout=%b (expect 0, 1)", sum8, cout8);
        $display("16-bit: sum=%h cout=%b (expect 0, 1)", sum16, cout16);

        $finish;
    end
endmodule

All three instances come from the same adder module; only the #(.WIDTH(N)) override changes. Each wraps to 0 with cout=1 at its own boundary, which shows the parameterization scales rather than merely working at the one width you tested.

Parameterization is about more than tidy port lists: every internal signal, reset value and derived constant must scale with the parameter so that one verified file replaces N copies. In the next article we look at generate if and generate case, which take the same idea from “one module at different sizes” to “one module with structurally different internals depending on a parameter”.

generate if and generate case →