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

A Width-Configurable ALU

We built a 4-bit ALU by hand in Module 2. To close Section 7.1, let us rebuild that idea with everything from the last three articles: a single ALU whose width is a parameter, whose opcode width derives itself with $clog2, and whose testbench uses generate to cover several widths at once.

The parameterized ALU

`timescale 1ns / 1ps
`default_nettype none

module alu #(
    parameter WIDTH   = 8,
    parameter NUM_OPS = 8,
    parameter OP_WIDTH = $clog2(NUM_OPS)
) (
    input  wire [WIDTH-1:0]    a, b,
    input  wire [OP_WIDTH-1:0] op,
    output reg  [WIDTH-1:0]    result,
    output wire                zero
);
    localparam [OP_WIDTH-1:0] OP_ADD = 0,
                              OP_SUB = 1,
                              OP_AND = 2,
                              OP_OR  = 3,
                              OP_XOR = 4,
                              OP_NOT = 5,
                              OP_SLL = 6,
                              OP_SRL = 7;

    always @(*) begin
        result = {WIDTH{1'b0}};
        case (op)
            OP_ADD: result = a + b;
            OP_SUB: result = a - b;
            OP_AND: result = a & b;
            OP_OR:  result = a | b;
            OP_XOR: result = a ^ b;
            OP_NOT: result = ~a;
            OP_SLL: result = a << b[$clog2(WIDTH)-1:0];
            OP_SRL: result = a >> b[$clog2(WIDTH)-1:0];
            default: result = {WIDTH{1'b0}};
        endcase
    end

    assign zero = (result == {WIDTH{1'b0}});
endmodule

Everything scales off WIDTH and NUM_OPS. Set WIDTH to 32 and a, b, result and the shift-amount slice all widen with no editing. OP_WIDTH derives from NUM_OPS through $clog2, so adding a ninth operation later widens the opcode bus from 3 bits to 4 everywhere it is referenced.

Why the shift amount gets its own slice

Look at a << b[$clog2(WIDTH)-1:0]. A shift amount only needs $\lceil \log_2(\text{WIDTH}) \rceil$ bits, since shifting an 8-bit value by 8 or more gives all zeros anyway. Slicing b down to those bits keeps the synthesized shifter as small as it needs to be at every width. Leave the full width of b as the shift amount and some synthesis tools will build a needlessly wide barrel shifter whose upper shift-amount bits are never useful.

Test across configurations, not one

The point of parameterizing the ALU was to reuse it at several widths. A testbench that instantiates it once at 8 bits has verified only the 8-bit case.

module tb_alu_multi_width;

    genvar w;
    generate
        for (w = 4; w <= 16; w = w + 4) begin : gen_width_test
            // Each generated block gets its own DUT instance and its own signals,
            // scoped by width: 4, 8, 12 and 16 bits, all from one testbench.
            reg  [w-1:0] a, b;
            reg  [2:0]   op;
            wire [w-1:0] result;
            wire         zero;

            alu #(.WIDTH(w)) dut (.a(a), .b(b), .op(op), .result(result), .zero(zero));

            initial begin
                #(w);  // stagger start times slightly so $display output stays readable
                a = {w{1'b1}}; b = 1;
                op = 3'd0;  // OP_ADD
                #10;
                $display("WIDTH=%0d: %0d + %0d = %0d (zero flag=%b)", w, a, b, result, zero);
            end
        end
    endgenerate

endmodule

This is generate for from Module 5 stamping out four ALU instances at four widths inside one testbench, each with its own local signals inside the generate block. One test file now covers the range of configurations the module claims to support.

That is Section 7.1 in one module: parameter and localparam make the interface configurable, $clog2 derives dependent widths, and generate in both its repeating and its structural forms lets the module and its testbench scale. Every FIFO, memory controller and datapath block from here on builds on the same habits.

Practice Exercise

Extend the ALU with a ninth operation, OP_XNOR (bitwise XNOR of a and b), and confirm that OP_WIDTH widens from 3 bits to 4 on its own once NUM_OPS becomes 9.

Solution
module alu #(
    parameter WIDTH   = 8,
    parameter NUM_OPS = 9,                    // bumped from 8 to 9
    parameter OP_WIDTH = $clog2(NUM_OPS)      // now evaluates to 4, not 3
) (
    input  wire [WIDTH-1:0]    a, b,
    input  wire [OP_WIDTH-1:0] op,
    output reg  [WIDTH-1:0]    result,
    output wire                zero
);
    localparam [OP_WIDTH-1:0] OP_ADD  = 0,
                              OP_SUB  = 1,
                              OP_AND  = 2,
                              OP_OR   = 3,
                              OP_XOR  = 4,
                              OP_NOT  = 5,
                              OP_SLL  = 6,
                              OP_SRL  = 7,
                              OP_XNOR = 8;

    always @(*) begin
        result = {WIDTH{1'b0}};
        case (op)
            OP_ADD:  result = a + b;
            OP_SUB:  result = a - b;
            OP_AND:  result = a & b;
            OP_OR:   result = a | b;
            OP_XOR:  result = a ^ b;
            OP_NOT:  result = ~a;
            OP_SLL:  result = a << b[$clog2(WIDTH)-1:0];
            OP_SRL:  result = a >> b[$clog2(WIDTH)-1:0];
            OP_XNOR: result = ~(a ^ b);
            default: result = {WIDTH{1'b0}};
        endcase
    end

    assign zero = (result == {WIDTH{1'b0}});
endmodule

The only change needed for the wider opcode was NUM_OPS = 9. OP_WIDTH recomputed itself from 3 to 4 bits and every port and signal that references it widened along with it, which is the payoff parameterization is supposed to deliver. The testbench’s op register needs to become 4 bits wide to match.

That closes parameterization. In the next article we start Section 7.2 with a question this ALU has been quietly avoiding: what happens to a - b and to comparisons like a > b the moment the operands are meant to be signed, and where that goes wrong if you are not careful.

โ† A Parameterized Memory Model Signed Arithmetic Pitfalls โ†’