RTL Design Patterns · Chapter 5 · Arithmetic & Datapath Patterns
Datapath + Control Separation
A single cycle rarely finishes the job. A multiply-accumulate, a running checksum, or a bus transaction is a sequence of single-cycle operations, and something must decide which operation runs each cycle and when the whole thing is done. Bolting that sequencing onto the compute logic until neither can be understood alone is the disastrous way to build it. The professional way is to split. Put everything that can compute and store into a datapath that makes no decisions, and put every decision into a controller FSM that does no arithmetic, joined by a narrow interface: control signals down, status flags up. This split is what makes a design reusable, verifiable, and easy to grow. This lesson builds a width-generic multiply-accumulate engine, verifies it computes a times b plus c, and studies the shared-resource timing bug that defines the pattern, in SystemVerilog, Verilog, and VHDL.
Intermediate16 min readRTL Design PatternsDatapathControlController FSMMultiply-AccumulateResource Sharing
Chapter 5 · Section 5.6 · Arithmetic & Datapath Patterns
1. The Engineering Problem
You have just finished the ALU of 5.5 — a compute core that, given two operands and an opcode, produces any one of add, subtract, AND, shift, or compare in a single cycle. Now the block above you asks for something the ALU cannot do alone: a multiply-accumulate, acc = a*b + c, on a target that has no hardware multiplier. You already know the trick — a multiply by repeated addition: start the accumulator at c, then add a to it b times. That is b+1 cycles of work, a sequence, and the ALU has no concept of a sequence. It does one operation and forgets. Something else has to decide, each cycle, which operation runs, which register captures the result, and when the loop is finished.
The tempting move is to grow the ALU: give it a little state, an internal counter, a mode bit, an always block that both computes the sum and decides whether to keep looping. Do that a few times — add a running-checksum mode here, a saturating variant there — and you have built a monolith: one block that both computes and sequences, where the arithmetic and the decisions are tangled in the same statements. You cannot reuse the arithmetic without dragging the sequencing along; you cannot change the schedule without risking the math; and you cannot verify either in isolation, because there is no isolation left. When a bug appears you cannot even say whether it is a compute bug or a control bug, because the design does not separate the two.
This is the same crossroads Chapter 4 reached at 4.6: the moment a design needs more than one cycle, it stops being a circuit and becomes a controller driving a datapath. The discipline that keeps it maintainable, reusable, and verifiable is to split the design along that seam — everything that computes into a datapath, every decision into a controller — and connect them across one deliberately narrow interface. That split is the subject of this page, and the multiply-accumulate is the worked example we carry through it.
// Goal: acc = a*b + c, with NO hardware multiplier.
// Method: acc starts at c, then add a, b times -> a SEQUENCE of ALU ops.
//
// cycle 0 : acc <- c (load)
// cycle 1 : acc <- acc + a (ALU add) cnt <- b-1
// cycle 2 : acc <- acc + a (ALU add) cnt <- cnt-1
// ... repeat while (cnt != 0) <- a DECISION, per cycle
// cycle k : cnt == 0 (ZERO status) -> done
//
// The ALU can do ONE add per cycle. It cannot decide "keep looping" or
// "we are done" — that is sequencing. WHO decides which op runs this cycle,
// which register captures it, and when to stop? That is the CONTROLLER,
// and the clean way to add it is to SPLIT compute from control.2. Mental Model
3. Pattern Anatomy
The split is two boxes and one interface. Everything on this page is a way of respecting that structure.
The datapath (the lower box). A collection of storage and compute that can perform any single-cycle operation on demand and never decides which: the accumulator acc and the loop counter cnt (registers with enables, from 2.x), the shared ALU of 5.5 (add/sub/pass), and the operand muxes (from 1.1) that steer which sources reach the ALU inputs this cycle. Its inputs are control signals; its outputs are the datapath results held in registers plus a bundle of status flags — here zero (is cnt zero?), but in general also less-than, equal, overflow, negative. The datapath is combinational-plus-registers with a clean cut: the ALU and muxes are combinational, the acc/cnt captures are clocked. Crucially it is WIDTH-generic — it computes on WIDTH-bit data and does not know or care what sequence it is part of.
The controller (the upper box). A pure FSM — state register, next-state logic, output logic — from Chapter 4. Its inputs are start and the datapath's status flags; its outputs are the control signals that drive the datapath (alu_op, sel_a/sel_b, en_acc, en_cnt, load) and done. It contains no data arithmetic: it does not compute acc, it commands the datapath to. It reads zero to decide whether the loop continues, and it emits the control word that makes the next single-cycle operation happen.
The interface — control down, status up. This is the load-bearing part.
- Control signals DOWN. The opcode (
alu_op), the mux selects (sel_a,sel_b), the register enables (en_acc,en_cnt),load. Each is a command: do this single-cycle operation now. - Status flags UP.
zero, and in the general case less-than, equal, overflow, done. Each is a fact the controller branches on: the datapath is in this condition.
Datapath + Control — two boxes, one narrow interface (control down, status up)
data flowWhy split — four payoffs. The separation is not tidiness for its own sake; each property below is a concrete engineering win.
- Reuse. One datapath, many controllers. The same accumulator-plus-ALU-plus-muxes that does a multiply-accumulate can, under a different controller, do a running sum, a dot-product, or a divide-by-subtraction — or be driven by a microcode ROM instead of an FSM. The datapath is the reusable asset; the controller is the cheap, swappable part.
- Verifiability. You can verify the datapath exhaustively — for each control-signal combination, does it produce the right result and flags? — because it has no sequence to get lost in. You verify the controller as a sequence — does it emit the right control word and branch correctly on status? — with the datapath stubbed or modelled. Then you verify the composition. Three tractable problems instead of one intractable one.
- Scalability. Widen the datapath from 8 to 32 bits and the controller is untouched (control is width-agnostic). Add an operation to the datapath and only the controller states that use it change. The seam localizes change.
- Timing. With the split explicit you can reason about the interface's timing: is a status combinational (available the same cycle) or registered (available the next cycle)? The controller must sample it at the right time — exactly the 4.6 lesson. A monolith hides this; the split forces you to name it.
The worked micro-example. The datapath is acc (accumulator) and cnt (down-counter) registers, the shared ALU, and operand muxes; the controller sequences acc = a*b + c as: load acc <- c and cnt <- b; then loop — while cnt != 0, do acc <- acc + a and cnt <- cnt - 1; when zero (cnt reached 0), assert done. With a=5, b=3, c=2 the golden result is 5 + 5 + 5 + 2 = 17. Every RTL block below implements one of these three things — the datapath, the controller, or the composition — and every testbench runs this example to done and asserts acc == 17.
4. Real RTL Implementation
This is the core of the page. We build three things — (a) the datapath (acc + cnt registers, shared ALU, operand muxes, driven by control, producing the zero status; WIDTH-generic), (b) the controller FSM (sequences the MAC, branches on zero), and (c) the composed datapath+controller plus the timing/contention bug (buggy vs fixed) — and each is shown in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking clocked testbench. The worked example a*b + c with a=5, b=3, c=2 → 17 runs end-to-end in all three.
Two disciplines run through every block. First, the datapath makes no decisions — its combinational ALU/mux logic is total (default-assigned, latch-free, the 1.1/5.5 discipline) and its registers use explicit clocked enables (<=, the 2.x discipline). Second, the controller does no arithmetic — it is a pure FSM whose only job is to emit the control word and branch on status.
4a. The datapath — compute + store, no decisions, WIDTH-generic
The datapath holds acc and cnt, owns the single shared ALU, and steers its operands with muxes. Every action it takes is commanded: alu_op picks add/sub/pass, sel_a/sel_b pick the operands, en_acc/en_cnt/load say which register captures. It emits one status flag, zero (is cnt == 0). It decides nothing.
module mac_datapath #(
parameter int WIDTH = 8
)(
input logic clk,
input logic rst, // synchronous, active-high
// operands (external)
input logic [WIDTH-1:0] a, // the addend
input logic [WIDTH-1:0] b, // the multiplier (loop count)
input logic [WIDTH-1:0] c, // the accumulate seed
// CONTROL SIGNALS DOWN (from the controller) — commands, no decisions here
input logic load, // load acc<-c, cnt<-b this cycle
input logic en_acc, // capture ALU result into acc
input logic en_cnt, // capture ALU result into cnt
input logic alu_op, // 0 = A+B (add), 1 = A-B (sub)
input logic sel_a, // ALU operand-A mux: 0=acc, 1=cnt
input logic sel_b, // ALU operand-B mux: 0=a, 1=one
// DATAPATH OUTPUTS
output logic [WIDTH-1:0] acc, // the running result
// STATUS FLAGS UP (to the controller)
output logic zero // cnt == 0 (loop-terminal)
);
logic [WIDTH-1:0] cnt;
logic [WIDTH-1:0] op_a, op_b, alu_y;
// --- operand muxes (1.1): steer sources to the shared ALU. Combinational,
// total (every select drives an output) => latch-free by construction. ---
assign op_a = sel_a ? cnt : acc; // A = acc (accumulate) or cnt (decrement)
assign op_b = sel_b ? {{(WIDTH-1){1'b0}}, 1'b1} // B = 1 (for cnt-1)
: a; // or a (for acc+a)
// --- the SINGLE shared ALU (5.5): one adder/subtractor, add OR sub, no decision. ---
assign alu_y = alu_op ? (op_a - op_b) : (op_a + op_b);
// --- registers with clocked enables (2.x): acc and cnt capture on command. ---
always_ff @(posedge clk) begin
if (rst) begin
acc <= '0;
cnt <= '0;
end else if (load) begin
acc <= c; // seed the accumulate
cnt <= b; // seed the loop count
end else begin
if (en_acc) acc <= alu_y; // acc <- acc + a
if (en_cnt) cnt <= alu_y; // cnt <- cnt - 1
end
end
// --- status flag UP: purely a fact about the datapath, no branching. ---
assign zero = (cnt == '0);
endmoduleThe datapath is exhaustively describable: for each (load, en_acc, en_cnt, alu_op, sel_a, sel_b) command, the next acc/cnt and the zero flag are fully determined — no sequence, no decision. The testbench drives it directly, with no controller, to prove the compute contract in isolation: command acc + a and cnt - 1 and check the registers step correctly and zero rises when cnt hits 0.
module mac_datapath_tb;
localparam int WIDTH = 8;
logic clk = 0, rst;
logic [WIDTH-1:0] a, b, c, acc;
logic load, en_acc, en_cnt, alu_op, sel_a, sel_b, zero;
int errors = 0;
always #5 clk = ~clk;
mac_datapath #(.WIDTH(WIDTH)) dut (.*);
task automatic step; @(posedge clk); #1; endtask
initial begin
rst = 1; {load,en_acc,en_cnt,alu_op,sel_a,sel_b} = '0;
a = 8'd5; b = 8'd3; c = 8'd2;
step; rst = 0;
load = 1; step; load = 0; // acc<-2, cnt<-3
assert (acc === 8'd2 && zero === 1'b0) else begin $error("load"); errors++; end
// one loop pass, done as TWO datapath commands (add, then dec):
sel_a=0; sel_b=0; alu_op=0; en_acc=1; step; en_acc=0; // acc <- acc + a = 7
assert (acc === 8'd7) else begin $error("acc+a"); errors++; end
sel_a=1; sel_b=1; alu_op=1; en_cnt=1; step; en_cnt=0; // cnt <- cnt - 1 = 2
assert (zero === 1'b0) else begin $error("cnt-1"); errors++; end
if (errors == 0) $display("PASS: datapath obeys control, zero tracks cnt");
else $display("FAIL: %0d datapath errors", errors);
$finish;
end
endmoduleThe Verilog datapath is the same structure with reg/wire typing and an always @(posedge clk). The muxes are continuous assigns (latch-proof), the ALU is one shared adder/subtractor, and the enables gate the register captures.
module mac_datapath #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst,
input wire [WIDTH-1:0] a, b, c,
input wire load, en_acc, en_cnt, alu_op, sel_a, sel_b,
output reg [WIDTH-1:0] acc,
output wire zero
);
reg [WIDTH-1:0] cnt;
wire [WIDTH-1:0] op_a, op_b, alu_y;
// operand muxes: total continuous assigns, no latch
assign op_a = sel_a ? cnt : acc;
assign op_b = sel_b ? {{(WIDTH-1){1'b0}}, 1'b1} : a; // 1 or a
// single shared ALU: add or subtract
assign alu_y = alu_op ? (op_a - op_b) : (op_a + op_b);
always @(posedge clk) begin
if (rst) begin
acc <= {WIDTH{1'b0}};
cnt <= {WIDTH{1'b0}};
end else if (load) begin
acc <= c;
cnt <= b;
end else begin
if (en_acc) acc <= alu_y;
if (en_cnt) cnt <= alu_y;
end
end
assign zero = (cnt == {WIDTH{1'b0}}); // status UP
endmodule module mac_datapath_tb;
parameter WIDTH = 8;
reg clk, rst;
reg [WIDTH-1:0] a, b, c;
reg load, en_acc, en_cnt, alu_op, sel_a, sel_b;
wire [WIDTH-1:0] acc;
wire zero;
integer errors;
initial clk = 0;
always #5 clk = ~clk;
mac_datapath #(.WIDTH(WIDTH)) dut (
.clk(clk), .rst(rst), .a(a), .b(b), .c(c),
.load(load), .en_acc(en_acc), .en_cnt(en_cnt),
.alu_op(alu_op), .sel_a(sel_a), .sel_b(sel_b), .acc(acc), .zero(zero));
initial begin
errors = 0;
rst = 1; load=0; en_acc=0; en_cnt=0; alu_op=0; sel_a=0; sel_b=0;
a = 8'd5; b = 8'd3; c = 8'd2;
@(posedge clk); #1; rst = 0;
load = 1; @(posedge clk); #1; load = 0; // acc<-2 cnt<-3
if (acc !== 8'd2 || zero !== 1'b0) begin $display("FAIL: load"); errors=errors+1; end
sel_a=0; sel_b=0; alu_op=0; en_acc=1; @(posedge clk); #1; en_acc=0; // acc<-7
if (acc !== 8'd7) begin $display("FAIL: acc+a"); errors=errors+1; end
sel_a=1; sel_b=1; alu_op=1; en_cnt=1; @(posedge clk); #1; en_cnt=0; // cnt<-2
if (zero !== 1'b0) begin $display("FAIL: cnt-1"); errors=errors+1; end
if (errors == 0) $display("PASS: datapath obeys control, zero tracks cnt");
else $display("FAIL: %0d datapath errors", errors);
$finish;
end
endmoduleIn VHDL the datapath is a numeric_std design: acc/cnt are unsigned, the muxes are when/else concurrent assignments, the shared ALU is one add/subtract expression, and the clocked process captures under the enables. zero is a concurrent status output.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mac_datapath is
generic ( WIDTH : positive := 8 );
port (
clk : in std_logic;
rst : in std_logic; -- sync, active-high
a, b, c : in unsigned(WIDTH-1 downto 0);
-- CONTROL DOWN
load : in std_logic;
en_acc : in std_logic;
en_cnt : in std_logic;
alu_op : in std_logic; -- '0'=add '1'=sub
sel_a : in std_logic; -- '0'=acc '1'=cnt
sel_b : in std_logic; -- '0'=a '1'=one
acc : out unsigned(WIDTH-1 downto 0);
-- STATUS UP
zero : out std_logic
);
end entity;
architecture rtl of mac_datapath is
signal acc_r, cnt_r : unsigned(WIDTH-1 downto 0);
signal op_a, op_b, alu_y : unsigned(WIDTH-1 downto 0);
constant ONE : unsigned(WIDTH-1 downto 0) := to_unsigned(1, WIDTH);
begin
-- operand muxes (total when/else, no latch)
op_a <= cnt_r when sel_a = '1' else acc_r;
op_b <= ONE when sel_b = '1' else a;
-- single shared ALU
alu_y <= (op_a - op_b) when alu_op = '1' else (op_a + op_b);
process (clk)
begin
if rising_edge(clk) then
if rst = '1' then
acc_r <= (others => '0');
cnt_r <= (others => '0');
elsif load = '1' then
acc_r <= c;
cnt_r <= b;
else
if en_acc = '1' then acc_r <= alu_y; end if;
if en_cnt = '1' then cnt_r <= alu_y; end if;
end if;
end if;
end process;
acc <= acc_r;
zero <= '1' when cnt_r = 0 else '0'; -- status UP
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mac_datapath_tb is
end entity;
architecture sim of mac_datapath_tb is
constant WIDTH : positive := 8;
signal clk : std_logic := '0';
signal rst, load, en_acc, en_cnt, alu_op, sel_a, sel_b, zero : std_logic := '0';
signal a, b, c, acc : unsigned(WIDTH-1 downto 0);
begin
dut : entity work.mac_datapath
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst, a => a, b => b, c => c,
load => load, en_acc => en_acc, en_cnt => en_cnt,
alu_op => alu_op, sel_a => sel_a, sel_b => sel_b,
acc => acc, zero => zero);
clk <= not clk after 5 ns;
stim : process
begin
a <= to_unsigned(5, WIDTH); b <= to_unsigned(3, WIDTH); c <= to_unsigned(2, WIDTH);
rst <= '1'; wait until rising_edge(clk); wait for 1 ns; rst <= '0';
load <= '1'; wait until rising_edge(clk); wait for 1 ns; load <= '0'; -- acc<-2 cnt<-3
assert acc = to_unsigned(2, WIDTH) and zero = '0'
report "datapath load failed" severity error;
sel_a <= '0'; sel_b <= '0'; alu_op <= '0'; en_acc <= '1';
wait until rising_edge(clk); wait for 1 ns; en_acc <= '0'; -- acc<-7
assert acc = to_unsigned(7, WIDTH) report "acc+a failed" severity error;
sel_a <= '1'; sel_b <= '1'; alu_op <= '1'; en_cnt <= '1';
wait until rising_edge(clk); wait for 1 ns; en_cnt <= '0'; -- cnt<-2
assert zero = '0' report "cnt-1 failed" severity error;
report "datapath self-check complete" severity note;
wait;
end process;
end architecture;4b. The controller — a pure FSM that sequences and branches on status
The controller is the grammar. It holds only state, reads start and the datapath's zero, and emits the control word. It does no arithmetic — it never touches acc or cnt data. States: IDLE (wait for start) → LOAD (seed acc/cnt) → ADD (command acc <- acc + a) → DEC (command cnt <- cnt - 1, then branch on zero) → DONE. Splitting ADD and DEC into two states is deliberate: it schedules the two datapath operations onto the single shared ALU one per cycle — the scheduling contract §7 is all about.
module mac_ctrl (
input logic clk,
input logic rst,
input logic start,
input logic zero, // STATUS UP: cnt == 0
// CONTROL DOWN
output logic load,
output logic en_acc,
output logic en_cnt,
output logic alu_op, // 0=add (acc+a), 1=sub (cnt-1)
output logic sel_a, // 0=acc, 1=cnt
output logic sel_b, // 0=a, 1=one
output logic done
);
typedef enum logic [2:0] {IDLE, LOAD, ADD, DEC, FIN} state_t;
state_t state, nstate;
// state register
always_ff @(posedge clk)
if (rst) state <= IDLE;
else state <= nstate;
// next-state logic — branches on the datapath STATUS, no arithmetic
always_comb begin
nstate = state;
unique case (state)
IDLE: if (start) nstate = LOAD;
LOAD: nstate = (zero) ? FIN : ADD; // b==0 -> nothing to add
ADD: nstate = DEC; // one shared-ALU op per cycle
DEC: nstate = (zero) ? FIN : ADD; // sample registered zero, then loop
FIN: nstate = FIN;
endcase
end
// output logic — the control word for each state (Moore: outputs depend on state)
always_comb begin
{load, en_acc, en_cnt, alu_op, sel_a, sel_b, done} = '0;
unique case (state)
LOAD: load = 1'b1; // acc<-c, cnt<-b
ADD: begin en_acc = 1'b1; alu_op = 1'b0; sel_a = 1'b0; sel_b = 1'b0; end // acc<-acc+a
DEC: begin en_cnt = 1'b1; alu_op = 1'b1; sel_a = 1'b1; sel_b = 1'b1; end // cnt<-cnt-1
FIN: done = 1'b1;
default: ; // IDLE: all controls low
endcase
end
endmoduleThe controller is verified as a sequence, independently of real arithmetic: drive start, drive a zero model, and check the emitted control word matches the expected schedule cycle by cycle. This is the second half of the "verify each half alone" payoff.
module mac_ctrl_tb;
logic clk = 0, rst, start, zero;
logic load, en_acc, en_cnt, alu_op, sel_a, sel_b, done;
int errors = 0;
always #5 clk = ~clk;
mac_ctrl dut (.*);
initial begin
rst = 1; start = 0; zero = 0;
@(posedge clk); #1; rst = 0;
start = 1; @(posedge clk); #1; start = 0; // IDLE -> LOAD
@(posedge clk); #1; // LOAD -> ADD (zero low)
assert (en_acc && !en_cnt && !alu_op) else begin $error("ADD control wrong"); errors++; end
@(posedge clk); #1; // ADD -> DEC
assert (en_cnt && alu_op && sel_a && sel_b) else begin $error("DEC control wrong"); errors++; end
zero = 1; @(posedge clk); #1; // model cnt hit 0 -> DEC -> FIN
@(posedge clk); #1;
assert (done) else begin $error("done not asserted"); errors++; end
if (errors == 0) $display("PASS: controller emits the correct control sequence");
else $display("FAIL: %0d controller errors", errors);
$finish;
end
endmoduleThe Verilog controller uses localparam state encodings and two always @(*) blocks (next-state and output) plus a clocked state register — the classic three-block FSM of Chapter 4.
module mac_ctrl (
input wire clk, rst, start, zero,
output reg load, en_acc, en_cnt, alu_op, sel_a, sel_b, done
);
localparam [2:0] IDLE=3'd0, LOAD=3'd1, ADD=3'd2, DEC=3'd3, FIN=3'd4;
reg [2:0] state, nstate;
always @(posedge clk)
if (rst) state <= IDLE;
else state <= nstate;
always @(*) begin
nstate = state;
case (state)
IDLE: if (start) nstate = LOAD;
LOAD: nstate = zero ? FIN : ADD;
ADD: nstate = DEC;
DEC: nstate = zero ? FIN : ADD;
FIN: nstate = FIN;
default: nstate = IDLE;
endcase
end
always @(*) begin
{load, en_acc, en_cnt, alu_op, sel_a, sel_b, done} = 7'b0; // default: all low
case (state)
LOAD: load = 1'b1;
ADD: begin en_acc = 1'b1; alu_op = 1'b0; sel_a = 1'b0; sel_b = 1'b0; end
DEC: begin en_cnt = 1'b1; alu_op = 1'b1; sel_a = 1'b1; sel_b = 1'b1; end
FIN: done = 1'b1;
default: ;
endcase
end
endmodule module mac_ctrl_tb;
reg clk, rst, start, zero;
wire load, en_acc, en_cnt, alu_op, sel_a, sel_b, done;
integer errors;
initial clk = 0;
always #5 clk = ~clk;
mac_ctrl dut (.clk(clk), .rst(rst), .start(start), .zero(zero),
.load(load), .en_acc(en_acc), .en_cnt(en_cnt), .alu_op(alu_op),
.sel_a(sel_a), .sel_b(sel_b), .done(done));
initial begin
errors = 0; rst = 1; start = 0; zero = 0;
@(posedge clk); #1; rst = 0;
start = 1; @(posedge clk); #1; start = 0; // -> LOAD
@(posedge clk); #1; // -> ADD
if (!(en_acc && !en_cnt && !alu_op)) begin $display("FAIL: ADD control"); errors=errors+1; end
@(posedge clk); #1; // -> DEC
if (!(en_cnt && alu_op && sel_a && sel_b)) begin $display("FAIL: DEC control"); errors=errors+1; end
zero = 1; @(posedge clk); #1; // -> FIN
@(posedge clk); #1;
if (!done) begin $display("FAIL: done"); errors=errors+1; end
if (errors == 0) $display("PASS: controller emits the correct control sequence");
else $display("FAIL: %0d controller errors", errors);
$finish;
end
endmoduleIn VHDL the controller is a state enumeration with a clocked state process and combinational next-state/output processes. process (all) drives every control output on every path (default-first) so the output logic is latch-free.
library ieee;
use ieee.std_logic_1164.all;
entity mac_ctrl is
port (
clk, rst, start, zero : in std_logic;
load, en_acc, en_cnt : out std_logic;
alu_op, sel_a, sel_b : out std_logic;
done : out std_logic
);
end entity;
architecture rtl of mac_ctrl is
type state_t is (IDLE, LOAD, ADD, DEC, FIN);
signal state, nstate : state_t;
begin
-- state register
process (clk)
begin
if rising_edge(clk) then
if rst = '1' then state <= IDLE; else state <= nstate; end if;
end if;
end process;
-- next-state: branch on the datapath STATUS, no arithmetic
process (all)
begin
nstate <= state;
case state is
when IDLE => if start = '1' then nstate <= LOAD; end if;
when LOAD => if zero = '1' then nstate <= FIN; else nstate <= ADD; end if;
when ADD => nstate <= DEC;
when DEC => if zero = '1' then nstate <= FIN; else nstate <= ADD; end if;
when FIN => nstate <= FIN;
end case;
end process;
-- output logic: default-first (latch-free), one control word per state
process (all)
begin
load <= '0'; en_acc <= '0'; en_cnt <= '0';
alu_op <= '0'; sel_a <= '0'; sel_b <= '0'; done <= '0';
case state is
when LOAD => load <= '1';
when ADD => en_acc <= '1'; alu_op <= '0'; sel_a <= '0'; sel_b <= '0';
when DEC => en_cnt <= '1'; alu_op <= '1'; sel_a <= '1'; sel_b <= '1';
when FIN => done <= '1';
when others => null;
end case;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
entity mac_ctrl_tb is
end entity;
architecture sim of mac_ctrl_tb is
signal clk : std_logic := '0';
signal rst, start, zero : std_logic := '0';
signal load, en_acc, en_cnt, alu_op, sel_a, sel_b, done : std_logic;
begin
dut : entity work.mac_ctrl
port map (clk => clk, rst => rst, start => start, zero => zero,
load => load, en_acc => en_acc, en_cnt => en_cnt,
alu_op => alu_op, sel_a => sel_a, sel_b => sel_b, done => done);
clk <= not clk after 5 ns;
stim : process
begin
rst <= '1'; wait until rising_edge(clk); wait for 1 ns; rst <= '0';
start <= '1'; wait until rising_edge(clk); wait for 1 ns; start <= '0'; -- -> LOAD
wait until rising_edge(clk); wait for 1 ns; -- -> ADD
assert en_acc = '1' and en_cnt = '0' and alu_op = '0'
report "ADD control wrong" severity error;
wait until rising_edge(clk); wait for 1 ns; -- -> DEC
assert en_cnt = '1' and alu_op = '1' and sel_a = '1' and sel_b = '1'
report "DEC control wrong" severity error;
zero <= '1'; wait until rising_edge(clk); wait for 1 ns; -- -> FIN
wait until rising_edge(clk); wait for 1 ns;
assert done = '1' report "done not asserted" severity error;
report "controller self-check complete" severity note;
wait;
end process;
end architecture;4c. The composition — and the timing/contention bug (buggy vs fixed)
Wire the controller's control outputs to the datapath's control inputs and the datapath's zero back to the controller: that is the whole MAC engine. The top runs the worked example to done, and the golden result is acc = 5*3 + 2 = 17. This is where the interface becomes a contract, and where §7's bug lives: if the controller schedules the accumulate-add and the counter-decrement onto the single shared ALU in the same cycle, they collide — only one can use the ALU — and the result is wrong. The buggy controller below tries to do both in one ADD state; the fixed one splits them into ADD then DEC so the one ALU serves one operation per cycle.
// ---- BUGGY controller: schedules acc+a AND cnt-1 in the SAME cycle onto the
// ONE shared ALU. The ALU can produce only ONE result per cycle, so acc and
// cnt cannot both be correct: they collide on the shared resource. ----
module mac_ctrl_bad (
input logic clk, rst, start, zero,
output logic load, en_acc, en_cnt, alu_op, sel_a, sel_b, done
);
typedef enum logic [1:0] {IDLE, LOAD, LOOP, FIN} state_t;
state_t state, nstate;
always_ff @(posedge clk) if (rst) state <= IDLE; else state <= nstate;
always_comb begin
nstate = state;
unique case (state)
IDLE: if (start) nstate = LOAD;
LOAD: nstate = (zero) ? FIN : LOOP;
LOOP: nstate = (zero) ? FIN : LOOP; // reads zero; loops in ONE state
FIN: nstate = FIN;
endcase
end
always_comb begin
{load,en_acc,en_cnt,alu_op,sel_a,sel_b,done} = '0;
unique case (state)
LOAD: load = 1'b1;
// BUG: en_acc AND en_cnt asserted together — both want the shared ALU
// this cycle, but alu_op/sel_* can pick only ONE operation.
LOOP: begin en_acc = 1'b1; en_cnt = 1'b1; alu_op = 1'b0; sel_a = 1'b0; sel_b = 1'b0; end
FIN: done = 1'b1;
default: ;
endcase
end
endmodule
// ---- FIXED: use mac_ctrl (ADD then DEC) — one shared-ALU op per cycle. ----
module mac_top #(
parameter int WIDTH = 8
)(
input logic clk, rst, start,
input logic [WIDTH-1:0] a, b, c,
output logic [WIDTH-1:0] acc,
output logic done
);
logic load, en_acc, en_cnt, alu_op, sel_a, sel_b, zero;
mac_ctrl u_ctrl (.clk(clk), .rst(rst), .start(start), .zero(zero),
.load(load), .en_acc(en_acc), .en_cnt(en_cnt), .alu_op(alu_op),
.sel_a(sel_a), .sel_b(sel_b), .done(done));
mac_datapath #(.WIDTH(WIDTH)) u_dp (.clk(clk), .rst(rst), .a(a), .b(b), .c(c),
.load(load), .en_acc(en_acc), .en_cnt(en_cnt), .alu_op(alu_op),
.sel_a(sel_a), .sel_b(sel_b), .acc(acc), .zero(zero));
endmodule module mac_top_tb;
localparam int WIDTH = 8;
logic clk = 0, rst, start, done;
logic [WIDTH-1:0] a, b, c, acc;
always #5 clk = ~clk;
mac_top #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .start(start),
.a(a), .b(b), .c(c), .acc(acc), .done(done));
initial begin
rst = 1; start = 0; a = 8'd5; b = 8'd3; c = 8'd2; // golden = 5*3+2 = 17
@(posedge clk); #1; rst = 0;
start = 1; @(posedge clk); #1; start = 0;
repeat (40) begin @(posedge clk); #1; if (done) break; end
assert (done && acc === 8'd17)
$display("PASS: MAC computed a*b+c = %0d (expected 17)", acc);
else
$display("FAIL: acc=%0d done=%b (expected 17) — shared-ALU / timing bug", acc, done);
$finish;
end
endmoduleThe Verilog composition mirrors it: the buggy controller asserts both enables in one loop state; the fixed top instantiates mac_ctrl (ADD then DEC). The testbench runs to done and checks acc == 17.
// BUGGY controller: en_acc and en_cnt in the SAME loop cycle -> shared-ALU collision.
module mac_ctrl_bad (
input wire clk, rst, start, zero,
output reg load, en_acc, en_cnt, alu_op, sel_a, sel_b, done
);
localparam [1:0] IDLE=0, LOAD=1, LOOP=2, FIN=3;
reg [1:0] state, nstate;
always @(posedge clk) if (rst) state <= IDLE; else state <= nstate;
always @(*) begin
nstate = state;
case (state)
IDLE: if (start) nstate = LOAD;
LOAD: nstate = zero ? FIN : LOOP;
LOOP: nstate = zero ? FIN : LOOP;
default: nstate = FIN;
endcase
end
always @(*) begin
{load,en_acc,en_cnt,alu_op,sel_a,sel_b,done} = 7'b0;
case (state)
LOAD: load = 1'b1;
LOOP: begin en_acc=1'b1; en_cnt=1'b1; alu_op=1'b0; sel_a=1'b0; sel_b=1'b0; end // BUG
FIN: done = 1'b1;
default: ;
endcase
end
endmodule
// FIXED top: uses mac_ctrl (ADD then DEC) — one shared-ALU op per cycle.
module mac_top #(
parameter WIDTH = 8
)(
input wire clk, rst, start,
input wire [WIDTH-1:0] a, b, c,
output wire [WIDTH-1:0] acc,
output wire done
);
wire load, en_acc, en_cnt, alu_op, sel_a, sel_b, zero;
mac_ctrl u_ctrl (.clk(clk), .rst(rst), .start(start), .zero(zero),
.load(load), .en_acc(en_acc), .en_cnt(en_cnt), .alu_op(alu_op),
.sel_a(sel_a), .sel_b(sel_b), .done(done));
mac_datapath #(.WIDTH(WIDTH)) u_dp (.clk(clk), .rst(rst), .a(a), .b(b), .c(c),
.load(load), .en_acc(en_acc), .en_cnt(en_cnt), .alu_op(alu_op),
.sel_a(sel_a), .sel_b(sel_b), .acc(acc), .zero(zero));
endmodule module mac_top_tb;
parameter WIDTH = 8;
reg clk, rst, start;
reg [WIDTH-1:0] a, b, c;
wire [WIDTH-1:0] acc;
wire done;
integer i;
initial clk = 0;
always #5 clk = ~clk;
mac_top #(.WIDTH(WIDTH)) dut (.clk(clk), .rst(rst), .start(start),
.a(a), .b(b), .c(c), .acc(acc), .done(done));
initial begin
rst = 1; start = 0; a = 8'd5; b = 8'd3; c = 8'd2; // golden = 17
@(posedge clk); #1; rst = 0;
start = 1; @(posedge clk); #1; start = 0;
for (i = 0; i < 40; i = i + 1) begin @(posedge clk); #1; if (done) i = 40; end
if (done && acc === 8'd17)
$display("PASS: MAC computed a*b+c = %0d (expected 17)", acc);
else
$display("FAIL: acc=%0d done=%b (expected 17) — shared-ALU / timing bug", acc, done);
$finish;
end
endmoduleIn VHDL the composition is a structural architecture instantiating the two entities; the buggy controller asserts both enables in one loop state. The testbench runs the worked example to done and asserts acc = 17.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mac_top is
generic ( WIDTH : positive := 8 );
port (
clk, rst, start : in std_logic;
a, b, c : in unsigned(WIDTH-1 downto 0);
acc : out unsigned(WIDTH-1 downto 0);
done : out std_logic
);
end entity;
architecture rtl of mac_top is
signal load, en_acc, en_cnt, alu_op, sel_a, sel_b, zero : std_logic;
begin
u_ctrl : entity work.mac_ctrl -- FIXED: ADD then DEC
port map (clk => clk, rst => rst, start => start, zero => zero,
load => load, en_acc => en_acc, en_cnt => en_cnt,
alu_op => alu_op, sel_a => sel_a, sel_b => sel_b, done => done);
u_dp : entity work.mac_datapath
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst, a => a, b => b, c => c,
load => load, en_acc => en_acc, en_cnt => en_cnt,
alu_op => alu_op, sel_a => sel_a, sel_b => sel_b,
acc => acc, zero => zero);
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity mac_top_tb is
end entity;
architecture sim of mac_top_tb is
constant WIDTH : positive := 8;
signal clk : std_logic := '0';
signal rst, start, done : std_logic := '0';
signal a, b, c, acc : unsigned(WIDTH-1 downto 0);
begin
dut : entity work.mac_top
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst, start => start,
a => a, b => b, c => c, acc => acc, done => done);
clk <= not clk after 5 ns;
stim : process
begin
a <= to_unsigned(5, WIDTH); b <= to_unsigned(3, WIDTH); c <= to_unsigned(2, WIDTH); -- golden 17
rst <= '1'; wait until rising_edge(clk); wait for 1 ns; rst <= '0';
start <= '1'; wait until rising_edge(clk); wait for 1 ns; start <= '0';
for i in 0 to 39 loop
wait until rising_edge(clk); wait for 1 ns;
exit when done = '1';
end loop;
assert done = '1' and acc = to_unsigned(17, WIDTH)
report "MAC wrong: acc /= 17 (shared-ALU / timing bug)" severity error;
report "MAC self-check complete: a*b+c = 17" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: the controller schedules single-cycle operations onto a datapath with real latency and finite ports — assert one operation per shared resource per cycle, and sample each status when it is actually valid, or the interface's timing contract breaks.
5. Verification Strategy
The whole point of the split is that it makes verification tractable: you prove the datapath correct, the controller correct, and the composition correct — three bounded problems instead of one tangled one. The single invariant that captures a correct engine is:
The datapath obeys every control command exactly, the controller emits exactly the right control sequence and branches correctly on status, and their composition computes the worked example to its golden value.
Everything below is that invariant, made checkable, and it maps directly onto the testbenches in §4.
- Verify the datapath alone — exhaustively over control (self-checking). Drive the control signals directly, with no controller, and for each command combination check the datapath's next state and flags:
loadseedsacc<-c/cnt<-b;en_accwith the accumulate mux/op givesacc <- acc + a;en_cntwith the decrement mux/op givescnt <- cnt - 1; andzerorises exactly whencntreaches 0. Because the datapath has no sequence, this is a complete check of the compute contract. The threemac_datapathtestbenches in §4a do exactly this: SVassert (acc === 8'd7), Verilogif (acc !== 8'd7) $display("FAIL…"), VHDLassert acc = to_unsigned(7,WIDTH) report … severity error. - Verify the controller alone — as a sequence, on a modelled status. Drive
startand a modelledzero(not the real datapath) and check the emitted control word cycle by cycle: IDLE holds all controls low, LOAD assertsload, ADD assertsen_acc/add, DEC assertsen_cnt/sub, anddonerises afterzero. This proves the grammar — the schedule and the branch — independent of arithmetic. Themac_ctrltestbenches in §4b assert the control word per state. - Verify the composition — run the worked example to a golden value. Wire the two together, drive
a=5, b=3, c=2, pulsestart, wait fordone, and assertacc == 17. Themac_toptestbenches in §4c do this:assert (done && acc === 8'd17)(SV),if (done && acc === 8'd17)(Verilog),assert done = '1' and acc = to_unsigned(17,WIDTH)(VHDL). The golden value is the end-to-end contract:5 + 5 + 5 + 2 = 17. - The one-operation-per-shared-resource invariant. State it as a property that holds every cycle: at most one command targets the shared ALU per cycle —
en_accanden_cntare never both asserted in the same cycle, because the single ALU produces one result per cycle. The buggy controller of §4c violates it; the fix (ADD then DEC) restores it. A composition testbench should assert this invariant directly, not just the final value, so the collision is caught at its source. - Status-timing corner: sample when valid. Decide, then test the decision: is
zerocombinational (same cycle) or registered (next cycle)? Herecntis registered, sozeroreflects the captured count and the controller samples it in the state after the decrement — the DEC→FIN branch. Point the composition testbench at operands that stress the boundary (b=0, so the loop must be skipped entirely;b=1, a single pass) and confirm the branch fires on the right cycle. Sampling one cycle early is exactly the §7 failure. - Parameter-generic and corner operands. Re-run the composition for
WIDTH = 8, 16and for corner operands:b=0(result must becwith no add),b=1(one add), and a largerbthat exercises many loop passes. A schedule that works forb=3can still be wrong atb=0if the LOAD→FIN skip is missing. Expected waveform: on a correct run,startpulses, then each loop pass shows oneen_acccycle followed by oneen_cntcycle (never both high together),accsteps2 → 7 → 12 → 17,cntsteps3 → 2 → 1 → 0,zerorises withcnt=0, anddonefollows — the visual signature of a schedule that respects the shared ALU.
6. Common Mistakes
Control/status timing mismatch — sampling a status a cycle early or late. The interface has a timing dimension the port list does not show: a status is either combinational (valid the same cycle the control was issued) or registered (valid the next cycle). If the controller branches on zero the cycle the decrement is commanded rather than the cycle the count is captured, it reads the stale value and loops one time too many or too few. This is the 4.6 lesson at the composition level: the controller must sample each status in the cycle it is actually valid, which depends on the datapath's real latency (full post-mortem in §7).
Shared-resource contention — two operations, one resource, same cycle. The datapath has finite ports: one shared ALU, one write port on a register, one bus. If the controller schedules two operations that both need that resource in the same cycle — acc <- acc + a and cnt <- cnt - 1 both through the one ALU — they collide, and at most one can be right. The fix is scheduling: serialize the two onto separate cycles (ADD then DEC), or add the resource (a second adder for the counter). The controller's schedule must fit within the datapath's port count.
Leaking datapath decisions into the controller (or control into the datapath). If the controller starts doing arithmetic — computing acc + a itself and just handing the datapath a number — or the datapath starts deciding which operation to run based on data it inspects, the seam is gone: neither half is reusable, and you cannot verify them in isolation. Keep the contract clean: the datapath computes and never decides; the controller decides and never computes. The moment a data value appears in the controller's next-state logic, or a sequencing decision appears in the datapath, the split has leaked.
A monolithic FSM that encodes datapath values in states — state explosion. The temptation is to fold the datapath into the FSM: a state per count value, a state per accumulator value, so the "controller" is really the whole computation unrolled into states. For a MAC that loops b times this needs a state per possible b — the state count explodes with the data width, and the design becomes unsynthesizable and unverifiable. The datapath's job is to hold those values in registers; the controller's states should count only the phases of the algorithm (IDLE/LOAD/ADD/DEC/FIN), never the data.
Widening the datapath but not the counter (or the control). Because the split localizes change, it is easy to widen acc/a/b/c to a new WIDTH and forget that the loop count cnt and the zero compare must track it too, or that a status the controller samples changed timing. Change one side of the interface and re-verify the other: the seam is narrow, but it is still a contract both sides must honour.
7. DebugLab
The MAC that returned the wrong product — a schedule that collided on the shared ALU
The engineering lesson: the datapath/control interface is a contract, and its terms are timing and resource count — control-signal scheduling must respect the datapath's real latency and its finite ports. The tell is a bug that depends on the sequence ("b=1 passes, b=3 fails, sometimes it hangs"): sequence-dependence in a design that is supposed to compute a fixed function means the schedule, not the arithmetic, is wrong — the controller asked the datapath to do more in one cycle than its ports allow, or read a status before the datapath had produced it. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: schedule at most one operation per shared resource per cycle (serialize onto separate states, or add the resource), and sample each status in the cycle it is actually valid (know whether it is combinational or registered, and branch accordingly). Verify by asserting the one-op-per-resource invariant and running the worked example to its golden value.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "split compute from control, respect the interface contract" habit.
Exercise 1 — Draw the two boxes and the interface
For the MAC engine of this page, list (i) every signal that crosses the interface and its direction (control down / status up), and (ii) for each, one sentence on whether it is a command or a fact. Then state which single signal, if it were sampled one cycle early, would cause the loop to run the wrong number of times — and why.
Exercise 2 — Reuse the datapath under a new controller
Keep the mac_datapath from §4a unchanged. Design (in states + control words, no full RTL) a different controller that makes it compute a running sum of N external values streamed in on a — acc <- acc + a each cycle a new value is valid, for N values — instead of a MAC. State what changes (the controller) and what does not (the datapath), and explain why that is the reuse payoff of the split.
Exercise 3 — Schedule onto a scarce resource
Your datapath has exactly one ALU and one register write port. You must, in a loop body, do both acc <- acc + a and cnt <- cnt - 1. Give (i) the serialize schedule (states and how many cycles per loop pass) and (ii) the replicate fix (what hardware you add and the cycles-per-pass it buys). Name the throughput-vs-area trade-off each makes, and state the invariant a testbench should assert to catch a schedule that over-subscribes the ALU.
Exercise 4 — Spot the leak
Two engineers implement the MAC. Engineer A's controller next-state logic contains if (acc + a > threshold) …. Engineer B's datapath contains a case that inspects the accumulator value to decide which operation to run. For each, name which direction the interface leaked (control into datapath, or datapath decision into control), what reuse/verifiability property it destroys, and how you would refactor it back to a clean split.
10. Related Tutorials
Continue in RTL Design Patterns (forward links unlock as they ship):
- FSM + Datapath (FSMD) — Chapter 4.6; the flagship this capstone generalizes — a controller driving a datapath across a control/status interface, here raised to a reusable methodology.
- ALU Construction — Chapter 5.5; the shared compute core this page's datapath is built around and sequences.
- Multiplexers (2:1, N:1, one-hot) — Chapter 1.1; the operand muxes that steer sources to the shared ALU, and the latch-free select discipline the datapath relies on.
- Register Enable / Load — Chapter 2.x; the enabled
acc/cntregisters that capture the ALU result on the controller's command. - Inferring RAM — Chapter 6.1 (unlocks as it ships); when the datapath's storage grows from registers to a memory the controller sequences addresses into.
- Register Files — Chapter 6.x (unlocks as it ships); a multi-port datapath store the controller reads and writes across cycles.
- valid/ready Handshake — Chapter 9.1 (unlocks as it ships); how the controller's
start/donegrow into a backpressured interface to the block above.
Backward / composed patterns:
- FSM Fundamentals — Chapter 4.1; the three-block controller (state register + next-state + output logic) this page's controller is.
- The Register Pattern (D-FF) — Chapter 2.1; the storage atom the accumulator and counter are built from.
- Adders & Subtractors — Chapter 5.1; the shared add/subtract unit at the heart of the datapath's ALU.
- Multipliers — Chapter 5.x; the multiply-by-repeated-addition method this page's worked example sequences by hand.
- Barrel Shifters — Chapter 5.3; another datapath functional unit a controller can sequence across cycles.
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this page applies at the block level.
- What Is an RTL Design Pattern? — Chapter 0.1; why the datapath/control split earns the name "pattern" — a recurring problem, a reusable form, a synthesis reality, and a signature failure.
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Case Statements — the construct behind the controller's next-state and output logic and the datapath's operation select.
- Arithmetic Operators — the
+/-the shared ALU is built from, and the width rules the WIDTH-generic datapath depends on. - Blocking and Non-Blocking Assignments — why the clocked
acc/cntcaptures use<=and the combinational control/mux logic uses=. - If-Else Statements — the conditional branches the controller uses to sequence on the
zerostatus.
11. Summary
- Split compute from control. A datapath computes and stores (the ALU, accumulator, operand muxes) and makes no decisions; a controller is a pure FSM that decides which operation runs each cycle and does no arithmetic. The datapath is the verbs and nouns; the controller is the grammar.
- One narrow interface — control down, status up. Control signals (
alu_op,sel_a/sel_b,en_acc/en_cnt,load,done) go down as commands; status flags (zero, less-than, overflow) come up as facts the controller branches on. Keep it narrow and the two halves stay independent. - The split pays off four ways. Reuse — one datapath, many controllers (or microcode). Verifiability — exhaust the datapath, sequence-test the controller, then check the composition, three tractable problems. Scalability — widen the datapath, the controller is untouched. Timing — the split forces you to name combinational vs registered status and sample it correctly.
- The interface is a timing/resource contract. The controller must schedule at most one operation per shared resource per cycle (serialize onto separate states, or add the resource) and sample each status in the cycle it is valid (combinational = same cycle, registered = next). Break either and you get the §7 shared-ALU / timing-contention bug — a wrong answer on exactly the sequences that collide.
- Verify with a golden value. Drive the datapath exhaustively over control, the controller as a sequence on a modelled status, and the composition end-to-end — running the worked multiply-accumulate
a*b + cwitha=5, b=3, c=2todoneand assertingacc == 17, in self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL.