USB · Module 27
Senior Host-Controller Architecture
Whiteboard an xHCI controller and name its one clever idea — a cycle bit that lets hardware and software share a ring with no lock, and a cycle-state pair that tells full from empty without a counter.
The first of the four senior questions, and the first whiteboard exercise.
1. The Question
"Draw me an xHCI host controller."
This is a large question with a small right answer. An xHCI controller has a dozen blocks in it and you can spend twenty minutes drawing boxes without saying anything the interviewer wanted to hear — because what they are listening for is whether you can identify the one genuinely clever idea in the architecture and explain why it is there.
2. What to Draw, and the Order to Draw It In
Draw the data structures first and the logic second. xHCI is a software-visible data-structure design far more than it is a state-machine design, and a candidate who starts with the rings is signalling that they know that.
MMIO registers the doorbell, and the operational regs
|
Device Context Base Address Array
|
Device Context (one per attached device)
|
Endpoint Context (one per endpoint, holds the dequeue pointer)
|
TRANSFER RING <-- one per endpoint. Software produces, the
| controller consumes.
|
COMMAND RING <-- one, for configure/address/reset commands
EVENT RING <-- one or more, and the direction is REVERSED:
the CONTROLLER produces, software consumes
Then, and only then, the logic:
scheduler -> DMA engine -> root hub ports -> PHYsThe single most important structural point: the event ring runs the other way. Transfer and command rings are produced by software and consumed by hardware; the event ring is produced by hardware and consumed by software. One mechanism, used in both directions, which is why it is worth understanding properly.
3. The Clever Idea: The Cycle Bit
Software and hardware share each ring with no lock and no exchange of head and tail pointers. Ownership of every entry is carried by a single bit inside the entry itself.
There is no shared counter to race on, no lock to take, and no doorbell strictly required for correctness — the doorbell is a performance optimisation that saves the controller from polling.
Ownership carried in the entry, not in a lock
The wrap, and the inversion that makes it work
4. The Second Clever Part: Full Is Not Empty
Here is where most explanations stop too early, and where the interviewer's follow-up lands.
The cycle bit alone cannot tell the producer whether the ring is full. The consumer never writes anything — it has no way to mark an entry as consumed — so from the producer's side there is no per-entry evidence that an entry has been processed.
What distinguishes the two is the pair of cycle states:
enq == deq and PCS == CCS -> EMPTY
enq == deq and PCS != CCS -> FULL
The pointers are equal in BOTH cases. The cycle states
differ only when the producer has lapped the consumer
exactly once.That is how this ring holds all RING_N entries rather than RING_N − 1, with no counter and no wasted slot — which a plain head/tail ring buffer cannot do. A classic ring either keeps a separate count (another shared variable to race on) or leaves one entry permanently unused so that head == tail can mean only "empty".
5. Seven Properties
| # | Property |
|---|---|
| 1 | ring_full and ring_empty agree with the true occupancy. |
| 2 | Full and empty are never both true. |
| 3 | Entries come back in FIFO order, with the exact data enqueued. |
| 4 | Nothing accepted is ever lost: enqueued − dequeued = occupancy. |
| 5 | The cycle state inverts on every wrap, on both sides. |
| 6 | A rejected enqueue does not modify the ring. |
| 7 | The per-entry ownership test and the pointer/cycle test always agree. |
6. Verilog-2005 RTL
// =====================================================================
// xhci_ring -- "Whiteboard an xHCI host controller" in hardware.
//
// An xHCI controller is a large block diagram with one genuinely clever
// idea in it, and the interview is about whether you can name it:
//
// Software and hardware share a ring buffer with NO LOCK and no
// head/tail pointer exchange. Ownership of each entry is carried
// by a single bit in the entry itself -- the CYCLE BIT.
//
// How it works: the ring has a "current cycle state" that both sides
// track, and it INVERTS every time the pointer wraps. An entry whose
// cycle bit equals the consumer's cycle state is owned by the consumer;
// one that differs is owned by the producer. Software writes an entry
// and sets its cycle bit last; that single write transfers ownership.
//
// There is no shared counter to race on, no lock to take, and no
// doorbell strictly required for correctness. A full ring and an empty
// ring are distinguishable -- which the classic head==tail ring buffer
// cannot do without wasting an entry or adding a counter.
//
// It is a beautiful mechanism and it has exactly one way to get it
// wrong: failing to invert the cycle state on wrap. Do that and the
// consumer reads the whole ring exactly once and then stops forever,
// having decided every entry belongs to the producer.
// =====================================================================
module xhci_ring #(
parameter integer RING_N = 8 // entries, including the Link TRB
) (
input wire clk,
input wire rst_n,
// ---- the PRODUCER side (software enqueuing a transfer) ----
input wire prod_req,
input wire [15:0] prod_data,
output wire prod_ack,
output wire ring_full,
// ---- the CONSUMER side (the controller fetching work) ----
input wire cons_req,
output wire cons_valid,
output wire [15:0] cons_data,
output wire ring_empty,
// ---- the state both sides track ----
output wire pcs, // producer cycle state
output wire ccs, // consumer cycle state
output wire [3:0] enq_ptr,
output wire [3:0] deq_ptr,
// ---- observability ----
output wire [31:0] n_enq,
output wire [31:0] n_deq,
output wire [31:0] n_prod_wrap,
output wire [31:0] n_cons_wrap,
output wire [31:0] n_full_reject,
output wire [31:0] n_empty_read,
output wire [31:0] n_owner_error // must always read 0
);
// The ring entries, plus the cycle bit that carries ownership.
reg [15:0] data [0:RING_N-1];
reg cyc [0:RING_N-1];
reg [3:0] enq_r, deq_r;
reg pcs_r, ccs_r;
reg pa_r;
reg cv_r;
reg [15:0] cd_r;
reg [31:0] enq_c, deq_c, pwrap_c, cwrap_c, full_c, empty_c, own_c;
assign prod_ack = pa_r;
assign cons_valid = cv_r;
assign cons_data = cd_r;
assign pcs = pcs_r;
assign ccs = ccs_r;
assign enq_ptr = enq_r;
assign deq_ptr = deq_r;
assign n_enq = enq_c;
assign n_deq = deq_c;
assign n_prod_wrap = pwrap_c;
assign n_cons_wrap = cwrap_c;
assign n_full_reject = full_c;
assign n_empty_read = empty_c;
assign n_owner_error = own_c;
// =================================================================
// THE MECHANISM, and it is the whole design.
//
// There are two separate claims here and conflating them is the
// commonest way to get a ring buffer wrong.
//
// (1) IS THIS ENTRY READY? A single-bit test, and the only thing
// the cycle bit itself answers. An entry whose cycle bit equals
// the consumer's cycle state has been finished by the producer;
// one that differs has not. Software writes the payload and sets
// the cycle bit LAST, and that single store transfers ownership.
// No lock, no barrier beyond ordering the two writes.
//
// (2) IS THE RING FULL OR EMPTY? The cycle bit alone cannot say,
// because the consumer never writes anything -- so the producer
// has no per-entry evidence that an entry has been consumed.
// What distinguishes the two is the pair of CYCLE STATES:
//
// enq == deq and pcs == pcs -> EMPTY
// enq == deq and pcs != ccs -> FULL
//
// The pointers are equal in both cases. The cycle states differ
// only when the producer has lapped the consumer exactly once.
// That is how this ring holds RING_N entries rather than
// RING_N-1, without a counter and without a wasted slot -- which
// a plain head/tail ring cannot do.
// =================================================================
wire ptr_eq = (enq_r == deq_r);
wire cons_owns_deq = (cyc[deq_r] == ccs_r);
assign ring_empty = ptr_eq && (pcs_r == ccs_r);
assign ring_full = ptr_eq && (pcs_r != ccs_r);
wire prod_owns_enq = !ring_full;
integer k;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
enq_r <= 4'd0;
deq_r <= 4'd0;
// Both sides start at cycle state 1 and every entry starts at 0,
// so the ring begins EMPTY: cyc != ccs everywhere.
pcs_r <= 1'b1;
ccs_r <= 1'b1;
pa_r <= 1'b0;
cv_r <= 1'b0;
cd_r <= 16'd0;
enq_c <= 32'd0;
deq_c <= 32'd0;
pwrap_c <= 32'd0;
cwrap_c <= 32'd0;
full_c <= 32'd0;
empty_c <= 32'd0;
own_c <= 32'd0;
for (k = 0; k < RING_N; k = k + 1) begin
data[k] <= 16'd0;
cyc[k] <= 1'b0;
end
end else begin
pa_r <= 1'b0;
cv_r <= 1'b0;
// ---- PRODUCER: write the entry, then its cycle bit ----
//
// In hardware both happen on the same edge; in software the cycle
// bit MUST be written last, because that single store is what
// transfers ownership. A controller that observed the cycle bit
// before the payload would fetch a half-written descriptor.
if (prod_req) begin
if (prod_owns_enq) begin
data[enq_r] <= prod_data;
cyc[enq_r] <= pcs_r; // ownership handed over
pa_r <= 1'b1;
enq_c <= enq_c + 32'd1;
if (enq_r == RING_N - 1) begin
// ---- THE WRAP, and the one line the whole thing hinges on ----
//
// At the end of the ring the pointer returns to zero AND the
// cycle state INVERTS. Without the inversion the producer
// would write entries whose cycle bit still matches what the
// consumer already consumed, and the ring would appear
// permanently empty from the consumer's side.
enq_r <= 4'd0;
pcs_r <= ~pcs_r;
pwrap_c <= pwrap_c + 32'd1;
end else begin
enq_r <= enq_r + 4'd1;
end
end else begin
// The ring is full. Counted rather than silently dropped,
// because a producer that cannot tell "full" from "accepted"
// loses transfers with no error anywhere.
full_c <= full_c + 32'd1;
end
end
// ---- CONSUMER: take the entry if it is ours ----
if (cons_req) begin
if (cons_owns_deq) begin
cv_r <= 1'b1;
cd_r <= data[deq_r];
deq_c <= deq_c + 32'd1;
if (deq_r == RING_N - 1) begin
deq_r <= 4'd0;
ccs_r <= ~ccs_r;
cwrap_c <= cwrap_c + 32'd1;
end else begin
deq_r <= deq_r + 4'd1;
end
end else begin
empty_c <= empty_c + 32'd1;
end
end
// ---- the self-check: the two formulations must AGREE ----
//
// "The entry at the dequeue pointer is ready" and "the ring is not
// empty" are computed by completely different means -- a single-bit
// ownership test on one entry, and a comparison of two pointers and
// two cycle states. They are the same claim, so they must always
// agree, and a broken cycle-state inversion breaks exactly one of
// them.
//
// Unreachable on a correct design, counted so a run can publish the
// number zero.
if (cons_owns_deq == ring_empty)
own_c <= own_c + 32'd1;
end
end
endmodule7. SystemVerilog RTL
// =====================================================================
// xhci_ring -- SystemVerilog.
//
// Same mechanism, and one thing the Verilog cannot say: the ring entry
// is a STRUCT with the payload and the cycle bit in it, which is what a
// TRB actually is. Keeping them in one object makes the ordering
// requirement -- payload first, cycle bit last -- a property of one
// assignment rather than an unwritten rule about two separate arrays.
//
// An xHCI controller is a large block diagram with one genuinely clever
// idea in it, and the interview is about whether you can name it:
//
// Software and hardware share a ring buffer with NO LOCK and no
// head/tail pointer exchange. Ownership of each entry is carried
// by a single bit in the entry itself -- the CYCLE BIT.
//
// How it works: the ring has a "current cycle state" that both sides
// track, and it INVERTS every time the pointer wraps. An entry whose
// cycle bit equals the consumer's cycle state is owned by the consumer;
// one that differs is owned by the producer. Software writes an entry
// and sets its cycle bit last; that single write transfers ownership.
//
// There is no shared counter to race on, no lock to take, and no
// doorbell strictly required for correctness. A full ring and an empty
// ring are distinguishable -- which the classic head==tail ring buffer
// cannot do without wasting an entry or adding a counter.
//
// It is a beautiful mechanism and it has exactly one way to get it
// wrong: failing to invert the cycle state on wrap. Do that and the
// consumer reads the whole ring exactly once and then stops forever,
// having decided every entry belongs to the producer.
// =====================================================================
module xhci_ring #(
parameter int RING_N = 8 // entries, including the Link TRB
) (
input logic clk,
input logic rst_n,
// ---- the PRODUCER side (software enqueuing a transfer) ----
input logic prod_req,
input logic [15:0]prod_data,
output logic prod_ack,
output logic ring_full,
// ---- the CONSUMER side (the controller fetching work) ----
input logic cons_req,
output logic cons_valid,
output logic [15:0]cons_data,
output logic ring_empty,
// ---- the state both sides track ----
output logic pcs, // producer cycle state
output logic ccs, // consumer cycle state
output logic [3:0] enq_ptr,
output logic [3:0] deq_ptr,
// ---- observability ----
output logic [31:0]n_enq,
output logic [31:0]n_deq,
output logic [31:0]n_prod_wrap,
output logic [31:0]n_cons_wrap,
output logic [31:0]n_full_reject,
output logic [31:0]n_empty_read,
output logic [31:0]n_owner_error // must always read 0
);
// A ring entry, which is what a TRB is: a payload and a cycle bit that
// carries ownership. Keeping them in one object makes "write the payload,
// then the cycle bit" a property of one assignment rather than an
// unwritten rule about two separate arrays.
typedef struct packed {
logic [15:0] data;
logic cyc;
} trb_t;
trb_t ring [RING_N];
logic [3:0] enq_r, deq_r;
logic pcs_r, ccs_r;
logic pa_r;
logic cv_r;
logic [15:0] cd_r;
logic [31:0] enq_c, deq_c, pwrap_c, cwrap_c, full_c, empty_c, own_c;
// Icarus Verilog 13 aborts its elaborator on a variable FIELD SELECT into
// an unpacked array of packed structs -- `ring[enq_r].cyc` fails an
// internal assertion rather than reporting an error. Reading the whole
// element into a wire first and selecting the field from THAT is legal,
// portable, and arguably clearer: it names the entry being examined.
wire trb_t deq_trb = ring[deq_r];
// A struct temporary for the write side. Icarus also rejects a
// named-field assignment pattern, and building the entry field by
// field on a local has a virtue anyway: the order in which the two
// fields are set is visible, and in software that order is the
// entire correctness argument.
trb_t nxt_trb;
assign prod_ack = pa_r;
assign cons_valid = cv_r;
assign cons_data = cd_r;
assign pcs = pcs_r;
assign ccs = ccs_r;
assign enq_ptr = enq_r;
assign deq_ptr = deq_r;
assign n_enq = enq_c;
assign n_deq = deq_c;
assign n_prod_wrap = pwrap_c;
assign n_cons_wrap = cwrap_c;
assign n_full_reject = full_c;
assign n_empty_read = empty_c;
assign n_owner_error = own_c;
// =================================================================
// THE MECHANISM, and it is the whole design.
//
// There are two separate claims here and conflating them is the
// commonest way to get a ring buffer wrong.
//
// (1) IS THIS ENTRY READY? A single-bit test, and the only thing
// the cycle bit itself answers. An entry whose cycle bit equals
// the consumer's cycle state has been finished by the producer;
// one that differs has not. Software writes the payload and sets
// the cycle bit LAST, and that single store transfers ownership.
// No lock, no barrier beyond ordering the two writes.
//
// (2) IS THE RING FULL OR EMPTY? The cycle bit alone cannot say,
// because the consumer never writes anything -- so the producer
// has no per-entry evidence that an entry has been consumed.
// What distinguishes the two is the pair of CYCLE STATES:
//
// enq == deq and pcs == pcs -> EMPTY
// enq == deq and pcs != ccs -> FULL
//
// The pointers are equal in both cases. The cycle states differ
// only when the producer has lapped the consumer exactly once.
// That is how this ring holds RING_N entries rather than
// RING_N-1, without a counter and without a wasted slot -- which
// a plain head/tail ring cannot do.
// =================================================================
wire ptr_eq = (enq_r == deq_r);
wire cons_owns_deq = (deq_trb.cyc == ccs_r);
assign ring_empty = ptr_eq && (pcs_r == ccs_r);
assign ring_full = ptr_eq && (pcs_r != ccs_r);
wire prod_owns_enq = !ring_full;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
enq_r <= 4'd0;
deq_r <= 4'd0;
// Both sides start at cycle state 1 and every entry starts at 0,
// so the ring begins EMPTY: cyc != ccs everywhere.
pcs_r <= 1'b1;
ccs_r <= 1'b1;
pa_r <= 1'b0;
cv_r <= 1'b0;
cd_r <= 16'd0;
enq_c <= 32'd0;
deq_c <= 32'd0;
pwrap_c <= 32'd0;
cwrap_c <= 32'd0;
full_c <= 32'd0;
empty_c <= 32'd0;
own_c <= 32'd0;
for (int k = 0; k < RING_N; k++) begin
ring[k] <= {16'd0, 1'b0};
end
end else begin
pa_r <= 1'b0;
cv_r <= 1'b0;
// ---- PRODUCER: write the entry, then its cycle bit ----
//
// In hardware both happen on the same edge; in software the cycle
// bit MUST be written last, because that single store is what
// transfers ownership. A controller that observed the cycle bit
// before the payload would fetch a half-written descriptor.
if (prod_req) begin
if (prod_owns_enq) begin
// Payload AND ownership in ONE write. In software these are two
// stores and the cycle bit MUST be second, because that store is
// what hands the entry over: a controller that saw the cycle bit
// before the payload would fetch a half-written descriptor.
nxt_trb.data = prod_data; // payload FIRST
nxt_trb.cyc = pcs_r; // then ownership
ring[enq_r] <= nxt_trb;
pa_r <= 1'b1;
enq_c <= enq_c + 32'd1;
if (enq_r == RING_N - 1) begin
// ---- THE WRAP, and the one line the whole thing hinges on ----
//
// At the end of the ring the pointer returns to zero AND the
// cycle state INVERTS. Without the inversion the producer
// would write entries whose cycle bit still matches what the
// consumer already consumed, and the ring would appear
// permanently empty from the consumer's side.
enq_r <= 4'd0;
pcs_r <= ~pcs_r;
pwrap_c <= pwrap_c + 32'd1;
end else begin
enq_r <= enq_r + 4'd1;
end
end else begin
// The ring is full. Counted rather than silently dropped,
// because a producer that cannot tell "full" from "accepted"
// loses transfers with no error anywhere.
full_c <= full_c + 32'd1;
end
end
// ---- CONSUMER: take the entry if it is ours ----
if (cons_req) begin
if (cons_owns_deq) begin
cv_r <= 1'b1;
cd_r <= deq_trb.data;
deq_c <= deq_c + 32'd1;
if (deq_r == RING_N - 1) begin
deq_r <= 4'd0;
ccs_r <= ~ccs_r;
cwrap_c <= cwrap_c + 32'd1;
end else begin
deq_r <= deq_r + 4'd1;
end
end else begin
empty_c <= empty_c + 32'd1;
end
end
// ---- the self-check: the two formulations must AGREE ----
//
// "The entry at the dequeue pointer is ready" and "the ring is not
// empty" are computed by completely different means -- a single-bit
// ownership test on one entry, and a comparison of two pointers and
// two cycle states. They are the same claim, so they must always
// agree, and a broken cycle-state inversion breaks exactly one of
// them.
//
// Unreachable on a correct design, counted so a run can publish the
// number zero.
if (cons_owns_deq == ring_empty)
own_c <= own_c + 32'd1;
end
end
endmodule8. VHDL-2008 RTL
-- =====================================================================
-- xhci_ring -- VHDL-2008.
--
-- VHDL gets the shape both other versions wanted: a RECORD per ring
-- entry, in an array, with a variable index -- and unlike Icarus it
-- will actually compile a field select on one.
--
-- It also refuses to let the two cycle states be confused with each
-- other by accident, because they are declared separately and used
-- separately, and a comparison between them is the one expression in
-- the file that has to be right.
-- =====================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
package xr_pkg is
-- What a TRB is: a payload, and a cycle bit that carries ownership.
type trb_t is record
data : std_logic_vector(15 downto 0);
cyc : std_logic;
end record;
type trb_array is array (natural range <>) of trb_t;
end package;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.xr_pkg.all;
entity xhci_ring is
generic (
RING_N : natural := 8
);
port (
clk : in std_logic;
rst_n : in std_logic;
prod_req : in std_logic;
prod_data : in std_logic_vector(15 downto 0);
prod_ack : out std_logic;
ring_full : out std_logic;
cons_req : in std_logic;
cons_valid : out std_logic;
cons_data : out std_logic_vector(15 downto 0);
ring_empty : out std_logic;
pcs : out std_logic;
ccs : out std_logic;
enq_ptr : out std_logic_vector(3 downto 0);
deq_ptr : out std_logic_vector(3 downto 0);
n_enq : out std_logic_vector(31 downto 0);
n_deq : out std_logic_vector(31 downto 0);
n_prod_wrap : out std_logic_vector(31 downto 0);
n_cons_wrap : out std_logic_vector(31 downto 0);
n_full_reject : out std_logic_vector(31 downto 0);
n_empty_read : out std_logic_vector(31 downto 0);
n_owner_error : out std_logic_vector(31 downto 0)
);
end entity;
architecture rtl of xhci_ring is
signal ring : trb_array(0 to RING_N-1)
:= (others => (data => (others => '0'), cyc => '0'));
signal enq_r, deq_r : natural range 0 to 15 := 0;
-- Both start at 1 while every entry's cycle bit starts at 0, so the ring
-- begins EMPTY: no entry's cycle bit matches the consumer's state.
signal pcs_r, ccs_r : std_logic := '1';
signal pa_r : std_logic := '0';
signal cv_r : std_logic := '0';
signal cd_r : std_logic_vector(15 downto 0) := (others => '0');
signal enq_c, deq_c, pwrap_c, cwrap_c, full_c, empty_c, own_c
: unsigned(31 downto 0) := (others => '0');
-- =================================================================
-- THE MECHANISM. Two separate claims, and conflating them is the
-- commonest way to get a ring buffer wrong.
--
-- (1) IS THIS ENTRY READY? A single-bit test, and the only thing the
-- cycle bit itself answers. An entry whose cycle bit equals the
-- consumer's cycle state has been finished by the producer.
-- Software writes the payload and sets the cycle bit LAST, and
-- that single store transfers ownership -- no lock at all.
--
-- (2) IS THE RING FULL OR EMPTY? The cycle bit cannot say, because
-- the consumer never writes anything and the producer therefore
-- has no per-entry evidence that an entry was consumed. What
-- separates the two is the pair of CYCLE STATES:
--
-- enq = deq and pcs = ccs -> EMPTY
-- enq = deq and pcs /= ccs -> FULL
--
-- The pointers are equal in both. The cycle states differ only
-- when the producer has lapped the consumer exactly once, which is
-- how this ring holds RING_N entries rather than RING_N-1 without
-- a counter and without a wasted slot.
-- =================================================================
signal ptr_eq : boolean;
signal cons_owns_deq : boolean;
signal is_empty : boolean;
signal is_full : boolean;
begin
ptr_eq <= enq_r = deq_r;
cons_owns_deq <= ring(deq_r).cyc = ccs_r;
is_empty <= ptr_eq and (pcs_r = ccs_r);
is_full <= ptr_eq and (pcs_r /= ccs_r);
ring_empty <= '1' when is_empty else '0';
ring_full <= '1' when is_full else '0';
prod_ack <= pa_r;
cons_valid <= cv_r;
cons_data <= cd_r;
pcs <= pcs_r;
ccs <= ccs_r;
enq_ptr <= std_logic_vector(to_unsigned(enq_r, 4));
deq_ptr <= std_logic_vector(to_unsigned(deq_r, 4));
n_enq <= std_logic_vector(enq_c);
n_deq <= std_logic_vector(deq_c);
n_prod_wrap <= std_logic_vector(pwrap_c);
n_cons_wrap <= std_logic_vector(cwrap_c);
n_full_reject <= std_logic_vector(full_c);
n_empty_read <= std_logic_vector(empty_c);
n_owner_error <= std_logic_vector(own_c);
main : process(clk, rst_n)
variable nxt : trb_t;
begin
if rst_n = '0' then
enq_r <= 0;
deq_r <= 0;
pcs_r <= '1';
ccs_r <= '1';
pa_r <= '0';
cv_r <= '0';
cd_r <= (others => '0');
enq_c <= (others => '0');
deq_c <= (others => '0');
pwrap_c <= (others => '0');
cwrap_c <= (others => '0');
full_c <= (others => '0');
empty_c <= (others => '0');
own_c <= (others => '0');
ring <= (others => (data => (others => '0'), cyc => '0'));
elsif rising_edge(clk) then
pa_r <= '0';
cv_r <= '0';
-- ---- PRODUCER: the payload, then the cycle bit ----
--
-- In hardware both land on the same edge. In software the cycle bit
-- MUST be written second, because that store is what hands the entry
-- over: a controller that observed the cycle bit before the payload
-- would fetch a half-written descriptor.
if prod_req = '1' then
if not is_full then
nxt.data := prod_data; -- payload FIRST
nxt.cyc := pcs_r; -- then ownership
ring(enq_r) <= nxt;
pa_r <= '1';
enq_c <= enq_c + 1;
if enq_r = RING_N - 1 then
-- ---- THE WRAP, and the one line the whole thing hinges on ----
--
-- At the end of the ring the pointer returns to zero AND the
-- cycle state INVERTS. Without the inversion the producer keeps
-- writing entries whose cycle bit still matches what the
-- consumer already consumed, and the ring looks permanently
-- empty from the consumer's side: it reads the ring exactly
-- once and then stops forever.
enq_r <= 0;
pcs_r <= not pcs_r;
pwrap_c <= pwrap_c + 1;
else
enq_r <= enq_r + 1;
end if;
else
-- The ring is full. Counted rather than silently dropped: a
-- producer that cannot tell "full" from "accepted" loses
-- transfers with no error anywhere.
full_c <= full_c + 1;
end if;
end if;
-- ---- CONSUMER: take the entry if it is ours ----
if cons_req = '1' then
-- Gated on the PER-ENTRY cycle bit, which is the actual xHCI
-- mechanism -- not on the pointer/cycle-state comparison. The two
-- are equivalent on a correct design (that is the self-check
-- below), and they diverge the instant the cycle bits are wrong.
-- Gating on the pointer test made this VHDL consumer immune to a
-- mutation the Verilog one caught, and the two columns read
-- 40184 against 358120.
if cons_owns_deq then
cv_r <= '1';
cd_r <= ring(deq_r).data;
deq_c <= deq_c + 1;
if deq_r = RING_N - 1 then
deq_r <= 0;
ccs_r <= not ccs_r;
cwrap_c <= cwrap_c + 1;
else
deq_r <= deq_r + 1;
end if;
else
empty_c <= empty_c + 1;
end if;
end if;
-- ---- the self-check: the two formulations must AGREE ----
--
-- "The entry at the dequeue pointer is ready" and "the ring is not
-- empty" are computed by completely different means: a single-bit
-- ownership test on one entry, and a comparison of two pointers and
-- two cycle states. They are the same claim, so they must always
-- agree -- and a broken cycle-state inversion breaks exactly one.
--
-- Unreachable on a correct design, counted so a run can publish zero.
if cons_owns_deq = is_empty then
own_c <= own_c + 1;
end if;
end if;
end process;
end architecture;9. The Testbench: A Model That Counts
The shadow tracks occupancy with a counter and a queue — which is exactly the mechanism the design deliberately does not have.
Design: full/empty from a single-bit ownership test plus
two cycle states. No counter anywhere.
Model: a queue and an integer count.
If the cycle-state arithmetic is wrong -- and the one way
to get it wrong is to forget the inversion on wrap -- the
two disagree immediately, because a counter cannot make
that mistake.The phases matter as much as the model. Going round the ring more than once is the entire point: a ring that is filled and drained once, without wrapping, cannot distinguish a correct cycle-state inversion from no inversion at all. Phase 1 does six full laps; phase 3 holds a steady occupancy while both pointers wrap, so the two cycle states are unequal for half the run.
That last phase is the one that separates this mechanism from a pointer comparison. A design that compared pointers instead of cycle states works perfectly at steady occupancy and fails nowhere else.
Verilog-2005 testbench
// =====================================================================
// Testbench for xhci_ring.
//
// The shadow model is independent in the strongest way available here:
// it tracks occupancy with a COUNTER and a queue, which is exactly the
// mechanism the design deliberately does NOT have.
//
// The design decides full and empty from single-bit ownership tests on
// one entry. The model decides them by counting. If the cycle-bit
// arithmetic is wrong -- and the one way to get it wrong is to forget
// the inversion on wrap -- the two disagree immediately, because a
// counter cannot make that mistake.
// =====================================================================
`timescale 1ns/1ps
module tb_xr_v;
localparam integer RING_N = 8;
reg clk = 1'b0, rst_n = 1'b0;
reg prod_req = 1'b0;
reg [15:0] prod_data = 16'd0;
reg cons_req = 1'b0;
wire prod_ack, ring_full, cons_valid, ring_empty;
wire [15:0] cons_data;
wire pcs, ccs;
wire [3:0] enq_ptr, deq_ptr;
wire [31:0] n_enq, n_deq, n_prod_wrap, n_cons_wrap,
n_full_reject, n_empty_read, n_owner_error;
xhci_ring #(.RING_N(RING_N)) dut (
.clk(clk), .rst_n(rst_n),
.prod_req(prod_req), .prod_data(prod_data),
.prod_ack(prod_ack), .ring_full(ring_full),
.cons_req(cons_req), .cons_valid(cons_valid),
.cons_data(cons_data), .ring_empty(ring_empty),
.pcs(pcs), .ccs(ccs), .enq_ptr(enq_ptr), .deq_ptr(deq_ptr),
.n_enq(n_enq), .n_deq(n_deq),
.n_prod_wrap(n_prod_wrap), .n_cons_wrap(n_cons_wrap),
.n_full_reject(n_full_reject), .n_empty_read(n_empty_read),
.n_owner_error(n_owner_error)
);
always #5 clk = ~clk;
integer errors = 0, checks = 0, steps = 0;
integer seed;
function [31:0] urand;
input dummy;
begin urand = $random(seed) & 32'h3FFF_FFFF; end
endfunction
// ---- the shadow: a COUNTER and a queue, not cycle bits ----
reg [15:0] q [0:RING_N-1];
integer qh, qt, qn; // head, tail, count
reg [31:0] x_enq, x_deq, x_full, x_empty;
// ---- the headline counters ----
integer n_lost = 0; // an accepted entry never came back
integer n_wrong = 0; // an entry came back with the wrong data or order
integer n_stuck = 0; // the ring reported empty with entries in it
// ---- exhaustive reach over (occupancy, dequeue pointer) ----
//
// The cycle PARITY is deliberately not a third dimension. pcs differs
// from ccs exactly when the producer has lapped the consumer an odd
// number of times, which is DETERMINED by the occupancy: it is 0 at
// occupancy 0 and 1 at occupancy RING_N. Treating it as a free axis gives
// a 144-point domain of which 72 points do not exist, and a coverage
// figure that can never close -- which is how an honest 72/72 gets
// reported as a suspicious 72/144.
reg reach [0:71];
integer ri, n_reach;
task ck(input cond, input [255:0] what);
begin
checks = checks + 1;
if (!cond) begin
errors = errors + 1;
if (errors <= 20)
$display(" ERROR @%0t step=%0d: %0s", $time, steps, what);
end
end
endtask
task mark;
begin
ri = (qn * 8) + deq_ptr;
if (ri < 72) reach[ri] = 1'b1;
end
endtask
// ---------------------------------------------------------------
// One cycle: optionally enqueue, optionally dequeue, check both.
// ---------------------------------------------------------------
task step(input pr, input [15:0] pd, input cr);
reg e_pack, e_cvalid, e_full, e_empty;
reg [15:0] e_cdata;
begin
prod_req = pr; prod_data = pd; cons_req = cr;
// ---- what SHOULD happen, from the COUNTER ----
e_full = (qn == RING_N);
e_empty = (qn == 0);
e_pack = pr && !e_full;
e_cvalid = cr && !e_empty;
e_cdata = e_cvalid ? q[qh] : 16'd0;
mark;
// ---- check the status flags BEFORE the edge ----
//
// They are combinational functions of the ring's state, and the
// whole claim of the design is that a single-bit ownership test
// computes them correctly. So they are compared against the
// counter's answer on every cycle.
ck(ring_full === e_full, "ring_full disagrees with the occupancy count");
ck(ring_empty === e_empty, "ring_empty disagrees with the occupancy count");
ck(!(ring_full && ring_empty), "the ring reported full AND empty");
if (!e_empty && ring_empty) n_stuck = n_stuck + 1;
ck(n_stuck == 0, "the ring reported empty with entries in it");
// ---- advance the shadow ----
if (e_pack) begin
q[qt] = pd;
qt = (qt + 1) % RING_N;
qn = qn + 1;
x_enq = x_enq + 1;
end else if (pr) begin
x_full = x_full + 1;
end
if (e_cvalid) begin
qh = (qh + 1) % RING_N;
qn = qn - 1;
x_deq = x_deq + 1;
end else if (cr) begin
x_empty = x_empty + 1;
end
@(posedge clk);
#1;
steps = steps + 1;
prod_req = 1'b0; cons_req = 1'b0;
// ---- PROPERTY 1: the handshakes ----
ck(prod_ack === e_pack, "prod_ack disagrees");
ck(cons_valid === e_cvalid, "cons_valid disagrees");
// ---- PROPERTY 2: FIFO order and exact data ----
if (e_cvalid) begin
if (cons_data !== e_cdata) n_wrong = n_wrong + 1;
ck(cons_data === e_cdata, "the wrong entry came back");
end
ck(n_wrong == 0, "an entry came back out of order or corrupted");
// ---- PROPERTY 3: the counters agree ----
ck(n_enq === x_enq, "enqueue count disagrees");
ck(n_deq === x_deq, "dequeue count disagrees");
ck(n_full_reject === x_full, "full-reject count disagrees");
ck(n_empty_read === x_empty, "empty-read count disagrees");
// ---- PROPERTY 4: ownership is exclusive ----
ck(n_owner_error === 32'd0, "the design detected a doubly-owned entry");
// ---- PROPERTY 5: nothing accepted is ever lost ----
//
// Enqueued minus dequeued must equal the occupancy, always. This is
// the check that a broken wrap shows up in: the entries are still
// in the ring and the consumer has decided they belong to somebody
// else, so the difference stops matching.
if ((x_enq - x_deq) != qn) n_lost = n_lost + 1;
ck((x_enq - x_deq) == qn, "accepted entries went missing");
ck(n_lost == 0, "an accepted entry was lost");
mark;
end
endtask
task reset_dut;
begin
rst_n = 1'b0;
prod_req = 0; cons_req = 0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
qh = 0; qt = 0; qn = 0;
x_enq = 0; x_deq = 0; x_full = 0; x_empty = 0;
@(posedge clk); #1;
end
endtask
integer k, j, occ, lap;
initial begin
for (ri = 0; ri < 72; ri = ri + 1) reach[ri] = 1'b0;
seed = 32'd27007;
// =============================================================
// PHASE 1 (DIRECTED, EXHAUSTIVE) -- fill and drain completely,
// several times round, so the ring WRAPS and the cycle state
// inverts in both directions.
//
// Going round more than once is the entire point. A ring that is
// filled and drained once, without wrapping, cannot distinguish a
// correct cycle-state inversion from no inversion at all.
// =============================================================
reset_dut;
for (lap = 0; lap < 6; lap = lap + 1) begin
// fill it exactly full
for (k = 0; k < RING_N; k = k + 1)
step(1'b1, 16'hA000 + (lap * 16) + k, 1'b0);
ck(ring_full === 1'b1, "the ring did not report full after RING_N entries");
// one more must be refused, and must not disturb anything
step(1'b1, 16'hDEAD, 1'b0);
ck(prod_ack === 1'b0, "an entry was accepted into a full ring");
// drain it exactly empty, in order
for (k = 0; k < RING_N; k = k + 1)
step(1'b0, 16'd0, 1'b1);
ck(ring_empty === 1'b1, "the ring did not report empty after draining");
// one more must be refused
step(1'b0, 16'd0, 1'b1);
ck(cons_valid === 1'b0, "an entry came out of an empty ring");
end
// =============================================================
// PHASE 2 (DIRECTED, EXHAUSTIVE) -- every occupancy from 0 to
// RING_N, reached by filling, then one enqueue and one dequeue
// attempted at that occupancy.
// =============================================================
for (occ = 0; occ <= RING_N; occ = occ + 1) begin
reset_dut;
for (k = 0; k < occ; k = k + 1) step(1'b1, 16'hB000 + k, 1'b0);
// an enqueue at this occupancy
step(1'b1, 16'hC0DE, 1'b0);
// a dequeue at this occupancy
step(1'b0, 16'd0, 1'b1);
// and both at once, which must do both
step(1'b1, 16'hBEEF, 1'b1);
end
// =============================================================
// PHASE 2b (DIRECTED, EXHAUSTIVE) -- close the domain.
//
// The directed phases above reach only 59 of the 72
// (occupancy, dequeue pointer) pairs: the ring is only ever EMPTY or
// FULL at dequeue pointer 0, because every lap starts and ends there.
//
// The BASE row of the directed-only run exposed this -- it read 1
// error, which is `exhaustive sweep incomplete`. The coverage claim
// was quietly depending on the RANDOM phase, which means the sweep was
// not exhaustive by directed stimulus at all.
//
// Filling k and draining k leaves the ring empty with the pointer at
// k, and filling from there reaches full at k.
// =============================================================
for (j = 1; j < RING_N; j = j + 1) begin
reset_dut;
for (k = 0; k < j; k = k + 1) step(1'b1, 16'h3000 + k, 1'b0);
for (k = 0; k < j; k = k + 1) step(1'b0, 16'd0, 1'b1);
ck(ring_empty === 1'b1, "the ring is not empty after matched fill and drain");
// now empty with the pointer at j: fill it completely and drain again
for (k = 0; k < RING_N; k = k + 1) step(1'b1, 16'h4000 + k, 1'b0);
ck(ring_full === 1'b1, "the ring is not full at a non-zero pointer");
for (k = 0; k < RING_N; k = k + 1) step(1'b0, 16'd0, 1'b1);
ck(ring_empty === 1'b1, "the ring is not empty after a full lap from j");
end
// =============================================================
// PHASE 3 (DIRECTED) -- simultaneous enqueue and dequeue, held
// at a steady occupancy for many laps.
//
// This is the case that keeps the two pointers a fixed distance
// apart while both wrap, so the producer and consumer cycle states
// are unequal for half the run. A design that compared pointers
// instead of cycle bits would work here and fail nowhere else.
// =============================================================
for (occ = 1; occ < RING_N; occ = occ + 1) begin
reset_dut;
for (k = 0; k < occ; k = k + 1) step(1'b1, 16'hE000 + k, 1'b0);
for (k = 0; k < 4 * RING_N; k = k + 1)
step(1'b1, 16'hF000 + k, 1'b1);
end
// =============================================================
// PHASE 4 (DIRECTED) -- a full ring drained one entry at a time
// while the producer keeps pushing, so the ring stays full and
// the wrap happens under back-pressure.
// =============================================================
reset_dut;
for (k = 0; k < RING_N; k = k + 1) step(1'b1, 16'h1000 + k, 1'b0);
for (k = 0; k < 5 * RING_N; k = k + 1) begin
// The ring does NOT stay exactly full, and that is correct. On a
// full ring a simultaneous enqueue and dequeue refuses the enqueue:
// the producer reads the ring's state as it stood BEFORE this cycle,
// and a consumer freeing a slot on the same edge is a different agent
// whose action it cannot have seen. Occupancy settles one below full.
//
// Asserting "stays full" here was wrong and produced 40 errors
// against a correct design.
step(1'b1, 16'h2000 + k, 1'b1);
ck(qn >= RING_N - 1,
"matched traffic drained the ring instead of holding it near full");
ck(ring_empty === 1'b0, "a ring under matched traffic reported empty");
end
// =============================================================
// PHASE 5 (RANDOM) -- arbitrary interleaving.
// =============================================================
`ifndef DIRECTED_ONLY
reset_dut;
for (k = 0; k < 40000; k = k + 1)
step((urand(0) % 3) != 0, urand(0) & 16'hFFFF, (urand(0) % 3) != 0);
`endif
n_reach = 0;
for (ri = 0; ri < 72; ri = ri + 1) if (reach[ri]) n_reach = n_reach + 1;
$display("steps=%0d checks=%0d reach=%0d/72 errors=%0d",
steps, checks, n_reach, errors);
$display("[ring] enq=%0d deq=%0d prod_wraps=%0d cons_wraps=%0d full_rejects=%0d empty_reads=%0d",
n_enq, n_deq, n_prod_wrap, n_cons_wrap, n_full_reject, n_empty_read);
$display("[the whole point] lost entries = %0d, out-of-order = %0d, false-empty = %0d",
n_lost, n_wrong, n_stuck);
if (n_reach != 72) begin
$display("FAIL: exhaustive sweep incomplete"); errors = errors + 1;
end
if (errors == 0) $display("PASS: 0 errors in %0d checks", checks);
else $display("FAIL: %0d errors in %0d checks", errors, checks);
$finish;
end
endmoduleSystemVerilog testbench
// =====================================================================
// Testbench for xhci_ring.
//
// The shadow model is independent in the strongest way available here:
// it tracks occupancy with a COUNTER and a queue, which is exactly the
// mechanism the design deliberately does NOT have.
//
// The design decides full and empty from single-bit ownership tests on
// one entry. The model decides them by counting. If the cycle-bit
// arithmetic is wrong -- and the one way to get it wrong is to forget
// the inversion on wrap -- the two disagree immediately, because a
// counter cannot make that mistake.
// =====================================================================
`timescale 1ns/1ps
module tb_xr_sv;
localparam integer RING_N = 8;
logic clk = 1'b0, rst_n = 1'b0;
logic prod_req = 1'b0;
logic [15:0] prod_data = 16'd0;
logic cons_req = 1'b0;
logic prod_ack, ring_full, cons_valid, ring_empty;
logic [15:0] cons_data;
logic pcs, ccs;
logic [3:0] enq_ptr, deq_ptr;
logic [31:0] n_enq, n_deq, n_prod_wrap, n_cons_wrap,
n_full_reject, n_empty_read, n_owner_error;
xhci_ring #(.RING_N(RING_N)) dut (
.clk(clk), .rst_n(rst_n),
.prod_req(prod_req), .prod_data(prod_data),
.prod_ack(prod_ack), .ring_full(ring_full),
.cons_req(cons_req), .cons_valid(cons_valid),
.cons_data(cons_data), .ring_empty(ring_empty),
.pcs(pcs), .ccs(ccs), .enq_ptr(enq_ptr), .deq_ptr(deq_ptr),
.n_enq(n_enq), .n_deq(n_deq),
.n_prod_wrap(n_prod_wrap), .n_cons_wrap(n_cons_wrap),
.n_full_reject(n_full_reject), .n_empty_read(n_empty_read),
.n_owner_error(n_owner_error)
);
always #5 clk = ~clk;
integer errors = 0, checks = 0, steps = 0;
integer seed;
function automatic logic [31:0] urand(bit dummy);
return $random(seed) & 32'h3FFF_FFFF;
endfunction
// ---- the shadow: a COUNTER and a queue, not cycle bits ----
logic [15:0] q [0:RING_N-1];
integer qh, qt, qn; // head, tail, count
logic [31:0] x_enq, x_deq, x_full, x_empty;
// ---- the headline counters ----
integer n_lost = 0; // an accepted entry never came back
integer n_wrong = 0; // an entry came back with the wrong data or order
integer n_stuck = 0; // the ring reported empty with entries in it
// ---- exhaustive reach over (occupancy, dequeue pointer) ----
//
// The cycle PARITY is deliberately not a third dimension. pcs differs
// from ccs exactly when the producer has lapped the consumer an odd
// number of times, which is DETERMINED by the occupancy: it is 0 at
// occupancy 0 and 1 at occupancy RING_N. Treating it as a free axis gives
// a 144-point domain of which 72 points do not exist, and a coverage
// figure that can never close -- which is how an honest 72/72 gets
// reported as a suspicious 72/144.
logic reach [0:71];
integer ri, n_reach;
task ck(input logic cond, input logic [255:0] what);
begin
checks = checks + 1;
if (!cond) begin
errors = errors + 1;
if (errors <= 20)
$display(" ERROR @%0t step=%0d: %0s", $time, steps, what);
end
end
endtask
task mark;
begin
ri = (qn * 8) + deq_ptr;
if (ri < 72) reach[ri] = 1'b1;
end
endtask
// ---------------------------------------------------------------
// One cycle: optionally enqueue, optionally dequeue, check both.
// ---------------------------------------------------------------
task step(input logic pr, input logic [15:0] pd, input logic cr);
logic e_pack, e_cvalid, e_full, e_empty;
logic [15:0] e_cdata;
begin
prod_req = pr; prod_data = pd; cons_req = cr;
// ---- what SHOULD happen, from the COUNTER ----
e_full = (qn == RING_N);
e_empty = (qn == 0);
e_pack = pr && !e_full;
e_cvalid = cr && !e_empty;
e_cdata = e_cvalid ? q[qh] : 16'd0;
mark;
// ---- check the status flags BEFORE the edge ----
//
// They are combinational functions of the ring's state, and the
// whole claim of the design is that a single-bit ownership test
// computes them correctly. So they are compared against the
// counter's answer on every cycle.
ck(ring_full === e_full, "ring_full disagrees with the occupancy count");
ck(ring_empty === e_empty, "ring_empty disagrees with the occupancy count");
ck(!(ring_full && ring_empty), "the ring reported full AND empty");
if (!e_empty && ring_empty) n_stuck = n_stuck + 1;
ck(n_stuck == 0, "the ring reported empty with entries in it");
// ---- advance the shadow ----
if (e_pack) begin
q[qt] = pd;
qt = (qt + 1) % RING_N;
qn = qn + 1;
x_enq = x_enq + 1;
end else if (pr) begin
x_full = x_full + 1;
end
if (e_cvalid) begin
qh = (qh + 1) % RING_N;
qn = qn - 1;
x_deq = x_deq + 1;
end else if (cr) begin
x_empty = x_empty + 1;
end
@(posedge clk);
#1;
steps = steps + 1;
prod_req = 1'b0; cons_req = 1'b0;
// ---- PROPERTY 1: the handshakes ----
ck(prod_ack === e_pack, "prod_ack disagrees");
ck(cons_valid === e_cvalid, "cons_valid disagrees");
// ---- PROPERTY 2: FIFO order and exact data ----
if (e_cvalid) begin
if (cons_data !== e_cdata) n_wrong = n_wrong + 1;
ck(cons_data === e_cdata, "the wrong entry came back");
end
ck(n_wrong == 0, "an entry came back out of order or corrupted");
// ---- PROPERTY 3: the counters agree ----
ck(n_enq === x_enq, "enqueue count disagrees");
ck(n_deq === x_deq, "dequeue count disagrees");
ck(n_full_reject === x_full, "full-reject count disagrees");
ck(n_empty_read === x_empty, "empty-read count disagrees");
// ---- PROPERTY 4: ownership is exclusive ----
ck(n_owner_error === 32'd0, "the design detected a doubly-owned entry");
// ---- PROPERTY 5: nothing accepted is ever lost ----
//
// Enqueued minus dequeued must equal the occupancy, always. This is
// the check that a broken wrap shows up in: the entries are still
// in the ring and the consumer has decided they belong to somebody
// else, so the difference stops matching.
if ((x_enq - x_deq) != qn) n_lost = n_lost + 1;
ck((x_enq - x_deq) == qn, "accepted entries went missing");
ck(n_lost == 0, "an accepted entry was lost");
mark;
end
endtask
task reset_dut;
begin
rst_n = 1'b0;
prod_req = 0; cons_req = 0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
qh = 0; qt = 0; qn = 0;
x_enq = 0; x_deq = 0; x_full = 0; x_empty = 0;
@(posedge clk); #1;
end
endtask
integer k, j, occ, lap;
initial begin
for (ri = 0; ri < 72; ri = ri + 1) reach[ri] = 1'b0;
seed = 32'd27007;
// =============================================================
// PHASE 1 (DIRECTED, EXHAUSTIVE) -- fill and drain completely,
// several times round, so the ring WRAPS and the cycle state
// inverts in both directions.
//
// Going round more than once is the entire point. A ring that is
// filled and drained once, without wrapping, cannot distinguish a
// correct cycle-state inversion from no inversion at all.
// =============================================================
reset_dut;
for (lap = 0; lap < 6; lap = lap + 1) begin
// fill it exactly full
for (k = 0; k < RING_N; k = k + 1)
step(1'b1, 16'hA000 + (lap * 16) + k, 1'b0);
ck(ring_full === 1'b1, "the ring did not report full after RING_N entries");
// one more must be refused, and must not disturb anything
step(1'b1, 16'hDEAD, 1'b0);
ck(prod_ack === 1'b0, "an entry was accepted into a full ring");
// drain it exactly empty, in order
for (k = 0; k < RING_N; k = k + 1)
step(1'b0, 16'd0, 1'b1);
ck(ring_empty === 1'b1, "the ring did not report empty after draining");
// one more must be refused
step(1'b0, 16'd0, 1'b1);
ck(cons_valid === 1'b0, "an entry came out of an empty ring");
end
// =============================================================
// PHASE 2 (DIRECTED, EXHAUSTIVE) -- every occupancy from 0 to
// RING_N, reached by filling, then one enqueue and one dequeue
// attempted at that occupancy.
// =============================================================
for (occ = 0; occ <= RING_N; occ = occ + 1) begin
reset_dut;
for (k = 0; k < occ; k = k + 1) step(1'b1, 16'hB000 + k, 1'b0);
// an enqueue at this occupancy
step(1'b1, 16'hC0DE, 1'b0);
// a dequeue at this occupancy
step(1'b0, 16'd0, 1'b1);
// and both at once, which must do both
step(1'b1, 16'hBEEF, 1'b1);
end
// =============================================================
// PHASE 2b (DIRECTED, EXHAUSTIVE) -- close the domain.
//
// The directed phases above reach only 59 of the 72
// (occupancy, dequeue pointer) pairs: the ring is only ever EMPTY or
// FULL at dequeue pointer 0, because every lap starts and ends there.
//
// The BASE row of the directed-only run exposed this -- it read 1
// error, which is `exhaustive sweep incomplete`. The coverage claim
// was quietly depending on the RANDOM phase, which means the sweep was
// not exhaustive by directed stimulus at all.
//
// Filling k and draining k leaves the ring empty with the pointer at
// k, and filling from there reaches full at k.
// =============================================================
for (j = 1; j < RING_N; j = j + 1) begin
reset_dut;
for (k = 0; k < j; k = k + 1) step(1'b1, 16'h3000 + k, 1'b0);
for (k = 0; k < j; k = k + 1) step(1'b0, 16'd0, 1'b1);
ck(ring_empty === 1'b1, "the ring is not empty after matched fill and drain");
// now empty with the pointer at j: fill it completely and drain again
for (k = 0; k < RING_N; k = k + 1) step(1'b1, 16'h4000 + k, 1'b0);
ck(ring_full === 1'b1, "the ring is not full at a non-zero pointer");
for (k = 0; k < RING_N; k = k + 1) step(1'b0, 16'd0, 1'b1);
ck(ring_empty === 1'b1, "the ring is not empty after a full lap from j");
end
// =============================================================
// PHASE 3 (DIRECTED) -- simultaneous enqueue and dequeue, held
// at a steady occupancy for many laps.
//
// This is the case that keeps the two pointers a fixed distance
// apart while both wrap, so the producer and consumer cycle states
// are unequal for half the run. A design that compared pointers
// instead of cycle bits would work here and fail nowhere else.
// =============================================================
for (occ = 1; occ < RING_N; occ = occ + 1) begin
reset_dut;
for (k = 0; k < occ; k = k + 1) step(1'b1, 16'hE000 + k, 1'b0);
for (k = 0; k < 4 * RING_N; k = k + 1)
step(1'b1, 16'hF000 + k, 1'b1);
end
// =============================================================
// PHASE 4 (DIRECTED) -- a full ring drained one entry at a time
// while the producer keeps pushing, so the ring stays full and
// the wrap happens under back-pressure.
// =============================================================
reset_dut;
for (k = 0; k < RING_N; k = k + 1) step(1'b1, 16'h1000 + k, 1'b0);
for (k = 0; k < 5 * RING_N; k = k + 1) begin
// The ring does NOT stay exactly full, and that is correct. On a
// full ring a simultaneous enqueue and dequeue refuses the enqueue:
// the producer reads the ring's state as it stood BEFORE this cycle,
// and a consumer freeing a slot on the same edge is a different agent
// whose action it cannot have seen. Occupancy settles one below full.
//
// Asserting "stays full" here was wrong and produced 40 errors
// against a correct design.
step(1'b1, 16'h2000 + k, 1'b1);
ck(qn >= RING_N - 1,
"matched traffic drained the ring instead of holding it near full");
ck(ring_empty === 1'b0, "a ring under matched traffic reported empty");
end
// =============================================================
// PHASE 5 (RANDOM) -- arbitrary interleaving.
// =============================================================
`ifndef DIRECTED_ONLY
reset_dut;
for (k = 0; k < 40000; k = k + 1)
step((urand(0) % 3) != 0, urand(0) & 16'hFFFF, (urand(0) % 3) != 0);
`endif
n_reach = 0;
for (ri = 0; ri < 72; ri = ri + 1) if (reach[ri]) n_reach = n_reach + 1;
$display("steps=%0d checks=%0d reach=%0d/72 errors=%0d",
steps, checks, n_reach, errors);
$display("[ring] enq=%0d deq=%0d prod_wraps=%0d cons_wraps=%0d full_rejects=%0d empty_reads=%0d",
n_enq, n_deq, n_prod_wrap, n_cons_wrap, n_full_reject, n_empty_read);
$display("[the whole point] lost entries = %0d, out-of-order = %0d, false-empty = %0d",
n_lost, n_wrong, n_stuck);
if (n_reach != 72) begin
$display("FAIL: exhaustive sweep incomplete"); errors = errors + 1;
end
if (errors == 0) $display("PASS: 0 errors in %0d checks", checks);
else $display("FAIL: %0d errors in %0d checks", errors, checks);
$finish;
end
endmoduleVHDL-2008 testbench
-- =====================================================================
-- Testbench for xhci_ring (VHDL-2008).
--
-- The shadow tracks occupancy with a COUNTER and a queue, which is
-- exactly the mechanism the design deliberately does not have. The
-- design decides full and empty from single-bit ownership and a pair of
-- cycle states; the model counts. If the cycle-state arithmetic is
-- wrong -- and the one way to get it wrong is to forget the inversion on
-- wrap -- the two disagree immediately, because a counter cannot make
-- that mistake.
-- =====================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use std.textio.all;
use work.xr_pkg.all;
entity tb_xr_vhdl is
generic (DIRECTED_ONLY : boolean := false);
end entity;
architecture sim of tb_xr_vhdl is
constant RING_N : natural := 8;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal prod_req : std_logic := '0';
signal prod_data : std_logic_vector(15 downto 0) := (others => '0');
signal cons_req : std_logic := '0';
signal prod_ack, ring_full, cons_valid, ring_empty : std_logic;
signal cons_data : std_logic_vector(15 downto 0);
signal pcs, ccs : std_logic;
signal enq_ptr, deq_ptr : std_logic_vector(3 downto 0);
signal n_enq, n_deq, n_prod_wrap, n_cons_wrap,
n_full_reject, n_empty_read, n_owner_error
: std_logic_vector(31 downto 0);
signal done : boolean := false;
begin
dut : entity work.xhci_ring
generic map (RING_N => RING_N)
port map (
clk => clk, rst_n => rst_n,
prod_req => prod_req, prod_data => prod_data,
prod_ack => prod_ack, ring_full => ring_full,
cons_req => cons_req, cons_valid => cons_valid,
cons_data => cons_data, ring_empty => ring_empty,
pcs => pcs, ccs => ccs, enq_ptr => enq_ptr, deq_ptr => deq_ptr,
n_enq => n_enq, n_deq => n_deq,
n_prod_wrap => n_prod_wrap, n_cons_wrap => n_cons_wrap,
n_full_reject => n_full_reject, n_empty_read => n_empty_read,
n_owner_error => n_owner_error);
clk <= not clk after 5 ns when not done else '0';
stim : process
variable errors : natural := 0;
variable checks : natural := 0;
variable steps : natural := 0;
type q_arr is array (0 to RING_N-1) of std_logic_vector(15 downto 0);
variable q : q_arr := (others => (others => '0'));
variable qh, qt, qn : natural := 0;
variable x_enq, x_deq, x_full, x_empty : natural := 0;
variable n_lost, n_wrong, n_stuck : natural := 0;
variable reach : std_logic_vector(0 to 71) := (others => '0');
variable n_reach : natural := 0;
variable rnd : unsigned(31 downto 0) := x"0008C3D5";
variable ln : line;
procedure ck(cond : boolean; what : string) is
begin
checks := checks + 1;
if not cond then
errors := errors + 1;
if errors <= 20 then
write(ln, string'(" ERROR step=") & integer'image(steps)
& string'(": ") & what);
writeline(output, ln);
end if;
end if;
end procedure;
-- boolean to std_logic: VHDL has no implicit conversion, and a
-- conditional expression in argument position is VHDL-2019, not 2008.
function sl_of(b : boolean) return std_logic is
begin
if b then return '1'; else return '0'; end if;
end function;
impure function nxt_rnd return natural is
begin
rnd := rnd xor (rnd sll 13);
rnd := rnd xor (rnd srl 17);
rnd := rnd xor (rnd sll 5);
return to_integer(rnd(14 downto 0));
end function;
-- The cycle PARITY is deliberately not a third dimension of the reach
-- domain: pcs differs from ccs exactly when the producer has lapped the
-- consumer an odd number of times, which is DETERMINED by the occupancy.
-- Treating it as free gives a domain half of whose points do not exist.
procedure mark is
variable ri : natural;
begin
ri := qn * 8 + to_integer(unsigned(deq_ptr));
if ri < 72 then reach(ri) := '1'; end if;
end procedure;
procedure step(pr : std_logic; pd : std_logic_vector(15 downto 0);
cr : std_logic) is
variable e_pack, e_cvalid, e_full, e_empty : boolean;
variable e_cdata : std_logic_vector(15 downto 0);
begin
prod_req <= pr; prod_data <= pd; cons_req <= cr;
-- what SHOULD happen, from the COUNTER
e_full := (qn = RING_N);
e_empty := (qn = 0);
e_pack := (pr = '1') and not e_full;
e_cvalid := (cr = '1') and not e_empty;
if e_cvalid then e_cdata := q(qh); else e_cdata := (others => '0'); end if;
mark;
-- The status flags are combinational functions of the ring's state,
-- and the design's whole claim is that single-bit tests compute them
-- correctly. So they are compared against the counter every cycle.
ck((ring_full = '1') = e_full,
"ring_full disagrees with the occupancy count");
ck((ring_empty = '1') = e_empty,
"ring_empty disagrees with the occupancy count");
ck(not (ring_full = '1' and ring_empty = '1'),
"the ring reported full AND empty");
if not e_empty and ring_empty = '1' then n_stuck := n_stuck + 1; end if;
ck(n_stuck = 0, "the ring reported empty with entries in it");
if e_pack then
q(qt) := pd;
qt := (qt + 1) mod RING_N;
qn := qn + 1;
x_enq := x_enq + 1;
elsif pr = '1' then
x_full := x_full + 1;
end if;
if e_cvalid then
qh := (qh + 1) mod RING_N;
qn := qn - 1;
x_deq := x_deq + 1;
elsif cr = '1' then
x_empty := x_empty + 1;
end if;
wait until rising_edge(clk);
wait for 1 ns;
steps := steps + 1;
prod_req <= '0'; cons_req <= '0';
ck((prod_ack = '1') = e_pack, "prod_ack disagrees");
ck((cons_valid = '1') = e_cvalid, "cons_valid disagrees");
if e_cvalid then
if cons_data /= e_cdata then n_wrong := n_wrong + 1; end if;
ck(cons_data = e_cdata, "the wrong entry came back");
end if;
ck(n_wrong = 0, "an entry came back out of order or corrupted");
ck(to_integer(unsigned(n_enq)) = x_enq, "enqueue count disagrees");
ck(to_integer(unsigned(n_deq)) = x_deq, "dequeue count disagrees");
ck(to_integer(unsigned(n_full_reject)) = x_full, "full-reject count disagrees");
ck(to_integer(unsigned(n_empty_read)) = x_empty, "empty-read count disagrees");
ck(to_integer(unsigned(n_owner_error)) = 0,
"the design detected a doubly-owned entry");
-- Enqueued minus dequeued must equal the occupancy, always. This is
-- the check a broken wrap shows up in: the entries are still in the
-- ring and the consumer has decided they belong to somebody else.
if (x_enq - x_deq) /= qn then n_lost := n_lost + 1; end if;
ck((x_enq - x_deq) = qn, "accepted entries went missing");
ck(n_lost = 0, "an accepted entry was lost");
mark;
end procedure;
procedure reset_dut is
begin
rst_n <= '0';
prod_req <= '0'; cons_req <= '0';
wait until rising_edge(clk);
wait until rising_edge(clk);
rst_n <= '1';
qh := 0; qt := 0; qn := 0;
x_enq := 0; x_deq := 0; x_full := 0; x_empty := 0;
wait until rising_edge(clk);
wait for 1 ns;
end procedure;
begin
-- PHASE 1 (DIRECTED, EXHAUSTIVE) -- fill and drain completely, several
-- laps, so the ring WRAPS and both cycle states invert.
--
-- Going round more than once is the entire point: a ring filled and
-- drained once, without wrapping, cannot distinguish a correct
-- cycle-state inversion from no inversion at all.
reset_dut;
for lap in 0 to 5 loop
for k in 0 to RING_N-1 loop
step('1', std_logic_vector(to_unsigned(16#A000# + lap*16 + k, 16)), '0');
end loop;
ck(ring_full = '1', "the ring did not report full after RING_N entries");
step('1', x"DEAD", '0');
ck(prod_ack = '0', "an entry was accepted into a full ring");
for k in 0 to RING_N-1 loop
step('0', x"0000", '1');
end loop;
ck(ring_empty = '1', "the ring did not report empty after draining");
step('0', x"0000", '1');
ck(cons_valid = '0', "an entry came out of an empty ring");
end loop;
-- PHASE 2 (DIRECTED, EXHAUSTIVE) -- every occupancy 0..RING_N
for occ in 0 to RING_N loop
reset_dut;
for k in 0 to RING_N-1 loop
if k < occ then
step('1', std_logic_vector(to_unsigned(16#B000# + k, 16)), '0');
end if;
end loop;
step('1', x"C0DE", '0');
step('0', x"0000", '1');
step('1', x"BEEF", '1');
end loop;
-- PHASE 2b (DIRECTED, EXHAUSTIVE) -- close the domain.
--
-- The directed phases above reach only 59 of the 72
-- (occupancy, dequeue pointer) pairs: the ring is only ever EMPTY or FULL
-- at dequeue pointer 0, because every lap starts and ends there. The BASE
-- row of the directed-only run exposed it -- the coverage claim was
-- quietly depending on the RANDOM phase.
for j in 1 to RING_N-1 loop
reset_dut;
for k in 0 to RING_N-1 loop
if k < j then
step('1', std_logic_vector(to_unsigned(16#3000# + k, 16)), '0');
end if;
end loop;
for k in 0 to RING_N-1 loop
if k < j then step('0', x"0000", '1'); end if;
end loop;
ck(ring_empty = '1', "the ring is not empty after matched fill and drain");
for k in 0 to RING_N-1 loop
step('1', std_logic_vector(to_unsigned(16#4000# + k, 16)), '0');
end loop;
ck(ring_full = '1', "the ring is not full at a non-zero pointer");
for k in 0 to RING_N-1 loop
step('0', x"0000", '1');
end loop;
ck(ring_empty = '1', "the ring is not empty after a full lap from j");
end loop;
-- PHASE 3 (DIRECTED) -- simultaneous enqueue and dequeue held at a
-- steady occupancy for many laps, so the two pointers stay a fixed
-- distance apart while both wrap and the cycle states are unequal for
-- half the run. A design that compared pointers instead of cycle states
-- would work here and fail nowhere else.
for occ in 1 to RING_N-1 loop
reset_dut;
for k in 0 to RING_N-1 loop
if k < occ then
step('1', std_logic_vector(to_unsigned(16#E000# + k, 16)), '0');
end if;
end loop;
for k in 0 to 4*RING_N-1 loop
step('1', std_logic_vector(to_unsigned(16#F000# + k, 16)), '1');
end loop;
end loop;
-- PHASE 4 (DIRECTED) -- the wrap under back-pressure.
--
-- The ring does NOT stay exactly full, and that is correct: on a full
-- ring a simultaneous enqueue and dequeue refuses the enqueue, because
-- the producer reads the state as it stood BEFORE this cycle and a
-- consumer freeing a slot on the same edge is a different agent whose
-- action it cannot have seen.
reset_dut;
for k in 0 to RING_N-1 loop
step('1', std_logic_vector(to_unsigned(16#1000# + k, 16)), '0');
end loop;
for k in 0 to 5*RING_N-1 loop
step('1', std_logic_vector(to_unsigned(16#2000# + k, 16)), '1');
ck(qn >= RING_N - 1,
"matched traffic drained the ring instead of holding it near full");
ck(ring_empty = '0', "a ring under matched traffic reported empty");
end loop;
-- PHASE 5 (RANDOM)
if not DIRECTED_ONLY then
reset_dut;
for k in 0 to 39999 loop
step(sl_of((nxt_rnd mod 3) /= 0),
std_logic_vector(to_unsigned(nxt_rnd mod 65536, 16)),
sl_of((nxt_rnd mod 3) /= 0));
end loop;
end if;
n_reach := 0;
for i in 0 to 71 loop
if reach(i) = '1' then n_reach := n_reach + 1; end if;
end loop;
write(ln, string'("steps=") & integer'image(steps)
& string'(" checks=") & integer'image(checks)
& string'(" reach=") & integer'image(n_reach) & string'("/72")
& string'(" errors=") & integer'image(errors));
writeline(output, ln);
write(ln, string'("[ring] enq=") & integer'image(x_enq)
& string'(" deq=") & integer'image(x_deq)
& string'(" prod_wraps=") & integer'image(to_integer(unsigned(n_prod_wrap)))
& string'(" cons_wraps=") & integer'image(to_integer(unsigned(n_cons_wrap)))
& string'(" full_rejects=") & integer'image(x_full)
& string'(" empty_reads=") & integer'image(x_empty));
writeline(output, ln);
write(ln, string'("[the whole point] lost entries = ") & integer'image(n_lost)
& string'(", out-of-order = ") & integer'image(n_wrong)
& string'(", false-empty = ") & integer'image(n_stuck));
writeline(output, ln);
if n_reach /= 72 then
write(ln, string'("FAIL: exhaustive sweep incomplete"));
writeline(output, ln);
errors := errors + 1;
end if;
if errors = 0 then
write(ln, string'("PASS: 0 errors in ") & integer'image(checks)
& string'(" checks"));
else
write(ln, string'("FAIL: ") & integer'image(errors)
& string'(" errors in ") & integer'image(checks) & string'(" checks"));
end if;
writeline(output, ln);
done <= true;
wait;
end process;
end architecture;10. Exhaustive Verification
| Measure | Verilog | SystemVerilog | VHDL |
|---|---|---|---|
| (occupancy × dequeue pointer) reached | 72 / 72 | 72 / 72 | 72 / 72 |
| …reached by directed stimulus alone | 72 / 72 | 72 / 72 | 72 / 72 |
| full laps of the ring | 6 | 6 | 6 |
| steady-occupancy sweeps | 7 | 7 | 7 |
| Steps | 40639 | 40639 | 40639 |
| Checks executed | 594948 | 594948 | 594936 |
| enqueues accepted | 25471 | 25471 | 25456 |
| dequeues | 25464 | 25464 | 25452 |
| producer wraps / consumer wraps | 3183 / 3183 | 3183 / 3183 | 3182 / 3181 |
| enqueues refused (full) | 1221 | 1221 | 1240 |
| dequeues refused (empty) | 1120 | 1120 | 1088 |
| lost entries | 0 | 0 | 0 |
| out-of-order entries | 0 | 0 | 0 |
| false-empty reports | 0 | 0 | 0 |
| Result | PASS | PASS | PASS |
3183 wraps on each side is the number that matters. The mechanism is the wrap: every property in section 5 holds trivially on a ring that never gets to the end.
11. Mutation Testing
| # | Mutation | Verilog | SysVer | VHDL |
|---|---|---|---|---|
| G1 | the producer's cycle state is not inverted on wrap | 399998 | 399998 | 399796 |
| G4 | full and empty are swapped | 362078 | 362078 | 362182 |
| G6 | the payload is written but ownership is not transferred | 360915 | 360915 | 360712 |
| G5 | the cycle bit is written with the consumer's state | 358426 | 358426 | 358224 |
| G2 | the consumer's cycle state is not inverted on wrap | 324195 | 324195 | 324667 |
| G3 | full and empty are distinguished by the pointers alone | 299882 | 299882 | 301258 |
| G7 | the enqueue pointer wraps one entry early | 298097 | 298097 | 298195 |
| — | unmutated baseline | 0 | 0 | 0 |
All seven die in all three languages, and G1 — the one line the entire mechanism hinges on — scores highest.
G3 is the classic ring-buffer bug and it is worth naming as such: distinguishing full from empty by comparing pointers alone. A full ring reads as empty, the consumer stops, the producer sees space it does not have, and entries are overwritten before they are read. It is the bug that the cycle-state pair exists to prevent, and it scores 299,882.
Directed against random
| # | V all | V directed | V random | VHDL all | VHDL directed | VHDL random |
|---|---|---|---|---|---|---|
| G1 | 399998 | 3854 | 396144 | 399796 | 3854 | 395942 |
| G2 | 324195 | 2769 | 321426 | 324667 | 2769 | 321898 |
| G3 | 299882 | 2248 | 297634 | 301258 | 2248 | 299010 |
| G4 | 362078 | 5570 | 356508 | 362182 | 5570 | 356612 |
| G5 | 358426 | 2282 | 356144 | 358224 | 2282 | 355942 |
| G6 | 360915 | 4681 | 356234 | 360712 | 4681 | 356031 |
| G7 | 298097 | 2715 | 295382 | 298195 | 2715 | 295480 |
| — | BASE 0 | 0 | 0 | 0 | 0 | 0 |
Every directed column identical, and every one comfortably in the thousands.
12. The BASE Row of the Directed-Only Run Read 1
This is the most useful single number this chapter produced, and it is the one that would have been easiest to skip.
Every mutation column looked healthy. The full-run baseline was 0. But the directed-only baseline — the unmutated design, with the random phase compiled out — reported 1 error:
steps=471 checks=7027 reach=59/72 errors=0
FAIL: exhaustive sweep incomplete
FAIL: 1 errors in 7027 checks
59 of 72. The "exhaustive" sweep was quietly depending
on the RANDOM phase to finish it.The gap was specific: the directed phases only ever reached the ring's empty and full states at dequeue pointer 0, because every lap started and ended there. All 13 missing points were (occupancy 0, pointer ≠ 0) and (occupancy RING_N, pointer ≠ 0).
The fix is phase 2b: fill k and drain k, for each k, which leaves the ring empty with the pointer at k; then fill and drain a whole lap from there. 72/72 by directed stimulus alone, and the directed-only baseline is now 0.
13. Two Findings Between the Languages
The consumer's gate was not the same in all three. The Verilog and SystemVerilog consumers gate on the per-entry cycle bit (cons_owns_deq); the first VHDL gated on the pointer/cycle-state comparison (not is_empty). On a correct design those are equivalent — that equivalence is property 7 — so all three passed.
Under mutation G5 they diverged completely: 40,184 in VHDL against 358,120 elsewhere, because a VHDL consumer reading the pointer comparison is immune to a defect in the cycle bits.
Verilog/SV: if (cons_owns_deq) <- the cycle bit
VHDL: if not is_empty <- the pointers
Equivalent when correct. NOT equivalent when the cycle
bits are wrong -- which is the only interesting case.The faithful mechanism is the cycle bit: that is what an xHCI controller actually tests. Aligning the VHDL brought G5 to 358,224 and the three columns to within 0.5% across the whole table.
G6 was semantically inert in its first form. It inserted cyc[enq_r] <= cyc[enq_r]; before the original cyc[enq_r] <= pcs_r; in the same non-blocking block — so the later assignment won and the mutation did nothing. It scored a clean 0 and looked entirely plausible in the diff.
Restating it as "mark the entry as NOT ready" (cyc <= ~pcs_r) says the same thing about a real defect — the payload is written but ownership is never transferred, which is the missing release store in a software producer — and it is a mutation that actually applies. 0 → 360,915.
14. Follow-Ups the Interviewer Will Ask
"Why a ring instead of a linked list?" Sequential, prefetchable memory access instead of a pointer chase, and a fixed allocation. EHCI's linked lists were the main reason its DMA pattern was hostile to caches.
"What is the doorbell for, if the cycle bit is sufficient?" Performance. Without it the controller must poll the ring to notice new work; the doorbell tells it to look now. Correctness does not depend on it — which is why a missed doorbell shows up as latency rather than as a lost transfer.
"How does software know a transfer completed?" An event on the event ring, which uses the same cycle-bit mechanism in the opposite direction: the controller produces, software consumes. One mechanism, both directions.
"What is a Link TRB?" The entry at the end of the ring that points back to the start, so a ring can be larger than one contiguous allocation. It carries a Toggle Cycle flag, and that flag is what tells the consumer to invert its cycle state — the wrap in this design is the hardware equivalent.
"How are multiple speeds handled in one controller?" Uniformly. The speed lives in the device context; the rings, the scheduler and the DMA path are identical. This is the difference from EHCI's companion controllers, and it is xHCI's main architectural claim.
"What is a Device Context and who writes it?" A memory structure describing one device and its endpoints, allocated by software and written by both sides — software configures it, hardware updates the dequeue pointer in it. It is the one structure with two writers, and the field-level ownership rules matter.
"How many transfer rings are there?" One per endpoint per device, plus one command ring and at least one event ring. A 32-endpoint device with two interrupters has 34 rings.
15. UVM: Checking a Lock-Free Protocol
// A ring shared with no lock has exactly one interesting failure mode, and
// it is not a wrong value: it is an entry that BOTH sides believe they own,
// or NEITHER does. So this scoreboard does not compare data first -- it
// reconstructs ownership independently and checks it is exclusive.
class trb_txn extends uvm_sequence_item;
`uvm_object_utils(trb_txn)
rand bit is_produce;
rand bit [15:0] payload;
rand bit wrap_now; // this access lands on the last entry
function new(string name = "trb_txn"); super.new(name); endfunction
// A ring that never wraps cannot distinguish a correct cycle-state
// inversion from no inversion at all, so wrapping is forced often.
constraint c_wrap_often { wrap_now dist { 1 := 30, 0 := 70 }; }
endclass
class ring_scoreboard extends uvm_scoreboard;
`uvm_component_utils(ring_scoreboard)
uvm_analysis_imp #(trb_txn, ring_scoreboard) ap;
localparam int RING_N = 8;
// ---- the model: a COUNTER and a queue ----
//
// Deliberately the mechanism the design does NOT have. The design decides
// full and empty from one bit plus two cycle states; this counts. A
// counter cannot forget to invert on wrap, so the two disagree the instant
// the cycle arithmetic is wrong.
bit [15:0] q [$];
int occupancy;
int unsigned n_enq, n_deq, n_full_reject, n_empty_read;
int unsigned n_prod_wrap, n_cons_wrap;
int unsigned n_lost, n_double_owned;
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
// Reconstructed independently from the model, NOT read from the DUT.
function bit model_full(); return occupancy == RING_N; endfunction
function bit model_empty(); return occupancy == 0; endfunction
function void write(trb_txn t);
if (t.is_produce) begin
if (model_full()) begin
n_full_reject++;
end else begin
q.push_back(t.payload);
occupancy++;
n_enq++;
if (t.wrap_now) n_prod_wrap++;
end
end else begin
if (model_empty()) begin
n_empty_read++;
end else begin
void'(q.pop_front());
occupancy--;
n_deq++;
if (t.wrap_now) n_cons_wrap++;
end
end
// ---- the invariant that a lock-free ring lives or dies by ----
//
// Everything accepted is either still in the ring or has come out.
// Nothing is in both places and nothing is in neither. This is the
// check a missing cycle-state inversion shows up in: the entries are
// still there and the consumer has decided they belong to somebody else.
if ((n_enq - n_deq) != occupancy) begin
n_lost++;
`uvm_error("RING/LOST",
$sformatf("%0d enqueued, %0d dequeued, %0d in the ring: %0d entries are unaccounted for",
n_enq, n_deq, occupancy, (n_enq - n_deq) - occupancy))
end
if (model_full() && model_empty()) begin
n_double_owned++;
`uvm_error("RING/OWN",
"the ring reports both full and empty: ownership is not exclusive")
end
endfunction
// Called by the monitor with the DUT's own view, so the two independent
// derivations of full and empty can be compared. Property 7 in the
// chapter: a single-bit ownership test and a pointer/cycle comparison are
// the same claim by two different routes, and a broken inversion breaks
// exactly one of them.
function void check_flags(bit dut_full, bit dut_empty,
bit dut_entry_ready);
if (dut_full !== model_full())
`uvm_error("RING/FULL",
$sformatf("DUT says full=%0b, occupancy is %0d of %0d",
dut_full, occupancy, RING_N))
if (dut_empty !== model_empty())
`uvm_error("RING/EMPTY",
$sformatf("DUT says empty=%0b, occupancy is %0d", dut_empty, occupancy))
// The two formulations must agree with each other, not merely with me.
if (dut_entry_ready === dut_empty)
`uvm_error("RING/OWN",
"the per-entry ownership test and the pointer/cycle test disagree: one of the cycle states is not being inverted")
endfunction
function void report_phase(uvm_phase phase);
super.report_phase(phase);
`uvm_info("RING",
$sformatf("%0d enq, %0d deq | %0d full rejects, %0d empty reads | %0d/%0d wraps",
n_enq, n_deq, n_full_reject, n_empty_read,
n_prod_wrap, n_cons_wrap), UVM_LOW)
// The mechanism IS the wrap. Every property holds trivially on a ring
// that never reaches the end, so a run that never wrapped has tested
// nothing that distinguishes this design from a plain FIFO.
if (n_prod_wrap == 0 || n_cons_wrap == 0)
`uvm_error("RING/COV",
"the ring never wrapped on one side or the other: the cycle-state inversion was never exercised")
if (n_full_reject == 0)
`uvm_error("RING/COV",
"the ring was never full: full-versus-empty was never distinguished")
if (n_empty_read == 0)
`uvm_error("RING/COV",
"the ring was never read while empty")
endfunction
endclass16. Common Misconceptions
"The doorbell is how the controller learns about work." It is an optimisation. The cycle bit is what carries the information; the doorbell saves polling.
"Rings need a lock." They need one bit per entry and two cycle states. That is the whole synchronisation.
"The cycle bit says which entry is next." It says who owns each entry. The pointers say which is next.
"The cycle bit tells the producer when the ring is full." It cannot — the consumer never writes anything. Full comes from the pointers plus the cycle-state pair.
"head == tail means empty." Only if you also compare cycle states. Otherwise full and empty are indistinguishable — mutation G3, scoring 299,882.
"A ring wastes one entry to tell full from empty." A classic one does. This mechanism does not, and that is the point.
"Events come back on the transfer ring." They come back on the event ring, which runs in the opposite direction.
"xHCI needs a companion controller for full speed." That was EHCI. One xHCI controller handles every speed, and removing the companion was a main design goal.
"A Link TRB is just a pointer." It also carries the Toggle Cycle flag, which is what tells the consumer to invert its cycle state.
17. Exercises
1. Draw the xHCI data structures from memory, in the order this chapter recommends, and mark which side produces each ring.
2. Prove that enq == deq with PCS == CCS means empty and with PCS != CCS means full, given that both invert on wrap. State the assumption your proof needs about how far ahead the producer can get.
3. G1 removes the producer's inversion. Trace a ring of 4 entries through 12 enqueues and 12 dequeues and give the exact cycle at which the consumer stops forever.
4. The cycle parity is determined by the occupancy. Prove it, and say what that implies for any coverage model of a ring buffer.
5. The directed-only BASE row read 1 error at 59/72 reach. Explain what that measures that a full-run coverage figure does not, and design the smallest phase that closes a gap of that shape.
6. The VHDL consumer gated on the pointer test and was immune to G5. Give another pair of provably-equivalent formulations in a design you know, and say which one you would implement.
7. Add a Link TRB with a Toggle Cycle flag, so the ring can span two allocations. Which of the seven properties change, and what new one is needed?
18. Summary
| Idea | Why it matters |
|---|---|
| Draw the data structures first | xHCI is a data-structure design |
| The event ring runs the other way | one mechanism, both directions |
| Ownership lives in the entry, not a lock | one bit, and the producer sets it last |
| Both cycle states invert on wrap | the one line the mechanism hinges on |
| The cycle bit cannot say full | the consumer never writes anything |
enq == deq plus cycle-state equality | separates full from empty with no counter |
All RING_N entries usable | a classic ring wastes one or adds a counter |
| Two formulations, checked against each other | a broken inversion breaks exactly one |
| A ring that never wraps tests nothing | every property holds trivially before the end |
| The doorbell is an optimisation | a missed one costs latency, not data |
| One controller, every speed | removing the companion controller was the goal |
| Cycle parity is determined by occupancy | so 144 coverage points include 72 that cannot exist |
| Check the directed-only BASE row | coverage that needs the random phase is not coverage |
| A mutation before a later write is inert | G6 scored a clean 0 while looking plausible |
| Pick the formulation the spec uses | the other one becomes a check |
| 72 states, 6 laps, 7 mutations | 0 lost entries in 594,948 checks |
Tooling
| Step | Command |
|---|---|
| Verilog-2005 | iverilog -g2005 -o xr_v.out xr_v.v xr_v_tb.v && ./xr_v.out |
| SystemVerilog | iverilog -g2012 -o xr_sv.out xr_sv.sv xr_sv_tb.sv && ./xr_sv.out |
| VHDL-2008 analyse | nvc --std=2008 -a xr_vhdl.vhd xr_vhdl_tb.vhd |
| VHDL-2008 elaborate | nvc --std=2008 -e tb_xr_vhdl |
| VHDL-2008 run | nvc --std=2008 -r tb_xr_vhdl |
| One mutation | iverilog -g2005 -DMUT_G1 -o mm xr_v_mut.v xr_v_tb.v && ./mm |
| Directed only (Verilog) | iverilog -g2005 -DDIRECTED_ONLY -o mm xr_v_mut.v xr_v_tb.v && ./mm |
| Directed only (VHDL) | nvc --std=2008 -e -gDIRECTED_ONLY=true tb_xr_vhdl |
All three implementations pass with 0 errors: all 72 reachable (occupancy × dequeue pointer) states, reached by directed stimulus alone; six complete laps of the ring with 3183 wraps on each side; steady-occupancy sweeps that hold the two cycle states unequal for half the run; zero lost entries, zero out-of-order entries and zero false-empty reports in 594,948 checks; and every one of the seven mutations killed by directed stimulus alone, with all seven directed scores identical across languages.
Chapter 27.8 — Senior Verification Strategy is the second senior question and the one most likely to be asked of anybody applying to a verification team: design a UVM environment for a USB device controller. The answer that gets hired is not a list of components — it is a checker that fails when the stimulus was too weak to prove anything.
Continue learning
Related tutorials
- Related topic
xHCI Overview
xHCI shares a ring between software and hardware using one Cycle bit per TRB and no pointer exchange at all — and an unowned entry holds the previous lap's complete, plausible descriptor.
- Related topic
Host Resource Management
Software cannot edit a context hardware owns, so xHCI has two of them — and inside a Configure Endpoint command the Drop flags are applied before the Add flags, making drop+add an atomic re-initialisation.
- Related topic
USB Controllers on SoC
DWC2, MUSB and xHCI differ in a hundred mechanical ways that do not matter and one architectural way that does — whether endpoints own their packet buffers or share a pool.
- Related topic
What Is USB?
The opening interview question answered with one load-bearing idea instead of a list — USB is host-scheduled, and polling, NAK, the frame and the missing interrupt line are all consequences of it.
Standards & specifications
- Governing standard
- USB-IF (Universal Serial Bus Specification)(opens USB Implementers Forum (USB-IF) in a new tab)
Defines the USB bus — its electrical signalling, connectors, packet and transaction model, device framework and the descriptors a device must expose — together with the device-class specifications layered on it. It does not define host-controller register interfaces (xHCI and EHCI are separate documents) nor any operating system's driver architecture.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the USB curriculum.
