A FIFO has passed its tests for weeks. Data goes in, data comes out, in order. Then a new test throttles the consumer so the FIFO actually fills, and the very first time it holds four entries and a fifth write arrives, the oldest word vanishes. The bug was there all along, one comparison off at the boundary. No earlier test had ever been at the boundary.
Why do so many bugs live at boundaries, and how do you make sure tests reach them?
The obvious test checks typical use: push some words, pop them, compare. Typical use lives in the middle of every range: FIFOs half full, lengths in the middle, counters far from wrapping. Boundary behaviour is different code (or the same code taking a different branch), and it is exactly where designers make off-by-one mistakes: > instead of >=, a count that should stop at 4 stopping at 5. A test that never reaches the boundary cannot see a boundary bug, however long it runs.
Kind of boundary | Examples | Typical mistake |
|---|---|---|
Capacity | FIFO full and empty, credits at zero and maximum | Flag one entry early or late |
Range | Minimum and maximum length, zero, all ones | Maximum treated as zero, length 0 not rejected |
Wrap-around | Pointer or counter rolling over | Comparison wrong after the wrap |
First and last | First flit, last flit, first transaction after reset | Special case missing or doubled |
Saturation | A counter that should stop at its maximum | Wraps to zero instead |
A 4-entry FIFO whose full flag compares count > 4 instead of count == 4. Test A pushes three words and pops them. Test B pushes five without popping, then pops everything. A reference queue checks every word that comes out.
// A 4-entry FIFO whose full flag is one entry late: full = (count > 4) instead of
// (count == 4), so it rises only after a fifth push has already overwritten the oldest entry.
// Test A pushes 3 words and pops them: mid-range, it passes. Test B pushes 5 words
// with no pops, the boundary case, then pops everything: word 1 has been overwritten.
// The checker confirms test A saw no error and test B caught the overwrite.
module tb;
logic clk = 0;
always #5 clk = ~clk;
initial begin $dumpfile("wave.vcd"); $dumpvars(0, tb); end
int cyc = 0;
always @(posedge clk) cyc <= cyc + 1;
logic [7:0] mem [0:3];
logic [1:0] wp = 0, rp = 0;
logic [2:0] count = 0;
logic push = 0, pop = 0, full, empty;
logic [7:0] din = 0, dout;
assign full = (count > 3'd4); // BUG: should be (count == 4)
assign empty = (count == 3'd0);
assign dout = mem[rp];
always @(posedge clk) begin
if (push && !full) begin mem[wp] <= din; wp <= wp + 2'd1; end
if (pop && !empty) rp <= rp + 2'd1;
count <= count + ((push && !full) ? 3'd1 : 3'd0) - ((pop && !empty) ? 3'd1 : 3'd0);
end
// stimulus: test A (cycles 1-6), test B (cycles 8-18)
always @(posedge clk) begin
push <= (cyc >= 0 && cyc <= 2) || (cyc >= 7 && cyc <= 11);
pop <= (cyc >= 3 && cyc <= 5) || (cyc >= 12 && cyc <= 16);
din <= (cyc <= 2) ? 8'(8'h10 + cyc) : (cyc >= 7 && cyc <= 11) ? 8'(cyc - 6) : 8'h00; // A: 10,11,12 B: 1,2,3,4,5
end
// checker: a reference queue of accepted words
logic [7:0] q [$];
int errs_a = 0, errs_b = 0;
always @(posedge clk) begin
if (pop && !empty) begin
logic [7:0] e;
e = q.pop_front();
if (dout !== e) begin if (cyc <= 6) errs_a++; else errs_b++; end
end
if (push && !full) q.push_back(din);
end
initial begin
#220;
if (errs_a == 0 && errs_b > 0) $display("PASS: mid-range test A saw no error; boundary test B caught the overwrite (wrong words: %0d)", errs_b);
else $display("FAIL: test A errors %0d, test B errors %0d", errs_a, errs_b);
$finish;
end
endmoduleHow to read it, slot by slot:
Slots 0 to 6, test A: three pushes (0x10, 0x11, 0x12), count rises to 3, three pops read them back in order. Mid-range: correct.
Slots 7 to 11, test B: five pushes (1 to 5). Count reaches 4 at slot 11 with full still low, so the fifth push is accepted into a full FIFO and overwrites word 1.
Slot 12: count reads 5 and full finally rises, one push too late.
Slots 12 to 16: the pops read 5, 2, 3, 4 and then 5 again: the first word out is the overwritten slot. The checker reports the wrong word, and the run prints PASS because test B caught what test A could not.
Approach | How | Cost |
|---|---|---|
Directed boundary tests | Write the case: fill to exactly full, send length 15 | Only boundaries someone listed |
Biased random | Weight extreme values and slow consumers (Part 4) | Less time in the middle |
Small configurations | Shrink FIFOs and credits so limits are hit constantly | Must stay a legal configuration |
Coverage at the limits | Bins for full, empty, maximum, wrap (Part 6) | Only measures; someone must act |
Formal | Prove the property for all states, boundaries included (Part 7) | Block-sized logic only |
Practice | Effect |
|---|---|
Boundary bins in every coverage model | A limit never reached shows up as a hole |
Back-pressure and throttling in stimulus | Queues actually fill |
Formal proofs on FIFOs and counters | Off-by-ones found in minutes, for every state |
DCRouter has five planted corner-case bugs, including a full flag one entry late (BUG_1) and a payload length of 15 treated as 0 (BUG_3). The smoke test of lesson 0.7 never fills a FIFO and only sends 2-flit payloads, so it misses both. Part 4 measures how quickly random traffic finds them, with and without biased lengths and slow sinks.
254 to 255 (reaching the limit), 255 with a further increment (must stay 255), and the clear back to 0; a counter that wraps instead of saturating fails only at 255.
It never holds more than three words, so the full flag's comparison is never exercised; the bug needs the FIFO exactly full.
Bugs cluster at boundaries: capacity, range, wrap-around, first and last, saturation.
Typical traffic stays in the middle, so boundaries must be reached on purpose.
List every boundary, then test it, cover it or prove it.