RTL Design Patterns · Chapter 9 · Handshake & Flow Control
Common Handshake Bugs
The valid and ready handshake is simple, just three wires and three rules, yet the same small set of violations recurs: silicon that hangs on one traffic pattern, a stream that drops one word in a thousand, a block that passed every unit test and then deadlocked once it met its neighbour. This lesson is a field guide to those failures. Every handshake bug is either a liveness bug, where a transfer that should happen never does and the link hangs, or a safety bug, where a word is lost, duplicated, or sampled wrong. The canonical cases include valid gated by ready, valid dropped before the accept, data mutated while valid is held, sampling on valid alone, a ready glitch, and valid asserted out of reset before the data is real. Each gets its symptom, root cause, and exact fix, with the two most instructive shown buggy versus fixed against a scoreboard, in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design Patternsvalid/readyHandshakeDeadlockBackpressureDebugging
Chapter 9 · Section 9.5 · Handshake & Flow Control
1. The Engineering Problem
You have shipped a block that uses the valid/ready handshake from 9.1 correctly — or so every unit test said. It streamed words in order, exactly once, through randomized backpressure, and passed. Then it went into the system, and the bug reports started. On one program the whole pipeline hangs, dead, with valid and ready both stuck low. On another it runs but the downstream checker occasionally sees a word that is off by a few, or a word that arrives twice, or a word that is simply missing from an otherwise-perfect stream. None of it reproduced in the block's own testbench. All of it is a handshake bug.
This is the recurring shape of handshake failure, and it is why the contract needs a catalog even though the contract itself is three wires and three rules. The rules are simple, but each one has a characteristic way of being broken, and the break is usually invisible in isolation — it needs a real neighbour, a real stall, or a real reset to surface. A source that gates valid with ready looks fine against an always-ready stub and deadlocks only against a real sink. A source that mutates its payload under a held offer looks fine against an always-ready sink and corrupts a word only under backpressure. A sink that samples on valid alone looks fine when it never stalls and duplicates a word the first time it does. The bug hides in exactly the case the unit test did not exercise.
So the engineering problem here is not how to build a handshake — 9.1 and 9.2 did that. It is how to recognize a broken one: given a link that hangs, or a stream with the wrong count or a wrong value, how do you classify the failure and name the rule it violated fast enough to fix it — ideally on the whiteboard, from ten lines of RTL, before it ever reaches silicon. That reflex is what this page builds, and it starts with a taxonomy that turns a confusing symptom into a two-way decision.
// A handshake link that passed its own unit tests, now misbehaving in the system.
// The symptoms cluster into exactly two families:
// LIVENESS failure -- a transfer that should happen NEVER does:
// valid and ready both stuck low forever -> the link is DEAD (a hang / deadlock).
// SAFETY failures -- a transfer happens, but WRONGLY:
// a word is LOST (count too low),
// a word is DUPLICATED (count too high),
// a word is MIS-SAMPLED (count right, value wrong).
// Every one traces back to a violated rule from 9.1. The catalog names which.2. Mental Model
3. Pattern Anatomy
The reference handshake is the fixed point against which every bug is measured, so the anatomy restates it — tersely, because 9.1 built it — and then lays out the failure taxonomy that hangs off it.
The correct handshake, restated. Three wires: a data bus forward, a one-bit valid forward, a one-bit ready backward. One derived signal: a transfer is exactly valid && ready, computed identically by both sides. Three rules govern how each side may drive its wire:
- Rule 1 —
validcomes from local state only, never fromready. The producer assertsvalidbecause it has a word, full stop. Ifvalidlooked atreadyandreadylooked atvalid, the two would form a combinational loop and could deadlock. - Rule 2 — once
validis high, hold it withdatabit-for-bit stable until the transfer. No retracting the offer, no editing the payload mid-offer. The word is frozen from the moment it is offered until the cycle it is taken. - Rule 3 —
readymay depend combinationally onvalid. A sink can look before it leaps. The asymmetry is one-way and deliberate: becausevalidnever looks back, no loop forms.
Get those right and the link delivers every word once, in order, with the correct value — the safety property — and always eventually moves — the liveness property. Every bug below is one of those two properties broken by one of those three rules violated.
The failure taxonomy. Six canonical bugs, split first by liveness versus safety and, within safety, by count-wrong versus value-wrong:
- Liveness —
validgated byready(breaks Rule 1). The source computesvalid = have_data && ready; wired to a sink whosereadylooks atvalid, the two deadlock. Nothing ever moves. (DebugLab row 1.) - Safety, count-wrong (lost) —
validdropped before the accept (breaks Rule 2). The source de-assertsvalidfor a cycle while an offer is still standing, or advances its index onvalidrather than on the transfer, so a word offered during a stall is skipped and lost. (DebugLab row 2; full tri-HDL in §4.) - Safety, value-wrong (mis-sample) —
datamutated whilevalidheld (breaks Rule 2). The payload changes before the accept, so a stalled sink latches a later value than the one offered. Count right, value wrong. (DebugLab row 3; full tri-HDL in §4.) - Safety, count-wrong (duplicate) — sampling on
validalone (ignoresready). The sink captures whenevervalidis high, without checkingready, so it latches the same held word every stall cycle — a duplicate, or a word it had no room for. (DebugLab row 4.) - Safety — a combinational
readyglitch (a racy Rule 3).readyis a multi-level combinational function that momentarily pulses high while its inputs settle, registering a phantom accept the source also sees — a word dropped or double-counted. - Safety —
validasserted out of reset before data is ready.validresets high (or floats) while the payload is stillXor stale, so the first word out is garbage the sink dutifully accepts.
The handshake bug taxonomy — classify the symptom, then name the broken rule
data flowWhy the symptom, not the RTL, is the entry point. Handshake bugs are notorious for being invisible in isolation and obvious in the taxonomy. The deadlock needs two blocks that each look at the other; a single block against a stub never closes the loop. The lost word and the mis-sample need backpressure; against an always-ready sink the offer is accepted the cycle it is made, so valid never has to be held and the payload never has a chance to drift. The duplicate needs the sink to stall while valid is held. In every case the unit test that passed simply never entered the state where the rule mattered. That is why you debug from the symptom down the taxonomy, not from the RTL up — the RTL looks correct until you know which case to put it in.
4. Real RTL Implementation
This is a debugging clinic, so the RTL here is buggy source versus corrected source, each wired to a reference sink/scoreboard whose self-checking testbench fails on the buggy DUT and passes on the fixed one. We take the two most instructive safety bugs — (a) valid dropped before the accept, a lost word, and (b) data mutated while valid is held, a mis-sample — and show each in SystemVerilog, Verilog, and VHDL. A shared reference sink and scoreboard (built once, in SystemVerilog, and mirrored in the other two) does the catching: it drives gappy backpressure and checks in-order, exactly-once delivery with the offered word tied to the captured word. The remaining catalog bugs (the deadlock, the duplicate, the glitch, the reset-garbage) are dissected in §7 and prevented in §6.
One discipline runs through every fixed block, inherited from 9.1: registers update on the clock with non-blocking assignments, reset is explicit (synchronous, active-high rst), the source drives valid from its own registered state only, holds valid and data stable while valid && !ready, and advances only on the transfer valid && ready.
4a. The reference sink and scoreboard — the bug-catcher
Both buggy sources are wired to the same reference sink and scoreboard. The sink applies gappy backpressure (that is what surfaces the bugs) and captures on the transfer; the scoreboard ties each captured word to the word the source offered — the check that a count-only scoreboard would miss. This is the SystemVerilog reference; the Verilog and VHDL versions mirror it inline with each DUT.
module ref_sink #(
parameter int WIDTH = 32
)(
input logic clk,
input logic rst,
input logic valid, // forward from the source under test
input logic [WIDTH-1:0] data,
input logic ready_gap, // 0 on the cycles we want to STALL
output logic ready, // ready from local state (never gates valid)
output logic got, // pulses on each accepted word
output logic [WIDTH-1:0] captured // the word we accepted this cycle
);
// ready is driven from LOCAL state only -- a clean, non-gating backpressure source.
// ready_gap lets the testbench inject stalls to exercise the held-offer path.
assign ready = ready_gap;
wire xfer = valid & ready; // the ONE transfer condition
always_ff @(posedge clk) begin
if (rst) begin
got <= 1'b0; captured <= '0;
end else begin
got <= xfer; // one pulse per ACCEPTED word
if (xfer) captured <= data; // capture ONLY on the transfer
end
end
endmodule4b. Bug (a) — valid dropped before the accept (a lost word), buggy vs fixed
The buggy source advances its index on valid alone — every cycle it has a word, whether or not that word was accepted — so any word offered during a stall is skipped and never delivered. The symptom is a lost word: the scoreboard receives fewer words than were emitted. The fix is Rule 2 plus the transfer rule: advance only on valid && ready.
// BUGGY: the index advances every cycle VALID is high, regardless of READY. Any word
// offered on a stall cycle (valid high, ready low) is skipped -- the source moved on
// but the sink never accepted it. Against an always-ready sink this is invisible.
module src_drop_bad #(parameter int WIDTH = 32, parameter int NWORDS = 8) (
input logic clk, rst, ready,
output logic valid,
output logic [WIDTH-1:0] data
);
logic [$clog2(NWORDS+1)-1:0] idx;
logic have_data;
always_ff @(posedge clk) begin
if (rst) begin idx <= '0; have_data <= 1'b1; end
else if (valid) begin // WRONG: advances on VALID, not on transfer
if (idx == NWORDS-1) have_data <= 1'b0;
else idx <= idx + 1'b1;
end
end
assign valid = have_data; // Rule 1 OK -- valid is local
assign data = 32'hA0 + idx; // word k = 0xA0 + k
endmodule
// FIXED: advance ONLY on the transfer (valid && ready). A stalled word stays offered,
// with the same index and same data, until the cycle it is actually accepted (Rule 2).
module src_drop_good #(parameter int WIDTH = 32, parameter int NWORDS = 8) (
input logic clk, rst, ready,
output logic valid,
output logic [WIDTH-1:0] data
);
logic [$clog2(NWORDS+1)-1:0] idx;
logic have_data;
wire xfer = valid & ready; // the transfer condition
always_ff @(posedge clk) begin
if (rst) begin idx <= '0; have_data <= 1'b1; end
else if (xfer) begin // RIGHT: advance ONLY on the transfer
if (idx == NWORDS-1) have_data <= 1'b0;
else idx <= idx + 1'b1;
end
end
assign valid = have_data;
assign data = 32'hA0 + idx; // held stable while idx is held (Rule 2)
endmoduleThe testbench wires the source to the reference sink, drives gappy backpressure, and asserts the scoreboard invariant: every emitted word is received exactly once, in order. The buggy source loses words under the stalls (received count too low); the fixed source delivers all NWORDS.
module src_drop_tb;
localparam int WIDTH = 32, NWORDS = 8;
logic clk = 1'b0, rst, valid, ready, ready_gap, got;
logic [WIDTH-1:0] data, captured;
int errors = 0, received = 0;
logic [WIDTH-1:0] expect_word = 32'hA0;
// Bind to src_drop_good to PASS; swap to src_drop_bad to WATCH words vanish.
src_drop_good #(.WIDTH(WIDTH), .NWORDS(NWORDS)) dut (
.clk(clk), .rst(rst), .ready(ready), .valid(valid), .data(data));
ref_sink #(.WIDTH(WIDTH)) snk (
.clk(clk), .rst(rst), .valid(valid), .data(data), .ready_gap(ready_gap),
.ready(ready), .got(got), .captured(captured));
always #5 clk = ~clk;
initial begin
rst = 1'b1; ready_gap = 1'b0;
@(posedge clk); #1; rst = 1'b0;
for (int t = 0; t < 6*NWORDS; t++) begin
@(negedge clk);
ready_gap = ($urandom_range(0, 2) != 0); // ~2/3 ready -> frequent stalls
@(posedge clk); #1;
if (got) begin // an accepted word must be next-in-order
assert (captured === expect_word)
else begin $error("t=%0d out-of-order/dup: got %h exp %h", t, captured, expect_word); errors++; end
expect_word = expect_word + 1;
received++;
end
end
// The whole bug: the buggy source delivers FEWER than NWORDS -- words were dropped.
assert (received === NWORDS)
else begin $error("received %0d words, expected %0d -- WORD(S) LOST", received, NWORDS); errors++; end
if (errors == 0) $display("PASS: all %0d words delivered in order, none lost", received);
else $display("FAIL: %0d errors (dropped word under stall)", errors);
$finish;
end
endmoduleThe Verilog form is the same story with reg/wire typing and an inline reference sink; the bug is the single if (valid) where it should be if (valid & ready).
// BUGGY: advance on VALID alone -> words offered during a stall are skipped.
module src_drop_bad #(parameter WIDTH = 32, parameter NWORDS = 8) (
input wire clk, rst, ready,
output wire valid, output wire [WIDTH-1:0] data
);
reg [31:0] idx; reg have_data;
always @(posedge clk) begin
if (rst) begin idx <= 0; have_data <= 1'b1; end
else if (valid) begin // WRONG: not gated by the transfer
if (idx == NWORDS-1) have_data <= 1'b0;
else idx <= idx + 1'b1;
end
end
assign valid = have_data;
assign data = 32'hA0 + idx;
endmodule
// FIXED: advance only on the transfer (valid & ready).
module src_drop_good #(parameter WIDTH = 32, parameter NWORDS = 8) (
input wire clk, rst, ready,
output wire valid, output wire [WIDTH-1:0] data
);
reg [31:0] idx; reg have_data;
wire xfer = valid & ready;
always @(posedge clk) begin
if (rst) begin idx <= 0; have_data <= 1'b1; end
else if (xfer) begin // RIGHT: gated by the transfer
if (idx == NWORDS-1) have_data <= 1'b0;
else idx <= idx + 1'b1;
end
end
assign valid = have_data;
assign data = 32'hA0 + idx;
endmodule module src_drop_tb;
parameter WIDTH = 32, NWORDS = 8;
reg clk, rst, ready_gap;
wire valid;
wire [WIDTH-1:0] data;
reg [WIDTH-1:0] captured, expect_word;
reg got;
integer t, errors, received;
wire ready = ready_gap; // reference sink: clean local ready
wire xfer = valid & ready;
src_drop_good #(.WIDTH(WIDTH), .NWORDS(NWORDS)) dut (
.clk(clk), .rst(rst), .ready(ready), .valid(valid), .data(data));
// Inline reference sink: capture on the transfer only.
always @(posedge clk) begin
if (rst) begin got <= 1'b0; captured <= 0; end
else begin got <= xfer; if (xfer) captured <= data; end
end
always #5 clk = ~clk;
initial begin
errors = 0; received = 0; expect_word = 32'hA0;
clk = 1'b0; rst = 1'b1; ready_gap = 1'b0;
@(posedge clk); #1; rst = 1'b0;
for (t = 0; t < 6*NWORDS; t = t + 1) begin
@(negedge clk);
ready_gap = ($random % 3) != 0; // ~2/3 ready -> frequent stalls
@(posedge clk); #1;
if (got) begin
if (captured !== expect_word) begin
$display("FAIL: t=%0d out-of-order/dup: got %h exp %h", t, captured, expect_word);
errors = errors + 1;
end
expect_word = expect_word + 1;
received = received + 1;
end
end
if (received !== NWORDS) begin
$display("FAIL: received %0d words, expected %0d -- WORD(S) LOST", received, NWORDS);
errors = errors + 1;
end
if (errors == 0) $display("PASS: all %0d words delivered, none lost", received);
else $display("FAIL: %0d errors (dropped word under stall)", errors);
$finish;
end
endmoduleIn VHDL the same bug is elsif valid = '1' where the fix is elsif (valid and ready) = '1'; the reference sink is a clocked process capturing on the transfer.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: advance on VALID alone -> a word offered during a stall is skipped.
entity src_drop_bad is
generic ( WIDTH : positive := 32; NWORDS : positive := 8 );
port ( clk, rst, ready : in std_logic;
valid : out std_logic;
data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of src_drop_bad is
signal idx : integer range 0 to NWORDS := 0;
signal have_data : std_logic := '1';
signal valid_i : std_logic;
begin
valid_i <= have_data;
valid <= valid_i;
data <= std_logic_vector(to_unsigned(16#A0# + idx, WIDTH));
process (clk) begin
if rising_edge(clk) then
if rst = '1' then idx <= 0; have_data <= '1';
elsif valid_i = '1' then -- WRONG: not gated by the transfer
if idx = NWORDS-1 then have_data <= '0';
else idx <= idx + 1;
end if;
end if;
end if;
end process;
end architecture;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- FIXED: advance ONLY on the transfer (valid and ready).
entity src_drop_good is
generic ( WIDTH : positive := 32; NWORDS : positive := 8 );
port ( clk, rst, ready : in std_logic;
valid : out std_logic;
data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of src_drop_good is
signal idx : integer range 0 to NWORDS := 0;
signal have_data : std_logic := '1';
signal valid_i : std_logic;
begin
valid_i <= have_data;
valid <= valid_i;
data <= std_logic_vector(to_unsigned(16#A0# + idx, WIDTH));
process (clk)
variable xfer : std_logic;
begin
if rising_edge(clk) then
xfer := valid_i and ready; -- the transfer condition
if rst = '1' then idx <= 0; have_data <= '1';
elsif xfer = '1' then -- RIGHT: gated by the transfer
if idx = NWORDS-1 then have_data <= '0';
else idx <= idx + 1;
end if;
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity src_drop_tb is
end entity;
architecture sim of src_drop_tb is
constant WIDTH : positive := 32; constant NWORDS : positive := 8;
signal clk : std_logic := '0';
signal rst, valid, ready, ready_gap, got : std_logic := '0';
signal data, captured : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
begin
-- Bind to src_drop_good to PASS; src_drop_bad loses words.
dut : entity work.src_drop_good
generic map (WIDTH => WIDTH, NWORDS => NWORDS)
port map (clk => clk, rst => rst, ready => ready, valid => valid, data => data);
ready <= ready_gap; -- reference sink: clean local ready
-- Inline reference sink: capture on the transfer only.
sink : process (clk)
variable xfer : std_logic;
begin
if rising_edge(clk) then
xfer := valid and ready;
if rst = '1' then got <= '0'; captured <= (others => '0');
else got <= xfer; if xfer = '1' then captured <= data; end if;
end if;
end if;
end process;
clkgen : process begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process;
stim : process
variable expect_word : integer := 16#A0#;
variable received : integer := 0;
variable errors : integer := 0;
variable seed1, seed2 : positive := 7;
variable r : real;
begin
rst <= '1'; ready_gap <= '0';
wait until rising_edge(clk); wait for 1 ns; rst <= '0';
for t in 0 to 6*NWORDS - 1 loop
wait until falling_edge(clk);
uniform(seed1, seed2, r); -- ~2/3 ready -> frequent stalls
if r < 0.66 then ready_gap <= '1'; else ready_gap <= '0'; end if;
wait until rising_edge(clk); wait for 1 ns;
if got = '1' then
assert captured = std_logic_vector(to_unsigned(expect_word, WIDTH))
report "out-of-order or duplicate word" severity error;
expect_word := expect_word + 1;
received := received + 1;
end if;
end loop;
assert received = NWORDS
report "word(s) lost -- received fewer than emitted" severity error;
report "src_drop self-check complete" severity note;
wait;
end process;
end architecture;4c. Bug (b) — data mutated while valid is held (a mis-sample), buggy vs fixed
The second bug keeps the count right and gets the value wrong. The buggy source drives data from a free-running counter that ticks every cycle, so while the sink stalls with valid held, the payload under the offer drifts. When ready finally rises, the sink latches a later value — the counter shifted by the number of stall cycles — not the word that was offered. The fix is Rule 2: register the payload and change it only on the transfer.
// BUGGY: valid is local (Rule 1 OK), but DATA is wired to a counter that advances every
// cycle, even while the offer is held. A stalled sink latches a DRIFTED value (Rule 2 broken).
module src_mutate_bad #(parameter int WIDTH = 32, parameter int NWORDS = 8) (
input logic clk, rst, ready,
output logic valid,
output logic [WIDTH-1:0] data
);
logic [WIDTH-1:0] counter;
logic [$clog2(NWORDS+1)-1:0] delivered;
wire xfer = valid & ready;
always_ff @(posedge clk) begin
if (rst) begin counter <= 32'hA0; delivered <= '0; end
else begin
counter <= counter + 1'b1; // WRONG: DATA drifts every cycle...
if (xfer) delivered <= delivered + 1'b1; // ...even while the offer is held
end
end
assign valid = (delivered < NWORDS); // valid from local state (Rule 1 OK)
assign data = counter; // but the payload is NOT frozen
endmodule
// FIXED: register the payload; change DATA only on the transfer, so the held offer is stable.
module src_mutate_good #(parameter int WIDTH = 32, parameter int NWORDS = 8) (
input logic clk, rst, ready,
output logic valid,
output logic [WIDTH-1:0] data
);
logic [WIDTH-1:0] data_reg;
logic [$clog2(NWORDS+1)-1:0] delivered;
wire xfer = valid & ready;
always_ff @(posedge clk) begin
if (rst) begin data_reg <= 32'hA0; delivered <= '0; end
else if (xfer) begin // RIGHT: change DATA only on the transfer
data_reg <= data_reg + 1'b1; // load the next word
delivered <= delivered + 1'b1;
end
// else HOLD: data_reg untouched, so the offer is frozen (Rule 2).
end
assign valid = (delivered < NWORDS);
assign data = data_reg; // stable across any stall
endmoduleThe scoreboard here adds the data-stable-under-stall check: whenever the previous cycle held a valid offer that was not accepted (valid && !ready), the data must be unchanged this cycle. That single assertion is what catches a drifting payload — a count-only scoreboard would pass.
module src_mutate_tb;
localparam int WIDTH = 32, NWORDS = 8;
logic clk = 1'b0, rst, valid, ready, ready_gap, got;
logic [WIDTH-1:0] data, captured;
int errors = 0, received = 0;
logic [WIDTH-1:0] expect_word = 32'hA0;
logic [WIDTH-1:0] prev_data; logic prev_valid, prev_ready;
// Bind to src_mutate_good to PASS; swap to src_mutate_bad to watch a stalled word drift.
src_mutate_good #(.WIDTH(WIDTH), .NWORDS(NWORDS)) dut (
.clk(clk), .rst(rst), .ready(ready), .valid(valid), .data(data));
ref_sink #(.WIDTH(WIDTH)) snk (
.clk(clk), .rst(rst), .valid(valid), .data(data), .ready_gap(ready_gap),
.ready(ready), .got(got), .captured(captured));
always #5 clk = ~clk;
initial begin
rst = 1'b1; ready_gap = 1'b0; prev_valid = 1'b0;
@(posedge clk); #1; rst = 1'b0;
for (int t = 0; t < 6*NWORDS; t++) begin
@(negedge clk);
ready_gap = ($urandom_range(0, 2) != 0);
prev_data = data; prev_valid = valid; prev_ready = ready;
@(posedge clk); #1;
// Rule 2: a held, unaccepted offer must keep the SAME data.
if (prev_valid && !prev_ready)
assert (data === prev_data)
else begin $error("t=%0d data drifted under stall %h -> %h", t, prev_data, data); errors++; end
if (got) begin
assert (captured === expect_word)
else begin $error("t=%0d mis-sampled: got %h exp %h", t, captured, expect_word); errors++; end
expect_word = expect_word + 1; received++;
end
end
if (errors == 0) $display("PASS: %0d words, correct value, data stable under stall", received);
else $display("FAIL: %0d errors (payload drifted under stall)", errors);
$finish;
end
endmoduleThe Verilog pair mirrors it: data wired to a ticking counter is the bug, a registered payload advanced only on the transfer is the fix, and the testbench adds the data-stable check.
// BUGGY: data follows a free-running counter -> a stalled sink latches a drifted value.
module src_mutate_bad #(parameter WIDTH = 32, parameter NWORDS = 8) (
input wire clk, rst, ready,
output wire valid, output wire [WIDTH-1:0] data
);
reg [WIDTH-1:0] counter; reg [31:0] delivered;
wire xfer = valid & ready;
always @(posedge clk) begin
if (rst) begin counter <= 32'hA0; delivered <= 0; end
else begin
counter <= counter + 1'b1; // WRONG: payload drifts every cycle
if (xfer) delivered <= delivered + 1'b1;
end
end
assign valid = (delivered < NWORDS);
assign data = counter;
endmodule
// FIXED: registered payload, changed only on the transfer.
module src_mutate_good #(parameter WIDTH = 32, parameter NWORDS = 8) (
input wire clk, rst, ready,
output wire valid, output wire [WIDTH-1:0] data
);
reg [WIDTH-1:0] data_reg; reg [31:0] delivered;
wire xfer = valid & ready;
always @(posedge clk) begin
if (rst) begin data_reg <= 32'hA0; delivered <= 0; end
else if (xfer) begin // change DATA only on the transfer
data_reg <= data_reg + 1'b1;
delivered <= delivered + 1'b1;
end
end
assign valid = (delivered < NWORDS);
assign data = data_reg;
endmodule module src_mutate_tb;
parameter WIDTH = 32, NWORDS = 8;
reg clk, rst, ready_gap;
wire valid;
wire [WIDTH-1:0] data;
reg [WIDTH-1:0] captured, expect_word, prev_data;
reg got, prev_valid, prev_ready;
integer t, errors, received;
wire ready = ready_gap;
wire xfer = valid & ready;
src_mutate_good #(.WIDTH(WIDTH), .NWORDS(NWORDS)) dut (
.clk(clk), .rst(rst), .ready(ready), .valid(valid), .data(data));
always @(posedge clk) begin // inline reference sink
if (rst) begin got <= 1'b0; captured <= 0; end
else begin got <= xfer; if (xfer) captured <= data; end
end
always #5 clk = ~clk;
initial begin
errors = 0; received = 0; expect_word = 32'hA0; prev_valid = 1'b0;
clk = 1'b0; rst = 1'b1; ready_gap = 1'b0;
@(posedge clk); #1; rst = 1'b0;
for (t = 0; t < 6*NWORDS; t = t + 1) begin
@(negedge clk);
ready_gap = ($random % 3) != 0;
prev_data = data; prev_valid = valid; prev_ready = ready;
@(posedge clk); #1;
if (prev_valid && !prev_ready && (data !== prev_data)) begin
$display("FAIL: t=%0d data drifted under stall %h -> %h", t, prev_data, data);
errors = errors + 1;
end
if (got) begin
if (captured !== expect_word) begin
$display("FAIL: t=%0d mis-sampled: got %h exp %h", t, captured, expect_word);
errors = errors + 1;
end
expect_word = expect_word + 1; received = received + 1;
end
end
if (errors == 0) $display("PASS: %0d words, correct value, data stable under stall", received);
else $display("FAIL: %0d errors (payload drifted under stall)", errors);
$finish;
end
endmoduleIn VHDL the bug is data <= counter with counter incremented every cycle; the fix registers the payload and advances it only on valid and ready. The testbench adds the data-stable assertion.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: data follows a free-running counter -> a stalled sink latches a drifted value.
entity src_mutate_bad is
generic ( WIDTH : positive := 32; NWORDS : positive := 8 );
port ( clk, rst, ready : in std_logic;
valid : out std_logic;
data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of src_mutate_bad is
signal counter : unsigned(WIDTH-1 downto 0) := to_unsigned(16#A0#, WIDTH);
signal delivered : integer range 0 to NWORDS := 0;
signal valid_i : std_logic;
begin
valid_i <= '1' when delivered < NWORDS else '0'; -- valid from local state (Rule 1 OK)
valid <= valid_i;
data <= std_logic_vector(counter); -- but the payload is not frozen
process (clk)
variable xfer : std_logic;
begin
if rising_edge(clk) then
xfer := valid_i and ready;
if rst = '1' then counter <= to_unsigned(16#A0#, WIDTH); delivered <= 0;
else
counter <= counter + 1; -- WRONG: drifts every cycle
if xfer = '1' then delivered <= delivered + 1; end if;
end if;
end if;
end process;
end architecture;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- FIXED: registered payload, changed only on the transfer.
entity src_mutate_good is
generic ( WIDTH : positive := 32; NWORDS : positive := 8 );
port ( clk, rst, ready : in std_logic;
valid : out std_logic;
data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of src_mutate_good is
signal data_reg : unsigned(WIDTH-1 downto 0) := to_unsigned(16#A0#, WIDTH);
signal delivered : integer range 0 to NWORDS := 0;
signal valid_i : std_logic;
begin
valid_i <= '1' when delivered < NWORDS else '0';
valid <= valid_i;
data <= std_logic_vector(data_reg); -- stable across any stall (Rule 2)
process (clk)
variable xfer : std_logic;
begin
if rising_edge(clk) then
xfer := valid_i and ready;
if rst = '1' then data_reg <= to_unsigned(16#A0#, WIDTH); delivered <= 0;
elsif xfer = '1' then -- change DATA only on the transfer
data_reg <= data_reg + 1;
delivered <= delivered + 1;
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity src_mutate_tb is
end entity;
architecture sim of src_mutate_tb is
constant WIDTH : positive := 32; constant NWORDS : positive := 8;
signal clk : std_logic := '0';
signal rst, valid, ready, ready_gap, got : std_logic := '0';
signal data, captured : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
begin
-- Bind to src_mutate_good to PASS; src_mutate_bad drifts under stall.
dut : entity work.src_mutate_good
generic map (WIDTH => WIDTH, NWORDS => NWORDS)
port map (clk => clk, rst => rst, ready => ready, valid => valid, data => data);
ready <= ready_gap;
sink : process (clk)
variable xfer : std_logic;
begin
if rising_edge(clk) then
xfer := valid and ready;
if rst = '1' then got <= '0'; captured <= (others => '0');
else got <= xfer; if xfer = '1' then captured <= data; end if;
end if;
end if;
end process;
clkgen : process begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process;
stim : process
variable expect_word : integer := 16#A0#;
variable received : integer := 0;
variable prev_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
variable prev_valid, prev_ready : std_logic := '0';
variable seed1, seed2 : positive := 7;
variable r : real;
begin
rst <= '1'; ready_gap <= '0';
wait until rising_edge(clk); wait for 1 ns; rst <= '0';
for t in 0 to 6*NWORDS - 1 loop
wait until falling_edge(clk);
uniform(seed1, seed2, r);
if r < 0.66 then ready_gap <= '1'; else ready_gap <= '0'; end if;
prev_data := data; prev_valid := valid; prev_ready := ready;
wait until rising_edge(clk); wait for 1 ns;
-- Rule 2: a held, unaccepted offer must keep the same data.
if prev_valid = '1' and prev_ready = '0' then
assert data = prev_data
report "data drifted under stall" severity error;
end if;
if got = '1' then
assert captured = std_logic_vector(to_unsigned(expect_word, WIDTH))
report "mis-sampled word" severity error;
expect_word := expect_word + 1; received := received + 1;
end if;
end loop;
report "src_mutate self-check complete" severity note;
wait;
end process;
end architecture;Across all six blocks the lesson is one sentence: a word offered on a valid/ready link must be advanced only on the transfer and frozen until then — advance on valid alone and you lose it, edit data under a held valid and you corrupt it — and a scoreboard that ties each captured word to the offered word is what catches both.
5. Verification Strategy
Handshake bugs share a cruel property: they are invisible in the easy case and only appear under the exact stimulus the block designer skipped. So verification here is less about a clever checker and more about forcing the hard case — backpressure, integration, reset — and then asserting the two properties that split every bug: liveness (the link eventually moves) and safety (every word moves once, in order, with the offered value). The single governing invariant:
Every word the source emits is received by the sink exactly once and in order (none lost, none duplicated); the value each transfer delivers is the value that was offered when
validfirst rose (not a drifted one); and the link always makes progress — at least one transfer eventually occurs whenever the source has data and the sink has room.
Everything below makes that checkable and ties each check to the catalog bug it catches.
- A scoreboard that ties captured to offered (catches lost, duplicate, mis-sample). Model the expected stream as a reference counter and, on every transfer (
valid && ready), assert the captured word equals the next expected word, then advance the model. At the end assert the received count equals the emitted count. The count catches lost (too few) and duplicate (too many); the per-word compare catches mis-sample (right count, wrong value). This is the §4 scoreboard — SVassert (captured === expect_word), Verilogif (captured !== expect_word) $display("FAIL…"), VHDLassert captured = … severity error— and it must be driven with gappy backpressure, because an always-ready sink hides all three. - A data-stable-under-stall assertion (catches the mutation / mis-sample directly). Every cycle, if the previous cycle had
valid && !ready(a held, unaccepted offer), assertdatais unchanged this cycle. This is the sole direct detector of a Rule 2 payload violation; the §4c bug fails here and only here. Property in words:validhigh andreadylow impliesdatastable next cycle. - A no-transfer-without-both check (catches sampling on
validalone). Assert the sink captures a word on exactly the cyclesvalid && ready— never onvalidalone (which would duplicate a held word during a stall) and never onreadyalone (which would latch garbage while the producer is idle). A sinkgotpulse that fires whenreadywas low is the bug. - A liveness / deadlock check (catches
validgated byready). Wire the source to a sink whosereadylegally depends onvalid(Rule 3) and assert at least one transfer occurs within a bounded window. A source that gatesvalidwithreadyforms a combinational loop and makes zero progress — the check is a nonzero transfer count. Liveness must be a first-class assertion, because a deadlocked link passes every safety check trivially (nothing wrong ever moves because nothing moves). - A reset-and-first-word check (catches garbage out of reset). Assert
validis low during and immediately after reset until the source's payload is known, and that the first accepted word is the intended first word, not anXor a stale value. Drive reset asserted, then deasserted, and confirm no transfer carries an unknown payload — the direct detector forvalidasserted before data is ready. - Corner cases and the integration harness. Exercise the patterns that break weak implementations:
readylow for many consecutive cycles withvalidheld (long stall — the word must survive);readytoggling every cycle (transfer only on the high cycles); back-to-backvalid && readywith no gaps (full throughput, one word per cycle); aready-before-validbubble (no spurious transfer); and, crucially, an integration test that wires the real source to the real sink rather than a stub — the only stimulus that surfaces the deadlock, since it needs two blocks that each look at the other. - Expected waveform. On a correct run,
validrises when the source has a word and stays high withdatafrozen across everyready-low cycle, dropping only after the transfer; each transfer is a single overlap; the captured stream equals the emitted stream, in order. The visual signature of each bug is distinct: a deadlock isvalidandreadyboth stuck low forever; a lost word is avalid-high stall cycle where the source's index advanced anyway; a mis-sample isdatachanging whilevalidis held; a duplicate is a sink capture on a cyclereadywas low; garbage-out-of-reset is a transfer whosedataisX.
6. Common Mistakes
valid combinationally gated by ready. The one liveness bug and the signature handshake failure. Writing valid = have_data && ready makes the offer depend on the acceptance; paired with a sink whose ready looks at valid (which Rule 3 permits), the two form a combinational loop and the link deadlocks with zero transfers. It is invisible against an always-ready stub and fatal at integration. The rule is absolute: valid is a function of local producer state only; it never reads ready. (DebugLab row 1.)
Advancing the source on valid instead of on the transfer. The lost-word bug. A source that increments its index, pops its read pointer, or clears have_data every cycle valid is high — regardless of ready — skips every word it offered during a stall, because it moved on but the sink never accepted. Advance local state only on valid && ready. (DebugLab row 2; §4b.)
Mutating data while valid is held. The mis-sample bug. If the payload changes before the accept — wired to a free-running counter, recomputed each cycle, or advanced on the wrong edge — a stalled sink latches a later value than the one offered, off by the number of stall cycles. Count right, value wrong. Register the payload and change it only on the transfer. (DebugLab row 3; §4c.)
Sampling data on valid alone, ignoring ready. The duplicate bug. A sink that captures whenever valid is high — without also checking ready — latches the same held word on every stall cycle (duplicating it downstream) or captures a word it has no room for (overrunning its buffer). A transfer is valid && ready; capture on exactly that condition, never on valid by itself. (DebugLab row 4.)
A combinational ready that glitches. ready may legally look at valid (Rule 3), but it must be a clean function of valid and registered state — not a deep combinational mess that momentarily pulses high while its inputs settle. A ready glitch high for a sliver of a cycle while valid is high can register a phantom accept the source also sees, dropping or double-counting a word. Drive ready from stable registered occupancy (or a shallow, clean combinational function of valid), never from a racy multi-level path.
valid asserted out of reset before the payload is ready. If valid resets high (or is left un-reset and floats), while the source's data is still X or stale, the first word out is garbage the sink accepts. Reset valid to 0 and keep it low until the first real word is loaded — the positive form is: reset the control bit to the safe (no-offer) state, and only raise valid once the payload is known.
Trusting the unit test that used a stub. Not a rule violation but the reason every rule violation ships: a source verified only against an always-ready sink never has to hold an offer, so Rules 1 and 2 are never exercised; a sink verified only against an always-valid source never stalls. Verify with gappy backpressure and an integration harness of the real source and real sink, or the catalog bugs sail through review.
7. DebugLab
The handshake bug clinic — a deadlock, a lost word, a mis-sample, and a duplicate
The engineering lesson: a handshake never fails in a mysterious new way — it fails in one of two, and the symptom tells you which before you read the RTL. A stuck link (nothing moves) is a liveness bug, and there is essentially one cause: a valid that was gated by ready, so the two wait on each other — restore Rule 1 and drive valid locally. A link that moves but delivers the wrong stream is a safety bug; cut again by count versus value. A wrong count means a lost word (the source advanced on valid instead of the transfer) or a duplicate (the sink sampled on valid alone) — the fix is to advance and capture on exactly valid && ready. A right count with a wrong value means a mis-sample (the payload drifted while valid was held) — the fix is Rule 2, freeze the offer until the transfer. Classify, then name the rule, then apply its positive form. And because every one of these hides against a trivial stub, the meta-lesson is to verify under gappy backpressure and at integration — the exact stimulus the unit test skipped. These four failures, and their fixes, are identical across SystemVerilog, Verilog, and VHDL, and they are the same bugs you will debug on AXI, APB, and every streaming link.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the classify-then-name-the-rule reflex the page is built to install.
Exercise 1 — Classify six symptoms
For each observed symptom, name the family (liveness or safety), the sub-class where it applies (count-wrong lost / count-wrong duplicate / value-wrong mis-sample), and the single rule that was violated: (a) the pipeline hangs at integration with valid and ready both low; (b) under backpressure the downstream count is one short per stall; (c) under backpressure the count is right but one value is off by the stall length; (d) under backpressure the count is high and a held word appears twice; (e) the first word after reset is X; (f) a word is occasionally dropped when a deeply combinational ready settles. Do it in one line each, from the symptom alone.
Exercise 2 — Break it two ways, then trace the fix
Take a correct source that offers three words (0xA0, 0xA1, 0xA2) with the sink ready pattern 1, 0, 0, 1, 1 (accept, stall twice, accept, accept). (i) Modify it to advance on valid alone and hand-trace which word is lost and on which cycle. (ii) Separately, modify it to drive data from a counter and hand-trace which value the sink captures for the second word. Then, for the correct source, trace all three transfers and confirm every captured value equals the offered value.
Exercise 3 — Why the stub hid it
For each of the three source-side bugs (valid gated by ready, advance-on-valid, mutate-under-stall), state the exact stub sink that makes the bug invisible in a unit test, and explain in one line the mechanism by which that stub removes the condition the bug needs. Then state the one stimulus property (a single knob) that, if added to every unit test, would have surfaced all three.
Exercise 4 — Write the catch-all test plan
Write the test plan (not the RTL) that catches all six catalog bugs before tape-out. Specify: the scoreboard model and its two count/value checks; the data-stable-under-stall assertion and the exact cycle condition that triggers it; the no-transfer-without-both check; the liveness/deadlock check and the kind of sink you must connect for it to fire; the reset-and-first-word check; and the backpressure corner cases (long stall, toggling ready, back-to-back throughput, ready-before-valid bubble, integration of real source and real sink). For each of the six bugs, name the single check that catches it.
10. Related Tutorials
In-track dependencies (the contract this page catalogs the violations of):
- The valid/ready Handshake — Chapter 9.1; the direct prerequisite — the three-wire contract and the three rules (
validnever looks atready; hold the offer with stable data;readymay look atvalid). Every bug here is one of those rules broken. - Backpressure & Stall — Chapter 9.2; the direct prerequisite — how a low
readypropagates back to stall the producer, and the stall vocabulary these safety bugs live in (every one of them surfaces only under backpressure). - The Valid-Bit Discipline — Chapter 8.3; the producer half of the handshake and the stale-valid/data misalignment that is the pipelined cousin of the mis-sample bug.
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- Skid Buffer — Chapter 9.3; the registered stage that both breaks the long combinational
readychain and, done wrong, is a rich source of its own lost/duplicated-word bugs — the fix for the timing cost these bugs sit near. - Credit-Based Flow Control — Chapter 9.4; the alternative to backward
ready, with its own bug catalog (lost credits, credit over-count) that mirrors these liveness/safety families. - Ready/Valid Pipelining — Chapter 9.6; composing many handshake stages, where a single mis-registered
readyreintroduces the deadlock and the dropped word at scale. - Case Study — Streaming FIFO — Chapter 9.7; a full FIFO exposed as a handshake sink and source, where these bugs appear as integration failures across the write and read handshakes.
Backward / in-track method and structure:
- Synchronous FIFO Architecture — Chapter 7.1; the FIFO whose write side is a handshake sink and read side a source — the canonical place a sample-on-valid-alone or advance-on-valid bug corrupts occupancy.
- FIFO Full/Empty Generation — Chapter 7.2; the
full/emptyflags that are a FIFO'sready/valid, and whose off-by-one is the FIFO-side cousin of these count-wrong bugs. - FIFO Verification — Chapter 7.x; the scoreboard and in-order/exactly-once methodology this page's bug-catcher is built on.
- Reset Strategy — Chapter 2.5; the reset discipline behind the garbage-out-of-reset bug — resetting
validto the safe no-offer state so the first word is neverX. - The RTL Design Mindset — Chapter 0.2; the Verification lens this page applies — you debug from the symptom down the taxonomy, not from the RTL up.
- What Is an RTL Design Pattern? — Chapter 0.1; why a contract this simple still earns a catalog of signature failures.
Verilog v1 prerequisites (the grammar these bugs and fixes transcribe into):
- Blocking and Non-Blocking Assignments — why the source's payload and state advance with non-blocking (
<=) so the offer moves atomically on the transfer edge, and how a blocking mistake reintroduces the mutation bug. - If-Else Statements — the reset / transfer / hold priority (
if (rst) … else if (valid && ready) …) whose wrong guard (if (valid)) is the lost-word bug. - Initial & Always Blocks — the clocked
alwaysblock that must advance only on the transfer and capture only onvalid && ready. - Physical Data Types — the
wire/regtyping behind drivingvalid/readycombinationally versus registering the payload and the captured word.
11. Summary
- Every handshake bug is liveness or safety — classify first, from the symptom. A stuck link (nothing moves, a hang or deadlock) is a liveness bug; a link that moves but delivers the wrong stream is a safety bug. That one cut, made from the symptom before you read a line of RTL, is more than half the debugging, and it maps every failure onto a violated rule from 9.1.
- Liveness has essentially one cause:
validgated byready. Writingvalid = have_data && readymakes the offer depend on the acceptance; wired to a sink whosereadylooks atvalid(Rule 3 permits it), the two form a combinational loop and the link deadlocks with zero transfers — invisible against a stub, fatal at integration. The fix is Rule 1: drivevalidfrom local state only. - Safety splits by count versus value. A wrong count is a lost word (the source advanced on
validinstead of the transfer) or a duplicate (the sink sampled onvalidalone) — fix both by acting on exactlyvalid && ready. A right count with a wrong value is a mis-sample (the payload drifted whilevalidwas held) — fix by Rule 2, register the offer and change it only on the transfer. - The rest of the catalog: the
readyglitch and the reset-garbage. A racy combinationalreadycan pulse a phantom accept — drivereadyfrom clean registered state.validasserted out of reset before the payload is real emits a garbage first word — resetvalidto 0 and raise it only once the data is known. Both are safety bugs the same classification catches. - The bugs hide against stubs; verify the hard case. Each failure is invisible in isolation and appears only under backpressure or at integration — the exact stimulus a naive unit test skips. Verify with a scoreboard under gappy backpressure (count and value), a data-stable-under-stall assertion, a no-transfer-without-both check, and a liveness check against a real ready-depends-on-valid sink. The two most instructive bugs — a lost word and a mis-sample — are built buggy-vs-fixed and self-check-verified here in SystemVerilog, Verilog, and VHDL, and the whole catalog is the same on AXI, APB, and every streaming link.