Designs and Projects
Synchronous FIFO in Verilog
Build and self-check a four-entry single-clock FIFO with registered reads, full and empty protection, simultaneous read/write behavior, and explicit memory-mapping limits.
5 min read · Updated
A synchronous FIFO is a first-in, first-out queue whose read and write control use the same clock. It stores words in an array, advances separate read and write pointers only when an operation is accepted, and uses occupancy state to prevent reading an empty queue or writing a full one.
Prerequisites: clocked registers, non-blocking assignments, arrays of registers, pointer wraparound, equality comparison, and a self-checking testbench. This article is intentionally single-clock. It is not an asynchronous FIFO and it is not a clock-domain-crossing solution.
Exact behavior of this example
Ambiguity at the boundaries is where FIFO examples become dangerous, so this design states its policy before showing code:
- A write is accepted only when
full == 0. - A read is accepted only when
empty == 0. - A simultaneous accepted read and write keeps
countunchanged. - A read returns data through the registered
data_outon the accepting rising edge. - A write requested while full is rejected even if a read is requested on that edge.
- A read requested while empty is rejected even if a write is requested on that edge.
Those last two conservative rules avoid same-address read/write collision semantics at the full and empty boundaries. More permissive production FIFOs can support replacement-on-full or first-word fall-through, but only with an explicit, verified contract.
Predict before running
The test writes A1 B2 C3 D4 and rejects a write-only E5 while full. It then requests read and write together while still full. Before reading the code, complete this state trace using the boundary policy above. “Returned” means the registered data_out immediately after the edge.
| Situation | Count before | Write request | Read request | Accept write? | Accept read? | Returned | Count after |
|---|---|---|---|---|---|---|---|
| Full boundary | 4 | E5 |
yes | ? | ? | ? | ? |
| Next edge | 3 | E5 |
yes | ? | ? | ? | ? |
| Empty boundary | 0 | F6 |
yes | ? | ? | ? | ? |
| Recovery | 1 | no | yes | ? | ? | ? | ? |
Finally, predict the drain order after the first two rows.
`timescale 1ns/1ps
module sync_fifo4 (
input wire clk,
input wire rst,
input wire write_en,
input wire read_en,
input wire [7:0] data_in,
output reg [7:0] data_out,
output wire full,
output wire empty,
output reg [2:0] count
);
reg [7:0] memory [0:3];
reg [1:0] write_ptr;
reg [1:0] read_ptr;
wire accept_write = write_en && !full;
wire accept_read = read_en && !empty;
assign full = (count == 3'd4);
assign empty = (count == 3'd0);
always @(posedge clk) begin
if (rst) begin
write_ptr <= 2'd0;
read_ptr <= 2'd0;
count <= 3'd0;
data_out <= 8'h00;
end else begin
if (accept_write) begin
memory[write_ptr] <= data_in;
write_ptr <= write_ptr + 2'd1;
end
if (accept_read) begin
data_out <= memory[read_ptr];
read_ptr <= read_ptr + 2'd1;
end
case ({accept_write, accept_read})
2'b10: count <= count + 3'd1;
2'b01: count <= count - 3'd1;
default: count <= count;
endcase
end
end
endmodule
module tb;
reg clk, rst, write_en, read_en;
reg [7:0] data_in;
wire [7:0] data_out;
wire full, empty;
wire [2:0] count;
sync_fifo4 dut (
.clk(clk), .rst(rst), .write_en(write_en), .read_en(read_en),
.data_in(data_in), .data_out(data_out), .full(full), .empty(empty), .count(count)
);
always #5 clk = ~clk;
task transfer;
input do_write;
input do_read;
input [7:0] value;
begin
@(negedge clk);
write_en = do_write;
read_en = do_read;
data_in = value;
@(posedge clk); #1;
end
endtask
task require_read;
input [7:0] expected;
begin
if (data_out !== expected) begin
$display("FAIL READ expected=%02h actual=%02h", expected, data_out);
$finish;
end
end
endtask
initial begin
clk = 1'b0;
rst = 1'b1;
write_en = 1'b0;
read_en = 1'b0;
data_in = 8'h00;
repeat (2) @(posedge clk);
#1;
$display("RESET empty=%0b full=%0b count=%0d", empty, full, count);
if (!empty || full || count !== 3'd0) begin $display("FAIL RESET"); $finish; end
@(negedge clk);
rst = 1'b0;
transfer(1'b1, 1'b0, 8'hA1);
transfer(1'b1, 1'b0, 8'hB2);
transfer(1'b1, 1'b0, 8'hC3);
transfer(1'b1, 1'b0, 8'hD4);
$display("FULL count=%0d empty=%0b full=%0b", count, empty, full);
if (!full || empty || count !== 3'd4) begin $display("FAIL FULL"); $finish; end
transfer(1'b1, 1'b0, 8'hE5);
$display("OVERFLOW_BLOCKED count=%0d", count);
if (count !== 3'd4) begin $display("FAIL OVERFLOW"); $finish; end
transfer(1'b1, 1'b1, 8'hE5);
require_read(8'hA1);
$display("FULL_BOTH read=%02h write_blocked count=%0d", data_out, count);
if (count !== 3'd3) begin $display("FAIL FULL BOUNDARY"); $finish; end
transfer(1'b1, 1'b1, 8'hE5);
require_read(8'hB2);
$display("SIMULTANEOUS read=%02h wrote=e5 count=%0d", data_out, count);
if (count !== 3'd3) begin $display("FAIL SIMULTANEOUS"); $finish; end
transfer(1'b0, 1'b1, 8'h00);
require_read(8'hC3);
$display("DRAIN data=%02h", data_out);
transfer(1'b0, 1'b1, 8'h00);
require_read(8'hD4);
$display("DRAIN data=%02h", data_out);
transfer(1'b0, 1'b1, 8'h00);
require_read(8'hE5);
$display("DRAIN data=%02h count=%0d empty=%0b", data_out, count, empty);
transfer(1'b1, 1'b1, 8'hF6);
require_read(8'hE5);
$display("EMPTY_BOTH read_blocked wrote=f6 count=%0d", count);
if (empty || full || count !== 3'd1) begin $display("FAIL EMPTY BOUNDARY"); $finish; end
transfer(1'b0, 1'b1, 8'h00);
require_read(8'hF6);
$display("RECOVER data=%02h count=%0d empty=%0b", data_out, count, empty);
transfer(1'b0, 1'b1, 8'h00);
require_read(8'hF6);
$display("UNDERFLOW_BLOCKED data=%02h count=%0d", data_out, count);
if (!empty || count !== 3'd0) begin $display("FAIL UNDERFLOW"); $finish; end
$display("PASS");
$finish;
end
endmoduleExpected output — reveal after you predict
RESET empty=1 full=0 count=0
FULL count=4 empty=0 full=1
OVERFLOW_BLOCKED count=4
FULL_BOTH read=a1 write_blocked count=3
SIMULTANEOUS read=b2 wrote=e5 count=3
DRAIN data=c3
DRAIN data=d4
DRAIN data=e5 count=0 empty=1
EMPTY_BOTH read_blocked wrote=f6 count=1
RECOVER data=f6 count=0 empty=1
UNDERFLOW_BLOCKED data=f6 count=0
PASSCompare your boundary trace
| Situation | Count before | Accept write? | Accept read? | Returned | Count after |
|---|---|---|---|---|---|
| Full boundary | 4 | no | yes | A1 |
3 |
| Next edge | 3 | yes (E5) |
yes | B2 |
3 |
| Empty boundary | 0 | yes (F6) |
no | unchanged (E5) |
1 |
| Recovery | 1 | no | yes | F6 |
0 |
The final drain order is C3 D4 E5. Pointer values wrap because they are two bits wide; occupancy does not come from comparing the pointers alone, because equal pointers can mean either empty or full. The separate 3-bit count disambiguates those states.
Hardware it describes
The RTL describes a four-word storage array, two 2-bit pointer registers, a 3-bit occupancy register, increment/decrement logic, equality comparisons for full and empty, control muxing, and a separate data_out register. Conservative Yosys prep recognizes the array as a generic memory cell; the simulation proves that this RTL updates data_out only on an accepted rising-edge read. Those are deliberately separate claims: the test does not inspect or claim a clocked read-port mode for a target memory primitive.
Simulation, synthesis, and implementation are different claims
- Simulation: checks reset, fill order, write-only full protection, simultaneous read/write at full, an accepted simultaneous read/write away from a boundary, complete FIFO order, simultaneous read/write at empty, read-only empty protection, and registered output behavior.
- Generic synthesis: conservative Yosys
preprequires a generic memory cell plus sequential/control logic, and a separate completesynthscript must finish. This proves recognized synthesizable storage and a completed generic flow—not a specific memory read-port mode or block-RAM inference on a device. - Implementation: a vendor tool may map this tiny array to flip-flops, LUT/distributed RAM, block RAM, or other resources depending on size, read mode, target, constraints, and tool settings. Same-address collision and reset support also vary by primitive and mapping flow.
Limitations
- Single clock only. Do not use this design to cross clock domains.
- Fixed width and depth. A reusable parameterized FIFO needs power-of-two checks, derived pointer widths, and wider verification.
- Conservative boundary policy: no write-through while full and no fall-through while empty.
- No
almost_full,almost_empty, packet boundaries, error flag, flush, backpressure protocol, or asynchronous reset. - The testbench is strong for this four-entry contract but is not formal proof and does not explore arbitrary long sequences.
Sources and verification
- Yosys documentation — memory handling and target-dependent mapping
- AMD Vivado Synthesis Guide — memory inference capabilities (vendor-specific example of why final mapping is a target-tool claim)
- IEEE 1364-2005 — Verilog arrays and procedural semantics
Example provenance: SkillLift Labs authored the FIFO, boundary policy, self-checking testbench, diagram, and timing explanation. tests/scripts/tutorial-verified-examples.test.ts compiles and simulates the complete example, requires exact published output, checks an executable queue model including both simultaneous boundary cases, and reruns through the real article-to-playground source split. tests/scripts/tutorial-tut8-synthesis.test.ts also compiles with warnings, inspects a conservative Yosys prep netlist for generic memory plus sequential/control cells, and separately requires complete generic synth. No asynchronous behavior, metastability, target memory read mode, block-RAM mapping, board result, or physical timing was tested or claimed.