UART · Module 10
Thresholds, Watermarks and Trigger Levels
Full and empty are boundaries, not warnings. A trigger level is chosen by comparing the headroom it leaves against the consumer's worst-case latency — and against the interrupt load it costs.
Chapter 10.2 produced full_o and empty_o. Both are exact and both are too late to be useful on their own.
full_o asserting means the next arriving character has nowhere to go. empty_o asserting on the transmit side means the line has already gone idle. Neither is a warning; each is a report that the opportunity to act gracefully has already passed.
What a design needs is an early warning, and the occupancy counter Chapter 10.2 §3 kept makes it almost free. The engineering is not in the comparator — it is in choosing the number:
A trigger level is the point where remaining headroom still exceeds the consumer's worst-case service latency, while not costing more service events than the system can afford.
Those two requirements pull in opposite directions, and §5 makes the trade quantitative.
1. Boundaries Versus Warnings
| Signal | Asserts when | Useful for |
|---|---|---|
empty_o | nothing to read | knowing the queue is drained |
full_o | nothing can be stored | reporting, not preventing |
rx_trigger_o | occupancy has risen to a chosen level | scheduling service before full |
tx_trigger_o | occupancy has fallen to a chosen level | scheduling refill before empty |
The two flags describe the states a design is trying to avoid; the two triggers describe the approach to them. A system that services its UART only on full_o has already lost a byte by the time it runs — Chapter 10.4 is about that moment, and this chapter is about not reaching it.
2. Terminology, Stated Rather Than Assumed
Vendor documentation uses these words inconsistently, and a design review that assumes shared meaning goes wrong quickly. The model used throughout Module 10:
| Term | Meaning here |
|---|---|
| trigger level | a configured occupancy value compared against level_o |
rx_trigger_o | level_o >= RX_TRIGGER — enough has arrived to be worth servicing |
tx_trigger_o | level_o <= TX_TRIGGER — few enough remain to be worth refilling |
| watermark | a synonym for trigger level; high and low watermarks are the two thresholds of a hysteresis pair |
| almost-full / almost-empty | trigger levels expressed as a distance from the boundary rather than an absolute occupancy |
almost_full and full are not the same signal and conflating them is the error §1 is about. almost_full is a design-chosen warning; full is a physical fact about the queue.
3. The Senses Are Opposite, and the Reason Is Structural
This looks like an arbitrary convention and is not.
RX FIFO: producer = the line (fixed rate) consumer = software (bursty)
the DANGER is occupancy RISING -> trigger when level >= T
TX FIFO: producer = software (bursty) consumer = the line (fixed rate)
the DANGER is occupancy FALLING -> trigger when level <= TEach trigger points at the boundary that FIFO can actually hit. An RX FIFO drains only when software runs, so it fills toward full_o and the warning must fire on the way up. A TX FIFO drains continuously at the line rate whether or not software runs, so it empties toward empty_o and the warning must fire on the way down.
The consequence for a UART IP is that the two thresholds mean different things to the same software: the RX trigger says "come and collect", and the TX trigger says "come and refill". A driver typically handles both in one interrupt and must not treat them symmetrically.
4. The RTL
// Synthesizable SystemVerilog — threshold logic. Purely combinational
// on the occupancy counter of Chapter 10.2 §3.
module uart_fifo_watermark #(
parameter int unsigned DEPTH = 16,
parameter int unsigned RX_TRIGGER = 12, // RX: service when level >= this
parameter int unsigned TX_TRIGGER = 4 // TX: refill when level <= this
) (
input logic [$clog2(DEPTH+1)-1:0] level_i,
output logic rx_trigger_o,
output logic tx_trigger_o
);
localparam int unsigned CNT_W = $clog2(DEPTH + 1);
initial begin
if (RX_TRIGGER > DEPTH)
$fatal(1, "uart_fifo_watermark: RX_TRIGGER %0d exceeds DEPTH %0d", RX_TRIGGER, DEPTH);
if (TX_TRIGGER > DEPTH)
$fatal(1, "uart_fifo_watermark: TX_TRIGGER %0d exceeds DEPTH %0d", TX_TRIGGER, DEPTH);
if (RX_TRIGGER == 0)
$fatal(1, "uart_fifo_watermark: RX_TRIGGER of 0 is always asserted");
end
// The senses are OPPOSITE because the two FIFOs are filled and drained
// from opposite ends — Chapter 10.3 §3.
assign rx_trigger_o = (level_i >= CNT_W'(RX_TRIGGER));
assign tx_trigger_o = (level_i <= CNT_W'(TX_TRIGGER));
endmoduleTwo comparators against a counter that already exists. That is the whole cost, and it is why the counter-based FIFO of Chapter 10.2 §3 pays for itself — a pointer-plus-extra-bit FIFO would have to reconstruct occupancy here.
RX_TRIGGER == 0 is rejected, because level >= 0 is always true and the trigger would be permanently asserted — a configuration that produces an interrupt storm rather than an error, and is therefore worth catching at elaboration.
TX_TRIGGER == 0 is legal and means trigger only when completely empty, which is a defensible if latency-hostile choice. The asymmetry in the guards is deliberate: one value is meaningless and the other is merely aggressive.
5. Deriving the Trigger Level
The headroom a trigger leaves is the distance to the boundary, in time:
headroom_entries = DEPTH − RX_TRIGGER
T_headroom = headroom_entries × T_frame [s]The requirement is that service begins at the trigger and must complete before the queue fills:
T_headroom ≥ worst-case service latency + marginMachine-computed for a 16-entry RX FIFO at 115,200 baud 8N1, where T_frame = 86.8056 µs:
RX_TRIGGER | headroom entries | T_headroom | fits 100 µs? | fits 250 µs? | fits 500 µs? |
|---|---|---|---|---|---|
| 4 | 12 | 1041.67 µs | yes | yes | yes |
| 8 | 8 | 694.44 µs | yes | yes | yes |
| 10 | 6 | 520.83 µs | yes | yes | yes |
| 12 | 4 | 347.22 µs | yes | yes | NO |
| 14 | 2 | 173.61 µs | yes | NO | NO |
| 15 | 1 | 86.81 µs | NO | NO | NO |
A trigger of 15 in a 16-entry FIFO leaves exactly one frame time — the same deadline the single holding register of Chapter 9.3 had. The buffer is present and the trigger has discarded its entire benefit.
The other half of the trade
Lowering the trigger buys headroom and costs service events, because each one covers fewer bytes:
RX_TRIGGER | bytes per service event | events/s at 115,200 baud |
|---|---|---|
| 4 | 4 | 2,880 |
| 8 | 8 | 1,440 |
| 12 | 12 | 960 |
Three times the interrupt rate for three times the headroom. On a system where an interrupt costs a few microseconds of overhead, 2,880 per second is a measurable fraction of a core; on one where the handler is expensive or the core is shared, it may not be affordable.
Rising occupancy against trigger and full
17 cycles6. Hysteresis
A single threshold has a problem the table above hides: occupancy does not rise monotonically. Under steady traffic with a consumer keeping roughly in step, it hovers — and a comparator against one value chatters, asserting and deasserting every time the level crosses the boundary in either direction.
For an interrupt that is merely noisy. For flow control it is worse: Chapter 10.5 drives a physical pin from a threshold, and a chattering pin tells the far end to stop and start repeatedly, destroying throughput for no reason.
The fix is two thresholds with the decision held between them:
assert when level >= HIGH
deassert when level <= LOW with LOW < HIGH
between: HOLD the current decisionThis chapter does not implement hysteresis for the interrupt triggers, because an interrupt that re-asserts is usually harmless and the extra state is not obviously worth it. Chapter 10.5 does implement it for flow control, where the cost of chatter is real, and the RTL there is the pattern above.
Whether an interrupt trigger needs hysteresis depends on how it is consumed. A level-sensitive interrupt that is cleared by draining the FIFO naturally de-asserts once; an edge-triggered one that re-fires on every crossing may need it. That is a property of the interrupt architecture rather than of the FIFO — Module 13's territory.
7. Verification
Test both sides of each threshold, one entry apart. A trigger tested only well above and well below its level passes with an off-by-one.
From this module's simulation:
| Stimulus | Expected | Result |
|---|---|---|
| level 0 | rx_trigger low, tx_trigger high | pass |
level 4 (== TX_TRIGGER) | tx_trigger high | pass |
| level 5 (one above) | tx_trigger low | pass |
level 11 (one below RX_TRIGGER) | rx_trigger low | pass |
level 12 (== RX_TRIGGER) | rx_trigger high | pass |
level 16 (== DEPTH) | rx_trigger high | pass |
The level-0 row is the positive control for the TX sense, and it is the one that catches a copy-paste of the RX comparison: an empty TX FIFO must assert its trigger, and a >= there would leave it silent exactly when refill is most needed.
// Assertion — the triggers are exactly their comparisons. Trivial to state
// and worth stating, because the SENSES are easy to transpose.
property p_rx_trigger_sense;
@(posedge clk) rx_trigger_o == (level_i >= CNT_W'(RX_TRIGGER));
endproperty
assert property (p_rx_trigger_sense);
property p_tx_trigger_sense;
@(posedge clk) tx_trigger_o == (level_i <= CNT_W'(TX_TRIGGER));
endproperty
assert property (p_tx_trigger_sense);
// Assertion — a full FIFO always asserts the RX trigger. Any legal
// RX_TRIGGER is at most DEPTH, so this must hold, and it fails if the
// comparison was written with > instead of >=.
property p_full_implies_rx_trigger;
@(posedge clk) (level_i == CNT_W'(DEPTH)) |-> rx_trigger_o;
endproperty
assert property (p_full_implies_rx_trigger);Verify the headroom claim, not just the comparator. The useful system test drives characters at the line rate, delays the consumer by the worst-case latency, and asserts that no byte is lost. That tests the choice of threshold rather than the logic implementing it, and it is the test that would have caught the failure described in §5's callout.
8. Debugging
9. What This Means on an FPGA
Two comparators against an existing counter. At DEPTH = 16 that is two 5-bit comparisons — a handful of LUTs, and nothing that affects timing.
Make the trigger runtime-configurable if the baud rate is. The correct threshold depends on T_frame, so a UART whose rate is software-selectable has a threshold that should move with it. That is a register rather than a parameter, and the comparator is the same either way — Module 13 owns the register, but the decision to make the value an input rather than a parameter belongs to whoever instantiates this block.
Probe the trigger with the occupancy. The pair shows both whether the threshold is being reached and how much headroom remained at the moment it was — which the trigger alone cannot say.
Record the minimum headroom ever observed. A companion to Chapter 10.1 §9's high-water mark: DEPTH − max(level) is the margin the chosen threshold actually had, measured rather than assumed, and it is the number that says whether a design is comfortable or one interrupt away from losing data.
10. Understanding Check
11. Summary
full_o and empty_o are boundaries, not warnings — each reports that the chance to act gracefully has passed. A trigger level fires on the approach.
The senses are opposite for a structural reason: an RX FIFO fills toward full so its trigger is level >= T, and a TX FIFO empties toward empty so its trigger is level <= T. Writing both the same way leaves one silent exactly when it is needed.
The trigger is derived from headroom in time: T_headroom = (DEPTH − T) × T_frame. At 16 entries and 115,200 baud, a trigger of 12 leaves 347.22 µs and a trigger of 15 leaves 86.81 µs — the single-register deadline, with the buffer's entire benefit discarded.
Lowering the trigger costs service events: 2,880/s at T=4 against 960/s at T=12. Three times the headroom for three times the interrupt rate.
Most designs choose the trigger from the interrupt budget alone, because worst-case latency is the number nobody has. The result works in testing and loses bytes under load. When no trigger satisfies both constraints, the answer is a deeper FIFO — depth raises both, the trigger only trades them.
Headroom is in entries; the requirement is in time. A threshold sized at one baud has T_frame-proportional headroom at another, which is why a rate-configurable UART wants a configurable trigger.
Hysteresis is not implemented here because interrupt chatter is cheap; Chapter 10.5 implements it because pin chatter is not.
12. What Comes Next
A trigger is a warning, and a warning can be ignored — by software that is genuinely too slow, or by a burst larger than any headroom.
Chapter 10.4 takes the moment the warning fails. It defines overflow against the FIFO contract precisely, states which byte is lost and why that is the same policy the one-entry receiver chose, distinguishes the FIFO's internal overflow_evt_o from the receiver's rx_overrun_o — two events describing the same loss at different points — and confronts the limit that motivates the rest of the module: ready = !full backpressures a local producer and cannot reach the wire.
Browse the full path on the UART tutorials index. For the occupancy counter these thresholds compare against, read back to Chapter 10.2.
Continue learning
Related tutorials
- Related topic
Why UART IP Needs FIFOs
A single holding register makes the service deadline one frame time. Depth multiplies that deadline — and buys time rather than bandwidth, which is the distinction that decides whether a buffer helps at all.
- Related topic
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.
- Related topic
Overflow, Overrun and Backpressure
At the boundary the buffer is full and the line does not stop. Two events describe the same loss at different points, and the ready signal that backpressures a local producer has no path to the far end.
- Related topic
RTS/CTS Hardware Flow Control
Out-of-band backpressure with a latency. The far end may be mid-frame, may have another committed, and takes time to notice — so the threshold must be derived from the bytes still in flight.
Where this fits
Part of the UART curriculum.
