UART · Module 10
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.
Chapter 10.4 ended at a wall: ready = !full stops a local producer completely and has no path off the device. The far end's transmitter has its own clock, its own schedule, and no knowledge of this design.
Hardware flow control closes that gap with a dedicated wire. The wire is the easy part. The engineering is in one fact that the phrase "flow control" actively obscures:
Asserting flow control when the FIFO is full is already too late.
The far end may be halfway through a character, may have already committed another to its shift register, and takes some time to notice the request at all. Every one of those bytes is already in flight and cannot be recalled. The threshold that asserts backpressure must therefore leave enough headroom to absorb them — and §5 shows a configuration where no threshold in a 16-entry FIFO is sufficient.
1. Naming, Because the Conventional Names Are Ambiguous
RTS and CTS are inherited from modem wiring, where the roles were asymmetric between terminal equipment and communication equipment. The names survived; the original meanings did not travel well, and a design review that assumes shared understanding of "RTS" goes wrong quickly.
Role-based language first, because it is unambiguous:
local receiver backpressure OUTPUT — "I am running out of room; stop sending"
remote transmitter permission INPUT — "the far end says I may send"Then the mapping used throughout this chapter, stated once:
| Signal | Direction | Polarity | Meaning |
|---|---|---|---|
rts_n_o | out of this device | active low | 0 = far end may send · 1 = far end should stop |
cts_n_i | in to this device | active low | 0 = we may send · 1 = we must not start a new frame |
In a null-modem connection the two cross: this device's rts_n_o drives the far end's cts_n_i, and vice versa. That is the arrangement assumed here, and it is the common one for two peer devices.
Active low is a convention, not a requirement, and inverted variants are common. What matters is that the polarity appear once, in a named signal, rather than being implied by prose — a mistake that costs a board spin rather than a debug session.
2. The Latency Is the Subject
3. The In-Flight Budget
Stated as a model rather than a constant, because the third term depends on the far end:
in_flight = 1 (frame currently being transmitted)
+ 1 (frame already committed to the shifter)
+ ceil(recognition_delay / T_frame)Machine-computed across rates and recognition delays:
| baud | T_frame | recognition 0 µs | 10 µs | 50 µs |
|---|---|---|---|---|
| 115,200 | 86.81 µs | 2 bytes | 3 bytes | 3 bytes |
| 1,000,000 | 10.00 µs | 2 bytes | 3 bytes | 7 bytes |
| 3,000,000 | 3.33 µs | 2 bytes | 5 bytes | 17 bytes |
Read the bottom-right cell. At 3 Mbaud with a far end that takes 50 µs to react, seventeen characters arrive after backpressure is asserted. A 16-entry FIFO cannot hold them at any threshold — even asserting at level 0 leaves only 16 entries of headroom.
That is not a threshold problem; it is a capacity problem. The conclusion falls straight out of the arithmetic: at that rate and that far end, flow control requires a deeper FIFO, and no amount of tuning the trigger rescues it. A design that discovers this in the field instead will have spent the intervening time adjusting thresholds.
The recognition delay is the term nobody knows, and it is worth pressing on. For a hardware UART on the far end it is typically a synchroniser plus a comparison — a few clocks. For a USB-serial bridge, a driver-mediated link or anything with its own buffering, it can be orders of magnitude larger, and the 50 µs column is not a pathological case.
4. The Threshold
The requirement is the same shape as Chapter 10.3's and the number is different:
H = DEPTH − STOP_LEVEL must satisfy H ≥ in_flight + marginFor a 16-entry FIFO:
STOP_LEVEL | headroom H | covers 2 bytes? | 3 bytes? | 4 bytes? |
|---|---|---|---|---|
| 8 | 8 | yes | yes | yes |
| 10 | 6 | yes | yes | yes |
| 12 | 4 | yes | yes | yes |
| 13 | 3 | yes | yes | NO |
| 14 | 2 | yes | NO | NO |
| 15 | 1 | NO | NO | NO |
At 115,200 baud with a hardware far end, STOP_LEVEL = 12 gives 4 bytes of headroom against 2 in flight — a margin of 2 entries.
5. Hysteresis Is Not Optional Here
Chapter 10.3 §6 noted that a single threshold chatters while occupancy hovers on the boundary, and that for an interrupt this is merely noisy. For a pin it is expensive.
A chattering rts_n_o tells the far end to stop and start repeatedly. Each stop costs it the recognition delay and possibly an aborted opportunity to launch; each start costs the same again. Throughput collapses, and the symptom — a link running far below its configured rate with no errors reported — is one of the harder ones to attribute.
The fix is two thresholds with the decision held between them:
stop when level >= STOP_LEVEL
resume when level <= RESUME_LEVEL with RESUME_LEVEL < STOP_LEVEL
between: HOLDThe gap must be wide enough to be worth the round trip. Resuming one entry below the stop level means the far end restarts, sends one character, and is stopped again. A gap of roughly half the depth — stop at 12, resume at 6 in a 16-entry FIFO — means each resume buys six characters of useful transfer.
6. The RTL
// Synthesizable SystemVerilog — receive-side backpressure with hysteresis.
module uart_flow_ctrl #(
parameter int unsigned DEPTH = 16,
parameter int unsigned STOP_LEVEL = 12, // request stop at/above this
parameter int unsigned RESUME_LEVEL = 6 // allow again at/below this
) (
input logic clk,
input logic rst_n,
input logic [$clog2(DEPTH+1)-1:0] level_i,
output logic rts_n_o, // ACTIVE LOW out: 0 = may send, 1 = stop
output logic allow_remote_o // same decision, active high, for readers
);
localparam int unsigned CNT_W = $clog2(DEPTH + 1);
initial begin
if (RESUME_LEVEL >= STOP_LEVEL)
$fatal(1, "uart_flow_ctrl: RESUME_LEVEL %0d must be below STOP_LEVEL %0d",
RESUME_LEVEL, STOP_LEVEL);
if (STOP_LEVEL > DEPTH)
$fatal(1, "uart_flow_ctrl: STOP_LEVEL %0d exceeds DEPTH %0d", STOP_LEVEL, DEPTH);
end
logic allow_q;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
allow_q <= 1'b1; // out of reset we can accept
end else begin
// Two separate thresholds, not one. A single threshold chatters
// while occupancy sits on the boundary — Chapter 10.5 §5.
if (level_i >= CNT_W'(STOP_LEVEL)) allow_q <= 1'b0;
else if (level_i <= CNT_W'(RESUME_LEVEL)) allow_q <= 1'b1;
// between the two: HOLD the current decision
end
end
assign allow_remote_o = allow_q;
assign rts_n_o = ~allow_q; // active low
endmodule// Synthesizable SystemVerilog — transmit-side permission gating.
module uart_tx_gate (
input logic clk,
input logic rst_n,
input logic cts_n_i, // ACTIVE LOW in: 0 = permitted to send
input logic tx_busy_i, // from the Module 7 transmitter
input logic tx_fifo_empty_i,
output logic launch_allowed_o // gate on offering the next byte
);
logic cts_meta_q, cts_sync_q;
// cts_n_i arrives from a pin driven by another device's clock. It is
// asynchronous and gets the same two-stage treatment as rx_i — Ch 5.1.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cts_meta_q <= 1'b1; // reset to "not permitted"
cts_sync_q <= 1'b1;
end else begin
cts_meta_q <= cts_n_i;
cts_sync_q <= cts_meta_q;
end
end
// A launch needs permission AND something to send. tx_busy_i is NOT in
// this expression: a frame in flight finishes regardless of cts.
assign launch_allowed_o = !cts_sync_q && !tx_fifo_empty_i;
endmodule7. A Frame in Progress Is Never Truncated
The transmit gate contains one deliberate omission, and it is the chapter's second-most-important point:
assign launch_allowed_o = !cts_sync_q && !tx_fifo_empty_i;
// ^^^^^^^^^^^^^^^^
// tx_busy_i is NOT in this expression.tx_busy_i is an input to the module and is not used in the decision. That is intentional: the gate controls whether the next frame may launch, and has no influence on a frame already being emitted.
The cts_n_i synchroniser is not optional either. It is a pin driven by another device's clock, so it is genuinely asynchronous and gets the same two-stage treatment Chapter 5.1 applied to rx_i. It resets to 1 — not permitted — because a transmitter released from reset with no information about the far end should not assume permission.
This is the only other asynchronous input in the design. Chapter 8.1 §9 noted that the enable architecture leaves the UART with exactly one clock-domain crossing; adding hardware flow control makes it two, and both are handled identically at the boundary.
Stop at 12, resume at 6
13 cycles8. Verification
The suite tests the decision logic and the gate, with a positive control on both sides of every threshold.
| Stimulus | Expected | Result |
|---|---|---|
| reset, low occupancy | permission granted, rts_n_o = 0 | pass |
| level 11 (one below stop) | still permitted | pass |
level 12 (== STOP_LEVEL) | stopped, rts_n_o = 1 | pass |
| level 11, then 7 (between thresholds) | still stopped — held | pass |
level 6 (== RESUME_LEVEL) | permitted again | pass |
| oscillate 11↔12 twenty times | zero toggles on the pin | pass |
| genuine drain to 6 | exactly one toggle | pass |
cts_n_i denied, data waiting | no launch | pass |
cts_n_i permitted, data waiting | launch allowed | pass |
cts_n_i permitted, FIFO empty | no launch | pass |
cts_n_i withdrawn while tx_busy high | gate blocks next launch only | pass |
The chatter test is the one that distinguishes hysteresis from a comment claiming hysteresis. Twenty oscillations across the stop boundary producing zero pin transitions, immediately followed by a genuine drain producing exactly one, tests both that the hold works and that it has not frozen.
// Assertion — permission is withdrawn at or above the stop level and is
// never granted there. States the contract in the direction that matters.
property p_stopped_at_or_above_threshold;
@(posedge clk) disable iff (!rst_n)
(level_i >= CNT_W'(STOP_LEVEL)) |=> !allow_remote_o;
endproperty
assert property (p_stopped_at_or_above_threshold);
// Assertion — permission is restored at or below the resume level.
property p_allowed_at_or_below_resume;
@(posedge clk) disable iff (!rst_n)
(level_i <= CNT_W'(RESUME_LEVEL)) |=> allow_remote_o;
endproperty
assert property (p_allowed_at_or_below_resume);
// Assertion — the hysteresis band HOLDS. Between the two thresholds the
// decision may not change; this is what the chatter test exercises.
property p_hysteresis_holds;
@(posedge clk) disable iff (!rst_n)
((level_i > CNT_W'(RESUME_LEVEL)) && (level_i < CNT_W'(STOP_LEVEL)))
|=> $stable(allow_remote_o);
endproperty
assert property (p_hysteresis_holds);
// Assertion — the two outputs are complements. Cheap, and it catches a
// polarity inversion introduced during integration.
property p_polarity_consistent;
@(posedge clk) disable iff (!rst_n) rts_n_o == ~allow_remote_o;
endproperty
assert property (p_polarity_consistent);
// Assertion — no launch without permission, regardless of pending data.
property p_no_launch_without_cts;
@(posedge clk) disable iff (!rst_n) launch_allowed_o |-> !cts_sync_q;
endproperty
assert property (p_no_launch_without_cts);What is deliberately not asserted: that the far end stops within any particular time. That is a property of the far end, not of this design, and asserting it would be asserting something the architecture does not control — the discipline Chapter 5.4 §7 applied to voting.
The system-level test is different from all of these. Drive characters at the line rate into a FIFO with a deliberately stalled consumer, assert backpressure at the threshold, then count how many characters arrive afterwards and compare against §3's budget. That tests the threshold rather than the comparator, and it is the only test that would catch a headroom that is too small.
9. Debugging
10. What This Means on an FPGA
The logic is one comparator pair and a flip-flop. Hysteresis costs a second comparator; the whole block is a handful of LUTs next to the memory it protects.
cts_n_i needs the same constraint treatment as rx_i. It is a genuine asynchronous input, and the two-stage synchroniser is the only structure in this chapter that timing analysis cares about. Module 12 owns what the constraint says.
Pin polarity is a board-level decision that reaches into the RTL. Some transceivers invert; some connectors cross the pair and some do not. Keeping the polarity in one named assignment — assign rts_n_o = ~allow_q; — means a board change is one line rather than a search.
Probe rts_n_o with level and the arriving characters. The three together answer the only question that matters: did backpressure assert at the right occupancy, and how many bytes arrived after it. That measurement is §3's budget, taken rather than assumed.
Consider whether flow control is reachable at all at the intended rate. §3's 3 Mbaud row is the check worth doing before committing to a depth: if the in-flight budget exceeds the FIFO, the pins are present and useless.
11. Understanding Check
12. Summary
Names are ambiguous, so the convention is stated once: rts_n_o is this device's active-low backpressure output, cts_n_i is its permission input, and in a peer connection they cross.
Flow control has a latency, and two of its terms cannot be engineered away from this side: the frame in progress and a frame already committed — the second being a direct consequence of the gap-free transmission Chapter 7.4 built.
in_flight = 1 + 1 + ceil(recognition_delay / T_frame)2 bytes at 115,200 with a hardware far end; 17 at 3 Mbaud with a 50 µs reaction — where a 16-entry FIFO cannot hold them at any threshold, making it a capacity problem rather than a tuning one.
The threshold is H = DEPTH − STOP_LEVEL ≥ in_flight + margin. At 16 entries and 115,200 baud, stopping at 12 gives 4 bytes of headroom against 2 in flight.
The flow-control threshold and the interrupt threshold are different questions — bytes versus time — and coincide only at particular operating points.
Hysteresis is not optional on a pin. Chatter costs a round trip per transition and presents as throughput far below the configured rate with no errors. Verified: twenty oscillations across the boundary, zero pin transitions; a genuine drain, exactly one.
A frame in progress is never truncated. tx_busy_i is deliberately absent from the launch expression, because freezing the line mid-frame corrupts the character rather than pausing it.
And cts_n_i is the design's second asynchronous input, synchronised like rx_i and reset to not permitted.
13. What Comes Next
Hardware flow control needs two more pins. Plenty of links do not have them — a three-wire connection, a bridge that exposes only data lines, a connector chosen before flow control was considered.
Chapter 10.6 takes the alternative: encode the same request inside the byte stream. That removes the pin requirement and introduces a problem hardware flow control does not have — the control characters occupy values that arbitrary binary payload may legitimately contain, so the mechanism is only transparent if a layer above says it is. It also has its own latency, and the comparison between the two is a genuine trade rather than a ranking.
Browse the full path on the UART tutorials index. For the transmitter launch point this chapter gates, read back to Chapter 7.4.
Continue learning
Related tutorials
- Related topic
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.
- 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
XON/XOFF Software Flow Control and Its Limits
Sending the stop request inside the data stream removes the pin requirement and creates a problem hardware flow control does not have: the control values are ones arbitrary binary payload may legitimately contain.
- Related topic
Credits — Backpressure Across a Wire You Cannot Reach
An on-chip receiver says no with one wire. A receiver on the far side of a Link cannot, so PCIe replaces the ready signal with advertised capacity and local accounting — six pools, two units, and one rule about spending what you have not been given.
Where this fits
Part of the UART curriculum.
