Valid/Ready Backpressure: Deadlocks, Bubbles, and Fixes
Why valid/ready handshakes deadlock or lose throughput, and how to fix them with AXI rules, a skid buffer, SVA assertions, and a review checklist.
Most blocks pass their unit tests. The trouble starts when two of them are connected and the downstream side says "not now." Valid/ready handshakes look simple, yet they cause two of the most expensive integration problems in chip and FPGA design: deadlocks, where traffic stops for good, and bubbles, where traffic moves at a fraction of the rate the architecture promised.
This article covers the handshake rules that prevent both, the patterns that break them, a skid buffer you can drop into a pipeline, assertions that catch violations early, and a checklist for design reviews.
The contract in four rules
The AXI family (AXI4, AXI4-Lite, AXI4-Stream) and most in-house streaming interfaces use the same contract:
- A transfer happens on a rising clock edge where VALID and READY are both high. Nothing else counts.
- The source must not wait for READY before asserting VALID. VALID may depend only on the source's own state, never on READY.
- Once VALID is high, it stays high, and the payload stays stable, until the transfer happens. No retracting, no swapping data mid-wait.
- The sink may wait for VALID before asserting READY, and may raise or lower READY freely before VALID arrives.
The asymmetry between rules 2 and 4 is the whole point. One side has to commit first, and the contract says it is the source. If both sides are allowed to wait, you can build a system where neither one moves.
Deadlock patterns
1. Mutual waiting
A source that says "I'll assert VALID once I see READY," connected to a sink that says "I'll assert READY once I see VALID," never transfers. Each block is reasonable in isolation, and each might pass its own testbench if the testbench driver happens to commit first. Integration is where it hangs.
2. Combinational VALID/READY loops
If VALID is computed combinationally from READY on one side, and READY combinationally from VALID on the other, you have a combinational loop. Synthesis and lint will usually flag it; simulation may oscillate, settle on an arbitrary value, or hit a zero-delay iteration limit. Rule 2 removes this structurally: if VALID never depends on READY, the loop cannot close.
3. Cross-channel dependencies
Multi-channel protocols add a subtler trap. In AXI4 writes, the address (AW) and data (W) channels are independent. The specification allows a slave to wait for AWVALID, WVALID, or both before asserting AWREADY or WREADY. It does not allow a master to wait for AWREADY or WREADY before asserting AWVALID or WVALID.
Now picture a bridge that only presents WVALID after its AW beat is accepted, connected to a slave that legally waits for write data before accepting the address. The bridge waits for AWREADY; the slave waits for WVALID. Both channels freeze. Each block passes its own tests, and the bug appears only when they meet.
The general form is any cycle of dependencies between channels or buffers: a block that will not accept input until it can emit output, feeding a path that eventually loops back to its own input. Drawing the dependency graph (who waits on whom) during review is the cheapest way to find these.
Bubble patterns
Bubbles do not hang anything. They quietly cut throughput, which is worse in some ways because nothing fails.
The half-rate register
The most common pipeline register accepts only when it is empty:
assign s_ready = !m_valid; // accept only when the output register is empty
When downstream is always ready, the register fills on one cycle, drains on the next, and cannot accept on the drain cycle because s_ready was computed from the full state. The result is one transfer every two cycles: 50% throughput, with no errors and no assertion failures.
The obvious fix and its cost
The full-throughput version lets the register accept while it is being drained:
assign s_ready = !m_valid || m_ready;
This is correct and runs at full rate, but it creates a combinational path from m_ready to s_ready. Chain ten of these stages and READY ripples back through ten levels of logic in one cycle. On an FPGA, or in a fast ASIC clock domain, that path often becomes the critical path.
The naive registered READY
The tempting fix is to register READY: s_ready <= m_ready;. That breaks the timing path but loses data. When downstream drops READY at cycle t, upstream still sees READY high during cycle t and sends one more beat. If the stage has a single register, and that register is full and stalled, the extra beat has nowhere to go.
The fix: a skid buffer
A skid buffer adds exactly one spare slot to absorb that extra beat. Both outputs, m_valid and s_ready, come straight from flops, so it breaks the timing path in both directions while sustaining one transfer per cycle.
Diagram, in words: upstream data enters on the left. It can go to one of two places: directly into the output register (which drives m_valid/m_data to the right), or into a one-entry skid register beside it. A 2:1 mux in front of the output register selects the skid register when it holds data and the upstream input otherwise. s_ready on the left is simply "the skid register is empty." When downstream stalls while a beat is in flight, that beat lands in the skid register and s_ready drops on the next cycle. When downstream resumes, the skid register drains first, preserving order.
+-----------+
s_data --->| skid |---+
| | register | | +-----+ +----------+
| +-----------+ +-->| | | output |---> m_data / m_valid
| | mux |--->| register |
+---------------------->| | +----------+<--- m_ready
+-----+
s_ready = !skid_valid
module skid_buffer #(parameter int W = 32) (
input logic clk,
input logic rst_n,
input logic s_valid,
output logic s_ready,
input logic [W-1:0] s_data,
output logic m_valid,
input logic m_ready,
output logic [W-1:0] m_data
);
logic skid_valid;
logic [W-1:0] skid_data;
wire s_fire = s_valid && s_ready;
wire out_free = !m_valid || m_ready; // output register empty or draining
assign s_ready = !skid_valid; // registered: no path from m_ready
// Control
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
m_valid <= 1'b0;
skid_valid <= 1'b0;
end else if (out_free) begin
m_valid <= skid_valid || s_fire;
skid_valid <= 1'b0; // skid drains first (s_fire is 0 when it is full)
end else if (s_fire) begin
skid_valid <= 1'b1; // downstream stalled: park the in-flight beat
end
end
// Datapath (no reset needed)
always_ff @(posedge clk) begin
if (out_free)
m_data <= skid_valid ? skid_data : s_data;
if (!out_free && s_fire)
skid_data <= s_data;
end
endmodule
Why it is safe: s_fire can only be true when the skid register is empty, so nothing is overwritten. When the output is free, the skid register (if full) moves forward and the input is not accepted that cycle, because s_ready was low. When downstream is always ready, the skid register never fills, s_ready stays high, and the stage passes one beat per cycle.
The cost is a second W-bit register and a mux. For wide datapaths that adds up, so use skid buffers where timing needs them: long READY chains, SLR or partition crossings on FPGAs, and block boundaries owned by different teams.
Assertions that catch it early
Bind a small checker to every valid/ready interface. These properties catch retracted VALID, payload changes during a stall, X propagation, and stalls that never end.
module vr_checker #(parameter int W = 32, parameter int MAX_STALL = 1000) (
input logic clk, rst_n,
input logic valid, ready,
input logic [W-1:0] data
);
default clocking cb @(posedge clk); endclocking
default disable iff (!rst_n);
// Rule 3: VALID holds until the transfer
a_valid_hold: assert property (valid && !ready |=> valid);
// Rule 3: payload stable while waiting
a_data_stable: assert property (valid && !ready |=> $stable(data));
// No X on handshake signals, or on data when it matters
a_ctrl_known: assert property (!$isunknown({valid, ready}));
a_data_known: assert property (valid |-> !$isunknown(data));
// Simulation proxy for liveness: no stall longer than MAX_STALL cycles
a_progress: assert property (valid |-> ##[0:MAX_STALL] ready);
endmodule
bind skid_buffer vr_checker #(.W(W)) u_chk_in (.clk, .rst_n,
.valid(s_valid), .ready(s_ready), .data(s_data));
bind skid_buffer vr_checker #(.W(W)) u_chk_out (.clk, .rst_n,
.valid(m_valid), .ready(m_ready), .data(m_data));
Choose MAX_STALL per interface from the architecture, not from the testbench. In formal verification, replace the bounded check with s_eventually ready under fairness assumptions on the environment. Rule 2 (VALID must not depend on READY) is structural; check it in review or with a lint rule for combinational paths from READY to VALID, since a simulation assertion cannot prove independence.
Assertions only fire if the stimulus exercises backpressure. Drive READY randomly in every block-level testbench, including long stalls and single-cycle toggles. A testbench that holds READY high will never find any of the bugs above.
Measure throughput, don't assume it
Add four counters per important interface, sampled every cycle:
| VALID | READY | Meaning |
|---|---|---|
| 1 | 1 | Transfer |
| 1 | 0 | Stall: downstream is pushing back |
| 0 | 1 | Starve: upstream has nothing to send |
| 0 | 0 | Idle |
If transfers per active cycle are near 50% and the stalls alternate with transfers, look for a half-rate register. If one interface shows heavy stalls while the next one downstream shows heavy starvation, something in between is losing cycles. These counters cost a few flops and turn "it seems slow" into a specific stage to inspect. They are just as useful in silicon or on an FPGA board as in simulation.
Review checklist
- [ ] Every source asserts VALID based only on its own state; no combinational path from READY to VALID.
- [ ] VALID and payload are held until the handshake; nothing retracts or overwrites a pending beat.
- [ ] Multi-channel interfaces (AXI AW/W/B, AR/R) follow the spec's dependency rules; the inter-channel dependency graph has no cycles.
- [ ] Every pipeline register is either full-throughput or documented as intentionally half-rate.
- [ ] Long READY chains are broken with skid buffers or FIFOs, and timing reports confirm it.
- [ ] Any registered READY has a slot to absorb the beat in flight.
- [ ] A valid/ready checker is bound to every block boundary, with an architectural
MAX_STALL. - [ ] Testbenches randomize READY, including long stalls.
- [ ] Transfer/stall/starve counters exist on interfaces that carry performance targets.
Where this comes from
These are the bugs I have spent much of my career on: RTL design, integration and CPU work at Intel, iPhone camera hardware at Samsung, and an FPGA AI accelerator built from the ground up. The blocks change; the failure modes at their boundaries stay remarkably consistent. More on that kind of work is on the chip design and FPGA development pages.
Have an interface that hangs, drops data, or delivers less bandwidth than it should? Send a short description of the symptom through the contact page, and I'll reply with how I would approach it.