"This operation takes several cycles. How do you split the controller from the datapath?" The interviewer is testing ownership discipline and the command-status contract between the two blocks.
The complete answer draws the boundary, names who drives what, and shows a multi-cycle run proved against a reference.
Read the arrows as ownership. The controller has four states: IDLE waits for start and asserts load on the accepting edge, LOAD is one startup cycle, RUN issues one subtract per edge until a_eq_b, and DONE pulses done_o before returning to IDLE.
The controller issues a command valid for each operation edge. Consecutive commands may keep a signal high across several cycles: each edge performs another micro-operation. The datapath owns the data registers; status flows back combinationally from them.
Signal | Direction | Rule |
|---|---|---|
| Controller to datapath | At most one command per edge; a repeated subtract may remain high across consecutive edges |
| Datapath to controller | Combinational from registers; they describe current values |
| Outside to controller | Start is accepted only in IDLE; operands are captured on that edge; done is a one-cycle pulse |
Exactly one block drives each signal. The controller never touches a data register, and the datapath never decides the sequence. load is asserted combinationally in IDLE when start is high, so the datapath captures operands on the accepting edge. The caller may change them immediately afterwards. LOAD is a startup state, not a later sampling point.
Multi-cycle work is just repeated micro-ops. The controller sequences subtract steps until the status says equal, then raises done_o. Variable latency is expected, so completion is signalled, not assumed.
Status comparators sit on the controller critical path, since they feed its next-state choice combinationally. That is the known cost of the split, and STA must cover it.
Edge (15,1) | Pre-edge command | Post-edge operands/state |
|---|---|---|
1: accept start in IDLE | load | 15,1 / LOAD |
2 | None | 15,1 / RUN |
3, 4, …, 16 | sub_a on every edge | 14,1 then 13,1 … then 1,1 / RUN |
17 | None: equality seen | 1,1 / DONE; done_o visible after edge |
18 | None | Return IDLE; downstream consumes pre-edge done |
One operand is zero. What breaks? The subtract loop never reaches equal, so the machine never finishes. Guard zero up front (b == 0 means done with a), or require nonzero inputs.
A datapath step needs two cycles instead of one. What changes? If exactly two cycles is guaranteed, wait that count and qualify result validity. If latency varies, issue once and wait for completion; do not repeat acceptance accidentally while busy.
What sets the throughput here? One subtraction per RUN edge, plus acceptance, LOAD and completion/return overhead. Higher overlapping throughput requires an architecture change; a faster clock changes results per second, not subtracts per cycle.
"The controller reads the datapath registers directly." That welds the two blocks together. The controller reads only the status flags, so the datapath stays reusable and separately checkable.
"Wait a fixed 10 cycles, then take the result." Cycle counting breaks when operands change the run length. Poll done_o instead.
"Both blocks can clear the accumulator." Two writers on one register are rejected by always_ff checking, or, with plain always, become an order-dependent simulation race (no X appears) and a multi-driver synthesis error. Ownership is exclusive, always.
a_eq_b from the datapath instead of comparing the operands itself?The operands live in the datapath. Comparing them in the controller would duplicate the registers or read across the boundary, breaking the split. Status flags are the only way back.
It waits for done_o with a watchdog, then reads. The run length varies with the operands, so no fixed delay would work for all pairs.
A subtractive GCD split into datapath, controller, and top wiring, checked against a reference over all 225 nonzero 4-bit operand pairs. The test changes both operand pins to zero immediately after acceptance to check the sampling contract. After each run it waits one cycle for the return to IDLE, since a start arriving during DONE would be missed.
module gcd_datapath (
input logic clk, rst_n, load, sub_a, sub_b,
input logic [3:0] a_in, b_in,
output logic [3:0] a, b
);
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin a <= 4'd0; b <= 4'd0; end
else if (load) begin a <= a_in; b <= b_in; end
else if (sub_a) a <= a - b;
else if (sub_b) b <= b - a;
endmodule
module gcd_ctrl (
input logic start, a_gt_b, a_eq_b, clk, rst_n,
output logic load, sub_a, sub_b, busy, done_o
);
typedef enum logic [1:0] {IDLE, LOAD, RUN, DONE} state_t;
state_t state, next_state;
always_comb begin
next_state = state;
load = 1'b0; sub_a = 1'b0; sub_b = 1'b0;
busy = 1'b1; done_o = 1'b0;
case (state)
IDLE: begin
busy = 1'b0;
if (start) begin load = 1'b1; next_state = LOAD; end
end
LOAD: next_state = RUN; // operands were captured on the accepting IDLE edge
RUN: if (a_eq_b) next_state = DONE;
else if (a_gt_b) sub_a = 1'b1;
else sub_b = 1'b1;
DONE: begin busy = 1'b0; done_o = 1'b1; next_state = IDLE; end
default: next_state = IDLE;
endcase
end
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) state <= IDLE;
else state <= next_state;
endmodule
module gcd_top (
input logic clk, rst_n, start,
input logic [3:0] a_in, b_in,
output logic busy, done_o,
output logic [3:0] result
);
logic load, sub_a, sub_b, a_gt_b, a_eq_b;
logic [3:0] a, b;
assign a_gt_b = (a > b);
assign a_eq_b = (a == b);
assign result = a;
gcd_datapath dp (.*);
gcd_ctrl ct (.*);
endmodule