USB · Module 21
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.
Three chapters of this module have decided whether to accept a packet. 21.1 decided it from the toggle, 21.2 from buffer occupancy, 21.3 from lengths.
This chapter is about the fact that the decision arrives too late.
1. The CRC Is the Last Thing on the Wire
A USB DATA packet is a PID, a payload, and a 16-bit CRC covering that payload:
[ DATA0 ][ b0 b1 b2 b3 ......... bN ][ CRC16 ]
^
the first moment the device
knows whether ANY of those
bytes are goodThe bytes arrived one at a time at the bus's rate — 480 Mb/s on a high-speed link — and the device had to put each one somewhere as it came. It cannot hold a 1024-byte payload in flip-flops while it waits: that is what the endpoint buffer is, and there is only one of it.
So the device must start writing before it knows whether the data is good. There is no version of this design in which it does not.
2. Two Pointers, and Only One of Them Is Real
The answer is to make the write provisional.
committed ---->| firmware reads up to here
|###### provisional ######>|
\_ bytes written, but not yet real _/The committed pointer is what firmware can see. The provisional pointer runs ahead of it during reception. At the end of the packet exactly one of two things happens:
| what happens | cost | |
|---|---|---|
| COMMIT | committed <= provisional — the bytes become visible | one pointer assignment |
| ROLLBACK | provisional <= committed — the bytes are unwritten | one pointer assignment |
The payload was physically written to the buffer either way. What makes it "not written" is that nothing points at it, and the next packet overwrites it. A rollback costs no cycles and no bus time — which matters, because the device has to be ready for the next transaction immediately.
The provisional write, and the two ways a packet can end
3. You Cannot Change Your Mind Mid-Packet
The second constraint, and the one that surprises people.
Suppose the buffer fills up halfway through reception. The device now knows it cannot accept this packet. So why keep receiving?
Because there is nowhere to put the refusal.
A USB transaction has exactly one place a handshake may be sent: after the packet ends. There is no mid-packet abort. There is no flow control inside a packet. There is no way to tell the host to stop talking. The host will transmit every byte it intended to and then wait for an answer.
So the engine:
- keeps receiving,
- discards the bytes that do not fit,
- records that an overflow happened,
- and NAKs at the end.
4. The Decision, and Its Order
Every rule in this chapter's decision comes from an earlier one, which is what makes the block a composition rather than a fourth set of rules:
| Condition | Handshake | Buffer | From |
|---|---|---|---|
| endpoint halted | STALL | rollback | 21.1 — a halt outranks everything but SETUP |
| CRC failed | silence | rollback | 21.1 — a NAK would claim knowledge the device lacks |
| toggle mismatch | ACK | rollback | 21.1 — a retransmission: re-ACK, store nothing |
| overflowed | NAK | rollback | 21.2 — no room; ask again |
| otherwise | ACK | COMMIT |
Four different causes, three different handshakes, and from the host's side they are nearly indistinguishable — which is why the design also emits a drop_reason. DROP_CRC means fix your cable; DROP_NO_ROOM means firmware is too slow; DROP_TOGGLE means an ACK went missing; DROP_HALTED means software has not cleared a halt. Four different engineers, four different afternoons.
The engine's three states, and the one-cycle decision that ends a packet
5. What We Are Building
usb_packet_engine #(CAP = 16, PW = 5)
from the bus to the buffer
------------ -------------
rx_start prov_wr_en
rx_byte_valid prov_wr_addr
rx_byte [7:0] prov_wr_data
rx_end
rx_crc_ok } valid only wr_ptr COMMITTED: firmware sees this
toggle_match } at rx_end prov_ptr PROVISIONAL: we write here
ep_halted
flush receiving / overflowed
commit / rollback
handshake NONE / ACK / NAK / STALL
drop_reason NONE / HALTED / CRC /
TOGGLE / NO_ROOM
n_commit / n_crc_drop / n_dup_drop / n_nak_space / n_stall / n_bytes6. Verilog-2005 Implementation
// usb_packet_engine -- the block that actually moves the bytes, and the
// constraint that shapes it: THE HANDSHAKE IS DUE BEFORE THE CRC IS KNOWN.
//
// THE ORDERING PROBLEM
//
// A USB DATA packet is a PID, a payload, and a 16-bit CRC covering that
// payload. The CRC is the LAST thing on the wire:
//
// [ DATA0 ][ b0 b1 b2 ... bN ][ CRC16 ]
// ^
// the first moment the device knows
// whether any of those bytes are good
//
// But the bytes arrived one at a time, at the bus's rate, and the device has
// nowhere to put them except the endpoint buffer. It cannot hold a 1024-byte
// payload in flip-flops while it waits for the CRC -- that is the buffer,
// and there is only one of it.
//
// So the device MUST start writing before it knows whether the data is good.
//
// THE ANSWER IS A PROVISIONAL WRITE
//
// Two pointers. The COMMITTED pointer is what firmware can see. The
// PROVISIONAL pointer runs ahead of it during reception:
//
// committed ---->| firmware reads up to here
// |###### provisional ######>|
// \_ bytes written, but not yet real _/
//
// At the end of the packet, exactly one of two things happens:
//
// COMMIT the CRC passed and the packet is wanted:
// committed <= provisional. The bytes become visible.
//
// ROLLBACK anything went wrong:
// provisional <= committed. The bytes are unwritten by the
// simple expedient of never having been pointed at.
//
// The payload was physically written to the buffer either way. What makes it
// "not written" is that nothing points at it, and the next packet overwrites
// it. A rollback costs one pointer assignment and no cycles.
//
// YOU CANNOT CHANGE YOUR MIND MID-PACKET
//
// The second constraint, and the one that surprises people. Suppose the
// buffer fills up halfway through reception. The device now knows it cannot
// accept this packet -- so why keep receiving?
//
// Because THERE IS NOWHERE TO PUT THE REFUSAL. A USB transaction has exactly
// one place a handshake may be sent: after the packet ends. There is no
// mid-packet abort, no flow control inside a packet, no way to tell the host
// to stop talking. The host will transmit every byte it intended to and then
// wait for an answer.
//
// So the engine keeps receiving, DISCARDS the bytes that do not fit, records
// that an overflow happened, and NAKs at the end. Stopping the byte counter
// early would lose the length; stopping the state machine early would leave
// it out of step with the bus for the next packet.
//
// THE DECISION, AND ITS ORDER
//
// halted -> STALL, rollback (chapter 21.1: halt outranks all)
// CRC failed -> SILENCE, rollback (21.1: a NAK would claim knowledge)
// toggle wrong -> ACK, rollback (21.1: a retransmission, re-ACKed
// and NOT stored)
// overflowed -> NAK, rollback (no room; ask again)
// otherwise -> ACK, COMMIT
module usb_packet_engine #(
parameter CAP = 16, // endpoint buffer capacity in bytes
parameter PW = 5 // pointer width: must hold 0 .. CAP
) (
input wire clk,
input wire rst_n,
input wire rx_start, // a DATA packet has begun
input wire rx_byte_valid, // a payload byte is on rx_byte
input wire [7:0] rx_byte,
input wire rx_end, // the packet ended; the two signals
input wire rx_crc_ok, // below are valid only in this cycle
input wire toggle_match,
input wire ep_halted,
input wire flush,
output wire [PW-1:0] wr_ptr, // COMMITTED: what firmware can see
output wire [PW-1:0] prov_ptr, // PROVISIONAL: where we are writing
output wire prov_wr_en,
output wire [PW-1:0] prov_wr_addr,
output wire [7:0] prov_wr_data,
output wire receiving,
output wire overflowed, // this packet did not fit
output wire commit,
output wire rollback,
output wire [2:0] handshake,
output wire [2:0] drop_reason, // WHY, when it was rolled back
output reg [31:0] n_commit,
output reg [31:0] n_crc_drop,
output reg [31:0] n_dup_drop,
output reg [31:0] n_nak_space,
output reg [31:0] n_stall,
output reg [31:0] n_bytes
);
localparam [2:0] H_NONE = 3'd0, // no handshake leaves the device
H_ACK = 3'd1,
H_NAK = 3'd2,
H_STALL = 3'd3;
reg [PW-1:0] wptr_r;
reg [PW-1:0] prov_r;
reg rx_r;
reg ovf_r;
assign wr_ptr = wptr_r;
assign prov_ptr = prov_r;
assign receiving = rx_r;
assign overflowed = ovf_r;
// A byte is written provisionally only while there is room. Once there is
// not, the packet is doomed -- but reception CONTINUES, because there is
// nowhere to put a refusal until the packet ends.
wire have_room = (prov_r < CAP[PW-1:0]);
wire accepting = rx_r && rx_byte_valid;
assign prov_wr_en = accepting && have_room;
assign prov_wr_addr = prov_r;
assign prov_wr_data = rx_byte;
// A packet carrying the toggle we are NOT expecting is a retransmission of
// one already accepted (chapter 21.1). Its bytes are already in the buffer.
wire dup = !toggle_match;
// THE END-OF-PACKET DECISION. Nothing before rx_end can produce a
// handshake, because nothing before rx_end is known.
assign handshake = !rx_end ? H_NONE
: ep_halted ? H_STALL
: !rx_crc_ok ? H_NONE // silence, never a NAK
: dup ? H_ACK // re-ACK, store nothing
: ovf_r ? H_NAK
: H_ACK;
// The same priority order, reported as a REASON. Four different causes
// produce three different handshakes, and from the host's side they are
// nearly indistinguishable -- so the device says which one it was.
localparam [2:0] DROP_NONE = 3'd0, // nothing was dropped
DROP_HALTED = 3'd1, // the endpoint is halted
DROP_CRC = 3'd2, // the payload was corrupt
DROP_TOGGLE = 3'd3, // a retransmission: already have it
DROP_NO_ROOM = 3'd4; // it did not fit
assign drop_reason = !rx_end ? DROP_NONE
: ep_halted ? DROP_HALTED
: !rx_crc_ok ? DROP_CRC
: dup ? DROP_TOGGLE
: ovf_r ? DROP_NO_ROOM
: DROP_NONE;
// Exactly one of commit and rollback happens at the end of every packet.
assign commit = rx_end && !ep_halted && rx_crc_ok && !dup && !ovf_r;
assign rollback = rx_end && !commit;
// How many bytes this packet actually contributed, if it is committed.
wire [PW-1:0] gained = prov_r - wptr_r;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr_r <= {PW{1'b0}};
prov_r <= {PW{1'b0}};
rx_r <= 1'b0;
ovf_r <= 1'b0;
n_commit <= 32'd0;
n_crc_drop <= 32'd0;
n_dup_drop <= 32'd0;
n_nak_space <= 32'd0;
n_stall <= 32'd0;
n_bytes <= 32'd0;
end else if (flush) begin
// A flush discards everything, provisional and committed alike.
wptr_r <= {PW{1'b0}};
prov_r <= {PW{1'b0}};
rx_r <= 1'b0;
ovf_r <= 1'b0;
end else if (rx_start) begin
// The provisional pointer starts from the committed one, every packet.
// Anything left over from a rolled-back packet is discarded here as
// well as at the rollback -- belt and braces, because starting from a
// stale provisional pointer is the bug that corrupts the NEXT packet.
rx_r <= 1'b1;
prov_r <= wptr_r;
ovf_r <= 1'b0;
end else if (rx_end) begin
rx_r <= 1'b0;
if (commit) begin
wptr_r <= prov_r;
n_commit <= n_commit + 32'd1;
n_bytes <= n_bytes + {{(32-PW){1'b0}}, gained};
end else begin
// ROLLBACK. The bytes are still physically in the buffer; what makes
// them not-written is that nothing points at them any more.
prov_r <= wptr_r;
if (ep_halted) n_stall <= n_stall + 32'd1;
else if (!rx_crc_ok) n_crc_drop <= n_crc_drop + 32'd1;
else if (dup) n_dup_drop <= n_dup_drop + 32'd1;
else n_nak_space <= n_nak_space + 32'd1;
end
end else if (accepting) begin
if (have_room) prov_r <= prov_r + {{(PW-1){1'b0}}, 1'b1};
else ovf_r <= 1'b1; // doomed, but keep receiving
end
end
endmoduleThree details are load-bearing.
prov_r <= wptr_r on rx_start, not only on rollback. Belt and braces: the provisional pointer is re-anchored at the start of every packet as well as at the end of a failed one. Starting a packet from a stale provisional pointer is the bug that corrupts the next packet rather than the current one, which makes it roughly four times harder to find.
have_room is prov_r < CAP, strictly. A packet that fills the buffer exactly is not an overflow — it fits. Mutation H4 makes this < CAP-1 and costs 254 000 failures, because it makes the last byte of the buffer permanently unreachable.
ovf_r is set in the else of if (have_room) — so an overflow is recorded only when a byte actually had nowhere to go. This is what makes the first version of mutation H6 provably equivalent; see §11.
7. SystemVerilog Implementation
// usb_packet_engine -- the block that actually moves the bytes, and the
// constraint that shapes it: THE HANDSHAKE IS DUE BEFORE THE CRC IS KNOWN.
//
// THE ORDERING PROBLEM
//
// A USB DATA packet is a PID, a payload, and a 16-bit CRC covering that
// payload. The CRC is the LAST thing on the wire:
//
// [ DATA0 ][ b0 b1 b2 ... bN ][ CRC16 ]
// ^
// the first moment the device knows
// whether any of those bytes are good
//
// But the bytes arrived one at a time, at the bus's rate, and the device has
// nowhere to put them except the endpoint buffer. It cannot hold a 1024-byte
// payload in flip-flops while it waits for the CRC -- that is the buffer,
// and there is only one of it.
//
// So the device MUST start writing before it knows whether the data is good.
//
// THE ANSWER IS A PROVISIONAL WRITE
//
// Two pointers. The COMMITTED pointer is what firmware can see. The
// PROVISIONAL pointer runs ahead of it during reception:
//
// committed ---->| firmware reads up to here
// |###### provisional ######>|
// \_ bytes written, but not yet real _/
//
// At the end of the packet, exactly one of two things happens:
//
// COMMIT the CRC passed and the packet is wanted:
// committed <= provisional. The bytes become visible.
//
// ROLLBACK anything went wrong:
// provisional <= committed. The bytes are unwritten by the
// simple expedient of never having been pointed at.
//
// The payload was physically written to the buffer either way. What makes it
// "not written" is that nothing points at it, and the next packet overwrites
// it. A rollback costs one pointer assignment and no cycles.
//
// YOU CANNOT CHANGE YOUR MIND MID-PACKET
//
// The second constraint, and the one that surprises people. Suppose the
// buffer fills up halfway through reception. The device now knows it cannot
// accept this packet -- so why keep receiving?
//
// Because THERE IS NOWHERE TO PUT THE REFUSAL. A USB transaction has exactly
// one place a handshake may be sent: after the packet ends. There is no
// mid-packet abort, no flow control inside a packet, no way to tell the host
// to stop talking. The host will transmit every byte it intended to and then
// wait for an answer.
//
// So the engine keeps receiving, DISCARDS the bytes that do not fit, records
// that an overflow happened, and NAKs at the end. Stopping the byte counter
// early would lose the length; stopping the state machine early would leave
// it out of step with the bus for the next packet.
//
// THE DECISION, AND ITS ORDER
//
// halted -> STALL, rollback (chapter 21.1: halt outranks all)
// CRC failed -> SILENCE, rollback (21.1: a NAK would claim knowledge)
// toggle wrong -> ACK, rollback (21.1: a retransmission, re-ACKed
// and NOT stored)
// overflowed -> NAK, rollback (no room; ask again)
// otherwise -> ACK, COMMIT
package usb_pktengine_pkg;
typedef enum logic [2:0] {
H_NONE = 3'd0, // no handshake leaves the device at all
H_ACK = 3'd1,
H_NAK = 3'd2,
H_STALL = 3'd3
} handshake_e;
// WHY a packet was rolled back. Four different reasons that all look like
// "the data did not arrive" from the host's side, and all need different
// action from whoever is debugging the device.
typedef enum logic [2:0] {
DROP_NONE = 3'd0, // nothing was dropped
DROP_HALTED = 3'd1, // the endpoint is halted
DROP_CRC = 3'd2, // the payload was corrupt
DROP_TOGGLE = 3'd3, // a retransmission: we already have these bytes
DROP_NO_ROOM = 3'd4 // it did not fit
} drop_reason_e;
endpackage
module usb_packet_engine
import usb_pktengine_pkg::*;
#(
parameter int CAP = 16, // endpoint buffer capacity in bytes
parameter int PW = 5 // pointer width: must hold 0 .. CAP
) (
input logic clk,
input logic rst_n,
input logic rx_start, // a DATA packet has begun
input logic rx_byte_valid, // a payload byte is on rx_byte
input logic [7:0] rx_byte,
input logic rx_end, // the packet ended; the two signals
input logic rx_crc_ok, // below are valid only in this cycle
input logic toggle_match,
input logic ep_halted,
input logic flush,
output logic [PW-1:0] wr_ptr, // COMMITTED: what firmware can see
output logic [PW-1:0] prov_ptr, // PROVISIONAL: where we are writing
output logic prov_wr_en,
output logic [PW-1:0] prov_wr_addr,
output logic [7:0] prov_wr_data,
output logic receiving,
output logic overflowed, // this packet did not fit
output logic commit,
output logic rollback,
output handshake_e handshake,
output drop_reason_e drop_reason, // WHY, when it was rolled back
output logic [31:0] n_commit,
output logic [31:0] n_crc_drop,
output logic [31:0] n_dup_drop,
output logic [31:0] n_nak_space,
output logic [31:0] n_stall,
output logic [31:0] n_bytes
);
logic [PW-1:0] wptr_r;
logic [PW-1:0] prov_r;
logic rx_r;
logic ovf_r;
assign wr_ptr = wptr_r;
assign prov_ptr = prov_r;
assign receiving = rx_r;
assign overflowed = ovf_r;
// A byte is written provisionally only while there is room. Once there is
// not, the packet is doomed -- but reception CONTINUES, because there is
// nowhere to put a refusal until the packet ends.
logic have_room, accepting;
assign have_room = (prov_r < PW'(CAP));
assign accepting = rx_r && rx_byte_valid;
assign prov_wr_en = accepting && have_room;
assign prov_wr_addr = prov_r;
assign prov_wr_data = rx_byte;
// A packet carrying the toggle we are NOT expecting is a retransmission of
// one already accepted (chapter 21.1). Its bytes are already in the buffer.
logic dup;
assign dup = !toggle_match;
// THE END-OF-PACKET DECISION. Nothing before rx_end can produce a
// handshake, because nothing before rx_end is known.
always_comb begin
if (!rx_end) handshake = H_NONE;
else if (ep_halted) handshake = H_STALL;
else if (!rx_crc_ok) handshake = H_NONE; // silence, never a NAK
else if (dup) handshake = H_ACK; // re-ACK, store nothing
else if (ovf_r) handshake = H_NAK;
else handshake = H_ACK;
end
// The same priority order, reported as a REASON. Four different causes
// produce three different handshakes, and from the host's side they are
// nearly indistinguishable -- so the device says which one it was.
always_comb begin
if (!rx_end) drop_reason = DROP_NONE;
else if (ep_halted) drop_reason = DROP_HALTED;
else if (!rx_crc_ok) drop_reason = DROP_CRC;
else if (dup) drop_reason = DROP_TOGGLE;
else if (ovf_r) drop_reason = DROP_NO_ROOM;
else drop_reason = DROP_NONE;
end
// Exactly one of commit and rollback happens at the end of every packet.
assign commit = rx_end && !ep_halted && rx_crc_ok && !dup && !ovf_r;
assign rollback = rx_end && !commit;
// How many bytes this packet actually contributed, if it is committed.
logic [PW-1:0] gained;
assign gained = prov_r - wptr_r;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr_r <= '0;
prov_r <= '0;
rx_r <= 1'b0;
ovf_r <= 1'b0;
n_commit <= '0;
n_crc_drop <= '0;
n_dup_drop <= '0;
n_nak_space <= '0;
n_stall <= '0;
n_bytes <= '0;
end else if (flush) begin
// A flush discards everything, provisional and committed alike.
wptr_r <= '0;
prov_r <= '0;
rx_r <= 1'b0;
ovf_r <= 1'b0;
end else if (rx_start) begin
// The provisional pointer starts from the committed one, every packet.
// Anything left over from a rolled-back packet is discarded here as
// well as at the rollback -- belt and braces, because starting from a
// stale provisional pointer is the bug that corrupts the NEXT packet.
rx_r <= 1'b1;
prov_r <= wptr_r;
ovf_r <= 1'b0;
end else if (rx_end) begin
rx_r <= 1'b0;
if (commit) begin
wptr_r <= prov_r;
n_commit <= n_commit + 1;
n_bytes <= n_bytes + 32'(gained);
end else begin
// ROLLBACK. The bytes are still physically in the buffer; what makes
// them not-written is that nothing points at them any more.
prov_r <= wptr_r;
if (ep_halted) n_stall <= n_stall + 1;
else if (!rx_crc_ok) n_crc_drop <= n_crc_drop + 1;
else if (dup) n_dup_drop <= n_dup_drop + 1;
else n_nak_space <= n_nak_space + 1;
end
end else if (accepting) begin
if (have_room) prov_r <= prov_r + 1'b1;
else ovf_r <= 1'b1; // doomed, but keep receiving
end
end
endmodule8. VHDL-2008 Implementation
-- usb_packet_engine -- the block that actually moves the bytes, and the
-- constraint that shapes it: THE HANDSHAKE IS DUE BEFORE THE CRC IS KNOWN.
--
-- THE ORDERING PROBLEM
--
-- A USB DATA packet is a PID, a payload, and a 16-bit CRC covering that
-- payload. The CRC is the LAST thing on the wire:
--
-- [ DATA0 ][ b0 b1 b2 ... bN ][ CRC16 ]
-- ^
-- the first moment the device knows
-- whether any of those bytes are good
--
-- But the bytes arrived one at a time, at the bus's rate, and the device has
-- nowhere to put them except the endpoint buffer. It cannot hold a 1024-byte
-- payload in flip-flops while it waits for the CRC.
--
-- So the device MUST start writing before it knows whether the data is good.
--
-- THE ANSWER IS A PROVISIONAL WRITE
--
-- Two pointers. The COMMITTED pointer is what firmware can see; the
-- PROVISIONAL pointer runs ahead of it during reception. At the end of the
-- packet, exactly one of two things happens:
--
-- COMMIT the CRC passed and the packet is wanted:
-- committed <= provisional. The bytes become visible.
--
-- ROLLBACK anything went wrong:
-- provisional <= committed. The bytes are unwritten by the
-- simple expedient of never having been pointed at.
--
-- The payload was physically written either way. What makes it "not written"
-- is that nothing points at it, and the next packet overwrites it.
--
-- YOU CANNOT CHANGE YOUR MIND MID-PACKET
--
-- Suppose the buffer fills halfway through. The device now knows it cannot
-- accept this packet -- so why keep receiving?
--
-- Because THERE IS NOWHERE TO PUT THE REFUSAL. A USB transaction has exactly
-- one place a handshake may be sent: after the packet ends. There is no
-- mid-packet abort and no flow control inside a packet. The host will
-- transmit every byte it intended to and then wait for an answer.
--
-- So the engine keeps receiving, discards the bytes that do not fit, records
-- that an overflow happened, and NAKs at the end.
--
-- THE DECISION, AND ITS ORDER
--
-- halted -> STALL, rollback (chapter 21.1: halt outranks all)
-- CRC failed -> SILENCE, rollback (21.1: a NAK would claim knowledge)
-- toggle wrong -> ACK, rollback (21.1: a retransmission)
-- overflowed -> NAK, rollback (no room; ask again)
-- otherwise -> ACK, COMMIT
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
package usb_pktengine_pkg is
type handshake_t is (H_NONE, H_ACK, H_NAK, H_STALL);
-- WHY a packet was rolled back. Four causes that all look like "the data
-- did not arrive" from the host's side, and all need different action from
-- whoever is debugging the device.
type drop_reason_t is (
DROP_NONE, -- nothing was dropped
DROP_HALTED, -- the endpoint is halted
DROP_CRC, -- the payload was corrupt
DROP_TOGGLE, -- a retransmission: we already have these bytes
DROP_NO_ROOM -- it did not fit
);
function hs_code(h : handshake_t) return std_logic_vector;
function dr_code(d : drop_reason_t) return std_logic_vector;
end package;
package body usb_pktengine_pkg is
function hs_code(h : handshake_t) return std_logic_vector is
begin
return std_logic_vector(to_unsigned(handshake_t'pos(h), 3));
end function;
function dr_code(d : drop_reason_t) return std_logic_vector is
begin
return std_logic_vector(to_unsigned(drop_reason_t'pos(d), 3));
end function;
end package body;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.usb_pktengine_pkg.all;
entity usb_packet_engine is
generic (
CAP : natural := 16; -- endpoint buffer capacity in bytes
PW : natural := 5 -- pointer width: must hold 0 .. CAP
);
port (
clk : in std_logic;
rst_n : in std_logic;
rx_start : in std_logic; -- a DATA packet has begun
rx_byte_valid : in std_logic; -- a payload byte is on rx_byte
rx_byte : in std_logic_vector(7 downto 0);
rx_end : in std_logic; -- the packet ended; the two signals
rx_crc_ok : in std_logic; -- below are valid only in this cycle
toggle_match : in std_logic;
ep_halted : in std_logic;
flush : in std_logic;
wr_ptr : out std_logic_vector(PW-1 downto 0); -- COMMITTED
prov_ptr : out std_logic_vector(PW-1 downto 0); -- PROVISIONAL
prov_wr_en : out std_logic;
prov_wr_addr : out std_logic_vector(PW-1 downto 0);
prov_wr_data : out std_logic_vector(7 downto 0);
receiving : out std_logic;
overflowed : out std_logic;
commit : out std_logic;
rollback : out std_logic;
handshake : out std_logic_vector(2 downto 0);
drop_reason : out std_logic_vector(2 downto 0);
n_commit : out std_logic_vector(31 downto 0);
n_crc_drop : out std_logic_vector(31 downto 0);
n_dup_drop : out std_logic_vector(31 downto 0);
n_nak_space : out std_logic_vector(31 downto 0);
n_stall : out std_logic_vector(31 downto 0);
n_bytes : out std_logic_vector(31 downto 0)
);
end entity;
architecture rtl of usb_packet_engine is
signal wptr_r : natural range 0 to CAP := 0;
signal prov_r : natural range 0 to CAP := 0;
signal rx_r : std_logic := '0';
signal ovf_r : std_logic := '0';
signal have_room, accepting, dup : std_logic;
signal hs : handshake_t;
signal dr : drop_reason_t;
signal cmt, rbk : std_logic;
signal cmt_r, crc_r, dup_r, nak_r, stl_r, byt_r
: unsigned(31 downto 0) := (others => '0');
begin
wr_ptr <= std_logic_vector(to_unsigned(wptr_r, PW));
prov_ptr <= std_logic_vector(to_unsigned(prov_r, PW));
receiving <= rx_r;
overflowed <= ovf_r;
-- A byte is written provisionally only while there is room. Once there is
-- not, the packet is doomed -- but reception CONTINUES, because there is
-- nowhere to put a refusal until the packet ends.
have_room <= '1' when prov_r < CAP else '0';
accepting <= rx_r and rx_byte_valid;
prov_wr_en <= accepting and have_room;
prov_wr_addr <= std_logic_vector(to_unsigned(prov_r, PW));
prov_wr_data <= rx_byte;
-- A packet carrying the toggle we are NOT expecting is a retransmission of
-- one already accepted (chapter 21.1). Its bytes are already in the buffer.
dup <= not toggle_match;
-- THE END-OF-PACKET DECISION. Nothing before rx_end can produce a
-- handshake, because nothing before rx_end is known.
decide : process (rx_end, ep_halted, rx_crc_ok, dup, ovf_r)
begin
if rx_end = '0' then
hs <= H_NONE;
dr <= DROP_NONE;
elsif ep_halted = '1' then
hs <= H_STALL;
dr <= DROP_HALTED;
elsif rx_crc_ok = '0' then
hs <= H_NONE; -- silence, never a NAK
dr <= DROP_CRC;
elsif dup = '1' then
hs <= H_ACK; -- re-ACK, store nothing
dr <= DROP_TOGGLE;
elsif ovf_r = '1' then
hs <= H_NAK;
dr <= DROP_NO_ROOM;
else
hs <= H_ACK;
dr <= DROP_NONE;
end if;
end process;
handshake <= hs_code(hs);
drop_reason <= dr_code(dr);
-- Exactly one of commit and rollback happens at the end of every packet.
cmt <= '1' when (rx_end = '1' and ep_halted = '0' and rx_crc_ok = '1'
and dup = '0' and ovf_r = '0') else '0';
rbk <= '1' when (rx_end = '1' and cmt = '0') else '0';
commit <= cmt;
rollback <= rbk;
regs : process (clk, rst_n)
begin
if rst_n = '0' then
wptr_r <= 0;
prov_r <= 0;
rx_r <= '0';
ovf_r <= '0';
cmt_r <= (others => '0');
crc_r <= (others => '0');
dup_r <= (others => '0');
nak_r <= (others => '0');
stl_r <= (others => '0');
byt_r <= (others => '0');
elsif rising_edge(clk) then
if flush = '1' then
-- A flush discards everything, provisional and committed alike.
wptr_r <= 0;
prov_r <= 0;
rx_r <= '0';
ovf_r <= '0';
elsif rx_start = '1' then
-- The provisional pointer starts from the committed one, every
-- packet. Starting from a stale provisional pointer is the bug that
-- corrupts the NEXT packet.
rx_r <= '1';
prov_r <= wptr_r;
ovf_r <= '0';
elsif rx_end = '1' then
rx_r <= '0';
if cmt = '1' then
wptr_r <= prov_r;
cmt_r <= cmt_r + 1;
byt_r <= byt_r + to_unsigned(prov_r - wptr_r, 32);
else
-- ROLLBACK. The bytes are still physically in the buffer; what
-- makes them not-written is that nothing points at them any more.
prov_r <= wptr_r;
if ep_halted = '1' then
stl_r <= stl_r + 1;
elsif rx_crc_ok = '0' then
crc_r <= crc_r + 1;
elsif dup = '1' then
dup_r <= dup_r + 1;
else
nak_r <= nak_r + 1;
end if;
end if;
elsif accepting = '1' then
if have_room = '1' then
prov_r <= prov_r + 1;
else
ovf_r <= '1'; -- doomed, but keep receiving
end if;
end if;
end if;
end process;
n_commit <= std_logic_vector(cmt_r);
n_crc_drop <= std_logic_vector(crc_r);
n_dup_drop <= std_logic_vector(dup_r);
n_nak_space <= std_logic_vector(nak_r);
n_stall <= std_logic_vector(stl_r);
n_bytes <= std_logic_vector(byt_r);
end architecture;9. Seeing the Rollback
A packet is written provisionally, its CRC fails, and firmware never sees a byte of it
usb_packet_engine — provisional write and rollback
10 cycleswr_ptr is flat across the entire waveform. That row is the whole design: whatever happens during a packet, firmware's view of the buffer does not change until the packet is known to be good.
10. The Testbenches
The unit of stimulus here is a complete packet — start, N bytes, end, with a chosen CRC / toggle / halt outcome — so each testbench has a packet() task and drives everything through it. The exhaustive domain is over packets rather than cycles:
17 starting committed-pointer positions (0 .. CAP)
x 18 packet lengths (0 .. CAP+1, past capacity)
x 2 (crc) x 2 (toggle) x 2 (halted)
= 2448 complete packets, each driven byte by byte from a
known buffer occupancy set up by a prior committed packetEach bench also keeps a shadow model — its own buffer contents, its own two pointers, its own overflow flag — and a second memory mirroring the DUT's actual writes, so that a rollback can be checked for what it really means: firmware must never see those bytes, and the next packet must overwrite them.
10.1 Verilog testbench
`timescale 1ns/1ps
module tb_pe_v;
localparam CAP = 16, PW = 5;
reg clk=0, rst_n=0;
reg rx_start=0, rx_byte_valid=0, rx_end=0;
reg rx_crc_ok=1, toggle_match=1, ep_halted=0, flush=0;
reg [7:0] rx_byte=0;
wire [PW-1:0] wr_ptr, prov_ptr, prov_wr_addr;
wire [7:0] prov_wr_data;
wire prov_wr_en, receiving, overflowed, commit, rollback;
wire [2:0] handshake, drop_reason;
wire [31:0] n_commit, n_crc_drop, n_dup_drop, n_nak_space, n_stall, n_bytes;
always #5 clk=~clk;
usb_packet_engine #(.CAP(CAP), .PW(PW)) dut (
.clk(clk), .rst_n(rst_n), .rx_start(rx_start),
.rx_byte_valid(rx_byte_valid), .rx_byte(rx_byte), .rx_end(rx_end),
.rx_crc_ok(rx_crc_ok), .toggle_match(toggle_match),
.ep_halted(ep_halted), .flush(flush), .wr_ptr(wr_ptr),
.prov_ptr(prov_ptr), .prov_wr_en(prov_wr_en),
.prov_wr_addr(prov_wr_addr), .prov_wr_data(prov_wr_data),
.receiving(receiving), .overflowed(overflowed), .commit(commit),
.rollback(rollback), .handshake(handshake), .drop_reason(drop_reason),
.n_commit(n_commit),
.n_crc_drop(n_crc_drop), .n_dup_drop(n_dup_drop),
.n_nak_space(n_nak_space), .n_stall(n_stall), .n_bytes(n_bytes));
localparam [2:0] H_NONE=0, H_ACK=1, H_NAK=2, H_STALL=3;
localparam [2:0] DROP_NONE=0, DROP_HALTED=1, DROP_CRC=2, DROP_TOGGLE=3,
DROP_NO_ROOM=4;
// ---- SHADOW MODEL: an independent copy of the buffer and both pointers.
// ---- The buffer contents are modelled too, so that a rollback can be
// ---- checked for what it really means: firmware must never SEE the bytes.
reg [7:0] s_mem [0:CAP-1];
reg [7:0] d_mem [0:CAP-1]; // what the DUT's writes actually produced
integer s_wptr, s_prov, s_ovf, s_rx;
integer m_commit, m_crc, m_dup, m_nak, m_stall, m_bytes;
integer errors=0, i, j, k, rlen;
integer n_dom=0, n_rand=0;
integer c_commit=0, c_crc=0, c_dup=0, c_ovf=0, c_stall=0, c_exact=0;
task check(input cond, input [639:0] msg);
begin if (!cond) begin errors=errors+1;
if (errors <= 25)
$display(" FAIL: %0s (rx=%b start=%b vld=%b end=%b crc=%b tog=%b halt=%b | wptr=%0d prov=%0d ovf=%b hs=%0d || model w=%0d p=%0d o=%0d, t=%0t)",
msg, receiving, rx_start, rx_byte_valid, rx_end, rx_crc_ok,
toggle_match, ep_halted, wr_ptr, prov_ptr, overflowed,
handshake, s_wptr, s_prov, s_ovf, $time);
end end
endtask
task check_comb;
reg [2:0] e_hs, e_drop;
reg e_commit, e_rollback, e_dup;
begin
e_dup = !toggle_match;
// The model decides with a case-over-conditions tree where the design
// uses a ternary chain -- a different route to the same answer.
case (1'b1)
(!rx_end): e_hs = H_NONE;
ep_halted: e_hs = H_STALL;
(!rx_crc_ok): e_hs = H_NONE;
e_dup: e_hs = H_ACK;
(s_ovf != 0): e_hs = H_NAK;
default: e_hs = H_ACK;
endcase
e_commit = rx_end && !ep_halted && rx_crc_ok && !e_dup && (s_ovf == 0);
e_rollback = rx_end && !e_commit;
case (1'b1)
(!rx_end): e_drop = DROP_NONE;
ep_halted: e_drop = DROP_HALTED;
(!rx_crc_ok): e_drop = DROP_CRC;
e_dup: e_drop = DROP_TOGGLE;
(s_ovf != 0): e_drop = DROP_NO_ROOM;
default: e_drop = DROP_NONE;
endcase
check(handshake === e_hs, "handshake matches the model");
check(drop_reason === e_drop, "drop_reason matches the model");
// A rollback always has a reason, and a commit never does. A summary
// that can disagree with the thing it summarises is worse than none.
check((drop_reason !== DROP_NONE) === rollback,
"drop_reason disagrees with rollback");
check(commit === e_commit, "commit matches the model");
check(rollback === e_rollback, "rollback matches the model");
check(wr_ptr === s_wptr[PW-1:0], "the COMMITTED pointer matches");
check(prov_ptr === s_prov[PW-1:0], "the PROVISIONAL pointer matches");
check(overflowed === (s_ovf != 0), "overflowed matches the model");
check(receiving === (s_rx != 0), "receiving matches the model");
// ---- SAFETY PROPERTIES, independent of the model ----
// 1. THE property. The committed pointer NEVER moves except on a
// commit. Everything else is provisional by construction.
// (checked across the clock edge in step)
// 2. The provisional pointer never falls behind the committed one --
// that would expose bytes the packet has not written yet.
check(prov_ptr >= wr_ptr,
"the provisional pointer fell behind the committed one");
// 3. Neither pointer ever exceeds the buffer.
check(wr_ptr <= CAP[PW-1:0], "the committed pointer left the buffer");
check(prov_ptr <= CAP[PW-1:0], "the provisional pointer left the buffer");
// 4. Commit and rollback are mutually exclusive, and every packet end
// produces exactly one of them.
check(!(commit && rollback), "commit and rollback both asserted");
if (rx_end) check(commit || rollback,
"a packet ended with neither a commit nor a rollback");
if (!rx_end) check(!commit && !rollback,
"a commit or rollback outside a packet end");
// 5. A write only ever happens while receiving and with room.
if (prov_wr_en)
check(receiving && (prov_ptr < CAP[PW-1:0]),
"a provisional write outside reception or past the buffer");
// 6. A handshake is only ever produced at the end of a packet.
if (!rx_end) check(handshake === H_NONE,
"a handshake was produced mid-packet");
// 7. A failed CRC is answered with SILENCE, never a NAK (chapter 21.1).
if (rx_end && !ep_halted && !rx_crc_ok)
check(handshake === H_NONE,
"a corrupt packet was answered -- the device claimed knowledge it lacks");
// 8. A retransmission is ACKed and NOT committed (chapter 21.1).
if (rx_end && !ep_halted && rx_crc_ok && e_dup) begin
check(handshake === H_ACK, "a retransmission was not ACKed");
check(!commit, "a retransmission was COMMITTED -- duplicated data");
end
// 9. An overflowed packet is never committed, whatever else is true.
if (overflowed) check(!commit,
"a packet that did not fit was committed anyway");
end
endtask
task model_step;
begin
if (flush) begin
s_wptr=0; s_prov=0; s_rx=0; s_ovf=0;
end else if (rx_start) begin
s_rx=1; s_prov=s_wptr; s_ovf=0;
end else if (rx_end) begin
s_rx=0;
if (!ep_halted && rx_crc_ok && toggle_match && (s_ovf==0)) begin
m_bytes = m_bytes + (s_prov - s_wptr);
s_wptr = s_prov;
m_commit = m_commit + 1;
end else begin
s_prov = s_wptr;
if (ep_halted) m_stall = m_stall + 1;
else if (!rx_crc_ok) m_crc = m_crc + 1;
else if (!toggle_match) m_dup = m_dup + 1;
else m_nak = m_nak + 1;
end
end else if (s_rx != 0 && rx_byte_valid) begin
if (s_prov < CAP) begin
s_mem[s_prov] = rx_byte;
s_prov = s_prov + 1;
end else s_ovf = 1;
end
end
endtask
task step;
begin
#1;
check_comb;
// mirror the DUT's own write into a separate memory, so the bench can
// check what firmware would actually READ after a rollback
if (prov_wr_en) d_mem[prov_wr_addr] = prov_wr_data;
model_step;
@(posedge clk); #1;
check(wr_ptr === s_wptr[PW-1:0], "the committed pointer tracked the model");
check(prov_ptr === s_prov[PW-1:0], "the provisional pointer tracked the model");
check(n_commit === m_commit[31:0], "n_commit matches the model");
check(n_crc_drop === m_crc[31:0], "n_crc_drop matches the model");
check(n_dup_drop === m_dup[31:0], "n_dup_drop matches the model");
check(n_nak_space === m_nak[31:0], "n_nak_space matches the model");
check(n_stall === m_stall[31:0], "n_stall matches the model");
check(n_bytes === m_bytes[31:0], "n_bytes matches the model");
end
endtask
task idle; begin rx_start=0; rx_byte_valid=0; rx_end=0; flush=0; end endtask
task hard_reset;
begin
rst_n=0; idle; rx_crc_ok=1; toggle_match=1; ep_halted=0;
@(posedge clk); #1; @(posedge clk); #1; rst_n=1; #1;
s_wptr=0; s_prov=0; s_rx=0; s_ovf=0;
m_commit=0; m_crc=0; m_dup=0; m_nak=0; m_stall=0; m_bytes=0;
end
endtask
// Drive one complete packet of `len` bytes and end it with the given
// conditions. This is the unit the whole block is organised around.
task packet(input integer len, input crc, input tog, input halt);
integer b;
begin
idle; rx_start=1; step; idle;
for (b=0; b<len; b=b+1) begin
rx_byte_valid=1; rx_byte = (8'hA0 + b[7:0]); step; idle;
end
rx_crc_ok=crc; toggle_match=tog; ep_halted=halt;
rx_end=1; step; idle;
rx_crc_ok=1; toggle_match=1; ep_halted=0;
end
endtask
initial begin
for (i=0;i<CAP;i=i+1) begin s_mem[i]=0; d_mem[i]=0; end
hard_reset;
check(wr_ptr === 5'd0, "reset leaves the committed pointer at zero");
check(!receiving, "and not receiving");
// ===== A. EXHAUSTIVE packet-outcome domain =====
// 17 starting committed-pointer positions (0..16) x 18 packet lengths
// (0..17, past the 16-byte capacity) x crc ok/bad x toggle match/mismatch
// x halted/running = 17 x 18 x 8 = 2448 complete packets, each driven
// byte by byte from a known buffer occupancy.
for (i=0; i<=CAP; i=i+1) // starting committed pointer
for (j=0; j<=CAP+1; j=j+1) // packet length, past capacity
for (k=0; k<8; k=k+1) begin // crc x toggle x halted
hard_reset;
// fill the buffer to `i` with a committed packet of length i
if (i > 0) packet(i, 1'b1, 1'b1, 1'b0);
check(wr_ptr === i[PW-1:0], "the prefill committed exactly i bytes");
packet(j, k[2], k[1], k[0]);
n_dom = n_dom + 1;
if (k[0]) c_stall = c_stall + 1;
else if (!k[2]) c_crc = c_crc + 1;
else if (!k[1]) c_dup = c_dup + 1;
else if (i + j > CAP) c_ovf = c_ovf + 1;
else begin
c_commit = c_commit + 1;
if (i + j == CAP) c_exact = c_exact + 1;
end
end
$display(" exhaustive packet-outcome sweep: %0d of %0d packets verified",
n_dom, 17*18*8);
// ===== B. directed: the provisional-write story =====
hard_reset;
// 1. A good packet commits and the bytes become visible.
packet(4, 1'b1, 1'b1, 1'b0);
#1;
check(!commit,
"commit is a one-cycle pulse at the packet end and is gone by now");
check(wr_ptr === 5'd4, "four bytes are committed");
check(n_bytes === 32'd4, "and counted");
// 2. THE case. A packet arrives, is written provisionally, and its CRC
// fails. The committed pointer must not have moved.
rx_start=1; step; idle;
for (i=0;i<6;i=i+1) begin rx_byte_valid=1; rx_byte=8'hFF; step; idle; end
#1;
check(prov_ptr === 5'd10,
"the provisional pointer ran ahead to 10 -- the bytes ARE in the buffer");
check(wr_ptr === 5'd4,
"but the committed pointer has not moved: firmware still sees 4");
rx_crc_ok=0; rx_end=1; step; idle; rx_crc_ok=1;
check(wr_ptr === 5'd4, "after the CRC failure it is STILL 4");
#1;
check(prov_ptr === 5'd4,
"and the provisional pointer rolled back to meet it");
check(n_crc_drop === 32'd1, "one packet dropped for a failed CRC");
check(n_bytes === 32'd4, "and no bytes were added");
// 3. The next packet reuses the same buffer space. The 0xFF bytes from
// the dropped packet are physically still there and are overwritten.
packet(3, 1'b1, 1'b1, 1'b0);
check(wr_ptr === 5'd7, "three more bytes committed, from 4 to 7");
check(d_mem[4] === 8'hA0,
"the byte at offset 4 is from the NEW packet, not the dropped one");
// 4. A retransmission: good CRC, wrong toggle. ACKed, not committed.
packet(3, 1'b1, 1'b0, 1'b0);
check(wr_ptr === 5'd7, "the committed pointer did not move");
check(n_dup_drop === 32'd1, "and it was counted as a duplicate");
// 5. Overflow. The buffer holds 7 of 16; a 12-byte packet does not fit.
// Reception continues to the end, and the NAK comes at the end.
rx_start=1; step; idle;
for (i=0;i<12;i=i+1) begin
rx_byte_valid=1; rx_byte=8'h5A; step; idle;
#1;
if (i >= 9) check(overflowed,
"once the buffer filled, the packet is marked doomed");
check(receiving,
"but reception CONTINUES -- there is nowhere to put a refusal yet");
end
#1; check(prov_ptr === 5'd16, "the provisional pointer stopped at capacity");
rx_end=1; step; idle;
check(wr_ptr === 5'd7, "nothing was committed");
check(n_nak_space === 32'd1, "and the packet was NAKed for want of room");
// 6. A packet that fits EXACTLY fills the buffer and commits.
hard_reset;
packet(CAP, 1'b1, 1'b1, 1'b0);
check(wr_ptr === CAP[PW-1:0], "a packet filling the buffer exactly commits");
check(!overflowed, "and is NOT an overflow -- it fit");
check(n_commit === 32'd1, "one commit");
// 7. One more byte than fits does not.
hard_reset;
packet(CAP+1, 1'b1, 1'b1, 1'b0);
check(wr_ptr === 5'd0, "one byte too many commits nothing at all");
check(n_nak_space === 32'd1, "and is NAKed");
// 8. A halted endpoint STALLs whatever else is true.
hard_reset;
packet(4, 1'b1, 1'b1, 1'b1);
check(wr_ptr === 5'd0, "a halted endpoint commits nothing");
check(n_stall === 32'd1, "and STALLs");
// 9. A zero-length packet commits, and commits zero bytes.
hard_reset;
packet(0, 1'b1, 1'b1, 1'b0);
check(n_commit === 32'd1, "a zero-length packet is a packet and commits");
check(wr_ptr === 5'd0, "adding no bytes");
check(n_bytes === 32'd0, "and counting none");
// ===== C. randomised: many packets against one buffer =====
hard_reset;
for (i=0;i<4000;i=i+1) begin
rlen = {$random}%19;
if (({$random}%16)==0) begin flush=1; step; idle; end
packet(rlen, ({$random}%8)!=0, ({$random}%8)!=0, ({$random}%32)==0);
n_rand = n_rand + 1;
end
check(c_commit > 100, "commits happened often");
check(c_crc > 100, "CRC failures happened often");
check(c_dup > 100, "retransmissions happened often");
check(c_ovf > 100, "overflows happened often");
check(c_stall > 100, "halted packets happened often");
check(c_exact > 10, "packets that exactly filled the buffer were seen");
$display("");
$display(" REACH: exhaustive-packets=%0d random-packets=%0d",
n_dom, n_rand);
$display(" OUTCOMES in the sweep: commit=%0d exact-fit=%0d crc-drop=%0d dup-drop=%0d overflow=%0d stall=%0d",
c_commit, c_exact, c_crc, c_dup, c_ovf, c_stall);
$display(" COUNTERS: commits=%0d bytes=%0d crc=%0d dup=%0d nak=%0d stall=%0d",
n_commit, n_bytes, n_crc_drop, n_dup_drop, n_nak_space, n_stall);
$display(" [Verilog] usb_packet_engine: %0d errors", errors);
$display(" [Verilog] %0s", errors==0 ? "PASS" : "FAIL");
$display("");
$finish;
end
endmodule10.2 SystemVerilog testbench
`timescale 1ns/1ps
module tb_pe_sv;
import usb_pktengine_pkg::*;
localparam CAP = 16, PW = 5;
logic clk=0, rst_n=0;
logic rx_start=0, rx_byte_valid=0, rx_end=0;
logic rx_crc_ok=1, toggle_match=1, ep_halted=0, flush=0;
logic [7:0] rx_byte=0;
logic [PW-1:0] wr_ptr, prov_ptr, prov_wr_addr;
logic [7:0] prov_wr_data;
logic prov_wr_en, receiving, overflowed, commit, rollback;
handshake_e handshake;
drop_reason_e drop_reason;
// 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 = 21404;
wire [31:0] n_commit, n_crc_drop, n_dup_drop, n_nak_space, n_stall, n_bytes;
always #5 clk=~clk;
usb_packet_engine #(.CAP(CAP), .PW(PW)) dut (
.clk, .rst_n, .rx_start, .rx_byte_valid, .rx_byte, .rx_end, .rx_crc_ok,
.toggle_match, .ep_halted, .flush, .wr_ptr, .prov_ptr, .prov_wr_en,
.prov_wr_addr, .prov_wr_data, .receiving, .overflowed, .commit,
.rollback, .handshake, .drop_reason, .n_commit, .n_crc_drop,
.n_dup_drop, .n_nak_space, .n_stall, .n_bytes);
// ---- SHADOW MODEL: an independent copy of the buffer and both pointers.
// ---- The buffer contents are modelled too, so that a rollback can be
// ---- checked for what it really means: firmware must never SEE the bytes.
logic [7:0] s_mem [0:CAP-1];
logic [7:0] d_mem [0:CAP-1]; // what the DUT's writes actually produced
int s_wptr, s_prov, s_ovf, s_rx;
int m_commit, m_crc, m_dup, m_nak, m_stall, m_bytes;
int errors=0, i, j, k, rlen;
int n_dom=0, n_rand=0;
int c_commit=0, c_crc=0, c_dup=0, c_ovf=0, c_stall=0, c_exact=0;
task automatic check(input bit cond, input string msg);
// Icarus will not call .name() on a net, so the enum outputs are copied
// into variables of the same type before being printed.
handshake_e hs_v; drop_reason_e dr_v;
if (!cond) begin
errors++;
hs_v = handshake; dr_v = drop_reason;
if (errors <= 25)
$display(" FAIL: %0s (rx=%b start=%b vld=%b end=%b crc=%b tog=%b halt=%b | wptr=%0d prov=%0d ovf=%b hs=%s why=%s || model w=%0d p=%0d o=%0d, t=%0t)",
msg, receiving, rx_start, rx_byte_valid, rx_end, rx_crc_ok,
toggle_match, ep_halted, wr_ptr, prov_ptr, overflowed,
hs_v.name(), dr_v.name(), s_wptr, s_prov, s_ovf, $time);
end
endtask
task automatic check_comb;
handshake_e e_hs; drop_reason_e e_drop;
bit e_commit, e_rollback, e_dup;
begin
e_dup = !toggle_match;
// The model decides with a case-over-conditions tree where the design
// uses a ternary chain -- a different route to the same answer.
case (1'b1)
(!rx_end): e_hs = H_NONE;
ep_halted: e_hs = H_STALL;
(!rx_crc_ok): e_hs = H_NONE;
e_dup: e_hs = H_ACK;
(s_ovf != 0): e_hs = H_NAK;
default: e_hs = H_ACK;
endcase
e_commit = rx_end && !ep_halted && rx_crc_ok && !e_dup && (s_ovf == 0);
e_rollback = rx_end && !e_commit;
case (1'b1)
(!rx_end): e_drop = DROP_NONE;
ep_halted: e_drop = DROP_HALTED;
(!rx_crc_ok): e_drop = DROP_CRC;
e_dup: e_drop = DROP_TOGGLE;
(s_ovf != 0): e_drop = DROP_NO_ROOM;
default: e_drop = DROP_NONE;
endcase
check(handshake === e_hs, "handshake matches the model");
check(drop_reason === e_drop, "drop_reason matches the model");
// A rollback always has a reason, and a commit never does. A summary
// that can disagree with the thing it summarises is worse than none.
check((drop_reason !== DROP_NONE) === rollback,
"drop_reason disagrees with rollback");
check(commit === e_commit, "commit matches the model");
check(rollback === e_rollback, "rollback matches the model");
check(wr_ptr === PW'(s_wptr), "the COMMITTED pointer matches");
check(prov_ptr === PW'(s_prov), "the PROVISIONAL pointer matches");
check(overflowed === (s_ovf != 0), "overflowed matches the model");
check(receiving === (s_rx != 0), "receiving matches the model");
// ---- SAFETY PROPERTIES, independent of the model ----
// 1. THE property. The committed pointer NEVER moves except on a
// commit. Everything else is provisional by construction.
// (checked across the clock edge in step)
// 2. The provisional pointer never falls behind the committed one --
// that would expose bytes the packet has not written yet.
check(prov_ptr >= wr_ptr,
"the provisional pointer fell behind the committed one");
// 3. Neither pointer ever exceeds the buffer.
check(wr_ptr <= PW'(CAP), "the committed pointer left the buffer");
check(prov_ptr <= PW'(CAP), "the provisional pointer left the buffer");
// 4. Commit and rollback are mutually exclusive, and every packet end
// produces exactly one of them.
check(!(commit && rollback), "commit and rollback both asserted");
if (rx_end) check(commit || rollback,
"a packet ended with neither a commit nor a rollback");
if (!rx_end) check(!commit && !rollback,
"a commit or rollback outside a packet end");
// 5. A write only ever happens while receiving and with room.
if (prov_wr_en)
check(receiving && (prov_ptr < PW'(CAP)),
"a provisional write outside reception or past the buffer");
// 6. A handshake is only ever produced at the end of a packet.
if (!rx_end) check(handshake === H_NONE,
"a handshake was produced mid-packet");
// 7. A failed CRC is answered with SILENCE, never a NAK (chapter 21.1).
if (rx_end && !ep_halted && !rx_crc_ok)
check(handshake === H_NONE,
"a corrupt packet was answered -- the device claimed knowledge it lacks");
// 8. A retransmission is ACKed and NOT committed (chapter 21.1).
if (rx_end && !ep_halted && rx_crc_ok && e_dup) begin
check(handshake === H_ACK, "a retransmission was not ACKed");
check(!commit, "a retransmission was COMMITTED -- duplicated data");
end
// 9. An overflowed packet is never committed, whatever else is true.
if (overflowed) check(!commit,
"a packet that did not fit was committed anyway");
end
endtask
task automatic model_step;
begin
if (flush) begin
s_wptr=0; s_prov=0; s_rx=0; s_ovf=0;
end else if (rx_start) begin
s_rx=1; s_prov=s_wptr; s_ovf=0;
end else if (rx_end) begin
s_rx=0;
if (!ep_halted && rx_crc_ok && toggle_match && (s_ovf==0)) begin
m_bytes = m_bytes + (s_prov - s_wptr);
s_wptr = s_prov;
m_commit = m_commit + 1;
end else begin
s_prov = s_wptr;
if (ep_halted) m_stall = m_stall + 1;
else if (!rx_crc_ok) m_crc = m_crc + 1;
else if (!toggle_match) m_dup = m_dup + 1;
else m_nak = m_nak + 1;
end
end else if (s_rx != 0 && rx_byte_valid) begin
if (s_prov < CAP) begin
s_mem[s_prov] = rx_byte;
s_prov = s_prov + 1;
end else s_ovf = 1;
end
end
endtask
task automatic step;
begin
#1;
check_comb;
// mirror the DUT's own write into a separate memory, so the bench can
// check what firmware would actually READ after a rollback
if (prov_wr_en) d_mem[prov_wr_addr] = prov_wr_data;
model_step;
@(posedge clk); #1;
check(wr_ptr === PW'(s_wptr), "the committed pointer tracked the model");
check(prov_ptr === PW'(s_prov), "the provisional pointer tracked the model");
check(n_commit === 32'(m_commit), "n_commit matches the model");
check(n_crc_drop === 32'(m_crc), "n_crc_drop matches the model");
check(n_dup_drop === 32'(m_dup), "n_dup_drop matches the model");
check(n_nak_space === 32'(m_nak), "n_nak_space matches the model");
check(n_stall === 32'(m_stall), "n_stall matches the model");
check(n_bytes === 32'(m_bytes), "n_bytes matches the model");
end
endtask
task automatic idle; begin rx_start=0; rx_byte_valid=0; rx_end=0; flush=0; end endtask
task automatic hard_reset;
begin
rst_n=0; idle; rx_crc_ok=1; toggle_match=1; ep_halted=0;
@(posedge clk); #1; @(posedge clk); #1; rst_n=1; #1;
s_wptr=0; s_prov=0; s_rx=0; s_ovf=0;
m_commit=0; m_crc=0; m_dup=0; m_nak=0; m_stall=0; m_bytes=0;
end
endtask
// Drive one complete packet of `len` bytes and end it with the given
// conditions. This is the unit the whole block is organised around.
task automatic packet(input int len, input bit crc, input bit tog,
input bit halt);
int b;
begin
idle; rx_start=1; step; idle;
for (b=0; b<len; b=b+1) begin
rx_byte_valid=1; rx_byte = 8'(8'hA0 + b); step; idle;
end
rx_crc_ok=crc; toggle_match=tog; ep_halted=halt;
rx_end=1; step; idle;
rx_crc_ok=1; toggle_match=1; ep_halted=0;
end
endtask
initial begin
void'($urandom(urandom_seed));
for (i=0;i<CAP;i++) begin s_mem[i]=0; d_mem[i]=0; end
hard_reset;
check(wr_ptr === 5'd0, "reset leaves the committed pointer at zero");
check(!receiving, "and not receiving");
// ===== A. EXHAUSTIVE packet-outcome domain =====
// 17 starting committed-pointer positions (0..16) x 18 packet lengths
// (0..17, past the 16-byte capacity) x crc ok/bad x toggle match/mismatch
// x halted/running = 17 x 18 x 8 = 2448 complete packets, each driven
// byte by byte from a known buffer occupancy.
for (i=0; i<=CAP; i=i+1) // starting committed pointer
for (j=0; j<=CAP+1; j=j+1) // packet length, past capacity
for (k=0; k<8; k=k+1) begin // crc x toggle x halted
hard_reset;
// fill the buffer to `i` with a committed packet of length i
if (i > 0) packet(i, 1'b1, 1'b1, 1'b0);
check(wr_ptr === PW'(i), "the prefill committed exactly i bytes");
packet(j, k[2], k[1], k[0]);
n_dom = n_dom + 1;
if (k[0]) c_stall = c_stall + 1;
else if (!k[2]) c_crc = c_crc + 1;
else if (!k[1]) c_dup = c_dup + 1;
else if (i + j > CAP) c_ovf = c_ovf + 1;
else begin
c_commit = c_commit + 1;
if (i + j == CAP) c_exact = c_exact + 1;
end
end
$display(" exhaustive packet-outcome sweep: %0d of %0d packets verified",
n_dom, 17*18*8);
// ===== B. directed: the provisional-write story =====
hard_reset;
// 1. A good packet commits and the bytes become visible.
packet(4, 1'b1, 1'b1, 1'b0);
#1;
check(!commit,
"commit is a one-cycle pulse at the packet end and is gone by now");
check(wr_ptr === 5'd4, "four bytes are committed");
check(n_bytes === 32'd4, "and counted");
// 2. THE case. A packet arrives, is written provisionally, and its CRC
// fails. The committed pointer must not have moved.
rx_start=1; step; idle;
for (i=0;i<6;i=i+1) begin rx_byte_valid=1; rx_byte=8'hFF; step; idle; end
#1;
check(prov_ptr === 5'd10,
"the provisional pointer ran ahead to 10 -- the bytes ARE in the buffer");
check(wr_ptr === 5'd4,
"but the committed pointer has not moved: firmware still sees 4");
rx_crc_ok=0; rx_end=1; step; idle; rx_crc_ok=1;
check(wr_ptr === 5'd4, "after the CRC failure it is STILL 4");
#1;
check(prov_ptr === 5'd4,
"and the provisional pointer rolled back to meet it");
check(n_crc_drop === 32'd1, "one packet dropped for a failed CRC");
check(n_bytes === 32'd4, "and no bytes were added");
// 3. The next packet reuses the same buffer space. The 0xFF bytes from
// the dropped packet are physically still there and are overwritten.
packet(3, 1'b1, 1'b1, 1'b0);
check(wr_ptr === 5'd7, "three more bytes committed, from 4 to 7");
check(d_mem[4] === 8'hA0,
"the byte at offset 4 is from the NEW packet, not the dropped one");
// 4. A retransmission: good CRC, wrong toggle. ACKed, not committed.
packet(3, 1'b1, 1'b0, 1'b0);
check(wr_ptr === 5'd7, "the committed pointer did not move");
check(n_dup_drop === 32'd1, "and it was counted as a duplicate");
// 5. Overflow. The buffer holds 7 of 16; a 12-byte packet does not fit.
// Reception continues to the end, and the NAK comes at the end.
rx_start=1; step; idle;
for (i=0;i<12;i=i+1) begin
rx_byte_valid=1; rx_byte=8'h5A; step; idle;
#1;
if (i >= 9) check(overflowed,
"once the buffer filled, the packet is marked doomed");
check(receiving,
"but reception CONTINUES -- there is nowhere to put a refusal yet");
end
#1; check(prov_ptr === 5'd16, "the provisional pointer stopped at capacity");
rx_end=1; step; idle;
check(wr_ptr === 5'd7, "nothing was committed");
check(n_nak_space === 32'd1, "and the packet was NAKed for want of room");
// 6. A packet that fits EXACTLY fills the buffer and commits.
hard_reset;
packet(CAP, 1'b1, 1'b1, 1'b0);
check(wr_ptr === PW'(CAP), "a packet filling the buffer exactly commits");
check(!overflowed, "and is NOT an overflow -- it fit");
check(n_commit === 32'd1, "one commit");
// 7. One more byte than fits does not.
hard_reset;
packet(CAP+1, 1'b1, 1'b1, 1'b0);
check(wr_ptr === 5'd0, "one byte too many commits nothing at all");
check(n_nak_space === 32'd1, "and is NAKed");
// 8. A halted endpoint STALLs whatever else is true.
hard_reset;
packet(4, 1'b1, 1'b1, 1'b1);
check(wr_ptr === 5'd0, "a halted endpoint commits nothing");
check(n_stall === 32'd1, "and STALLs");
// 9. A zero-length packet commits, and commits zero bytes.
hard_reset;
packet(0, 1'b1, 1'b1, 1'b0);
check(n_commit === 32'd1, "a zero-length packet is a packet and commits");
check(wr_ptr === 5'd0, "adding no bytes");
check(n_bytes === 32'd0, "and counting none");
// ===== C. randomised: many packets against one buffer =====
hard_reset;
for (i=0;i<4000;i=i+1) begin
rlen = $urandom%19;
if (($urandom%16)==0) begin flush=1; step; idle; end
packet(rlen, ($urandom%8)!=0, ($urandom%8)!=0, ($urandom%32)==0);
n_rand = n_rand + 1;
end
check(c_commit > 100, "commits happened often");
check(c_crc > 100, "CRC failures happened often");
check(c_dup > 100, "retransmissions happened often");
check(c_ovf > 100, "overflows happened often");
check(c_stall > 100, "halted packets happened often");
check(c_exact > 10, "packets that exactly filled the buffer were seen");
$display("");
$display(" REACH: exhaustive-packets=%0d random-packets=%0d",
n_dom, n_rand);
$display(" OUTCOMES in the sweep: commit=%0d exact-fit=%0d crc-drop=%0d dup-drop=%0d overflow=%0d stall=%0d",
c_commit, c_exact, c_crc, c_dup, c_ovf, c_stall);
$display(" COUNTERS: commits=%0d bytes=%0d crc=%0d dup=%0d nak=%0d stall=%0d",
n_commit, n_bytes, n_crc_drop, n_dup_drop, n_nak_space, n_stall);
$display(" [SystemVerilog] usb_packet_engine: %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_pktengine_pkg.all;
entity tb_pe_vhdl is
end entity;
architecture sim of tb_pe_vhdl is
constant CAP : natural := 16;
constant PW : natural := 5;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal rx_start, rx_byte_valid, rx_end, flush : std_logic := '0';
signal rx_crc_ok, toggle_match : std_logic := '1';
signal ep_halted : std_logic := '0';
signal rx_byte : std_logic_vector(7 downto 0) := (others => '0');
signal wr_ptr, prov_ptr, prov_wr_addr : std_logic_vector(PW-1 downto 0);
signal prov_wr_data : std_logic_vector(7 downto 0);
signal prov_wr_en, receiving, overflowed, commit, rollback : std_logic;
signal handshake, drop_reason : std_logic_vector(2 downto 0);
signal n_commit, n_crc_drop, n_dup_drop, n_nak_space, n_stall, n_bytes
: std_logic_vector(31 downto 0);
signal running : boolean := true;
type mem_t is array (0 to CAP-1) of integer;
begin
clk <= not clk after 5 ns when running else '0';
dut : entity work.usb_packet_engine
generic map (CAP => CAP, PW => PW)
port map (clk => clk, rst_n => rst_n, rx_start => rx_start,
rx_byte_valid => rx_byte_valid, rx_byte => rx_byte,
rx_end => rx_end, rx_crc_ok => rx_crc_ok,
toggle_match => toggle_match, ep_halted => ep_halted,
flush => flush, wr_ptr => wr_ptr, prov_ptr => prov_ptr,
prov_wr_en => prov_wr_en, prov_wr_addr => prov_wr_addr,
prov_wr_data => prov_wr_data, receiving => receiving,
overflowed => overflowed, commit => commit,
rollback => rollback, handshake => handshake,
drop_reason => drop_reason, n_commit => n_commit,
n_crc_drop => n_crc_drop, n_dup_drop => n_dup_drop,
n_nak_space => n_nak_space, n_stall => n_stall,
n_bytes => n_bytes);
stim : process
variable seed1 : positive := 3313;
variable seed2 : positive := 7717;
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;
-- SHADOW MODEL: an independent copy of the buffer and both pointers.
variable s_mem, d_mem : mem_t := (others => 0);
variable s_wptr, s_prov, s_rx, s_ovf : integer := 0;
variable m_commit, m_crc, m_dup, m_nak, m_stall, m_bytes : integer := 0;
variable n_dom, n_rand : integer := 0;
variable c_commit, c_crc, c_dup, c_ovf, c_stall, c_exact : integer := 0;
procedure check(cond : boolean; msg : string) is
begin
if not cond then
errors := errors + 1;
if errors <= 25 then
report " FAIL: " & msg
& " (rx=" & std_logic'image(receiving)(2)
& " start=" & std_logic'image(rx_start)(2)
& " vld=" & std_logic'image(rx_byte_valid)(2)
& " end=" & std_logic'image(rx_end)(2)
& " crc=" & std_logic'image(rx_crc_ok)(2)
& " tog=" & std_logic'image(toggle_match)(2)
& " halt=" & std_logic'image(ep_halted)(2)
& " | wptr=" & integer'image(to_integer(unsigned(wr_ptr)))
& " prov=" & integer'image(to_integer(unsigned(prov_ptr)))
& " ovf=" & std_logic'image(overflowed)(2)
& " hs=" & integer'image(to_integer(unsigned(handshake)))
& " || model w=" & integer'image(s_wptr)
& " p=" & integer'image(s_prov)
& " o=" & integer'image(s_ovf)
& ")" 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_comb is
variable e_hs : handshake_t;
variable e_drop : drop_reason_t;
variable e_commit, e_rollback, e_dup : boolean;
begin
e_dup := toggle_match = '0';
-- The model decides with a flat if-chain over booleans where the design
-- uses signals and an enumerated process -- a different route.
if rx_end = '0' then e_hs := H_NONE; e_drop := DROP_NONE;
elsif ep_halted = '1' then e_hs := H_STALL; e_drop := DROP_HALTED;
elsif rx_crc_ok = '0' then e_hs := H_NONE; e_drop := DROP_CRC;
elsif e_dup then e_hs := H_ACK; e_drop := DROP_TOGGLE;
elsif s_ovf /= 0 then e_hs := H_NAK; e_drop := DROP_NO_ROOM;
else e_hs := H_ACK; e_drop := DROP_NONE;
end if;
e_commit := rx_end = '1' and ep_halted = '0' and rx_crc_ok = '1'
and not e_dup and s_ovf = 0;
e_rollback := rx_end = '1' and not e_commit;
check(handshake = hs_code(e_hs), "handshake matches the model");
check(drop_reason = dr_code(e_drop), "drop_reason matches the model");
-- A rollback always has a reason, and a commit never does.
check((drop_reason /= dr_code(DROP_NONE)) = (rollback = '1'),
"drop_reason disagrees with rollback");
check((commit = '1') = e_commit, "commit matches the model");
check((rollback = '1') = e_rollback, "rollback matches the model");
check(to_integer(unsigned(wr_ptr)) = s_wptr,
"the COMMITTED pointer matches");
check(to_integer(unsigned(prov_ptr)) = s_prov,
"the PROVISIONAL pointer matches");
check((overflowed = '1') = (s_ovf /= 0), "overflowed matches the model");
check((receiving = '1') = (s_rx /= 0), "receiving matches the model");
-- ---- SAFETY PROPERTIES, independent of the model ----
-- 2. The provisional pointer never falls behind the committed one.
check(to_integer(unsigned(prov_ptr)) >= to_integer(unsigned(wr_ptr)),
"the provisional pointer fell behind the committed one");
-- 3. Neither pointer ever exceeds the buffer.
check(to_integer(unsigned(wr_ptr)) <= CAP,
"the committed pointer left the buffer");
check(to_integer(unsigned(prov_ptr)) <= CAP,
"the provisional pointer left the buffer");
-- 4. Commit and rollback are mutually exclusive, and every packet end
-- produces exactly one of them.
check(not (commit = '1' and rollback = '1'),
"commit and rollback both asserted");
if rx_end = '1' then
check(commit = '1' or rollback = '1',
"a packet ended with neither a commit nor a rollback");
else
check(commit = '0' and rollback = '0',
"a commit or rollback outside a packet end");
end if;
-- 5. A write only ever happens while receiving and with room.
if prov_wr_en = '1' then
check(receiving = '1' and to_integer(unsigned(prov_ptr)) < CAP,
"a provisional write outside reception or past the buffer");
end if;
-- 6. A handshake is only ever produced at the end of a packet.
if rx_end = '0' then
check(handshake = hs_code(H_NONE),
"a handshake was produced mid-packet");
end if;
-- 7. A failed CRC is answered with SILENCE, never a NAK.
if rx_end = '1' and ep_halted = '0' and rx_crc_ok = '0' then
check(handshake = hs_code(H_NONE),
"a corrupt packet was answered -- the device claimed knowledge it lacks");
end if;
-- 8. A retransmission is ACKed and NOT committed.
if rx_end = '1' and ep_halted = '0' and rx_crc_ok = '1' and e_dup then
check(handshake = hs_code(H_ACK), "a retransmission was not ACKed");
check(commit = '0',
"a retransmission was COMMITTED -- duplicated data");
end if;
-- 9. An overflowed packet is never committed.
if overflowed = '1' then
check(commit = '0', "a packet that did not fit was committed anyway");
end if;
end procedure;
procedure model_step is
begin
if flush = '1' then
s_wptr := 0; s_prov := 0; s_rx := 0; s_ovf := 0;
elsif rx_start = '1' then
s_rx := 1; s_prov := s_wptr; s_ovf := 0;
elsif rx_end = '1' then
s_rx := 0;
if ep_halted = '0' and rx_crc_ok = '1' and toggle_match = '1'
and s_ovf = 0 then
m_bytes := m_bytes + (s_prov - s_wptr);
s_wptr := s_prov;
m_commit := m_commit + 1;
else
s_prov := s_wptr;
if ep_halted = '1' then m_stall := m_stall + 1;
elsif rx_crc_ok = '0' then m_crc := m_crc + 1;
elsif toggle_match = '0' then m_dup := m_dup + 1;
else m_nak := m_nak + 1;
end if;
end if;
elsif s_rx /= 0 and rx_byte_valid = '1' then
if s_prov < CAP then
s_mem(s_prov) := to_integer(unsigned(rx_byte));
s_prov := s_prov + 1;
else
s_ovf := 1;
end if;
end if;
end procedure;
procedure step is
begin
wait for 1 ns;
check_comb;
-- mirror the DUT's own write into a separate memory, so the bench can
-- check what firmware would actually READ after a rollback
if prov_wr_en = '1' then
d_mem(to_integer(unsigned(prov_wr_addr)))
:= to_integer(unsigned(prov_wr_data));
end if;
model_step;
wait until rising_edge(clk);
wait for 1 ns;
check(to_integer(unsigned(wr_ptr)) = s_wptr,
"the committed pointer tracked the model");
check(to_integer(unsigned(prov_ptr)) = s_prov,
"the provisional pointer tracked the model");
check(n_commit = std_logic_vector(to_unsigned(m_commit, 32)),
"n_commit matches the model");
check(n_crc_drop = std_logic_vector(to_unsigned(m_crc, 32)),
"n_crc_drop matches the model");
check(n_dup_drop = std_logic_vector(to_unsigned(m_dup, 32)),
"n_dup_drop matches the model");
check(n_nak_space = std_logic_vector(to_unsigned(m_nak, 32)),
"n_nak_space matches the model");
check(n_stall = std_logic_vector(to_unsigned(m_stall, 32)),
"n_stall matches the model");
check(n_bytes = std_logic_vector(to_unsigned(m_bytes, 32)),
"n_bytes matches the model");
end procedure;
procedure idle is
begin
rx_start <= '0'; rx_byte_valid <= '0'; rx_end <= '0'; flush <= '0';
end procedure;
procedure hard_reset is
begin
rst_n <= '0'; idle;
rx_crc_ok <= '1'; toggle_match <= '1'; ep_halted <= '0';
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_wptr := 0; s_prov := 0; s_rx := 0; s_ovf := 0;
m_commit := 0; m_crc := 0; m_dup := 0; m_nak := 0;
m_stall := 0; m_bytes := 0;
end procedure;
-- Drive one complete packet of `len` bytes and end it with the given
-- conditions. This is the unit the whole block is organised around.
procedure packet(len : integer; crc : std_logic; tog : std_logic;
halt : std_logic) is
begin
idle; rx_start <= '1'; step; idle;
for b in 0 to len - 1 loop
rx_byte_valid <= '1';
rx_byte <= std_logic_vector(to_unsigned((16#A0# + b) mod 256, 8));
step; idle;
end loop;
rx_crc_ok <= crc; toggle_match <= tog; ep_halted <= halt;
rx_end <= '1'; step; idle;
rx_crc_ok <= '1'; toggle_match <= '1'; ep_halted <= '0';
end procedure;
variable iv, rlen : integer;
variable kc, kt, kh : std_logic;
begin
hard_reset;
check(to_integer(unsigned(wr_ptr)) = 0,
"reset leaves the committed pointer at zero");
check(receiving = '0', "and not receiving");
-- ===== A. EXHAUSTIVE packet-outcome domain =====
-- 17 starting committed-pointer positions (0..16) x 18 packet lengths
-- (0..17, past the 16-byte capacity) x crc ok/bad x toggle match/mismatch
-- x halted/running = 17 x 18 x 8 = 2448 complete packets.
for i in 0 to CAP loop
for j in 0 to CAP + 1 loop
for k in 0 to 7 loop
if (k / 4) mod 2 = 1 then kc := '1'; else kc := '0'; end if;
if (k / 2) mod 2 = 1 then kt := '1'; else kt := '0'; end if;
if k mod 2 = 1 then kh := '1'; else kh := '0'; end if;
hard_reset;
if i > 0 then packet(i, '1', '1', '0'); end if;
check(to_integer(unsigned(wr_ptr)) = i,
"the prefill committed exactly i bytes");
packet(j, kc, kt, kh);
n_dom := n_dom + 1;
if kh = '1' then c_stall := c_stall + 1;
elsif kc = '0' then c_crc := c_crc + 1;
elsif kt = '0' then c_dup := c_dup + 1;
elsif i + j > CAP then c_ovf := c_ovf + 1;
else
c_commit := c_commit + 1;
if i + j = CAP then c_exact := c_exact + 1; end if;
end if;
end loop;
end loop;
end loop;
report " exhaustive packet-outcome sweep: " & integer'image(n_dom)
& " of 2448 packets verified" severity note;
-- ===== B. directed: the provisional-write story =====
hard_reset;
-- 1. A good packet commits and the bytes become visible.
packet(4, '1', '1', '0');
wait for 1 ns;
check(commit = '0',
"commit is a one-cycle pulse at the packet end and is gone by now");
check(to_integer(unsigned(wr_ptr)) = 4, "four bytes are committed");
check(n_bytes = std_logic_vector(to_unsigned(4, 32)), "and counted");
-- 2. THE case. A packet arrives, is written provisionally, and its CRC
-- fails. The committed pointer must not have moved.
rx_start <= '1'; step; idle;
for i in 0 to 5 loop
rx_byte_valid <= '1'; rx_byte <= x"FF"; step; idle;
end loop;
wait for 1 ns;
check(to_integer(unsigned(prov_ptr)) = 10,
"the provisional pointer ran ahead to 10 -- the bytes ARE in the buffer");
check(to_integer(unsigned(wr_ptr)) = 4,
"but the committed pointer has not moved: firmware still sees 4");
rx_crc_ok <= '0'; rx_end <= '1'; step; idle; rx_crc_ok <= '1';
check(to_integer(unsigned(wr_ptr)) = 4,
"after the CRC failure it is STILL 4");
wait for 1 ns;
check(to_integer(unsigned(prov_ptr)) = 4,
"and the provisional pointer rolled back to meet it");
check(n_crc_drop = std_logic_vector(to_unsigned(1, 32)),
"one packet dropped for a failed CRC");
check(n_bytes = std_logic_vector(to_unsigned(4, 32)),
"and no bytes were added");
-- 3. The next packet reuses the same buffer space. The 0xFF bytes from
-- the dropped packet are physically still there and are overwritten.
packet(3, '1', '1', '0');
check(to_integer(unsigned(wr_ptr)) = 7,
"three more bytes committed, from 4 to 7");
check(d_mem(4) = 16#A0#,
"the byte at offset 4 is from the NEW packet, not the dropped one");
-- 4. A retransmission: good CRC, wrong toggle. ACKed, not committed.
packet(3, '1', '0', '0');
check(to_integer(unsigned(wr_ptr)) = 7,
"the committed pointer did not move");
check(n_dup_drop = std_logic_vector(to_unsigned(1, 32)),
"and it was counted as a duplicate");
-- 5. Overflow. The buffer holds 7 of 16; a 12-byte packet does not fit.
-- Reception continues to the end, and the NAK comes at the end.
rx_start <= '1'; step; idle;
for i in 0 to 11 loop
rx_byte_valid <= '1'; rx_byte <= x"5A"; step; idle;
wait for 1 ns;
if i >= 9 then
check(overflowed = '1',
"once the buffer filled, the packet is marked doomed");
end if;
check(receiving = '1',
"but reception CONTINUES -- there is nowhere to put a refusal yet");
end loop;
wait for 1 ns;
check(to_integer(unsigned(prov_ptr)) = 16,
"the provisional pointer stopped at capacity");
rx_end <= '1'; step; idle;
check(to_integer(unsigned(wr_ptr)) = 7, "nothing was committed");
check(n_nak_space = std_logic_vector(to_unsigned(1, 32)),
"and the packet was NAKed for want of room");
-- 6. A packet that fits EXACTLY fills the buffer and commits.
hard_reset;
packet(CAP, '1', '1', '0');
check(to_integer(unsigned(wr_ptr)) = CAP,
"a packet filling the buffer exactly commits");
check(overflowed = '0', "and is NOT an overflow -- it fit");
check(n_commit = std_logic_vector(to_unsigned(1, 32)), "one commit");
-- 7. One more byte than fits does not.
hard_reset;
packet(CAP + 1, '1', '1', '0');
check(to_integer(unsigned(wr_ptr)) = 0,
"one byte too many commits nothing at all");
check(n_nak_space = std_logic_vector(to_unsigned(1, 32)), "and is NAKed");
-- 8. A halted endpoint STALLs whatever else is true.
hard_reset;
packet(4, '1', '1', '1');
check(to_integer(unsigned(wr_ptr)) = 0,
"a halted endpoint commits nothing");
check(n_stall = std_logic_vector(to_unsigned(1, 32)), "and STALLs");
-- 9. A zero-length packet commits, and commits zero bytes.
hard_reset;
packet(0, '1', '1', '0');
check(n_commit = std_logic_vector(to_unsigned(1, 32)),
"a zero-length packet is a packet and commits");
check(to_integer(unsigned(wr_ptr)) = 0, "adding no bytes");
check(n_bytes = std_logic_vector(to_unsigned(0, 32)), "and counting none");
-- ===== C. randomised: many packets against one buffer =====
-- 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 3999 loop
rnd(rlen, 19);
rnd(iv, 16);
if iv = 0 then flush <= '1'; step; idle; end if;
rnd(iv, 8); if iv /= 0 then kc := '1'; else kc := '0'; end if;
rnd(iv, 8); if iv /= 0 then kt := '1'; else kt := '0'; end if;
rnd(iv, 32); if iv = 0 then kh := '1'; else kh := '0'; end if;
packet(rlen, kc, kt, kh);
n_rand := n_rand + 1;
end loop;
check(c_commit > 100, "commits happened often");
check(c_crc > 100, "CRC failures happened often");
check(c_dup > 100, "retransmissions happened often");
check(c_ovf > 100, "overflows happened often");
check(c_stall > 100, "halted packets happened often");
check(c_exact > 10, "packets that exactly filled the buffer were seen");
report " REACH: exhaustive-packets=" & integer'image(n_dom)
& " random-packets=" & integer'image(n_rand) severity note;
report " OUTCOMES in the sweep: commit=" & integer'image(c_commit)
& " exact-fit=" & integer'image(c_exact)
& " crc-drop=" & integer'image(c_crc)
& " dup-drop=" & integer'image(c_dup)
& " overflow=" & integer'image(c_ovf)
& " stall=" & integer'image(c_stall) severity note;
report " COUNTERS: commits="
& integer'image(to_integer(unsigned(n_commit)))
& " bytes=" & integer'image(to_integer(unsigned(n_bytes)))
& " crc=" & integer'image(to_integer(unsigned(n_crc_drop)))
& " dup=" & integer'image(to_integer(unsigned(n_dup_drop)))
& " nak=" & integer'image(to_integer(unsigned(n_nak_space)))
& " stall=" & integer'image(to_integer(unsigned(n_stall)))
severity note;
report " [VHDL] usb_packet_engine: " & 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 and a Provably Equivalent Mutation
| Measure | Verilog | SystemVerilog | VHDL |
|---|---|---|---|
| Exhaustive packets | 2448 / 2448 | 2448 / 2448 | 2448 / 2448 |
| Random packets | 4000 | 4000 | 4000 |
| sweep: commits | 153 | 153 | 153 |
| sweep: exact-fit commits | 17 | 17 | 17 |
| sweep: CRC drops | 612 | 612 | 612 |
| sweep: retransmissions | 306 | 306 | 306 |
| sweep: overflows | 153 | 153 | 153 |
| sweep: halted | 1224 | 1224 | 1224 |
| commits (total) | 771 | 733 | 734 |
| bytes committed | 3679 | 3454 | 3408 |
| CRC / dup / NAK / STALL | 481 / 459 / 2171 / 118 | 511 / 440 / 2193 / 123 | 496 / 469 / 2195 / 106 |
| Result | PASS | PASS | PASS |
The sweep columns are identical across all three languages because the sweep is deterministic — 2448 packets in a fixed order. The totals differ because the 4000 randomised packets come from three independent generators.
Now the interesting part.
This is the second provably-equivalent mutation in this curriculum, and both had the same shape: a term added to a condition that another term already implied. It is worth recognising on sight, because the alternative is hours spent strengthening a suite that was never weak.
12. Mutation Testing
| # | Mutation | Verilog | SysVer | VHDL |
|---|---|---|---|---|
| H1 | commit-as-you-go — no provisional stage at all | 319189 | 331479 | 321278 |
| H2 | a rollback does not restore the provisional pointer | 5588 | 6079 | 5616 |
| H3 | an overflowed packet is committed anyway | 198145 | 210324 | 200370 |
| H4 | the room test is off by one (< CAP-1) | 254711 | 258725 | 261336 |
| H5 | a retransmission is NAKed instead of ACKed | 1532 | 1494 | 1552 |
| H6 | the engine gives up mid-packet on overflow | 33158 | 33838 | 33451 |
| H7 | a failed CRC is answered with NAK, not silence | 2188 | 2248 | 2218 |
| — | unmutated baseline | 0 | 0 | 0 |
All seven die, all counts distinct, columns within 4%.
H1 is the mutation this chapter exists for. Removing the provisional stage means the committed pointer advances with every byte — so a packet whose CRC fails leaves a partial, corrupt packet visible to firmware, and the n_bytes count is wrong for every dropped packet as well. At 319 000 failures it is the largest in the module, because every single byte of every failed packet is a violation.
H2 is the quietest at ~5600, and it is the most dangerous in the field. The rollback happens — the committed pointer does not move — but the provisional pointer is left where the failed packet stopped. The current packet is handled correctly. The next one starts writing in the wrong place, and its data lands at an offset that depends on the length of a packet that was discarded. That is a corruption whose position varies with traffic history, which is about the least reproducible thing a bug can be.
Note also that the design defends against H2 twice — the rollback restores the pointer and rx_start re-anchors it. Mutation H2 removes only the first, and the second is why the count is 5600 rather than 300 000: most packets are re-anchored before the damage shows. A redundant defence makes a bug rarer without making it less severe, which is exactly the combination that gets something shipped.
H5 and H7 are the two handshake mutations inherited from Chapter 21.1, at ~1500 and ~2200. They are the smallest because each changes exactly one output on one narrow condition, and they are worth keeping in this block's matrix precisely to confirm that composing the rules did not lose them.
13. Debugging Walkthrough: Corruption at an Offset That Moves
The report. A device occasionally delivers a buffer with a block of bytes in the wrong place — not corrupted values, but correct data at the wrong offset, as if part of a transfer had been pasted in shifted. Once every few hours under load. The offset is different every time.
Step 1 — corrupted or displaced? Displaced. The bytes are valid payload; they are in the wrong position. That rules out the CRC: a CRC failure gives you noise, not correct data in the wrong place. Something is writing to the right buffer at the wrong address.
Step 2 — is it the host or the device? Check whether the host ever sees a NAK or a retry around the event. It does: each corruption event is preceded by a transaction the host retried.
Step 3 — which retries? Instrument n_crc_drop, n_dup_drop, n_nak_space and n_stall separately — which is why drop_reason exists. The corruptions follow DROP_CRC events specifically, not the other three.
Step 4 — what is special about a CRC drop? It is the one outcome where bytes were written to the buffer and then disowned. A DROP_TOGGLE packet also writes bytes, but it is a retransmission of data already committed, so the same bytes land in the same place. A DROP_NO_ROOM packet writes nothing new past the cap. Only a CRC drop writes a fresh run of bytes that must then vanish.
Step 5 — the decisive trace. Capture wr_ptr and prov_ptr across a CRC-dropped packet. wr_ptr is correct: it does not move. prov_ptr does not come back. The next packet starts writing from where the failed one ended.
Step 6 — why the offset varies. The displacement equals the length of the discarded packet, which varies with traffic. And it only happens after a CRC failure, which only happens under electrical stress. Mutation H2, in production.
14. UVM: Building Packets, Not Cycles
The natural transaction here is a whole packet, not a bus cycle. A driver that randomises rx_start, rx_byte_valid and rx_end independently spends most of its time producing sequences that no bus could ever generate, and almost none producing the four-way conjunctions that matter.
14.1 The transaction
class usb_pkt_item extends uvm_sequence_item;
`uvm_object_utils(usb_pkt_item)
// ONE PACKET, not one cycle. The driver expands this into rx_start, a run
// of rx_byte_valid cycles, and rx_end -- a sequence the bus can actually
// produce, which randomising the three signals independently would not.
rand int unsigned length;
rand bit crc_ok;
rand bit toggle_match;
rand bit halted;
rand bit flush_before;
// Lengths up to one past the buffer capacity, so overflow is reachable.
constraint c_length { length inside {[0:17]}; }
// A healthy bus. The error sequences below override these.
constraint c_mostly_good {
crc_ok dist {1 := 88, 0 := 12};
toggle_match dist {1 := 88, 0 := 12};
halted dist {0 := 97, 1 := 3};
flush_before dist {0 := 94, 1 := 6};
}
// The boundary lengths get extra weight: 0 (a ZLP is a packet), exactly
// the capacity, and one past it. A uniform draw over 0..17 visits the
// exact-fit case 6% of the time and the suite needs it far more often.
constraint c_boundaries {
length dist { 0 := 12, 16 := 12, 17 := 12, [1:15] := 64 };
}
function new(string name = "usb_pkt_item"); super.new(name); endfunction
function string convert2string();
return $sformatf("len=%0d crc=%0b tog=%0b halt=%0b flush=%0b",
length, crc_ok, toggle_match, halted, flush_before);
endfunction
endclass14.2 Sequences
// THE sequence for this chapter: a long packet that is written provisionally
// and then rejected, immediately followed by a good one. The property under
// test is that the good packet lands where the bad one STARTED, not where it
// ended -- which is mutation H2, the displaced-data bug from section 13.
class reject_then_accept_seq extends uvm_sequence #(usb_pkt_item);
`uvm_object_utils(reject_then_accept_seq)
function new(string name = "reject_then_accept_seq"); super.new(name); endfunction
task body();
repeat (400) begin
usb_pkt_item it;
// A packet that will be rolled back, long enough that a stale
// provisional pointer is unmistakable.
it = usb_pkt_item::type_id::create("it");
start_item(it);
it.c_mostly_good.constraint_mode(0);
if (!it.randomize() with { crc_ok == 0; halted == 0;
toggle_match == 1;
length inside {[4:8]};
flush_before == 0; })
`uvm_error("RAND", "reject randomize failed")
finish_item(it);
// ...and a good one right behind it.
it = usb_pkt_item::type_id::create("it");
start_item(it);
it.c_mostly_good.constraint_mode(0);
if (!it.randomize() with { crc_ok == 1; halted == 0;
toggle_match == 1;
length inside {[1:4]};
flush_before == 0; })
`uvm_error("RAND", "accept randomize failed")
finish_item(it);
end
endtask
endclass
// Fill the buffer to the brim, then present packets that do and do not fit.
// This is where the off-by-one in the room test lives: exactly CAP must
// commit and exactly CAP+1 must not.
class exact_fit_seq extends uvm_sequence #(usb_pkt_item);
`uvm_object_utils(exact_fit_seq)
function new(string name = "exact_fit_seq"); super.new(name); endfunction
task body();
repeat (300) begin
usb_pkt_item it;
int unsigned prefill = $urandom_range(0, 16);
it = usb_pkt_item::type_id::create("it");
start_item(it);
it.c_mostly_good.constraint_mode(0);
it.c_boundaries.constraint_mode(0);
if (!it.randomize() with { flush_before == 1; crc_ok == 1;
toggle_match == 1; halted == 0;
length == prefill; })
`uvm_error("RAND", "prefill randomize failed")
finish_item(it);
// Exactly the remaining space, or one more than it.
it = usb_pkt_item::type_id::create("it");
start_item(it);
it.c_mostly_good.constraint_mode(0);
it.c_boundaries.constraint_mode(0);
if (!it.randomize() with { flush_before == 0; crc_ok == 1;
toggle_match == 1; halted == 0;
length inside {16 - prefill,
17 - prefill}; })
`uvm_error("RAND", "fit randomize failed")
finish_item(it);
end
endtask
endclass
// A packet that overflows halfway. The property under test is that the
// engine keeps receiving to the end -- section 3's rule, and mutation H6.
class overflow_midpacket_seq extends uvm_sequence #(usb_pkt_item);
`uvm_object_utils(overflow_midpacket_seq)
function new(string name = "overflow_midpacket_seq"); super.new(name); endfunction
task body();
repeat (300) begin
usb_pkt_item it;
// fill most of the buffer...
it = usb_pkt_item::type_id::create("it");
start_item(it);
it.c_mostly_good.constraint_mode(0);
it.c_boundaries.constraint_mode(0);
if (!it.randomize() with { flush_before == 1; crc_ok == 1;
toggle_match == 1; halted == 0;
length inside {[10:14]}; })
`uvm_error("RAND", "prefill randomize failed")
finish_item(it);
// ...then send a packet far too long for what is left
it = usb_pkt_item::type_id::create("it");
start_item(it);
it.c_mostly_good.constraint_mode(0);
it.c_boundaries.constraint_mode(0);
if (!it.randomize() with { flush_before == 0; crc_ok == 1;
toggle_match == 1; halted == 0;
length inside {[12:17]}; })
`uvm_error("RAND", "overflow randomize failed")
finish_item(it);
end
endtask
endclass14.3 The scoreboard
class usb_pkt_scoreboard extends uvm_scoreboard;
`uvm_component_utils(usb_pkt_scoreboard)
uvm_analysis_imp #(usb_pkt_mon_item, usb_pkt_scoreboard) ap;
localparam int CAP = 16;
// The scoreboard's own committed pointer, and its own idea of what
// firmware can see. Comparing the DUT's wr_ptr against the DUT's prov_ptr
// would be circular.
int unsigned sb_committed;
int unsigned n_commit, n_rollback, n_overflow, n_exact_fit;
int unsigned drop_by[5];
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
// Called once per COMPLETE packet by the monitor.
function void write(usb_pkt_mon_item t);
bit will_overflow = (sb_committed + t.length) > CAP;
bit should_commit = !t.halted && t.crc_ok && t.toggle_match
&& !will_overflow;
// ---- THE property. The committed pointer moves ONLY on a commit. ----
if (should_commit) begin
if (t.wr_ptr_after != sb_committed + t.length)
`uvm_error("COMMIT",
$sformatf("committed pointer is %0d, expected %0d",
t.wr_ptr_after, sb_committed + t.length))
sb_committed += t.length;
n_commit++;
if (sb_committed == CAP) n_exact_fit++;
end else begin
if (t.wr_ptr_after != sb_committed)
`uvm_error("ROLLBACK",
$sformatf("committed pointer MOVED on a rejected packet: %0d, expected %0d -- firmware can see bytes that were never accepted",
t.wr_ptr_after, sb_committed))
n_rollback++;
end
// ---- ...and the provisional pointer comes BACK. This is the one that
// ---- displaces the NEXT packet rather than corrupting this one. ----
if (t.prov_ptr_after != sb_committed)
`uvm_error("STALE_PROV",
$sformatf("provisional pointer left at %0d, expected %0d -- the next packet will be written at the wrong offset",
t.prov_ptr_after, sb_committed))
// ---- Reception runs to the END of every packet, whatever happens ----
if (t.bytes_seen != t.length)
`uvm_error("EARLY_ABORT",
$sformatf("the engine saw %0d of %0d bytes -- it stopped listening mid-packet, and there is nowhere to put a refusal until the packet ends",
t.bytes_seen, t.length))
// ---- A packet that fits EXACTLY is not an overflow ----
if (!will_overflow && t.overflowed)
`uvm_error("OFF_BY_ONE",
"a packet that fits was reported as an overflow -- the last buffer byte is unreachable")
if (will_overflow && !t.overflowed)
`uvm_error("OVERFLOW", "a packet that did not fit was not reported")
if (will_overflow) n_overflow++;
// ---- The reason must match the priority order ----
begin
drop_reason_e expect_why;
if (!t.halted && t.crc_ok && t.toggle_match && !will_overflow)
expect_why = DROP_NONE;
else if (t.halted) expect_why = DROP_HALTED;
else if (!t.crc_ok) expect_why = DROP_CRC;
else if (!t.toggle_match) expect_why = DROP_TOGGLE;
else expect_why = DROP_NO_ROOM;
if (t.drop_reason != expect_why)
`uvm_error("REASON",
$sformatf("drop_reason=%s, expected %s -- four causes that need four different fixes",
t.drop_reason.name(), expect_why.name()))
drop_by[int'(expect_why)]++;
end
if (t.flush_before) sb_committed = 0;
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SB", $sformatf(
"commits=%0d rollbacks=%0d overflows=%0d exact-fits=%0d",
n_commit, n_rollback, n_overflow, n_exact_fit), UVM_LOW)
// Every drop reason must have been exercised: they are four different
// bugs wearing the same symptom.
foreach (drop_by[i])
if (drop_by[i] == 0)
`uvm_error("COVERAGE",
$sformatf("drop reason %0d never occurred", i))
if (n_exact_fit == 0) `uvm_error("COVERAGE",
"no packet ever filled the buffer exactly -- the off-by-one is untested")
endfunction
endclass14.4 Functional coverage
covergroup pkt_engine_cg with function sample(
int unsigned start_ptr, int unsigned length, bit crc, bit tog, bit halt,
bit ovf, bit committed, drop_reason_e why);
// Buffer occupancy BEFORE the packet, at the boundaries that matter.
cp_start : coverpoint start_ptr {
bins empty = {0};
bins partial = {[1:15]};
bins full = {16};
}
// Packet length relative to the capacity, not in absolute bytes.
cp_len : coverpoint length {
bins zero = {0}; // a ZLP is a packet and commits
bins small = {[1:15]};
bins exact = {16}; // exactly the capacity
bins too_long = {17}; // one past it
}
cp_why : coverpoint why {
bins none = {DROP_NONE};
bins halted = {DROP_HALTED};
bins crc = {DROP_CRC};
bins toggle = {DROP_TOGGLE};
bins no_room = {DROP_NO_ROOM};
}
cp_ovf : coverpoint ovf { bins fitted = {0}; bins overflowed = {1}; }
// THE cross. Occupancy against length: the bin (start=partial, len=exact)
// is where the room test's off-by-one lives, and (start=full, len=zero)
// is the zero-length packet into a full buffer, which must still commit.
x_fit : cross cp_start, cp_len;
// Every drop reason from every starting occupancy -- a rollback from an
// empty buffer and one from a nearly-full buffer exercise different
// pointer arithmetic.
x_why_start : cross cp_why, cp_start;
// The four-way outcome conjunction. Crossing the three error inputs pins
// the PRIORITY: halted beats CRC beats toggle beats room.
cp_crc : coverpoint crc { bins ok = {1}; bins bad = {0}; }
cp_tog : coverpoint tog { bins match = {1}; bins mismatch = {0}; }
cp_halt : coverpoint halt { bins running = {0}; bins halted = {1}; }
x_priority : cross cp_crc, cp_tog, cp_halt, cp_ovf;
endgroupx_priority is a 16-bin cross whose only purpose is to pin the order of §4's decision. Any single error condition tells you the design reacts to it; only presenting two or three at once tells you which one wins — and the priority is the part a reimplementation gets wrong, because each individual rule looks independently correct.
15. SystemVerilog Assertions
module usb_packet_engine_sva
import usb_pktengine_pkg::*;
#(
parameter int CAP = 16,
parameter int PW = 5
) (
input logic clk,
input logic rst_n,
input logic rx_start,
input logic rx_byte_valid,
input logic rx_end,
input logic rx_crc_ok,
input logic toggle_match,
input logic ep_halted,
input logic flush,
input logic [PW-1:0] wr_ptr,
input logic [PW-1:0] prov_ptr,
input logic prov_wr_en,
input logic [PW-1:0] prov_wr_addr,
input logic receiving,
input logic overflowed,
input logic commit,
input logic rollback,
input handshake_e handshake,
input drop_reason_e drop_reason
);
default clocking cb @(posedge clk); endclocking
default disable iff (!rst_n);
// ---- 1. THE property. The committed pointer moves ONLY on a commit or
// ---- a flush. Everything else the engine does is provisional.
property p_committed_moves_only_on_commit;
(!$stable(wr_ptr)) |-> $past(commit || flush);
endproperty
a_committed_moves_only_on_commit :
assert property (p_committed_moves_only_on_commit)
else $error("the committed pointer moved without a commit -- firmware can see unaccepted bytes");
// ---- 2. A rollback restores the provisional pointer. ----
property p_rollback_restores_prov;
(rollback && !flush) |=> (prov_ptr == $past(wr_ptr));
endproperty
a_rollback_restores_prov : assert property (p_rollback_restores_prov)
else $error("a rollback left the provisional pointer stale -- the NEXT packet lands at the wrong offset");
// ---- 3. Every packet starts from the committed pointer. ----
property p_packet_starts_anchored;
(rx_start && !flush) |=> (prov_ptr == $past(wr_ptr));
endproperty
a_packet_starts_anchored : assert property (p_packet_starts_anchored);
// ---- 4. The provisional pointer never falls behind the committed one. ----
property p_prov_never_behind;
prov_ptr >= wr_ptr;
endproperty
a_prov_never_behind : assert property (p_prov_never_behind);
// ---- 5. Neither pointer leaves the buffer. ----
property p_pointers_bounded;
(wr_ptr <= PW'(CAP)) && (prov_ptr <= PW'(CAP));
endproperty
a_pointers_bounded : assert property (p_pointers_bounded);
// ---- 6. Exactly one of commit and rollback at every packet end, and
// ---- neither at any other time.
property p_one_outcome_per_packet;
rx_end |-> (commit ^ rollback);
endproperty
a_one_outcome_per_packet : assert property (p_one_outcome_per_packet);
property p_no_outcome_outside_end;
!rx_end |-> (!commit && !rollback);
endproperty
a_no_outcome_outside_end : assert property (p_no_outcome_outside_end);
// ---- 7. A handshake exists only at a packet end. ----
property p_handshake_only_at_end;
!rx_end |-> (handshake == H_NONE);
endproperty
a_handshake_only_at_end : assert property (p_handshake_only_at_end)
else $error("a handshake was produced mid-packet -- there is nowhere on the bus to put it");
// ---- 8. THE second property. Reception is never abandoned early: while
// ---- a packet is in progress, only rx_end or a flush ends it.
property p_no_early_abort;
(receiving && !rx_end && !flush) |=> receiving;
endproperty
a_no_early_abort : assert property (p_no_early_abort)
else $error("the engine stopped receiving mid-packet -- the bus is still sending and it is no longer listening");
// ---- 9. An overflowed packet is never committed. ----
property p_overflow_never_commits;
overflowed |-> !commit;
endproperty
a_overflow_never_commits : assert property (p_overflow_never_commits);
// ---- 10. A write only happens while receiving, with room. ----
property p_write_needs_room;
prov_wr_en |-> (receiving && (prov_ptr < PW'(CAP))
&& (prov_wr_addr == prov_ptr));
endproperty
a_write_needs_room : assert property (p_write_needs_room);
// ---- 11. A rollback always has a reason, a commit never does. ----
property p_reason_matches_outcome;
(drop_reason != DROP_NONE) == rollback;
endproperty
a_reason_matches_outcome : assert property (p_reason_matches_outcome);
// ---- Cover: every outcome, and the boundary cases. ----
c_commit : cover property ((commit));
c_crc_drop : cover property ((rollback && (drop_reason == DROP_CRC)));
c_dup_drop : cover property ((rollback && (drop_reason == DROP_TOGGLE)));
c_no_room : cover property ((rollback && (drop_reason == DROP_NO_ROOM)));
c_halted : cover property ((rollback && (drop_reason == DROP_HALTED)));
c_exact_fit : cover property ((commit && (prov_ptr == PW'(CAP))));
c_zlp_commit : cover property ((commit && (prov_ptr == wr_ptr)));
endmodule
bind usb_packet_engine usb_packet_engine_sva #(.CAP(CAP), .PW(PW)) u_sva (.*);16. Common Misconceptions
"Wait for the CRC before writing anything." There is nowhere to wait. The payload can be 1024 bytes and the only storage is the buffer you would be writing into.
"A rollback has to erase the bytes." It does not. The bytes are only meaningful because a pointer says so; move the pointer back and they are gone. Erasing would cost cycles the device does not have.
"If the buffer fills mid-packet, stop receiving." There is nowhere to put the refusal until the packet ends. Stopping leaves the engine out of step with the bus (mutation H6, 33 000 failures).
"A packet that exactly fills the buffer is an overflow." It fits. have_room is prov_ptr < CAP, strictly — and the off-by-one makes the last byte of the buffer permanently unreachable (mutation H4).
"A CRC failure and a full buffer are both just 'packet dropped'." They need opposite responses from whoever is debugging: one is electrical, one is a firmware latency problem. That is why drop_reason exists.
"The rollback is enough — the packet start does not need to re-anchor the pointer." Two defences against the same bug is not waste here; it is what turns a 300 000-failure bug into a 5600-failure one, which is the difference between "caught in the first simulation" and "shipped". See H2.
"A zero-length packet has nothing to commit, so it can be skipped." It commits zero bytes and it is still a commit — the handshake, the counter and the toggle all depend on it. See Chapter 21.2.
17. Exercises
1. Make the committed pointer advance per byte (mutation H1) and predict, before running it, whether n_bytes or wr_ptr fails first. Explain why the count is 319 000 rather than the ~3500 bytes the suite commits.
2. The design re-anchors prov_ptr on rx_start and on rollback. Remove the rx_start anchor instead of the rollback one and measure the new count. Which of the two defences is worth more, and does that match which one you would have written first?
3. H6's first version was provably equivalent. Construct a third mutation of the overflow path that is also equivalent, prove it, and then construct one that looks equivalent and is not.
4. Add a rx_abort input modelling a bus reset arriving mid-packet. Which of the eleven SVA properties need changing? Property 8 says reception is never abandoned early — restate it so that it remains true and still forbids H6.
5. Property 1 uses $past(commit || flush). Write the version that does not use $past, using an auxiliary flop, and say which you would prefer in a formal run and which in simulation.
6. The scoreboard in §14.3 checks t.bytes_seen != t.length. Work out what the monitor has to do to produce bytes_seen, and why counting prov_wr_en pulses would give the wrong answer for an overflowing packet.
18. Summary
| Idea | Why it matters |
|---|---|
| The CRC is the last thing on the wire | the device must write before it knows |
| Provisional writes, one committed pointer | firmware sees only what was accepted |
COMMIT: committed ← provisional | one assignment, no cycles |
ROLLBACK: provisional ← committed | the bytes are unwritten by being unpointed-at |
Re-anchor on rx_start as well | two defences; H2 shows what the second is worth |
| You cannot change your mind mid-packet | there is nowhere to put a refusal until the end |
| So an overflow keeps receiving and NAKs at the end | giving up loses the bus |
have_room is < CAP, strictly | an exact fit is a fit |
| halted > CRC > toggle > room | four causes, three handshakes |
drop_reason names which | four different afternoons for four engineers |
| 2448-packet exhaustive verification | every occupancy × every length × every outcome |
| 7 mutations, all killed in 3 languages | after one was found provably equivalent |
Tooling
| Step | Command |
|---|---|
| Verilog-2005 | iverilog -g2005 -o pe_v.out pe_v.v pe_v_tb.v && ./pe_v.out |
| SystemVerilog | iverilog -g2012 -o pe_sv.out pe_sv.sv pe_sv_tb.sv && ./pe_sv.out |
| VHDL-2008 analyse | nvc --std=2008 -a pe_vhdl.vhd pe_vhdl_tb.vhd |
| VHDL-2008 elaborate | nvc --std=2008 -e tb_pe_vhdl |
| VHDL-2008 run | nvc --std=2008 -r tb_pe_vhdl |
| One mutation | iverilog -g2005 -DMUT_H1 -o mm pe_v_mut.v pe_v_tb.v && ./mm |
All three implementations pass with 0 errors: 2448 of 2448 exhaustive packets, 4000 randomised packets, every outcome reached and asserted reached.
Chapter 21.5 — Controller FSMs closes the module with the state machine that sits above all four of these blocks — Attached, Powered, Default, Address, Configured, Suspended. Its defining property is that a bus reset can arrive in any state and always returns to Default, and the part that gets missed is that returning to Default is not enough: every endpoint's toggle and halt must be flushed with it, or a re-enumerated device carries stale per-endpoint state into a fresh session.
Continue learning
Related tutorials
- Related topic
USB 3.x Packets
Every SuperSpeed packet carries two independent CRCs, and that is not redundancy: a corrupt header is a link-layer problem while corrupt data is a protocol-layer one, so the header CRC must gate the type decode.
- 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
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.
- 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.
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.
