Skip to content
VLSI Mentor

USB · Module 21

FIFO Architecture

An endpoint FIFO stores packets, not bytes — a zero-length packet carries nothing and must still occupy a buffer, because it is the only thing that terminates a transfer ending on a packet boundary.

Chapter 21.1 ended with a signal called fifo_write_en. This chapter builds what it writes into, and the design turns on a distinction that sounds pedantic right up to the moment it hangs a transfer.

1. Firmware Wants Bytes. The Bus Delivers Packets.

The obvious endpoint buffer is a byte FIFO. Bytes go in from the bus, bytes come out to firmware, occupancy is a byte count. Every FIFO tutorial ever written describes this FIFO, and it is the wrong one.

The boundaries between USB packets carry information that the bytes do not.

A USB transfer — "send me these 1024 bytes" — is not one packet. It is a sequence of packets, each at most wMaxPacketSize bytes, and there is no length field anywhere in the protocol saying how many there will be. So how does the receiving end know when a transfer has finished?

Which raises the question this chapter is really about: what if the data ends exactly on a packet boundary?

A 1024-byte transfer on a 64-byte endpoint is sixteen full packets and nothing left over. There is no short packet to send. The host has received 1024 bytes and has no idea whether more are coming.

2. The Zero-Length Packet

The answer is to send a seventeenth packet containing no bytes at all.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  1024 bytes on a 64-byte endpoint:

     [64][64][64][64][64][64][64][64]
     [64][64][64][64][64][64][64][64]   <- sixteen full packets
     [  ]                               <- and a ZERO-LENGTH PACKET

  The ZLP carries nothing. It is not an optimisation, an idle
  marker, or a formality. It is the only thing that tells the
  host the transfer is over.

A ZLP is a real packet. It has a token, a PID with a data toggle, a CRC over an empty payload, and it is ACKed like any other. The only thing it lacks is bytes.

And that is exactly what a byte FIFO cannot represent. Writing zero bytes into a byte FIFO is indistinguishable from writing nothing:

byte FIFOpacket FIFO
store a 64-byte packetoccupancy += 64occupancy += 1
store a ZLPoccupancy += 0 — nothing happenedoccupancy += 1
can firmware tell?noyes

So: a ZLP must occupy a buffer. The FIFO stores packets and their lengths, and zero is a perfectly good length.

3. Why Two Buffers

The second structural decision. A USB transaction must be answered immediately — the device has roughly a bit-time to decide between ACK and NAK (Chapter 21.1), so the answer is "is there a free buffer right now?"

With one buffer, firmware has to drain a packet before the next one arrives. It cannot: the bus does not wait for a CPU. So every second transaction is NAKed, the host retries, and the endpoint runs at a fraction of its rate.

With two, the bus fills one while firmware drains the other:

Ping-pong: two buffers, and the property that makes them worth having

A ping-pong endpoint buffer. The bus side writes a complete packet and its length into whichever buffer the write pointer selects. Firmware reads the buffer the read pointer selects. Both can happen in the same cycle, leaving occupancy unchanged. When both buffers are full the endpoint must NAK.Bus sidewr_commit + wr_lenBuffer Apayload + its lengthFirmwarerd_ack when consumedBoth full?the endpoint must NAKBuffer Bpayload + its lengthRead pointeroldest packet firstwriteread12
The bus writes one buffer while firmware reads the other, in the same cycle. That simultaneity is the whole point — a design that blocks the write while a read is in progress has two buffers and the throughput of one.

The property that makes ping-pong work is that a write and a read in the SAME CYCLE must both succeed and leave the occupancy unchanged. It is easy to write a two-buffer FIFO that quietly serialises them, and the result has twice the area and none of the benefit — mutation F3 in §11 measures exactly that.

4. Two Buffers Is a Ring, Not a Pair

The third trap. It is tempting to think of ping-pong as "alternate between A and B", with one bit that flips each time. That works until something interrupts the rhythm.

Two buffers used in order is a 2-deep ring, and a ring needs a read pointer that is genuinely independent of the write pointer. Packets must come out in the order they went in. Consider a flush — an endpoint reset, or a CLEAR_FEATURE — arriving when the read pointer is at B and the write pointer is at A:

write pointerread pointeroccupancy
before flushAB1
flush resets only the countAB0
bus writes the next packetinto Astill B1
firmware reads—B — the wrong buffer

Firmware reads a stale buffer holding a packet from before the reset. So a flush must reset both pointers, not just the occupancy. That is mutation F2.

5. What We Are Building

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  usb_ep_pingpong

  bus side                      firmware side
  --------                      -------------
  wr_commit                     rd_ack
  wr_len   [6:0]   ZERO LEGAL   rd_valid
  wr_ready                      rd_len   [6:0]
  nak_needed                    rd_zlp
                                rd_short
  flush                         rd_class  NONE / ZLP / SHORT / FULL
  mps      [6:0]                used     [1:0]

  n_written / n_read / n_zlp / n_short / n_nak / n_flush

rd_class deserves a note. It is a single named value carrying the same three facts as rd_valid, rd_zlp and rd_short — and it exists because PKT_NONE and PKT_ZLP are the two things a byte FIFO cannot tell apart. Giving them distinct names in the type system puts the distinction somewhere a reader cannot skip past:

rd_classmeaningterminates the transfer?
PKT_NONEthe FIFO is empty; there is no packet— there is nothing to ask about
PKT_ZLPa packet of zero bytesyes
PKT_SHORTfewer than mps bytesyes
PKT_FULLexactly mps bytesno — more is coming

