Datapath Design and Parameterization

Open up any serious digital design — a CPU, a crypto engine, a video scaler — and you will find the same anatomy: a set of muscles that move and crunch data, and a small brain telling the muscles what to do each cycle. The muscles are the datapath: registers, multiplexers, and an ALU or adder, all wide (8, 32, 64 bits). The brain is the controller: the finite state machine from last lesson, issuing skinny one-bit orders — select this mux input, enable that register. This split, plus SystemVerilog's tools for building hardware in any width (parameter and generate), is how designs scale from toy to product without being rewritten.

Parameters: one description, any width

You never write "a 32-bit adder". You write an N-bit adder and let the instantiator choose N. The knob is a parameter, and generate stamps out the repeated structure — here, a ripple-carry adder built from the full_adder of Module 1:

module ripple_adder #(parameter WIDTH = 8) ( input logic [WIDTH-1:0] a, b, input logic cin, output logic [WIDTH-1:0] sum, output logic cout ); logic [WIDTH:0] carry; // the carry chain: WIDTH+1 strands assign carry[0] = cin; assign cout = carry[WIDTH]; generate for (genvar i = 0; i < WIDTH; i++) begin : bit_slice full_adder fa (.a(a[i]), .b(b[i]), .cin(carry[i]), .sum(sum[i]), .cout(carry[i+1])); end endgenerate endmodule

The generate-for is not a loop that runs — it is a photocopier that runs at elaboration time, when the tool flattens your design into gates. With WIDTH = 32 it stamps out thirty-two full_adder instances, wired nose to tail through the carry chain, before the first simulation cycle ever ticks. Instantiators pick the width at the point of use:

ripple_adder #(.WIDTH(32)) pc_adder (.a(pc), .b(32'd4), .cin(1'b0), …); ripple_adder #(.WIDTH(64)) wide_adder (.a(x), .b(y), .cin(c), …);

One source file, two different pieces of hardware. A cousin, localparam, names a constant derived inside the module that instantiators cannot override — perfect for things like counter widths: localparam CNT_W = $clog2(WIDTH + 1); (the ceiling-log function every parameterized design leans on).

The decomposition, drawn

Here is the machine this lesson builds — a multiplier that computes a \times b the way you were taught long multiplication, one binary digit per clock cycle. Fat arrows carry data; thin dashed lines carry orders:

The division of labour is strict. The datapath can add, shift and store — but it has no idea when to. The controller knows exactly when — but touches no data wider than a bit. Every cycle it reads a little status (here just mplier[0]: "is the current multiplier digit a 1?") and sets the select and enable lines. Processors, GPUs and every non-trivial peripheral are this picture at larger scale.

The shift-add multiplier in RTL

Long multiplication in base 2: for each bit of a, if the bit is 1, add the (appropriately shifted) b into a running sum. One bit per cycle, so an N-bit multiply takes N cycles — a classic trade of time for silicon:

module shift_add_mult #(parameter WIDTH = 8) ( input logic clk, rst_n, start, input logic [WIDTH-1:0] a, b, output logic [2*WIDTH-1:0] product, output logic done ); localparam CNT_W = $clog2(WIDTH + 1); // ── Datapath registers (the muscles). ── logic [2*WIDTH-1:0] acc, mcand; logic [WIDTH-1:0] mplier; logic [CNT_W-1:0] count; // ── Controller (the brain): a two-state FSM. ── typedef enum logic { IDLE, RUN } state_t; state_t state; always_ff @(posedge clk) begin if (!rst_n) state <= IDLE; else if (state == IDLE && start) begin // load: seize the operands acc <= '0; mcand <= b; mplier <= a; count <= WIDTH; state <= RUN; end else if (state == RUN) begin if (mplier[0]) acc <= acc + mcand; // this digit is 1: add mcand <= mcand << 1; // next digit is worth double mplier <= mplier >> 1; // expose the next digit count <= count - 1; if (count == 1) state <= IDLE; end end assign product = acc; assign done = (state == IDLE); endmodule

Savour one subtlety, courtesy of non-blocking assignment: in a RUN cycle, acc adds the old mcand while mcand simultaneously doubles — both right-hand sides read the pre-edge world, so the add uses this digit's place value and the doubling prepares the next. Settle, then snap, doing two jobs in one edge.

Watch it multiply

The two-phase simulator again — the state object is the four datapath registers, and next() is one RUN cycle. Watch the partial products accumulate exactly like the columns of long multiplication:

// ── The datapath registers and one RUN cycle of the controller's orders. ── interface State { acc: number; mcand: number; mplier: number; count: number } function next(s: State): State { return { acc: (s.mplier & 1) ? s.acc + s.mcand : s.acc, // add when this digit is 1 mcand: s.mcand * 2, // mcand <= mcand << 1 mplier: s.mplier >> 1, // mplier <= mplier >> 1 count: s.count - 1, }; } const WIDTH = 4, a = 11, b = 13; // 1011 x 1101 let s: State = { acc: 0, mcand: b, mplier: a, count: WIDTH }; // the IDLE load console.log(`${a} x ${b}, one multiplier bit per cycle:`); console.log("cycle | bit | action | acc"); console.log("------+-----+---------------+----"); for (let cycle = 1; s.count > 0; cycle++) { const bit = s.mplier & 1; const action = bit ? `acc += ${s.mcand}`.padEnd(13) : "hold".padEnd(13); const after = next(s); console.log(` ${cycle} | ${bit} | ${action} | ${after.acc}`); s = after; } console.log(`done: product = ${s.acc} (check: ${a} x ${b} = ${a * b})`);

Four cycles, four digits, correct product. Re-parameterize to WIDTH = 32 and the same description multiplies 32-bit numbers in 32 cycles — no code changes, just a different photocopy count at elaboration.

You can! Write assign product = a * b; and the synthesizer will build you a combinational multiplier — a small city of AND gates and adder rows that computes the whole product inside one cycle. It is enormously bigger than our one adder, and its long gate chains may force your whole chip's clock to slow down. The shift-add version is the opposite corner of the same trade: tiny, but N cycles per result. Real chips live all along this spectrum — pipelined array multipliers, Booth encoding, digit-serial designs — and choosing a point on it is the job. The datapath/controller decomposition is what makes the choice swappable: the controller barely changes when the datapath gets a faster multiplier, because the brain never cared how strong the muscles were, only what they could be told to do.

parameter WIDTH looks like a variable, and newcomers duly try to change it at runtime — "if the packet is short, make the bus 16 bits this cycle". No. Parameters exist only at elaboration time: the moment the tool flattens your design, every parameter is frozen into a number, every generate is expanded, and what remains is a fixed netlist of gates. Silicon cannot grow flip-flops at runtime any more than a bridge can grow a lane at rush hour. A "variable-width" design is really a maximum-width datapath plus control logic that ignores the unused bits — width chosen once, at elaboration, forever. If you find yourself wishing a parameter could change per clock cycle, what you actually want is a mux and a control signal: that is the datapath/controller split telling you where the flexibility belongs.