The synchronous FIFO is the most verified block in hardware, and still a steady source of bugs: the full flag set one word early or late, a pointer that wraps wrongly, data overwritten when push and pop meet. A complete formal check of a FIFO has four parts: no overflow or underflow, flags consistent with the contents, data out in order, and a proof for every cycle. This article builds that check for a FIFO that tells full from empty by its pointers alone, and finds a capacity bug.

10 min read | Intermediate

What You'll Learn

  • Write the complete property set for a FIFO
  • Use a reference count kept by the formal code to check the flags
  • Find the classic full-versus-empty pointer bug and fix it with an extra pointer bit
  • Complete a FIFO proof with a helper invariant

The Property Set

  • No overflow, no underflow: the number of stored words stays between 0 and the depth.
  • Flags: full exactly when the FIFO holds the depth, empty exactly when it holds nothing.
  • Data order: every word comes out once, unchanged and in order, checked with the tracked value from the data integrity article.
  • Reachability: full and empty both happen, and push and pop in the same cycle happen in every fill level.

The count of stored words is the key to the first two. This FIFO has no count register, so the formal code keeps its own: a reference count n updated from the accepted pushes and pops. It is independent of the pointers, so it can judge them.

The Design: a Pointer-Only FIFO


module fifo_ptr (input clk, input rst, input push, input [7:0] din, input pop,
                 output [7:0] dout, output full, output empty);
  reg [7:0] mem [0:3];
  reg [1:0] wptr, rptr;
  assign empty = (wptr == rptr);
  assign full  = (wptr + 2'd1 == rptr);          // bug: this FIFO can only hold 3 words
  assign dout  = mem[rptr];
  wire do_push = push && !full;
  wire do_pop  = pop && !empty;
  always @(posedge clk)
    if (rst) begin
      wptr <= 2'd0; rptr <= 2'd0;
    end else begin
      if (do_push) begin mem[wptr] <= din; wptr <= wptr + 2'd1; end
      if (do_pop)  rptr <= rptr + 2'd1;
    end
endmodule

With 2-bit pointers into 4 entries, "pointers equal" means either empty or full: after 4 pushes the write pointer wraps around to the read pointer. The designer avoided the ambiguity by calling the FIFO full one word early. That works, but it silently turns a 4-entry FIFO into a 3-entry one, against the specification.

The Properties


`ifdef FORMAL
  reg init = 1'b1;
  always @(posedge clk) init <= 1'b0;
  always @(*) if (init) assume (rst);
  // reference count, kept by the formal code from the accepted operations
  reg [2:0] n = 3'd0;
  always @(posedge clk) n <= rst ? 3'd0 : n + do_push - do_pop;
  // specification: 4 entries, full exactly at 4, empty exactly at 0
  always @(*) if (!init) assert (n <= 3'd4);
  always @(*) if (!init) assert (full == (n == 3'd4));
  always @(*) if (!init) assert (empty == (n == 3'd0));
`endif

The run fails at step 4:

After three pushes, full is high while the reference count is 3. In a simulation this looks like normal back-pressure, and nothing breaks. Only a check of the flag against the specified depth notices the lost entry.

Quick Check

Q: Would the data-order check with a tracked value find this bug?

Show Answer

No. Every word that is accepted comes out correctly; the FIFO simply accepts one word less. Only the flag rule against the specified depth sees it, which is why a FIFO needs both kinds of properties.

The Fix: One Extra Pointer Bit

The standard solution gives each pointer one more bit than the address needs. The low bits address the memory; the extra bit flips at every wrap. Equal pointers mean empty, and pointers that differ only in the extra bit mean full:


reg [2:0] wptr, rptr;                          // one extra bit tells full from empty
assign empty = (wptr == rptr);
assign full  = (wptr == {~rptr[2], rptr[1:0]});
assign dout  = mem[rptr[1:0]];
// ... mem[wptr[1:0]] <= din; wptr <= wptr + 3'd1; rptr <= rptr + 3'd1;

With it, the three assertions pass, and a meant-to-fail check assert (!full) fails at step 5, which shows the FIFO really fills up with 4 words.

Quick Check

Q: With 3-bit pointers, the write pointer is 5 and the read pointer is 1. Is the FIFO full, empty, or neither?

Show Answer

Full. In binary, 5 is 101 and 1 is 001: the low two bits are equal and the extra bit differs, so the write pointer is exactly one wrap ahead. The FIFO holds 4 words, and 5 - 1 = 4 confirms it.

The Proof

In prove mode, the fixed FIFO is UNKNOWN at first: the induction step starts from states where the reference count and the pointers disagree. The helper invariant states their relation:


// helper: the reference count equals the pointer difference
always @(*) if (!init) assert (n == wptr - rptr);

With it, k-induction proves all properties with k = 1. This helper, count equals write pointer minus read pointer, is the standard strengthening for every FIFO.

Running It in ChipVerify Lab

The example below is the pointer-only FIFO with the bug and the reference-count properties. It fails at step 4.

Common Mistakes

Mistake #1: Checking full and empty only against each other

!(full && empty) passes on this FIFO. Check each flag against the specified depth, using an independent count.

Mistake #2: Checking only data or only flags

Flag bugs and data bugs are independent: the capacity bug here leaves data intact, and the pointer bug in the data integrity article leaves the count intact. A FIFO needs both kinds of property.

Mistake #3: Using a depth too small to fill the FIFO

Full-related bugs need at least as many cycles as the FIFO has entries, plus reset and a report. Cover "full" to be sure the depth reaches it.

Practice Exercise

Challenge: Restore the Fourth Entry

Open the example and run it. Change the FIFO to use 3-bit pointers with the extra wrap bit, as shown above, and run again. Then add a meant-to-fail check that full never goes high, to confirm the FIFO can now hold 4 words.

Hint

Every use of a pointer as a memory address needs the low two bits only: mem[wptr[1:0]] and mem[rptr[1:0]]. The resets and increments become 3 bits wide.

Solution

With the 3-bit pointers, all three assertions pass at depth 10. The meant-to-fail check always @(*) if (!init) assert (!full); fails at step 5: four pushes in steps 1 to 4, and the FIFO is full in step 5 with the reference count at 4.

Open the ready-made example in the lab and run the formal check from the formal panel. The free tier allows a depth of up to 10 cycles, and PRO allows up to 100.

Summary

  • A complete FIFO check covers overflow, flags, data order, reachability and a proof.
  • A reference count kept by the formal code judges the flags independently of the design's pointers.
  • A pointer-only FIFO that called itself full one word early lost a quarter of its capacity, found at step 4.
  • An extra wrap bit on each pointer tells full from empty without wasting an entry.
  • The helper "count equals write pointer minus read pointer" makes the FIFO properties inductive.
Did you find what you were looking for?

Quiz

No quiz questions available for this article.