CASE STUDY / DIGITAL DESIGN / VERIFICATION
Small queue. Clear contract.
A parameterized synchronous FIFO in SystemVerilog, built to explore the corner cases behind seemingly simple hardware buffering.
THE PROJECT IN ONE MINUTE
My engineering work
Implemented a parameterized single-clock FIFO and a separate shift-array reference scoreboard to exercise reset, full/empty boundaries and simultaneous reads/writes.
Inspect first
Four passing configurations, including 800 fixed-seed transactions per setup; the page links directly to SystemVerilog RTL, testbench, simulator log and raw VCD.
Verification boundary
Simulation of a defined single-clock contract. Generic Yosys synthesis is documented below; device timing, FPGA deployment, and CDC remain future work.
What happens at the boundaries?
FIFO buffers show up between producers and consumers throughout digital systems. The happy path is obvious: write data in, read it out in order. What matters just as much is what happens when the queue is already full or empty, or both requests arrive on the same clock.
I kept this experiment intentionally narrow: one clock domain, a simple registered read interface and testable rules for every boundary case. That leaves room to reason about the design rather than burying the behaviour inside a larger system.
Define what each clock is allowed to do.
The interface accepts requests wr_en and rd_en at each rising edge. The output rd_data updates only for an accepted read. full, empty and count expose the occupancy after the clock edge.
Don't invent data.
A read when empty is blocked. If a write also arrives, only the write proceeds—this FIFO has no first-word fall-through.
Keep the stream moving.
A write when full is blocked, except when a valid read happens on that edge. Both may then proceed, preserving full occupancy.
Clear state, not storage.
Active-low asynchronous reset clears pointers, occupancy and registered output. Memory contents are not reset; zero occupancy prevents stale reads.
This contract is deliberately specific. It is not AXI-Stream, asynchronous CDC buffering or a general-purpose bus-ready/valid protocol.
Make acceptance explicit.
Two combinational conditions decide what happens on the next edge. A read is accepted only if the FIFO is nonempty; a write is accepted if there is space, or if the same edge removes an existing word. This avoids special-case bookkeeping inside the occupancy counter.
wire take_read = rd_en && !empty;
wire take_write = wr_en && (!full || take_read);
case ({take_write, take_read})
2'b10: count <= count + 1'b1;
2'b01: count <= count - 1'b1;
default: count <= count;
endcase
The pointers wrap at DEPTH-1 instead of relying on binary overflow. That is why a three-entry instance works as well as an eight-entry instance.
Registered read data
Read output changes after a valid clocked pop and otherwise holds its last value. The design is not combinational-first-word fall-through.
Simultaneous transactions
On full, an accepted read and write replace the oldest word in one edge; an NBA-registered read returns the old word rather than the incoming data.
Parameterization
Pointer and occupancy widths derive from depth. The simulation regression covers depths 1, 3, 4 and 8 and a wider 16-bit data path.
Don't just drive signals. Check outcomes.
The testbench keeps an independent shift-array queue, unlike the RTL's circular pointer storage. After every transaction it checks the registered output, occupancy and flags. Directed cases exercise both empty and full boundaries; each configuration also runs 800 fixed-seed pseudo-random transactions.
4 / 4 configurations passed
Depths 1, 3 and 4 with eight-bit data, plus depth 8 with 16-bit data. Each test included reset, underflow/overflow prevention, simultaneous operations, wraparound and randomized push/pop traffic. These are simulation results, not FPGA tests.
What happens when a full queue is written and read on the same edge?
- Contract → RTL
At full occupancy, a standalone write is rejected. If a read is accepted on that edge,
take_write = wr_en && (!full || take_read)allows the incoming word to replace the oldest one while the count stays full. The read returns the previous head, not the new word. - Stimulus → independent check
The public bench first fills the queue, tries an overflow, then sends simultaneous write/read requests. Its separate shift-array reference model checks output, occupancy and flags after the edge; it does not copy the DUT's circular-pointer algorithm.
- Recorded outcome → boundary
The saved depth-4, eight-bit run reports PASS, 823 checked cycles, 385 accepted writes, 384 reads, 38 blocked writes and 60 blocked reads. These are finite simulator observations under one configuration, not a hardware throughput result.
RTL acceptance logic ↗Stimulus and scoreboard ↗Actual four-run log ↗
Read the captured edge, not just the PASS line. In the linked depth-4 waveform, sample 09 requests a write of EE while full and without a read; count stays four. At sample 10, simultaneous read and write of F1 keep count four while rd_data=01, the previous head. Samples 11–14 read 02, 03, 04, F1 in order. This is a cycle-sampled simulator trace, not measured silicon timing.
bash projects/fifo-rtl/run_tests.sh
The public source + testbench bundle ↓ preserves the original project paths and runner. Unzip and run the command from the extracted root with Icarus Verilog available. The archived log was generated from these files; a future new run may differ in non-functional simulator diagnostics.
One missing condition. One dropped write.
This controlled mutation exercise makes a real failure observable: remove the simultaneous-read exception from the existing write-acceptance rule, then run the same independent testbench. It illustrates diagnosis and regression protection; it is not a claim of a historical bug I discovered in production.
cycle=10 DEPTH=4
expected count/full/empty/data=4/1/0/1
actual=3/0/0/1Read the failing and passing runs ↗ · Download the failing waveform ↓ · Download the reproduction script ↓
python3 scripts/reproduce_fifo_debug.py /tmp/fifo-debug
Through a real synthesis flow.
Yosys 0.52 successfully elaborated and synthesized the same FIFO RTL for all four tested configurations. check -assert reported zero structural problems. The -noabc run produces generic logic cells, so these counts are not FPGA LUT counts, clock rates, area estimates, or timing closure.
| Depth | Data width | Generic cells |
|---|---|---|
| 1 | 8 | 29 |
| 3 | 8 | 113 |
| 4 | 8 | 150 |
| 8 | 16 | 480 |
Read actual Yosys output ↗ · Download measured counts ↓ · Download the synthesis runner ↓
bash projects/fifo-rtl/run_synthesis.sh /tmp/fifo-synthesis
The next step is target-device mapping, constraints, and board verification. This completes a small synthesis milestone while keeping physical performance claims separate.
Correct in simulation isn't the finish line.
This is a single-clock register-array FIFO, verified in simulation and checked through generic synthesis. Hardware timing, SRAM inference, power, formal proof, FPGA deployment, and cross-clock safety remain outside its demonstrated scope. The passing testbench supports the documented interface contract under the tested cases and parameters; it does not prove correctness in every environment.
Next steps are target-device mapping and timing constraints, followed by a board demonstration. Ready/valid integration and formal properties are further design directions.
The lesson is straightforward: a hardware block is only as convincing as its precisely stated behaviour and the checks supporting it.
Discuss the design