RTL Design Patterns · Chapter 9 · Handshake & Flow Control
Backpressure & Stall Propagation
A valid/ready link carries two flows in opposite directions: data moves forward from producer to consumer, while the permission to move, the ready signal, travels backward. A real datapath is not one link but a chain of blocks, each a consumer of the block before it and a producer to the block after it. When the final sink drops ready, the block feeding it must hold its result, and because it is holding it can no longer accept a fresh word, so it drops its own input ready. That refusal is backpressure, and it ripples upstream stage by stage until it reaches the source. This lesson builds a correctly back-pressuring register stage and chain, proves with a scoreboard testbench that no word is lost, duplicated, or reordered, and dissects the classic bug of an input ready not gated by downstream stall. Built in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design PatternsBackpressureStall Propagationvalid/readyFlow ControlHandshake
Chapter 9 · Section 9.2 · Handshake & Flow Control
1. The Engineering Problem
You have a streaming datapath: a source produces one data word per cycle, a processing block transforms it, and a sink consumes the result. So far this looks like the pipeline you built in Chapter 8 — words march forward, one stage per cycle. Then the sink misbehaves in the most ordinary way possible: some cycles it simply cannot take a word. Its downstream FIFO is full, its bus port lost arbitration this cycle, its own consumer stalled. On those cycles the sink de-asserts ready and says, in effect, not now — hold.
In 9.1 you learned what that means for a single link: the block feeding the sink must keep its valid high and its data lines frozen until the sink's ready returns, because a transfer only happens on a cycle where valid && ready are both high. Fine — one block holds. But holding is not free. The moment the processing block is holding an output it could not hand off, it has a problem of its own: the source above it is still trying to push a fresh word into it every cycle. If the block accepts that fresh word while it is still holding the previous result, the held result is overwritten and lost forever — a word that entered the chain never comes out. To avoid that, the block must refuse the new word: it must de-assert its own input ready. And now the source, seeing its ready low, must hold its word and data too — exactly as 9.1 told the producer to do.
That is the whole shape of the problem. A single point of backpressure at the sink does not stay at the sink. It forces the block above it to stall, which forces that block to refuse input, which forces the block above that to stall, and so on — a wave of de-asserted ready that ripples backward through the chain until it reaches the source, which finally holds. Data flows one way; the permission to move data flows the other way. Getting this ripple right — every block refusing new input exactly when it cannot pass its current output forward — is what makes a streaming datapath lose no words under arbitrary backpressure. Getting it wrong is the single most common way a valid/ready datapath silently drops data.
// A three-block chain: source -> block -> sink. Data moves L->R.
wire out_valid, out_ready; // block -> sink (data forward, ready back)
wire in_valid, in_ready; // source -> block (data forward, ready back)
wire [31:0] out_data, in_data;
// The sink de-asserts ready this cycle (its FIFO is full):
// out_ready == 0 => the block cannot hand off out_data.
// WRONG — the block keeps accepting input regardless:
// assign in_ready = 1'b1; // "always ready" => it swallows the held word
// RIGHT — backpressure must PROPAGATE: the block accepts a new word only when
// it can move its current one. in_ready must fall when the sink stalls.
// assign in_ready = out_ready | slot_empty; // gated accept (this page)2. Mental Model
3. Pattern Anatomy
The pattern is one block — a valid/ready register stage that back-pressures correctly — and the chain you get by wiring several of them in series. Understand the single stage's backward path and the whole chain follows.
The two directions of one link. Every link has a forward pair (valid, data) driven by the producer and a backward signal (ready) driven by the consumer. A transfer happens on exactly the cycles where valid && ready. That is 9.1. Backpressure is what happens on the cycles where valid && !ready — the producer wants to move a word but the consumer won't take it, so the producer must hold: keep valid high and keep data bit-for-bit stable until it sees ready.
The stall condition, per block. A block holding an output looks downstream and computes one bit: stall = out_valid && !out_ready — I have a valid word and my consumer won't take it. When stall is true the block must do two things at once, and both matter:
- Freeze its output. Keep
out_validhigh andout_dataunchanged (the 9.1 hold). This is a clock-enable disable on the output register — the same clock-enable freeze that stalled a pipeline stage in 8.4. - Refuse its input. De-assert
in_ready, so the block above it cannot push a fresh word in and overwrite the word it is holding. This is the new, load-bearing piece: the stall does not just hold the output, it closes the door upstream.
The gated-accept rule. Refusing input is not "always refuse when stalled" bluntly bolted on — it is a precise condition. A single-entry register stage should accept a new input word exactly when its output slot will be free next cycle, i.e. either the slot is empty now, or the word currently in it is being taken this cycle (out_valid && out_ready). In one line: in_ready = (!out_valid) || out_ready — I can take a new word if I'm not holding one, or if the one I'm holding is leaving right now. That single expression is the entire correctness of the pattern; §4 builds it, and §4d shows what breaks when you replace it with a constant 1.
The ripple, and its cost. In a chain, each block's in_ready is the previous block's out_ready. So the sink's ready low feeds block N's out_ready, which drops block N's in_ready, which is block N-1's out_ready, which drops block N-1's in_ready — a single combinational wave that reaches the source in the same cycle. That is the point of the pattern and its problem: because ready propagates combinationally back through every block, the ready path is a long combinational chain whose delay grows with chain length. A ten-block chain has a ready signal that must ripple through ten blocks' accept logic in one cycle — a timing-critical path that gets worse the longer the chain. (Registering ready to break that path is exactly what the skid buffer of 9.3 does; this page keeps ready combinational so you can see the problem it solves.)
Data forward, ready backward — a stall at the sink ripples to the source
data flowFlow conservation — the invariant that proves it. Because every block either passes its word forward (on valid && ready) or holds it unchanged (on valid && !ready), and never accepts a new word while holding, no word is ever created or destroyed. Count the words that enter the source and the words that leave the sink: they must be equal, in the same order, for any pattern of downstream ready. That conservation law is the property §5 asserts and the thing the §4d bug violates — under the bug, the entering-count exceeds the leaving-count, because a swallowed word entered but never left.
4. Real RTL Implementation
This is the core of the page. We build (a) a WIDTH-generic valid/ready register stage that back-pressures correctly (the gated-accept rule, output held under backpressure), (b) a small chain of two such stages proving the ripple works end to end, and (d) the swallowed-word bug — input ready ungated vs the gated-ready fix, each in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking clocked testbench. The testbenches apply random downstream backpressure and scoreboard the stream: every word that goes in must come out exactly once, in order. The idea — accept a new word only when the held one can move, so ready flows backward exactly as data flows forward — is identical across all three languages.
One discipline runs through every RTL block: a transfer advances only when valid && ready, and in_ready is gated by downstream backpressure (in_ready = !out_valid || out_ready). Miss the gate and you get pattern (d)'s swallowed word.
4a. The valid/ready register stage — back-pressures correctly, three ways
The atom of the whole chapter is one register stage holding at most one word, with a valid/ready port on each side. The output register is clock-enabled by the accept condition; in_ready is gated so a new word is taken only when the held word will be gone next cycle. Because the payload register only ever updates on an accepted transfer, out_data is automatically held stable under backpressure — the 9.1 hold falls out of the gating, it is not a separate mechanism.
module vr_stage #(
parameter int WIDTH = 32 // payload width, any value
)(
input logic clk,
input logic rst, // synchronous, active-high
// upstream (this block is the CONSUMER): data forward, ready back
input logic in_valid,
output logic in_ready,
input logic [WIDTH-1:0] in_data,
// downstream (this block is the PRODUCER): data forward, ready back
output logic out_valid,
input logic out_ready,
output logic [WIDTH-1:0] out_data
);
// A transfer on a link happens iff BOTH valid and ready are high that cycle.
wire in_fire = in_valid && in_ready; // we accepted a new word
wire out_fire = out_valid && out_ready; // we handed our word downstream
// GATED ACCEPT — the whole pattern in one line:
// accept new input only when the output slot is empty OR is emptying now.
assign in_ready = (!out_valid) || out_ready;
always_ff @(posedge clk) begin
if (rst) begin
out_valid <= 1'b0;
out_data <= '0;
end else begin
// The word leaves: mark slot empty (unless a new one arrives same cycle).
if (out_fire) out_valid <= 1'b0;
// A new word is accepted: load it, mark slot full. (in_fire can only be
// true when in_ready is high, i.e. the slot is free or freeing.)
if (in_fire) begin
out_valid <= 1'b1; // slot now holds a valid word
out_data <= in_data; // payload updates ONLY on accept =>
end // held stable under backpressure
end
end
endmoduleThe out_data register updates on in_fire only, so while the block is stalled (out_valid && !out_ready, no in_fire because in_ready is low) the payload cannot change — the 9.1 stability requirement is a consequence of the gate, not extra logic. The SystemVerilog testbench is clocked: it streams an incrementing sequence in, randomly de-asserts the downstream ready, and scoreboards every accepted word against every emitted word.
module vr_stage_tb;
localparam int WIDTH = 32;
localparam int NWORDS = 200;
logic clk = 0, rst = 1;
logic in_valid, in_ready, out_valid, out_ready;
logic [WIDTH-1:0] in_data, out_data;
int errors = 0;
vr_stage #(.WIDTH(WIDTH)) dut (.*);
always #5 clk = ~clk;
// Scoreboard: expected words are 0,1,2,... in order; each must come out once.
int sent = 0, recv = 0;
initial begin
in_valid = 0; in_data = 0; out_ready = 0;
repeat (2) @(posedge clk); rst = 0;
// Producer: offer word 'sent' whenever the stage is ready to accept.
fork
begin : producer
while (sent < NWORDS) begin
in_valid = 1;
in_data = sent;
@(posedge clk);
if (in_valid && in_ready) sent++; // accepted -> next word
end
in_valid = 0;
end
begin : consumer // RANDOM backpressure
while (recv < NWORDS) begin
out_ready = $urandom_range(0, 1); // ready jitters every cycle
@(posedge clk);
if (out_valid && out_ready) begin // a word transferred out
// Exactly-once, in-order: the recv-th word out must == recv.
assert (out_data === recv)
else begin $error("out of order: got %0d exp %0d", out_data, recv); errors++; end
recv++;
end
end
out_ready = 0;
end
join
// Flow conservation: every word sent came out, none extra, none lost.
assert (sent == NWORDS && recv == NWORDS)
else begin $error("count mismatch sent=%0d recv=%0d", sent, recv); errors++; end
if (errors == 0) $display("PASS: %0d words, in order, none lost/dup under backpressure", recv);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog form is the same stage with reg/wire typing and always @(posedge clk); the gated-accept expression and the accept-only-when-freeing register update are identical. The testbench uses if (...) $display("FAIL") for its self-check and $random for the backpressure jitter.
module vr_stage #(
parameter WIDTH = 32
)(
input wire clk,
input wire rst, // synchronous, active-high
input wire in_valid,
output wire in_ready,
input wire [WIDTH-1:0] in_data,
output reg out_valid,
input wire out_ready,
output reg [WIDTH-1:0] out_data
);
wire in_fire = in_valid & in_ready;
wire out_fire = out_valid & out_ready;
// Gated accept: take input only when the slot is empty or emptying this cycle.
assign in_ready = (~out_valid) | out_ready;
always @(posedge clk) begin
if (rst) begin
out_valid <= 1'b0;
out_data <= {WIDTH{1'b0}};
end else begin
if (out_fire) out_valid <= 1'b0; // word left -> slot empties
if (in_fire) begin
out_valid <= 1'b1; // new word loaded -> slot full
out_data <= in_data; // updates only on accept => held stable
end
end
end
endmodule module vr_stage_tb;
parameter WIDTH = 32;
parameter NWORDS = 200;
reg clk, rst;
reg in_valid, out_ready;
wire in_ready, out_valid;
reg [WIDTH-1:0] in_data;
wire [WIDTH-1:0] out_data;
integer sent, recv, errors;
vr_stage #(.WIDTH(WIDTH)) dut (
.clk(clk), .rst(rst),
.in_valid(in_valid), .in_ready(in_ready), .in_data(in_data),
.out_valid(out_valid), .out_ready(out_ready), .out_data(out_data));
initial clk = 0; always #5 clk = ~clk;
initial begin
rst = 1; in_valid = 0; in_data = 0; out_ready = 0;
sent = 0; recv = 0; errors = 0;
repeat (2) @(posedge clk); rst = 0;
// Single always-driven producer + consumer, sampled each clock.
forever begin
@(posedge clk);
// Consumer: random backpressure, check any word that leaves.
if (out_valid && out_ready) begin
if (out_data !== recv) begin
$display("FAIL: out of order got %0d exp %0d", out_data, recv);
errors = errors + 1;
end
recv = recv + 1;
end
// Producer: advance the offered word when it was accepted.
if (in_valid && in_ready) sent = sent + 1;
// Drive next cycle's stimulus.
in_valid = (sent < NWORDS);
in_data = sent;
out_ready = $random & 1'b1; // 0/1 backpressure jitter
if (recv == NWORDS) begin
if (sent !== NWORDS || errors != 0)
$display("FAIL: sent=%0d recv=%0d errors=%0d", sent, recv, errors);
else
$display("PASS: %0d words in order, none lost/dup under backpressure", recv);
$finish;
end
end
end
endmoduleIn VHDL the same stage uses numeric_std, a synchronous active-high reset, and the identical gated-accept expression as a concurrent assignment. The payload register updates only on an accepted transfer, so it is held under backpressure exactly as in the other two HDLs.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity vr_stage is
generic ( WIDTH : positive := 32 );
port (
clk : in std_logic;
rst : in std_logic; -- synchronous, active-high
in_valid : in std_logic;
in_ready : out std_logic;
in_data : in std_logic_vector(WIDTH-1 downto 0);
out_valid : out std_logic;
out_ready : in std_logic;
out_data : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of vr_stage is
signal ovalid : std_logic := '0'; -- readable copy of out_valid
begin
out_valid <= ovalid;
-- Gated accept: take input only when the slot is empty or emptying this cycle.
in_ready <= (not ovalid) or out_ready;
process (clk)
variable in_fire : boolean;
variable out_fire : boolean;
begin
if rising_edge(clk) then
in_fire := (in_valid = '1') and ((ovalid = '0') or (out_ready = '1'));
out_fire := (ovalid = '1') and (out_ready = '1');
if rst = '1' then
ovalid <= '0';
out_data <= (others => '0');
else
if out_fire then ovalid <= '0'; end if; -- word left -> slot empties
if in_fire then
ovalid <= '1'; -- new word -> slot full
out_data <= in_data; -- updates only on accept
end if;
end if;
end if;
end process;
end architecture;The VHDL testbench clocks the stage, jitters out_ready from a simple LFSR, and uses assert … report … severity error to check in-order, exactly-once delivery and the final flow-conservation count.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity vr_stage_tb is
end entity;
architecture sim of vr_stage_tb is
constant WIDTH : positive := 32;
constant NWORDS : natural := 200;
signal clk : std_logic := '0';
signal rst : std_logic := '1';
signal in_valid : std_logic := '0';
signal in_ready : std_logic;
signal in_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal out_valid : std_logic;
signal out_ready : std_logic := '0';
signal out_data : std_logic_vector(WIDTH-1 downto 0);
begin
dut : entity work.vr_stage
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst,
in_valid => in_valid, in_ready => in_ready, in_data => in_data,
out_valid => out_valid, out_ready => out_ready, out_data => out_data);
clk <= not clk after 5 ns;
stim : process
variable sent, recv : natural := 0;
variable lfsr : unsigned(7 downto 0) := x"2B";
variable errors : natural := 0;
begin
wait until rising_edge(clk); wait until rising_edge(clk); rst <= '0';
while recv < NWORDS loop
-- consumer: check any word that transfers out this cycle
if (out_valid = '1') and (out_ready = '1') then
assert out_data = std_logic_vector(to_unsigned(recv, WIDTH))
report "out of order / lost word under backpressure" severity error;
if out_data /= std_logic_vector(to_unsigned(recv, WIDTH)) then
errors := errors + 1;
end if;
recv := recv + 1;
end if;
-- producer: advance offered word when accepted
if (in_valid = '1') and (in_ready = '1') then sent := sent + 1; end if;
-- drive next cycle
if sent < NWORDS then in_valid <= '1'; else in_valid <= '0'; end if;
in_data <= std_logic_vector(to_unsigned(sent, WIDTH));
lfsr := (lfsr(6 downto 0) & (lfsr(7) xor lfsr(5))); -- cheap RNG
out_ready <= lfsr(0); -- backpressure jitter
wait until rising_edge(clk);
end loop;
assert (sent = NWORDS) and (errors = 0)
report "count mismatch or ordering error" severity error;
report "vr_stage self-check complete" severity note;
wait;
end process;
end architecture;4b. A two-stage chain — the ripple, end to end
Wire two stages in series and the backward ripple appears on its own: stage 1's in_ready is stage 0's out_ready, so when the sink de-asserts ready, stage 1 stalls and drops its in_ready, which drops stage 0's out_ready, so stage 0 stalls and drops its in_ready — the refusal reaches the source. No extra logic; the chain is just the atom composed, and flow conservation still holds across the whole chain.
module vr_chain #(
parameter int WIDTH = 32
)(
input logic clk, rst,
input logic in_valid,
output logic in_ready,
input logic [WIDTH-1:0] in_data,
output logic out_valid,
input logic out_ready,
output logic [WIDTH-1:0] out_data
);
// Internal link between the two stages: mid_valid/mid_ready/mid_data.
logic mid_valid, mid_ready;
logic [WIDTH-1:0] mid_data;
vr_stage #(.WIDTH(WIDTH)) s0 (
.clk, .rst,
.in_valid (in_valid), .in_ready (in_ready), .in_data (in_data),
.out_valid(mid_valid), .out_ready(mid_ready), .out_data(mid_data));
vr_stage #(.WIDTH(WIDTH)) s1 (
.clk, .rst,
.in_valid (mid_valid), .in_ready (mid_ready), .in_data (mid_data),
.out_valid(out_valid), .out_ready(out_ready), .out_data(out_data));
// s1.in_ready == s0.out_ready == mid_ready: the sink's stall reaches s0 in ONE
// cycle, combinationally, and then the source. Data forward, ready backward.
endmodule module vr_chain_tb;
localparam int WIDTH = 32, NWORDS = 300;
logic clk = 0, rst = 1, in_valid, in_ready, out_valid, out_ready;
logic [WIDTH-1:0] in_data, out_data;
int sent = 0, recv = 0, errors = 0;
vr_chain #(.WIDTH(WIDTH)) dut (.*);
always #5 clk = ~clk;
initial begin
in_valid = 0; in_data = 0; out_ready = 0;
repeat (2) @(posedge clk); rst = 0;
forever begin
@(posedge clk);
if (out_valid && out_ready) begin
assert (out_data === recv) // still in order across 2 stages
else begin $error("chain out of order: got %0d exp %0d", out_data, recv); errors++; end
recv++;
end
if (in_valid && in_ready) sent++;
in_valid = (sent < NWORDS);
in_data = sent;
out_ready = $urandom_range(0, 1); // random backpressure at the sink
if (recv == NWORDS) begin
if (sent == NWORDS && errors == 0)
$display("PASS: chain delivered %0d words in order, none lost", recv);
else $display("FAIL: sent=%0d recv=%0d errors=%0d", sent, recv, errors);
$finish;
end
end
end
endmoduleThe Verilog and VHDL chains are the identical structural composition — instantiate two vr_stages and wire stage 0's output link to stage 1's input link — so the RTL is a wiring exercise, not new logic. We show the Verilog wiring; the VHDL is the same two port maps sharing a mid_* link, and the §4a testbenches extend to the chain unchanged except for the deeper latency.
module vr_chain #(
parameter WIDTH = 32
)(
input wire clk, rst,
input wire in_valid,
output wire in_ready,
input wire [WIDTH-1:0] in_data,
output wire out_valid,
input wire out_ready,
output wire [WIDTH-1:0] out_data
);
wire mid_valid, mid_ready;
wire [WIDTH-1:0] mid_data;
vr_stage #(.WIDTH(WIDTH)) s0 (
.clk(clk), .rst(rst),
.in_valid(in_valid), .in_ready(in_ready), .in_data(in_data),
.out_valid(mid_valid), .out_ready(mid_ready), .out_data(mid_data));
vr_stage #(.WIDTH(WIDTH)) s1 (
.clk(clk), .rst(rst),
.in_valid(mid_valid), .in_ready(mid_ready), .in_data(mid_data),
.out_valid(out_valid), .out_ready(out_ready), .out_data(out_data));
endmodule4c. What holds the payload stable — the stability guarantee
Before the bug, make the stability guarantee explicit, because it is the property most engineers get accidentally right and then break during a refactor. In §4a the payload register updates on in_fire only. Under backpressure in_ready is low, so in_fire is impossible, so out_data cannot change — it is held bit-for-bit until the transfer completes. This is the 9.1 rule data must remain stable while valid is high and ready is low satisfied for free by the gating. A correct stage never needs a separate "freeze the data" clause; if you find yourself adding one, the accept condition is wrong. The §5 verification asserts this directly: while out_valid is high and out_ready is low, out_data this cycle must equal out_data last cycle.
// Bind or embed alongside the DUT. Conceptual SVA-style property (v1-level intent):
// whenever we are presenting a valid word that is NOT being taken, next cycle the
// valid stays high and the data is UNCHANGED. This is the 9.1 hold, verified.
logic [WIDTH-1:0] data_q;
logic valid_q, stalled_q;
always_ff @(posedge clk) begin
data_q <= out_data;
valid_q <= out_valid;
stalled_q <= out_valid && !out_ready; // we were stalled last cycle
if (stalled_q) begin
assert (out_valid) // valid must not drop during a stall
else $error("valid dropped while backpressured");
assert (out_data === data_q) // data must be held bit-for-bit
else $error("data changed while backpressured: %h -> %h", data_q, out_data);
end
end4d. The swallowed-word bug — ungated input ready vs the gated-ready fix
Pattern (d) is the signature failure, shown here as buggy vs fixed RTL in each language and dramatized in §7. The bug is one line: a block computes its input ready as a constant 1 — always accepting — instead of gating it on can I move my current output forward. When the sink de-asserts ready and the block is holding a valid output, a fresh input word arrives, the register accepts it (because in_ready was stuck high), the held word is overwritten and lost, and no one ever knows — the chain simply delivers one fewer word. It passes every test where the sink is always ready and corrupts under any backpressure.
// BUGGY: in_ready is tied high. While the block holds a stalled output and the sink
// is not ready, a new input word still gets accepted -> it OVERWRITES the held
// word -> the held word is silently LOST. Backpressure does NOT propagate.
module vr_stage_bad #(parameter int WIDTH = 32)(
input logic clk, rst,
input logic in_valid, output logic in_ready, input logic [WIDTH-1:0] in_data,
output logic out_valid, input logic out_ready, output logic [WIDTH-1:0] out_data
);
assign in_ready = 1'b1; // <-- BUG: always accepting
wire in_fire = in_valid && in_ready;
wire out_fire = out_valid && out_ready;
always_ff @(posedge clk) begin
if (rst) begin out_valid <= 0; out_data <= '0; end
else begin
if (out_fire) out_valid <= 1'b0;
if (in_fire) begin // fires even while stalled ->
out_valid <= 1'b1; // overwrites the held word
out_data <= in_data;
end
end
end
endmodule
// FIXED: gate in_ready on downstream backpressure. Accept a new word only when the
// slot is empty OR is emptying this cycle -> the held word is never clobbered,
// and the stall propagates upstream.
module vr_stage_good #(parameter int WIDTH = 32)(
input logic clk, rst,
input logic in_valid, output logic in_ready, input logic [WIDTH-1:0] in_data,
output logic out_valid, input logic out_ready, output logic [WIDTH-1:0] out_data
);
assign in_ready = (!out_valid) || out_ready; // <-- FIX: gated accept
wire in_fire = in_valid && in_ready;
wire out_fire = out_valid && out_ready;
always_ff @(posedge clk) begin
if (rst) begin out_valid <= 0; out_data <= '0; end
else begin
if (out_fire) out_valid <= 1'b0;
if (in_fire) begin out_valid <= 1'b1; out_data <= in_data; end
end
end
endmodule module vr_bug_tb;
localparam int WIDTH = 32, NWORDS = 200;
logic clk = 0, rst = 1, in_valid, in_ready, out_valid, out_ready;
logic [WIDTH-1:0] in_data, out_data;
int sent = 0, recv = 0, errors = 0;
// Bind to vr_stage_good to PASS; to vr_stage_bad to watch words vanish.
vr_stage_good #(.WIDTH(WIDTH)) dut (.*);
always #5 clk = ~clk;
initial begin
in_valid = 0; in_data = 0; out_ready = 0;
repeat (2) @(posedge clk); rst = 0;
repeat (4*NWORDS) begin
@(posedge clk);
if (out_valid && out_ready) begin
// exactly-once & in order: word #recv out must equal recv
assert (out_data === recv)
else begin $error("LOST/REORDERED: got %0d exp %0d", out_data, recv); errors++; end
recv++;
end
if (in_valid && in_ready) sent++;
in_valid = (sent < NWORDS);
in_data = sent;
out_ready = $urandom_range(0, 1); // backpressure exposes the bug
if (recv == NWORDS) break;
end
// The bad DUT swallows words: sent reaches NWORDS but recv never does, or
// out_data arrives out of order because a held word was overwritten.
if (recv == NWORDS && errors == 0)
$display("PASS: no word lost/dup/reordered under backpressure (gated accept)");
else $display("FAIL: recv=%0d of %0d, errors=%0d (words swallowed under backpressure)", recv, NWORDS, errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same one-line difference: assign in_ready = 1'b1; versus assign in_ready = (~out_valid) | out_ready;. Under random out_ready the bad stage's held word gets overwritten and the scoreboard sees a gap.
// BUGGY: always accepting -> overwrites the held word under backpressure.
module vr_stage_bad #(parameter WIDTH = 32)(
input wire clk, rst,
input wire in_valid, output wire in_ready, input wire [WIDTH-1:0] in_data,
output reg out_valid, input wire out_ready, output reg [WIDTH-1:0] out_data
);
assign in_ready = 1'b1; // BUG
wire in_fire = in_valid & in_ready;
wire out_fire = out_valid & out_ready;
always @(posedge clk) begin
if (rst) begin out_valid <= 1'b0; out_data <= {WIDTH{1'b0}}; end
else begin
if (out_fire) out_valid <= 1'b0;
if (in_fire) begin out_valid <= 1'b1; out_data <= in_data; end
end
end
endmodule
// FIXED: gate in_ready on downstream readiness -> stall propagates, no word lost.
module vr_stage_good #(parameter WIDTH = 32)(
input wire clk, rst,
input wire in_valid, output wire in_ready, input wire [WIDTH-1:0] in_data,
output reg out_valid, input wire out_ready, output reg [WIDTH-1:0] out_data
);
assign in_ready = (~out_valid) | out_ready; // FIX: gated accept
wire in_fire = in_valid & in_ready;
wire out_fire = out_valid & out_ready;
always @(posedge clk) begin
if (rst) begin out_valid <= 1'b0; out_data <= {WIDTH{1'b0}}; end
else begin
if (out_fire) out_valid <= 1'b0;
if (in_fire) begin out_valid <= 1'b1; out_data <= in_data; end
end
end
endmodule module vr_bug_tb;
parameter WIDTH = 32, NWORDS = 200;
reg clk, rst, in_valid, out_ready;
wire in_ready, out_valid;
reg [WIDTH-1:0] in_data;
wire [WIDTH-1:0] out_data;
integer sent, recv, errors, guard;
// Bind to vr_stage_good to PASS; to vr_stage_bad to see words vanish.
vr_stage_good #(.WIDTH(WIDTH)) dut (
.clk(clk), .rst(rst),
.in_valid(in_valid), .in_ready(in_ready), .in_data(in_data),
.out_valid(out_valid), .out_ready(out_ready), .out_data(out_data));
initial clk = 0; always #5 clk = ~clk;
initial begin
rst = 1; in_valid = 0; in_data = 0; out_ready = 0;
sent = 0; recv = 0; errors = 0; guard = 0;
repeat (2) @(posedge clk); rst = 0;
while (recv < NWORDS && guard < 8*NWORDS) begin
@(posedge clk);
guard = guard + 1;
if (out_valid && out_ready) begin
if (out_data !== recv) begin
$display("FAIL: lost/reordered got %0d exp %0d", out_data, recv);
errors = errors + 1;
end
recv = recv + 1;
end
if (in_valid && in_ready) sent = sent + 1;
in_valid = (sent < NWORDS);
in_data = sent;
out_ready = $random & 1'b1;
end
if (recv == NWORDS && errors == 0)
$display("PASS: no word lost/reordered under backpressure");
else $display("FAIL: recv=%0d of %0d errors=%0d (swallowed under backpressure)", recv, NWORDS, errors);
$finish;
end
endmoduleIn VHDL the bug is the same: in_ready <= '1'; (always accepting) versus in_ready <= (not ovalid) or out_ready; (gated). The buggy architecture overwrites the held payload whenever a word arrives during a stall.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: always accepting -> a word arriving during a stall overwrites the held one.
entity vr_stage_bad is
generic ( WIDTH : positive := 32 );
port ( clk, rst : in std_logic;
in_valid : in std_logic; in_ready : out std_logic;
in_data : in std_logic_vector(WIDTH-1 downto 0);
out_valid : out std_logic; out_ready : in std_logic;
out_data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of vr_stage_bad is
signal ovalid : std_logic := '0';
begin
out_valid <= ovalid;
in_ready <= '1'; -- BUG: always accepting
process (clk)
variable inf, outf : boolean;
begin
if rising_edge(clk) then
inf := (in_valid = '1'); -- accepts regardless of stall
outf := (ovalid = '1') and (out_ready = '1');
if rst = '1' then ovalid <= '0'; out_data <= (others => '0');
else
if outf then ovalid <= '0'; end if;
if inf then ovalid <= '1'; out_data <= in_data; end if; -- clobbers held word
end if;
end if;
end process;
end architecture;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- FIXED: gate in_ready on downstream backpressure -> the stall propagates upstream.
entity vr_stage_good is
generic ( WIDTH : positive := 32 );
port ( clk, rst : in std_logic;
in_valid : in std_logic; in_ready : out std_logic;
in_data : in std_logic_vector(WIDTH-1 downto 0);
out_valid : out std_logic; out_ready : in std_logic;
out_data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of vr_stage_good is
signal ovalid : std_logic := '0';
begin
out_valid <= ovalid;
in_ready <= (not ovalid) or out_ready; -- FIX: gated accept
process (clk)
variable inf, outf : boolean;
begin
if rising_edge(clk) then
inf := (in_valid = '1') and ((ovalid = '0') or (out_ready = '1'));
outf := (ovalid = '1') and (out_ready = '1');
if rst = '1' then ovalid <= '0'; out_data <= (others => '0');
else
if outf then ovalid <= '0'; end if;
if inf then ovalid <= '1'; out_data <= in_data; end if;
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity vr_bug_tb is
end entity;
architecture sim of vr_bug_tb is
constant WIDTH : positive := 32;
constant NWORDS : natural := 200;
signal clk : std_logic := '0';
signal rst : std_logic := '1';
signal in_valid : std_logic := '0';
signal in_ready : std_logic;
signal in_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal out_valid : std_logic;
signal out_ready : std_logic := '0';
signal out_data : std_logic_vector(WIDTH-1 downto 0);
begin
-- Bind to vr_stage_good to PASS; to vr_stage_bad to observe lost words.
dut : entity work.vr_stage_good
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst,
in_valid => in_valid, in_ready => in_ready, in_data => in_data,
out_valid => out_valid, out_ready => out_ready, out_data => out_data);
clk <= not clk after 5 ns;
stim : process
variable sent, recv : natural := 0;
variable errors : natural := 0;
variable lfsr : unsigned(7 downto 0) := x"5C";
variable guard : natural := 0;
begin
wait until rising_edge(clk); wait until rising_edge(clk); rst <= '0';
while (recv < NWORDS) and (guard < 8*NWORDS) loop
guard := guard + 1;
if (out_valid = '1') and (out_ready = '1') then
assert out_data = std_logic_vector(to_unsigned(recv, WIDTH))
report "lost/reordered word under backpressure" severity error;
if out_data /= std_logic_vector(to_unsigned(recv, WIDTH)) then
errors := errors + 1;
end if;
recv := recv + 1;
end if;
if (in_valid = '1') and (in_ready = '1') then sent := sent + 1; end if;
if sent < NWORDS then in_valid <= '1'; else in_valid <= '0'; end if;
in_data <= std_logic_vector(to_unsigned(sent, WIDTH));
lfsr := (lfsr(6 downto 0) & (lfsr(7) xor lfsr(5)));
out_ready <= lfsr(0);
wait until rising_edge(clk);
end loop;
assert (recv = NWORDS) and (errors = 0)
report "words swallowed under backpressure (ungated in_ready)" severity error;
report "vr_bug self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: a block may accept a new input word only on a cycle where its held output can move — in_ready = !out_valid || out_ready — or backpressure fails to propagate and a word is silently swallowed.
5. Verification Strategy
Backpressure correctness is a conservation law, and the good news is that a scoreboard testbench checks it directly: drive an identifiable stream into the source, jerk the sink's ready around randomly, and prove the sink receives exactly that stream — every word, once, in order. The single invariant that captures a correct back-pressuring chain is:
No word is created or destroyed under backpressure. Every word that enters leaves exactly once, in the same order, for any pattern of downstream
ready.
Everything below is that one invariant made checkable, and it maps directly onto the clocked testbenches in §4.
- Scoreboard testbench with random backpressure (self-checking). Drive an incrementing sequence (
0, 1, 2, …) into the source and de-assert the sink'sreadyon a random subset of cycles. The consumer side counts words out and asserts the k-th word out equals k — this proves in-order and exactly-once in one check, because a lost word makes the count fall short and a reordered/duplicated word makesout_data !== recv. The three §4a/§4d testbenches do exactly this: SVassert (out_data === recv), Verilogif (out_data !== recv) $display("FAIL…"), VHDLassert out_data = to_unsigned(recv,…) report … severity error. Run the same test againstvr_stage_badand it must fail — a verification suite that passes the buggy DUT is not testing backpressure. - Flow-conservation invariant. State it as the property that must hold at end of test:
words_sent == words_received == NWORDS, with no gaps. Conceptually (v1-level, no SVA class needed): the number of accepted input transfers (in_valid && in_ready) always equals the number of completed output transfers (out_valid && out_ready) plus the number currently held in the chain. If that identity ever breaks, a block dropped or duplicated a word. - Payload-stability assertion under backpressure. While
out_validis high andout_readyis low,out_datanext cycle must equalout_datathis cycle, andout_validmust stay high — the §4c check. This catches a block that stalls its output but lets its data lines wobble, and a block that dropsvalidmid-stall (which would look to the consumer like the word vanished). - Source-stall corner. Assert that the source actually stalls when the chain is back-pressured: when the sink holds
readylow long enough to fill the chain, the source'sin_readymust go low and the source must stop advancing (its offered word does not change). Drive a burst, hold the sink off, and confirm nothing enters the chain during the stall — this proves the ripple reached all the way back. - Corner cases to exercise. Sink
readystuck low for many cycles (full stall, chain fills and holds); sinkreadytoggling every cycle (maximal jitter — the worst case for a lost word); a single-cyclereadypulse (one word slips through, the rest hold);readylow across arstdeassert (the chain must start empty and lose nothing); back-to-back full-throughput (readyalways high, one word per cycle — no bubbles, the baseline). - Expected waveform. On a correct run, whenever
out_valid && !out_ready,out_datais a flat horizontal line (held) andout_validstays high across the stall; the momentout_readyrises, that word transfers and the next appears. Tracein_readyand you see it fall the cycle the sink stalls and the chain is full — the visible signature of the ripple reaching the source. The buggy DUT's waveform showsin_readystuck high through a stall andout_datachanging whileout_valid && !out_ready— the held word being overwritten, live.
6. Common Mistakes
Producing anyway under backpressure (advancing without valid && ready). The most fundamental error: a block updates its output or considers a word "sent" on a cycle where out_ready was low. In valid/ready, a transfer happens only on valid && ready — if you advance state assuming the word left when it did not, the downstream never took it and the word is lost. Every state update that represents "this word moved on" must be gated on out_valid && out_ready, never on out_valid alone. This is the root of most backpressure data loss.
Not propagating the stall (holding output but still accepting input). A block correctly freezes its output when the sink stalls, but leaves its input ready high — so it keeps swallowing upstream words while its output slot is occupied, overwriting the held word (the §7 bug) or, in a deeper buffer, overrunning it. Holding your output is only half the job; the other half is closing the door upstream by gating in_ready on whether your current output can move. A stall that does not ripple back is not a stall, it is a leak.
Changing the payload while backpressured. The block holds out_valid high but lets out_data change cycle to cycle while out_ready is low. The consumer, which samples on the cycle it finally asserts ready, then captures a different word than the one that was presented earlier — silent corruption that looks like data getting scrambled. The 9.1 rule is absolute: while valid is high and ready is low, data must be bit-for-bit stable. In the register-stage pattern this is automatic (the payload updates only on accept); it breaks when someone drives out_data from combinational logic that keeps recomputing.
Combinational ready/valid loop. Gating in_ready on out_ready combinationally is correct — but if a block also makes its out_valid depend combinationally on its in_valid (a fully-combinational passthrough on both signals), a chain can form a combinational cycle where ready depends on valid depends on ready. It either fails to converge in simulation or creates a real combinational loop in synthesis. The register-stage pattern breaks this by registering out_valid; a purely combinational stage must break the loop on one direction (which is part of what the skid buffer of 9.3 formalizes).
Ignoring the length of the combinational ready path. Because ready ripples back through every block combinationally in one cycle, a long chain has a ready signal whose delay grows with the number of blocks — a timing-critical path that can miss its clock period. Treating a ten-stage valid/ready chain as if each link were independent ignores that the whole backward ready path is one combinational arc. The fix is architectural: register ready at intervals (skid buffers, 9.3) to segment the path, trading a cycle of latency for a shorter critical path.
Assuming full throughput because it passed with ready always high. A chain tested only with the sink always ready never exercises a single stall, so the swallowed-word bug (§7) is invisible — the block's ungated in_ready is never wrong when nothing ever back-pressures. The test that matters is random backpressure; a suite without it validates nothing about flow control. If your backpressure test passes the buggy DUT, the test, not the DUT, is broken.
7. DebugLab
The word that vanished under backpressure — a stall that never propagated upstream
The engineering lesson: backpressure must ripple all the way back — a block may accept a new input word only when it can move its current output. Holding your output under a downstream stall is only half the contract; the other half is de-asserting your input ready so the block above you holds too, and so on to the source. The tell is data loss that depends on when the sink stalls: a stream that is complete when the consumer is fast and short by a word or two when it back-pressures means a stall stopped at some block's output and never reached its input. Two habits make it impossible, and they are identical across SystemVerilog, Verilog, and VHDL: gate every block's in_ready on whether its current output can advance (!out_valid || out_ready), and verify with random downstream backpressure and a scoreboard, so no stall pattern goes untested. Ready flows backward exactly as data flows forward — build both directions or the contract is broken.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the "data forward, ready backward, and the stall must ripple" habit.
Exercise 1 — Trace the ripple
A chain is source → A → B → C → sink, each block a single-entry valid/ready stage, all currently holding a valid word (the chain is full). On cycle t the sink de-asserts ready. Write down, for cycle t, the value of each link's ready (sink→C, C→B, B→A, A→source) and state which blocks hold and whether the source advances. Then the sink re-asserts ready on cycle t+1: describe what moves and in what order. (Hint: ready is combinational within a cycle; the whole backward arc resolves in cycle t.)
Exercise 2 — Predict the loss
Two engineers build the compute block from §1. Engineer A writes assign in_ready = !out_valid || out_ready;. Engineer B writes assign in_ready = 1'b1;. Both pass a testbench where the sink is always ready. Describe (i) exactly which cycle B's block first loses a word once the sink de-asserts ready, (ii) what the scoreboard sees (sent vs received count, and the first out-of-order value), and (iii) why A's block is safe by construction. Then say what one change to the testbench would have exposed B's bug on the first run.
Exercise 3 — Where the payload must be held
For the §4a stage, prove on paper that out_data cannot change while out_valid && !out_ready. Identify the exact signal that gates the payload register, show why it is low during a stall, and then describe a plausible refactor (e.g. driving out_data from combinational logic instead of a register) that would break the stability guarantee even though out_valid still holds — and what the consumer would observe as a result.
Exercise 4 — Size the timing problem
You have a valid/ready chain of 12 combinational-ready stages, and static timing shows the backward ready path is now the critical path. Without changing the number of stages, give two architectural changes that shorten the ready arc, and name the cost each introduces (latency, storage, or throughput). (Hint: think about registering ready, where a skid buffer fits, and whether every link truly needs a combinational ready.)
10. Related Tutorials
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- valid/ready Handshake — Chapter 9.1; the direct prerequisite — the two-signal contract, what one transfer is (
valid && ready), and the producer-holds rule this page propagates through a chain. - Skid Buffer — Chapter 9.3; registers
readyto break the long combinational backward path this page's chain suffers from, without dropping the in-flight word — the timing fix for backpressure. - Credit-Based Flow Control — Chapter 9.4; the alternative to per-cycle backpressure — the consumer grants credits ahead of time so the producer never has to stall on a combinational
ready. - Handshake Bugs & Anti-Patterns — Chapter 9.5; the catalogue of valid/ready failures, of which the swallowed word here is one.
- Ready/Valid Pipelining — Chapter 9.6; composing back-pressuring stages into a full-throughput pipeline.
In-track dependencies (what backpressure drives and buffers):
- Pipeline Stalls, Bubbles & Flush — Chapter 8.4; the stall mechanism this page rides —
stall = valid_out && !ready_out, clock-enable freeze upstream, bubble below — now driven by the interfacereadyinstead of a manual stall wire. - Pipeline Valid-Bit Discipline — Chapter 8.3; carrying
validalongside data, the forward half of the contract whose backward half is backpressure. - Pipelined Datapath — Chapter 8.2; the forward-marching pipeline that backpressure teaches how to stall correctly.
- Synchronous FIFO Architecture — Chapter 7.1; the elastic buffer that absorbs backpressure — a FIFO between producer and consumer decouples their rates so the stall doesn't ripple every cycle.
- FIFO Full/Empty Logic — Chapter 7.2;
fullis a FIFO's backpressure (it de-asserts write-ready) andemptygates its read-valid— the same contract, named differently. - Register with Enable/Load — Chapter 2.3; the clock-enabled register that a valid/ready stage is built from — enable = accept, hold = stall.
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Blocking and Non-Blocking Assignments — why the payload/valid registers use non-blocking (
<=) in the clocked block, and the gated-accept combinational logic uses continuous assignment. - If-Else Statements — the conditional structure behind the accept/hold decision inside the clocked block.
- Case Statements — the construct a multi-state flow-control block generalizes to when a stage carries more than one entry.
11. Summary
- A valid/ready chain carries two flows in opposite directions. Data flows forward (
valid,data, producer-driven); readiness — backpressure — flows backward (ready, consumer-driven). A word transfers only on a cycle wherevalid && ready; onvalid && !readythe producer must holdvalidhigh anddatastable. Backpressure is not a bolt-on — it is the interface seen from the readiness direction. - A stall must propagate, not stay local. A block that cannot hand off its output must both freeze that output and de-assert its own input
ready, so the block above holds too. That refusal ripples upstream, block by block, on the same cycle, until it reaches the source, which holds per 9.1. This is the 8.4 stall (stall = valid_out && !ready_out, clock-enable freeze upstream, bubble below), now driven by the interfaceready. - The gated-accept rule is the whole pattern. A single-entry stage asserts
in_ready = !out_valid || out_ready— accept a new word only when the output slot is empty or is emptying this cycle. This one line both propagates the stall and holds the payload stable for free (the data register updates only on an accepted transfer), and replacing it with a constant1is the signature swallowed-word bug of §7. - The backward
readypath is combinational and grows with chain length. Becausereadyripples through every block's accept gate in one cycle, a long chain has a timing-critical backward arc — the problem the skid buffer (9.3) solves by registeringready, trading a cycle of latency for a shorter path. - Correctness is flow conservation, proved by a scoreboard under random backpressure. No word is created or destroyed: every word in leaves exactly once, in order, for any
readypattern. Verify by driving an identifiable stream, jittering the sink'sreadyrandomly, and asserting the k-th word out equals k — self-checking testbenches shown here in SystemVerilog, Verilog, and VHDL, each of which fails the ungated-in_readyDUT and passes the gated fix.