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_level_i — enough has arrived to be worth servicing |
tx_trigger_o | level_o <= tx_level_i — 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
module uart_fifo_watermark #(
parameter int unsigned DEPTH = 16
) (
input logic [$clog2(DEPTH+1)-1:0] level_i,
// Chapter 13.2 — the trigger values are RUNTIME inputs. They select among
// comparisons this block already implements, so by Chapter 11.4's test
// they belong to a register. The reset value still comes from a parameter,
// one level up, which is the recommended shape for a tunable default.
input logic [$clog2(DEPTH+1)-1:0] rx_level_i, // RX: service when level >= this
input logic [$clog2(DEPTH+1)-1:0] tx_level_i, // TX: refill when level <= this
output logic rx_trigger_o,
output logic tx_trigger_o
);
// 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 >= rx_level_i);
assign tx_trigger_o = (level_i <= tx_level_i);
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.
A trigger of 0 must be 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. Earlier versions of this block took the thresholds as parameters and rejected zero at elaboration. Chapter 13.2 moves them to runtime inputs, because a driver has to be able to tune the service latency without a re-synthesis — and that move converts this build-time rejection into a runtime clamp in the register block, since software can now write the value that elaboration used to forbid.
tx_level_i == 0 is legal and means trigger only when completely empty, which is a defensible if latency-hostile choice. rx_level_i == 0 is not, because level >= 0 is always true and the trigger would be permanently asserted. The asymmetry is deliberate: one value is meaningless and the other is merely aggressive. Since both are now runtime inputs rather than parameters, neither can be rejected at elaboration — the clamp belongs to the register block, and Module 13 owns it.
5. Deriving the Trigger Level
The headroom a trigger leaves is the distance to the boundary, in time:
headroom_entries = DEPTH − rx_level
T_headroom = headroom_entries × T_frame [s]Here rx_level is the value software writes to rx_level_i — §4 made the
trigger a runtime input rather than a parameter, so the sizing below chooses
a register value, not a synthesis-time constant. The arithmetic is the
same; what changes is that a system can re-tune it without a rebuild.
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_level | 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_level | 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. The Same Comparators in Three Languages
The block is two comparators and nothing else — no clock, no state. What makes it worth publishing three times is that the trigger levels are runtime inputs, so the same gates answer a different question after a register write.
//===========================================================================
// uart_fifo_watermark_v — Synthesizable Verilog-2001
//
// Pure combinational: two comparators, nothing clocked. The senses are
// OPPOSITE because the two FIFOs are filled and drained from opposite ends
// -- an RX FIFO needs service when it is getting FULL, a TX FIFO when it is
// getting EMPTY (Chapter 10.3 section 3).
//===========================================================================
module uart_fifo_watermark_v (
level_i, rx_level_i, tx_level_i, rx_trigger_o, tx_trigger_o
);
parameter DEPTH = 16;
function integer clog2;
input integer value;
integer i;
begin
clog2 = 1;
for (i = value - 1; i > 1; i = i >> 1) clog2 = clog2 + 1;
end
endfunction
localparam CNT_W = clog2(DEPTH + 1);
input [CNT_W-1:0] level_i;
input [CNT_W-1:0] rx_level_i; // RX: service when level >= this
input [CNT_W-1:0] tx_level_i; // TX: refill when level <= this
output rx_trigger_o;
output tx_trigger_o;
assign rx_trigger_o = (level_i >= rx_level_i);
assign tx_trigger_o = (level_i <= tx_level_i);
endmodule--==========================================================================
-- uart_fifo_watermark -- Synthesizable VHDL-2008
-- Two comparators, nothing clocked. The senses are OPPOSITE because the
-- two FIFOs are filled and drained from opposite ends.
--==========================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.uart_fifo_pkg.all;
entity uart_fifo_watermark is
generic (
DEPTH : positive := 16
);
port (
level_i : in std_logic_vector(cnt_width(DEPTH+1)-1 downto 0);
rx_level_i : in std_logic_vector(cnt_width(DEPTH+1)-1 downto 0);
tx_level_i : in std_logic_vector(cnt_width(DEPTH+1)-1 downto 0);
rx_trigger_o : out std_logic; -- RX: service when level >= rx_level
tx_trigger_o : out std_logic -- TX: refill when level <= tx_level
);
end entity uart_fifo_watermark;
architecture rtl of uart_fifo_watermark is
begin
rx_trigger_o <= '1' when unsigned(level_i) >= unsigned(rx_level_i) else '0';
tx_trigger_o <= '1' when unsigned(level_i) <= unsigned(tx_level_i) else '0';
end architecture rtl;| Property | Verilog-2001 | SystemVerilog | VHDL-2008 | Agree? |
|---|---|---|---|---|
| RX sense | level >= rx_level | same | same | yes |
| TX sense | level <= tx_level | same | same | yes |
| comparison type | unsigned vectors | unsigned vectors | unsigned() cast | yes |
| trigger source | input ports | input ports | input ports | yes — registers, not parameters |
| width | clog2(DEPTH+1) | $clog2(DEPTH+1) | cnt_width(DEPTH+1) | yes |
| port style | non-ANSI | ANSI | ANSI | no — the same 2001 rule as 10.2 |
VHDL's explicit unsigned() cast is the interesting row. A
std_logic_vector has no arithmetic meaning in VHDL, so the comparison
will not compile until the designer states whether it is signed or unsigned.
Both Verilog dialects treat an unadorned vector as unsigned silently. Here
they agree; in a design with a signed quantity they would not, and the VHDL
is the one that would have refused to guess.
8. The Self-Checking Testbenches
INDEPENDENCE: the checker states the two rules in words — "RX asks for service at or above its level", "TX asks for a refill at or below its" — and sweeps occupancy against them. It never reuses the DUT's expression.
The strongest check is exhaustive, because at this size exhaustive is cheap:
// every occupancy against every legal trigger value: 17 x 17 pairs
for (j = 0; j <= DEPTH; j = j + 1) begin
rx_level = j[CNT_W-1:0];
tx_level = j[CNT_W-1:0];
for (i = 0; i <= DEPTH; i = i + 1) begin
level = i[CNT_W-1:0];
#1;
if (rx_trig !== (i >= j)) bad_rx = bad_rx + 1;
if (tx_trig !== (i <= j)) bad_tx = bad_tx + 1;
end
end289 combinations per output, and the block is small enough that this is the right answer. A sampled test would have to argue about which points matter; an exhaustive one does not.
//===========================================================================
// tb_uart_fifo_watermark_v — self-checking Verilog-2001 testbench
//
// INDEPENDENCE: the checker states the two rules in words -- "RX asks for
// service at or above its level", "TX asks for a refill at or below its" --
// and sweeps every occupancy from 0 to DEPTH against them. It never reuses
// the DUT's comparison expression.
//
// The value of this block is that the two senses are OPPOSITE, so the
// sweep is run at several trigger settings including the degenerate ones.
//===========================================================================
`timescale 1ns/1ps
module tb_uart_fifo_watermark_v;
localparam DEPTH = 16;
localparam CNT_W = 5;
reg [CNT_W-1:0] level = 5'd0, rx_level = 5'd12, tx_level = 5'd4;
wire rx_trig, tx_trig;
uart_fifo_watermark_v #(.DEPTH(DEPTH)) dut (
.level_i(level), .rx_level_i(rx_level), .tx_level_i(tx_level),
.rx_trigger_o(rx_trig), .tx_trigger_o(tx_trig));
integer checks = 0, failures = 0;
task check;
input cond;
input [8*58-1:0] name;
begin
checks = checks + 1;
if (cond) $display(" PASS %0s", name);
else begin failures = failures + 1; $display(" FAIL %0s", name); end
end
endtask
integer i, j, bad_rx, bad_tx;
initial begin
#200_000;
$display(" FAIL watchdog: simulation did not finish");
$display("== %0d checks, %0d failures ==", checks+1, failures+1);
$display(" RESULT: VERILOG WATERMARK TESTS FAILED (timeout)");
$finish;
end
initial begin
$display("== uart_fifo_watermark_v : self-checking Verilog testbench ==");
// ---- exhaustive sweep at the default settings --------------------
rx_level = 5'd12; tx_level = 5'd4;
bad_rx = 0; bad_tx = 0;
for (i = 0; i <= DEPTH; i = i + 1) begin
level = i[CNT_W-1:0];
#1;
if (rx_trig !== (i >= 12)) bad_rx = bad_rx + 1;
if (tx_trig !== (i <= 4)) bad_tx = bad_tx + 1;
end
check(bad_rx == 0, "RX trigger correct at every level 0..DEPTH");
check(bad_tx == 0, "TX trigger correct at every level 0..DEPTH");
// ---- the boundary values themselves ------------------------------
level = 5'd11; #1; check(rx_trig === 1'b0, "RX: one below the level does not trigger");
level = 5'd12; #1; check(rx_trig === 1'b1, "RX: AT the level triggers (>= not >)");
level = 5'd4; #1; check(tx_trig === 1'b1, "TX: AT the level triggers (<= not <)");
level = 5'd5; #1; check(tx_trig === 1'b0, "TX: one above the level does not trigger");
// ---- the senses are opposite -------------------------------------
// A nearly-empty FIFO asks TX for a refill and does NOT ask RX for
// service. A nearly-full one does the reverse. If both fired
// together the two outputs would be the same signal.
level = 5'd0; #1;
check(tx_trig === 1'b1 && rx_trig === 1'b0, "empty: TX asks, RX silent");
level = DEPTH[CNT_W-1:0]; #1;
check(rx_trig === 1'b1 && tx_trig === 1'b0, "full: RX asks, TX silent");
// ---- runtime reprogramming, which is the point of the inputs -----
// These are register values, not parameters: the same hardware must
// answer a different question after a write.
level = 5'd8;
rx_level = 5'd8; #1; check(rx_trig === 1'b1, "reprogrammed RX level takes effect at once");
rx_level = 5'd9; #1; check(rx_trig === 1'b0, "and again when raised above the level");
tx_level = 5'd8; #1; check(tx_trig === 1'b1, "reprogrammed TX level takes effect at once");
tx_level = 5'd7; #1; check(tx_trig === 1'b0, "and again when lowered below the level");
// ---- degenerate settings -----------------------------------------
rx_level = 5'd0; level = 5'd0; #1;
check(rx_trig === 1'b1, "RX level 0: always triggers, even empty");
tx_level = DEPTH[CNT_W-1:0]; level = DEPTH[CNT_W-1:0]; #1;
check(tx_trig === 1'b1, "TX level DEPTH: always triggers, even full");
// ---- a full sweep at every legal trigger value -------------------
bad_rx = 0; bad_tx = 0;
for (j = 0; j <= DEPTH; j = j + 1) begin
rx_level = j[CNT_W-1:0];
tx_level = j[CNT_W-1:0];
for (i = 0; i <= DEPTH; i = i + 1) begin
level = i[CNT_W-1:0];
#1;
if (rx_trig !== (i >= j)) bad_rx = bad_rx + 1;
if (tx_trig !== (i <= j)) bad_tx = bad_tx + 1;
end
end
check(bad_rx == 0, "RX correct across all 17x17 level/trigger pairs");
check(bad_tx == 0, "TX correct across all 17x17 level/trigger pairs");
$display("== %0d checks, %0d failures ==", checks, failures);
if (failures == 0) $display(" RESULT: ALL VERILOG WATERMARK TESTS PASSED");
else $display(" RESULT: VERILOG WATERMARK TESTS FAILED");
$finish;
end
endmodule//===========================================================================
// tb_uart_fifo_watermark_sv — self-checking Verilog-2001 testbench
//
// INDEPENDENCE: the checker states the two rules in words -- "RX asks for
// service at or above its level", "TX asks for a refill at or below its" --
// and sweeps every occupancy from 0 to DEPTH against them. It never reuses
// the DUT's comparison expression.
//
// The value of this block is that the two senses are OPPOSITE, so the
// sweep is run at several trigger settings including the degenerate ones.
//===========================================================================
`timescale 1ns/1ps
module tb_uart_fifo_watermark_sv;
localparam DEPTH = 16;
localparam CNT_W = 5;
logic [CNT_W-1:0] level = 5'd0, rx_level = 5'd12, tx_level = 5'd4;
logic rx_trig, tx_trig;
uart_fifo_watermark #(.DEPTH(DEPTH)) dut (
.level_i(level), .rx_level_i(rx_level), .tx_level_i(tx_level),
.rx_trigger_o(rx_trig), .tx_trigger_o(tx_trig));
int unsigned checks = 0, failures = 0;
task automatic check(input bit cond, input string name);
checks++;
if (cond) $display(" PASS %0s", name);
else begin failures++; $display(" FAIL %0s", name); end
endtask
int i, j, bad_rx, bad_tx;
initial begin
#200us;
$display(" FAIL watchdog: simulation did not finish");
$display("== %0d checks, %0d failures ==", checks+1, failures+1);
$display(" RESULT: SYSTEMVERILOG WATERMARK TESTS FAILED (timeout)");
$finish;
end
initial begin
$display("== uart_fifo_watermark : self-checking Verilog testbench ==");
// ---- exhaustive sweep at the default settings --------------------
rx_level = 5'd12; tx_level = 5'd4;
bad_rx = 0; bad_tx = 0;
for (i = 0; i <= DEPTH; i = i + 1) begin
level = i[CNT_W-1:0];
#1;
if (rx_trig !== (i >= 12)) bad_rx = bad_rx + 1;
if (tx_trig !== (i <= 4)) bad_tx = bad_tx + 1;
end
check(bad_rx == 0, "RX trigger correct at every level 0..DEPTH");
check(bad_tx == 0, "TX trigger correct at every level 0..DEPTH");
// ---- the boundary values themselves ------------------------------
level = 5'd11; #1; check(rx_trig === 1'b0, "RX: one below the level does not trigger");
level = 5'd12; #1; check(rx_trig === 1'b1, "RX: AT the level triggers (>= not >)");
level = 5'd4; #1; check(tx_trig === 1'b1, "TX: AT the level triggers (<= not <)");
level = 5'd5; #1; check(tx_trig === 1'b0, "TX: one above the level does not trigger");
// ---- the senses are opposite -------------------------------------
// A nearly-empty FIFO asks TX for a refill and does NOT ask RX for
// service. A nearly-full one does the reverse. If both fired
// together the two outputs would be the same signal.
level = 5'd0; #1;
check(tx_trig === 1'b1 && rx_trig === 1'b0, "empty: TX asks, RX silent");
level = DEPTH[CNT_W-1:0]; #1;
check(rx_trig === 1'b1 && tx_trig === 1'b0, "full: RX asks, TX silent");
// ---- runtime reprogramming, which is the point of the inputs -----
// These are register values, not parameters: the same hardware must
// answer a different question after a write.
level = 5'd8;
rx_level = 5'd8; #1; check(rx_trig === 1'b1, "reprogrammed RX level takes effect at once");
rx_level = 5'd9; #1; check(rx_trig === 1'b0, "and again when raised above the level");
tx_level = 5'd8; #1; check(tx_trig === 1'b1, "reprogrammed TX level takes effect at once");
tx_level = 5'd7; #1; check(tx_trig === 1'b0, "and again when lowered below the level");
// ---- degenerate settings -----------------------------------------
rx_level = 5'd0; level = 5'd0; #1;
check(rx_trig === 1'b1, "RX level 0: always triggers, even empty");
tx_level = DEPTH[CNT_W-1:0]; level = DEPTH[CNT_W-1:0]; #1;
check(tx_trig === 1'b1, "TX level DEPTH: always triggers, even full");
// ---- a full sweep at every legal trigger value -------------------
bad_rx = 0; bad_tx = 0;
for (j = 0; j <= DEPTH; j = j + 1) begin
rx_level = j[CNT_W-1:0];
tx_level = j[CNT_W-1:0];
for (i = 0; i <= DEPTH; i = i + 1) begin
level = i[CNT_W-1:0];
#1;
if (rx_trig !== (i >= j)) bad_rx = bad_rx + 1;
if (tx_trig !== (i <= j)) bad_tx = bad_tx + 1;
end
end
check(bad_rx == 0, "RX correct across all 17x17 level/trigger pairs");
check(bad_tx == 0, "TX correct across all 17x17 level/trigger pairs");
$display("== %0d checks, %0d failures ==", checks, failures);
if (failures == 0) $display(" RESULT: ALL SYSTEMVERILOG WATERMARK TESTS PASSED");
else $display(" RESULT: SYSTEMVERILOG WATERMARK TESTS FAILED");
$finish;
end
endmodule--==========================================================================
-- tb_uart_fifo_watermark -- self-checking VHDL-2008 testbench
-- Same 16 checks as the Verilog and SystemVerilog testbenches.
--==========================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use std.env.finish;
use work.uart_fifo_pkg.all;
entity tb_uart_fifo_watermark is
end entity tb_uart_fifo_watermark;
architecture sim of tb_uart_fifo_watermark is
constant DEPTH : positive := 16;
constant CNT_W : positive := cnt_width(DEPTH+1);
signal level, rx_level, tx_level : std_logic_vector(CNT_W-1 downto 0);
signal rx_trig, tx_trig : std_logic;
begin
dut : entity work.uart_fifo_watermark
generic map (DEPTH => DEPTH)
port map (level_i => level, rx_level_i => rx_level, tx_level_i => tx_level,
rx_trigger_o => rx_trig, tx_trigger_o => tx_trig);
stim : process
variable checks, failures, bad_rx, bad_tx : natural := 0;
procedure check(cond : boolean; name : string) is
begin
checks := checks + 1;
if cond then report " PASS " & name;
else failures := failures + 1; report " FAIL " & name severity error; end if;
end procedure;
procedure setv(l, r, t : natural) is
begin
level <= std_logic_vector(to_unsigned(l, CNT_W));
rx_level <= std_logic_vector(to_unsigned(r, CNT_W));
tx_level <= std_logic_vector(to_unsigned(t, CNT_W));
wait for 1 ns;
end procedure;
begin
report "== uart_fifo_watermark (VHDL) : self-checking testbench ==";
bad_rx := 0; bad_tx := 0;
for i in 0 to DEPTH loop
setv(i, 12, 4);
if (rx_trig = '1') /= (i >= 12) then bad_rx := bad_rx + 1; end if;
if (tx_trig = '1') /= (i <= 4) then bad_tx := bad_tx + 1; end if;
end loop;
check(bad_rx = 0, "RX trigger correct at every level 0..DEPTH");
check(bad_tx = 0, "TX trigger correct at every level 0..DEPTH");
setv(11,12,4); check(rx_trig = '0', "RX: one below the level does not trigger");
setv(12,12,4); check(rx_trig = '1', "RX: AT the level triggers (>= not >)");
setv(4,12,4); check(tx_trig = '1', "TX: AT the level triggers (<= not <)");
setv(5,12,4); check(tx_trig = '0', "TX: one above the level does not trigger");
setv(0,12,4);
check(tx_trig = '1' and rx_trig = '0', "empty: TX asks, RX silent");
setv(DEPTH,12,4);
check(rx_trig = '1' and tx_trig = '0', "full: RX asks, TX silent");
setv(8,8,4); check(rx_trig = '1', "reprogrammed RX level takes effect at once");
setv(8,9,4); check(rx_trig = '0', "and again when raised above the level");
setv(8,9,8); check(tx_trig = '1', "reprogrammed TX level takes effect at once");
setv(8,9,7); check(tx_trig = '0', "and again when lowered below the level");
setv(0,0,7); check(rx_trig = '1', "RX level 0: always triggers, even empty");
setv(DEPTH,0,DEPTH); check(tx_trig = '1', "TX level DEPTH: always triggers, even full");
bad_rx := 0; bad_tx := 0;
for j in 0 to DEPTH loop
for i in 0 to DEPTH loop
setv(i, j, j);
if (rx_trig = '1') /= (i >= j) then bad_rx := bad_rx + 1; end if;
if (tx_trig = '1') /= (i <= j) then bad_tx := bad_tx + 1; end if;
end loop;
end loop;
check(bad_rx = 0, "RX correct across all 17x17 level/trigger pairs");
check(bad_tx = 0, "TX correct across all 17x17 level/trigger pairs");
report "== " & integer'image(checks) & " checks, " &
integer'image(failures) & " failures ==";
if failures = 0 then report " RESULT: ALL VHDL WATERMARK TESTS PASSED";
else report " RESULT: VHDL WATERMARK TESTS FAILED" severity error; end if;
finish;
end process stim;
end architecture sim;== uart_fifo_watermark_v : self-checking Verilog testbench ==
PASS RX trigger correct at every level 0..DEPTH
PASS TX trigger correct at every level 0..DEPTH
PASS RX: AT the level triggers (>= not >)
PASS TX: AT the level triggers (<= not <)
PASS empty: TX asks, RX silent
PASS full: RX asks, TX silent
PASS reprogrammed RX level takes effect at once
PASS RX level 0: always triggers, even empty
PASS TX level DEPTH: always triggers, even full
PASS RX correct across all 17x17 level/trigger pairs
PASS TX correct across all 17x17 level/trigger pairs
== 16 checks, 0 failures ==
RESULT: ALL VERILOG WATERMARK TESTS PASSED16 checks in each language, 0 failures.
Two checks assert that the two outputs DISAGREE, and they are the ones §3 exists for: an empty FIFO asks TX for a refill and does not ask RX for service, and a full one does the reverse. If both fired together the two outputs would be one signal with two names.
Proving these checks can fail
| Mutation | What it breaks | Failures |
|---|---|---|
| both senses the same direction | §3's structural argument | 6 / 16 |
9. 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_level_i) | tx_trigger high | pass |
| level 5 (one above) | tx_trigger low | pass |
level 11 (one below rx_level_i) | rx_trigger low | pass |
level 12 (== rx_level_i) | 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 >= rx_level_i);
endproperty
assert property (p_rx_trigger_sense);
property p_tx_trigger_sense;
@(posedge clk) tx_trigger_o == (level_i <= tx_level_i);
endproperty
assert property (p_tx_trigger_sense);
// Assertion — a full FIFO always asserts the RX trigger. Any legal
// rx_level_i 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.
10. Debugging
11. 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.
12. Understanding Check
13. 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.
14. 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.
