Skip to content
VLSI Mentor

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

SignalAsserts whenUseful for
empty_onothing to readknowing the queue is drained
full_onothing can be storedreporting, not preventing
rx_trigger_ooccupancy has risen to a chosen levelscheduling service before full
tx_trigger_ooccupancy has fallen to a chosen levelscheduling 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:

TermMeaning here
trigger levela configured occupancy value compared against level_o
rx_trigger_olevel_o >= RX_TRIGGERenough has arrived to be worth servicing
tx_trigger_olevel_o <= TX_TRIGGERfew enough remain to be worth refilling
watermarka synonym for trigger level; high and low watermarks are the two thresholds of a hysteresis pair
almost-full / almost-emptytrigger 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 <= T

Each 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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));
endmodule

Two 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
T_headroom  ≥  worst-case service latency  +  margin

Machine-computed for a 16-entry RX FIFO at 115,200 baud 8N1, where T_frame = 86.8056 µs:

RX_TRIGGERheadroom entriesT_headroomfits 100 µs?fits 250 µs?fits 500 µs?
4121041.67 µsyesyesyes
88694.44 µsyesyesyes
106520.83 µsyesyesyes
124347.22 µsyesyesNO
142173.61 µsyesNONO
15186.81 µsNONONO

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_TRIGGERbytes per service eventevents/s at 115,200 baud
442,880
881,440
1212960

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 cycles
A trace of seventeen character arrivals into a sixteen-entry receive FIFO with no consumer activity. Occupancy rises by one with each arrival. The receive trigger, configured at twelve, asserts when occupancy reaches twelve and remains asserted thereafter. Four further arrivals bring occupancy to sixteen, at which point the full flag asserts. The interval between the trigger asserting and full asserting is the headroom, four entries or about three hundred and forty seven microseconds at one hundred fifteen thousand two hundred baud, and it is the window within which the consumer must begin and complete its service.headroom: 4 entries = 347.22 usheadroom: 4 entries = 347.22 ustrigger — service must STARTtrigger — service mustSTARTfull — a byte is now lostfull — a byte is now lostlevel012345678910111213141516rx_trigger_ofull_ot0t1t2t3t4t5t6t7t8t9t10t11t12t13t14t15t16
Figure 1 — occupancy against the two thresholds. Columns are CHARACTER ARRIVALS, one per frame time, not clock cycles. The trigger asserts on the way up at level 12 and the remaining four entries are the headroom the consumer must fit inside; full asserts four arrivals later, and by then a byte has already been lost.
Threshold logic reading a FIFO occupancy counter. A single occupancy counter inside the FIFO is compared against two configured values. The receive trigger comparator asserts when occupancy is greater than or equal to the receive trigger level, indicating that enough characters have accumulated to be worth servicing and that the remaining headroom is now being consumed. The transmit trigger comparator asserts when occupancy is less than or equal to the transmit trigger level, indicating that few enough entries remain that a refill should be scheduled before the queue empties and the line goes idle. The same occupancy value is also read by the hardware flow control logic in a later chapter, so the counter serves three consumers.FIFOCh 10.2level_o0 .. DEPTHlevel >= RX_Trising dangerservice meto Module 13flow controlCh 10.5 — same counterSTOP / RESUMEhysteresis pairlevel <= TX_Tfalling dangerrefill meto Module 13occupancyrx_trigger_otx_trigger_oalso read here12
Figure 2 — where the thresholds sit. Both comparators read the same occupancy counter the FIFO already maintains, and each points at the boundary its own queue can actually reach. The receive trigger drives service scheduling; the transmit trigger drives refill; the same counter also feeds the flow-control decision of Chapter 10.5, which is why keeping occupancy explicit pays for itself three times over.

6. 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
assert   when level >= HIGH
deassert when level <= LOW          with LOW < HIGH
between: HOLD the current decision

This 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:

StimulusExpectedResult
level 0rx_trigger low, tx_trigger highpass
level 4 (== TX_TRIGGER)tx_trigger highpass
level 5 (one above)tx_trigger lowpass
level 11 (one below RX_TRIGGER)rx_trigger lowpass
level 12 (== RX_TRIGGER)rx_trigger highpass
level 16 (== DEPTH)rx_trigger highpass

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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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

Where this fits

Part of the UART curriculum.