The scoreboard watches only the handshakes. Every accepted write pushes its word into a reference queue, and every accepted read must return the word at the head of that queue. The queue size is also checked against the depth.
Read it top to bottom. The scoreboard never looks inside the FIFO: it sees only words that were accepted on the write side and taken on the read side.
Compare with queue head: catches a wrong word, a dropped word, a duplicate and a reordering, because each one breaks the order of the queue.
Size check: catches overflow, a write accepted when the FIFO already holds DEPTH words. A data-only check misses it.
Underflow: a read with an empty queue is an error too.
A test that has never failed proves nothing. The testbench below runs the same random traffic into four FIFO models: one correct and three with an injected bug every 50 writes.
FIFO under test | Scoreboard errors (seed 215) |
|---|---|
Correct | 0 |
Drops a word every 50 writes | 21,281 (every word after the drop is compared with the wrong expectation) |
Duplicates a word every 50 writes | 1,860 |
Swaps two adjacent words every 50 writes | 228 |
The counts differ because each bug disturbs the data stream differently: a drop shifts every later word, while a swap disturbs two. Counts depend on the seed and simulator; record both with any result.
After one word is dropped, every later read is compared with a different expected word (shifted by one), so each read mismatches. A real testbench might stop at the first error or re-synchronise; counting all of them shows how far the corruption spreads.
Run the scoreboard with several clock ratios and the metastability-aware pointer synchroniser (see the async FIFO topic).
Add coverage of full, empty, simultaneous read and write, almost-full and almost-empty, and wrap-around of the pointers.
Add protocol assertions: no write accepted when full, no read accepted when empty, Gray pointers one bit per step.
Add reset scenarios with a reset-aware FIFO model and checker: reset in the middle of traffic on both sides, then confirm empty and not-full. The simple model/checker below has no reset interface.
Add a mutation run (binary pointers, wrong full condition) to show the tests catch real CDC bugs.
The scoreboard, a bug-injecting FIFO model, and a testbench applying common offered enables/data to four variants. Acceptance differs with each variant's occupancy. Supply +SEED=<integer> and record the simulator/version as well as the printed seeds; PRNG sequences are tool-dependent.
`timescale 1ns/1ps
// Contract: pre-edge accepted-transfer strobes and show-ahead read data; no reset/flush mid-epoch.
// Coincident accepted transfers require 0 < pre-edge occupancy < DEPTH.
// No empty bypass or full-turnover acceptance. Registered-read FIFOs need a latency-aware checker.
module fifo_scoreboard #(parameter int DW = 8, parameter int DEPTH = 16) (
input logic wclk, rclk,
input logic w_fire, r_fire, // a word was accepted by the FIFO / taken from it
input logic [DW-1:0] w_data, r_data,
output int errors
);
logic [DW-1:0] q [$];
int max_fill;
initial begin errors = 0; max_fill = 0; end
always @(posedge wclk) if (w_fire) begin
q.push_back(w_data);
if (q.size() > max_fill) max_fill = q.size();
if (q.size() > DEPTH) errors++; // overflow: more words stored than the FIFO can hold
end
always @(posedge rclk) if (r_fire) begin
if (q.size() == 0) errors++; // underflow: a read with nothing written
else begin
logic [DW-1:0] expected;
expected = q.pop_front();
if (r_data !== expected) errors++; // wrong data or wrong order
end
end
endmodule
// A simple FIFO model with injectable bugs, only to test the scoreboard.
// BUG 0: none 1: a word is dropped now and then 2: a word is duplicated now and then 3: two adjacent words are swapped
module buggy_fifo #(parameter int BUG = 0, parameter int DW = 8, parameter int DEPTH = 16) (
input logic wclk, rclk,
input logic winc, rinc,
input logic [DW-1:0] wdata,
output logic [DW-1:0] rdata,
output logic w_fire, r_fire
);
if (DEPTH < 1) begin : g_depth_check
initial $fatal(1, "buggy_fifo: DEPTH must be positive");
end
logic [DW-1:0] mem [DEPTH];
int rp = 0, wp = 0, count = 0;
assign w_fire = winc && (wp - rp < DEPTH);
assign r_fire = rinc && (wp - rp > 0);
assign rdata = mem[rp % DEPTH];
always @(posedge wclk) if (w_fire) begin
count <= count + 1;
if (BUG == 1 && (count + 1) % 50 == 0) begin
// drop: the word is accepted but never stored
end else if (BUG == 2 && (count + 1) % 50 == 0 && wp - rp < DEPTH - 1) begin
mem[wp % DEPTH] <= wdata; mem[(wp + 1) % DEPTH] <= wdata; wp <= wp + 2; // duplicate
end else if (BUG == 3 && (count + 1) % 50 == 0 && wp - rp >= 1) begin
mem[(wp - 1) % DEPTH] <= wdata; mem[wp % DEPTH] <= mem[(wp - 1) % DEPTH]; wp <= wp + 1; // swap with the previous word
end else begin
mem[wp % DEPTH] <= wdata; wp <= wp + 1;
end
end
always @(posedge rclk) if (r_fire) rp <= rp + 1;
endmodule
// DEPTH > 64 must not alias storage. Two rounds also exercise address wraparound.
module fifo_depth_check (output bit done, output int errors);
logic clk = 0, winc = 0, rinc = 0, w_fire, r_fire;
logic [15:0] wdata = 0, rdata;
always #5 clk = ~clk;
buggy_fifo #(.BUG(0), .DW(16), .DEPTH(128)) dut (
.wclk(clk), .rclk(clk), .winc, .rinc, .wdata, .rdata, .w_fire, .r_fire
);
initial begin
done = 0; errors = 0;
for (int round = 0; round < 2; round++) begin
for (int i = 0; i < 128; i++) begin
@(negedge clk); winc = 1; wdata = 128 * round + i;
@(posedge clk); if (!w_fire) errors++;
end
@(negedge clk); winc = 0;
for (int i = 0; i < 128; i++) begin
@(negedge clk); rinc = 1;
@(posedge clk);
if (!r_fire || rdata !== 16'(128 * round + i)) errors++;
end
@(negedge clk); rinc = 0;
end
done = 1;
end
endmodule
// Common clock deliberately makes accepted transfers coincide at interior occupancy.
module scoreboard_contract_check (output bit done, output int errors);
logic clk = 0, w_fire = 0, r_fire = 0;
logic [7:0] w_data = 0, r_data = 0;
always #5 clk = ~clk;
fifo_scoreboard #(.DW(8), .DEPTH(2)) dut (
.wclk(clk), .rclk(clk), .w_fire, .r_fire, .w_data, .r_data, .errors
);
task automatic step(input bit w, r, input logic [7:0] wd, rd);
@(negedge clk); w_fire = w; r_fire = r; w_data = wd; r_data = rd;
@(posedge clk); #1;
endtask
initial begin
done = 0;
step(1, 0, 8'ha1, 0); // empty -> one
step(1, 1, 8'hb2, 8'ha1); // coincident transfers at occupancy one
step(1, 0, 8'hc3, 0); // one -> full
step(0, 1, 0, 8'hb2); // full -> one (no full-turnover write)
step(0, 1, 0, 8'hc3); // one -> empty (no bypass)
@(negedge clk); w_fire = 0; r_fire = 0;
done = 1;
end
endmodule
BUG = 0 must preserve 128 distinct words across two fill/read rounds and accept interior coincident transfers plus separate boundary transfers under its show-ahead contract. New offers stop before a bounded 128-read-cycle drain; the clean model must have accepted-write/read equality and an empty reference queue, and every model must drain its stored words. A dropped-word mutant may leave reference entries after its hardware empties; that is expected corruption, not permission to ignore a clean-model remainder. Final failures are fatal. Empty-bypass/full-turnover interfaces still require an atomic checker.
The scoreboard has an interface contract. w_fire and r_fire mean transfers accepted at their rising edges, with data and fire signals stable before those edges. r_data must already be the word consumed at that edge (first-word fall-through, or show-ahead), not registered read data that appears afterward.
Testbench, run with Icarus Verilog 13.0 (2026-10-16), default seed 215: prints PASS. Counts depend on the seed and simulator; record both with any result.
The error counts in the mutation table come from an earlier version of the bench, with independently randomised offers, and are not re-run against this controlled experiment.