RTL Design Patterns · Chapter 13 · Case Studies
Multi-Master Bus Arbiter + Controller
This case study is where arbitration becomes a real bus: several masters, such as a CPU, a DMA engine, and a debug port, all need to reach one shared slave over one set of address and data wires that can carry only one transaction at a time. Nothing here is new. The arbiter core is a round-robin arbiter so no master starves, the ownership lock is a request and grant handshake that holds grant for a whole transaction so the bus is never re-arbitrated mid-transfer, and the bus controller is a safe FSM that runs an address phase, a data phase, and a done strobe. You will wire these into one parameterized bus, drive heavy contention, and prove that every transaction completes correctly with no bus contention. The two signature bugs, a grant that changes mid-transaction and a master driving without grant, are built and verified in SystemVerilog, Verilog, and VHDL.
Advanced20 min readRTL Design PatternsBus ArbiterRound-RobinOwnership LockBus Controller FSMAtomicityFairnessBus Contention
Chapter 13 · Section 13.4 · Case Studies
1. The Engineering Problem
You have three masters — a CPU load/store unit, a DMA engine, and a debug port — and one shared slave, a small memory. Between them runs one bus: one address bus, one write-data bus, one read-data bus back. Only one master can drive those wires at a time; two masters driving the shared address bus in the same cycle is not two transactions, it is a short — contention that resolves to X. So you need something to decide, each transaction, whose address and data ride the bus, to route them to the addressed slave, and to route the slave's read data back to the right master. That decider is an arbiter, and the machine that runs the chosen transfer beat by beat is a bus controller.
The naive picture — an arbiter that picks a winner every cycle and a mux that steers that winner onto the bus — is almost right, and its failure is the whole reason this is a capstone. A real transaction is not one cycle. A master puts an address on the bus in one beat and its data in the next beat (or several beats for a burst); the transfer is atomic — it must run start to finish without another master's wires cutting in. If the arbiter re-arbitrates every cycle, then the instant a higher-priority master raises its request mid-transfer, the grant flips, the bus mux switches to the new master, and the original master's transaction is sliced in half: its address beat went to the slave but its data beat came from someone else. The transfer is corrupted, silently, and only under contention.
So the arbiter must do two jobs that pull in opposite directions. It must be fair — no master waits forever, every master is served within a bounded number of transactions no matter how greedy its neighbours are (Chapter 10.5) — and it must be atomic — once a master is granted, it keeps the bus for its entire transaction, no mid-transfer preemption. Fairness says 'take turns'; atomicity says 'but finish your turn.' The structure that satisfies both is a round-robin arbiter (10.2) wrapped in an ownership lock (10.4): pick fairly, then hold the grant until the transaction signals done, and only then advance the rotation. That is the pattern this page builds.
// Several masters want one shared slave over ONE set of bus wires:
wire [N-1:0] req; // each master asserts req when it has a transaction
wire [N-1:0] grant; // one-hot: exactly one owner drives the bus
wire [WIDTH-1:0] addr; // the shared address bus (muxed to the OWNER)
wire [WIDTH-1:0] wdata; // the shared write-data bus (muxed to the OWNER)
// WRONG -- re-arbitrate every cycle. A higher-priority master asking mid-transfer
// flips grant, the mux switches, and the in-flight transaction is corrupted:
// assign grant = req & (~req + 1); // pure combinational pick, no lock => preemption
// RIGHT -- round-robin pick, then LOCK the grant on the owner until xfer_done, then
// advance the pointer past the winner and re-arbitrate. Fair AND atomic.
// (the pattern this page builds -- arbiter core + ownership lock + controller FSM)2. Mental Model
3. Pattern Anatomy
The arbitered bus is three blocks bound by one lock. Take the topology first (who drives whom), then the transaction timeline (what happens beat by beat), then the two pieces of discipline that decide correctness: the ownership lock that freezes arbitration, and the mutual exclusion the bus mux enforces.
The topology — N masters, an arbiter, a shared bus, one slave. Each master has a request line and its own address/write-data it wants to drive. The arbiter takes all N requests and produces a one-hot grant. The grant does two things: it feeds the ownership lock (which holds it steady for the transaction) and it selects the N-to-1 bus mux that steers only the owner's address and write-data onto the shared bus. Address decode on the shared address selects the slave; the slave's read-data returns on the shared read bus and is routed back to the owner. One owner drives the bus; everyone else is masked off.
Multi-master bus topology — N masters through the arbiter and ownership lock, muxed onto one shared bus to the slave
data flowThe transaction timeline — request, grant, address, data, release, re-arbitrate. A master with work raises REQUEST and holds it. When the arbiter grants it and the lock latches it as owner, the controller runs the transaction: an ADDRESS beat drives the owner's address (and, for a write, sets the direction) onto the shared bus; then one or more DATA beats move the payload — the slave latches write-data, or returns read-data. When the last beat completes the controller raises xfer_done; the owner drops its request, the lock releases, and the arbiter advances the pointer past this owner and re-arbitrates. The grant is stable from the moment it is latched until xfer_done — that stability is atomicity.
Transaction timeline — one master's bus transaction, request to re-arbitrate
data flowThe ownership lock — the grant register that freezes arbitration. The arbiter's combinational round-robin pick is a candidate; it is only the winner in cycles where no owner is active. The lock is a tiny FSM (IDLE, BUSY) with an OWNER register: in IDLE, if any request is active, latch the pick into OWNER and go BUSY; in BUSY, hold OWNER unchanged until the controller raises xfer_done, then clear OWNER and return to IDLE. The grant driven to the mux is OWNER, never the raw pick. While BUSY the pointer does not advance and the pick is ignored — the whole point. This is the request/grant discipline of 10.4: grant held through the transaction, request stable until release.
Mutual exclusion — the bus mux is the enforcement. The shared address and write-data buses are an N-to-1 mux (Chapter 1.1) selected by the one-hot grant: addr = master_addr[owner_index], and likewise for write-data. Because the grant is one-hot (at most one bit set), at most one master's wires reach the bus — mutual exclusion is structural, not a rule you hope masters obey. A master that instead drives the shared bus directly whenever it has a request is a second driver on the same net; two drivers is contention, and the value on the bus goes X. Masters must present their address/data to the mux and let the grant gate them — never drive the shared net themselves.
Round-robin fairness across transactions. Because the pointer advances to winner + 1 only at release, the winner of a transaction drops to the bottom of the rotation for the next arbitration. A busy master cannot win two transactions in a row while another master is asking: it wins, runs its (atomic) transaction, releases, and the pointer has moved past it, so the next requester at or after the new pointer wins. Worst-case wait is bounded — every asking master is served within N transactions (10.5). Fairness is a property across transactions; atomicity is a property within one; the lock is what lets both coexist.
4. Real RTL Implementation
This is the core of the page. We build the arbitered bus incrementally — (a) the round-robin arbiter core, (b) the request/grant ownership-lock layer, (c) the bus controller FSM — then the integrated, parameterized arbitered_bus, and show it plus its contention self-checking testbench and the grant-lost-mid-burst bug-vs-lock fix in SystemVerilog, Verilog, and VHDL. The idea is identical across the three; only the spelling differs.
Three conventions run through every block, each an earlier lesson made concrete:
- The pointer advances only at release. The round-robin core picks combinationally every cycle, but the pointer that rotates priority is updated only when a transaction completes (
xfer_done), not per cycle — that is what makes the arbiter fair per transaction while a transaction stays atomic. - The grant to the bus is the OWNER register, never the raw pick. The lock latches the winner and holds it; the bus mux and every master read the held grant. This is the one thing that keeps a transaction from being sliced.
- The shared bus is a one-hot-grant mux, and only the owner drives it. Address and write-data are muxed by grant; the slave is chosen by address decode. Masters offer their wires to the mux and are gated by grant — they never drive the shared net directly.
4a. Building block (i) — the round-robin arbiter core (10.2)
The core is the 10.2 round-robin arbiter almost verbatim: mask requests at or after the pointer, run a lowest-set-bit priority encoder, fall back to the unmasked encoder on wrap, and expose the one-hot pick. The only capstone change is that here the pointer will be advanced by the lock at release (§4b), not on every grant — so this block exposes the pick and lets the caller own the pointer update.
module rr_core #(
parameter int N = 4 // number of masters
)(
input logic [N-1:0] req, // N requests, valid in parallel
input logic [$clog2(N)-1:0] ptr, // rotating priority origin (owned by the lock)
output logic [N-1:0] pick // one-hot fair winner (0 if idle)
);
logic [N-1:0] mask, req_masked;
logic [N-1:0] pick_masked, pick_unmasked;
// MASK: keep only requests at index ptr or higher -> priority resolves FROM the
// pointer, not from index 0. This is what makes the pick rotate (10.2).
always_comb begin
for (int i = 0; i < N; i++) mask[i] = (i >= ptr);
req_masked = req & mask;
// lowest-set-bit priority encoder: v & (~v + 1) is one-hot (or 0).
pick_masked = req_masked & (~req_masked + 1'b1); // first req at-or-after ptr
pick_unmasked = req & (~req + 1'b1); // WRAP: first req from 0
// fallback: masked winner if any, else wrap to the unmasked winner.
pick = (|req_masked) ? pick_masked : pick_unmasked;
end
endmoduleThe pick is fair if the pointer is advanced past each winner. In 10.2 that happened every grant; here the transaction spans many cycles, so the pointer must advance exactly once per transaction — at release. That deferral is §4b's job.
4b. Building block (ii) — the ownership-lock layer (10.4)
The lock turns the per-cycle pick into a per-transaction owner. It is a two-state FSM with an OWNER register: latch the pick on entry to BUSY, hold it until xfer_done, then clear it and advance the round-robin pointer to winner + 1. The grant the rest of the system sees is OWNER — held for the whole transaction. This is shown as part of the integrated module (§4d); the standalone reasoning — grant driven from a register not the pick, pointer advanced only at release — is exactly the 10.4 handshake and the 10.2 rotation combined.
4c. Building block (iii) — the bus controller FSM (4.1)
The controller is a safe FSM (4.1) that runs one transaction while an owner holds the bus: ADDRESS (drive the owner's address onto the shared bus for one beat; decode selects the slave), then DATA (move BEATS beats of payload — the slave latches write-data or returns read-data), then raise xfer_done for one cycle and go back to IDLE. It starts when the lock reports an owner and signals xfer_done when the last data beat completes; that strobe is what tells the lock to release. The full RTL is in the integrated module below.
4d. The integrated arbitered bus — parameterized, in SystemVerilog
One module: rr_core + the ownership lock + the controller FSM + the N-to-1 bus mux + a simple slave, parameterized on N (masters) and WIDTH. Each master offers m_addr[i] / m_wdata[i] and a req[i]; the bus drives bus_addr / bus_wdata from the owner and returns bus_rdata; the slave here is a small write-then-read memory selected by the top address bit.
module arbitered_bus #(
parameter int N = 4, // number of masters
parameter int WIDTH = 8, // address/data width
parameter int BEATS = 2, // data beats per transaction
parameter int PW = (N > 1) ? $clog2(N) : 1
)(
input logic clk,
input logic rst, // synchronous, active-high
input logic [N-1:0] req, // req[i] = master i has a transaction
input logic [WIDTH-1:0] m_addr [N], // each master's address
input logic [WIDTH-1:0] m_wdata [N], // each master's write-data
output logic [N-1:0] grant, // one-hot OWNER (held across the transaction)
output logic xfer_done, // one-cycle: this transaction completed
output logic [WIDTH-1:0] bus_addr, // shared address bus (owner-muxed)
output logic [WIDTH-1:0] bus_wdata, // shared write-data bus (owner-muxed)
output logic [WIDTH-1:0] bus_rdata // shared read-data bus (from slave)
);
// ------------------- round-robin arbiter core (10.2) --------------------
logic [PW-1:0] ptr; // rotation origin, advanced at release
logic [N-1:0] pick;
rr_core #(.N(N)) core (.req(req), .ptr(ptr), .pick(pick));
// encode a one-hot vector to an index (for the pointer update)
function automatic logic [PW-1:0] idx_of(input logic [N-1:0] oh);
idx_of = '0;
for (int i = 0; i < N; i++) if (oh[i]) idx_of = i[PW-1:0];
endfunction
// ------------------- ownership lock (10.4) + controller FSM (4.1) -------
typedef enum logic [1:0] { IDLE, ADDRESS, DATA } state_t;
state_t state;
logic [N-1:0] owner; // registered one-hot owner (the LOCK)
logic [$clog2(BEATS+1)-1:0] beat; // data beats remaining
always_ff @(posedge clk) begin
if (rst) begin
state <= IDLE; owner <= '0; beat <= '0; ptr <= '0; xfer_done <= 1'b0;
end else begin
xfer_done <= 1'b0; // default: strobe is one cycle
unique case (state)
IDLE: if (|req) begin
owner <= pick; // LATCH the fair winner as OWNER (lock)
state <= ADDRESS; // ...and hold it; controller starts
end
ADDRESS: begin
beat <= BEATS[$clog2(BEATS+1)-1:0]; // set up the data beats
state <= DATA; // address beat done -> data phase
end
DATA: if (beat == 1) begin // last data beat completing
xfer_done <= 1'b1; // signal the transaction is done
// RELEASE: free the owner and advance the pointer PAST it.
ptr <= (idx_of(owner) == N-1) ? '0 : idx_of(owner) + 1'b1;
owner <= '0;
state <= IDLE; // re-arbitrate next cycle
end else begin
beat <= beat - 1'b1; // ...more beats; grant STAYS locked
end
endcase
end
end
assign grant = owner; // grant is the HELD one-hot owner
// ------------------- N-to-1 bus mux by grant (mutual exclusion) ---------
// Only the OWNER's address/data reach the shared bus. Because owner is one-hot,
// at most one master drives the bus -- mutual exclusion is structural.
logic [WIDTH-1:0] sel_addr, sel_wdata;
always_comb begin
sel_addr = '0; sel_wdata = '0;
for (int i = 0; i < N; i++)
if (owner[i]) begin sel_addr = m_addr[i]; sel_wdata = m_wdata[i]; end
end
assign bus_addr = (state != IDLE) ? sel_addr : '0; // drive only while owned
assign bus_wdata = (state == DATA) ? sel_wdata : '0; // write-data during DATA
// ------------------- simple slave: address-decoded write-then-read -------
// Two 1-word "slaves" selected by the top address bit; latch on the ADDRESS beat.
logic [WIDTH-1:0] mem0, mem1, addr_q;
always_ff @(posedge clk) begin
if (rst) begin mem0 <= '0; mem1 <= '0; addr_q <= '0; end
else begin
if (state == ADDRESS) addr_q <= sel_addr; // capture the transaction address
if (state == DATA) begin // write beats land in the decoded slave
if (addr_q[WIDTH-1]) mem1 <= sel_wdata;
else mem0 <= sel_wdata;
end
end
end
assign bus_rdata = addr_q[WIDTH-1] ? mem1 : mem0; // read-back from the decoded slave
endmoduleTwo lines carry the whole design. owner <= pick (only in IDLE) and holding owner unchanged through ADDRESS and DATA is the ownership lock — the grant cannot change mid-transaction. ptr <= idx_of(owner) + 1 (only at release, when beat == 1) is the round-robin rotation — the pointer advances past the winner exactly once per transaction, so fairness is preserved without breaking atomicity. The bus mux gated on owner[i] is the mutual exclusion — one driver, structurally. The contention testbench drives every master every cycle and proves all three at once.
module arbitered_bus_tb;
localparam int N = 3, WIDTH = 8, BEATS = 2, PW = 2;
logic clk = 0, rst;
logic [N-1:0] req, grant;
logic xfer_done;
logic [WIDTH-1:0] m_addr [N], m_wdata [N];
logic [WIDTH-1:0] bus_addr, bus_wdata, bus_rdata;
int errors = 0;
int served [N]; // transactions completed per master
always #5 clk = ~clk;
arbitered_bus #(.N(N), .WIDTH(WIDTH), .BEATS(BEATS)) dut (
.clk(clk), .rst(rst), .req(req), .m_addr(m_addr), .m_wdata(m_wdata),
.grant(grant), .xfer_done(xfer_done),
.bus_addr(bus_addr), .bus_wdata(bus_wdata), .bus_rdata(bus_rdata));
// MUTUAL EXCLUSION: grant is at most one-hot, and only a requester is granted.
always @(posedge clk) if (!rst) begin
assert ($onehot0(grant))
else begin $error("TWO owners on the bus: grant=%b", grant); errors++; end
assert ((grant & req) == grant)
else begin $error("granted a non-requester: req=%b grant=%b", req, grant); errors++; end
end
// ATOMICITY: once a master owns the bus, its grant must not change until xfer_done.
logic [N-1:0] grant_q;
always @(posedge clk) if (!rst) begin
if (|grant_q && !xfer_done)
assert (grant == grant_q)
else begin $error("grant changed MID-TRANSACTION: %b -> %b", grant_q, grant); errors++; end
grant_q <= grant;
end
// FAIRNESS + count: tally which master completed each transaction.
always @(posedge clk) if (!rst && xfer_done)
for (int i = 0; i < N; i++) if (grant[i]) served[i]++;
initial begin
rst = 1; req = '0;
for (int i = 0; i < N; i++) begin m_addr[i] = i[WIDTH-1:0]; m_wdata[i] = (8'hA0 + i); served[i] = 0; end
grant_q = '0;
@(posedge clk); #1; rst = 0;
req = '1; // HEAVY CONTENTION: everyone asks, always
repeat (12 * (2 + BEATS)) @(posedge clk); // run many transactions
req = '0;
repeat (4) @(posedge clk);
// FAIRNESS: with all masters always asking, round-robin serves each within N
// transactions, so the counts must be within 1 of each other (no starvation).
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
if (served[i] > served[j] + 1) begin
$error("UNFAIR: master %0d served %0d, master %0d served %0d", i, served[i], j, served[j]);
errors++;
end
if (errors == 0) $display("PASS: single owner, atomic transfers, fair rotation under contention");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmodule4e. The signature bug — the grant changes mid-transaction (buggy vs fixed)
Every arbitered-bus failure that survives a single-beat test lives in the ownership lock. The sharpest is having no lock: the grant is the raw combinational round-robin pick, recomputed every cycle. Single-beat transfers pass — they finish in the same cycle the grant is stable. But the instant a transaction spans more than one beat and a higher-priority-in-rotation master raises its request mid-transfer, the pick flips, the bus mux switches, and the in-flight transaction is corrupted: its address beat went to the slave, its data beat came from a different master. It 'works' only for single-beat transfers with no contention.
// BUGGY: grant is the RAW combinational pick, recomputed every cycle. There is no
// OWNER register and no hold. A master that raises req mid-transfer changes the
// pick -> grant flips -> the bus mux switches -> the burst is sliced. clk/rst are
// unused here: having no state IS the bug.
module bus_grant_bad #(parameter int N = 4, parameter int PW = 2) (
input logic clk, rst,
input logic [N-1:0] req,
input logic [PW-1:0] ptr,
input logic xfer_done, // ignored: nothing to release
output logic [N-1:0] grant
);
logic [N-1:0] mask, req_masked;
always_comb begin
for (int i = 0; i < N; i++) mask[i] = (i >= ptr);
req_masked = req & mask;
// per-cycle pick with NO lock -> re-arbitrates mid-transaction -> preemption
grant = (|req_masked) ? (req_masked & (~req_masked + 1'b1))
: (req & (~req + 1'b1));
end
endmodule
// FIXED: latch the pick into OWNER and HOLD it until xfer_done. The grant to the bus
// is OWNER, so it cannot change mid-transaction; the pointer (not shown) advances
// only at release, preserving fairness.
module bus_grant_good #(parameter int N = 4, parameter int PW = 2) (
input logic clk, rst,
input logic [N-1:0] req,
input logic [PW-1:0] ptr,
input logic xfer_done,
output logic [N-1:0] grant
);
typedef enum logic {IDLE, BUSY} state_t;
state_t state;
logic [N-1:0] owner;
logic [N-1:0] mask, req_masked, pick;
always_comb begin
for (int i = 0; i < N; i++) mask[i] = (i >= ptr);
req_masked = req & mask;
pick = (|req_masked) ? (req_masked & (~req_masked + 1'b1))
: (req & (~req + 1'b1));
end
always_ff @(posedge clk)
if (rst) begin state <= IDLE; owner <= '0; end
else unique case (state)
IDLE: if (|req) begin owner <= pick; state <= BUSY; end // LATCH + LOCK
BUSY: if (xfer_done) begin owner <= '0; state <= IDLE; end // RELEASE
endcase // else HOLD
assign grant = owner; // HELD -> no mid-transaction change
endmodule module bus_lock_tb;
localparam int N = 2, PW = 1;
logic clk = 0, rst, xfer_done;
logic [N-1:0] req, grant_bad, grant_good;
logic [PW-1:0] ptr = '0;
int errors = 0;
always #5 clk = ~clk;
bus_grant_bad #(.N(N), .PW(PW)) bad (.clk(clk), .rst(rst), .req(req), .ptr(ptr), .xfer_done(xfer_done), .grant(grant_bad));
bus_grant_good #(.N(N), .PW(PW)) good (.clk(clk), .rst(rst), .req(req), .ptr(ptr), .xfer_done(xfer_done), .grant(grant_good));
initial begin
rst = 1; req = '0; xfer_done = 0; @(posedge clk); #1; rst = 0;
// Master 1 (lower priority) starts a multi-beat transaction, alone.
req = 2'b10; @(posedge clk); #1;
// Both grants land on master 1 to begin its burst.
assert (grant_good == 2'b10) else begin $error("good: master 1 not owner"); errors++; end
// Mid-burst, higher-priority master 0 raises its request (no xfer_done yet).
req = 2'b11; @(posedge clk); #1;
// FIXED: grant stays LOCKED on master 1 -> atomic.
assert (grant_good == 2'b10)
else begin $error("FIXED preempted: grant=%b", grant_good); errors++; end
// BUGGY: grant JUMPS to master 0 mid-transaction -> the burst is sliced.
if (grant_bad == 2'b10) begin $error("bug not shown: buggy arbiter held"); errors++; end
else $display("observed: buggy grant flipped to %b mid-transaction (burst corrupted)", grant_bad);
// Complete master 1's transaction; the FIXED arbiter releases and hands off.
xfer_done = 1; @(posedge clk); #1; xfer_done = 0;
assert (grant_good == 2'b01 || grant_good == 2'b00)
else begin $error("FIXED no clean release: grant=%b", grant_good); errors++; end
if (errors == 0) $display("PASS: the locked arbiter keeps the transaction whole; the pulse arbiter preempts");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmodule4f. The integrated arbitered bus in Verilog
The same three blocks with reg/wire typing and localparam states. The lock (latch owner, hold to xfer_done), the pointer update at release, and the grant-gated bus mux are unchanged; only the spelling differs. Verilog cannot pass an unpacked port array in the classic style, so the masters' addresses and data are flattened into N*WIDTH buses and sliced with an indexed part-select.
module arbitered_bus #(
parameter N = 4, WIDTH = 8, BEATS = 2, PW = 2
)(
input wire clk, rst,
input wire [N-1:0] req,
input wire [N*WIDTH-1:0] m_addr_flat, // N master addresses packed
input wire [N*WIDTH-1:0] m_wdata_flat, // N master write-data packed
output wire [N-1:0] grant,
output reg xfer_done,
output wire [WIDTH-1:0] bus_addr, bus_wdata,
output wire [WIDTH-1:0] bus_rdata
);
// ---- round-robin core (10.2): masked + unmasked priority ----
reg [PW-1:0] ptr;
wire [N-1:0] mask_v, req_masked, pick_m, pick_u, pick;
genvar g;
generate for (g = 0; g < N; g = g + 1) begin : mk
assign mask_v[g] = (g >= ptr);
end endgenerate
assign req_masked = req & mask_v;
assign pick_m = req_masked & (~req_masked + 1'b1); // first req at-or-after ptr
assign pick_u = req & (~req + 1'b1); // wrap: first req from 0
assign pick = (|req_masked) ? pick_m : pick_u;
// ---- ownership lock (10.4) + controller FSM (4.1) ----
localparam [1:0] IDLE = 2'd0, ADDRESS = 2'd1, DATA = 2'd2;
reg [1:0] state;
reg [N-1:0] owner;
reg [15:0] beat;
integer i;
reg [PW-1:0] widx;
always @(*) begin // one-hot owner -> index (for ptr update)
widx = 0;
for (i = 0; i < N; i = i + 1) if (owner[i]) widx = i[PW-1:0];
end
always @(posedge clk) begin
if (rst) begin state<=IDLE; owner<=0; beat<=0; ptr<=0; xfer_done<=1'b0; end
else begin
xfer_done <= 1'b0;
case (state)
IDLE: if (|req) begin owner <= pick; state <= ADDRESS; end // LATCH + LOCK
ADDRESS: begin beat <= BEATS; state <= DATA; end
DATA: if (beat == 1) begin
xfer_done <= 1'b1; // last beat -> done
ptr <= (widx == N-1) ? 0 : widx + 1'b1; // advance PAST winner (release)
owner <= 0; state <= IDLE;
end else beat <= beat - 1'b1; // hold grant, more beats
endcase
end
end
assign grant = owner;
// ---- N-to-1 bus mux by grant (mutual exclusion) ----
reg [WIDTH-1:0] sel_addr, sel_wdata;
always @(*) begin
sel_addr = 0; sel_wdata = 0;
for (i = 0; i < N; i = i + 1) if (owner[i]) begin
sel_addr = m_addr_flat [i*WIDTH +: WIDTH];
sel_wdata = m_wdata_flat[i*WIDTH +: WIDTH];
end
end
assign bus_addr = (state != IDLE) ? sel_addr : {WIDTH{1'b0}};
assign bus_wdata = (state == DATA) ? sel_wdata : {WIDTH{1'b0}};
// ---- address-decoded slave: write-then-read, selected by the top addr bit ----
reg [WIDTH-1:0] mem0, mem1, addr_q;
always @(posedge clk) begin
if (rst) begin mem0<=0; mem1<=0; addr_q<=0; end
else begin
if (state == ADDRESS) addr_q <= sel_addr;
if (state == DATA) begin
if (addr_q[WIDTH-1]) mem1 <= sel_wdata; else mem0 <= sel_wdata;
end
end
end
assign bus_rdata = addr_q[WIDTH-1] ? mem1 : mem0;
endmodule module arbitered_bus_tb;
parameter N = 3, WIDTH = 8, BEATS = 2;
reg clk, rst;
reg [N-1:0] req;
reg [N*WIDTH-1:0] m_addr_flat, m_wdata_flat;
wire [N-1:0] grant;
wire xfer_done;
wire [WIDTH-1:0] bus_addr, bus_wdata, bus_rdata;
reg [N-1:0] grant_q;
integer errors, i, s0, s1, s2, k;
arbitered_bus #(.N(N), .WIDTH(WIDTH), .BEATS(BEATS), .PW(2)) dut (
.clk(clk), .rst(rst), .req(req),
.m_addr_flat(m_addr_flat), .m_wdata_flat(m_wdata_flat),
.grant(grant), .xfer_done(xfer_done),
.bus_addr(bus_addr), .bus_wdata(bus_wdata), .bus_rdata(bus_rdata));
initial clk = 0; always #5 clk = ~clk;
// MUTUAL EXCLUSION + ATOMICITY, checked every cycle.
always @(posedge clk) if (!rst) begin
if (!((grant & (grant - 1)) == 0)) // one-hot-or-zero
begin $display("FAIL: TWO owners grant=%b", grant); errors = errors + 1; end
if ((grant & req) !== grant) // granted a requester
begin $display("FAIL: granted non-requester req=%b grant=%b", req, grant); errors = errors + 1; end
if (|grant_q && !xfer_done && grant !== grant_q) // atomic: no mid-transfer change
begin $display("FAIL: grant changed mid-transaction %b -> %b", grant_q, grant); errors = errors + 1; end
grant_q <= grant;
if (xfer_done) begin
if (grant[0]) s0 = s0 + 1;
if (grant[1]) s1 = s1 + 1;
if (grant[2]) s2 = s2 + 1;
end
end
initial begin
errors = 0; s0 = 0; s1 = 0; s2 = 0; grant_q = 0;
m_addr_flat = {8'd2, 8'd1, 8'd0}; // master 2,1,0 addresses
m_wdata_flat = {8'hA2, 8'hA1, 8'hA0}; // master 2,1,0 write-data
rst = 1; req = 0; @(posedge clk); #1; rst = 0;
req = {N{1'b1}}; // HEAVY CONTENTION: everyone asks always
for (k = 0; k < 12*(2+BEATS); k = k + 1) @(posedge clk);
req = 0; repeat (4) @(posedge clk);
// FAIRNESS: counts within 1 of each other (round-robin, no starvation).
if (s0 > s1 + 1 || s1 > s0 + 1 || s0 > s2 + 1 || s2 > s0 + 1 || s1 > s2 + 1 || s2 > s1 + 1)
begin $display("FAIL: unfair served = %0d %0d %0d", s0, s1, s2); errors = errors + 1; end
if (errors == 0) $display("PASS: single owner, atomic, fair under contention (%0d %0d %0d)", s0, s1, s2);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog lock bug-vs-fix pair is the same story: the buggy arbiter is the raw combinational pick (no owner register), the fixed one latches owner and holds it until xfer_done.
// BUGGY: raw combinational pick, no owner register -> re-arbitrates every cycle.
module bus_grant_bad #(parameter N = 4, PW = 2) (
input wire clk, rst, xfer_done,
input wire [N-1:0] req, input wire [PW-1:0] ptr,
output wire [N-1:0] grant
);
wire [N-1:0] mask_v, req_masked;
genvar g;
generate for (g = 0; g < N; g = g + 1) begin : mk assign mask_v[g] = (g >= ptr); end endgenerate
assign req_masked = req & mask_v;
assign grant = (|req_masked) ? (req_masked & (~req_masked + 1'b1))
: (req & (~req + 1'b1)); // BUG: no lock
endmodule
// FIXED: latch owner, hold until xfer_done -> grant cannot change mid-transaction.
module bus_grant_good #(parameter N = 4, PW = 2) (
input wire clk, rst, xfer_done,
input wire [N-1:0] req, input wire [PW-1:0] ptr,
output wire [N-1:0] grant
);
localparam IDLE = 1'b0, BUSY = 1'b1;
reg state;
reg [N-1:0] owner;
wire [N-1:0] mask_v, req_masked, pick;
genvar g;
generate for (g = 0; g < N; g = g + 1) begin : mk assign mask_v[g] = (g >= ptr); end endgenerate
assign req_masked = req & mask_v;
assign pick = (|req_masked) ? (req_masked & (~req_masked + 1'b1))
: (req & (~req + 1'b1));
always @(posedge clk)
if (rst) begin state <= IDLE; owner <= 0; end
else case (state)
IDLE: if (|req) begin owner <= pick; state <= BUSY; end // LATCH + LOCK
BUSY: if (xfer_done) begin owner <= 0; state <= IDLE; end // RELEASE
endcase
assign grant = owner; // HELD
endmodule4g. The integrated arbitered bus in VHDL
VHDL states the same machines with an enumerated state type, numeric_std counters, and rising_edge(clk). The lock latches the owner and holds it to xfer_done, the pointer advances only at release, and the bus mux is gated on the one-hot owner — the identical structure.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity arbitered_bus is
generic ( N : integer := 4; WIDTH : integer := 8; BEATS : integer := 2 );
port (
clk, rst : in std_logic;
req : in std_logic_vector(N-1 downto 0);
m_addr : in std_logic_vector(N*WIDTH-1 downto 0); -- N master addresses packed
m_wdata : in std_logic_vector(N*WIDTH-1 downto 0); -- N master write-data packed
grant : out std_logic_vector(N-1 downto 0);
xfer_done : out std_logic;
bus_addr : out std_logic_vector(WIDTH-1 downto 0);
bus_wdata : out std_logic_vector(WIDTH-1 downto 0);
bus_rdata : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of arbitered_bus is
type state_t is (S_IDLE, S_ADDRESS, S_DATA);
signal state : state_t := S_IDLE;
signal owner : std_logic_vector(N-1 downto 0) := (others=>'0');
signal ptr : integer range 0 to N-1 := 0;
signal beat : integer range 0 to BEATS := 0;
signal mem0, mem1, addr_q : std_logic_vector(WIDTH-1 downto 0) := (others=>'0');
-- round-robin pick: masked (at-or-after ptr) with unmasked wrap fallback
function pick_rr (req : std_logic_vector; ptr : integer) return std_logic_vector is
variable n : integer := req'length;
variable rm : std_logic_vector(req'range) := (others=>'0');
variable g : std_logic_vector(req'range) := (others=>'0');
variable done : boolean := false;
begin
for i in 0 to n-1 loop if i >= ptr then rm(i) := req(i); end if; end loop;
-- lowest set bit of the masked set (first req at-or-after ptr)
for i in 0 to n-1 loop if rm(i)='1' and not done then g(i):='1'; done:=true; end if; end loop;
if not done then -- WRAP: lowest set bit of the unmasked set
for i in 0 to n-1 loop if req(i)='1' and not done then g(i):='1'; done:=true; end if; end loop;
end if;
return g;
end function;
function idx_of (oh : std_logic_vector) return integer is
variable r : integer := 0;
begin
for i in 0 to oh'length-1 loop if oh(i)='1' then r := i; end if; end loop;
return r;
end function;
begin
process (clk)
variable pick : std_logic_vector(N-1 downto 0);
variable sel_addr : std_logic_vector(WIDTH-1 downto 0);
variable sel_wdata: std_logic_vector(WIDTH-1 downto 0);
begin
if rising_edge(clk) then
if rst = '1' then
state <= S_IDLE; owner <= (others=>'0'); ptr <= 0; beat <= 0;
xfer_done <= '0'; mem0 <= (others=>'0'); mem1 <= (others=>'0'); addr_q <= (others=>'0');
else
xfer_done <= '0';
pick := pick_rr(req, ptr);
-- select the OWNER's address/data (all-zero when no owner)
sel_addr := (others=>'0'); sel_wdata := (others=>'0');
for i in 0 to N-1 loop
if owner(i)='1' then
sel_addr := m_addr (i*WIDTH+WIDTH-1 downto i*WIDTH);
sel_wdata := m_wdata(i*WIDTH+WIDTH-1 downto i*WIDTH);
end if;
end loop;
case state is
when S_IDLE =>
if req /= (req'range => '0') then
owner <= pick; state <= S_ADDRESS; -- LATCH + LOCK
end if;
when S_ADDRESS =>
addr_q <= sel_addr; -- capture transaction address
beat <= BEATS; state <= S_DATA;
when S_DATA =>
if addr_q(WIDTH-1)='1' then mem1 <= sel_wdata; else mem0 <= sel_wdata; end if;
if beat = 1 then
xfer_done <= '1'; -- last beat -> done
if idx_of(owner) = N-1 then ptr <= 0; else ptr <= idx_of(owner)+1; end if; -- release
owner <= (others=>'0'); state <= S_IDLE;
else
beat <= beat - 1; -- hold grant, more beats
end if;
end case;
end if;
end if;
end process;
grant <= owner;
-- combinational bus mux by the one-hot owner (mutual exclusion is structural)
process (owner, state, m_addr, m_wdata, addr_q, mem0, mem1)
variable a, d : std_logic_vector(WIDTH-1 downto 0);
begin
a := (others=>'0'); d := (others=>'0');
for i in 0 to N-1 loop
if owner(i)='1' then
a := m_addr (i*WIDTH+WIDTH-1 downto i*WIDTH);
d := m_wdata(i*WIDTH+WIDTH-1 downto i*WIDTH);
end if;
end loop;
if state /= S_IDLE then bus_addr <= a; else bus_addr <= (others=>'0'); end if;
if state = S_DATA then bus_wdata <= d; else bus_wdata <= (others=>'0'); end if;
end process;
bus_rdata <= mem1 when addr_q(WIDTH-1)='1' else mem0;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity arbitered_bus_tb is end entity;
architecture sim of arbitered_bus_tb is
constant N : integer := 3; constant WIDTH : integer := 8; constant BEATS : integer := 2;
signal clk : std_logic := '0';
signal rst, xfer_done : std_logic;
signal req, grant, grant_q : std_logic_vector(N-1 downto 0) := (others=>'0');
signal m_addr, m_wdata : std_logic_vector(N*WIDTH-1 downto 0);
signal bus_addr, bus_wdata, bus_rdata : std_logic_vector(WIDTH-1 downto 0);
function onehot0 (v : std_logic_vector) return boolean is
variable c : integer := 0;
begin
for i in v'range loop if v(i)='1' then c := c + 1; end if; end loop;
return c <= 1;
end function;
begin
dut : entity work.arbitered_bus
generic map (N=>N, WIDTH=>WIDTH, BEATS=>BEATS)
port map (clk=>clk, rst=>rst, req=>req, m_addr=>m_addr, m_wdata=>m_wdata,
grant=>grant, xfer_done=>xfer_done,
bus_addr=>bus_addr, bus_wdata=>bus_wdata, bus_rdata=>bus_rdata);
clk <= not clk after 5 ns;
-- MUTUAL EXCLUSION + ATOMICITY every cycle
check : process (clk) begin
if rising_edge(clk) and rst = '0' then
assert onehot0(grant) report "TWO owners on the bus" severity error;
assert (grant and req) = grant report "granted a non-requester" severity error;
if grant_q /= (grant_q'range => '0') and xfer_done = '0' then
assert grant = grant_q report "grant changed MID-TRANSACTION" severity error;
end if;
grant_q <= grant;
end if;
end process;
stim : process begin
-- master 0,1,2 addresses and data packed low-index first
m_addr <= std_logic_vector(to_unsigned(2,WIDTH)) & std_logic_vector(to_unsigned(1,WIDTH)) & std_logic_vector(to_unsigned(0,WIDTH));
m_wdata <= x"A2" & x"A1" & x"A0";
rst <= '1'; req <= (others=>'0');
wait until rising_edge(clk); rst <= '0';
req <= (others=>'1'); -- HEAVY CONTENTION
for i in 0 to 12*(2+BEATS) loop wait until rising_edge(clk); end loop;
req <= (others=>'0');
for i in 0 to 3 loop wait until rising_edge(clk); end loop;
report "arbitered_bus self-check complete (single owner, atomic, fair under contention)" severity note;
wait;
end process;
end architecture;The VHDL lock bug-vs-fix pair is the same one difference: the buggy arbiter returns the combinational pick with no owner register; the fixed one latches the owner and holds it until xfer_done.
library ieee;
use ieee.std_logic_1164.all;
-- shared round-robin pick used by both (masked + unmasked wrap)
package bus_pick_pkg is
function pick_rr (req : std_logic_vector; ptr : integer) return std_logic_vector;
end package;
package body bus_pick_pkg is
function pick_rr (req : std_logic_vector; ptr : integer) return std_logic_vector is
variable n : integer := req'length;
variable rm : std_logic_vector(req'range) := (others=>'0');
variable g : std_logic_vector(req'range) := (others=>'0');
variable done : boolean := false;
begin
for i in 0 to n-1 loop if i >= ptr then rm(i) := req(i); end if; end loop;
for i in 0 to n-1 loop if rm(i)='1' and not done then g(i):='1'; done:=true; end if; end loop;
if not done then
for i in 0 to n-1 loop if req(i)='1' and not done then g(i):='1'; done:=true; end if; end loop;
end if;
return g;
end function;
end package body;
library ieee;
use ieee.std_logic_1164.all;
use work.bus_pick_pkg.all;
-- BUGGY: raw combinational pick, no owner register -> re-arbitrates every cycle.
entity bus_grant_bad is
generic ( N : integer := 4 );
port ( clk, rst, xfer_done : in std_logic;
req : in std_logic_vector(N-1 downto 0);
ptr : in integer range 0 to N-1;
grant : out std_logic_vector(N-1 downto 0) );
end entity;
architecture rtl of bus_grant_bad is
begin
grant <= pick_rr(req, ptr); -- BUG: no lock, no hold -> preemption mid-transaction
end architecture;
library ieee;
use ieee.std_logic_1164.all;
use work.bus_pick_pkg.all;
-- FIXED: latch owner, hold until xfer_done -> grant cannot change mid-transaction.
entity bus_grant_good is
generic ( N : integer := 4 );
port ( clk, rst, xfer_done : in std_logic;
req : in std_logic_vector(N-1 downto 0);
ptr : in integer range 0 to N-1;
grant : out std_logic_vector(N-1 downto 0) );
end entity;
architecture rtl of bus_grant_good is
type state_t is (S_IDLE, S_BUSY);
signal state : state_t := S_IDLE;
signal owner : std_logic_vector(N-1 downto 0) := (others=>'0');
begin
process (clk) begin
if rising_edge(clk) then
if rst = '1' then state <= S_IDLE; owner <= (others=>'0');
else
case state is
when S_IDLE => if req /= (req'range => '0') then
owner <= pick_rr(req, ptr); state <= S_BUSY; -- LATCH + LOCK
end if;
when S_BUSY => if xfer_done = '1' then
owner <= (others=>'0'); state <= S_IDLE; -- RELEASE
end if; -- else HOLD
end case;
end if;
end if;
end process;
grant <= owner; -- HELD -> no mid-transaction change
end architecture;Across all three languages the arbitered bus's whole correctness is one sentence: pick a fair winner with the round-robin core, LATCH it as the owner and HOLD the grant until the controller signals xfer_done, drive the shared bus through a mux gated by that one-hot grant, and advance the rotation pointer PAST the owner only at release — so the transaction is atomic and the arbiter is fair at once.
5. Verification Strategy
An arbitered bus's correctness is not 'did the first transaction look right' — it is 'under heavy contention, does every master's transaction complete to the right slave with the right data and in the master's order, is the bus ever driven by two masters at once, is any transaction corrupted by a mid-transfer re-arbitration, and is the arbiter fair.' The invariants, and the whole §4 contention testbench, are:
Under all masters contending, (1) the grant is one-hot-or-zero every cycle and only ever on a requester (mutual exclusion), (2) once a master owns the bus its grant does not change until xfer_done (atomicity — no mid-transfer preemption), (3) each transaction moves the owner's address and data to the addressed slave and reads back correctly (functional correctness), and (4) every asking master is served within N transactions (fairness — no starvation).
Everything below is those invariants made checkable, and they map onto the contention testbenches in §4.
- Mutual exclusion (single driver). Every cycle, assert the grant is one-hot-or-zero (
$onehot0(grant)/(grant & (grant-1)) == 0/ a bit-count<= 1) and that(grant & req) == grant(only a requester is granted). Two owners means two drivers on the shared bus — the second §7 bug — and this assertion catches it every cycle, not just when the data happens to differ. - Atomicity (no mid-transfer preemption — the signature check). Register the previous grant; on any cycle where an owner was active last cycle and
xfer_donedid not fire, assertgrant == grant_q. A grant that changes while a transaction is in flight is the signature bug (§7 row 1): the lock is missing or the pointer advanced early. This is the assertion the whole capstone hangs on, and it only fires under contention — a lone master never exercises it. - Functional correctness (right slave, right data, in order). For each master, drive a distinct address (that decodes to a known slave) and distinct write-data; after its transaction completes, read the slave back and assert it holds that master's data. Because transactions are serialized by the lock, the slave's final state is a well-defined sequence — check that a master's write is not clobbered by another master mid-transfer (which is exactly what preemption would do).
- Fairness (bounded wait, no starvation — 10.5). With every master asking every cycle, tally which master completes each transaction and assert the counts stay within one of each other over a long run. A fixed-priority collapse (pointer never advancing) shows here as one master's count running away while others stall — the classic starvation signature. Re-run with a lone persistent requester to confirm it is still served every turn.
- Drive heavy contention, not a polite one-at-a-time schedule. The only stimulus that exercises all four invariants is everyone asking at once. A testbench that starts one master, lets it finish, then starts the next will pass even a broken (unlocked) arbiter, because it never creates a mid-transfer latecomer. Assert
req = all-onesand let it run for many transactions — that is the test that separates a bus that works in a demo from one that works in a chip. - Expected waveform.
grantis one-hot and stable for the whole ADDRESS-plus-DATA span of each transaction, then moves to the next master in rotation the cycle afterxfer_done.bus_addrholds the owner's address during the transaction;bus_wdatacarries the owner's data during DATA beats;bus_addr/bus_wdataare zero (or idle) between transactions. Agrantthat flickers within a transaction is the visual signature of the missing-lock bug; a bus value that goesXunder contention is the visual signature of a master driving the shared net without holding grant.
6. Common Mistakes
Grant changes mid-transaction — the missing ownership lock. The signature failure and the reason this is a capstone. If the grant is the raw combinational round-robin pick, recomputed every cycle, then a higher-priority-in-rotation master raising its request mid-transfer flips the grant, switches the bus mux, and slices the in-flight transaction — its address beat went to the slave, its data beat came from someone else. It passes single-beat transfers and any one-at-a-time schedule, and corrupts multi-beat transfers the instant there is contention. The fix is the lock: latch the winner into an OWNER register, drive the grant from that register, and hold it until xfer_done. Full post-mortem in §7 row 1; buggy-vs-fixed RTL in §4e.
A master driving the shared bus without holding grant — bus contention. A master that puts its address or data on the shared net whenever it has a request (rather than only through the grant-gated mux) is a second driver. Two drivers on one net is contention: the value resolves to X in simulation and is a short in silicon. The discipline is structural — masters present their wires to the N-to-1 bus mux and are gated by the one-hot grant; they never drive the shared net directly. Post-mortem in §7 row 2.
Arbiter unfairness — a master starves under load. If the round-robin pointer is not advanced past the winner (it advances to the winner instead of winner+1, or does not advance at all), the rotation collapses back into fixed priority: the same master wins every arbitration and lower-priority masters starve (10.5). Under the contention test this shows as one master's completion count running away. Advance the pointer to winner + 1 (mod N) exactly once per transaction, at release — the deferral is what keeps fairness and atomicity.
Address decode selecting the wrong slave. The shared address must be decoded to select exactly one slave, and the decode must be stable for the whole transaction. Decoding a changing address (e.g. re-reading the master's address after the mux has moved on) or an off-by-one decode routes the transfer to the wrong slave — the write lands in the wrong memory, or the read returns another slave's data. Capture the transaction address on the ADDRESS beat (into an addr_q register) and decode that, so the slave selection is fixed for the transaction even as beats advance.
Advancing the pointer per cycle instead of per transaction. A tempting shortcut is to keep the 10.2 arbiter verbatim and advance the pointer every cycle it produces a grant. But a transaction spans many cycles with the grant held, so a per-cycle advance moves the rotation during a transaction — and if the lock is also missing, re-arbitrates mid-transfer. The pointer must advance exactly once, at xfer_done. Tie the pointer update to release, not to grant-asserted.
Not holding request stable until granted (and through the transaction). A master that pulses its request for one cycle and drops it can be dropped by the arbiter before the lock latches it, or can look like a release mid-transaction. The request must be held from the moment work arrives until the transaction completes — stable until granted, stable through the transaction — which is exactly the request/grant handshake discipline (10.4). A glitchy request is indistinguishable from a release and desynchronizes the lock.
7. DebugLab
The bus that works in a demo and corrupts transfers under load — a grant that moves mid-transaction and a master that drives without grant
The engineering lesson: a shared bus is correct only if exactly one master owns it for the whole of each transaction — and both signature bugs are the same crime, letting more than one master reach the bus, committed at two different stations. The first lets a second master reach the bus in time (the grant moves mid-transaction because there is no lock, so a latecomer slices a live transfer); the second lets a second master reach the bus in space (a master drives the shared net on its request instead of its grant, so two drivers fight in the same cycle). Both pass a one-at-a-time demo and both surface the instant the bus is loaded. Three habits make the bus trustworthy, identical across SystemVerilog, Verilog, and VHDL: lock the grant on the owner until the transaction is done (latch the pick, hold it to xfer_done), drive the shared bus only through a grant-gated mux (never on request), and advance the round-robin pointer past the winner only at release (fair per transaction, atomic within one) — and verify under heavy contention, not a polite schedule, because everyone-asking-at-once is the only test that exercises the lock, the mux, and the rotation at all.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses composing the pieces and getting the atomicity-versus-fairness accounting right.
Exercise 1 — Bound the worst-case wait
For N masters all asking every cycle, with each transaction taking 1 + BEATS cycles (one address beat plus BEATS data beats), derive the worst-case number of cycles a master waits from raising its request to being granted. State (i) the worst-case wait in transactions under round-robin, (ii) the cycles per transaction, and (iii) the resulting cycle bound, and say how it changes if bursts of up to K beats are allowed.
Exercise 2 — Predict the missing-lock failure
An engineer wires the combinational round-robin pick straight to the bus mux (no owner register) and runs 2-beat transactions. Describe precisely (i) what a one-at-a-time testbench reports, (ii) what happens when master 1 is mid-transaction and master 0 raises its request, and (iii) which byte the addressed slave ends up holding. Then give the one-block fix and the assertion that would have exposed it.
Exercise 3 — Add a second slave and an error response
The bus decodes the top address bit to one of two slaves. Add a third address region that maps to no slave, and an error response for it. State (i) where the decode changes, (ii) what the controller drives back to the master for an unmapped address (an error flag on the read-data path), and (iii) how the master's controller should react. Name which earlier pattern the decode and the error flag draw on.
Exercise 4 — Add a read/write direction and a ready handshake on the slave
The current slave latches or returns data in a fixed number of beats. Give the slave a ready line so it can stall a beat (a slow memory). State (i) where the controller must wait on ready and how that changes the beat counter, (ii) why the grant must stay locked across the stall (and what would break if it did not), and (iii) what the fairness bound becomes when a slave can insert wait states. Name which earlier pattern the ready stall draws on.
10. Related Tutorials
The patterns this bus composes (the lessons you are assembling here):
- Round-Robin Arbiters — Chapter 10.2; the arbiter core — masked + unmasked priority, pointer to winner+1, one-hot grant, bounded wait — reused here as the fair picker.
- Request / Grant Handshake — Chapter 10.4; the ownership lock — latch the winner as owner, hold the grant through the transaction, release on done — the atomicity layer of this bus.
- Arbiter Fairness — Chapter 10.5; the no-starvation guarantee the contention testbench asserts, and why the pointer must advance past the winner.
- Fixed-Priority Arbiters — Chapter 10.1; the priority encoder inside the round-robin core, and the starvation it causes if the pointer never rotates.
- FSM Fundamentals — Chapter 4.1; the bus controller — IDLE, ADDRESS, DATA — is this safe FSM sequencing one transaction.
- FSM + Datapath — Chapter 4.6; the control-down / status-up split between the controller FSM and its beat counter, address register, and bus mux.
- Valid / Ready Handshake — Chapter 9.1; the master-side and slave-side handshake discipline that paces each beat and lets a slow slave stall.
Backward / method:
- The RTL Design Mindset — Chapter 0.2; the Interface → State → Datapath → Control → Verification lenses this case study walks end to end.
- What Is an RTL Design Pattern? — Chapter 0.1; why composing patterns into a real bus fabric — not inventing a new one — is the design skill this capstone rehearses.
- UART Case Study — Chapter 13.1; the sibling capstone — three patterns composed into a real peripheral, verified end to end.
Forward — the other case studies (unlock as they ship):
- SPI Master Case Study (
/rtl-design-patterns/case-study-spi-master) — Chapter 13.2; an FSM + shift register master that drives a source-synchronous serial bus — a single-master transaction sequencer. - Streaming FIFO Case Study (
/rtl-design-patterns/case-study-streaming-fifo) — Chapter 13.3; buffering the beat stream a bus master produces before it hits a slow slave. - CDC Stream Case Study (
/rtl-design-patterns/case-study-cdc-stream) — Chapter 13.5; moving a bus stream safely across two unrelated clocks.
Verilog v1 prerequisites (the grammar these controllers transcribe into):
- Case Statements — the construct behind the controller FSM and the round-robin core's masked/unmasked selection.
- If-Else Statements — the priority chains and the lock's IDLE/BUSY branching throughout the design.
- Blocking and Non-Blocking Assignments —
<=in the clocked lock, controller, and slave registers throughout the design.
11. Summary
- An arbitered bus is patterns composed into a real fabric, not a new pattern. It is a round-robin arbiter (10.2) feeding an ownership lock (10.4) driving a bus controller FSM (4.1), with an N-to-1 bus mux (1.1) gated by the one-hot grant and an address decode selecting the slave. Each block is a lesson you already had; the capstone is wiring them so a transaction is atomic and the arbiter is fair at once.
- The grant must be locked for the whole transaction. Latch the winning pick into an OWNER register, drive the grant from that register, and hold it until the controller raises
xfer_done. A grant driven from the raw combinational pick re-arbitrates every cycle, so a mid-transfer latecomer slices the in-flight transaction — the signature bug (§7 row 1). The held grant is atomicity. - The bus is a grant-gated mux — mutual exclusion is structural. The shared address/write-data buses are an N-to-1 mux selected by the one-hot grant, so at most one master drives the bus by construction. A master that drives the shared net on its request instead of its grant is a second driver — contention,
X(§7 row 2). Drive only through the mux, gated by grant. - Fairness and atomicity coexist because the pointer advances at release. Advance the round-robin pointer to
winner + 1 (mod N)exactly once per transaction, atxfer_done— so the winner drops to the bottom of the rotation after its turn (fair, bounded wait, no starvation, 10.5) while the grant stayed locked for the whole turn (atomic). Advancing per cycle, or to the winner instead of winner+1, collapses the rotation back into starvation. - Prove it with heavy contention, not a polite schedule. Drive every master every cycle and assert one-hot grant (mutual exclusion), grant-unchanged-until-done (atomicity), right-slave-right-data (correctness), and served-counts-within-one (fairness). A one-at-a-time demo passes even a broken, unlocked arbiter; everyone-asking-at-once is the only test that exercises the lock, the mux, and the rotation — self-checking contention testbenches shown in SystemVerilog, Verilog, and VHDL.