USB · Module 21
FIFO Architecture
An endpoint FIFO stores packets, not bytes — a zero-length packet carries nothing and must still occupy a buffer, because it is the only thing that terminates a transfer ending on a packet boundary.
Chapter 21.1 ended with a signal called fifo_write_en. This chapter builds what it writes into, and the design turns on a distinction that sounds pedantic right up to the moment it hangs a transfer.
1. Firmware Wants Bytes. The Bus Delivers Packets.
The obvious endpoint buffer is a byte FIFO. Bytes go in from the bus, bytes come out to firmware, occupancy is a byte count. Every FIFO tutorial ever written describes this FIFO, and it is the wrong one.
The boundaries between USB packets carry information that the bytes do not.
A USB transfer — "send me these 1024 bytes" — is not one packet. It is a sequence of packets, each at most wMaxPacketSize bytes, and there is no length field anywhere in the protocol saying how many there will be. So how does the receiving end know when a transfer has finished?
Which raises the question this chapter is really about: what if the data ends exactly on a packet boundary?
A 1024-byte transfer on a 64-byte endpoint is sixteen full packets and nothing left over. There is no short packet to send. The host has received 1024 bytes and has no idea whether more are coming.
2. The Zero-Length Packet
The answer is to send a seventeenth packet containing no bytes at all.
1024 bytes on a 64-byte endpoint:
[64][64][64][64][64][64][64][64]
[64][64][64][64][64][64][64][64] <- sixteen full packets
[ ] <- and a ZERO-LENGTH PACKET
The ZLP carries nothing. It is not an optimisation, an idle
marker, or a formality. It is the only thing that tells the
host the transfer is over.A ZLP is a real packet. It has a token, a PID with a data toggle, a CRC over an empty payload, and it is ACKed like any other. The only thing it lacks is bytes.
And that is exactly what a byte FIFO cannot represent. Writing zero bytes into a byte FIFO is indistinguishable from writing nothing:
| byte FIFO | packet FIFO | |
|---|---|---|
| store a 64-byte packet | occupancy += 64 | occupancy += 1 |
| store a ZLP | occupancy += 0 — nothing happened | occupancy += 1 |
| can firmware tell? | no | yes |
So: a ZLP must occupy a buffer. The FIFO stores packets and their lengths, and zero is a perfectly good length.
3. Why Two Buffers
The second structural decision. A USB transaction must be answered immediately — the device has roughly a bit-time to decide between ACK and NAK (Chapter 21.1), so the answer is "is there a free buffer right now?"
With one buffer, firmware has to drain a packet before the next one arrives. It cannot: the bus does not wait for a CPU. So every second transaction is NAKed, the host retries, and the endpoint runs at a fraction of its rate.
With two, the bus fills one while firmware drains the other:
Ping-pong: two buffers, and the property that makes them worth having
The property that makes ping-pong work is that a write and a read in the SAME CYCLE must both succeed and leave the occupancy unchanged. It is easy to write a two-buffer FIFO that quietly serialises them, and the result has twice the area and none of the benefit — mutation F3 in §11 measures exactly that.
4. Two Buffers Is a Ring, Not a Pair
The third trap. It is tempting to think of ping-pong as "alternate between A and B", with one bit that flips each time. That works until something interrupts the rhythm.
Two buffers used in order is a 2-deep ring, and a ring needs a read pointer that is genuinely independent of the write pointer. Packets must come out in the order they went in. Consider a flush — an endpoint reset, or a CLEAR_FEATURE — arriving when the read pointer is at B and the write pointer is at A:
| write pointer | read pointer | occupancy | |
|---|---|---|---|
| before flush | A | B | 1 |
| flush resets only the count | A | B | 0 |
| bus writes the next packet | into A | still B | 1 |
| firmware reads | — | B — the wrong buffer |
Firmware reads a stale buffer holding a packet from before the reset. So a flush must reset both pointers, not just the occupancy. That is mutation F2.
5. What We Are Building
usb_ep_pingpong
bus side firmware side
-------- -------------
wr_commit rd_ack
wr_len [6:0] ZERO LEGAL rd_valid
wr_ready rd_len [6:0]
nak_needed rd_zlp
rd_short
flush rd_class NONE / ZLP / SHORT / FULL
mps [6:0] used [1:0]
n_written / n_read / n_zlp / n_short / n_nak / n_flushrd_class deserves a note. It is a single named value carrying the same three facts as rd_valid, rd_zlp and rd_short — and it exists because PKT_NONE and PKT_ZLP are the two things a byte FIFO cannot tell apart. Giving them distinct names in the type system puts the distinction somewhere a reader cannot skip past:
rd_class | meaning | terminates the transfer? |
|---|---|---|
PKT_NONE | the FIFO is empty; there is no packet | — there is nothing to ask about |
PKT_ZLP | a packet of zero bytes | yes |
PKT_SHORT | fewer than mps bytes | yes |
PKT_FULL | exactly mps bytes | no — more is coming |
6. Verilog-2005 Implementation
// usb_ep_pingpong -- the endpoint FIFO, and the reason it cannot be a byte
// FIFO no matter how convenient that would be.
//
// A PACKET FIFO, NOT A BYTE FIFO
//
// Firmware wants a stream of bytes. The bus delivers a stream of PACKETS, and
// the boundaries between them carry meaning that the bytes do not:
//
// * a packet SHORTER than the endpoint's max packet size TERMINATES the
// current transfer. That is how a device says "this is all of it" without
// any length field anywhere.
//
// * a ZERO-LENGTH PACKET is that same signal when the data happened to end
// exactly on a packet boundary. It carries no bytes and it is not
// optional -- without it the host waits for more data for ever.
//
// So a ZLP is a REAL PACKET and it MUST OCCUPY A BUFFER. A FIFO that stores
// bytes cannot represent it at all: zero bytes written is indistinguishable
// from nothing written. This is the single most common way a first device
// controller hangs, and it hangs only on transfers whose length happens to be
// an exact multiple of the max packet size -- which on a 64-byte endpoint is
// one transfer in sixty-four, and never the one on the bench.
//
// WHY TWO BUFFERS
//
// A transaction must be answered immediately: ACK if the data was taken, NAK
// if there was no room. With ONE buffer, firmware must drain a packet before
// the next one arrives, and it cannot -- so every second transaction is NAKed
// and the endpoint runs at half rate or worse.
//
// With TWO, the bus can write one buffer while firmware reads the other:
//
// bus -> [ buffer A ] -> firmware both happening
// bus -> [ buffer B ] -> firmware in the same cycle
//
// That is the whole of "ping-pong", and the property that makes it work is
// that a write and a read in the SAME CYCLE must both succeed and leave the
// occupancy unchanged. A design that blocks the write while a read is in
// progress has two buffers and the throughput of one.
//
// THE ORDERING TRAP
//
// Two buffers used alternately is a 2-deep RING, not a pair of independent
// slots. Packets must come out in the order they went in, which needs a read
// pointer that is genuinely separate from the write pointer. "Alternate each
// time" works until a flush happens at an odd moment, and then firmware reads
// buffer B while the bus is filling buffer A.
module usb_ep_pingpong #(
parameter LEN_W = 7 // 0 .. 64 needs 7 bits
) (
input wire clk,
input wire rst_n,
input wire flush, // endpoint reset / CLEAR_FEATURE
input wire [LEN_W-1:0] mps, // this endpoint's max packet size
input wire wr_commit, // the bus has a complete packet
input wire [LEN_W-1:0] wr_len, // its length -- ZERO IS LEGAL
input wire rd_ack, // firmware has consumed rd_len bytes
output wire wr_ready, // a buffer is free: the bus may ACK
output wire rd_valid, // a packet is waiting for firmware
output wire [LEN_W-1:0] rd_len,
output wire rd_zlp, // that packet is zero-length
output wire rd_short, // and/or shorter than mps: ends the
// transfer
output wire [1:0] rd_class, // the same fact, as one named value
output wire [1:0] used,
output wire nak_needed, // a packet arriving now must be NAKed
output reg [31:0] n_written,
output reg [31:0] n_read,
output reg [31:0] n_zlp,
output reg [31:0] n_short,
output reg [31:0] n_nak,
output reg [31:0] n_flush
);
reg [LEN_W-1:0] len_mem [0:1];
reg wr_ptr;
reg rd_ptr;
reg [1:0] count;
// Occupancy, not "which buffer" -- the two are different questions and
// conflating them is the ordering trap above.
assign wr_ready = (count != 2'd2);
assign rd_valid = (count != 2'd0);
assign used = count;
assign nak_needed = !wr_ready;
assign rd_len = len_mem[rd_ptr];
// A ZLP is only a ZLP if there IS a packet. An empty FIFO has no length,
// and reporting zero-length-packet on an empty FIFO invents a terminator.
assign rd_zlp = rd_valid && (rd_len == {LEN_W{1'b0}});
// STRICTLY less than mps. A packet of exactly mps bytes is a FULL packet
// and the transfer continues; off-by-one here truncates every transfer
// whose length is an exact multiple of the packet size.
assign rd_short = rd_valid && (rd_len < mps);
// The same three facts as one value. PKT_ZLP is a distinct class from
// PKT_NONE precisely because a byte-oriented FIFO cannot tell them apart,
// and that confusion is what hangs a transfer.
localparam [1:0] PKT_NONE = 2'd0, // the FIFO is empty: no packet at all
PKT_ZLP = 2'd1, // zero bytes -- a real packet
PKT_SHORT = 2'd2, // below mps -- also a terminator
PKT_FULL = 2'd3; // exactly mps -- the transfer CONTINUES
assign rd_class = !rd_valid ? PKT_NONE
: (rd_len == {LEN_W{1'b0}}) ? PKT_ZLP
: (rd_len < mps) ? PKT_SHORT
: PKT_FULL;
// A write and a read in the same cycle must BOTH happen. That is the entire
// reason for the second buffer.
wire do_wr = wr_commit && wr_ready;
wire do_rd = rd_ack && rd_valid;
integer i;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr <= 1'b0;
rd_ptr <= 1'b0;
count <= 2'd0;
n_written <= 32'd0;
n_read <= 32'd0;
n_zlp <= 32'd0;
n_short <= 32'd0;
n_nak <= 32'd0;
n_flush <= 32'd0;
for (i = 0; i < 2; i = i + 1) len_mem[i] <= {LEN_W{1'b0}};
end else if (flush) begin
// BOTH pointers, not just the count. A flush that resets occupancy but
// leaves rd_ptr where it was makes firmware read the buffer the bus is
// about to write.
wr_ptr <= 1'b0;
rd_ptr <= 1'b0;
count <= 2'd0;
n_flush <= n_flush + 32'd1;
end else begin
if (do_wr) begin
len_mem[wr_ptr] <= wr_len;
wr_ptr <= ~wr_ptr;
n_written <= n_written + 32'd1;
// A zero-length packet is counted as a packet, because it is one.
if (wr_len == {LEN_W{1'b0}}) n_zlp <= n_zlp + 32'd1;
if (wr_len < mps) n_short <= n_short + 32'd1;
end
if (do_rd) begin
rd_ptr <= ~rd_ptr;
n_read <= n_read + 32'd1;
end
// Occupancy moves only when exactly one side acts. Both acting leaves
// it where it was, which is what lets the endpoint run at full rate.
case ({do_wr, do_rd})
2'b10: count <= count + 2'd1;
2'b01: count <= count - 2'd1;
default: count <= count;
endcase
// A packet that arrived with no room is a NAK the endpoint had to send.
if (wr_commit && !wr_ready) n_nak <= n_nak + 32'd1;
end
end
endmoduleThree lines carry most of the risk in that listing.
rd_zlp = rd_valid && (rd_len == 0) — the rd_valid term is not defensive padding. An empty FIFO has rd_len reading whatever is in the buffer the read pointer happens to address, and without the guard an empty FIFO announces a zero-length packet, which is a fabricated transfer terminator. Mutation F6.
rd_short = rd_valid && (rd_len < mps) — strictly less than. A packet of exactly mps bytes is full and the transfer continues. Change < to <= and every transfer whose length is a multiple of the packet size ends one packet early. Mutation F4, and note that it is the mirror image of the ZLP bug: one truncates transfers, the other never ends them.
case ({do_wr, do_rd}) with 2'b11 falling into default: count <= count. That default is doing real work: it is the simultaneity property from §3, and writing it as two independent if statements is how it gets lost.
7. SystemVerilog Implementation
// usb_ep_pingpong -- the endpoint FIFO, and the reason it cannot be a byte
// FIFO no matter how convenient that would be.
//
// A PACKET FIFO, NOT A BYTE FIFO
//
// Firmware wants a stream of bytes. The bus delivers a stream of PACKETS, and
// the boundaries between them carry meaning that the bytes do not:
//
// * a packet SHORTER than the endpoint's max packet size TERMINATES the
// current transfer. That is how a device says "this is all of it" without
// any length field anywhere.
//
// * a ZERO-LENGTH PACKET is that same signal when the data happened to end
// exactly on a packet boundary. It carries no bytes and it is not
// optional -- without it the host waits for more data for ever.
//
// So a ZLP is a REAL PACKET and it MUST OCCUPY A BUFFER. A FIFO that stores
// bytes cannot represent it at all: zero bytes written is indistinguishable
// from nothing written. This is the single most common way a first device
// controller hangs, and it hangs only on transfers whose length happens to be
// an exact multiple of the max packet size -- which on a 64-byte endpoint is
// one transfer in sixty-four, and never the one on the bench.
//
// WHY TWO BUFFERS
//
// A transaction must be answered immediately: ACK if the data was taken, NAK
// if there was no room. With ONE buffer, firmware must drain a packet before
// the next one arrives, and it cannot -- so every second transaction is NAKed
// and the endpoint runs at half rate or worse.
//
// With TWO, the bus can write one buffer while firmware reads the other:
//
// bus -> [ buffer A ] -> firmware both happening
// bus -> [ buffer B ] -> firmware in the same cycle
//
// That is the whole of "ping-pong", and the property that makes it work is
// that a write and a read in the SAME CYCLE must both succeed and leave the
// occupancy unchanged. A design that blocks the write while a read is in
// progress has two buffers and the throughput of one.
//
// THE ORDERING TRAP
//
// Two buffers used alternately is a 2-deep RING, not a pair of independent
// slots. Packets must come out in the order they went in, which needs a read
// pointer that is genuinely separate from the write pointer. "Alternate each
// time" works until a flush happens at an odd moment, and then firmware reads
// buffer B while the bus is filling buffer A.
package usb_pingpong_pkg;
// What a packet MEANS to the transfer it belongs to. Naming these is not
// decoration: PKT_ZLP and PKT_SHORT both terminate a transfer and PKT_FULL
// does not, and that is the distinction the whole block exists to preserve.
typedef enum logic [1:0] {
PKT_NONE = 2'd0, // the FIFO is empty; there is no packet to describe
PKT_ZLP = 2'd1, // zero bytes -- a real packet, and a terminator
PKT_SHORT = 2'd2, // fewer than mps bytes -- also a terminator
PKT_FULL = 2'd3 // exactly mps bytes -- the transfer CONTINUES
} pkt_class_e;
endpackage
module usb_ep_pingpong
import usb_pingpong_pkg::*;
#(
parameter int LEN_W = 7 // 0 .. 64 needs 7 bits
) (
input logic clk,
input logic rst_n,
input logic flush, // endpoint reset / CLEAR_FEATURE
input logic [LEN_W-1:0] mps, // this endpoint's max packet size
input logic wr_commit, // the bus has a complete packet
input logic [LEN_W-1:0] wr_len, // its length -- ZERO IS LEGAL
input logic rd_ack, // firmware has consumed rd_len bytes
output logic wr_ready, // a buffer is free: the bus may ACK
output logic rd_valid, // a packet is waiting for firmware
output logic [LEN_W-1:0] rd_len,
output logic rd_zlp, // that packet is zero-length
output logic rd_short, // and/or shorter than mps: ends the
// transfer
output pkt_class_e rd_class, // the same fact, named
output logic [1:0] used,
output logic nak_needed,// a packet arriving now must be NAKed
output logic [31:0] n_written,
output logic [31:0] n_read,
output logic [31:0] n_zlp,
output logic [31:0] n_short,
output logic [31:0] n_nak,
output logic [31:0] n_flush
);
logic [LEN_W-1:0] len_mem [0:1];
logic wr_ptr;
logic rd_ptr;
logic [1:0] count;
// Occupancy, not "which buffer" -- the two are different questions and
// conflating them is the ordering trap above.
assign wr_ready = (count != 2'd2);
assign rd_valid = (count != 2'd0);
assign used = count;
assign nak_needed = !wr_ready;
assign rd_len = len_mem[rd_ptr];
// A ZLP is only a ZLP if there IS a packet. An empty FIFO has no length,
// and reporting zero-length-packet on an empty FIFO invents a terminator.
assign rd_zlp = rd_valid && (rd_len == '0);
// STRICTLY less than mps. A packet of exactly mps bytes is a FULL packet
// and the transfer continues; off-by-one here truncates every transfer
// whose length is an exact multiple of the packet size.
assign rd_short = rd_valid && (rd_len < mps);
// The same three facts as one named value. A case statement over
// pkt_class_e must handle PKT_ZLP explicitly, which is precisely the case a
// byte-oriented FIFO cannot represent and a careless reader forgets.
always_comb begin
if (!rd_valid) rd_class = PKT_NONE;
else if (rd_len == '0) rd_class = PKT_ZLP;
else if (rd_len < mps) rd_class = PKT_SHORT;
else rd_class = PKT_FULL;
end
// A write and a read in the same cycle must BOTH happen. That is the entire
// reason for the second buffer.
logic do_wr, do_rd;
assign do_wr = wr_commit && wr_ready;
assign do_rd = rd_ack && rd_valid;
int i;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr <= 1'b0;
rd_ptr <= 1'b0;
count <= 2'd0;
n_written <= '0;
n_read <= '0;
n_zlp <= '0;
n_short <= '0;
n_nak <= '0;
n_flush <= '0;
for (i = 0; i < 2; i++) len_mem[i] <= '0;
end else if (flush) begin
// BOTH pointers, not just the count. A flush that resets occupancy but
// leaves rd_ptr where it was makes firmware read the buffer the bus is
// about to write.
wr_ptr <= 1'b0;
rd_ptr <= 1'b0;
count <= 2'd0;
n_flush <= n_flush + 1;
end else begin
if (do_wr) begin
len_mem[wr_ptr] <= wr_len;
wr_ptr <= ~wr_ptr;
n_written <= n_written + 1;
// A zero-length packet is counted as a packet, because it is one.
if (wr_len == '0) n_zlp <= n_zlp + 1;
if (wr_len < mps) n_short <= n_short + 1;
end
if (do_rd) begin
rd_ptr <= ~rd_ptr;
n_read <= n_read + 1;
end
// Occupancy moves only when exactly one side acts. Both acting leaves
// it where it was, which is what lets the endpoint run at full rate.
case ({do_wr, do_rd})
2'b10: count <= count + 2'd1;
2'b01: count <= count - 2'd1;
default: count <= count;
endcase
// A packet that arrived with no room is a NAK the endpoint had to send.
if (wr_commit && !wr_ready) n_nak <= n_nak + 1;
end
end
endmodule8. VHDL-2008 Implementation
-- usb_ep_pingpong -- the endpoint FIFO, and the reason it cannot be a byte
-- FIFO no matter how convenient that would be.
--
-- A PACKET FIFO, NOT A BYTE FIFO
--
-- Firmware wants a stream of bytes. The bus delivers a stream of PACKETS, and
-- the boundaries between them carry meaning the bytes do not:
--
-- * a packet SHORTER than the endpoint's max packet size TERMINATES the
-- current transfer. That is how a device says "this is all of it" with no
-- length field anywhere.
--
-- * a ZERO-LENGTH PACKET is that same signal when the data happened to end
-- exactly on a packet boundary. It carries no bytes and it is not
-- optional -- without it the host waits for more data for ever.
--
-- So a ZLP is a REAL PACKET and it MUST OCCUPY A BUFFER. A FIFO that stores
-- bytes cannot represent it at all: zero bytes written is indistinguishable
-- from nothing written. This is the single most common way a first device
-- controller hangs, and it hangs only on transfers whose length is an exact
-- multiple of the max packet size -- one in sixty-four on a 64-byte endpoint,
-- and never the one on the bench.
--
-- WHY TWO BUFFERS
--
-- A transaction must be answered immediately: ACK if the data was taken, NAK
-- if there was no room. With ONE buffer, firmware must drain a packet before
-- the next arrives and it cannot, so every second transaction is NAKed.
--
-- With TWO, the bus writes one buffer while firmware reads the other, and the
-- property that makes it work is that a write and a read in the SAME CYCLE
-- must both succeed and leave occupancy unchanged. A design that blocks the
-- write while a read is in progress has two buffers and the throughput of one.
--
-- THE ORDERING TRAP
--
-- Two buffers used alternately is a 2-deep RING, not a pair of independent
-- slots. Packets must come out in the order they went in, which needs a read
-- pointer genuinely separate from the write pointer. "Alternate each time"
-- works until a flush happens at an odd moment, and then firmware reads the
-- buffer the bus is filling.
--
-- VHDL names the packet classification as an enumerated type, which is what
-- makes PKT_NONE and PKT_ZLP impossible to confuse: a case statement over
-- pkt_class_t must handle both, and "empty" and "a packet of zero bytes" are
-- exactly the two things a byte FIFO cannot tell apart.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
package usb_pingpong_pkg is
type pkt_class_t is (
PKT_NONE, -- the FIFO is empty; there is no packet to describe
PKT_ZLP, -- zero bytes -- a real packet, and a terminator
PKT_SHORT, -- fewer than mps bytes -- also a terminator
PKT_FULL -- exactly mps bytes -- the transfer CONTINUES
);
function class_code(c : pkt_class_t) return std_logic_vector;
end package;
package body usb_pingpong_pkg is
function class_code(c : pkt_class_t) return std_logic_vector is
begin
return std_logic_vector(to_unsigned(pkt_class_t'pos(c), 2));
end function;
end package body;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.usb_pingpong_pkg.all;
entity usb_ep_pingpong is
generic (
LEN_W : natural := 7 -- 0 .. 64 needs 7 bits
);
port (
clk : in std_logic;
rst_n : in std_logic;
flush : in std_logic; -- endpoint reset / CLEAR_FEATURE
mps : in std_logic_vector(LEN_W-1 downto 0);
wr_commit : in std_logic; -- the bus has a complete packet
wr_len : in std_logic_vector(LEN_W-1 downto 0); -- ZERO IS LEGAL
rd_ack : in std_logic; -- firmware consumed rd_len bytes
wr_ready : out std_logic; -- a buffer is free: the bus may ACK
rd_valid : out std_logic; -- a packet is waiting for firmware
rd_len : out std_logic_vector(LEN_W-1 downto 0);
rd_zlp : out std_logic;
rd_short : out std_logic;
rd_class : out std_logic_vector(1 downto 0);
used : out std_logic_vector(1 downto 0);
nak_needed : out std_logic;
n_written : out std_logic_vector(31 downto 0);
n_read : out std_logic_vector(31 downto 0);
n_zlp : out std_logic_vector(31 downto 0);
n_short : out std_logic_vector(31 downto 0);
n_nak : out std_logic_vector(31 downto 0);
n_flush : out std_logic_vector(31 downto 0)
);
end entity;
architecture rtl of usb_ep_pingpong is
type len_array_t is array (0 to 1) of unsigned(LEN_W-1 downto 0);
signal len_mem : len_array_t := (others => (others => '0'));
signal wr_ptr : integer range 0 to 1 := 0;
signal rd_ptr : integer range 0 to 1 := 0;
signal count : integer range 0 to 2 := 0;
signal wrdy, rval : std_logic;
-- Initialised so that the very first delta cycle, before reset has
-- propagated, does not compare an uninitialised vector and raise a
-- NUMERIC_STD metavalue warning.
signal cur_len : unsigned(LEN_W-1 downto 0) := (others => '0');
signal zlp_s, short_s : std_logic := '0';
signal cls : pkt_class_t;
signal do_wr, do_rd : std_logic;
signal wr_r, rd_r, zlp_r, short_r, nak_r, fl_r
: unsigned(31 downto 0) := (others => '0');
begin
-- Occupancy, not "which buffer" -- the two are different questions and
-- conflating them is the ordering trap above.
wrdy <= '1' when count /= 2 else '0';
rval <= '1' when count /= 0 else '0';
wr_ready <= wrdy;
rd_valid <= rval;
used <= std_logic_vector(to_unsigned(count, 2));
nak_needed <= not wrdy;
cur_len <= len_mem(rd_ptr);
rd_len <= std_logic_vector(cur_len);
-- A ZLP is only a ZLP if there IS a packet. An empty FIFO has no length,
-- and reporting zero-length-packet on an empty FIFO invents a terminator.
zlp_s <= '1' when (rval = '1' and cur_len = 0) else '0';
-- STRICTLY less than mps. A packet of exactly mps bytes is a FULL packet
-- and the transfer continues; off-by-one here truncates every transfer
-- whose length is an exact multiple of the packet size.
short_s <= '1' when (rval = '1' and cur_len < unsigned(mps)) else '0';
rd_zlp <= zlp_s;
rd_short <= short_s;
classify : process (rval, cur_len, mps)
begin
if rval = '0' then
cls <= PKT_NONE;
elsif cur_len = 0 then
cls <= PKT_ZLP;
elsif cur_len < unsigned(mps) then
cls <= PKT_SHORT;
else
cls <= PKT_FULL;
end if;
end process;
rd_class <= class_code(cls);
-- A write and a read in the same cycle must BOTH happen. That is the entire
-- reason for the second buffer.
do_wr <= wr_commit and wrdy;
do_rd <= rd_ack and rval;
regs : process (clk, rst_n)
begin
if rst_n = '0' then
wr_ptr <= 0;
rd_ptr <= 0;
count <= 0;
len_mem <= (others => (others => '0'));
wr_r <= (others => '0');
rd_r <= (others => '0');
zlp_r <= (others => '0');
short_r <= (others => '0');
nak_r <= (others => '0');
fl_r <= (others => '0');
elsif rising_edge(clk) then
if flush = '1' then
-- BOTH pointers, not just the count. A flush that resets occupancy
-- but leaves rd_ptr where it was makes firmware read the buffer the
-- bus is about to write.
wr_ptr <= 0;
rd_ptr <= 0;
count <= 0;
fl_r <= fl_r + 1;
else
if do_wr = '1' then
len_mem(wr_ptr) <= unsigned(wr_len);
wr_ptr <= 1 - wr_ptr;
wr_r <= wr_r + 1;
-- A zero-length packet is counted as a packet, because it is one.
if unsigned(wr_len) = 0 then
zlp_r <= zlp_r + 1;
end if;
if unsigned(wr_len) < unsigned(mps) then
short_r <= short_r + 1;
end if;
end if;
if do_rd = '1' then
rd_ptr <= 1 - rd_ptr;
rd_r <= rd_r + 1;
end if;
-- Occupancy moves only when exactly one side acts. Both acting leaves
-- it where it was, which is what lets the endpoint run at full rate.
if do_wr = '1' and do_rd = '0' then
count <= count + 1;
elsif do_wr = '0' and do_rd = '1' then
count <= count - 1;
end if;
-- A packet that arrived with no room is a NAK the endpoint had to
-- send.
if wr_commit = '1' and wrdy = '0' then
nak_r <= nak_r + 1;
end if;
end if;
end if;
end process;
n_written <= std_logic_vector(wr_r);
n_read <= std_logic_vector(rd_r);
n_zlp <= std_logic_vector(zlp_r);
n_short <= std_logic_vector(short_r);
n_nak <= std_logic_vector(nak_r);
n_flush <= std_logic_vector(fl_r);
end architecture;9. Seeing Both Properties
A simultaneous write and read, then the ZLP that ends the transfer
usb_ep_pingpong — simultaneity, and a zero-length packet
10 cyclesCycle 4 is the one to read carefully. wr_commit and rd_ack are both high, used does not move, and both operations happened. That is the second buffer earning its area.
10. The Testbenches
Each suite runs two separate exhaustive domains plus directed scenarios and a randomised phase. Two domains rather than one, because the full cross of control state against every packet length is larger than it needs to be and the two questions are independent:
| Domain | What it enumerates | Size |
|---|---|---|
| A — control | 6 reachable (occupancy, read-pointer) states × wr_commit × rd_ack × flush × 4 length classes | 192 |
| B — classification | every length 0…64 against every legal mps (8, 16, 32, 64) | 260 |
And the bench keeps a complete shadow model of the FIFO — its own pointers, its own occupancy, its own stored lengths — compared every cycle. That is the direct lesson of Chapter 21.1 §12, where a suite that checked only outputs and counters let two state-only mutations survive on 1 and 5 failures.
10.1 Verilog testbench
`timescale 1ns/1ps
module tb_pp_v;
localparam LEN_W = 7;
reg clk=0, rst_n=0;
reg flush=0, wr_commit=0, rd_ack=0;
reg [LEN_W-1:0] mps=8, wr_len=0;
wire wr_ready, rd_valid, rd_zlp, rd_short, nak_needed;
wire [LEN_W-1:0] rd_len;
wire [1:0] used, rd_class;
wire [31:0] n_written, n_read, n_zlp, n_short, n_nak, n_flush;
always #5 clk=~clk;
usb_ep_pingpong #(.LEN_W(LEN_W)) dut (
.clk(clk), .rst_n(rst_n), .flush(flush), .mps(mps),
.wr_commit(wr_commit), .wr_len(wr_len), .rd_ack(rd_ack),
.wr_ready(wr_ready), .rd_valid(rd_valid), .rd_len(rd_len),
.rd_zlp(rd_zlp), .rd_short(rd_short), .rd_class(rd_class), .used(used),
.nak_needed(nak_needed), .n_written(n_written), .n_read(n_read),
.n_zlp(n_zlp), .n_short(n_short), .n_nak(n_nak), .n_flush(n_flush));
// ---- A complete SHADOW MODEL of the FIFO. Keeping independent state in
// ---- the bench is what makes every state-only bug visible: chapter 21.1
// ---- learned that checking outputs and counters alone leaves a mutation
// ---- that corrupts only a pointer almost undetectable.
reg [LEN_W-1:0] s_len [0:1];
reg s_wrp, s_rdp;
reg [1:0] s_cnt;
integer s_written, s_read, s_zlp, s_short, s_nak, s_flush;
integer errors=0, i, j, k, a, b, c, st, lc;
integer n_ctrl=0, n_class=0;
integer n_occ [0:2];
integer n_both=0, n_zlp_stored=0, n_nakhit=0, n_shorthit=0;
task check(input cond, input [639:0] msg);
begin if (!cond) begin errors=errors+1;
if (errors <= 25)
$display(" FAIL: %0s (flush=%b wr=%b len=%0d rd=%b mps=%0d | used=%0d rdlen=%0d zlp=%b short=%b rdy=%b val=%b || model cnt=%0d rdp=%b wrp=%b, t=%0t)",
msg, flush, wr_commit, wr_len, rd_ack, mps, used, rd_len,
rd_zlp, rd_short, wr_ready, rd_valid, s_cnt, s_rdp, s_wrp,
$time);
end end
endtask
localparam [1:0] PKT_NONE=0, PKT_ZLP=1, PKT_SHORT=2, PKT_FULL=3;
task check_outputs;
reg e_wrdy, e_rval, e_zlp, e_short;
reg [1:0] e_class;
reg [LEN_W-1:0] e_rlen;
begin
e_wrdy = (s_cnt != 2'd2);
e_rval = (s_cnt != 2'd0);
e_rlen = s_len[s_rdp];
e_zlp = e_rval && (e_rlen == 0);
e_short = e_rval && (e_rlen < mps);
e_class = !e_rval ? PKT_NONE : (e_rlen == 0) ? PKT_ZLP
: (e_rlen < mps) ? PKT_SHORT : PKT_FULL;
check(wr_ready === e_wrdy, "wr_ready matches the shadow model");
check(rd_valid === e_rval, "rd_valid matches the shadow model");
check(used === s_cnt, "used matches the shadow occupancy");
check(nak_needed === !e_wrdy, "nak_needed is exactly !wr_ready");
if (e_rval) begin
check(rd_len === e_rlen,
"rd_len is the length of the OLDEST packet, not the newest");
check(rd_zlp === e_zlp, "rd_zlp matches the shadow model");
check(rd_short === e_short, "rd_short matches the shadow model");
end
check(rd_class === e_class, "rd_class matches the shadow model");
// rd_class must agree with the three booleans it summarises -- a
// summary that can disagree with its parts is worse than no summary.
check((rd_class === PKT_NONE) === !rd_valid,
"rd_class disagrees with rd_valid");
check((rd_class === PKT_ZLP) === rd_zlp,
"rd_class disagrees with rd_zlp");
check((rd_class === PKT_SHORT || rd_class === PKT_ZLP) === rd_short,
"rd_class disagrees with rd_short");
// ---- SAFETY PROPERTIES, independent of the model ----
// 1. THE property. A zero-length packet is a packet: it occupies a
// buffer, and it comes back out as a ZLP.
if (rd_valid && (rd_len == 0))
check(rd_zlp,
"a stored zero-length packet did not read back as a ZLP");
// 2. A ZLP is always short -- it is the limiting case of a short packet
// and must terminate the transfer just the same.
if (rd_zlp)
check(rd_short, "a ZLP was not reported as terminating the transfer");
// 3. A packet of exactly mps bytes is NOT short. Off by one here
// truncates every transfer that is a multiple of the packet size.
if (rd_valid && (rd_len == mps))
check(!rd_short,
"a full-size packet was reported short -- the transfer would end early");
// 4. Nothing is readable from an empty FIFO, and nothing is writable
// into a full one.
if (used == 2'd0) check(!rd_valid, "an empty FIFO offered a packet");
if (used == 2'd2) check(!wr_ready, "a full FIFO accepted another packet");
// 5. Occupancy never leaves 0..2.
check(used <= 2'd2, "occupancy exceeded the two buffers that exist");
// 6. An empty FIFO reports neither ZLP nor short -- it has no packet
// to describe, and inventing a terminator ends a live transfer.
if (!rd_valid)
check(!rd_zlp && !rd_short,
"an empty FIFO described a packet it does not hold");
if (s_cnt <= 2) n_occ[s_cnt] = n_occ[s_cnt] + 1;
end
endtask
// advance the shadow model by one clock, using the same inputs the DUT saw
task model_step;
reg d_wr, d_rd;
begin
d_wr = wr_commit && (s_cnt != 2'd2);
d_rd = rd_ack && (s_cnt != 2'd0);
if (flush) begin
s_wrp = 1'b0; s_rdp = 1'b0; s_cnt = 2'd0;
s_flush = s_flush + 1;
end else begin
if (d_wr) begin
s_len[s_wrp] = wr_len;
s_wrp = ~s_wrp;
s_written = s_written + 1;
if (wr_len == 0) begin s_zlp = s_zlp + 1; n_zlp_stored = n_zlp_stored + 1; end
if (wr_len < mps) begin s_short = s_short + 1; n_shorthit = n_shorthit + 1; end
end
if (d_rd) begin s_rdp = ~s_rdp; s_read = s_read + 1; end
if (d_wr && !d_rd) s_cnt = s_cnt + 2'd1;
if (!d_wr && d_rd) s_cnt = s_cnt - 2'd1;
if (d_wr && d_rd) n_both = n_both + 1;
end
end
endtask
task step;
reg was_full;
begin
#1;
check_outputs;
was_full = (s_cnt == 2'd2);
if (!flush && wr_commit && was_full) begin
s_nak = s_nak + 1; n_nakhit = n_nakhit + 1;
end
model_step;
@(posedge clk); #1;
// The shadow model's state IS the check: every pointer and the
// occupancy are compared through the outputs they drive, every cycle.
check(used === s_cnt, "occupancy tracked the model");
check(wr_ready === (s_cnt != 2'd2),"wr_ready tracked the model");
check(rd_valid === (s_cnt != 2'd0),"rd_valid tracked the model");
if (s_cnt != 0)
check(rd_len === s_len[s_rdp],
"the read pointer tracked the model -- packets come out in order");
check(n_written === s_written[31:0], "n_written matches the model");
check(n_read === s_read[31:0], "n_read matches the model");
check(n_zlp === s_zlp[31:0], "n_zlp matches the model");
check(n_short === s_short[31:0], "n_short matches the model");
check(n_nak === s_nak[31:0], "n_nak matches the model");
check(n_flush === s_flush[31:0], "n_flush matches the model");
end
endtask
task idle; begin flush=0; wr_commit=0; rd_ack=0; end endtask
task hard_reset;
begin
rst_n=0; idle; wr_len=0; mps=8;
@(posedge clk); #1; @(posedge clk); #1; rst_n=1; #1;
s_wrp=0; s_rdp=0; s_cnt=0; s_len[0]=0; s_len[1]=0;
s_written=0; s_read=0; s_zlp=0; s_short=0; s_nak=0; s_flush=0;
end
endtask
// Force the FIFO to a chosen (occupancy, read-pointer) pair. Reaching
// rd_ptr=1 needs a full write/read cycle, which is why the two are swept
// together rather than independently.
task goto_state(input [1:0] want_cnt, input want_rdp);
begin
idle; flush=1; step; idle;
if (want_rdp) begin
wr_commit=1; wr_len=4; step; idle; // write one
rd_ack=1; step; idle; // and read it back
end
for (k=0; k<want_cnt; k=k+1) begin
wr_commit=1; wr_len=(k==0) ? 7'd3 : 7'd5; step; idle;
end
#1;
check(used === want_cnt, "goto_state reached the occupancy");
end
endtask
initial begin
for (i=0;i<3;i=i+1) n_occ[i]=0;
hard_reset;
check(used === 2'd0, "reset leaves the FIFO empty");
check(!rd_valid, "with nothing to read");
check(wr_ready, "and room to write");
// ===== A. EXHAUSTIVE control sweep =====
// 6 reachable (occupancy, read-pointer) states x wr_commit x rd_ack
// x flush x 4 length classes (ZLP / 1 / mps-1 / mps) = 192 transitions.
mps = 8;
for (st=0; st<6; st=st+1)
for (a=0;a<2;a=a+1)
for (b=0;b<2;b=b+1)
for (c=0;c<2;c=c+1)
for (lc=0; lc<4; lc=lc+1) begin
// st counts 0..5 as {occupancy[1:0], read-pointer}
goto_state(st[2:1], st[0]);
wr_commit=a[0]; rd_ack=b[0]; flush=c[0];
case (lc)
0: wr_len = 7'd0; // a ZERO-LENGTH PACKET
1: wr_len = 7'd1;
2: wr_len = mps - 7'd1; // short
default: wr_len = mps; // full
endcase
step;
n_ctrl = n_ctrl + 1;
idle;
end
$display(" exhaustive control sweep: %0d of 192 transitions verified",
n_ctrl);
// ===== B. EXHAUSTIVE length/classification sweep =====
// Every length 0..64 against every max packet size the spec allows for a
// full-speed or high-speed endpoint. 65 x 4 = 260 points, which pins the
// ZLP and short-packet classification over its entire domain.
for (j=0; j<4; j=j+1) begin
case (j)
0: mps = 7'd8;
1: mps = 7'd16;
2: mps = 7'd32;
default: mps = 7'd64;
endcase
for (i=0; i<=64; i=i+1) begin
idle; flush=1; step; idle;
wr_commit=1; wr_len=i[LEN_W-1:0]; step; idle;
#1;
check(rd_valid, "the packet is readable whatever its length");
check(rd_len === i[LEN_W-1:0], "and its length came back exactly");
check(rd_zlp === (i == 0),
"ZLP is reported exactly when the length is zero");
check(rd_short === (i < mps),
"short is reported exactly when the length is below mps");
check(rd_class === ((i == 0) ? PKT_ZLP
: (i < mps) ? PKT_SHORT : PKT_FULL),
"rd_class classifies the length correctly");
rd_ack=1; step; idle;
n_class = n_class + 1;
end
end
$display(" exhaustive length/classification sweep: %0d of 260 points verified",
n_class);
// ===== C. directed: the two stories this block exists for =====
hard_reset; mps = 8;
// 1. THE ZLP story. A 16-byte transfer on an 8-byte endpoint is two full
// packets -- and the host cannot tell that it is finished until a
// THIRD, zero-length packet arrives.
wr_commit=1; wr_len=8; step; idle; // full packet
check(used === 2'd1, "the first full packet is buffered");
#1; check(!rd_short, "a full packet does NOT terminate the transfer");
rd_ack=1; step; idle;
wr_commit=1; wr_len=8; step; idle; // second full packet
rd_ack=1; step; idle;
wr_commit=1; wr_len=0; step; idle; // the ZLP
check(used === 2'd1,
"the ZERO-LENGTH packet occupied a buffer -- it is a real packet");
#1;
check(rd_valid, "and it is readable");
check(rd_zlp, "and reads back as a ZLP");
check(rd_short, "which terminates the transfer");
check(rd_len === 7'd0, "carrying no bytes");
rd_ack=1; step; idle;
check(n_zlp === 32'd1, "one zero-length packet was counted as a packet");
// 2. THE PING-PONG story. A write and a read in the SAME cycle must both
// succeed and leave occupancy unchanged. That is the entire point of
// the second buffer.
hard_reset; mps=8;
wr_commit=1; wr_len=3; step; idle;
wr_commit=1; wr_len=4; step; idle;
check(used === 2'd2, "both buffers are full");
check(!wr_ready, "so a further packet would have to be NAKed");
wr_commit=1; wr_len=5; rd_ack=1; #1;
check(!wr_ready, "the write cannot be accepted this cycle -- it is full");
check(rd_valid, "but the read can proceed");
step; idle;
check(used === 2'd1, "so occupancy fell by one");
check(n_nak === 32'd1, "and the refused packet was counted as a NAK");
// now with room, both happen at once
wr_commit=1; wr_len=6; rd_ack=1; step; idle;
check(used === 2'd1,
"a simultaneous write and read left occupancy UNCHANGED -- full rate");
#1; check(rd_len === 7'd6,
"and the surviving packet is the one just written");
// 3. Ordering. Packets come out oldest-first, not newest-first.
hard_reset; mps=8;
wr_commit=1; wr_len=11; step; idle;
wr_commit=1; wr_len=22; step; idle;
#1; check(rd_len === 7'd11, "the OLDEST packet is offered first");
rd_ack=1; step; idle;
#1; check(rd_len === 7'd22, "then the newer one");
// 4. Flush clears BOTH pointers, not just the count.
hard_reset; mps=8;
wr_commit=1; wr_len=11; step; idle;
rd_ack=1; step; idle; // read pointer now at buffer 1
wr_commit=1; wr_len=33; step; idle;
flush=1; step; idle;
check(used === 2'd0, "the flush emptied the FIFO");
wr_commit=1; wr_len=44; step; idle;
#1;
check(rd_len === 7'd44,
"and the next packet read is the one just written -- both pointers reset");
// ===== D. randomised =====
hard_reset;
for (i=0;i<40000;i=i+1) begin
case ({$random}%4)
0: mps = 7'd8;
1: mps = 7'd16;
2: mps = 7'd32;
default: mps = 7'd64;
endcase
wr_commit = ({$random}%3)!=0;
rd_ack = ({$random}%3)!=0;
flush = ({$random}%64)==0;
// bias hard toward the boundary lengths: 0 (ZLP), mps-1, mps
case ({$random}%4)
0: wr_len = 7'd0;
1: wr_len = mps - 7'd1;
2: wr_len = mps;
default: wr_len = {$random}%65;
endcase
step;
end
for (i=0;i<3;i=i+1)
check(n_occ[i] > 0, "every occupancy level was reached");
check(n_both > 2000, "simultaneous write+read happened often");
check(n_zlp_stored > 2000, "zero-length packets were stored often");
check(n_nakhit > 500, "the FIFO was found full often");
$display("");
$display(" REACH: control=%0d class=%0d | occupancy: empty=%0d one=%0d full=%0d",
n_ctrl, n_class, n_occ[0], n_occ[1], n_occ[2]);
$display(" CASES: simultaneous-rw=%0d zlps-stored=%0d short-pkts=%0d naks=%0d",
n_both, n_zlp_stored, n_shorthit, n_nakhit);
$display(" COUNTERS: written=%0d read=%0d zlp=%0d short=%0d nak=%0d flush=%0d",
n_written, n_read, n_zlp, n_short, n_nak, n_flush);
$display(" [Verilog] usb_ep_pingpong: %0d errors", errors);
$display(" [Verilog] %0s", errors==0 ? "PASS" : "FAIL");
$display("");
$finish;
end
endmodule10.2 SystemVerilog testbench
`timescale 1ns/1ps
module tb_pp_sv;
import usb_pingpong_pkg::*;
localparam LEN_W = 7;
logic clk=0, rst_n=0;
logic flush=0, wr_commit=0, rd_ack=0;
logic [LEN_W-1:0] mps=8, wr_len=0;
logic wr_ready, rd_valid, rd_zlp, rd_short, nak_needed;
logic [LEN_W-1:0] rd_len;
pkt_class_e rd_class;
logic [1:0] used;
logic [31:0] n_written, n_read, n_zlp, n_short, n_nak, n_flush;
// Icarus seeds $random and $urandom identically, so an unseeded run would
// replay the Verilog suite's stimulus exactly. See chapter 20.5 section 9.2.
int urandom_seed = 21202;
always #5 clk=~clk;
usb_ep_pingpong #(.LEN_W(LEN_W)) dut (
.clk, .rst_n, .flush, .mps, .wr_commit, .wr_len, .rd_ack,
.wr_ready, .rd_valid, .rd_len, .rd_zlp, .rd_short, .rd_class, .used,
.nak_needed, .n_written, .n_read, .n_zlp, .n_short, .n_nak, .n_flush);
// ---- A complete SHADOW MODEL of the FIFO. Keeping independent state in
// ---- the bench is what makes every state-only bug visible: chapter 21.1
// ---- learned that checking outputs and counters alone leaves a mutation
// ---- that corrupts only a pointer almost undetectable.
logic [LEN_W-1:0] s_len [0:1];
logic s_wrp, s_rdp;
logic [1:0] s_cnt;
int s_written, s_read, s_zlp, s_short, s_nak, s_flush;
int errors=0, i, j, k, a, b, c, st, lc;
int n_ctrl=0, n_class=0;
int n_occ [3];
int n_both=0, n_zlp_stored=0, n_nakhit=0, n_shorthit=0;
task automatic check(input bit cond, input string msg);
// Icarus will not call .name() on a net, so the enum output is copied
// into a variable of the same type before being printed.
pkt_class_e cls_v;
if (!cond) begin
errors++;
cls_v = rd_class;
if (errors <= 25)
$display(" FAIL: %0s (flush=%b wr=%b len=%0d rd=%b mps=%0d | used=%0d rdlen=%0d class=%s rdy=%b val=%b || model cnt=%0d rdp=%b wrp=%b, t=%0t)",
msg, flush, wr_commit, wr_len, rd_ack, mps, used, rd_len,
cls_v.name(), wr_ready, rd_valid, s_cnt, s_rdp, s_wrp, $time);
end
endtask
task automatic check_outputs;
bit e_wrdy, e_rval, e_zlp, e_short;
pkt_class_e e_class;
logic [LEN_W-1:0] e_rlen;
begin
e_wrdy = (s_cnt != 2'd2);
e_rval = (s_cnt != 2'd0);
e_rlen = s_len[s_rdp];
e_zlp = e_rval && (e_rlen == 0);
e_short = e_rval && (e_rlen < mps);
// Written as an if-chain rather than the design's ternary: a different
// route to the same classification, and Icarus will not take an
// enum-valued ternary without an explicit cast in any case.
if (!e_rval) e_class = PKT_NONE;
else if (e_rlen == '0) e_class = PKT_ZLP;
else if (e_rlen < mps) e_class = PKT_SHORT;
else e_class = PKT_FULL;
check(wr_ready === e_wrdy, "wr_ready matches the shadow model");
check(rd_valid === e_rval, "rd_valid matches the shadow model");
check(used === s_cnt, "used matches the shadow occupancy");
check(nak_needed === !e_wrdy, "nak_needed is exactly !wr_ready");
if (e_rval) begin
check(rd_len === e_rlen,
"rd_len is the length of the OLDEST packet, not the newest");
check(rd_zlp === e_zlp, "rd_zlp matches the shadow model");
check(rd_short === e_short, "rd_short matches the shadow model");
end
check(rd_class === e_class, "rd_class matches the shadow model");
// rd_class must agree with the three booleans it summarises -- a
// summary that can disagree with its parts is worse than no summary.
check((rd_class === PKT_NONE) === !rd_valid,
"rd_class disagrees with rd_valid");
check((rd_class === PKT_ZLP) === rd_zlp,
"rd_class disagrees with rd_zlp");
check((rd_class === PKT_SHORT || rd_class === PKT_ZLP) === rd_short,
"rd_class disagrees with rd_short");
// ---- SAFETY PROPERTIES, independent of the model ----
// 1. THE property. A zero-length packet is a packet: it occupies a
// buffer, and it comes back out as a ZLP.
if (rd_valid && (rd_len == 0))
check(rd_zlp,
"a stored zero-length packet did not read back as a ZLP");
// 2. A ZLP is always short -- it is the limiting case of a short packet
// and must terminate the transfer just the same.
if (rd_zlp)
check(rd_short, "a ZLP was not reported as terminating the transfer");
// 3. A packet of exactly mps bytes is NOT short. Off by one here
// truncates every transfer that is a multiple of the packet size.
if (rd_valid && (rd_len == mps))
check(!rd_short,
"a full-size packet was reported short -- the transfer would end early");
// 4. Nothing is readable from an empty FIFO, and nothing is writable
// into a full one.
if (used == 2'd0) check(!rd_valid, "an empty FIFO offered a packet");
if (used == 2'd2) check(!wr_ready, "a full FIFO accepted another packet");
// 5. Occupancy never leaves 0..2.
check(used <= 2'd2, "occupancy exceeded the two buffers that exist");
// 6. An empty FIFO reports neither ZLP nor short -- it has no packet
// to describe, and inventing a terminator ends a live transfer.
if (!rd_valid)
check(!rd_zlp && !rd_short,
"an empty FIFO described a packet it does not hold");
n_occ[int'(s_cnt)] = n_occ[int'(s_cnt)] + 1;
end
endtask
// advance the shadow model by one clock, using the same inputs the DUT saw
task automatic model_step;
bit d_wr, d_rd;
begin
d_wr = wr_commit && (s_cnt != 2'd2);
d_rd = rd_ack && (s_cnt != 2'd0);
if (flush) begin
s_wrp = 1'b0; s_rdp = 1'b0; s_cnt = 2'd0;
s_flush = s_flush + 1;
end else begin
if (d_wr) begin
s_len[s_wrp] = wr_len;
s_wrp = ~s_wrp;
s_written = s_written + 1;
if (wr_len == 0) begin s_zlp = s_zlp + 1; n_zlp_stored = n_zlp_stored + 1; end
if (wr_len < mps) begin s_short = s_short + 1; n_shorthit = n_shorthit + 1; end
end
if (d_rd) begin s_rdp = ~s_rdp; s_read = s_read + 1; end
if (d_wr && !d_rd) s_cnt = s_cnt + 2'd1;
if (!d_wr && d_rd) s_cnt = s_cnt - 2'd1;
if (d_wr && d_rd) n_both = n_both + 1;
end
end
endtask
task automatic step;
bit was_full;
begin
#1;
check_outputs;
was_full = (s_cnt == 2'd2);
if (!flush && wr_commit && was_full) begin
s_nak = s_nak + 1; n_nakhit = n_nakhit + 1;
end
model_step;
@(posedge clk); #1;
// The shadow model's state IS the check: every pointer and the
// occupancy are compared through the outputs they drive, every cycle.
check(used === s_cnt, "occupancy tracked the model");
check(wr_ready === (s_cnt != 2'd2),"wr_ready tracked the model");
check(rd_valid === (s_cnt != 2'd0),"rd_valid tracked the model");
if (s_cnt != 0)
check(rd_len === s_len[s_rdp],
"the read pointer tracked the model -- packets come out in order");
check(n_written === 32'(s_written), "n_written matches the model");
check(n_read === 32'(s_read), "n_read matches the model");
check(n_zlp === 32'(s_zlp), "n_zlp matches the model");
check(n_short === 32'(s_short), "n_short matches the model");
check(n_nak === 32'(s_nak), "n_nak matches the model");
check(n_flush === 32'(s_flush), "n_flush matches the model");
end
endtask
task automatic idle; begin flush=0; wr_commit=0; rd_ack=0; end endtask
task automatic hard_reset;
begin
rst_n=0; idle; wr_len=0; mps=8;
@(posedge clk); #1; @(posedge clk); #1; rst_n=1; #1;
s_wrp=0; s_rdp=0; s_cnt=0; s_len[0]=0; s_len[1]=0;
s_written=0; s_read=0; s_zlp=0; s_short=0; s_nak=0; s_flush=0;
end
endtask
// Force the FIFO to a chosen (occupancy, read-pointer) pair. Reaching
// rd_ptr=1 needs a full write/read cycle, which is why the two are swept
// together rather than independently.
task automatic goto_state(input logic [1:0] want_cnt, input bit want_rdp);
begin
idle; flush=1; step; idle;
if (want_rdp) begin
wr_commit=1; wr_len=4; step; idle; // write one
rd_ack=1; step; idle; // and read it back
end
for (k=0; k<int'(want_cnt); k++) begin
wr_commit=1; wr_len=(k==0) ? 7'd3 : 7'd5; step; idle;
end
#1;
check(used === want_cnt, "goto_state reached the occupancy");
end
endtask
initial begin
void'($urandom(urandom_seed));
foreach (n_occ[i]) n_occ[i]=0;
hard_reset;
check(used === 2'd0, "reset leaves the FIFO empty");
check(!rd_valid, "with nothing to read");
check(wr_ready, "and room to write");
// ===== A. EXHAUSTIVE control sweep =====
// 6 reachable (occupancy, read-pointer) states x wr_commit x rd_ack
// x flush x 4 length classes (ZLP / 1 / mps-1 / mps) = 192 transitions.
mps = 8;
for (st=0; st<6; st=st+1)
for (a=0;a<2;a=a+1)
for (b=0;b<2;b=b+1)
for (c=0;c<2;c=c+1)
for (lc=0; lc<4; lc=lc+1) begin
// st counts 0..5 as {occupancy[1:0], read-pointer}
goto_state(st[2:1], st[0]);
wr_commit=a[0]; rd_ack=b[0]; flush=c[0];
case (lc)
0: wr_len = 7'd0; // a ZERO-LENGTH PACKET
1: wr_len = 7'd1;
2: wr_len = mps - 7'd1; // short
default: wr_len = mps; // full
endcase
step;
n_ctrl = n_ctrl + 1;
idle;
end
$display(" exhaustive control sweep: %0d of 192 transitions verified",
n_ctrl);
// ===== B. EXHAUSTIVE length/classification sweep =====
// Every length 0..64 against every max packet size the spec allows for a
// full-speed or high-speed endpoint. 65 x 4 = 260 points, which pins the
// ZLP and short-packet classification over its entire domain.
for (j=0; j<4; j=j+1) begin
case (j)
0: mps = 7'd8;
1: mps = 7'd16;
2: mps = 7'd32;
default: mps = 7'd64;
endcase
for (i=0; i<=64; i=i+1) begin
idle; flush=1; step; idle;
wr_commit=1; wr_len=i[LEN_W-1:0]; step; idle;
#1;
check(rd_valid, "the packet is readable whatever its length");
check(rd_len === i[LEN_W-1:0], "and its length came back exactly");
check(rd_zlp === (i == 0),
"ZLP is reported exactly when the length is zero");
check(rd_short === (i < mps),
"short is reported exactly when the length is below mps");
if (i == 0) check(rd_class === PKT_ZLP,
"rd_class classifies a ZLP correctly");
else if (i < mps) check(rd_class === PKT_SHORT,
"rd_class classifies a short packet correctly");
else check(rd_class === PKT_FULL,
"rd_class classifies a full packet correctly");
rd_ack=1; step; idle;
n_class = n_class + 1;
end
end
$display(" exhaustive length/classification sweep: %0d of 260 points verified",
n_class);
// ===== C. directed: the two stories this block exists for =====
hard_reset; mps = 8;
// 1. THE ZLP story. A 16-byte transfer on an 8-byte endpoint is two full
// packets -- and the host cannot tell that it is finished until a
// THIRD, zero-length packet arrives.
wr_commit=1; wr_len=8; step; idle; // full packet
check(used === 2'd1, "the first full packet is buffered");
#1; check(!rd_short, "a full packet does NOT terminate the transfer");
rd_ack=1; step; idle;
wr_commit=1; wr_len=8; step; idle; // second full packet
rd_ack=1; step; idle;
wr_commit=1; wr_len=0; step; idle; // the ZLP
check(used === 2'd1,
"the ZERO-LENGTH packet occupied a buffer -- it is a real packet");
#1;
check(rd_valid, "and it is readable");
check(rd_zlp, "and reads back as a ZLP");
check(rd_short, "which terminates the transfer");
check(rd_len === 7'd0, "carrying no bytes");
rd_ack=1; step; idle;
check(n_zlp === 32'd1, "one zero-length packet was counted as a packet");
// 2. THE PING-PONG story. A write and a read in the SAME cycle must both
// succeed and leave occupancy unchanged. That is the entire point of
// the second buffer.
hard_reset; mps=8;
wr_commit=1; wr_len=3; step; idle;
wr_commit=1; wr_len=4; step; idle;
check(used === 2'd2, "both buffers are full");
check(!wr_ready, "so a further packet would have to be NAKed");
wr_commit=1; wr_len=5; rd_ack=1; #1;
check(!wr_ready, "the write cannot be accepted this cycle -- it is full");
check(rd_valid, "but the read can proceed");
step; idle;
check(used === 2'd1, "so occupancy fell by one");
check(n_nak === 32'd1, "and the refused packet was counted as a NAK");
// now with room, both happen at once
wr_commit=1; wr_len=6; rd_ack=1; step; idle;
check(used === 2'd1,
"a simultaneous write and read left occupancy UNCHANGED -- full rate");
#1; check(rd_len === 7'd6,
"and the surviving packet is the one just written");
// 3. Ordering. Packets come out oldest-first, not newest-first.
hard_reset; mps=8;
wr_commit=1; wr_len=11; step; idle;
wr_commit=1; wr_len=22; step; idle;
#1; check(rd_len === 7'd11, "the OLDEST packet is offered first");
rd_ack=1; step; idle;
#1; check(rd_len === 7'd22, "then the newer one");
// 4. Flush clears BOTH pointers, not just the count.
hard_reset; mps=8;
wr_commit=1; wr_len=11; step; idle;
rd_ack=1; step; idle; // read pointer now at buffer 1
wr_commit=1; wr_len=33; step; idle;
flush=1; step; idle;
check(used === 2'd0, "the flush emptied the FIFO");
wr_commit=1; wr_len=44; step; idle;
#1;
check(rd_len === 7'd44,
"and the next packet read is the one just written -- both pointers reset");
// ===== D. randomised =====
hard_reset;
for (i=0;i<40000;i=i+1) begin
case ($urandom%4)
0: mps = 7'd8;
1: mps = 7'd16;
2: mps = 7'd32;
default: mps = 7'd64;
endcase
wr_commit = ($urandom%3)!=0;
rd_ack = ($urandom%3)!=0;
flush = ($urandom%64)==0;
// bias hard toward the boundary lengths: 0 (ZLP), mps-1, mps
case ($urandom%4)
0: wr_len = 7'd0;
1: wr_len = mps - 7'd1;
2: wr_len = mps;
default: wr_len = $urandom%65;
endcase
step;
end
foreach (n_occ[i]) check(n_occ[i] > 0, "every occupancy level was reached");
check(n_both > 2000, "simultaneous write+read happened often");
check(n_zlp_stored > 2000, "zero-length packets were stored often");
check(n_nakhit > 500, "the FIFO was found full often");
$display("");
$display(" REACH: control=%0d class=%0d | occupancy: empty=%0d one=%0d full=%0d",
n_ctrl, n_class, n_occ[0], n_occ[1], n_occ[2]);
$display(" CASES: simultaneous-rw=%0d zlps-stored=%0d short-pkts=%0d naks=%0d",
n_both, n_zlp_stored, n_shorthit, n_nakhit);
$display(" COUNTERS: written=%0d read=%0d zlp=%0d short=%0d nak=%0d flush=%0d",
n_written, n_read, n_zlp, n_short, n_nak, n_flush);
$display(" [SystemVerilog] usb_ep_pingpong: %0d errors", errors);
$display(" [SystemVerilog] %0s", errors==0 ? "PASS" : "FAIL");
$display("");
$finish;
end
endmodule10.3 VHDL testbench
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.math_real.all;
use work.usb_pingpong_pkg.all;
entity tb_pp_vhdl is
end entity;
architecture sim of tb_pp_vhdl is
constant LEN_W : natural := 7;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal flush, wr_commit, rd_ack : std_logic := '0';
signal mps : std_logic_vector(LEN_W-1 downto 0)
:= std_logic_vector(to_unsigned(8, LEN_W));
signal wr_len : std_logic_vector(LEN_W-1 downto 0) := (others => '0');
signal wr_ready, rd_valid, rd_zlp, rd_short, nak_needed : std_logic;
signal rd_len : std_logic_vector(LEN_W-1 downto 0);
signal rd_class : std_logic_vector(1 downto 0);
signal used : std_logic_vector(1 downto 0);
signal n_written, n_read, n_zlp, n_short, n_nak, n_flush
: std_logic_vector(31 downto 0);
signal running : boolean := true;
type cnt3_t is array (0 to 2) of integer;
type len2_t is array (0 to 1) of integer;
begin
clk <= not clk after 5 ns when running else '0';
dut : entity work.usb_ep_pingpong
generic map (LEN_W => LEN_W)
port map (clk => clk, rst_n => rst_n, flush => flush, mps => mps,
wr_commit => wr_commit, wr_len => wr_len, rd_ack => rd_ack,
wr_ready => wr_ready, rd_valid => rd_valid, rd_len => rd_len,
rd_zlp => rd_zlp, rd_short => rd_short, rd_class => rd_class,
used => used, nak_needed => nak_needed,
n_written => n_written, n_read => n_read, n_zlp => n_zlp,
n_short => n_short, n_nak => n_nak, n_flush => n_flush);
stim : process
variable seed1 : positive := 4231;
variable seed2 : positive := 8017;
variable r1 : real;
-- VHDL-2008 requires a shared variable to have a protected type, so the
-- bookkeeping lives inside the single stimulus process instead.
variable errors : integer := 0;
-- A complete SHADOW MODEL of the FIFO. Keeping independent state in the
-- bench is what makes every state-only bug visible: chapter 21.1 learned
-- that checking outputs and counters alone leaves a mutation that
-- corrupts only a pointer almost undetectable.
variable s_len : len2_t := (others => 0);
variable s_wrp, s_rdp : integer := 0;
variable s_cnt : integer := 0;
variable s_written, s_read, s_zlp, s_short, s_nak, s_flush : integer := 0;
variable n_ctrl, n_class : integer := 0;
variable n_occ : cnt3_t := (others => 0);
variable n_both, n_zlp_stored, n_nakhit, n_shorthit : integer := 0;
procedure check(cond : boolean; msg : string) is
begin
if not cond then
errors := errors + 1;
if errors <= 25 then
report " FAIL: " & msg
& " (flush=" & std_logic'image(flush)(2)
& " wr=" & std_logic'image(wr_commit)(2)
& " len=" & integer'image(to_integer(unsigned(wr_len)))
& " rd=" & std_logic'image(rd_ack)(2)
& " mps=" & integer'image(to_integer(unsigned(mps)))
& " | used=" & integer'image(to_integer(unsigned(used)))
& " rdlen=" & integer'image(to_integer(unsigned(rd_len)))
& " class=" & integer'image(to_integer(unsigned(rd_class)))
& " rdy=" & std_logic'image(wr_ready)(2)
& " val=" & std_logic'image(rd_valid)(2)
& " || model cnt=" & integer'image(s_cnt)
& " rdp=" & integer'image(s_rdp)
& " wrp=" & integer'image(s_wrp)
& ")" severity note;
end if;
end if;
end procedure;
procedure rnd(variable v : out integer; m : integer) is
begin
uniform(seed1, seed2, r1);
v := integer(floor(r1 * real(m)));
end procedure;
procedure check_outputs is
variable e_wrdy, e_rval, e_zlp, e_short : boolean;
variable e_class : pkt_class_t;
variable e_rlen : integer;
variable mps_i : integer;
begin
mps_i := to_integer(unsigned(mps));
e_wrdy := s_cnt /= 2;
e_rval := s_cnt /= 0;
e_rlen := s_len(s_rdp);
e_zlp := e_rval and e_rlen = 0;
e_short := e_rval and e_rlen < mps_i;
-- Written as an if-chain rather than the design's process: a different
-- route to the same classification.
if not e_rval then e_class := PKT_NONE;
elsif e_rlen = 0 then e_class := PKT_ZLP;
elsif e_rlen < mps_i then e_class := PKT_SHORT;
else e_class := PKT_FULL;
end if;
check((wr_ready = '1') = e_wrdy, "wr_ready matches the shadow model");
check((rd_valid = '1') = e_rval, "rd_valid matches the shadow model");
check(to_integer(unsigned(used)) = s_cnt,
"used matches the shadow occupancy");
check((nak_needed = '1') = not e_wrdy,
"nak_needed is exactly not wr_ready");
if e_rval then
check(to_integer(unsigned(rd_len)) = e_rlen,
"rd_len is the length of the OLDEST packet, not the newest");
check((rd_zlp = '1') = e_zlp, "rd_zlp matches the shadow model");
check((rd_short = '1') = e_short, "rd_short matches the shadow model");
end if;
check(rd_class = class_code(e_class), "rd_class matches the shadow model");
-- rd_class must agree with the three booleans it summarises -- a
-- summary that can disagree with its parts is worse than no summary.
check((rd_class = class_code(PKT_NONE)) = (rd_valid = '0'),
"rd_class disagrees with rd_valid");
check((rd_class = class_code(PKT_ZLP)) = (rd_zlp = '1'),
"rd_class disagrees with rd_zlp");
check((rd_class = class_code(PKT_SHORT) or rd_class = class_code(PKT_ZLP))
= (rd_short = '1'), "rd_class disagrees with rd_short");
-- ---- SAFETY PROPERTIES, independent of the model ----
-- 1. THE property. A zero-length packet is a packet: it occupies a
-- buffer, and it comes back out as a ZLP.
if rd_valid = '1' and to_integer(unsigned(rd_len)) = 0 then
check(rd_zlp = '1',
"a stored zero-length packet did not read back as a ZLP");
end if;
-- 2. A ZLP is always short -- the limiting case of a short packet, and
-- it must terminate the transfer just the same.
if rd_zlp = '1' then
check(rd_short = '1',
"a ZLP was not reported as terminating the transfer");
end if;
-- 3. A packet of exactly mps bytes is NOT short.
if rd_valid = '1' and to_integer(unsigned(rd_len)) = mps_i then
check(rd_short = '0',
"a full-size packet was reported short -- the transfer would end early");
end if;
-- 4. Nothing readable from an empty FIFO, nothing writable into a full.
if to_integer(unsigned(used)) = 0 then
check(rd_valid = '0', "an empty FIFO offered a packet");
end if;
if to_integer(unsigned(used)) = 2 then
check(wr_ready = '0', "a full FIFO accepted another packet");
end if;
-- 5. Occupancy never leaves 0..2.
check(to_integer(unsigned(used)) <= 2,
"occupancy exceeded the two buffers that exist");
-- 6. An empty FIFO reports neither ZLP nor short.
if rd_valid = '0' then
check(rd_zlp = '0' and rd_short = '0',
"an empty FIFO described a packet it does not hold");
end if;
n_occ(s_cnt) := n_occ(s_cnt) + 1;
end procedure;
-- advance the shadow model by one clock, using the inputs the DUT saw
procedure model_step is
variable d_wr, d_rd : boolean;
variable mps_i : integer;
begin
mps_i := to_integer(unsigned(mps));
d_wr := wr_commit = '1' and s_cnt /= 2;
d_rd := rd_ack = '1' and s_cnt /= 0;
if flush = '1' then
s_wrp := 0; s_rdp := 0; s_cnt := 0;
s_flush := s_flush + 1;
else
if d_wr then
s_len(s_wrp) := to_integer(unsigned(wr_len));
s_wrp := 1 - s_wrp;
s_written := s_written + 1;
if to_integer(unsigned(wr_len)) = 0 then
s_zlp := s_zlp + 1; n_zlp_stored := n_zlp_stored + 1;
end if;
if to_integer(unsigned(wr_len)) < mps_i then
s_short := s_short + 1; n_shorthit := n_shorthit + 1;
end if;
end if;
if d_rd then
s_rdp := 1 - s_rdp;
s_read := s_read + 1;
end if;
if d_wr and not d_rd then s_cnt := s_cnt + 1; end if;
if not d_wr and d_rd then s_cnt := s_cnt - 1; end if;
if d_wr and d_rd then n_both := n_both + 1; end if;
end if;
end procedure;
procedure step is
variable was_full : boolean;
begin
wait for 1 ns;
check_outputs;
was_full := s_cnt = 2;
if flush = '0' and wr_commit = '1' and was_full then
s_nak := s_nak + 1; n_nakhit := n_nakhit + 1;
end if;
model_step;
wait until rising_edge(clk);
wait for 1 ns;
-- The shadow model's state IS the check: every pointer and the
-- occupancy are compared through the outputs they drive, every cycle.
check(to_integer(unsigned(used)) = s_cnt, "occupancy tracked the model");
check((wr_ready = '1') = (s_cnt /= 2), "wr_ready tracked the model");
check((rd_valid = '1') = (s_cnt /= 0), "rd_valid tracked the model");
if s_cnt /= 0 then
check(to_integer(unsigned(rd_len)) = s_len(s_rdp),
"the read pointer tracked the model -- packets come out in order");
end if;
check(n_written = std_logic_vector(to_unsigned(s_written, 32)),
"n_written matches the model");
check(n_read = std_logic_vector(to_unsigned(s_read, 32)),
"n_read matches the model");
check(n_zlp = std_logic_vector(to_unsigned(s_zlp, 32)),
"n_zlp matches the model");
check(n_short = std_logic_vector(to_unsigned(s_short, 32)),
"n_short matches the model");
check(n_nak = std_logic_vector(to_unsigned(s_nak, 32)),
"n_nak matches the model");
check(n_flush = std_logic_vector(to_unsigned(s_flush, 32)),
"n_flush matches the model");
end procedure;
procedure idle is
begin
flush <= '0'; wr_commit <= '0'; rd_ack <= '0';
end procedure;
procedure setlen(n : integer) is
begin
wr_len <= std_logic_vector(to_unsigned(n, LEN_W));
end procedure;
procedure setmps(n : integer) is
begin
mps <= std_logic_vector(to_unsigned(n, LEN_W));
end procedure;
procedure hard_reset is
begin
rst_n <= '0'; idle; setlen(0); setmps(8);
wait until rising_edge(clk); wait for 1 ns;
wait until rising_edge(clk); wait for 1 ns;
rst_n <= '1'; wait for 1 ns;
s_wrp := 0; s_rdp := 0; s_cnt := 0; s_len := (others => 0);
s_written := 0; s_read := 0; s_zlp := 0; s_short := 0;
s_nak := 0; s_flush := 0;
end procedure;
-- Force the FIFO to a chosen (occupancy, read-pointer) pair. Reaching
-- rd_ptr = 1 needs a full write/read cycle, which is why the two are
-- swept together rather than independently.
procedure goto_state(want_cnt : integer; want_rdp : integer) is
begin
idle; flush <= '1'; step; idle;
if want_rdp = 1 then
wr_commit <= '1'; setlen(4); step; idle; -- write one
rd_ack <= '1'; step; idle; -- and read it back
end if;
for k in 0 to want_cnt - 1 loop
wr_commit <= '1';
if k = 0 then setlen(3); else setlen(5); end if;
step; idle;
end loop;
wait for 1 ns;
check(to_integer(unsigned(used)) = want_cnt,
"goto_state reached the occupancy");
end procedure;
variable iv, mps_i : integer;
begin
hard_reset;
check(to_integer(unsigned(used)) = 0, "reset leaves the FIFO empty");
check(rd_valid = '0', "with nothing to read");
check(wr_ready = '1', "and room to write");
-- ===== A. EXHAUSTIVE control sweep =====
-- 6 reachable (occupancy, read-pointer) states x wr_commit x rd_ack
-- x flush x 4 length classes (ZLP / 1 / mps-1 / mps) = 192 transitions.
setmps(8);
for st in 0 to 5 loop
for a in 0 to 1 loop
for b in 0 to 1 loop
for c in 0 to 1 loop
for lc in 0 to 3 loop
-- st counts 0..5 as (occupancy, read-pointer)
goto_state(st / 2, st mod 2);
if a = 1 then wr_commit <= '1'; else wr_commit <= '0'; end if;
if b = 1 then rd_ack <= '1'; else rd_ack <= '0'; end if;
if c = 1 then flush <= '1'; else flush <= '0'; end if;
case lc is
when 0 => setlen(0); -- a ZERO-LENGTH PACKET
when 1 => setlen(1);
when 2 => setlen(7); -- mps - 1: short
when others => setlen(8); -- mps: full
end case;
step;
n_ctrl := n_ctrl + 1;
idle;
end loop;
end loop;
end loop;
end loop;
end loop;
report " exhaustive control sweep: " & integer'image(n_ctrl)
& " of 192 transitions verified" severity note;
-- ===== B. EXHAUSTIVE length/classification sweep =====
-- Every length 0..64 against every max packet size the spec allows for a
-- full-speed or high-speed endpoint. 65 x 4 = 260 points, which pins the
-- ZLP and short-packet classification over its entire domain.
for j in 0 to 3 loop
case j is
when 0 => mps_i := 8;
when 1 => mps_i := 16;
when 2 => mps_i := 32;
when others => mps_i := 64;
end case;
setmps(mps_i);
for i in 0 to 64 loop
idle; flush <= '1'; step; idle;
wr_commit <= '1'; setlen(i); step; idle;
wait for 1 ns;
check(rd_valid = '1', "the packet is readable whatever its length");
check(to_integer(unsigned(rd_len)) = i,
"and its length came back exactly");
check((rd_zlp = '1') = (i = 0),
"ZLP is reported exactly when the length is zero");
check((rd_short = '1') = (i < mps_i),
"short is reported exactly when the length is below mps");
if i = 0 then
check(rd_class = class_code(PKT_ZLP),
"rd_class classifies a ZLP correctly");
elsif i < mps_i then
check(rd_class = class_code(PKT_SHORT),
"rd_class classifies a short packet correctly");
else
check(rd_class = class_code(PKT_FULL),
"rd_class classifies a full packet correctly");
end if;
rd_ack <= '1'; step; idle;
n_class := n_class + 1;
end loop;
end loop;
report " exhaustive length/classification sweep: " & integer'image(n_class)
& " of 260 points verified" severity note;
-- ===== C. directed: the two stories this block exists for =====
hard_reset; setmps(8);
-- 1. THE ZLP story. A 16-byte transfer on an 8-byte endpoint is two full
-- packets -- and the host cannot tell it is finished until a THIRD,
-- zero-length packet arrives.
wr_commit <= '1'; setlen(8); step; idle;
check(to_integer(unsigned(used)) = 1, "the first full packet is buffered");
wait for 1 ns;
check(rd_short = '0', "a full packet does NOT terminate the transfer");
rd_ack <= '1'; step; idle;
wr_commit <= '1'; setlen(8); step; idle;
rd_ack <= '1'; step; idle;
wr_commit <= '1'; setlen(0); step; idle; -- the ZLP
check(to_integer(unsigned(used)) = 1,
"the ZERO-LENGTH packet occupied a buffer -- it is a real packet");
wait for 1 ns;
check(rd_valid = '1', "and it is readable");
check(rd_zlp = '1', "and reads back as a ZLP");
check(rd_short = '1', "which terminates the transfer");
check(to_integer(unsigned(rd_len)) = 0, "carrying no bytes");
rd_ack <= '1'; step; idle;
check(n_zlp = std_logic_vector(to_unsigned(1, 32)),
"one zero-length packet was counted as a packet");
-- 2. THE PING-PONG story. A write and a read in the SAME cycle must both
-- succeed and leave occupancy unchanged.
hard_reset; setmps(8);
wr_commit <= '1'; setlen(3); step; idle;
wr_commit <= '1'; setlen(4); step; idle;
check(to_integer(unsigned(used)) = 2, "both buffers are full");
check(wr_ready = '0', "so a further packet would have to be NAKed");
wr_commit <= '1'; setlen(5); rd_ack <= '1'; wait for 1 ns;
check(wr_ready = '0', "the write cannot be accepted this cycle");
check(rd_valid = '1', "but the read can proceed");
step; idle;
check(to_integer(unsigned(used)) = 1, "so occupancy fell by one");
check(n_nak = std_logic_vector(to_unsigned(1, 32)),
"and the refused packet was counted as a NAK");
wr_commit <= '1'; setlen(6); rd_ack <= '1'; step; idle;
check(to_integer(unsigned(used)) = 1,
"a simultaneous write and read left occupancy UNCHANGED -- full rate");
wait for 1 ns;
check(to_integer(unsigned(rd_len)) = 6,
"and the surviving packet is the one just written");
-- 3. Ordering. Packets come out oldest-first, not newest-first.
hard_reset; setmps(8);
wr_commit <= '1'; setlen(11); step; idle;
wr_commit <= '1'; setlen(22); step; idle;
wait for 1 ns;
check(to_integer(unsigned(rd_len)) = 11, "the OLDEST packet is offered first");
rd_ack <= '1'; step; idle;
wait for 1 ns;
check(to_integer(unsigned(rd_len)) = 22, "then the newer one");
-- 4. Flush clears BOTH pointers, not just the count.
hard_reset; setmps(8);
wr_commit <= '1'; setlen(11); step; idle;
rd_ack <= '1'; step; idle; -- read pointer now at buffer 1
wr_commit <= '1'; setlen(33); step; idle;
flush <= '1'; step; idle;
check(to_integer(unsigned(used)) = 0, "the flush emptied the FIFO");
wr_commit <= '1'; setlen(44); step; idle;
wait for 1 ns;
check(to_integer(unsigned(rd_len)) = 44,
"and the next packet read is the one just written -- both pointers reset");
-- ===== D. randomised =====
-- ieee.math_real.uniform is a genuinely different generator from either
-- Verilog builtin, which is what makes this column independent evidence.
hard_reset;
for i in 0 to 39999 loop
rnd(iv, 4);
case iv is
when 0 => mps_i := 8;
when 1 => mps_i := 16;
when 2 => mps_i := 32;
when others => mps_i := 64;
end case;
setmps(mps_i);
rnd(iv, 3); if iv /= 0 then wr_commit <= '1'; else wr_commit <= '0'; end if;
rnd(iv, 3); if iv /= 0 then rd_ack <= '1'; else rd_ack <= '0'; end if;
rnd(iv, 64); if iv = 0 then flush <= '1'; else flush <= '0'; end if;
-- bias hard toward the boundary lengths: 0 (ZLP), mps-1, mps
rnd(iv, 4);
case iv is
when 0 => setlen(0);
when 1 => setlen(mps_i - 1);
when 2 => setlen(mps_i);
when others => rnd(iv, 65); setlen(iv);
end case;
step;
end loop;
for i in 0 to 2 loop
check(n_occ(i) > 0, "every occupancy level was reached");
end loop;
check(n_both > 2000, "simultaneous write+read happened often");
check(n_zlp_stored > 2000, "zero-length packets were stored often");
check(n_nakhit > 500, "the FIFO was found full often");
report " REACH: control=" & integer'image(n_ctrl)
& " class=" & integer'image(n_class)
& " | occupancy: empty=" & integer'image(n_occ(0))
& " one=" & integer'image(n_occ(1))
& " full=" & integer'image(n_occ(2)) severity note;
report " CASES: simultaneous-rw=" & integer'image(n_both)
& " zlps-stored=" & integer'image(n_zlp_stored)
& " short-pkts=" & integer'image(n_shorthit)
& " naks=" & integer'image(n_nakhit) severity note;
report " COUNTERS: written="
& integer'image(to_integer(unsigned(n_written)))
& " read=" & integer'image(to_integer(unsigned(n_read)))
& " zlp=" & integer'image(to_integer(unsigned(n_zlp)))
& " short=" & integer'image(to_integer(unsigned(n_short)))
& " nak=" & integer'image(to_integer(unsigned(n_nak)))
& " flush=" & integer'image(to_integer(unsigned(n_flush)))
severity note;
report " [VHDL] usb_ep_pingpong: " & integer'image(errors) & " errors"
severity note;
if errors = 0 then
report " [VHDL] PASS" severity note;
else
report " [VHDL] FAIL" severity failure;
end if;
running <= false;
wait;
end process;
end architecture;11. Exhaustive Verification
| Measure | Verilog | SystemVerilog | VHDL |
|---|---|---|---|
| Control transitions | 192 / 192 | 192 / 192 | 192 / 192 |
| Length/class points | 260 / 260 | 260 / 260 | 260 / 260 |
| occupancy 0 reached | 9785 | 9634 | 9583 |
| occupancy 1 reached | 24107 | 24099 | 24220 |
| occupancy 2 reached | 7674 | 7833 | 7763 |
| simultaneous write+read | 10283 | 10159 | 10317 |
| zero-length packets stored | 5324 | 5270 | 5400 |
| short packets stored | 13414 | 13473 | 13483 |
| FIFO found full (NAK) | 5020 | 5084 | 5050 |
| packets written | 21128 | 21055 | 21198 |
| packets read | 20522 | 20441 | 20591 |
| flushes | 633 | 631 | 598 |
| Result | PASS | PASS | PASS |
The two rows in bold are the ones the chapter is about. 10 000 simultaneous write-and-read cycles is what makes the ping-pong claim evidence rather than assertion, and 5300 stored ZLPs is what makes the packet-FIFO claim the same. Both required deliberate stimulus bias — the randomised phase picks length 0 a quarter of the time, because a uniform draw over 0…64 would produce a ZLP 1.5% of the time and the case that matters most would be the case tested least.
12. Mutation Testing
| # | Mutation | Verilog | SysVer | VHDL |
|---|---|---|---|---|
| F1 | a zero-length packet is not stored | 283830 | 283550 | 285117 |
| F2 | flush resets the count but not the pointers | 46980 | 51306 | 45680 |
| F3 | a write is blocked while a read is in progress | 293453 | 293635 | 294518 |
| F4 | rd_short uses <= instead of < | 6587 | 6269 | 6503 |
| F5 | the read side uses the write pointer | 75366 | 75373 | 75817 |
| F6 | rd_zlp does not require a packet to exist | 6804 | 6915 | 6714 |
| F7 | wr_ready collapses to a single buffer | 358973 | 359206 | 358861 |
| — | unmutated baseline | 0 | 0 | 0 |
All seven die in all three languages, all counts distinct, columns within 10%.
F1, F3 and F7 are enormous — 280 000 to 360 000 — and that is informative rather than impressive. All three change the FIFO's occupancy behaviour, and occupancy drives wr_ready, rd_valid, rd_len, rd_class, used and four counters. One wrong term desynchronises the shadow model permanently, and every subsequent cycle fails. A number this large means "this mutation breaks the block", not "this check is sharp".
The informative ones are F4 and F6, at ~6500 and ~6800. Both are single-condition errors on a single output:
- F4 (
<becomes<=) fires only whenrd_lenis exactlymps— one length value out of 65, which the biased randomiser hits about a quarter of the time. - F6 (
rd_zlpwithoutrd_valid) fires only when the FIFO is empty and the stale buffer contents happen to be zero.
They are the two mutations that would survive a careless bench, and they are also the two that correspond to real, shipped bugs: F4 truncates every transfer that is a multiple of the packet size; F1 hangs every transfer that is a multiple of the packet size. The same input condition, opposite failure modes, and a bench that only ever sends 100-byte transfers finds neither.
13. Debugging Walkthrough: "It Works, Except on Files That Are a Round Number of Kilobytes"
The report. A USB device transfers files correctly, except that occasionally a transfer never completes — the host's read blocks for ever. The customer notices it mostly on .bin firmware images. Never on .jpg.
Step 1 — find the pattern. Collect the sizes of the transfers that hang. Every one is a multiple of 512. The ones that work are not. .bin images are padded to a block size; .jpg files are whatever size they are. That is a length-dependent bug, and the only length that matters to USB is wMaxPacketSize.
Step 2 — confirm the arithmetic. The endpoint is a high-speed bulk endpoint, wMaxPacketSize = 512. Every hanging transfer is an exact multiple of 512; every working transfer is not. So the transfers that hang are exactly those with no short packet to terminate them — the ones that need a ZLP.
Step 3 — is the ZLP being sent? Trace the bus. The device sends its last full packet and then nothing. The host issues IN token after IN token and gets NAK each time. So the device is not sending the ZLP, and the question is whether firmware asked for one.
Step 4 — which side dropped it? Instrument n_written on the endpoint and compare against the number of packets firmware committed. Firmware committed 17; the FIFO recorded 16. The ZLP was committed and not stored.
Step 5 — the cause. do_wr was gated on wr_len != 0, because zero bytes looked like nothing to do. Mutation F1, in production.
Step 6 — why the bench never found it. The bring-up tests sent 64, 100, 1000 and 4096 bytes. On a 512-byte endpoint, 4096 is a multiple — so the test should have caught it. It did not, because the bring-up test read a fixed number of bytes and never waited for the transfer to terminate. The test asserted the data was right, not that the transfer ended.
14. UVM: Making the Empty Packet a First-Class Stimulus
14.1 The transaction
class usb_fifo_item extends uvm_sequence_item;
`uvm_object_utils(usb_fifo_item)
rand bit wr_commit;
rand bit [6:0] wr_len;
rand bit rd_ack;
rand bit flush;
rand bit [6:0] mps;
// Only the four legal full-speed / high-speed packet sizes.
constraint c_legal_mps { mps inside {8, 16, 32, 64}; }
// A length can never exceed the endpoint's max packet size -- that is a
// protocol invariant, not a stimulus choice.
constraint c_len_fits { wr_len <= mps; }
// THE bias. A uniform draw over 0..64 produces a ZLP about 1.5% of the
// time, which would make the single most important case the least tested.
// The three boundary lengths get three quarters of the distribution.
constraint c_boundary_heavy {
wr_len dist { 0 := 25, // the ZERO-LENGTH PACKET
mps - 1 := 25, // the largest short packet
mps := 25, // a full packet: NOT a terminator
[1:mps-2] := 25 };
}
constraint c_flush_rare { flush dist {0 := 63, 1 := 1}; }
function new(string name = "usb_fifo_item"); super.new(name); endfunction
function string convert2string();
return $sformatf("wr=%0b len=%0d rd=%0b flush=%0b mps=%0d",
wr_commit, wr_len, rd_ack, flush, mps);
endfunction
endclass14.2 Sequences
// THE sequence for this chapter. It sends a complete transfer whose length is
// an EXACT MULTIPLE of the packet size -- n full packets and then the ZLP
// that terminates it. This is the transfer a byte FIFO hangs on, and random
// stimulus produces the pattern only by accident.
class multiple_of_mps_transfer_seq extends uvm_sequence #(usb_fifo_item);
`uvm_object_utils(multiple_of_mps_transfer_seq)
function new(string name = "multiple_of_mps_transfer_seq");
super.new(name);
endfunction
rand int unsigned n_transfers;
constraint c_n { n_transfers inside {[50:100]}; }
task body();
repeat (n_transfers) begin
usb_fifo_item it;
int unsigned pkt_size = 64;
int unsigned n_full = $urandom_range(1, 6);
// n full packets: the transfer is a multiple of the packet size, so
// NOTHING so far tells the host it is finished.
repeat (n_full) begin
it = usb_fifo_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { wr_commit == 1; mps == 64;
wr_len == 64; flush == 0; })
`uvm_error("RAND", "full-packet randomize failed")
finish_item(it);
end
// ...and the ZLP that does. A device that drops this hangs the host.
it = usb_fifo_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { wr_commit == 1; mps == 64;
wr_len == 0; flush == 0; })
`uvm_error("RAND", "ZLP randomize failed")
finish_item(it);
end
endtask
endclass
// Sustained full-rate traffic: a write and a read every cycle. The property
// under test is that occupancy holds steady rather than sawtoothing, which is
// the difference between real ping-pong and two buffers used one at a time.
class full_rate_seq extends uvm_sequence #(usb_fifo_item);
`uvm_object_utils(full_rate_seq)
function new(string name = "full_rate_seq"); super.new(name); endfunction
task body();
repeat (2000) begin
usb_fifo_item it = usb_fifo_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { wr_commit == 1; rd_ack == 1; flush == 0; })
`uvm_error("RAND", "full-rate randomize failed")
finish_item(it);
end
endtask
endclass
// A flush arriving at the awkward moment: with the read pointer advanced and
// a packet still buffered. This is the state in which resetting the count
// without resetting the pointers goes wrong.
class flush_mid_stream_seq extends uvm_sequence #(usb_fifo_item);
`uvm_object_utils(flush_mid_stream_seq)
function new(string name = "flush_mid_stream_seq"); super.new(name); endfunction
task body();
repeat (400) begin
usb_fifo_item it;
// write, read (read pointer advances), write (one packet buffered)
for (int i = 0; i < 3; i++) begin
it = usb_fifo_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { wr_commit == (i != 1);
rd_ack == (i == 1);
flush == 0; })
`uvm_error("RAND", "pre-flush randomize failed")
finish_item(it);
end
// ...and now flush, with the pointers out of phase
it = usb_fifo_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { flush == 1; })
`uvm_error("RAND", "flush randomize failed")
finish_item(it);
end
endtask
endclass14.3 The scoreboard
class usb_fifo_scoreboard extends uvm_scoreboard;
`uvm_component_utils(usb_fifo_scoreboard)
uvm_analysis_imp #(usb_fifo_mon_item, usb_fifo_scoreboard) ap;
// An independent queue of the packet LENGTHS the bus committed. A queue,
// not a byte count -- modelling the DUT as a byte FIFO in the scoreboard
// would reproduce the very bug under test.
int unsigned q[$];
int unsigned n_zlp_stored, n_simultaneous, n_full_nak, n_flushes;
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
function void write(usb_fifo_mon_item t);
bit do_wr, do_rd;
do_wr = t.wr_commit && (q.size() < 2);
do_rd = t.rd_ack && (q.size() > 0);
// ---- Check the outputs BEFORE applying this cycle's effects ----
if (q.size() == 0) begin
if (t.rd_valid)
`uvm_error("EMPTY", "an empty FIFO offered a packet");
if (t.rd_zlp || t.rd_short)
`uvm_error("EMPTY",
"an empty FIFO described a packet it does not hold -- that is a fabricated transfer terminator");
end else begin
if (t.rd_len != q[0])
`uvm_error("ORDER",
$sformatf("rd_len=%0d but the oldest buffered packet is %0d -- packets are coming out of order",
t.rd_len, q[0]))
// ---- THE property. Zero bytes is a packet, and it terminates. ----
if ((q[0] == 0) && !t.rd_zlp)
`uvm_error("ZLP",
"a stored zero-length packet did not read back as a ZLP")
if ((q[0] == 0) && !t.rd_short)
`uvm_error("ZLP",
"a ZLP was not reported as terminating the transfer")
// ---- And a full packet does NOT terminate. ----
if ((q[0] == t.mps) && t.rd_short)
`uvm_error("SHORT",
"a full-size packet was reported short -- the transfer ends one packet early")
end
if (t.used != q.size())
`uvm_error("OCCUPANCY",
$sformatf("used=%0d but the scoreboard holds %0d packets",
t.used, q.size()))
// ---- Ping-pong: both sides may act in the same cycle ----
if (t.wr_commit && t.rd_ack && (q.size() == 1)) begin
if (!do_wr || !do_rd)
`uvm_error("PINGPONG",
"a simultaneous write and read did not both proceed -- two buffers at the throughput of one")
n_simultaneous++;
end
if (t.wr_commit && (q.size() == 2)) n_full_nak++;
// ---- Apply this cycle to the scoreboard's own model ----
if (t.flush) begin
q.delete();
n_flushes++;
end else begin
if (do_wr) begin
q.push_back(t.wr_len);
if (t.wr_len == 0) n_zlp_stored++;
end
if (do_rd) void'(q.pop_front());
end
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SB", $sformatf(
"zlps=%0d simultaneous=%0d full-naks=%0d flushes=%0d",
n_zlp_stored, n_simultaneous, n_full_nak, n_flushes), UVM_LOW)
// A run that never stored a ZLP has not tested the thing this block is
// for, however many megabytes it moved.
if (n_zlp_stored == 0) `uvm_error("COVERAGE",
"no zero-length packet was ever stored -- the packet-FIFO property is untested")
if (n_simultaneous == 0) `uvm_error("COVERAGE",
"no simultaneous write and read -- the second buffer is untested")
if (n_full_nak == 0) `uvm_error("COVERAGE", "the FIFO was never found full")
if (n_flushes == 0) `uvm_error("COVERAGE", "no flush was ever applied")
endfunction
endclassThe scoreboard models the FIFO as a queue of lengths, and that choice is doing real verification work. A scoreboard written as "expected byte count" would go wrong in exactly the way the DUT under mutation F1 goes wrong, and would agree with it perfectly. A reference model that shares the design's misconception confirms nothing.
14.4 Functional coverage
covergroup ep_fifo_cg with function sample(
bit [6:0] len, bit [6:0] mps, bit [1:0] used, bit wr, bit rd, bit flush,
pkt_class_e cls);
// The classification, with ZLP as its own bin -- not folded into "short".
cp_class : coverpoint cls {
bins none = {PKT_NONE};
bins zlp = {PKT_ZLP}; // the bin that matters
bins short_pkt = {PKT_SHORT};
bins full = {PKT_FULL};
}
cp_used : coverpoint used { bins empty = {0}; bins half = {1}; bins full = {2}; }
cp_mps : coverpoint mps { bins sizes[] = {8, 16, 32, 64}; }
// The three boundary lengths by name, relative to mps -- an absolute
// coverpoint on len would report 100% while never hitting the boundary
// for the packet size actually in use.
cp_len_rel : coverpoint (len == 0 ? 0 : (len < mps ? 1 : 2)) {
bins zero = {0};
bins below = {1};
bins at_max = {2};
}
// THE cross. Every packet class at every legal max packet size: a ZLP on a
// 64-byte endpoint and a ZLP on an 8-byte endpoint are the same fact, and
// a design that special-cases one of them should be caught.
x_class_mps : cross cp_class, cp_mps;
// Simultaneity at every occupancy -- the ping-pong property.
cp_both : coverpoint (wr && rd) { bins simultaneous = {1}; bins single = {0}; }
x_both_used : cross cp_both, cp_used;
// A flush at every occupancy, which is where the pointer-reset bug lives.
cp_flush : coverpoint flush { bins flushing = {1}; }
x_flush_used : cross cp_flush, cp_used;
endgroupcp_len_rel is worth copying. An absolute coverpoint on len with bins for 0, 63 and 64 closes happily while never once presenting a boundary length for the mps actually in effect — 63 is the short-packet boundary only when mps is 64. Expressing the coverpoint relative to the parameter is what makes the cross with cp_mps mean what it looks like it means.
15. SystemVerilog Assertions
module usb_ep_pingpong_sva
import usb_pingpong_pkg::*;
#(
parameter int LEN_W = 7
) (
input logic clk,
input logic rst_n,
input logic flush,
input logic [LEN_W-1:0] mps,
input logic wr_commit,
input logic [LEN_W-1:0] wr_len,
input logic rd_ack,
input logic wr_ready,
input logic rd_valid,
input logic [LEN_W-1:0] rd_len,
input logic rd_zlp,
input logic rd_short,
input pkt_class_e rd_class,
input logic [1:0] used,
input logic nak_needed
);
default clocking cb @(posedge clk); endclocking
default disable iff (!rst_n);
// ---- 1. THE property. Committing a ZERO-LENGTH packet with room
// ---- available raises the occupancy, exactly as any other packet does.
property p_zlp_occupies_a_buffer;
(wr_commit && wr_ready && (wr_len == '0) && !flush && !rd_ack)
|=> (used == $past(used) + 2'd1);
endproperty
a_zlp_occupies_a_buffer : assert property (p_zlp_occupies_a_buffer)
else $error("a zero-length packet was committed and did not occupy a buffer");
// ---- 2. Ping-pong: a simultaneous write and read leave occupancy alone,
// ---- and BOTH must actually happen.
property p_simultaneous_holds_occupancy;
(wr_commit && wr_ready && rd_ack && rd_valid && !flush)
|=> (used == $past(used));
endproperty
a_simultaneous_holds_occupancy :
assert property (p_simultaneous_holds_occupancy)
else $error("a simultaneous write and read changed occupancy -- one of them was dropped");
// ---- 3. A ZLP always terminates the transfer. ----
property p_zlp_is_short;
rd_zlp |-> rd_short;
endproperty
a_zlp_is_short : assert property (p_zlp_is_short);
// ---- 4. A FULL packet never does. ----
property p_full_is_not_short;
(rd_valid && (rd_len == mps)) |-> !rd_short;
endproperty
a_full_is_not_short : assert property (p_full_is_not_short)
else $error("a packet of exactly mps bytes was reported short -- every multiple-of-mps transfer ends early");
// ---- 5. An empty FIFO describes no packet at all. ----
property p_empty_describes_nothing;
!rd_valid |-> (!rd_zlp && !rd_short && (rd_class == PKT_NONE));
endproperty
a_empty_describes_nothing : assert property (p_empty_describes_nothing)
else $error("an empty FIFO fabricated a transfer terminator");
// ---- 6. rd_class agrees with the booleans it summarises. ----
property p_class_agrees;
((rd_class == PKT_NONE) == !rd_valid)
&& ((rd_class == PKT_ZLP) == rd_zlp)
&& (((rd_class == PKT_ZLP) || (rd_class == PKT_SHORT)) == rd_short);
endproperty
a_class_agrees : assert property (p_class_agrees);
// ---- 7. Occupancy never leaves the two buffers that exist. ----
property p_occupancy_bounded;
used <= 2'd2;
endproperty
a_occupancy_bounded : assert property (p_occupancy_bounded);
// ---- 8. Readiness and validity are exactly occupancy, restated. ----
property p_ready_valid_track_occupancy;
(wr_ready == (used != 2'd2)) && (rd_valid == (used != 2'd0))
&& (nak_needed == !wr_ready);
endproperty
a_ready_valid_track_occupancy :
assert property (p_ready_valid_track_occupancy);
// ---- 9. A flush empties the FIFO completely, in one cycle. ----
property p_flush_empties;
flush |=> (used == 2'd0);
endproperty
a_flush_empties : assert property (p_flush_empties);
// ---- 10. Nothing is accepted into a full FIFO. ----
property p_no_overflow;
(used == 2'd2) |-> !wr_ready;
endproperty
a_no_overflow : assert property (p_no_overflow);
// ---- Cover: the cases that matter were actually reached. ----
c_zlp_stored : cover property ((wr_commit && wr_ready && (wr_len == '0)));
c_simultaneous : cover property ((wr_commit && wr_ready && rd_ack && rd_valid));
c_full : cover property ((used == 2'd2));
c_flush_loaded : cover property ((flush && (used != 2'd0)));
endmodule
bind usb_ep_pingpong usb_ep_pingpong_sva #(.LEN_W(LEN_W)) u_sva (.*);16. Common Misconceptions
"A zero-length packet is a no-op." It is the transfer terminator. Dropping it hangs the host on every transfer whose length is an exact multiple of the packet size.
"The FIFO should store bytes — firmware wants bytes." Firmware also needs to know where each packet ended, and a byte FIFO throws that away. Store packets; hand firmware the bytes of one packet at a time.
"A packet of mps bytes is the biggest packet, so it must be the last one." It is the opposite: a full packet means more is coming. Only a packet smaller than mps ends a transfer.
"Ping-pong just means two buffers." It means two buffers plus the ability to use both in the same cycle. A two-buffer design that serialises the write behind the read has the area cost and none of the throughput benefit — and it passes every test that does not check occupancy on a simultaneous cycle.
"Alternating between A and B is the same as a read pointer and a write pointer." Only while nothing interrupts the rhythm. A flush at the wrong moment leaves the two ends of a single alternating bit disagreeing, and firmware reads the buffer the bus is filling.
"rd_zlp needs no rd_valid guard — the length will be zero anyway when empty." The length is whatever is left in the buffer the read pointer addresses. Sometimes that is zero, and then an empty FIFO announces a transfer terminator that nothing sent.
"A flush only needs to clear the occupancy." It has to clear both pointers as well, or the next packet written and the next packet read are different buffers.
17. Exercises
1. Gate do_wr on wr_len != 0 and run the suite. Predict which of the two exhaustive domains catches it first, and why the count is ~284 000 rather than the ~5300 ZLPs the randomiser generates.
2. Deepen the FIFO from two buffers to four, keeping the same interface. Which of the seven mutations change their counts most, and which is now harder to kill? (Hint: F7 collapses wr_ready to "empty"; with four buffers that is a bigger lie but a rarer one.)
3. Property 9 does not catch mutation F2. Write a two-cycle SVA property that does, using $past to remember the length written before the flush. Then explain why the scoreboard's ordering check finds it without any temporal reasoning at all.
4. The c_boundary_heavy constraint gives length 0 a quarter of the distribution. Remove it, re-run the randomised phase, and measure how far F1's count falls. Use the result to argue for or against biased stimulus in a regression whose job is to find unknown bugs rather than confirm known ones.
5. cp_len_rel expresses the boundary relative to mps. Construct a regression in which an absolute coverpoint on len reports 100% while x_class_mps has an empty bin, and say which of the two you would gate a release on.
6. Add an overflow_err output that pulses if wr_commit is asserted when used == 2. Is this an error the endpoint should report, or normal flow control? Justify the answer using Chapter 21.1's handshake rules, then write the assertion that pins your choice.
18. Summary
| Idea | Why it matters |
|---|---|
| A short packet terminates a transfer | there is no length field in the protocol |
| A zero-length packet is a real packet | it is the terminator when the data ends on a boundary |
| ...so the FIFO stores packets, not bytes | a byte FIFO cannot represent zero bytes |
| A ZLP occupies a buffer | dropping it hangs every multiple-of-mps transfer |
rd_short is <, never <= | <= truncates every multiple-of-mps transfer |
rd_zlp requires rd_valid | or an empty FIFO fabricates a terminator |
| Two buffers, used in the same cycle | that simultaneity is the whole point |
| Two buffers are a ring, not a pair | packets come out oldest-first |
| A flush resets both pointers | not just the occupancy |
| 192 control + 260 classification points | two exhaustive domains, each named |
| 7 mutations, all killed in 3 languages | F1 and F4 are the same input condition, opposite failures |
Tooling
| Step | Command |
|---|---|
| Verilog-2005 | iverilog -g2005 -o pp_v.out pp_v.v pp_v_tb.v && ./pp_v.out |
| SystemVerilog | iverilog -g2012 -o pp_sv.out pp_sv.sv pp_sv_tb.sv && ./pp_sv.out |
| VHDL-2008 analyse | nvc --std=2008 -a pp_vhdl.vhd pp_vhdl_tb.vhd |
| VHDL-2008 elaborate | nvc --std=2008 -e tb_pp_vhdl |
| VHDL-2008 run | nvc --std=2008 -r tb_pp_vhdl |
| One mutation | iverilog -g2005 -DMUT_F1 -o mm pp_v_mut.v pp_v_tb.v && ./mm |
All three implementations pass with 0 errors: 192 of 192 control transitions, 260 of 260 length/classification points, 40 000 randomised cycles, every occupancy level reached and asserted reached.
Chapter 21.3 — Descriptor Engine is where the short-packet rule from this chapter meets the host's own idea of how much it wants. A GET_DESCRIPTOR request carries a wLength field, and the device must return the smaller of what was asked for and what exists — then decide, from that comparison, whether a ZLP is required to terminate the transfer. The rule is one line long and gets written wrong in both directions.
Continue learning
Related tutorials
- Related topic
Descriptor Engine
wLength is the size of the host's buffer, not a preference — and whether a zero-length packet must follow depends on comparing what was sent against what was asked for, not against what exists.
- Related topic
USB in Embedded Devices
On a microcontroller the controller is a peripheral, and the protocol can be perfectly correct while the device goes deaf. Everything turns on one question — who owns this buffer right now — answered by one bit per buffer and exhausted over 132 transitions in three languages.
- Related topic
Endpoint Logic
A lost ACK and a lost data packet look identical to the host, so it resends the same bytes — and the data toggle is the only thing that tells a device a retransmission from new data.
- Related topic
Protocol Engine
The CRC is the last thing on the wire, so a device must write the payload before it knows whether the payload is good — provisional writes, commit and rollback, and why you cannot change your mind mid-packet.
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.
