UART · Module 10
TX and RX FIFO Architecture
A UART FIFO is an asynchronous FIFO only when its two sides genuinely sit in different clock domains — which, for a receiver whose input was already synchronised, is usually not the case.
Chapter 10.1 treated the FIFO as a box with a depth. This chapter opens it, and it is the module's main RTL chapter: everything from 10.3 onward is written against the contract fixed here.
It starts with a question that sounds like it has an obvious answer and does not:
Does a UART FIFO need to be an asynchronous FIFO?
The word asynchronous appears in the protocol's name, the receive pin is genuinely asynchronous, and asynchronous FIFOs are a well-known structure — so the reflex is yes. §2 shows that for the architecture this curriculum has built, the answer is no, and that building one anyway imports Gray-code pointers, synchroniser chains and CDC review into a design with a single clock.
1. What the FIFO Must Store
Two instances, opposite roles, and the same storage primitive:
| RX FIFO | TX FIFO | |
|---|---|---|
| Producer | the receive engine (Module 6) | software, a bus master or DMA |
| Consumer | software, a bus master or DMA | the transmit engine (Module 7) |
| Push rate | fixed by the line — one per T_frame | bursty, whenever the producer runs |
| Pop rate | bursty | fixed by the line |
| Entry contents | data + per-character status — §8 | data only |
The producer and consumer swap sides; the queue does not change. One parameterised module serves both, and building two is duplicated verification for no benefit. The only asymmetry is the entry width, which §8 develops.
2. The Clocking Question
3. State, and Why Occupancy Is Explicit
The minimum state is a memory array, a write pointer and a read pointer. That is not quite enough, and the reason is worth seeing:
wr_ptr == rd_ptr means the queue is EMPTY
wr_ptr == rd_ptr means the queue is FULLBoth are true. After DEPTH pushes the write pointer has wrapped all the way round and equals the read pointer again, which is the identical condition as never having pushed at all. Pointer equality cannot distinguish the two.
The classical resolutions are an extra pointer bit, or a separate occupancy counter. This design keeps an explicit occupancy counter:
empty = (level == 0) full = (level == DEPTH)It costs a counter and buys three things. The full/empty ambiguity disappears; level is directly available to the thresholds of Chapter 10.3 and the flow control of Chapter 10.5, which both need occupancy rather than flags; and arbitrary depths work without the extra-bit trick, which assumes a power-of-two wrap.
The extra-pointer-bit form is equally valid and cheaper by a few flip-flops. It is the wrong choice here only because two later chapters need level anyway, so the counter would have to exist regardless.
4. The Contract
Fixed here, used unchanged by every later chapter of this module.
| Clock domain | one. clk for both sides. |
push_i / pop_i | requests, not guarantees |
pop_fire | pop_i && !empty_o |
push_fire | push_i && (!full_o || pop_fire) — §6 |
pop_data_o | the combinational head, valid when !empty_o, undefined when empty. Show-ahead / first-word fall-through. pop_i advances past it. |
empty_o | level_o == 0 |
full_o | level_o == DEPTH |
overflow_evt_o | one cycle: a push was requested and not accepted |
underflow_evt_o | one cycle: a pop was requested and not accepted |
| Reset | pointers, occupancy and events cleared. Memory is not reset — §9 |
| Wrap | explicit comparison against DEPTH-1, so any DEPTH ≥ 1 works |
The read-data contract is the one most often left ambiguous, and both forms are common. This design is show-ahead: the head is continuously visible, so the port composes directly with the valid/ready handshakes of Chapter 6.5 and Chapter 7.4 — !empty_o is a valid, pop_i is a ready. A registered-output FIFO, where pop_i is a strobe and data appears a cycle later, needs a skid buffer to present the same interface. Neither is wrong; publishing RTL without saying which is.
5. The RTL
// Synthesizable SystemVerilog — the canonical FIFO of §4.
module uart_sync_fifo #(
parameter int unsigned WIDTH = 8,
parameter int unsigned DEPTH = 16
) (
input logic clk,
input logic rst_n,
input logic push_i,
input logic [WIDTH-1:0] push_data_i,
input logic pop_i,
output logic [WIDTH-1:0] pop_data_o,
output logic empty_o,
output logic full_o,
output logic [$clog2(DEPTH+1)-1:0] level_o,
output logic overflow_evt_o,
output logic underflow_evt_o
);
// Pointers span 0 .. DEPTH-1, so they need $clog2(DEPTH) bits, guarded
// because $clog2(1) is 0 and a zero-width vector is illegal.
localparam int unsigned PTR_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH);
// Occupancy spans 0 .. DEPTH inclusive, so it must REPRESENT DEPTH — one
// more value than the pointers. Hence DEPTH+1 rather than DEPTH.
localparam int unsigned CNT_W = $clog2(DEPTH + 1);
initial begin
if (DEPTH < 1) $fatal(1, "uart_sync_fifo: DEPTH = %0d must be >= 1", DEPTH);
if (WIDTH < 1) $fatal(1, "uart_sync_fifo: WIDTH = %0d must be >= 1", WIDTH);
end
logic [WIDTH-1:0] mem [DEPTH];
logic [PTR_W-1:0] wr_ptr_q, rd_ptr_q;
logic [CNT_W-1:0] level_q;
logic push_fire, pop_fire;
assign empty_o = (level_q == '0);
assign full_o = (level_q == CNT_W'(DEPTH));
assign level_o = level_q;
assign pop_fire = pop_i && !empty_o;
// A push into a full FIFO is accepted when a pop frees an entry on the
// same cycle. Chapter 10.2 §5 argues this against the simpler
// `push_i && !full_o`, which needlessly drops a byte at the boundary.
assign push_fire = push_i && (!full_o || pop_fire);
// Show-ahead head. Combinational read of the memory array.
assign pop_data_o = mem[rd_ptr_q];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr_q <= '0;
rd_ptr_q <= '0;
level_q <= '0;
overflow_evt_o <= 1'b0;
underflow_evt_o <= 1'b0;
end else begin
overflow_evt_o <= push_i && !push_fire;
underflow_evt_o <= pop_i && !pop_fire;
if (push_fire) begin
mem[wr_ptr_q] <= push_data_i;
// EXPLICIT wrap. `wr_ptr_q + 1` relies on truncation, which
// is correct only when DEPTH is a power of two — §4.
wr_ptr_q <= (wr_ptr_q == PTR_W'(DEPTH - 1)) ? '0 : wr_ptr_q + 1'b1;
end
if (pop_fire) begin
rd_ptr_q <= (rd_ptr_q == PTR_W'(DEPTH - 1)) ? '0 : rd_ptr_q + 1'b1;
end
// ONE explicit four-way update. Two separate conditional
// increments would let the later assignment silently win — §4.
case ({push_fire, pop_fire})
2'b10: level_q <= level_q + 1'b1;
2'b01: level_q <= level_q - 1'b1;
default: level_q <= level_q; // 00 and 11 both hold
endcase
end
end
endmodule6. The Four Decisions Inside That Code
Widths are derived, and the two are different
localparam int unsigned PTR_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH);
localparam int unsigned CNT_W = $clog2(DEPTH + 1);Pointers span 0 … DEPTH−1, so $clog2(DEPTH) bits — with the guard, because $clog2(1) is 0 and a zero-width vector is illegal.
Occupancy spans 0 … DEPTH inclusive, so it must represent DEPTH — one more value than the pointers. At DEPTH = 16 that is 5 bits against the pointers' 4. Writing $clog2(DEPTH) for the counter gives 4 bits, which cannot hold 16, so full_o is never asserted and the FIFO silently overwrites itself.
This is the same +1 reasoning Chapter 9.4 §4 needed for the break threshold, and it is worth recognising as a pattern: a counter that must reach a limit needs one more value than a counter that must count up to it.
Wrap is explicit, not inherited from truncation
wr_ptr_q <= (wr_ptr_q == PTR_W'(DEPTH - 1)) ? '0 : wr_ptr_q + 1'b1;The tempting form is wr_ptr_q <= wr_ptr_q + 1'b1, relying on the register wrapping at 2^PTR_W. That equals DEPTH only when DEPTH is a power of two. At DEPTH = 5 the pointer is 3 bits and wraps at 8, so the queue silently uses eight slots of a five-slot memory — reading entries that were never written and losing entries that were.
Verified: the suite runs DEPTH = 1, 2, 3, 5, 10 and 16, and the non-power-of-two cases are where this would fail.
Occupancy updates once, in one place
case ({push_fire, pop_fire})
2'b10: level_q <= level_q + 1'b1;
2'b01: level_q <= level_q - 1'b1;
default: level_q <= level_q; // 00 and 11 both hold
endcaseThe natural-looking alternative is two independent conditionals:
// WRONG — on a simultaneous push and pop the later assignment wins and the
// level decrements. The FIFO then slowly empties itself under balanced load.
if (push_fire) level_q <= level_q + 1'b1;
if (pop_fire) level_q <= level_q - 1'b1;Both are non-blocking assignments to the same register, so the second silently overrides the first. The defect only appears when push and pop coincide, which a directed fill-then-drain test never produces — it needs randomised concurrent traffic, and the suite in §10 recorded 621 simultaneous transactions at DEPTH = 16 alone.
Push into a full FIFO is accepted when a pop frees a slot
assign pop_fire = pop_i && !empty_o;
assign push_fire = push_i && (!full_o || pop_fire);The simpler push_i && !full_o rejects a push whenever the FIFO is currently full, even if a pop is removing an entry on the same edge. The slot is free by the end of the cycle, and the byte was dropped for nothing.
7. Simultaneous Push and Pop, Enumerated
The full truth table, because Chapter 10.4 reasons about the boundary rows:
| state | push_i | pop_i | push_fire | pop_fire | level next | events |
|---|---|---|---|---|---|---|
| any | 0 | 0 | 0 | 0 | unchanged | — |
| not full | 1 | 0 | 1 | 0 | +1 | — |
| not empty | 0 | 1 | 0 | 1 | −1 | — |
| mid-range | 1 | 1 | 1 | 1 | unchanged | — |
| full | 1 | 0 | 0 | 0 | unchanged | overflow |
| full | 1 | 1 | 1 | 1 | unchanged | — |
| empty | 0 | 1 | 0 | 0 | unchanged | underflow |
| empty | 1 | 1 | 1 | 0 | +1 | underflow |
The last row is worth reading twice. An empty FIFO with a simultaneous push and pop accepts the push and rejects the pop — there is nothing to read this cycle, and the arriving byte is not forwarded combinationally to the read port. This design is show-ahead but not bypass: a byte pushed into an empty FIFO is visible on pop_data_o from the next cycle, not the same one.
That is a deliberate boundary. Adding same-cycle bypass would make pop_data_o depend combinationally on push_data_i, lengthening the path from the producer straight through to the consumer, and it buys one cycle of latency in a design where the producer is a UART delivering a byte every thousands of cycles.
8. The RX Entry Carries Its Status
Chapter 6.4 established per-character status and its publish discipline; Chapter 9.6 §4 established that the per-character form is the primitive and sticky status is derived from it. A FIFO is where that distinction becomes concrete.
If the RX FIFO stores only data, the association is destroyed. A byte and the flags describing it arrive together at the receiver's output; if the queue holds the byte for several frame times while the flags are a global sticky register, the consumer reading byte n sees status that may belong to byte n+3. Everything Chapter 6.4 built to keep status attached to its frame is undone by the buffer.
So the entry is wider than the data:
// Conceptual SystemVerilog — the RX FIFO's entry layout.
// One parameterised FIFO serves both directions; only the width differs.
localparam int unsigned RX_STATUS_W = 2; // parity, framing
localparam int unsigned RX_ENTRY_W = DATA_W + RX_STATUS_W;
// push side, from the receiver of Module 6:
assign rx_push_data = {rx_parity_err_o, rx_frame_err_o, rx_data_o};
// pop side, to the consumer:
assign {consumer_parity_err, consumer_frame_err, consumer_data} = rx_pop_data;Overrun is not in the entry, and the asymmetry is instructive: a parity or framing error describes a byte that exists, so it travels with that byte. An overrun describes a byte that does not exist — one that was dropped — so there is no entry to attach it to. Chapter 10.4 develops what that means for reporting it.
The TX entry is data only. There is no status to carry: the producer knows what it wrote, and the transmit engine has nothing to report about a byte it has not yet sent.
The register interface through which software reads a byte and its flags together is Module 13's. What this chapter fixes is that the information is available to be read atomically, which a data-only FIFO would have made impossible.
9. Reset and Memory Inference
Reset clears the pointers, the occupancy and the event outputs. It does not clear the memory.
The memory does not need clearing because its contents are unreadable while the queue is empty — pop_data_o is only meaningful when !empty_o, and after reset level_q is zero. Resetting the array would be functionally harmless and is deliberately avoided for a different reason.
Reset style affects memory inference. Most FPGA block RAMs have no reset on their storage array, so a FIFO whose memory is written inside a reset branch cannot map to block RAM and is implemented in registers or distributed RAM instead. At 16 × 10 bits that is fine; at 1,024 × 10 bits it is a large and unnecessary cost.
What depends on tool and target, and should not be promised: whether any particular depth maps to block RAM, how the read port is implemented, and whether a show-ahead read infers a distributed-RAM read or a registered one with bypass. Those vary by vendor, by device family and by the synthesis options in use. Module 12 covers implementation behaviour properly; what belongs here is the coding consequence — do not reset the array — and the reason for it.
10. Verification
The scoreboard must be structurally independent. A reference queue that consults the DUT's pointers to predict the DUT's behaviour proves nothing. This suite maintains its own queue and compares order, occupancy and flags on every cycle.
Results, across six configurations:
WIDTH | DEPTH | checks | failures | pushes | pops | simultaneous | overflow | underflow |
|---|---|---|---|---|---|---|---|---|
| 8 | 1 | 2,636 | 0 | 97 | 97 | 21 | 74 | 85 |
| 8 | 2 | 5,212 | 0 | 265 | 265 | 72 | 79 | 100 |
| 8 | 3 | 7,787 | 0 | 416 | 416 | 102 | 82 | 138 |
| 8 | 5 | 12,944 | 0 | 725 | 725 | 164 | 66 | 211 |
| 12 | 10 | 25,905 | 0 | 1,582 | 1,582 | 378 | 70 | 231 |
| 8 | 16 | 41,412 | 0 | 2,601 | 2,601 | 621 | 19 | 316 |
TOTAL: 4,805,704 checks, 0 failuresFour properties of that table matter more than the totals.
DEPTH = 1, 3, 5 and 10 are there deliberately. Three of them are not powers of two, which is where the wrap defect of §6 lives, and DEPTH = 1 is where the width guard and the full-plus-simultaneous case meet.
The simultaneous column is non-empty everywhere, because a randomised run with overlapping push and pop probabilities produces the case a directed test never does.
Overflow and underflow counts are non-zero, meaning the boundaries were genuinely exercised rather than avoided.
2,601 pushes at DEPTH = 16 is 162 pointer wraps. A FIFO can pass fill-and-drain and fail after wrapping; this run wraps repeatedly in every configuration.
Directed cases alongside the random run: reset state, push-one/pop-one, fill to full, rejected overflow push, full plus simultaneous push and pop, drain to empty, rejected underflow pop, empty plus simultaneous push and pop, and a final drain confirming the reference queue empties in step.
// Assertion — occupancy never exceeds the depth. The counter is wide enough
// to violate this, so it states a contract rather than a tautology.
property p_level_bounded;
@(posedge clk) disable iff (!rst_n) level_o <= CNT_W'(DEPTH);
endproperty
assert property (p_level_bounded);
// Assertion — the flags are exactly the occupancy extremes.
property p_empty_iff_zero;
@(posedge clk) disable iff (!rst_n) empty_o == (level_o == '0);
endproperty
assert property (p_empty_iff_zero);
property p_full_iff_depth;
@(posedge clk) disable iff (!rst_n) full_o == (level_o == CNT_W'(DEPTH));
endproperty
assert property (p_full_iff_depth);
// Assertion — the four-way level update of §6, stated as a property.
// This is what fails on the two-independent-conditionals bug.
property p_level_delta;
@(posedge clk) disable iff (!rst_n)
##1 level_o == ($past(level_o) + $past(push_fire) - $past(pop_fire));
endproperty
assert property (p_level_delta);
// Assertion — a rejected push is always reported.
property p_overflow_reported;
@(posedge clk) disable iff (!rst_n)
(push_i && !push_fire) |=> overflow_evt_o;
endproperty
assert property (p_overflow_reported);The fourth property is the one to keep. It expresses the entire occupancy contract in one line, it holds for all four combinations including the simultaneous case, and it fails immediately on the two-conditionals defect that randomised traffic is otherwise needed to expose.
11. Debugging
12. What This Means on an FPGA
Shallow FIFOs are registers or distributed RAM. Sixteen entries of ten bits is 160 flip-flops or a handful of LUT-RAMs — negligible next to a UART.
Depth is where the implementation changes character. Hundreds of entries want block RAM, and that brings the reset restriction of §9, a different read-port structure, and a possible extra cycle of read latency that the show-ahead contract would then need a bypass register to hide.
The occupancy comparator is cheap relative to the memory, which is why the counter-based form costs little even at large depths — a 10-bit comparator against a 1,024-entry RAM is noise.
Probe accepted transactions, not requests. A logic analyser capture of push_i tells you what was asked for; push_fire tells you what happened. The difference between them is exactly the overflow, and it is the measurement Chapter 10.4 needs.
Instrument the high-water mark, as Chapter 10.1 §9 argued. It costs a comparator and a register, and it is the only direct evidence of how much margin a chosen depth has on the real system.
13. Understanding Check
14. Summary
A UART FIFO is asynchronous only when its two sides genuinely sit in different clock domains. The receive pin's asynchrony was resolved by the synchroniser at the input boundary, so for this architecture a synchronous FIFO is correct — and building an asynchronous one anyway imports Gray-coded pointers, synchronisers and CDC review for a crossing that does not exist.
Pointer equality cannot distinguish full from empty, so occupancy is tracked explicitly. It costs a counter and pays for itself because 10.3 and 10.5 both need level anyway.
The contract of §4 is fixed for the whole module: requests versus accepted transactions, pop_data_o as the combinational show-ahead head, and overflow/underflow as one-cycle events.
Four decisions inside the RTL: derived widths where the counter needs one more value than the pointers; explicit wrap because truncation is only correct at power-of-two depths; one four-way occupancy update because two conditionals let the later silently win; and push accepted while full when a pop frees a slot, which is safe even at DEPTH = 1 because the read is combinational and the write is not.
An empty FIFO with a simultaneous push and pop accepts the push and rejects the pop — show-ahead, deliberately not bypass.
The RX entry carries its status, or buffering destroys the association the publish discipline was built to protect. Overrun is the exception, because it describes a byte that does not exist.
Verified across six configurations including three non-power-of-two depths: 4,805,704 checks, 0 failures, with simultaneous transactions, overflow, underflow and 162 pointer wraps all genuinely exercised.
15. What Comes Next
The FIFO reports full_o and empty_o. Both are boundaries, and by the time either is asserted the opportunity to act gracefully has passed.
Chapter 10.3 adds the early warning. It defines trigger levels and watermarks precisely, explains why the receive and transmit senses are opposite, derives a threshold from a latency budget rather than choosing one that looks reasonable, and introduces the hysteresis that stops a single threshold from chattering while occupancy sits on the boundary.
Browse the full path on the UART tutorials index. For the per-character status this entry carries, read back to Chapter 6.4.
Continue learning
Related tutorials
- Related topic
Parity Generation, Checking and Error Detection
One interval, one XOR reduction, and a detection guarantee with a sharp edge: parity catches every corruption that flips an odd number of protected bits and provably misses every even-numbered one — demonstrated, not asserted.
- Related topic
Frame Configurations: 8N1 and the Configuration Space
8N1 names three of the four choices a UART link depends on and omits the one most likely to be wrong. Reading the shorthand, computing what each configuration costs in intervals and line time, and why a longer frame spends timing margin as well as throughput.
- Related topic
Integer Dividers and Baud-Rate Error
The ratio is a fraction and a counter holds an integer, so rounding is a design decision with a measurable cost. Three policies, the actual rate each produces, and why the error belongs in units of a bit period rather than as a bare percentage.
- Related topic
Fractional Baud Generation
An accumulator keeps the remainder an integer divider discards, lengthening an occasional interval so the average converges on the ideal. That improves long-run accuracy by orders of magnitude while making individual intervals unequal — and a UART receiver never measures the average.
Where this fits
Part of the UART curriculum.