6. Verilog-2005 Implementation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// usb_ep_pingpong -- the endpoint FIFO, and the reason it cannot be a byte
// FIFO no matter how convenient that would be.
//
// A PACKET FIFO, NOT A BYTE FIFO
//
// Firmware wants a stream of bytes. The bus delivers a stream of PACKETS, and
// the boundaries between them carry meaning that the bytes do not:
//
//   * a packet SHORTER than the endpoint's max packet size TERMINATES the
//     current transfer. That is how a device says "this is all of it" without
//     any length field anywhere.
//
//   * a ZERO-LENGTH PACKET is that same signal when the data happened to end
//     exactly on a packet boundary. It carries no bytes and it is not
//     optional -- without it the host waits for more data for ever.
//
// So a ZLP is a REAL PACKET and it MUST OCCUPY A BUFFER. A FIFO that stores
// bytes cannot represent it at all: zero bytes written is indistinguishable
// from nothing written. This is the single most common way a first device
// controller hangs, and it hangs only on transfers whose length happens to be
// an exact multiple of the max packet size -- which on a 64-byte endpoint is
// one transfer in sixty-four, and never the one on the bench.
//
// WHY TWO BUFFERS
//
// A transaction must be answered immediately: ACK if the data was taken, NAK
// if there was no room. With ONE buffer, firmware must drain a packet before
// the next one arrives, and it cannot -- so every second transaction is NAKed
// and the endpoint runs at half rate or worse.
//
// With TWO, the bus can write one buffer while firmware reads the other:
//
//      bus  ->  [ buffer A ]  ->  firmware        both happening
//      bus  ->  [ buffer B ]  ->  firmware        in the same cycle
//
// That is the whole of "ping-pong", and the property that makes it work is
// that a write and a read in the SAME CYCLE must both succeed and leave the
// occupancy unchanged. A design that blocks the write while a read is in
// progress has two buffers and the throughput of one.
//
// THE ORDERING TRAP
//
// Two buffers used alternately is a 2-deep RING, not a pair of independent
// slots. Packets must come out in the order they went in, which needs a read
// pointer that is genuinely separate from the write pointer. "Alternate each
// time" works until a flush happens at an odd moment, and then firmware reads
// buffer B while the bus is filling buffer A.
module usb_ep_pingpong #(
  parameter LEN_W = 7            // 0 .. 64 needs 7 bits
) (
  input  wire              clk,
  input  wire              rst_n,

  input  wire              flush,       // endpoint reset / CLEAR_FEATURE
  input  wire [LEN_W-1:0]  mps,         // this endpoint's max packet size

  input  wire              wr_commit,   // the bus has a complete packet
  input  wire [LEN_W-1:0]  wr_len,      // its length -- ZERO IS LEGAL
  input  wire              rd_ack,      // firmware has consumed rd_len bytes

  output wire              wr_ready,    // a buffer is free: the bus may ACK
  output wire              rd_valid,    // a packet is waiting for firmware
  output wire [LEN_W-1:0]  rd_len,
  output wire              rd_zlp,      // that packet is zero-length
  output wire              rd_short,    // and/or shorter than mps: ends the
                                        // transfer
  output wire [1:0]        rd_class,    // the same fact, as one named value
  output wire [1:0]        used,
  output wire              nak_needed,  // a packet arriving now must be NAKed

  output reg  [31:0]       n_written,
  output reg  [31:0]       n_read,
  output reg  [31:0]       n_zlp,
  output reg  [31:0]       n_short,
  output reg  [31:0]       n_nak,
  output reg  [31:0]       n_flush
);
  reg [LEN_W-1:0] len_mem [0:1];
  reg             wr_ptr;
  reg             rd_ptr;
  reg [1:0]       count;

  // Occupancy, not "which buffer" -- the two are different questions and
  // conflating them is the ordering trap above.
  assign wr_ready   = (count != 2'd2);
  assign rd_valid   = (count != 2'd0);
  assign used       = count;
  assign nak_needed = !wr_ready;

  assign rd_len   = len_mem[rd_ptr];
  // A ZLP is only a ZLP if there IS a packet. An empty FIFO has no length,
  // and reporting zero-length-packet on an empty FIFO invents a terminator.
  assign rd_zlp   = rd_valid && (rd_len == {LEN_W{1'b0}});
  // STRICTLY less than mps. A packet of exactly mps bytes is a FULL packet
  // and the transfer continues; off-by-one here truncates every transfer
  // whose length is an exact multiple of the packet size.
  assign rd_short = rd_valid && (rd_len < mps);

  // The same three facts as one value. PKT_ZLP is a distinct class from
  // PKT_NONE precisely because a byte-oriented FIFO cannot tell them apart,
  // and that confusion is what hangs a transfer.
  localparam [1:0] PKT_NONE  = 2'd0,  // the FIFO is empty: no packet at all
                   PKT_ZLP   = 2'd1,  // zero bytes -- a real packet
                   PKT_SHORT = 2'd2,  // below mps -- also a terminator
                   PKT_FULL  = 2'd3;  // exactly mps -- the transfer CONTINUES

  assign rd_class = !rd_valid                    ? PKT_NONE
                  : (rd_len == {LEN_W{1'b0}})    ? PKT_ZLP
                  : (rd_len < mps)               ? PKT_SHORT
                                                 : PKT_FULL;

  // A write and a read in the same cycle must BOTH happen. That is the entire
  // reason for the second buffer.
  wire do_wr = wr_commit && wr_ready;
  wire do_rd = rd_ack   && rd_valid;

  integer i;
  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      wr_ptr    <= 1'b0;
      rd_ptr    <= 1'b0;
      count     <= 2'd0;
      n_written <= 32'd0;
      n_read    <= 32'd0;
      n_zlp     <= 32'd0;
      n_short   <= 32'd0;
      n_nak     <= 32'd0;
      n_flush   <= 32'd0;
      for (i = 0; i < 2; i = i + 1) len_mem[i] <= {LEN_W{1'b0}};
    end else if (flush) begin
      // BOTH pointers, not just the count. A flush that resets occupancy but
      // leaves rd_ptr where it was makes firmware read the buffer the bus is
      // about to write.
      wr_ptr  <= 1'b0;
      rd_ptr  <= 1'b0;
      count   <= 2'd0;
      n_flush <= n_flush + 32'd1;
    end else begin
      if (do_wr) begin
        len_mem[wr_ptr] <= wr_len;
        wr_ptr          <= ~wr_ptr;
        n_written       <= n_written + 32'd1;
        // A zero-length packet is counted as a packet, because it is one.
        if (wr_len == {LEN_W{1'b0}}) n_zlp <= n_zlp + 32'd1;
        if (wr_len < mps)            n_short <= n_short + 32'd1;
      end
      if (do_rd) begin
        rd_ptr <= ~rd_ptr;
        n_read <= n_read + 32'd1;
      end
      // Occupancy moves only when exactly one side acts. Both acting leaves
      // it where it was, which is what lets the endpoint run at full rate.
      case ({do_wr, do_rd})
        2'b10:   count <= count + 2'd1;
        2'b01:   count <= count - 2'd1;
        default: count <= count;
      endcase

      // A packet that arrived with no room is a NAK the endpoint had to send.
      if (wr_commit && !wr_ready) n_nak <= n_nak + 32'd1;
    end
  end
endmodule

Three lines carry most of the risk in that listing.

rd_zlp = rd_valid && (rd_len == 0) — the rd_valid term is not defensive padding. An empty FIFO has rd_len reading whatever is in the buffer the read pointer happens to address, and without the guard an empty FIFO announces a zero-length packet, which is a fabricated transfer terminator. Mutation F6.

rd_short = rd_valid && (rd_len < mps) — strictly less than. A packet of exactly mps bytes is full and the transfer continues. Change < to <= and every transfer whose length is a multiple of the packet size ends one packet early. Mutation F4, and note that it is the mirror image of the ZLP bug: one truncates transfers, the other never ends them.

case ({do_wr, do_rd}) with 2'b11 falling into default: count <= count. That default is doing real work: it is the simultaneity property from §3, and writing it as two independent if statements is how it gets lost.

7. SystemVerilog Implementation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// usb_ep_pingpong -- the endpoint FIFO, and the reason it cannot be a byte
// FIFO no matter how convenient that would be.

//
// A PACKET FIFO, NOT A BYTE FIFO
//
// Firmware wants a stream of bytes. The bus delivers a stream of PACKETS, and
// the boundaries between them carry meaning that the bytes do not:
//
//   * a packet SHORTER than the endpoint's max packet size TERMINATES the
//     current transfer. That is how a device says "this is all of it" without
//     any length field anywhere.
//
//   * a ZERO-LENGTH PACKET is that same signal when the data happened to end
//     exactly on a packet boundary. It carries no bytes and it is not
//     optional -- without it the host waits for more data for ever.
//
// So a ZLP is a REAL PACKET and it MUST OCCUPY A BUFFER. A FIFO that stores
// bytes cannot represent it at all: zero bytes written is indistinguishable
// from nothing written. This is the single most common way a first device
// controller hangs, and it hangs only on transfers whose length happens to be
// an exact multiple of the max packet size -- which on a 64-byte endpoint is
// one transfer in sixty-four, and never the one on the bench.
//
// WHY TWO BUFFERS
//
// A transaction must be answered immediately: ACK if the data was taken, NAK
// if there was no room. With ONE buffer, firmware must drain a packet before
// the next one arrives, and it cannot -- so every second transaction is NAKed
// and the endpoint runs at half rate or worse.
//
// With TWO, the bus can write one buffer while firmware reads the other:
//
//      bus  ->  [ buffer A ]  ->  firmware        both happening
//      bus  ->  [ buffer B ]  ->  firmware        in the same cycle
//
// That is the whole of "ping-pong", and the property that makes it work is
// that a write and a read in the SAME CYCLE must both succeed and leave the
// occupancy unchanged. A design that blocks the write while a read is in
// progress has two buffers and the throughput of one.
//
// THE ORDERING TRAP
//
// Two buffers used alternately is a 2-deep RING, not a pair of independent
// slots. Packets must come out in the order they went in, which needs a read
// pointer that is genuinely separate from the write pointer. "Alternate each
// time" works until a flush happens at an odd moment, and then firmware reads
// buffer B while the bus is filling buffer A.
package usb_pingpong_pkg;
  // What a packet MEANS to the transfer it belongs to. Naming these is not
  // decoration: PKT_ZLP and PKT_SHORT both terminate a transfer and PKT_FULL
  // does not, and that is the distinction the whole block exists to preserve.
  typedef enum logic [1:0] {
    PKT_NONE  = 2'd0,   // the FIFO is empty; there is no packet to describe
    PKT_ZLP   = 2'd1,   // zero bytes -- a real packet, and a terminator
    PKT_SHORT = 2'd2,   // fewer than mps bytes -- also a terminator
    PKT_FULL  = 2'd3    // exactly mps bytes -- the transfer CONTINUES
  } pkt_class_e;
endpackage

module usb_ep_pingpong
  import usb_pingpong_pkg::*;
#(
  parameter int LEN_W = 7        // 0 .. 64 needs 7 bits
) (
  input  logic              clk,
  input  logic              rst_n,

  input  logic              flush,     // endpoint reset / CLEAR_FEATURE
  input  logic [LEN_W-1:0]  mps,       // this endpoint's max packet size

  input  logic              wr_commit, // the bus has a complete packet
  input  logic [LEN_W-1:0]  wr_len,    // its length -- ZERO IS LEGAL
  input  logic              rd_ack,    // firmware has consumed rd_len bytes

  output logic              wr_ready,  // a buffer is free: the bus may ACK
  output logic              rd_valid,  // a packet is waiting for firmware
  output logic [LEN_W-1:0]  rd_len,
  output logic              rd_zlp,    // that packet is zero-length
  output logic              rd_short,  // and/or shorter than mps: ends the
                                       // transfer
  output pkt_class_e        rd_class,  // the same fact, named
  output logic [1:0]        used,
  output logic              nak_needed,// a packet arriving now must be NAKed

  output logic [31:0]       n_written,
  output logic [31:0]       n_read,
  output logic [31:0]       n_zlp,
  output logic [31:0]       n_short,
  output logic [31:0]       n_nak,
  output logic [31:0]       n_flush
);
  logic [LEN_W-1:0] len_mem [0:1];
  logic             wr_ptr;
  logic             rd_ptr;
  logic [1:0]       count;

  // Occupancy, not "which buffer" -- the two are different questions and
  // conflating them is the ordering trap above.
  assign wr_ready   = (count != 2'd2);
  assign rd_valid   = (count != 2'd0);
  assign used       = count;
  assign nak_needed = !wr_ready;

  assign rd_len   = len_mem[rd_ptr];
  // A ZLP is only a ZLP if there IS a packet. An empty FIFO has no length,
  // and reporting zero-length-packet on an empty FIFO invents a terminator.
  assign rd_zlp   = rd_valid && (rd_len == '0);
  // STRICTLY less than mps. A packet of exactly mps bytes is a FULL packet
  // and the transfer continues; off-by-one here truncates every transfer
  // whose length is an exact multiple of the packet size.
  assign rd_short = rd_valid && (rd_len < mps);

  // The same three facts as one named value. A case statement over
  // pkt_class_e must handle PKT_ZLP explicitly, which is precisely the case a
  // byte-oriented FIFO cannot represent and a careless reader forgets.
  always_comb begin
    if      (!rd_valid)        rd_class = PKT_NONE;
    else if (rd_len == '0)     rd_class = PKT_ZLP;
    else if (rd_len < mps)     rd_class = PKT_SHORT;
    else                       rd_class = PKT_FULL;
  end

  // A write and a read in the same cycle must BOTH happen. That is the entire
  // reason for the second buffer.
  logic do_wr, do_rd;
  assign do_wr = wr_commit && wr_ready;
  assign do_rd = rd_ack   && rd_valid;

  int i;
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      wr_ptr    <= 1'b0;
      rd_ptr    <= 1'b0;
      count     <= 2'd0;
      n_written <= '0;
      n_read    <= '0;
      n_zlp     <= '0;
      n_short   <= '0;
      n_nak     <= '0;
      n_flush   <= '0;
      for (i = 0; i < 2; i++) len_mem[i] <= '0;
    end else if (flush) begin
      // BOTH pointers, not just the count. A flush that resets occupancy but
      // leaves rd_ptr where it was makes firmware read the buffer the bus is
      // about to write.
      wr_ptr  <= 1'b0;
      rd_ptr  <= 1'b0;
      count   <= 2'd0;
      n_flush <= n_flush + 1;
    end else begin
      if (do_wr) begin
        len_mem[wr_ptr] <= wr_len;
        wr_ptr          <= ~wr_ptr;
        n_written       <= n_written + 1;
        // A zero-length packet is counted as a packet, because it is one.
        if (wr_len == '0)  n_zlp   <= n_zlp + 1;
        if (wr_len < mps)  n_short <= n_short + 1;
      end
      if (do_rd) begin
        rd_ptr <= ~rd_ptr;
        n_read <= n_read + 1;
      end
      // Occupancy moves only when exactly one side acts. Both acting leaves
      // it where it was, which is what lets the endpoint run at full rate.
      case ({do_wr, do_rd})
        2'b10:   count <= count + 2'd1;
        2'b01:   count <= count - 2'd1;
        default: count <= count;
      endcase

      // A packet that arrived with no room is a NAK the endpoint had to send.
      if (wr_commit && !wr_ready) n_nak <= n_nak + 1;
    end
  end
endmodule

8. VHDL-2008 Implementation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-- usb_ep_pingpong -- the endpoint FIFO, and the reason it cannot be a byte
-- FIFO no matter how convenient that would be.
--
-- A PACKET FIFO, NOT A BYTE FIFO
--
-- Firmware wants a stream of bytes. The bus delivers a stream of PACKETS, and
-- the boundaries between them carry meaning the bytes do not:
--
--   * a packet SHORTER than the endpoint's max packet size TERMINATES the
--     current transfer. That is how a device says "this is all of it" with no
--     length field anywhere.
--
--   * a ZERO-LENGTH PACKET is that same signal when the data happened to end
--     exactly on a packet boundary. It carries no bytes and it is not
--     optional -- without it the host waits for more data for ever.
--
-- So a ZLP is a REAL PACKET and it MUST OCCUPY A BUFFER. A FIFO that stores
-- bytes cannot represent it at all: zero bytes written is indistinguishable
-- from nothing written. This is the single most common way a first device
-- controller hangs, and it hangs only on transfers whose length is an exact
-- multiple of the max packet size -- one in sixty-four on a 64-byte endpoint,
-- and never the one on the bench.
--
-- WHY TWO BUFFERS
--
-- A transaction must be answered immediately: ACK if the data was taken, NAK
-- if there was no room. With ONE buffer, firmware must drain a packet before
-- the next arrives and it cannot, so every second transaction is NAKed.
--
-- With TWO, the bus writes one buffer while firmware reads the other, and the
-- property that makes it work is that a write and a read in the SAME CYCLE
-- must both succeed and leave occupancy unchanged. A design that blocks the
-- write while a read is in progress has two buffers and the throughput of one.
--
-- THE ORDERING TRAP
--
-- Two buffers used alternately is a 2-deep RING, not a pair of independent
-- slots. Packets must come out in the order they went in, which needs a read
-- pointer genuinely separate from the write pointer. "Alternate each time"
-- works until a flush happens at an odd moment, and then firmware reads the
-- buffer the bus is filling.
--
-- VHDL names the packet classification as an enumerated type, which is what
-- makes PKT_NONE and PKT_ZLP impossible to confuse: a case statement over
-- pkt_class_t must handle both, and "empty" and "a packet of zero bytes" are
-- exactly the two things a byte FIFO cannot tell apart.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

package usb_pingpong_pkg is
  type pkt_class_t is (
    PKT_NONE,    -- the FIFO is empty; there is no packet to describe
    PKT_ZLP,     -- zero bytes -- a real packet, and a terminator
    PKT_SHORT,   -- fewer than mps bytes -- also a terminator
    PKT_FULL     -- exactly mps bytes -- the transfer CONTINUES
  );

  function class_code(c : pkt_class_t) return std_logic_vector;
end package;

package body usb_pingpong_pkg is
  function class_code(c : pkt_class_t) return std_logic_vector is
  begin
    return std_logic_vector(to_unsigned(pkt_class_t'pos(c), 2));
  end function;
end package body;

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.usb_pingpong_pkg.all;

entity usb_ep_pingpong is
  generic (
    LEN_W : natural := 7             -- 0 .. 64 needs 7 bits
  );
  port (
    clk        : in  std_logic;
    rst_n      : in  std_logic;

    flush      : in  std_logic;      -- endpoint reset / CLEAR_FEATURE
    mps        : in  std_logic_vector(LEN_W-1 downto 0);

    wr_commit  : in  std_logic;      -- the bus has a complete packet
    wr_len     : in  std_logic_vector(LEN_W-1 downto 0); -- ZERO IS LEGAL
    rd_ack     : in  std_logic;      -- firmware consumed rd_len bytes

    wr_ready   : out std_logic;      -- a buffer is free: the bus may ACK
    rd_valid   : out std_logic;      -- a packet is waiting for firmware
    rd_len     : out std_logic_vector(LEN_W-1 downto 0);
    rd_zlp     : out std_logic;
    rd_short   : out std_logic;
    rd_class   : out std_logic_vector(1 downto 0);
    used       : out std_logic_vector(1 downto 0);
    nak_needed : out std_logic;

    n_written  : out std_logic_vector(31 downto 0);
    n_read     : out std_logic_vector(31 downto 0);
    n_zlp      : out std_logic_vector(31 downto 0);
    n_short    : out std_logic_vector(31 downto 0);
    n_nak      : out std_logic_vector(31 downto 0);
    n_flush    : out std_logic_vector(31 downto 0)
  );
end entity;

architecture rtl of usb_ep_pingpong is
  type len_array_t is array (0 to 1) of unsigned(LEN_W-1 downto 0);

  signal len_mem : len_array_t := (others => (others => '0'));
  signal wr_ptr  : integer range 0 to 1 := 0;
  signal rd_ptr  : integer range 0 to 1 := 0;
  signal count   : integer range 0 to 2 := 0;

  signal wrdy, rval : std_logic;
  -- Initialised so that the very first delta cycle, before reset has
  -- propagated, does not compare an uninitialised vector and raise a
  -- NUMERIC_STD metavalue warning.
  signal cur_len    : unsigned(LEN_W-1 downto 0) := (others => '0');
  signal zlp_s, short_s : std_logic := '0';
  signal cls        : pkt_class_t;
  signal do_wr, do_rd : std_logic;

  signal wr_r, rd_r, zlp_r, short_r, nak_r, fl_r
       : unsigned(31 downto 0) := (others => '0');
begin
  -- Occupancy, not "which buffer" -- the two are different questions and
  -- conflating them is the ordering trap above.
  wrdy <= '1' when count /= 2 else '0';
  rval <= '1' when count /= 0 else '0';

  wr_ready   <= wrdy;
  rd_valid   <= rval;
  used       <= std_logic_vector(to_unsigned(count, 2));
  nak_needed <= not wrdy;

  cur_len <= len_mem(rd_ptr);
  rd_len  <= std_logic_vector(cur_len);

  -- A ZLP is only a ZLP if there IS a packet. An empty FIFO has no length,
  -- and reporting zero-length-packet on an empty FIFO invents a terminator.
  zlp_s <= '1' when (rval = '1' and cur_len = 0) else '0';
  -- STRICTLY less than mps. A packet of exactly mps bytes is a FULL packet
  -- and the transfer continues; off-by-one here truncates every transfer
  -- whose length is an exact multiple of the packet size.
  short_s <= '1' when (rval = '1' and cur_len < unsigned(mps)) else '0';

  rd_zlp   <= zlp_s;
  rd_short <= short_s;

  classify : process (rval, cur_len, mps)
  begin
    if rval = '0' then
      cls <= PKT_NONE;
    elsif cur_len = 0 then
      cls <= PKT_ZLP;
    elsif cur_len < unsigned(mps) then
      cls <= PKT_SHORT;
    else
      cls <= PKT_FULL;
    end if;
  end process;

  rd_class <= class_code(cls);

  -- A write and a read in the same cycle must BOTH happen. That is the entire
  -- reason for the second buffer.
  do_wr <= wr_commit and wrdy;
  do_rd <= rd_ack   and rval;

  regs : process (clk, rst_n)
  begin
    if rst_n = '0' then
      wr_ptr  <= 0;
      rd_ptr  <= 0;
      count   <= 0;
      len_mem <= (others => (others => '0'));
      wr_r    <= (others => '0');
      rd_r    <= (others => '0');
      zlp_r   <= (others => '0');
      short_r <= (others => '0');
      nak_r   <= (others => '0');
      fl_r    <= (others => '0');
    elsif rising_edge(clk) then
      if flush = '1' then
        -- BOTH pointers, not just the count. A flush that resets occupancy
        -- but leaves rd_ptr where it was makes firmware read the buffer the
        -- bus is about to write.
        wr_ptr <= 0;
        rd_ptr <= 0;
        count  <= 0;
        fl_r   <= fl_r + 1;
      else
        if do_wr = '1' then
          len_mem(wr_ptr) <= unsigned(wr_len);
          wr_ptr          <= 1 - wr_ptr;
          wr_r            <= wr_r + 1;
          -- A zero-length packet is counted as a packet, because it is one.
          if unsigned(wr_len) = 0 then
            zlp_r <= zlp_r + 1;
          end if;
          if unsigned(wr_len) < unsigned(mps) then
            short_r <= short_r + 1;
          end if;
        end if;
        if do_rd = '1' then
          rd_ptr <= 1 - rd_ptr;
          rd_r   <= rd_r + 1;
        end if;
        -- Occupancy moves only when exactly one side acts. Both acting leaves
        -- it where it was, which is what lets the endpoint run at full rate.
        if do_wr = '1' and do_rd = '0' then
          count <= count + 1;
        elsif do_wr = '0' and do_rd = '1' then
          count <= count - 1;
        end if;

        -- A packet that arrived with no room is a NAK the endpoint had to
        -- send.
        if wr_commit = '1' and wrdy = '0' then
          nak_r <= nak_r + 1;
        end if;
      end if;
    end if;
  end process;

  n_written <= std_logic_vector(wr_r);
  n_read    <= std_logic_vector(rd_r);
  n_zlp     <= std_logic_vector(zlp_r);
  n_short   <= std_logic_vector(short_r);
  n_nak     <= std_logic_vector(nak_r);
  n_flush   <= std_logic_vector(fl_r);
end architecture;

9. Seeing Both Properties

A simultaneous write and read, then the ZLP that ends the transfer

usb_ep_pingpong — simultaneity, and a zero-length packet

10 cycles
A ten-cycle waveform with an 8-byte max packet size. Cycles 1 and 2 commit two full 8-byte packets and occupancy rises to 2, so wr_ready falls. At cycle 4 wr_commit and rd_ack are both high and occupancy stays at 2. At cycle 6 a read empties the FIFO. At cycle 7 a packet of length zero is committed: occupancy rises to 1, rd_class reads ZLP and rd_short rises.write + read: used stays 2write + read: used stays 2ZLP committedZLP committedZLP terminates the transferZLP terminates the transferclkwr_commitwr_len0888888000rd_ackused0012221011wr_readyrd_classNONENONEFULLFULLFULLFULLFULLNONEZLPZLPrd_shortn_zlp0000000011t0t1t2t3t4t5t6t7t8t9
Cycles 1–2 fill both buffers. Cycle 4 writes and reads in the same cycle: occupancy stays at 2 — that is ping-pong working. Cycle 7 commits a ZERO-LENGTH packet: occupancy rises, rd_class reads ZLP, and rd_short goes high. A byte FIFO would have recorded nothing at cycle 7.

Cycle 4 is the one to read carefully. wr_commit and rd_ack are both high, used does not move, and both operations happened. That is the second buffer earning its area.

10. The Testbenches

Each suite runs two separate exhaustive domains plus directed scenarios and a randomised phase. Two domains rather than one, because the full cross of control state against every packet length is larger than it needs to be and the two questions are independent:

DomainWhat it enumeratesSize
A — control6 reachable (occupancy, read-pointer) states × wr_commit × rd_ack × flush × 4 length classes192
B — classificationevery length 0…64 against every legal mps (8, 16, 32, 64)260

And the bench keeps a complete shadow model of the FIFO — its own pointers, its own occupancy, its own stored lengths — compared every cycle. That is the direct lesson of Chapter 21.1 §12, where a suite that checked only outputs and counters let two state-only mutations survive on 1 and 5 failures.

10.1 Verilog testbench

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
`timescale 1ns/1ps
module tb_pp_v;
  localparam LEN_W = 7;
  reg clk=0, rst_n=0;
  reg flush=0, wr_commit=0, rd_ack=0;
  reg [LEN_W-1:0] mps=8, wr_len=0;
  wire wr_ready, rd_valid, rd_zlp, rd_short, nak_needed;
  wire [LEN_W-1:0] rd_len;
  wire [1:0] used, rd_class;
  wire [31:0] n_written, n_read, n_zlp, n_short, n_nak, n_flush;
  always #5 clk=~clk;

  usb_ep_pingpong #(.LEN_W(LEN_W)) dut (
    .clk(clk), .rst_n(rst_n), .flush(flush), .mps(mps),
    .wr_commit(wr_commit), .wr_len(wr_len), .rd_ack(rd_ack),
    .wr_ready(wr_ready), .rd_valid(rd_valid), .rd_len(rd_len),
    .rd_zlp(rd_zlp), .rd_short(rd_short), .rd_class(rd_class), .used(used),
    .nak_needed(nak_needed), .n_written(n_written), .n_read(n_read),
    .n_zlp(n_zlp), .n_short(n_short), .n_nak(n_nak), .n_flush(n_flush));

  // ---- A complete SHADOW MODEL of the FIFO. Keeping independent state in
  // ---- the bench is what makes every state-only bug visible: chapter 21.1
  // ---- learned that checking outputs and counters alone leaves a mutation
  // ---- that corrupts only a pointer almost undetectable.
  reg [LEN_W-1:0] s_len [0:1];
  reg s_wrp, s_rdp;
  reg [1:0] s_cnt;
  integer s_written, s_read, s_zlp, s_short, s_nak, s_flush;

  integer errors=0, i, j, k, a, b, c, st, lc;
  integer n_ctrl=0, n_class=0;
  integer n_occ [0:2];
  integer n_both=0, n_zlp_stored=0, n_nakhit=0, n_shorthit=0;

  task check(input cond, input [639:0] msg);
    begin if (!cond) begin errors=errors+1;
      if (errors <= 25)
        $display("  FAIL: %0s (flush=%b wr=%b len=%0d rd=%b mps=%0d | used=%0d rdlen=%0d zlp=%b short=%b rdy=%b val=%b || model cnt=%0d rdp=%b wrp=%b, t=%0t)",
                 msg, flush, wr_commit, wr_len, rd_ack, mps, used, rd_len,
                 rd_zlp, rd_short, wr_ready, rd_valid, s_cnt, s_rdp, s_wrp,
                 $time);
    end end
  endtask

  localparam [1:0] PKT_NONE=0, PKT_ZLP=1, PKT_SHORT=2, PKT_FULL=3;

  task check_outputs;
    reg e_wrdy, e_rval, e_zlp, e_short;
    reg [1:0] e_class;
    reg [LEN_W-1:0] e_rlen;
    begin
      e_wrdy  = (s_cnt != 2'd2);
      e_rval  = (s_cnt != 2'd0);
      e_rlen  = s_len[s_rdp];
      e_zlp   = e_rval && (e_rlen == 0);
      e_short = e_rval && (e_rlen < mps);
      e_class = !e_rval ? PKT_NONE : (e_rlen == 0) ? PKT_ZLP
                                   : (e_rlen < mps) ? PKT_SHORT : PKT_FULL;

      check(wr_ready   === e_wrdy,  "wr_ready matches the shadow model");
      check(rd_valid   === e_rval,  "rd_valid matches the shadow model");
      check(used       === s_cnt,   "used matches the shadow occupancy");
      check(nak_needed === !e_wrdy, "nak_needed is exactly !wr_ready");
      if (e_rval) begin
        check(rd_len === e_rlen,
              "rd_len is the length of the OLDEST packet, not the newest");
        check(rd_zlp   === e_zlp,   "rd_zlp matches the shadow model");
        check(rd_short === e_short, "rd_short matches the shadow model");
      end
      check(rd_class === e_class, "rd_class matches the shadow model");
      // rd_class must agree with the three booleans it summarises -- a
      // summary that can disagree with its parts is worse than no summary.
      check((rd_class === PKT_NONE) === !rd_valid,
            "rd_class disagrees with rd_valid");
      check((rd_class === PKT_ZLP) === rd_zlp,
            "rd_class disagrees with rd_zlp");
      check((rd_class === PKT_SHORT || rd_class === PKT_ZLP) === rd_short,
            "rd_class disagrees with rd_short");

      // ---- SAFETY PROPERTIES, independent of the model ----
      // 1. THE property. A zero-length packet is a packet: it occupies a
      //    buffer, and it comes back out as a ZLP.
      if (rd_valid && (rd_len == 0))
        check(rd_zlp,
              "a stored zero-length packet did not read back as a ZLP");
      // 2. A ZLP is always short -- it is the limiting case of a short packet
      //    and must terminate the transfer just the same.
      if (rd_zlp)
        check(rd_short, "a ZLP was not reported as terminating the transfer");
      // 3. A packet of exactly mps bytes is NOT short. Off by one here
      //    truncates every transfer that is a multiple of the packet size.
      if (rd_valid && (rd_len == mps))
        check(!rd_short,
              "a full-size packet was reported short -- the transfer would end early");
      // 4. Nothing is readable from an empty FIFO, and nothing is writable
      //    into a full one.
      if (used == 2'd0) check(!rd_valid, "an empty FIFO offered a packet");
      if (used == 2'd2) check(!wr_ready, "a full FIFO accepted another packet");
      // 5. Occupancy never leaves 0..2.
      check(used <= 2'd2, "occupancy exceeded the two buffers that exist");
      // 6. An empty FIFO reports neither ZLP nor short -- it has no packet
      //    to describe, and inventing a terminator ends a live transfer.
      if (!rd_valid)
        check(!rd_zlp && !rd_short,
              "an empty FIFO described a packet it does not hold");

      if (s_cnt <= 2) n_occ[s_cnt] = n_occ[s_cnt] + 1;
    end
  endtask

  // advance the shadow model by one clock, using the same inputs the DUT saw
  task model_step;
    reg d_wr, d_rd;
    begin
      d_wr = wr_commit && (s_cnt != 2'd2);
      d_rd = rd_ack   && (s_cnt != 2'd0);
      if (flush) begin
        s_wrp = 1'b0; s_rdp = 1'b0; s_cnt = 2'd0;
        s_flush = s_flush + 1;
      end else begin
        if (d_wr) begin
          s_len[s_wrp] = wr_len;
          s_wrp = ~s_wrp;
          s_written = s_written + 1;
          if (wr_len == 0)  begin s_zlp = s_zlp + 1; n_zlp_stored = n_zlp_stored + 1; end
          if (wr_len < mps) begin s_short = s_short + 1; n_shorthit = n_shorthit + 1; end
        end
        if (d_rd) begin s_rdp = ~s_rdp; s_read = s_read + 1; end
        if (d_wr && !d_rd) s_cnt = s_cnt + 2'd1;
        if (!d_wr && d_rd) s_cnt = s_cnt - 2'd1;
        if (d_wr && d_rd)  n_both = n_both + 1;
      end
    end
  endtask

  task step;
    reg was_full;
    begin
      #1;
      check_outputs;
      was_full = (s_cnt == 2'd2);
      if (!flush && wr_commit && was_full) begin
        s_nak = s_nak + 1; n_nakhit = n_nakhit + 1;
      end
      model_step;
      @(posedge clk); #1;
      // The shadow model's state IS the check: every pointer and the
      // occupancy are compared through the outputs they drive, every cycle.
      check(used     === s_cnt,          "occupancy tracked the model");
      check(wr_ready === (s_cnt != 2'd2),"wr_ready tracked the model");
      check(rd_valid === (s_cnt != 2'd0),"rd_valid tracked the model");
      if (s_cnt != 0)
        check(rd_len === s_len[s_rdp],
              "the read pointer tracked the model -- packets come out in order");
      check(n_written === s_written[31:0], "n_written matches the model");
      check(n_read    === s_read[31:0],    "n_read matches the model");
      check(n_zlp     === s_zlp[31:0],     "n_zlp matches the model");
      check(n_short   === s_short[31:0],   "n_short matches the model");
      check(n_nak     === s_nak[31:0],     "n_nak matches the model");
      check(n_flush   === s_flush[31:0],   "n_flush matches the model");
    end
  endtask

  task idle; begin flush=0; wr_commit=0; rd_ack=0; end endtask

  task hard_reset;
    begin
      rst_n=0; idle; wr_len=0; mps=8;
      @(posedge clk); #1; @(posedge clk); #1; rst_n=1; #1;
      s_wrp=0; s_rdp=0; s_cnt=0; s_len[0]=0; s_len[1]=0;
      s_written=0; s_read=0; s_zlp=0; s_short=0; s_nak=0; s_flush=0;
    end
  endtask

  // Force the FIFO to a chosen (occupancy, read-pointer) pair. Reaching
  // rd_ptr=1 needs a full write/read cycle, which is why the two are swept
  // together rather than independently.
  task goto_state(input [1:0] want_cnt, input want_rdp);
    begin
      idle; flush=1; step; idle;
      if (want_rdp) begin
        wr_commit=1; wr_len=4; step; idle;      // write one
        rd_ack=1; step; idle;                   // and read it back
      end
      for (k=0; k<want_cnt; k=k+1) begin
        wr_commit=1; wr_len=(k==0) ? 7'd3 : 7'd5; step; idle;
      end
      #1;
      check(used === want_cnt, "goto_state reached the occupancy");
    end
  endtask

  initial begin
    for (i=0;i<3;i=i+1) n_occ[i]=0;
    hard_reset;
    check(used === 2'd0, "reset leaves the FIFO empty");
    check(!rd_valid, "with nothing to read");
    check(wr_ready, "and room to write");

    // ===== A. EXHAUSTIVE control sweep =====
    // 6 reachable (occupancy, read-pointer) states x wr_commit x rd_ack
    // x flush x 4 length classes (ZLP / 1 / mps-1 / mps) = 192 transitions.
    mps = 8;
    for (st=0; st<6; st=st+1)
     for (a=0;a<2;a=a+1)
      for (b=0;b<2;b=b+1)
       for (c=0;c<2;c=c+1)
        for (lc=0; lc<4; lc=lc+1) begin
          // st counts 0..5 as {occupancy[1:0], read-pointer}
          goto_state(st[2:1], st[0]);
          wr_commit=a[0]; rd_ack=b[0]; flush=c[0];
          case (lc)
            0: wr_len = 7'd0;        // a ZERO-LENGTH PACKET
            1: wr_len = 7'd1;
            2: wr_len = mps - 7'd1;  // short
            default: wr_len = mps;   // full
          endcase
          step;
          n_ctrl = n_ctrl + 1;
          idle;
        end
    $display("  exhaustive control sweep: %0d of 192 transitions verified",
             n_ctrl);

    // ===== B. EXHAUSTIVE length/classification sweep =====
    // Every length 0..64 against every max packet size the spec allows for a
    // full-speed or high-speed endpoint. 65 x 4 = 260 points, which pins the
    // ZLP and short-packet classification over its entire domain.
    for (j=0; j<4; j=j+1) begin
      case (j)
        0: mps = 7'd8;
        1: mps = 7'd16;
        2: mps = 7'd32;
        default: mps = 7'd64;
      endcase
      for (i=0; i<=64; i=i+1) begin
        idle; flush=1; step; idle;
        wr_commit=1; wr_len=i[LEN_W-1:0]; step; idle;
        #1;
        check(rd_valid, "the packet is readable whatever its length");
        check(rd_len === i[LEN_W-1:0], "and its length came back exactly");
        check(rd_zlp === (i == 0),
              "ZLP is reported exactly when the length is zero");
        check(rd_short === (i < mps),
              "short is reported exactly when the length is below mps");
        check(rd_class === ((i == 0) ? PKT_ZLP
                          : (i < mps) ? PKT_SHORT : PKT_FULL),
              "rd_class classifies the length correctly");
        rd_ack=1; step; idle;
        n_class = n_class + 1;
      end
    end
    $display("  exhaustive length/classification sweep: %0d of 260 points verified",
             n_class);

    // ===== C. directed: the two stories this block exists for =====
    hard_reset; mps = 8;

    // 1. THE ZLP story. A 16-byte transfer on an 8-byte endpoint is two full
    //    packets -- and the host cannot tell that it is finished until a
    //    THIRD, zero-length packet arrives.
    wr_commit=1; wr_len=8; step; idle;       // full packet
    check(used === 2'd1, "the first full packet is buffered");
    #1; check(!rd_short, "a full packet does NOT terminate the transfer");
    rd_ack=1; step; idle;
    wr_commit=1; wr_len=8; step; idle;       // second full packet
    rd_ack=1; step; idle;
    wr_commit=1; wr_len=0; step; idle;       // the ZLP
    check(used === 2'd1,
          "the ZERO-LENGTH packet occupied a buffer -- it is a real packet");
    #1;
    check(rd_valid, "and it is readable");
    check(rd_zlp, "and reads back as a ZLP");
    check(rd_short, "which terminates the transfer");
    check(rd_len === 7'd0, "carrying no bytes");
    rd_ack=1; step; idle;
    check(n_zlp === 32'd1, "one zero-length packet was counted as a packet");

    // 2. THE PING-PONG story. A write and a read in the SAME cycle must both
    //    succeed and leave occupancy unchanged. That is the entire point of
    //    the second buffer.
    hard_reset; mps=8;
    wr_commit=1; wr_len=3; step; idle;
    wr_commit=1; wr_len=4; step; idle;
    check(used === 2'd2, "both buffers are full");
    check(!wr_ready, "so a further packet would have to be NAKed");
    wr_commit=1; wr_len=5; rd_ack=1; #1;
    check(!wr_ready, "the write cannot be accepted this cycle -- it is full");
    check(rd_valid, "but the read can proceed");
    step; idle;
    check(used === 2'd1, "so occupancy fell by one");
    check(n_nak === 32'd1, "and the refused packet was counted as a NAK");
    // now with room, both happen at once
    wr_commit=1; wr_len=6; rd_ack=1; step; idle;
    check(used === 2'd1,
          "a simultaneous write and read left occupancy UNCHANGED -- full rate");
    #1; check(rd_len === 7'd6,
              "and the surviving packet is the one just written");

    // 3. Ordering. Packets come out oldest-first, not newest-first.
    hard_reset; mps=8;
    wr_commit=1; wr_len=11; step; idle;
    wr_commit=1; wr_len=22; step; idle;
    #1; check(rd_len === 7'd11, "the OLDEST packet is offered first");
    rd_ack=1; step; idle;
    #1; check(rd_len === 7'd22, "then the newer one");

    // 4. Flush clears BOTH pointers, not just the count.
    hard_reset; mps=8;
    wr_commit=1; wr_len=11; step; idle;
    rd_ack=1; step; idle;                 // read pointer now at buffer 1
    wr_commit=1; wr_len=33; step; idle;
    flush=1; step; idle;
    check(used === 2'd0, "the flush emptied the FIFO");
    wr_commit=1; wr_len=44; step; idle;
    #1;
    check(rd_len === 7'd44,
          "and the next packet read is the one just written -- both pointers reset");

    // ===== D. randomised =====
    hard_reset;
    for (i=0;i<40000;i=i+1) begin
      case ({$random}%4)
        0: mps = 7'd8;
        1: mps = 7'd16;
        2: mps = 7'd32;
        default: mps = 7'd64;
      endcase
      wr_commit = ({$random}%3)!=0;
      rd_ack    = ({$random}%3)!=0;
      flush     = ({$random}%64)==0;
      // bias hard toward the boundary lengths: 0 (ZLP), mps-1, mps
      case ({$random}%4)
        0: wr_len = 7'd0;
        1: wr_len = mps - 7'd1;
        2: wr_len = mps;
        default: wr_len = {$random}%65;
      endcase
      step;
    end

    for (i=0;i<3;i=i+1)
      check(n_occ[i] > 0, "every occupancy level was reached");
    check(n_both > 2000, "simultaneous write+read happened often");
    check(n_zlp_stored > 2000, "zero-length packets were stored often");
    check(n_nakhit > 500, "the FIFO was found full often");

    $display("");
    $display("  REACH: control=%0d class=%0d | occupancy: empty=%0d one=%0d full=%0d",
             n_ctrl, n_class, n_occ[0], n_occ[1], n_occ[2]);
    $display("  CASES: simultaneous-rw=%0d zlps-stored=%0d short-pkts=%0d naks=%0d",
             n_both, n_zlp_stored, n_shorthit, n_nakhit);
    $display("  COUNTERS: written=%0d read=%0d zlp=%0d short=%0d nak=%0d flush=%0d",
             n_written, n_read, n_zlp, n_short, n_nak, n_flush);
    $display("  [Verilog] usb_ep_pingpong: %0d errors", errors);
    $display("  [Verilog] %0s", errors==0 ? "PASS" : "FAIL");
    $display("");
    $finish;
  end
endmodule

10.2 SystemVerilog testbench

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
`timescale 1ns/1ps
module tb_pp_sv;
  import usb_pingpong_pkg::*;

  localparam LEN_W = 7;
  logic clk=0, rst_n=0;
  logic flush=0, wr_commit=0, rd_ack=0;
  logic [LEN_W-1:0] mps=8, wr_len=0;
  logic wr_ready, rd_valid, rd_zlp, rd_short, nak_needed;
  logic [LEN_W-1:0] rd_len;
  pkt_class_e rd_class;
  logic [1:0] used;
  logic [31:0] n_written, n_read, n_zlp, n_short, n_nak, n_flush;

  // Icarus seeds $random and $urandom identically, so an unseeded run would
  // replay the Verilog suite's stimulus exactly. See chapter 20.5 section 9.2.
  int urandom_seed = 21202;
  always #5 clk=~clk;

  usb_ep_pingpong #(.LEN_W(LEN_W)) dut (
    .clk, .rst_n, .flush, .mps, .wr_commit, .wr_len, .rd_ack,
    .wr_ready, .rd_valid, .rd_len, .rd_zlp, .rd_short, .rd_class, .used,
    .nak_needed, .n_written, .n_read, .n_zlp, .n_short, .n_nak, .n_flush);

  // ---- A complete SHADOW MODEL of the FIFO. Keeping independent state in
  // ---- the bench is what makes every state-only bug visible: chapter 21.1
  // ---- learned that checking outputs and counters alone leaves a mutation
  // ---- that corrupts only a pointer almost undetectable.
  logic [LEN_W-1:0] s_len [0:1];
  logic s_wrp, s_rdp;
  logic [1:0] s_cnt;
  int s_written, s_read, s_zlp, s_short, s_nak, s_flush;

  int errors=0, i, j, k, a, b, c, st, lc;
  int n_ctrl=0, n_class=0;
  int n_occ [3];
  int n_both=0, n_zlp_stored=0, n_nakhit=0, n_shorthit=0;

  task automatic check(input bit cond, input string msg);
    // Icarus will not call .name() on a net, so the enum output is copied
    // into a variable of the same type before being printed.
    pkt_class_e cls_v;
    if (!cond) begin
      errors++;
      cls_v = rd_class;
      if (errors <= 25)
        $display("  FAIL: %0s (flush=%b wr=%b len=%0d rd=%b mps=%0d | used=%0d rdlen=%0d class=%s rdy=%b val=%b || model cnt=%0d rdp=%b wrp=%b, t=%0t)",
                 msg, flush, wr_commit, wr_len, rd_ack, mps, used, rd_len,
                 cls_v.name(), wr_ready, rd_valid, s_cnt, s_rdp, s_wrp, $time);
    end
  endtask

  task automatic check_outputs;
    bit e_wrdy, e_rval, e_zlp, e_short;
    pkt_class_e e_class;
    logic [LEN_W-1:0] e_rlen;
    begin
      e_wrdy  = (s_cnt != 2'd2);
      e_rval  = (s_cnt != 2'd0);
      e_rlen  = s_len[s_rdp];
      e_zlp   = e_rval && (e_rlen == 0);
      e_short = e_rval && (e_rlen < mps);
      // Written as an if-chain rather than the design's ternary: a different
      // route to the same classification, and Icarus will not take an
      // enum-valued ternary without an explicit cast in any case.
      if      (!e_rval)          e_class = PKT_NONE;
      else if (e_rlen == '0)     e_class = PKT_ZLP;
      else if (e_rlen < mps)     e_class = PKT_SHORT;
      else                       e_class = PKT_FULL;

      check(wr_ready   === e_wrdy,  "wr_ready matches the shadow model");
      check(rd_valid   === e_rval,  "rd_valid matches the shadow model");
      check(used       === s_cnt,   "used matches the shadow occupancy");
      check(nak_needed === !e_wrdy, "nak_needed is exactly !wr_ready");
      if (e_rval) begin
        check(rd_len === e_rlen,
              "rd_len is the length of the OLDEST packet, not the newest");
        check(rd_zlp   === e_zlp,   "rd_zlp matches the shadow model");
        check(rd_short === e_short, "rd_short matches the shadow model");
      end
      check(rd_class === e_class, "rd_class matches the shadow model");
      // rd_class must agree with the three booleans it summarises -- a
      // summary that can disagree with its parts is worse than no summary.
      check((rd_class === PKT_NONE) === !rd_valid,
            "rd_class disagrees with rd_valid");
      check((rd_class === PKT_ZLP) === rd_zlp,
            "rd_class disagrees with rd_zlp");
      check((rd_class === PKT_SHORT || rd_class === PKT_ZLP) === rd_short,
            "rd_class disagrees with rd_short");

      // ---- SAFETY PROPERTIES, independent of the model ----
      // 1. THE property. A zero-length packet is a packet: it occupies a
      //    buffer, and it comes back out as a ZLP.
      if (rd_valid && (rd_len == 0))
        check(rd_zlp,
              "a stored zero-length packet did not read back as a ZLP");
      // 2. A ZLP is always short -- it is the limiting case of a short packet
      //    and must terminate the transfer just the same.
      if (rd_zlp)
        check(rd_short, "a ZLP was not reported as terminating the transfer");
      // 3. A packet of exactly mps bytes is NOT short. Off by one here
      //    truncates every transfer that is a multiple of the packet size.
      if (rd_valid && (rd_len == mps))
        check(!rd_short,
              "a full-size packet was reported short -- the transfer would end early");
      // 4. Nothing is readable from an empty FIFO, and nothing is writable
      //    into a full one.
      if (used == 2'd0) check(!rd_valid, "an empty FIFO offered a packet");
      if (used == 2'd2) check(!wr_ready, "a full FIFO accepted another packet");
      // 5. Occupancy never leaves 0..2.
      check(used <= 2'd2, "occupancy exceeded the two buffers that exist");
      // 6. An empty FIFO reports neither ZLP nor short -- it has no packet
      //    to describe, and inventing a terminator ends a live transfer.
      if (!rd_valid)
        check(!rd_zlp && !rd_short,
              "an empty FIFO described a packet it does not hold");

      n_occ[int'(s_cnt)] = n_occ[int'(s_cnt)] + 1;
    end
  endtask

  // advance the shadow model by one clock, using the same inputs the DUT saw
  task automatic model_step;
    bit d_wr, d_rd;
    begin
      d_wr = wr_commit && (s_cnt != 2'd2);
      d_rd = rd_ack   && (s_cnt != 2'd0);
      if (flush) begin
        s_wrp = 1'b0; s_rdp = 1'b0; s_cnt = 2'd0;
        s_flush = s_flush + 1;
      end else begin
        if (d_wr) begin
          s_len[s_wrp] = wr_len;
          s_wrp = ~s_wrp;
          s_written = s_written + 1;
          if (wr_len == 0)  begin s_zlp = s_zlp + 1; n_zlp_stored = n_zlp_stored + 1; end
          if (wr_len < mps) begin s_short = s_short + 1; n_shorthit = n_shorthit + 1; end
        end
        if (d_rd) begin s_rdp = ~s_rdp; s_read = s_read + 1; end
        if (d_wr && !d_rd) s_cnt = s_cnt + 2'd1;
        if (!d_wr && d_rd) s_cnt = s_cnt - 2'd1;
        if (d_wr && d_rd)  n_both = n_both + 1;
      end
    end
  endtask

  task automatic step;
    bit was_full;
    begin
      #1;
      check_outputs;
      was_full = (s_cnt == 2'd2);
      if (!flush && wr_commit && was_full) begin
        s_nak = s_nak + 1; n_nakhit = n_nakhit + 1;
      end
      model_step;
      @(posedge clk); #1;
      // The shadow model's state IS the check: every pointer and the
      // occupancy are compared through the outputs they drive, every cycle.
      check(used     === s_cnt,          "occupancy tracked the model");
      check(wr_ready === (s_cnt != 2'd2),"wr_ready tracked the model");
      check(rd_valid === (s_cnt != 2'd0),"rd_valid tracked the model");
      if (s_cnt != 0)
        check(rd_len === s_len[s_rdp],
              "the read pointer tracked the model -- packets come out in order");
      check(n_written === 32'(s_written), "n_written matches the model");
      check(n_read    === 32'(s_read),    "n_read matches the model");
      check(n_zlp     === 32'(s_zlp),     "n_zlp matches the model");
      check(n_short   === 32'(s_short),   "n_short matches the model");
      check(n_nak     === 32'(s_nak),     "n_nak matches the model");
      check(n_flush   === 32'(s_flush),   "n_flush matches the model");
    end
  endtask

  task automatic idle; begin flush=0; wr_commit=0; rd_ack=0; end endtask

  task automatic hard_reset;
    begin
      rst_n=0; idle; wr_len=0; mps=8;
      @(posedge clk); #1; @(posedge clk); #1; rst_n=1; #1;
      s_wrp=0; s_rdp=0; s_cnt=0; s_len[0]=0; s_len[1]=0;
      s_written=0; s_read=0; s_zlp=0; s_short=0; s_nak=0; s_flush=0;
    end
  endtask

  // Force the FIFO to a chosen (occupancy, read-pointer) pair. Reaching
  // rd_ptr=1 needs a full write/read cycle, which is why the two are swept
  // together rather than independently.
  task automatic goto_state(input logic [1:0] want_cnt, input bit want_rdp);
    begin
      idle; flush=1; step; idle;
      if (want_rdp) begin
        wr_commit=1; wr_len=4; step; idle;      // write one
        rd_ack=1; step; idle;                   // and read it back
      end
      for (k=0; k<int'(want_cnt); k++) begin
        wr_commit=1; wr_len=(k==0) ? 7'd3 : 7'd5; step; idle;
      end
      #1;
      check(used === want_cnt, "goto_state reached the occupancy");
    end
  endtask

  initial begin
    void'($urandom(urandom_seed));
    foreach (n_occ[i]) n_occ[i]=0;
    hard_reset;
    check(used === 2'd0, "reset leaves the FIFO empty");
    check(!rd_valid, "with nothing to read");
    check(wr_ready, "and room to write");

    // ===== A. EXHAUSTIVE control sweep =====
    // 6 reachable (occupancy, read-pointer) states x wr_commit x rd_ack
    // x flush x 4 length classes (ZLP / 1 / mps-1 / mps) = 192 transitions.
    mps = 8;
    for (st=0; st<6; st=st+1)
     for (a=0;a<2;a=a+1)
      for (b=0;b<2;b=b+1)
       for (c=0;c<2;c=c+1)
        for (lc=0; lc<4; lc=lc+1) begin
          // st counts 0..5 as {occupancy[1:0], read-pointer}
          goto_state(st[2:1], st[0]);
          wr_commit=a[0]; rd_ack=b[0]; flush=c[0];
          case (lc)
            0: wr_len = 7'd0;        // a ZERO-LENGTH PACKET
            1: wr_len = 7'd1;
            2: wr_len = mps - 7'd1;  // short
            default: wr_len = mps;   // full
          endcase
          step;
          n_ctrl = n_ctrl + 1;
          idle;
        end
    $display("  exhaustive control sweep: %0d of 192 transitions verified",
             n_ctrl);

    // ===== B. EXHAUSTIVE length/classification sweep =====
    // Every length 0..64 against every max packet size the spec allows for a
    // full-speed or high-speed endpoint. 65 x 4 = 260 points, which pins the
    // ZLP and short-packet classification over its entire domain.
    for (j=0; j<4; j=j+1) begin
      case (j)
        0: mps = 7'd8;
        1: mps = 7'd16;
        2: mps = 7'd32;
        default: mps = 7'd64;
      endcase
      for (i=0; i<=64; i=i+1) begin
        idle; flush=1; step; idle;
        wr_commit=1; wr_len=i[LEN_W-1:0]; step; idle;
        #1;
        check(rd_valid, "the packet is readable whatever its length");
        check(rd_len === i[LEN_W-1:0], "and its length came back exactly");
        check(rd_zlp === (i == 0),
              "ZLP is reported exactly when the length is zero");
        check(rd_short === (i < mps),
              "short is reported exactly when the length is below mps");
        if      (i == 0)   check(rd_class === PKT_ZLP,
                                 "rd_class classifies a ZLP correctly");
        else if (i < mps)  check(rd_class === PKT_SHORT,
                                 "rd_class classifies a short packet correctly");
        else               check(rd_class === PKT_FULL,
                                 "rd_class classifies a full packet correctly");
        rd_ack=1; step; idle;
        n_class = n_class + 1;
      end
    end
    $display("  exhaustive length/classification sweep: %0d of 260 points verified",
             n_class);

    // ===== C. directed: the two stories this block exists for =====
    hard_reset; mps = 8;

    // 1. THE ZLP story. A 16-byte transfer on an 8-byte endpoint is two full
    //    packets -- and the host cannot tell that it is finished until a
    //    THIRD, zero-length packet arrives.
    wr_commit=1; wr_len=8; step; idle;       // full packet
    check(used === 2'd1, "the first full packet is buffered");
    #1; check(!rd_short, "a full packet does NOT terminate the transfer");
    rd_ack=1; step; idle;
    wr_commit=1; wr_len=8; step; idle;       // second full packet
    rd_ack=1; step; idle;
    wr_commit=1; wr_len=0; step; idle;       // the ZLP
    check(used === 2'd1,
          "the ZERO-LENGTH packet occupied a buffer -- it is a real packet");
    #1;
    check(rd_valid, "and it is readable");
    check(rd_zlp, "and reads back as a ZLP");
    check(rd_short, "which terminates the transfer");
    check(rd_len === 7'd0, "carrying no bytes");
    rd_ack=1; step; idle;
    check(n_zlp === 32'd1, "one zero-length packet was counted as a packet");

    // 2. THE PING-PONG story. A write and a read in the SAME cycle must both
    //    succeed and leave occupancy unchanged. That is the entire point of
    //    the second buffer.
    hard_reset; mps=8;
    wr_commit=1; wr_len=3; step; idle;
    wr_commit=1; wr_len=4; step; idle;
    check(used === 2'd2, "both buffers are full");
    check(!wr_ready, "so a further packet would have to be NAKed");
    wr_commit=1; wr_len=5; rd_ack=1; #1;
    check(!wr_ready, "the write cannot be accepted this cycle -- it is full");
    check(rd_valid, "but the read can proceed");
    step; idle;
    check(used === 2'd1, "so occupancy fell by one");
    check(n_nak === 32'd1, "and the refused packet was counted as a NAK");
    // now with room, both happen at once
    wr_commit=1; wr_len=6; rd_ack=1; step; idle;
    check(used === 2'd1,
          "a simultaneous write and read left occupancy UNCHANGED -- full rate");
    #1; check(rd_len === 7'd6,
              "and the surviving packet is the one just written");

    // 3. Ordering. Packets come out oldest-first, not newest-first.
    hard_reset; mps=8;
    wr_commit=1; wr_len=11; step; idle;
    wr_commit=1; wr_len=22; step; idle;
    #1; check(rd_len === 7'd11, "the OLDEST packet is offered first");
    rd_ack=1; step; idle;
    #1; check(rd_len === 7'd22, "then the newer one");

    // 4. Flush clears BOTH pointers, not just the count.
    hard_reset; mps=8;
    wr_commit=1; wr_len=11; step; idle;
    rd_ack=1; step; idle;                 // read pointer now at buffer 1
    wr_commit=1; wr_len=33; step; idle;
    flush=1; step; idle;
    check(used === 2'd0, "the flush emptied the FIFO");
    wr_commit=1; wr_len=44; step; idle;
    #1;
    check(rd_len === 7'd44,
          "and the next packet read is the one just written -- both pointers reset");

    // ===== D. randomised =====
    hard_reset;
    for (i=0;i<40000;i=i+1) begin
      case ($urandom%4)
        0: mps = 7'd8;
        1: mps = 7'd16;
        2: mps = 7'd32;
        default: mps = 7'd64;
      endcase
      wr_commit = ($urandom%3)!=0;
      rd_ack    = ($urandom%3)!=0;
      flush     = ($urandom%64)==0;
      // bias hard toward the boundary lengths: 0 (ZLP), mps-1, mps
      case ($urandom%4)
        0: wr_len = 7'd0;
        1: wr_len = mps - 7'd1;
        2: wr_len = mps;
        default: wr_len = $urandom%65;
      endcase
      step;
    end

    foreach (n_occ[i]) check(n_occ[i] > 0, "every occupancy level was reached");
    check(n_both > 2000, "simultaneous write+read happened often");
    check(n_zlp_stored > 2000, "zero-length packets were stored often");
    check(n_nakhit > 500, "the FIFO was found full often");

    $display("");
    $display("  REACH: control=%0d class=%0d | occupancy: empty=%0d one=%0d full=%0d",
             n_ctrl, n_class, n_occ[0], n_occ[1], n_occ[2]);
    $display("  CASES: simultaneous-rw=%0d zlps-stored=%0d short-pkts=%0d naks=%0d",
             n_both, n_zlp_stored, n_shorthit, n_nakhit);
    $display("  COUNTERS: written=%0d read=%0d zlp=%0d short=%0d nak=%0d flush=%0d",
             n_written, n_read, n_zlp, n_short, n_nak, n_flush);
    $display("  [SystemVerilog] usb_ep_pingpong: %0d errors", errors);
    $display("  [SystemVerilog] %0s", errors==0 ? "PASS" : "FAIL");
    $display("");
    $finish;
  end
endmodule

10.3 VHDL testbench

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.math_real.all;
use work.usb_pingpong_pkg.all;

entity tb_pp_vhdl is
end entity;

architecture sim of tb_pp_vhdl is
  constant LEN_W : natural := 7;

  signal clk   : std_logic := '0';
  signal rst_n : std_logic := '0';
  signal flush, wr_commit, rd_ack : std_logic := '0';
  signal mps    : std_logic_vector(LEN_W-1 downto 0)
                := std_logic_vector(to_unsigned(8, LEN_W));
  signal wr_len : std_logic_vector(LEN_W-1 downto 0) := (others => '0');

  signal wr_ready, rd_valid, rd_zlp, rd_short, nak_needed : std_logic;
  signal rd_len   : std_logic_vector(LEN_W-1 downto 0);
  signal rd_class : std_logic_vector(1 downto 0);
  signal used     : std_logic_vector(1 downto 0);
  signal n_written, n_read, n_zlp, n_short, n_nak, n_flush
       : std_logic_vector(31 downto 0);

  signal running : boolean := true;

  type cnt3_t is array (0 to 2) of integer;
  type len2_t  is array (0 to 1) of integer;
begin
  clk <= not clk after 5 ns when running else '0';

  dut : entity work.usb_ep_pingpong
    generic map (LEN_W => LEN_W)
    port map (clk => clk, rst_n => rst_n, flush => flush, mps => mps,
              wr_commit => wr_commit, wr_len => wr_len, rd_ack => rd_ack,
              wr_ready => wr_ready, rd_valid => rd_valid, rd_len => rd_len,
              rd_zlp => rd_zlp, rd_short => rd_short, rd_class => rd_class,
              used => used, nak_needed => nak_needed,
              n_written => n_written, n_read => n_read, n_zlp => n_zlp,
              n_short => n_short, n_nak => n_nak, n_flush => n_flush);

  stim : process
    variable seed1 : positive := 4231;
    variable seed2 : positive := 8017;
    variable r1    : real;

    -- VHDL-2008 requires a shared variable to have a protected type, so the
    -- bookkeeping lives inside the single stimulus process instead.
    variable errors : integer := 0;

    -- A complete SHADOW MODEL of the FIFO. Keeping independent state in the
    -- bench is what makes every state-only bug visible: chapter 21.1 learned
    -- that checking outputs and counters alone leaves a mutation that
    -- corrupts only a pointer almost undetectable.
    variable s_len : len2_t := (others => 0);
    variable s_wrp, s_rdp : integer := 0;
    variable s_cnt : integer := 0;
    variable s_written, s_read, s_zlp, s_short, s_nak, s_flush : integer := 0;

    variable n_ctrl, n_class : integer := 0;
    variable n_occ : cnt3_t := (others => 0);
    variable n_both, n_zlp_stored, n_nakhit, n_shorthit : integer := 0;

    procedure check(cond : boolean; msg : string) is
    begin
      if not cond then
        errors := errors + 1;
        if errors <= 25 then
          report "  FAIL: " & msg
               & " (flush=" & std_logic'image(flush)(2)
               & " wr=" & std_logic'image(wr_commit)(2)
               & " len=" & integer'image(to_integer(unsigned(wr_len)))
               & " rd=" & std_logic'image(rd_ack)(2)
               & " mps=" & integer'image(to_integer(unsigned(mps)))
               & " | used=" & integer'image(to_integer(unsigned(used)))
               & " rdlen=" & integer'image(to_integer(unsigned(rd_len)))
               & " class=" & integer'image(to_integer(unsigned(rd_class)))
               & " rdy=" & std_logic'image(wr_ready)(2)
               & " val=" & std_logic'image(rd_valid)(2)
               & " || model cnt=" & integer'image(s_cnt)
               & " rdp=" & integer'image(s_rdp)
               & " wrp=" & integer'image(s_wrp)
               & ")" severity note;
        end if;
      end if;
    end procedure;

    procedure rnd(variable v : out integer; m : integer) is
    begin
      uniform(seed1, seed2, r1);
      v := integer(floor(r1 * real(m)));
    end procedure;

    procedure check_outputs is
      variable e_wrdy, e_rval, e_zlp, e_short : boolean;
      variable e_class : pkt_class_t;
      variable e_rlen  : integer;
      variable mps_i   : integer;
    begin
      mps_i   := to_integer(unsigned(mps));
      e_wrdy  := s_cnt /= 2;
      e_rval  := s_cnt /= 0;
      e_rlen  := s_len(s_rdp);
      e_zlp   := e_rval and e_rlen = 0;
      e_short := e_rval and e_rlen < mps_i;

      -- Written as an if-chain rather than the design's process: a different
      -- route to the same classification.
      if not e_rval then          e_class := PKT_NONE;
      elsif e_rlen = 0 then       e_class := PKT_ZLP;
      elsif e_rlen < mps_i then   e_class := PKT_SHORT;
      else                        e_class := PKT_FULL;
      end if;

      check((wr_ready = '1') = e_wrdy, "wr_ready matches the shadow model");
      check((rd_valid = '1') = e_rval, "rd_valid matches the shadow model");
      check(to_integer(unsigned(used)) = s_cnt,
            "used matches the shadow occupancy");
      check((nak_needed = '1') = not e_wrdy,
            "nak_needed is exactly not wr_ready");
      if e_rval then
        check(to_integer(unsigned(rd_len)) = e_rlen,
              "rd_len is the length of the OLDEST packet, not the newest");
        check((rd_zlp = '1') = e_zlp,     "rd_zlp matches the shadow model");
        check((rd_short = '1') = e_short, "rd_short matches the shadow model");
      end if;
      check(rd_class = class_code(e_class), "rd_class matches the shadow model");
      -- rd_class must agree with the three booleans it summarises -- a
      -- summary that can disagree with its parts is worse than no summary.
      check((rd_class = class_code(PKT_NONE)) = (rd_valid = '0'),
            "rd_class disagrees with rd_valid");
      check((rd_class = class_code(PKT_ZLP)) = (rd_zlp = '1'),
            "rd_class disagrees with rd_zlp");
      check((rd_class = class_code(PKT_SHORT) or rd_class = class_code(PKT_ZLP))
            = (rd_short = '1'), "rd_class disagrees with rd_short");

      -- ---- SAFETY PROPERTIES, independent of the model ----
      -- 1. THE property. A zero-length packet is a packet: it occupies a
      --    buffer, and it comes back out as a ZLP.
      if rd_valid = '1' and to_integer(unsigned(rd_len)) = 0 then
        check(rd_zlp = '1',
              "a stored zero-length packet did not read back as a ZLP");
      end if;
      -- 2. A ZLP is always short -- the limiting case of a short packet, and
      --    it must terminate the transfer just the same.
      if rd_zlp = '1' then
        check(rd_short = '1',
              "a ZLP was not reported as terminating the transfer");
      end if;
      -- 3. A packet of exactly mps bytes is NOT short.
      if rd_valid = '1' and to_integer(unsigned(rd_len)) = mps_i then
        check(rd_short = '0',
              "a full-size packet was reported short -- the transfer would end early");
      end if;
      -- 4. Nothing readable from an empty FIFO, nothing writable into a full.
      if to_integer(unsigned(used)) = 0 then
        check(rd_valid = '0', "an empty FIFO offered a packet");
      end if;
      if to_integer(unsigned(used)) = 2 then
        check(wr_ready = '0', "a full FIFO accepted another packet");
      end if;
      -- 5. Occupancy never leaves 0..2.
      check(to_integer(unsigned(used)) <= 2,
            "occupancy exceeded the two buffers that exist");
      -- 6. An empty FIFO reports neither ZLP nor short.
      if rd_valid = '0' then
        check(rd_zlp = '0' and rd_short = '0',
              "an empty FIFO described a packet it does not hold");
      end if;

      n_occ(s_cnt) := n_occ(s_cnt) + 1;
    end procedure;

    -- advance the shadow model by one clock, using the inputs the DUT saw
    procedure model_step is
      variable d_wr, d_rd : boolean;
      variable mps_i : integer;
    begin
      mps_i := to_integer(unsigned(mps));
      d_wr  := wr_commit = '1' and s_cnt /= 2;
      d_rd  := rd_ack = '1'    and s_cnt /= 0;
      if flush = '1' then
        s_wrp := 0; s_rdp := 0; s_cnt := 0;
        s_flush := s_flush + 1;
      else
        if d_wr then
          s_len(s_wrp) := to_integer(unsigned(wr_len));
          s_wrp        := 1 - s_wrp;
          s_written    := s_written + 1;
          if to_integer(unsigned(wr_len)) = 0 then
            s_zlp := s_zlp + 1; n_zlp_stored := n_zlp_stored + 1;
          end if;
          if to_integer(unsigned(wr_len)) < mps_i then
            s_short := s_short + 1; n_shorthit := n_shorthit + 1;
          end if;
        end if;
        if d_rd then
          s_rdp  := 1 - s_rdp;
          s_read := s_read + 1;
        end if;
        if d_wr and not d_rd then s_cnt := s_cnt + 1; end if;
        if not d_wr and d_rd then s_cnt := s_cnt - 1; end if;
        if d_wr and d_rd then n_both := n_both + 1; end if;
      end if;
    end procedure;

    procedure step is
      variable was_full : boolean;
    begin
      wait for 1 ns;
      check_outputs;
      was_full := s_cnt = 2;
      if flush = '0' and wr_commit = '1' and was_full then
        s_nak := s_nak + 1; n_nakhit := n_nakhit + 1;
      end if;
      model_step;
      wait until rising_edge(clk);
      wait for 1 ns;
      -- The shadow model's state IS the check: every pointer and the
      -- occupancy are compared through the outputs they drive, every cycle.
      check(to_integer(unsigned(used)) = s_cnt, "occupancy tracked the model");
      check((wr_ready = '1') = (s_cnt /= 2), "wr_ready tracked the model");
      check((rd_valid = '1') = (s_cnt /= 0), "rd_valid tracked the model");
      if s_cnt /= 0 then
        check(to_integer(unsigned(rd_len)) = s_len(s_rdp),
              "the read pointer tracked the model -- packets come out in order");
      end if;
      check(n_written = std_logic_vector(to_unsigned(s_written, 32)),
            "n_written matches the model");
      check(n_read = std_logic_vector(to_unsigned(s_read, 32)),
            "n_read matches the model");
      check(n_zlp = std_logic_vector(to_unsigned(s_zlp, 32)),
            "n_zlp matches the model");
      check(n_short = std_logic_vector(to_unsigned(s_short, 32)),
            "n_short matches the model");
      check(n_nak = std_logic_vector(to_unsigned(s_nak, 32)),
            "n_nak matches the model");
      check(n_flush = std_logic_vector(to_unsigned(s_flush, 32)),
            "n_flush matches the model");
    end procedure;

    procedure idle is
    begin
      flush <= '0'; wr_commit <= '0'; rd_ack <= '0';
    end procedure;

    procedure setlen(n : integer) is
    begin
      wr_len <= std_logic_vector(to_unsigned(n, LEN_W));
    end procedure;

    procedure setmps(n : integer) is
    begin
      mps <= std_logic_vector(to_unsigned(n, LEN_W));
    end procedure;

    procedure hard_reset is
    begin
      rst_n <= '0'; idle; setlen(0); setmps(8);
      wait until rising_edge(clk); wait for 1 ns;
      wait until rising_edge(clk); wait for 1 ns;
      rst_n <= '1'; wait for 1 ns;
      s_wrp := 0; s_rdp := 0; s_cnt := 0; s_len := (others => 0);
      s_written := 0; s_read := 0; s_zlp := 0; s_short := 0;
      s_nak := 0; s_flush := 0;
    end procedure;

    -- Force the FIFO to a chosen (occupancy, read-pointer) pair. Reaching
    -- rd_ptr = 1 needs a full write/read cycle, which is why the two are
    -- swept together rather than independently.
    procedure goto_state(want_cnt : integer; want_rdp : integer) is
    begin
      idle; flush <= '1'; step; idle;
      if want_rdp = 1 then
        wr_commit <= '1'; setlen(4); step; idle;   -- write one
        rd_ack <= '1'; step; idle;                 -- and read it back
      end if;
      for k in 0 to want_cnt - 1 loop
        wr_commit <= '1';
        if k = 0 then setlen(3); else setlen(5); end if;
        step; idle;
      end loop;
      wait for 1 ns;
      check(to_integer(unsigned(used)) = want_cnt,
            "goto_state reached the occupancy");
    end procedure;

    variable iv, mps_i : integer;
  begin
    hard_reset;
    check(to_integer(unsigned(used)) = 0, "reset leaves the FIFO empty");
    check(rd_valid = '0', "with nothing to read");
    check(wr_ready = '1', "and room to write");

    -- ===== A. EXHAUSTIVE control sweep =====
    -- 6 reachable (occupancy, read-pointer) states x wr_commit x rd_ack
    -- x flush x 4 length classes (ZLP / 1 / mps-1 / mps) = 192 transitions.
    setmps(8);
    for st in 0 to 5 loop
      for a in 0 to 1 loop
        for b in 0 to 1 loop
          for c in 0 to 1 loop
            for lc in 0 to 3 loop
              -- st counts 0..5 as (occupancy, read-pointer)
              goto_state(st / 2, st mod 2);
              if a = 1 then wr_commit <= '1'; else wr_commit <= '0'; end if;
              if b = 1 then rd_ack <= '1';    else rd_ack <= '0';    end if;
              if c = 1 then flush <= '1';     else flush <= '0';     end if;
              case lc is
                when 0      => setlen(0);   -- a ZERO-LENGTH PACKET
                when 1      => setlen(1);
                when 2      => setlen(7);   -- mps - 1: short
                when others => setlen(8);   -- mps: full
              end case;
              step;
              n_ctrl := n_ctrl + 1;
              idle;
            end loop;
          end loop;
        end loop;
      end loop;
    end loop;
    report "  exhaustive control sweep: " & integer'image(n_ctrl)
         & " of 192 transitions verified" severity note;

    -- ===== B. EXHAUSTIVE length/classification sweep =====
    -- Every length 0..64 against every max packet size the spec allows for a
    -- full-speed or high-speed endpoint. 65 x 4 = 260 points, which pins the
    -- ZLP and short-packet classification over its entire domain.
    for j in 0 to 3 loop
      case j is
        when 0      => mps_i := 8;
        when 1      => mps_i := 16;
        when 2      => mps_i := 32;
        when others => mps_i := 64;
      end case;
      setmps(mps_i);
      for i in 0 to 64 loop
        idle; flush <= '1'; step; idle;
        wr_commit <= '1'; setlen(i); step; idle;
        wait for 1 ns;
        check(rd_valid = '1', "the packet is readable whatever its length");
        check(to_integer(unsigned(rd_len)) = i,
              "and its length came back exactly");
        check((rd_zlp = '1') = (i = 0),
              "ZLP is reported exactly when the length is zero");
        check((rd_short = '1') = (i < mps_i),
              "short is reported exactly when the length is below mps");
        if i = 0 then
          check(rd_class = class_code(PKT_ZLP),
                "rd_class classifies a ZLP correctly");
        elsif i < mps_i then
          check(rd_class = class_code(PKT_SHORT),
                "rd_class classifies a short packet correctly");
        else
          check(rd_class = class_code(PKT_FULL),
                "rd_class classifies a full packet correctly");
        end if;
        rd_ack <= '1'; step; idle;
        n_class := n_class + 1;
      end loop;
    end loop;
    report "  exhaustive length/classification sweep: " & integer'image(n_class)
         & " of 260 points verified" severity note;

    -- ===== C. directed: the two stories this block exists for =====
    hard_reset; setmps(8);

    -- 1. THE ZLP story. A 16-byte transfer on an 8-byte endpoint is two full
    --    packets -- and the host cannot tell it is finished until a THIRD,
    --    zero-length packet arrives.
    wr_commit <= '1'; setlen(8); step; idle;
    check(to_integer(unsigned(used)) = 1, "the first full packet is buffered");
    wait for 1 ns;
    check(rd_short = '0', "a full packet does NOT terminate the transfer");
    rd_ack <= '1'; step; idle;
    wr_commit <= '1'; setlen(8); step; idle;
    rd_ack <= '1'; step; idle;
    wr_commit <= '1'; setlen(0); step; idle;              -- the ZLP
    check(to_integer(unsigned(used)) = 1,
          "the ZERO-LENGTH packet occupied a buffer -- it is a real packet");
    wait for 1 ns;
    check(rd_valid = '1', "and it is readable");
    check(rd_zlp = '1', "and reads back as a ZLP");
    check(rd_short = '1', "which terminates the transfer");
    check(to_integer(unsigned(rd_len)) = 0, "carrying no bytes");
    rd_ack <= '1'; step; idle;
    check(n_zlp = std_logic_vector(to_unsigned(1, 32)),
          "one zero-length packet was counted as a packet");

    -- 2. THE PING-PONG story. A write and a read in the SAME cycle must both
    --    succeed and leave occupancy unchanged.
    hard_reset; setmps(8);
    wr_commit <= '1'; setlen(3); step; idle;
    wr_commit <= '1'; setlen(4); step; idle;
    check(to_integer(unsigned(used)) = 2, "both buffers are full");
    check(wr_ready = '0', "so a further packet would have to be NAKed");
    wr_commit <= '1'; setlen(5); rd_ack <= '1'; wait for 1 ns;
    check(wr_ready = '0', "the write cannot be accepted this cycle");
    check(rd_valid = '1', "but the read can proceed");
    step; idle;
    check(to_integer(unsigned(used)) = 1, "so occupancy fell by one");
    check(n_nak = std_logic_vector(to_unsigned(1, 32)),
          "and the refused packet was counted as a NAK");
    wr_commit <= '1'; setlen(6); rd_ack <= '1'; step; idle;
    check(to_integer(unsigned(used)) = 1,
          "a simultaneous write and read left occupancy UNCHANGED -- full rate");
    wait for 1 ns;
    check(to_integer(unsigned(rd_len)) = 6,
          "and the surviving packet is the one just written");

    -- 3. Ordering. Packets come out oldest-first, not newest-first.
    hard_reset; setmps(8);
    wr_commit <= '1'; setlen(11); step; idle;
    wr_commit <= '1'; setlen(22); step; idle;
    wait for 1 ns;
    check(to_integer(unsigned(rd_len)) = 11, "the OLDEST packet is offered first");
    rd_ack <= '1'; step; idle;
    wait for 1 ns;
    check(to_integer(unsigned(rd_len)) = 22, "then the newer one");

    -- 4. Flush clears BOTH pointers, not just the count.
    hard_reset; setmps(8);
    wr_commit <= '1'; setlen(11); step; idle;
    rd_ack <= '1'; step; idle;               -- read pointer now at buffer 1
    wr_commit <= '1'; setlen(33); step; idle;
    flush <= '1'; step; idle;
    check(to_integer(unsigned(used)) = 0, "the flush emptied the FIFO");
    wr_commit <= '1'; setlen(44); step; idle;
    wait for 1 ns;
    check(to_integer(unsigned(rd_len)) = 44,
          "and the next packet read is the one just written -- both pointers reset");

    -- ===== D. randomised =====
    -- ieee.math_real.uniform is a genuinely different generator from either
    -- Verilog builtin, which is what makes this column independent evidence.
    hard_reset;
    for i in 0 to 39999 loop
      rnd(iv, 4);
      case iv is
        when 0      => mps_i := 8;
        when 1      => mps_i := 16;
        when 2      => mps_i := 32;
        when others => mps_i := 64;
      end case;
      setmps(mps_i);
      rnd(iv, 3); if iv /= 0 then wr_commit <= '1'; else wr_commit <= '0'; end if;
      rnd(iv, 3); if iv /= 0 then rd_ack <= '1';    else rd_ack <= '0';    end if;
      rnd(iv, 64); if iv = 0 then flush <= '1';     else flush <= '0';     end if;
      -- bias hard toward the boundary lengths: 0 (ZLP), mps-1, mps
      rnd(iv, 4);
      case iv is
        when 0      => setlen(0);
        when 1      => setlen(mps_i - 1);
        when 2      => setlen(mps_i);
        when others => rnd(iv, 65); setlen(iv);
      end case;
      step;
    end loop;

    for i in 0 to 2 loop
      check(n_occ(i) > 0, "every occupancy level was reached");
    end loop;
    check(n_both > 2000, "simultaneous write+read happened often");
    check(n_zlp_stored > 2000, "zero-length packets were stored often");
    check(n_nakhit > 500, "the FIFO was found full often");

    report "  REACH: control=" & integer'image(n_ctrl)
         & " class=" & integer'image(n_class)
         & " | occupancy: empty=" & integer'image(n_occ(0))
         & " one=" & integer'image(n_occ(1))
         & " full=" & integer'image(n_occ(2)) severity note;
    report "  CASES: simultaneous-rw=" & integer'image(n_both)
         & " zlps-stored=" & integer'image(n_zlp_stored)
         & " short-pkts=" & integer'image(n_shorthit)
         & " naks=" & integer'image(n_nakhit) severity note;
    report "  COUNTERS: written="
         & integer'image(to_integer(unsigned(n_written)))
         & " read=" & integer'image(to_integer(unsigned(n_read)))
         & " zlp=" & integer'image(to_integer(unsigned(n_zlp)))
         & " short=" & integer'image(to_integer(unsigned(n_short)))
         & " nak=" & integer'image(to_integer(unsigned(n_nak)))
         & " flush=" & integer'image(to_integer(unsigned(n_flush)))
         severity note;
    report "  [VHDL] usb_ep_pingpong: " & integer'image(errors) & " errors"
         severity note;
    if errors = 0 then
      report "  [VHDL] PASS" severity note;
    else
      report "  [VHDL] FAIL" severity failure;
    end if;
    running <= false;
    wait;
  end process;
end architecture;

11. Exhaustive Verification

MeasureVerilogSystemVerilogVHDL
Control transitions192 / 192192 / 192192 / 192
Length/class points260 / 260260 / 260260 / 260
occupancy 0 reached978596349583
occupancy 1 reached241072409924220
occupancy 2 reached767478337763
simultaneous write+read102831015910317
zero-length packets stored532452705400
short packets stored134141347313483
FIFO found full (NAK)502050845050
packets written211282105521198
packets read205222044120591
flushes633631598
ResultPASSPASSPASS

The two rows in bold are the ones the chapter is about. 10 000 simultaneous write-and-read cycles is what makes the ping-pong claim evidence rather than assertion, and 5300 stored ZLPs is what makes the packet-FIFO claim the same. Both required deliberate stimulus bias — the randomised phase picks length 0 a quarter of the time, because a uniform draw over 0…64 would produce a ZLP 1.5% of the time and the case that matters most would be the case tested least.

12. Mutation Testing

#MutationVerilogSysVerVHDL
F1a zero-length packet is not stored283830283550285117
F2flush resets the count but not the pointers469805130645680
F3a write is blocked while a read is in progress293453293635294518
F4rd_short uses <= instead of <658762696503
F5the read side uses the write pointer753667537375817
F6rd_zlp does not require a packet to exist680469156714
F7wr_ready collapses to a single buffer358973359206358861
—unmutated baseline000

All seven die in all three languages, all counts distinct, columns within 10%.

F1, F3 and F7 are enormous — 280 000 to 360 000 — and that is informative rather than impressive. All three change the FIFO's occupancy behaviour, and occupancy drives wr_ready, rd_valid, rd_len, rd_class, used and four counters. One wrong term desynchronises the shadow model permanently, and every subsequent cycle fails. A number this large means "this mutation breaks the block", not "this check is sharp".

The informative ones are F4 and F6, at ~6500 and ~6800. Both are single-condition errors on a single output:

  • F4 (< becomes <=) fires only when rd_len is exactly mps — one length value out of 65, which the biased randomiser hits about a quarter of the time.
  • F6 (rd_zlp without rd_valid) fires only when the FIFO is empty and the stale buffer contents happen to be zero.

They are the two mutations that would survive a careless bench, and they are also the two that correspond to real, shipped bugs: F4 truncates every transfer that is a multiple of the packet size; F1 hangs every transfer that is a multiple of the packet size. The same input condition, opposite failure modes, and a bench that only ever sends 100-byte transfers finds neither.

13. Debugging Walkthrough: "It Works, Except on Files That Are a Round Number of Kilobytes"

The report. A USB device transfers files correctly, except that occasionally a transfer never completes — the host's read blocks for ever. The customer notices it mostly on .bin firmware images. Never on .jpg.

Step 1 — find the pattern. Collect the sizes of the transfers that hang. Every one is a multiple of 512. The ones that work are not. .bin images are padded to a block size; .jpg files are whatever size they are. That is a length-dependent bug, and the only length that matters to USB is wMaxPacketSize.

Step 2 — confirm the arithmetic. The endpoint is a high-speed bulk endpoint, wMaxPacketSize = 512. Every hanging transfer is an exact multiple of 512; every working transfer is not. So the transfers that hang are exactly those with no short packet to terminate them — the ones that need a ZLP.

Step 3 — is the ZLP being sent? Trace the bus. The device sends its last full packet and then nothing. The host issues IN token after IN token and gets NAK each time. So the device is not sending the ZLP, and the question is whether firmware asked for one.

Step 4 — which side dropped it? Instrument n_written on the endpoint and compare against the number of packets firmware committed. Firmware committed 17; the FIFO recorded 16. The ZLP was committed and not stored.

Step 5 — the cause. do_wr was gated on wr_len != 0, because zero bytes looked like nothing to do. Mutation F1, in production.

Step 6 — why the bench never found it. The bring-up tests sent 64, 100, 1000 and 4096 bytes. On a 512-byte endpoint, 4096 is a multiple — so the test should have caught it. It did not, because the bring-up test read a fixed number of bytes and never waited for the transfer to terminate. The test asserted the data was right, not that the transfer ended.

14. UVM: Making the Empty Packet a First-Class Stimulus

14.1 The transaction

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class usb_fifo_item extends uvm_sequence_item;
  `uvm_object_utils(usb_fifo_item)

  rand bit        wr_commit;
  rand bit [6:0]  wr_len;
  rand bit        rd_ack;
  rand bit        flush;
  rand bit [6:0]  mps;

  // Only the four legal full-speed / high-speed packet sizes.
  constraint c_legal_mps { mps inside {8, 16, 32, 64}; }

  // A length can never exceed the endpoint's max packet size -- that is a
  // protocol invariant, not a stimulus choice.
  constraint c_len_fits { wr_len <= mps; }

  // THE bias. A uniform draw over 0..64 produces a ZLP about 1.5% of the
  // time, which would make the single most important case the least tested.
  // The three boundary lengths get three quarters of the distribution.
  constraint c_boundary_heavy {
    wr_len dist { 0         := 25,     // the ZERO-LENGTH PACKET
                  mps - 1   := 25,     // the largest short packet
                  mps       := 25,     // a full packet: NOT a terminator
                  [1:mps-2] := 25 };
  }

  constraint c_flush_rare { flush dist {0 := 63, 1 := 1}; }

  function new(string name = "usb_fifo_item"); super.new(name); endfunction

  function string convert2string();
    return $sformatf("wr=%0b len=%0d rd=%0b flush=%0b mps=%0d",
                     wr_commit, wr_len, rd_ack, flush, mps);
  endfunction
endclass

14.2 Sequences

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// THE sequence for this chapter. It sends a complete transfer whose length is
// an EXACT MULTIPLE of the packet size -- n full packets and then the ZLP
// that terminates it. This is the transfer a byte FIFO hangs on, and random
// stimulus produces the pattern only by accident.
class multiple_of_mps_transfer_seq extends uvm_sequence #(usb_fifo_item);
  `uvm_object_utils(multiple_of_mps_transfer_seq)
  function new(string name = "multiple_of_mps_transfer_seq");
    super.new(name);
  endfunction

  rand int unsigned n_transfers;
  constraint c_n { n_transfers inside {[50:100]}; }

  task body();
    repeat (n_transfers) begin
      usb_fifo_item it;
      int unsigned pkt_size = 64;
      int unsigned n_full   = $urandom_range(1, 6);

      // n full packets: the transfer is a multiple of the packet size, so
      // NOTHING so far tells the host it is finished.
      repeat (n_full) begin
        it = usb_fifo_item::type_id::create("it");
        start_item(it);
        if (!it.randomize() with { wr_commit == 1; mps == 64;
                                   wr_len == 64; flush == 0; })
          `uvm_error("RAND", "full-packet randomize failed")
        finish_item(it);
      end

      // ...and the ZLP that does. A device that drops this hangs the host.
      it = usb_fifo_item::type_id::create("it");
      start_item(it);
      if (!it.randomize() with { wr_commit == 1; mps == 64;
                                 wr_len == 0; flush == 0; })
        `uvm_error("RAND", "ZLP randomize failed")
      finish_item(it);
    end
  endtask
endclass

// Sustained full-rate traffic: a write and a read every cycle. The property
// under test is that occupancy holds steady rather than sawtoothing, which is
// the difference between real ping-pong and two buffers used one at a time.
class full_rate_seq extends uvm_sequence #(usb_fifo_item);
  `uvm_object_utils(full_rate_seq)
  function new(string name = "full_rate_seq"); super.new(name); endfunction

  task body();
    repeat (2000) begin
      usb_fifo_item it = usb_fifo_item::type_id::create("it");
      start_item(it);
      if (!it.randomize() with { wr_commit == 1; rd_ack == 1; flush == 0; })
        `uvm_error("RAND", "full-rate randomize failed")
      finish_item(it);
    end
  endtask
endclass

// A flush arriving at the awkward moment: with the read pointer advanced and
// a packet still buffered. This is the state in which resetting the count
// without resetting the pointers goes wrong.
class flush_mid_stream_seq extends uvm_sequence #(usb_fifo_item);
  `uvm_object_utils(flush_mid_stream_seq)
  function new(string name = "flush_mid_stream_seq"); super.new(name); endfunction

  task body();
    repeat (400) begin
      usb_fifo_item it;
      // write, read (read pointer advances), write (one packet buffered)
      for (int i = 0; i < 3; i++) begin
        it = usb_fifo_item::type_id::create("it");
        start_item(it);
        if (!it.randomize() with { wr_commit == (i != 1);
                                   rd_ack    == (i == 1);
                                   flush     == 0; })
          `uvm_error("RAND", "pre-flush randomize failed")
        finish_item(it);
      end
      // ...and now flush, with the pointers out of phase
      it = usb_fifo_item::type_id::create("it");
      start_item(it);
      if (!it.randomize() with { flush == 1; })
        `uvm_error("RAND", "flush randomize failed")
      finish_item(it);
    end
  endtask
endclass

14.3 The scoreboard

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class usb_fifo_scoreboard extends uvm_scoreboard;
  `uvm_component_utils(usb_fifo_scoreboard)

  uvm_analysis_imp #(usb_fifo_mon_item, usb_fifo_scoreboard) ap;

  // An independent queue of the packet LENGTHS the bus committed. A queue,
  // not a byte count -- modelling the DUT as a byte FIFO in the scoreboard
  // would reproduce the very bug under test.
  int unsigned q[$];
  int unsigned n_zlp_stored, n_simultaneous, n_full_nak, n_flushes;

  function new(string name, uvm_component parent);
    super.new(name, parent);
    ap = new("ap", this);
  endfunction

  function void write(usb_fifo_mon_item t);
    bit do_wr, do_rd;

    do_wr = t.wr_commit && (q.size() < 2);
    do_rd = t.rd_ack    && (q.size() > 0);

    // ---- Check the outputs BEFORE applying this cycle's effects ----
    if (q.size() == 0) begin
      if (t.rd_valid)
        `uvm_error("EMPTY", "an empty FIFO offered a packet");
      if (t.rd_zlp || t.rd_short)
        `uvm_error("EMPTY",
          "an empty FIFO described a packet it does not hold -- that is a fabricated transfer terminator");
    end else begin
      if (t.rd_len != q[0])
        `uvm_error("ORDER",
          $sformatf("rd_len=%0d but the oldest buffered packet is %0d -- packets are coming out of order",
                    t.rd_len, q[0]))
      // ---- THE property. Zero bytes is a packet, and it terminates. ----
      if ((q[0] == 0) && !t.rd_zlp)
        `uvm_error("ZLP",
          "a stored zero-length packet did not read back as a ZLP")
      if ((q[0] == 0) && !t.rd_short)
        `uvm_error("ZLP",
          "a ZLP was not reported as terminating the transfer")
      // ---- And a full packet does NOT terminate. ----
      if ((q[0] == t.mps) && t.rd_short)
        `uvm_error("SHORT",
          "a full-size packet was reported short -- the transfer ends one packet early")
    end

    if (t.used != q.size())
      `uvm_error("OCCUPANCY",
        $sformatf("used=%0d but the scoreboard holds %0d packets",
                  t.used, q.size()))

    // ---- Ping-pong: both sides may act in the same cycle ----
    if (t.wr_commit && t.rd_ack && (q.size() == 1)) begin
      if (!do_wr || !do_rd)
        `uvm_error("PINGPONG",
          "a simultaneous write and read did not both proceed -- two buffers at the throughput of one")
      n_simultaneous++;
    end

    if (t.wr_commit && (q.size() == 2)) n_full_nak++;

    // ---- Apply this cycle to the scoreboard's own model ----
    if (t.flush) begin
      q.delete();
      n_flushes++;
    end else begin
      if (do_wr) begin
        q.push_back(t.wr_len);
        if (t.wr_len == 0) n_zlp_stored++;
      end
      if (do_rd) void'(q.pop_front());
    end
  endfunction

  function void report_phase(uvm_phase phase);
    `uvm_info("SB", $sformatf(
      "zlps=%0d simultaneous=%0d full-naks=%0d flushes=%0d",
      n_zlp_stored, n_simultaneous, n_full_nak, n_flushes), UVM_LOW)

    // A run that never stored a ZLP has not tested the thing this block is
    // for, however many megabytes it moved.
    if (n_zlp_stored   == 0) `uvm_error("COVERAGE",
      "no zero-length packet was ever stored -- the packet-FIFO property is untested")
    if (n_simultaneous == 0) `uvm_error("COVERAGE",
      "no simultaneous write and read -- the second buffer is untested")
    if (n_full_nak     == 0) `uvm_error("COVERAGE", "the FIFO was never found full")
    if (n_flushes      == 0) `uvm_error("COVERAGE", "no flush was ever applied")
  endfunction
endclass

The scoreboard models the FIFO as a queue of lengths, and that choice is doing real verification work. A scoreboard written as "expected byte count" would go wrong in exactly the way the DUT under mutation F1 goes wrong, and would agree with it perfectly. A reference model that shares the design's misconception confirms nothing.

14.4 Functional coverage

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
covergroup ep_fifo_cg with function sample(
    bit [6:0] len, bit [6:0] mps, bit [1:0] used, bit wr, bit rd, bit flush,
    pkt_class_e cls);

  // The classification, with ZLP as its own bin -- not folded into "short".
  cp_class : coverpoint cls {
    bins none = {PKT_NONE};
    bins zlp  = {PKT_ZLP};      // the bin that matters
    bins short_pkt = {PKT_SHORT};
    bins full = {PKT_FULL};
  }

  cp_used : coverpoint used { bins empty = {0}; bins half = {1}; bins full = {2}; }
  cp_mps  : coverpoint mps  { bins sizes[] = {8, 16, 32, 64}; }

  // The three boundary lengths by name, relative to mps -- an absolute
  // coverpoint on len would report 100% while never hitting the boundary
  // for the packet size actually in use.
  cp_len_rel : coverpoint (len == 0 ? 0 : (len < mps ? 1 : 2)) {
    bins zero  = {0};
    bins below = {1};
    bins at_max = {2};
  }

  // THE cross. Every packet class at every legal max packet size: a ZLP on a
  // 64-byte endpoint and a ZLP on an 8-byte endpoint are the same fact, and
  // a design that special-cases one of them should be caught.
  x_class_mps : cross cp_class, cp_mps;

  // Simultaneity at every occupancy -- the ping-pong property.
  cp_both : coverpoint (wr && rd) { bins simultaneous = {1}; bins single = {0}; }
  x_both_used : cross cp_both, cp_used;

  // A flush at every occupancy, which is where the pointer-reset bug lives.
  cp_flush : coverpoint flush { bins flushing = {1}; }
  x_flush_used : cross cp_flush, cp_used;
endgroup

cp_len_rel is worth copying. An absolute coverpoint on len with bins for 0, 63 and 64 closes happily while never once presenting a boundary length for the mps actually in effect — 63 is the short-packet boundary only when mps is 64. Expressing the coverpoint relative to the parameter is what makes the cross with cp_mps mean what it looks like it means.

15. SystemVerilog Assertions

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module usb_ep_pingpong_sva
  import usb_pingpong_pkg::*;
#(
  parameter int LEN_W = 7
) (
  input logic             clk,
  input logic             rst_n,
  input logic             flush,
  input logic [LEN_W-1:0] mps,
  input logic             wr_commit,
  input logic [LEN_W-1:0] wr_len,
  input logic             rd_ack,
  input logic             wr_ready,
  input logic             rd_valid,
  input logic [LEN_W-1:0] rd_len,
  input logic             rd_zlp,
  input logic             rd_short,
  input pkt_class_e       rd_class,
  input logic [1:0]       used,
  input logic             nak_needed
);
  default clocking cb @(posedge clk); endclocking
  default disable iff (!rst_n);

  // ---- 1. THE property. Committing a ZERO-LENGTH packet with room
  // ----    available raises the occupancy, exactly as any other packet does.
  property p_zlp_occupies_a_buffer;
    (wr_commit && wr_ready && (wr_len == '0) && !flush && !rd_ack)
      |=> (used == $past(used) + 2'd1);
  endproperty
  a_zlp_occupies_a_buffer : assert property (p_zlp_occupies_a_buffer)
    else $error("a zero-length packet was committed and did not occupy a buffer");

  // ---- 2. Ping-pong: a simultaneous write and read leave occupancy alone,
  // ----    and BOTH must actually happen.
  property p_simultaneous_holds_occupancy;
    (wr_commit && wr_ready && rd_ack && rd_valid && !flush)
      |=> (used == $past(used));
  endproperty
  a_simultaneous_holds_occupancy :
    assert property (p_simultaneous_holds_occupancy)
    else $error("a simultaneous write and read changed occupancy -- one of them was dropped");

  // ---- 3. A ZLP always terminates the transfer. ----
  property p_zlp_is_short;
    rd_zlp |-> rd_short;
  endproperty
  a_zlp_is_short : assert property (p_zlp_is_short);

  // ---- 4. A FULL packet never does. ----
  property p_full_is_not_short;
    (rd_valid && (rd_len == mps)) |-> !rd_short;
  endproperty
  a_full_is_not_short : assert property (p_full_is_not_short)
    else $error("a packet of exactly mps bytes was reported short -- every multiple-of-mps transfer ends early");

  // ---- 5. An empty FIFO describes no packet at all. ----
  property p_empty_describes_nothing;
    !rd_valid |-> (!rd_zlp && !rd_short && (rd_class == PKT_NONE));
  endproperty
  a_empty_describes_nothing : assert property (p_empty_describes_nothing)
    else $error("an empty FIFO fabricated a transfer terminator");

  // ---- 6. rd_class agrees with the booleans it summarises. ----
  property p_class_agrees;
    ((rd_class == PKT_NONE)  == !rd_valid)
    && ((rd_class == PKT_ZLP) == rd_zlp)
    && (((rd_class == PKT_ZLP) || (rd_class == PKT_SHORT)) == rd_short);
  endproperty
  a_class_agrees : assert property (p_class_agrees);

  // ---- 7. Occupancy never leaves the two buffers that exist. ----
  property p_occupancy_bounded;
    used <= 2'd2;
  endproperty
  a_occupancy_bounded : assert property (p_occupancy_bounded);

  // ---- 8. Readiness and validity are exactly occupancy, restated. ----
  property p_ready_valid_track_occupancy;
    (wr_ready == (used != 2'd2)) && (rd_valid == (used != 2'd0))
    && (nak_needed == !wr_ready);
  endproperty
  a_ready_valid_track_occupancy :
    assert property (p_ready_valid_track_occupancy);

  // ---- 9. A flush empties the FIFO completely, in one cycle. ----
  property p_flush_empties;
    flush |=> (used == 2'd0);
  endproperty
  a_flush_empties : assert property (p_flush_empties);

  // ---- 10. Nothing is accepted into a full FIFO. ----
  property p_no_overflow;
    (used == 2'd2) |-> !wr_ready;
  endproperty
  a_no_overflow : assert property (p_no_overflow);

  // ---- Cover: the cases that matter were actually reached. ----
  c_zlp_stored   : cover property ((wr_commit && wr_ready && (wr_len == '0)));
  c_simultaneous : cover property ((wr_commit && wr_ready && rd_ack && rd_valid));
  c_full         : cover property ((used == 2'd2));
  c_flush_loaded : cover property ((flush && (used != 2'd0)));
endmodule

bind usb_ep_pingpong usb_ep_pingpong_sva #(.LEN_W(LEN_W)) u_sva (.*);

16. Common Misconceptions

"A zero-length packet is a no-op." It is the transfer terminator. Dropping it hangs the host on every transfer whose length is an exact multiple of the packet size.

"The FIFO should store bytes — firmware wants bytes." Firmware also needs to know where each packet ended, and a byte FIFO throws that away. Store packets; hand firmware the bytes of one packet at a time.

"A packet of mps bytes is the biggest packet, so it must be the last one." It is the opposite: a full packet means more is coming. Only a packet smaller than mps ends a transfer.

"Ping-pong just means two buffers." It means two buffers plus the ability to use both in the same cycle. A two-buffer design that serialises the write behind the read has the area cost and none of the throughput benefit — and it passes every test that does not check occupancy on a simultaneous cycle.

"Alternating between A and B is the same as a read pointer and a write pointer." Only while nothing interrupts the rhythm. A flush at the wrong moment leaves the two ends of a single alternating bit disagreeing, and firmware reads the buffer the bus is filling.

"rd_zlp needs no rd_valid guard — the length will be zero anyway when empty." The length is whatever is left in the buffer the read pointer addresses. Sometimes that is zero, and then an empty FIFO announces a transfer terminator that nothing sent.

"A flush only needs to clear the occupancy." It has to clear both pointers as well, or the next packet written and the next packet read are different buffers.

17. Exercises

1. Gate do_wr on wr_len != 0 and run the suite. Predict which of the two exhaustive domains catches it first, and why the count is ~284 000 rather than the ~5300 ZLPs the randomiser generates.

2. Deepen the FIFO from two buffers to four, keeping the same interface. Which of the seven mutations change their counts most, and which is now harder to kill? (Hint: F7 collapses wr_ready to "empty"; with four buffers that is a bigger lie but a rarer one.)

3. Property 9 does not catch mutation F2. Write a two-cycle SVA property that does, using $past to remember the length written before the flush. Then explain why the scoreboard's ordering check finds it without any temporal reasoning at all.

4. The c_boundary_heavy constraint gives length 0 a quarter of the distribution. Remove it, re-run the randomised phase, and measure how far F1's count falls. Use the result to argue for or against biased stimulus in a regression whose job is to find unknown bugs rather than confirm known ones.

5. cp_len_rel expresses the boundary relative to mps. Construct a regression in which an absolute coverpoint on len reports 100% while x_class_mps has an empty bin, and say which of the two you would gate a release on.

6. Add an overflow_err output that pulses if wr_commit is asserted when used == 2. Is this an error the endpoint should report, or normal flow control? Justify the answer using Chapter 21.1's handshake rules, then write the assertion that pins your choice.

18. Summary

IdeaWhy it matters
A short packet terminates a transferthere is no length field in the protocol
A zero-length packet is a real packetit is the terminator when the data ends on a boundary
...so the FIFO stores packets, not bytesa byte FIFO cannot represent zero bytes
A ZLP occupies a bufferdropping it hangs every multiple-of-mps transfer
rd_short is <, never <=<= truncates every multiple-of-mps transfer
rd_zlp requires rd_validor an empty FIFO fabricates a terminator
Two buffers, used in the same cyclethat simultaneity is the whole point
Two buffers are a ring, not a pairpackets come out oldest-first
A flush resets both pointersnot just the occupancy
192 control + 260 classification pointstwo exhaustive domains, each named
7 mutations, all killed in 3 languagesF1 and F4 are the same input condition, opposite failures

Tooling

StepCommand
Verilog-2005iverilog -g2005 -o pp_v.out pp_v.v pp_v_tb.v && ./pp_v.out
SystemVerilogiverilog -g2012 -o pp_sv.out pp_sv.sv pp_sv_tb.sv && ./pp_sv.out
VHDL-2008 analysenvc --std=2008 -a pp_vhdl.vhd pp_vhdl_tb.vhd
VHDL-2008 elaboratenvc --std=2008 -e tb_pp_vhdl
VHDL-2008 runnvc --std=2008 -r tb_pp_vhdl
One mutationiverilog -g2005 -DMUT_F1 -o mm pp_v_mut.v pp_v_tb.v && ./mm

All three implementations pass with 0 errors: 192 of 192 control transitions, 260 of 260 length/classification points, 40 000 randomised cycles, every occupancy level reached and asserted reached.


Chapter 21.3 — Descriptor Engine is where the short-packet rule from this chapter meets the host's own idea of how much it wants. A GET_DESCRIPTOR request carries a wLength field, and the device must return the smaller of what was asked for and what exists — then decide, from that comparison, whether a ZLP is required to terminate the transfer. The rule is one line long and gets written wrong in both directions.

Continue learning

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.