Sign in on the Premium hub to save lesson progress. This lesson is open to read so the page is a complete article, not an empty login wall.
Lesson 2.2

Complete sync FIFO RTL

A synthesizable, parameterized sync FIFO using extra-MSB pointers. Copy it into the simulator and drive a small testbench.

1

Module โ€” extra-MSB pointers

sync_fifo.v
module sync_fifo #(
  parameter WIDTH  = 8,
  parameter DEPTH  = 16,              // must be power of 2
  parameter ADDR_W = $clog2(DEPTH)
)(
  input  wire             clk,
  input  wire             rst_n,
  input  wire             wr_en,
  input  wire [WIDTH-1:0] wdata,
  output wire             full,
  input  wire             rd_en,
  output reg  [WIDTH-1:0] rdata,
  output wire             empty
);
  localparam PTR_W = ADDR_W + 1;

  reg [WIDTH-1:0]  mem [0:DEPTH-1];
  reg [PTR_W-1:0]  wptr, rptr;

  wire wr_fire = wr_en && !full;
  wire rd_fire = rd_en && !empty;

  assign empty = (wptr == rptr);
  assign full  = (wptr == {~rptr[PTR_W-1], rptr[PTR_W-2:0]});

  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) wptr <= {PTR_W{1'b0}};
    else if (wr_fire) wptr <= wptr + 1'b1;
  end

  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) rptr <= {PTR_W{1'b0}};
    else if (rd_fire) rptr <= rptr + 1'b1;
  end

  always @(posedge clk) begin
    if (wr_fire) mem[wptr[ADDR_W-1:0]] <= wdata;
  end

  always @(posedge clk) begin
    if (rd_fire) rdata <= mem[rptr[ADDR_W-1:0]];
  end
endmodule

Why wptr[ADDR_W-1:0] for the RAM address: the extra MSB is only a wrap counter. The memory has DEPTH rows.

Why rdata is a register: standard (not FWFT) FIFO โ€” data appears the cycle after a successful read. FWFT would mux combinational RAM read; that is a different timing contract.

2

Each always block, one job

  1. Write pointer increments only on wr_fire โ€” never on a blocked wr_en.
  2. Read pointer same with rd_fire.
  3. Memory write is synchronous, address = write pointer (truncated).
  4. Memory read is captured into rdata on rd_fire.
  5. Flags are combinational from current pointers so a write on the last free slot sees full in time if you compute full from next pointer โ€” see trap below.
Same-cycle full

The listing uses full from current pointers. A write that fills the last slot updates wptr on the clock edge; full rises after that edge. That is correct: the write that filled it was legal. The next cycle full=1 blocks another write. Do not also write when full is already 1.

3

Minimal testbench

tb_sync_fifo.v
module tb;
  reg clk=0, rst_n=0, wr_en=0, rd_en=0;
  reg [7:0] wdata=0;
  wire [7:0] rdata;
  wire full, empty;
  always #5 clk = ~clk;
  sync_fifo #(.WIDTH(8), .DEPTH(4)) dut (
    .clk(clk), .rst_n(rst_n),
    .wr_en(wr_en), .wdata(wdata), .full(full),
    .rd_en(rd_en), .rdata(rdata), .empty(empty)
  );
  integer i;
  initial begin
    #12 rst_n = 1;
    // fill
    for (i=0;i<4;i=i+1) begin
      @(posedge clk); wr_en<=1; wdata<=i+8'hA0;
    end
    @(posedge clk); wr_en<=0;
    if (!full) $display("FAIL expected full");
    // drain
    for (i=0;i<4;i=i+1) begin
      @(posedge clk); rd_en<=1;
    end
    @(posedge clk); rd_en<=0;
    if (!empty) $display("FAIL expected empty");
    else $display("ALL_TESTS_PASSED");
    $finish;
  end
endmodule
Paste both modules in the simulator โ†’
4

Expected fill/drain waveform

5

Lint, reset, and inferred RAM

The mem[] write is clocked and not reset โ€” that is what you want for a BRAM/SRAM inference. Resetting every RAM location is expensive and usually not required; only pointers and rdata (if you care) are reset. Some linters flag unread memory after reset: document that occupancy is empty so old RAM data is unreachable.

Split the pointer always blocks so write-enable of RAM is a simple if (wr_fire). Combining RAM and pointers in one block can prevent BRAM inference on FPGA. Async reset on pointers is fine; do not put async reset on the RAM write process.

6

Scoreboard that will carry to the async FIFO

Keep a SystemVerilog queue in the testbench: q.push_back(wdata) on wr_fire, compare q.pop_front() with rdata on the cycle it becomes valid (one cycle after rd_fire for this RTL). Assert q.size() <= DEPTH always. That golden model does not care whether clocks are sync or async โ€” only that fires are the commits. Lesson 2.3 adds overflow/underflow language on top of this scoreboard.

Q

Frequently asked questions

Why is DEPTH a power of two in this listing?

Addressing uses the low ADDR_W bits of a wrapping binary pointer. Non-power-of-two depths need a wrap mux or a different pointer coding. The async Gray FIFO later also requires power-of-two depth.

Why is rdata a register?

This is a standard FIFO: memory is sampled on rd_fire and appears the following cycle. FWFT would present data combinationally from the read address.

When does full rise after filling the last slot?

The write that fills DEPTH is legal because full was still 0. After that wr_clk/clk edge, pointers compare as full, so the next cycle blocks wr_fire.

What must a self-checking TB prove?

Push DEPTH unique beats, see full, refuse extra writes, pop them in order, see empty, and never see X on rdata after a legal read.

Key takeaways

  • Gate enables with flags. Never increment a pointer on a blocked request.
  • Address RAM with the low bits only.
  • This module is the same-clock cousin of the async FIFO. Dual-clock version keeps two copies of this idea, plus Gray CDC.