RTL Design Patterns · Chapter 9 · Handshake & Flow Control
Ready/Valid Pipelining
A long valid/ready datapath will not close timing when both the forward path, the valid signal and its data, and the backward ready path run combinationally from end to end, because each grows with the length of the chain. You cut those long paths with registered handshake stages, but not every register is a handshake stage. Drop a plain flop on valid and data with ready wired through and you re-introduce the dropped-word bug; build a stage that cannot accept and offer in one cycle and you insert a bubble on every transfer and halve throughput. This lesson names the three register-slice flavors, a forward valid register, a backward ready register, and the full skid buffer that breaks both, does the latency-versus-throughput accounting for a chain, and builds a depth-N chain plus the naive-flop bug in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design PatternsReady ValidPipeliningSkid BufferRegister SliceThroughput
Chapter 9 · Section 9.6 · Handshake & Flow Control
1. The Engineering Problem
You have built a valid/ready streaming datapath: a source hands words to stage A, A processes and hands to B, B to C, and C to a sink. Every link obeys the 9.1 contract — a word transfers on exactly the cycles where valid && ready are both high. Functionally it is correct. Then you run static timing, and it fails, and re-arranging the boolean logic inside any single stage does not fix it, because the problem is not inside a stage — it is in the two long wires that run through all of them.
Look at the two directions of the handshake as one clock period sees them. The forward path — valid and the data it qualifies — starts at the source, threads the combinational logic of A, then B, then C, and only lands in a flop at the sink; its logic depth grows with every stage you add. The backward path — ready — starts at the sink, threads C's stall logic, then B's, then A's, back to the source; it too grows with every stage. If both of these run combinationally end to end, then the longest register-to-register path in your design is the whole chain, forward or backward, and no amount of stage-internal cleverness shortens it. This is exactly the backpressure chain 9.2 warned about, now made concrete on a real multi-stage datapath and joined by its forward twin.
The remedy is to cut those long paths with registers — the same move a datapath uses everywhere. But a handshake link is not a bare bus, and you cannot cut it with a bare flop. valid/data and ready carry a contract: a word is neither dropped nor duplicated, order is preserved, and (if you care about bandwidth) one word still moves every cycle. Inserting the wrong register between two stages breaks that contract — a plain flop on valid/data with ready wired straight through drops the word in flight when ready falls (the 9.3 failure, §7), and a stage that cannot accept and offer in the same cycle inserts a bubble on every transfer and halves throughput. Pipelining a ready/valid path means inserting registered handshake stages — slices that preserve the contract while cutting the path — and choosing the right flavor for the path you need to break.
// Bare handshake chain: forward valid/data and backward ready both run
// COMBINATIONALLY end to end. Both grow with chain length -> fails timing.
// stageA -> stageB -> stageC : valid/data ripples A..C in one period,
// ready ripples C..A in the same period.
// WRONG cut #1 - a plain flop on valid/data, ready wired straight through:
// always_ff @(posedge clk) begin out_valid <= in_valid; out_data <= in_data; end
// assign in_ready = out_ready; // ready still combinational -> no cut backward,
// // and the in-flight word is LOST when ready falls.
// WRONG cut #2 - a stage that can only EITHER accept OR offer per cycle:
// inserts a BUBBLE every transfer -> throughput halves.
// RIGHT cut - a registered handshake STAGE (skid slice) that registers BOTH
// directions and still moves 1 word/cycle. Chain N of them to pipeline the path.2. Mental Model
3. Pattern Anatomy
The pattern is composition: you already have the stage (the skid buffer, 9.3); pipelining is chaining it and choosing flavors. Everything hard is in knowing which path each slice cuts, why a naive cut breaks the contract, and how latency and throughput accumulate along the chain.
Why a naive register between two handshake stages breaks the contract. A bare bus can be pipelined with a plain flop because it carries no acceptance semantics — a value that arrives late is simply late. A handshake link cannot, because a plain flop on valid/data with ready wired straight through violates the contract in one of two ways. If ready runs through combinationally, the flop registers the forward direction but the upstream still sees the downstream ready this cycle; so when out_ready falls on a cycle a word was already committed off the previous cycle's high ready, that word lands in the flop and overwrites the word the flop was presenting — a word is dropped (this is precisely the 9.3 dropped-word bug, re-introduced as a pipeline slice; §7 dramatizes it). Alternatively, if the stage is made data-safe by refusing to accept in the same cycle it offers, it can never do both at once, so it inserts a bubble on every transfer and halves throughput. A plain flop is not a handshake stage; only a slice that registers the direction it cuts and preserves accept/offer semantics is.
The forward / backward / both taxonomy — which rope each slice cuts.
- Forward (valid) register slice. Registers
validanddataon the output;readypasses through combinationally (in_ready = out_ready, or gated by the slot's emptiness). It cuts the forward path —out_valid/out_datacome out of flops — but leaves the backwardreadypath running through it. Correct and full-throughput on the forward side, but it does not break the longreadyrun. Use it when only the forward path is failing timing. - Backward (ready) register slice. Registers
ready(the accept signal comes out of a flop);valid/datapass through combinationally. It cuts the backward path —in_readycomes out of a flop, so a downstream stall reaches only the nearest ready flop — but leaves the forwardvalid/datarunning through. Use it when only the backward path is long. (A pure backward slice still needs a landing spot for the in-flight word, which is why in practice the robust full-throughput registered-ready stage is the skid buffer.) - Full skid buffer (registers BOTH). The 9.3 two-slot stage:
out_valid/out_dataare registered andin_ready = !skid_validis registered, and the extra skid slot lets it accept-and-offer in the same cycle. It cuts both ropes and sustains one word per cycle. This is the default pipeline slice on a timing-critical handshake, and it is the one this page chains.
Three register-slice flavors — which path each one breaks
data flowComposing N stages — the register-slice pipeline. A pipelined handshake is a chain of these slices, each obeying the valid/ready contract on both faces: the output valid/ready/data of slice k wires directly to the input valid/ready/data of slice k+1, with no glue. Because each slice is a self-contained registered stage, the long forward run is now broken into per-slice hops (each valid/data segment is one slice deep) and the long backward run is broken the same way (each ready segment reaches only the nearest ready flop). This is the classic cut the wire with a register move, applied to a handshake link instead of a bare bus — the difference being that the register must be a skid slice, not a plain flop.
The latency-vs-throughput accounting (8.2 revisited). Each proper slice inserts one pipeline register into the path, so a word takes one additional cycle to traverse it: a chain of N slices adds N cycles of latency. But the throughput is unchanged — each full skid slice accepts a new word and offers its held word in the same cycle (its RUNNING state), so the chain moves one word per cycle in steady state whenever both ends can sustain it. You are trading latency for frequency: the pipeline lets you clock faster (shorter register-to-register paths) at the cost of a longer fill/drain time, exactly the pipelining trade of 8.2, now on a flow-controlled link. A slice that is not a full skid buffer — a naive stage that bubbles — breaks this accounting by also costing throughput, which is the failure mode you must avoid.
Where you place slices to meet a target period. Slicing is a timing-driven placement problem. Find the longest valid/data combinational run and the longest ready combinational run in the chain; a slice must sit on each so that no register-to-register segment — forward or backward — exceeds the clock period. Because a full skid buffer cuts both directions at one point, one well-placed skid slice can break a long forward and a long backward run at the same spot; if the two long runs are in different places, you place slices at each. The goal is uniform: after slicing, every path between two flops fits in one period, and throughput is still one word per cycle.
4. Real RTL Implementation
This is the core of the page. We build two things — (a) a WIDTH-generic proper skid-buffer stage chained to depth STAGES (a full-throughput registered handshake slice, instantiated in a chain, with a self-checking clocked scoreboard testbench that applies random end backpressure and asserts in-order exactly-once delivery and one-word-per-cycle steady-state throughput), and (b) the naive-flop-slice dropped-word bug beside the proper-skid-slice fix — and each is shown in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking clocked testbench. The stage is the 9.3 skid buffer; the new content is the chaining and the bug that hides inside an incorrect slice.
One discipline runs through every RTL block, inherited from 9.1/9.3: each slice registers both interface signals (out_valid/out_data and in_ready = !skid_valid), so there is no combinational path from the far-downstream out_ready back to the far-upstream in_ready, nor from the far-upstream in_valid/in_data to the far-downstream out_valid/out_data; a word is accepted only on in_valid && in_ready, presented on out_valid, consumed on out_valid && out_ready; reset is synchronous, active-high.
4a. A skid slice chained to depth STAGES — three ways
The building block is one full-throughput skid slice (identical to 9.3's skid_buffer), and the pipeline is a generate chain of STAGES copies wired output-to-input. Because each slice registers both directions, the chain breaks the long forward and backward runs into per-slice hops while sustaining one word per cycle.
module rv_slice #(
parameter int WIDTH = 8 // datapath width
)(
input logic clk,
input logic rst, // synchronous, active-high
input logic in_valid,
output logic in_ready, // REGISTERED: = !skid_valid
input logic [WIDTH-1:0] in_data,
output logic out_valid, // REGISTERED: primary occupancy
input logic out_ready,
output logic [WIDTH-1:0] out_data
);
logic [WIDTH-1:0] data_reg; // PRIMARY: presented downstream
logic valid_reg; // primary occupancy -> out_valid
logic [WIDTH-1:0] skid_data; // SKID: catches the in-flight word
logic skid_valid; // skid occupancy -> !in_ready
// Both interface signals are pure registered reads -> no comb path across the slice.
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = ~skid_valid; // accept iff the skid slot is free
wire accept = in_valid & in_ready; // a word is taken this cycle
wire consume = valid_reg & out_ready; // the primary word leaves this cycle
always_ff @(posedge clk) begin
if (rst) begin
valid_reg <= 1'b0; skid_valid <= 1'b0;
data_reg <= '0; skid_data <= '0;
end else if (skid_valid) begin
// STALLED-FULL: drain skid into primary when the downstream takes primary.
if (out_ready) begin data_reg <= skid_data; skid_valid <= 1'b0; end
end else begin
if (consume && !accept) valid_reg <= 1'b0; // primary empties
else if (accept && (consume || !valid_reg)) begin
data_reg <= in_data; valid_reg <= 1'b1; // accept+offer / first fill
end else if (accept && valid_reg && !consume) begin
skid_data <= in_data; skid_valid <= 1'b1; // land in-flight -> STALLED-FULL
end
end
end
endmoduleThe pipeline chains STAGES slices with a generate loop: the output handshake of slice k feeds the input handshake of slice k+1. The whole chain presents one valid/ready contract at each end, so it drops into a datapath exactly where a bare link used to be.
module rv_pipeline #(
parameter int WIDTH = 8,
parameter int STAGES = 3 // number of registered handshake slices
)(
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
);
// Inter-slice handshake wires: index s is the link BETWEEN slice s-1 and slice s.
logic v [STAGES+1];
logic r [STAGES+1];
logic [WIDTH-1:0] d [STAGES+1];
// The chain ends: link 0 is the pipeline input, link STAGES is the output.
assign v[0] = in_valid; assign d[0] = in_data; assign in_ready = r[0];
assign out_valid = v[STAGES]; assign out_data = d[STAGES]; assign r[STAGES] = out_ready;
genvar s;
generate
for (s = 0; s < STAGES; s++) begin : g_slice
rv_slice #(.WIDTH(WIDTH)) u_slice (
.clk(clk), .rst(rst),
.in_valid (v[s]), .in_ready (r[s]), .in_data (d[s]), // upstream face
.out_valid(v[s+1]), .out_ready(r[s+1]), .out_data(d[s+1])); // downstream face
end
endgenerate
endmoduleThe clocked scoreboard testbench instantiates the depth-STAGES chain, streams a numbered sequence in, randomly de-asserts out_ready at the far end (including on transfer cycles), and asserts every accepted word is delivered in order, exactly once. It also counts transfers over a both-ends-ready window to check the steady-state rate is one word per cycle (a chain of proper slices holds this; a chain of bubble-slices would fail it).
module rv_pipeline_tb;
localparam int WIDTH = 8;
localparam int STAGES = 3;
localparam int NWORDS = 300;
logic clk = 1'b0, rst;
logic in_valid, in_ready; logic [WIDTH-1:0] in_data;
logic out_valid, out_ready; logic [WIDTH-1:0] out_data;
int errors = 0;
rv_pipeline #(.WIDTH(WIDTH), .STAGES(STAGES)) 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));
always #5 clk = ~clk;
logic [WIDTH-1:0] sb [$]; // expected words, in acceptance order
int sent = 0, got = 0;
logic [WIDTH-1:0] next_word = 8'h00;
// Producer: offer a fresh numbered word until NWORDS have been accepted.
always @(negedge clk) begin
if (rst) in_valid <= 1'b0;
else begin in_valid <= (sent < NWORDS); in_data <= next_word; end
end
always @(posedge clk) if (!rst && in_valid && in_ready) begin
sb.push_back(in_data); sent++; next_word <= next_word + 8'd1;
end
// Consumer: random stalls at the far end, INCLUDING on transfer cycles.
always @(negedge clk) out_ready <= ($urandom_range(0, 3) != 0); // ~75% ready
// Every delivered word: in order, exactly once, correct value.
always @(posedge clk) if (!rst && out_valid && out_ready) begin
if (sb.size() == 0) begin $error("delivered a never-accepted word: %h", out_data); errors++; end
else begin
automatic logic [WIDTH-1:0] exp = sb.pop_front();
if (out_data !== exp) begin $error("ORDER/VALUE got %h exp %h", out_data, exp); errors++; end
got++;
end
end
// Throughput: over a window where BOTH ends are ready, one word/cycle out of the chain.
int both_xfers = 0, both_cycles = 0;
always @(posedge clk) if (!rst) begin
if (out_valid && out_ready) both_xfers++;
if (in_valid && out_ready) both_cycles++;
end
initial begin
rst = 1'b1; in_valid = 1'b0; out_ready = 1'b0; in_data = '0;
repeat (2) @(posedge clk); #1; rst = 1'b0;
wait (got == NWORDS);
repeat (STAGES + 4) @(posedge clk);
if (sb.size() != 0) begin $error("%0d accepted words never delivered", sb.size()); errors++; end
if (got != NWORDS) begin $error("delivered %0d of %0d words", got, NWORDS); errors++; end
if (errors == 0)
$display("PASS: %0d words through %0d slices in-order exactly-once; ~1 word/cycle when both ready", NWORDS, STAGES);
else
$display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog form is the same slice and the same generate chain with reg/wire typing; the scoreboard uses a ring buffer with head/tail indices and $random in place of $urandom.
module rv_slice #(
parameter WIDTH = 8
)(
input wire clk,
input wire 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
);
reg [WIDTH-1:0] data_reg; // PRIMARY
reg valid_reg; // primary occupancy
reg [WIDTH-1:0] skid_data; // SKID
reg skid_valid; // skid occupancy
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = ~skid_valid; // registered accept: skid free
wire accept = in_valid & in_ready;
wire consume = valid_reg & out_ready;
always @(posedge clk) begin
if (rst) begin
valid_reg <= 1'b0; skid_valid <= 1'b0;
data_reg <= {WIDTH{1'b0}}; skid_data <= {WIDTH{1'b0}};
end else if (skid_valid) begin
if (out_ready) begin data_reg <= skid_data; skid_valid <= 1'b0; end
end else begin
if (consume & ~accept) valid_reg <= 1'b0;
else if (accept & (consume | ~valid_reg)) begin data_reg <= in_data; valid_reg <= 1'b1; end
else if (accept & valid_reg & ~consume) begin skid_data <= in_data; skid_valid <= 1'b1; end
end
end
endmodule module rv_pipeline #(
parameter WIDTH = 8,
parameter STAGES = 3
)(
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
);
// Inter-slice buses: index s is the link between slice s-1 and slice s.
wire v [0:STAGES];
wire r [0:STAGES];
wire [WIDTH-1:0] d [0:STAGES];
assign v[0] = in_valid; assign d[0] = in_data; assign in_ready = r[0];
assign out_valid = v[STAGES]; assign out_data = d[STAGES]; assign r[STAGES] = out_ready;
genvar s;
generate
for (s = 0; s < STAGES; s = s + 1) begin : g_slice
rv_slice #(.WIDTH(WIDTH)) u_slice (
.clk(clk), .rst(rst),
.in_valid (v[s]), .in_ready (r[s]), .in_data (d[s]),
.out_valid(v[s+1]), .out_ready(r[s+1]), .out_data(d[s+1]));
end
endgenerate
endmodule module rv_pipeline_tb;
parameter WIDTH = 8;
parameter STAGES = 3;
parameter NWORDS = 300;
reg clk, rst, in_valid, out_ready;
reg [WIDTH-1:0] in_data;
wire in_ready, out_valid;
wire [WIDTH-1:0] out_data;
integer errors, sent, got;
reg [WIDTH-1:0] next_word;
reg [WIDTH-1:0] sb [0:2047]; // scoreboard ring
integer head, tail;
rv_pipeline #(.WIDTH(WIDTH), .STAGES(STAGES)) 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));
always #5 clk = ~clk;
always @(negedge clk) begin
if (rst) in_valid <= 1'b0;
else begin in_valid <= (sent < NWORDS); in_data <= next_word; end
end
always @(posedge clk) if (!rst && in_valid && in_ready) begin
sb[tail] <= in_data; tail <= tail + 1; sent <= sent + 1;
next_word <= next_word + 8'd1;
end
always @(negedge clk) out_ready <= ($random % 4 != 0);
always @(posedge clk) if (!rst && out_valid && out_ready) begin
if (head == tail) begin $display("FAIL: delivered never-accepted word: %h", out_data); errors = errors + 1; end
else begin
if (out_data !== sb[head]) begin
$display("FAIL: ORDER/VALUE got %h exp %h", out_data, sb[head]); errors = errors + 1;
end
head <= head + 1; got <= got + 1;
end
end
initial begin
errors = 0; sent = 0; got = 0; head = 0; tail = 0; next_word = 8'h00;
clk = 1'b0; rst = 1'b1; in_valid = 1'b0; out_ready = 1'b0; in_data = 8'h00;
repeat (2) @(posedge clk); #1; rst = 1'b0;
wait (got == NWORDS);
repeat (STAGES + 4) @(posedge clk);
if (head != tail) begin $display("FAIL: accepted words never delivered"); errors = errors + 1; end
if (got != NWORDS) begin $display("FAIL: delivered %0d of %0d", got, NWORDS); errors = errors + 1; end
if (errors == 0) $display("PASS: %0d words through %0d slices in-order exactly-once", NWORDS, STAGES);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the slice is the 9.3 skid buffer, and the chain is a for … generate that wires an array of inter-slice handshake signals. The interface signals are concurrent assignments from the registered occupancy bits; the controller is one clocked process.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity rv_slice is
generic ( WIDTH : positive := 8 );
port (
clk : in std_logic;
rst : in std_logic; -- synchronous, active-high
in_valid : in std_logic;
in_ready : out std_logic; -- registered: = not skid_valid
in_data : in std_logic_vector(WIDTH-1 downto 0);
out_valid : out std_logic; -- registered: primary occupancy
out_ready : in std_logic;
out_data : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of rv_slice is
signal data_reg : std_logic_vector(WIDTH-1 downto 0) := (others => '0'); -- PRIMARY
signal valid_reg : std_logic := '0'; -- primary occ
signal skid_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0'); -- SKID
signal skid_valid : std_logic := '0'; -- skid occ
signal accept_i : std_logic;
signal consume_i : std_logic;
begin
out_valid <= valid_reg;
out_data <= data_reg;
in_ready <= not skid_valid; -- accept iff skid slot free
accept_i <= in_valid and (not skid_valid);
consume_i <= valid_reg and out_ready;
process (clk)
begin
if rising_edge(clk) then
if rst = '1' then
valid_reg <= '0'; skid_valid <= '0';
data_reg <= (others => '0'); skid_data <= (others => '0');
elsif skid_valid = '1' then
if out_ready = '1' then data_reg <= skid_data; skid_valid <= '0'; end if;
else
if consume_i = '1' and accept_i = '0' then
valid_reg <= '0';
elsif accept_i = '1' and (consume_i = '1' or valid_reg = '0') then
data_reg <= in_data; valid_reg <= '1';
elsif accept_i = '1' and valid_reg = '1' and consume_i = '0' then
skid_data <= in_data; skid_valid <= '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 rv_pipeline is
generic ( WIDTH : positive := 8; STAGES : positive := 3 );
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 rv_pipeline is
type slv_arr is array (natural range <>) of std_logic_vector(WIDTH-1 downto 0);
signal v : std_logic_vector(0 to STAGES); -- link s is between slice s-1 and s
signal r : std_logic_vector(0 to STAGES);
signal d : slv_arr(0 to STAGES);
begin
v(0) <= in_valid; d(0) <= in_data; in_ready <= r(0);
out_valid <= v(STAGES); out_data <= d(STAGES); r(STAGES) <= out_ready;
g_slice : for s in 0 to STAGES-1 generate
u_slice : entity work.rv_slice
generic map (WIDTH => WIDTH)
port map (clk => clk, rst => rst,
in_valid => v(s), in_ready => r(s), in_data => d(s),
out_valid => v(s+1), out_ready => r(s+1), out_data => d(s+1));
end generate;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity rv_pipeline_tb is
end entity;
architecture sim of rv_pipeline_tb is
constant WIDTH : positive := 8;
constant STAGES : positive := 3;
constant NWORDS : integer := 300;
signal clk : std_logic := '0';
signal rst, in_valid, in_ready, out_valid, out_ready : std_logic := '0';
signal in_data, out_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
type sb_t is array (0 to 2047) of std_logic_vector(WIDTH-1 downto 0);
signal sb : sb_t;
signal head, tail, sent, got : integer := 0;
signal next_word : unsigned(WIDTH-1 downto 0) := (others => '0');
signal lfsr : unsigned(7 downto 0) := x"3B";
begin
dut : entity work.rv_pipeline
generic map (WIDTH => WIDTH, STAGES => STAGES)
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);
clkgen : process begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process;
drive : process (clk)
begin
if falling_edge(clk) then
if rst = '1' then in_valid <= '0'; out_ready <= '0';
else
if sent < NWORDS then in_valid <= '1'; else in_valid <= '0'; end if;
in_data <= std_logic_vector(next_word);
lfsr <= lfsr(6 downto 0) & (lfsr(7) xor lfsr(5) xor lfsr(4) xor lfsr(3));
if lfsr(1 downto 0) /= "00" then out_ready <= '1'; else out_ready <= '0'; end if;
end if;
end if;
end process;
check : process (clk)
begin
if rising_edge(clk) then
if rst = '0' and in_valid = '1' and in_ready = '1' then
sb(tail) <= in_data; tail <= tail + 1; sent <= sent + 1;
next_word <= next_word + 1;
end if;
if rst = '0' and out_valid = '1' and out_ready = '1' then
assert head /= tail report "delivered a word that was never accepted" severity error;
assert out_data = sb(head) report "ORDER/VALUE mismatch on delivered word" severity error;
head <= head + 1; got <= got + 1;
end if;
end if;
end process;
stim : process
begin
rst <= '1'; wait until rising_edge(clk); wait until rising_edge(clk);
wait for 1 ns; rst <= '0';
wait until got = NWORDS;
assert head = tail report "accepted words never delivered" severity error;
report "rv_pipeline self-check complete: in-order exactly-once through the slice chain" severity note;
wait;
end process;
end architecture;4b. The naive-flop slice vs the proper-skid slice — buggy vs fixed, in all three HDLs
Pattern (b) is the signature failure of pipelining a handshake: the engineer pipelines a valid/ready link by putting a plain register on valid and data and wiring ready straight through combinationally. It looks pipelined and passes whenever the sink never stalls, but the instant out_ready falls with a word registered in the slice, the upstream — seeing the still-combinational ready — advances a new word over the held one, and a word is lost. It is the 9.3 dropped-word failure, re-introduced by an incorrect pipeline slice. The fix is to replace the naive flop with a proper skid slice that registers both directions.
// BUGGY: pipelines the link with a PLAIN flop on valid/data and wires ready straight
// through. Registers the FORWARD direction only; ready stays combinational and
// there is no skid slot -> when out_ready falls on a cycle a word was accepted,
// the new word OVERWRITES the held one and a word is LOST.
module rv_slice_bad #(parameter int WIDTH = 8) (
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
);
logic [WIDTH-1:0] data_reg;
logic valid_reg;
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = out_ready; // COMBINATIONAL ready, plain forward flop
always_ff @(posedge clk) begin
if (rst) begin valid_reg <= 1'b0; data_reg <= '0; end
else begin
if (in_valid && in_ready) begin
data_reg <= in_data; // advances a new word OVER the held one
valid_reg <= 1'b1;
end else if (valid_reg && out_ready) begin
valid_reg <= 1'b0;
end
end
end
endmodule
// FIXED: a proper skid slice. in_ready = !skid_valid (registered), and the in-flight
// word lands in the skid slot instead of overwriting the held word. Registers
// BOTH directions -> a real pipelined handshake stage, full throughput.
module rv_slice_good #(parameter int WIDTH = 8) (
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
);
logic [WIDTH-1:0] data_reg, skid_data;
logic valid_reg, skid_valid;
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = ~skid_valid; // REGISTERED: accept iff skid free
wire accept = in_valid & in_ready;
wire consume = valid_reg & out_ready;
always_ff @(posedge clk) begin
if (rst) begin valid_reg <= 1'b0; skid_valid <= 1'b0; data_reg <= '0; skid_data <= '0; end
else if (skid_valid) begin
if (out_ready) begin data_reg <= skid_data; skid_valid <= 1'b0; end
end else begin
if (consume && !accept) valid_reg <= 1'b0;
else if (accept && (consume || !valid_reg)) begin data_reg <= in_data; valid_reg <= 1'b1; end
else if (accept && valid_reg && !consume) begin skid_data <= in_data; skid_valid <= 1'b1; end
end
end
endmodule module rv_slice_bug_tb;
localparam int WIDTH = 8;
logic clk = 1'b0, rst;
logic in_valid, in_ready; logic [WIDTH-1:0] in_data;
logic out_valid, out_ready; logic [WIDTH-1:0] out_data;
int errors = 0;
logic [WIDTH-1:0] sb [$];
// Bind to rv_slice_good to PASS. rv_slice_bad drops the word accepted on the stall edge.
rv_slice_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));
always #5 clk = ~clk;
always @(posedge clk) if (!rst && in_valid && in_ready) sb.push_back(in_data);
always @(posedge clk) if (!rst && out_valid && out_ready) begin
if (sb.size() == 0) begin $error("delivered never-accepted word"); errors++; end
else begin
automatic logic [WIDTH-1:0] e = sb.pop_front();
if (out_data !== e) begin $error("lost/reordered: got %h exp %h", out_data, e); errors++; end
end
end
initial begin
rst = 1'b1; in_valid = 0; out_ready = 0; in_data = '0;
repeat (2) @(posedge clk); #1; rst = 1'b0;
// Force the hazard: hold in_valid high, de-assert out_ready on the accept cycle.
for (int i = 0; i < 8; i++) begin
@(negedge clk); in_valid = 1'b1; in_data = 8'h50 + i[7:0];
out_ready = (i % 2 == 0) ? 1'b0 : 1'b1; // stall on the transfer boundary
@(posedge clk); #1;
end
in_valid = 1'b0; out_ready = 1'b1;
repeat (8) @(posedge clk);
if (sb.size() != 0) begin $error("%0d accepted words never delivered (dropped)", sb.size()); errors++; end
if (errors == 0) $display("PASS: no word lost through the slice across the stall boundary");
else $display("FAIL: %0d errors (naive-flop slice dropped an in-flight word)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair tells the same story with reg/wire typing; the whole difference is the presence of the skid register and whether in_ready is registered (!skid_valid) or combinational (out_ready).
// BUGGY: plain forward flop, combinational ready, no skid slot -> in-flight word lost.
module rv_slice_bad #(parameter WIDTH = 8) (
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
);
reg [WIDTH-1:0] data_reg;
reg valid_reg;
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = out_ready; // COMBINATIONAL ready, single slot
always @(posedge clk) begin
if (rst) begin valid_reg <= 1'b0; data_reg <= {WIDTH{1'b0}}; end
else begin
if (in_valid && in_ready) begin
data_reg <= in_data; // overwrites the held, unconsumed word
valid_reg <= 1'b1;
end else if (valid_reg && out_ready) begin
valid_reg <= 1'b0;
end
end
end
endmodule
// FIXED: proper skid slice; in_ready = !skid_valid (registered), in-flight word lands in skid.
module rv_slice_good #(parameter WIDTH = 8) (
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
);
reg [WIDTH-1:0] data_reg, skid_data;
reg valid_reg, skid_valid;
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = ~skid_valid; // registered accept: skid free
wire accept = in_valid & in_ready;
wire consume = valid_reg & out_ready;
always @(posedge clk) begin
if (rst) begin valid_reg <= 1'b0; skid_valid <= 1'b0; data_reg <= {WIDTH{1'b0}}; skid_data <= {WIDTH{1'b0}}; end
else if (skid_valid) begin
if (out_ready) begin data_reg <= skid_data; skid_valid <= 1'b0; end
end else begin
if (consume & ~accept) valid_reg <= 1'b0;
else if (accept & (consume | ~valid_reg)) begin data_reg <= in_data; valid_reg <= 1'b1; end
else if (accept & valid_reg & ~consume) begin skid_data <= in_data; skid_valid <= 1'b1; end
end
end
endmodule module rv_slice_bug_tb;
parameter WIDTH = 8;
reg clk, rst, in_valid, out_ready;
reg [WIDTH-1:0] in_data;
wire in_ready, out_valid;
wire [WIDTH-1:0] out_data;
integer errors, i;
reg [WIDTH-1:0] sb [0:63];
integer head, tail;
// Bind to rv_slice_good to PASS; rv_slice_bad drops the in-flight word.
rv_slice_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));
always #5 clk = ~clk;
always @(posedge clk) if (!rst && in_valid && in_ready) begin
sb[tail] <= in_data; tail <= tail + 1;
end
always @(posedge clk) if (!rst && out_valid && out_ready) begin
if (head == tail) begin $display("FAIL: delivered never-accepted word"); errors = errors + 1; end
else begin
if (out_data !== sb[head]) begin
$display("FAIL: lost/reordered got %h exp %h", out_data, sb[head]); errors = errors + 1;
end
head <= head + 1;
end
end
initial begin
errors = 0; head = 0; tail = 0;
clk = 1'b0; rst = 1'b1; in_valid = 0; out_ready = 0; in_data = 8'h00;
repeat (2) @(posedge clk); #1; rst = 1'b0;
for (i = 0; i < 8; i = i + 1) begin
@(negedge clk); in_valid = 1'b1; in_data = 8'h50 + i;
out_ready = (i % 2 == 0) ? 1'b0 : 1'b1; // stall on the transfer boundary
@(posedge clk); #1;
end
in_valid = 1'b0; out_ready = 1'b1;
repeat (8) @(posedge clk);
if (head != tail) begin $display("FAIL: accepted words never delivered (dropped)"); errors = errors + 1; end
if (errors == 0) $display("PASS: no word lost through the slice across the stall boundary");
else $display("FAIL: %0d errors (naive-flop slice dropped an in-flight word)", errors);
$finish;
end
endmoduleIn VHDL the same bug appears when the slice has a single data_reg and drives in_ready <= out_ready; the fix adds skid_data/skid_valid and drives in_ready <= not skid_valid.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: plain forward flop, combinational ready, single slot -> in-flight word lost.
entity rv_slice_bad is
generic ( WIDTH : positive := 8 );
port ( clk, rst, 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 rv_slice_bad is
signal data_reg : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal valid_reg : std_logic := '0';
begin
out_valid <= valid_reg;
out_data <= data_reg;
in_ready <= out_ready; -- COMBINATIONAL ready, single slot
process (clk) begin
if rising_edge(clk) then
if rst = '1' then valid_reg <= '0'; data_reg <= (others => '0');
else
if in_valid = '1' and out_ready = '1' then
data_reg <= in_data; -- overwrites the held, unconsumed word
valid_reg <= '1';
elsif valid_reg = '1' and out_ready = '1' then
valid_reg <= '0';
end if;
end if;
end if;
end process;
end architecture;
-- FIXED: proper skid slice; in_ready = not skid_valid (registered).
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity rv_slice_good is
generic ( WIDTH : positive := 8 );
port ( clk, rst, 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 rv_slice_good is
signal data_reg, skid_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
signal valid_reg, skid_valid : std_logic := '0';
signal accept_i, consume_i : std_logic;
begin
out_valid <= valid_reg;
out_data <= data_reg;
in_ready <= not skid_valid; -- registered accept: skid free
accept_i <= in_valid and (not skid_valid);
consume_i <= valid_reg and out_ready;
process (clk) begin
if rising_edge(clk) then
if rst = '1' then
valid_reg <= '0'; skid_valid <= '0';
data_reg <= (others => '0'); skid_data <= (others => '0');
elsif skid_valid = '1' then
if out_ready = '1' then data_reg <= skid_data; skid_valid <= '0'; end if;
else
if consume_i = '1' and accept_i = '0' then
valid_reg <= '0';
elsif accept_i = '1' and (consume_i = '1' or valid_reg = '0') then
data_reg <= in_data; valid_reg <= '1';
elsif accept_i = '1' and valid_reg = '1' and consume_i = '0' then
skid_data <= in_data; skid_valid <= '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 rv_slice_bug_tb is
end entity;
architecture sim of rv_slice_bug_tb is
constant WIDTH : positive := 8;
signal clk : std_logic := '0';
signal rst, in_valid, in_ready, out_valid, out_ready : std_logic := '0';
signal in_data, out_data : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
type sb_t is array (0 to 63) of std_logic_vector(WIDTH-1 downto 0);
signal sb : sb_t;
signal head, tail : integer := 0;
begin
-- Bind to rv_slice_good to PASS; rv_slice_bad drops the in-flight word.
dut : entity work.rv_slice_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);
clkgen : process begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process;
check : process (clk)
begin
if rising_edge(clk) then
if rst = '0' and in_valid = '1' and in_ready = '1' then
sb(tail) <= in_data; tail <= tail + 1;
end if;
if rst = '0' and out_valid = '1' and out_ready = '1' then
assert head /= tail report "delivered a never-accepted word" severity error;
assert out_data = sb(head) report "lost/reordered word" severity error;
head <= head + 1;
end if;
end if;
end process;
stim : process
begin
rst <= '1'; wait until rising_edge(clk); wait until rising_edge(clk);
wait for 1 ns; rst <= '0';
for i in 0 to 7 loop
wait until falling_edge(clk);
in_valid <= '1'; in_data <= std_logic_vector(to_unsigned(16#50# + i, WIDTH));
if (i mod 2) = 0 then out_ready <= '0'; else out_ready <= '1'; end if; -- stall on boundary
wait until rising_edge(clk); wait for 1 ns;
end loop;
in_valid <= '0'; out_ready <= '1';
for i in 0 to 7 loop wait until rising_edge(clk); end loop;
assert head = tail report "accepted words never delivered (dropped)" severity error;
report "rv_slice_bug self-check complete: no word lost across the stall boundary" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: pipelining a handshake means inserting slices that preserve the contract — a plain flop on valid/data with ready wired through is not a handshake stage; use a full skid slice that registers both directions so no word is dropped and throughput stays one word per cycle.
5. Verification Strategy
A pipelined handshake's correctness is a data-integrity-and-rate-under-backpressure property along a chain of stages, so verification connects a producer and consumer through the full depth-N chain and proves that every accepted word is delivered in order, exactly once, under arbitrary end backpressure; that steady-state throughput is one word per cycle when both ends are ready; and that no combinational path runs from the far-upstream valid/data to the far-downstream, nor from the far-downstream out_ready to the far-upstream in_ready — each slice registers its cut.
Every word accepted on
in_valid && in_readyat the head of the chain is delivered onout_valid && out_readyat the tail exactly once and in FIFO order under ALL backpressure patterns; steady-state throughput is one word per cycle when both ends are ready; and every slice registers the direction it cuts, so there is no combinational path threading the whole chain in either direction.
Everything below makes that invariant checkable and maps onto the testbenches in §4.
- Build a chain of at least three slices and scoreboard it. Instantiate
STAGES >= 3proper skid slices (the §4arv_pipeline), push every word accepted at the head onto a reference queue, and pop-and-compare on every delivery at the tail. This proves in-order, exactly-once delivery across the whole chain, not just one stage — reordering or duplication introduced by any slice, or by the way slices compose, shows up here. - Randomized end backpressure, including on transfer cycles. Drive
out_readyat the tail from a random source that de-asserts at every phase, explicitly including the cycle a word transfers — that stall boundary, propagated back through the chain one slice at a time, is where a dropped-word slice fails. The §4a scoreboards do exactly this; a chain of naive-flop slices would drop words at every internal stall boundary. - Full 1/cycle throughput when both ends are ready. Hold
in_validandout_readycontinuously high and count deliveries over the window; after the fixed fill latency ofSTAGEScycles, deliveries must equal the cycle count. A chain of proper slices holds this (each accepts-and-offers in the same cycle); a chain where any slice is a half-throughput register slice delivers a word only every other cycle and fails — this is the check that separates a real pipelined handshake from a bubbling one. - Assert no combinational path across the whole chain (each slice registers its cut). The whole point is that
in_readyat the head is a registered function that reaches back only one slice, andout_valid/out_dataat the tail come out of flops — neither may wiggle combinationally in response to a signal at the far end within one cycle. Verify structurally (each slice drives its interface from flops) and in simulation by confirming the interface signals change only at clock edges. A testbench that findsin_readyat the head responding toout_readyat the tail in the same cycle has caught a chain that did not actually cut the path. - Compare a proper-slice chain against a half-throughput-slice chain. Run the throughput check on the §4a chain (full rate) and on a chain of single-register bubble slices (half rate) and confirm the accounting: same data-correctness, different bandwidth. This makes concrete why the slice flavor matters even when both are data-safe — the naive stage silently halves throughput.
- Corner cases along the chain. A long tail stall (backpressure propagates upstream slice by slice until the head's
in_readyfalls); the head idle mid-stream (in_validlow — the chain drains what it holds); reset mid-flight (every slice clears, allout_valid/skid_validreturn to zero,in_readyreturns high); andout_readytoggling every cycle at the tail, the harshest ordering test as each slice repeatedly crosses its RUNNING to STALLED-FULL boundary. The expected waveform on a correct run: with both ends ready, a word appears at the tailSTAGEScycles after it entered the head (the fill latency) and one word transfers per cycle thereafter; when the tail stalls, backpressure walks upstream one slice per cycle and no word in the chain is lost. The visual signature of a naive-flop slice is a delivered stream missing exactly the words caught in flight at an internal stall boundary.
6. Common Mistakes
Inserting a naive flop between handshake stages. The signature failure. To pipeline a link, the engineer drops a plain register on valid and data and wires ready straight through — registering only the forward direction with no skid slot. Because ready stays combinational and there is no landing pad, when out_ready falls on a cycle a word was accepted, the new word overwrites the held one and is dropped: the 9.3 dropped-word failure, now embedded in a pipeline slice. It passes whenever the sink never stalls mid-stream and corrupts exactly at the stall boundary. The fix is a proper skid slice that registers both directions and holds the in-flight word. (Post-mortem in §7.)
A half-throughput register slice where full throughput was required. A data-safe but over-conservative slice — a single register that accepts only when empty (in_ready = !valid_reg) — never drops a word but cannot accept and offer in the same cycle, so it inserts a bubble on every transfer and runs at half rate. Chain several of these and the whole pipeline runs at 0.5 words/cycle where the budget assumed 1.0. It is a fine choice only when the link can tolerate half rate; reaching for it when you needed full throughput silently halves your bandwidth. Use the full skid slice, whose RUNNING state accepts-and-offers in one cycle, wherever bandwidth matters.
Not actually breaking the ready chain. A datapath that looks pipelined — registers on the data path between stages — but whose ready still runs combinationally across all stages. You registered the forward path and left the backward path exactly as long as before, so the long combinational ready from 9.2 is untouched and the pipeline still fails timing on the backward path. Registering the data without registering the ready is doing half the work and keeping the timing bug. A pipeline slice must break the direction that is actually long; on a deep pipe that is usually both, which is why the full skid buffer (registers both) is the default slice.
Mismatched latency between parallel pipelined paths. When two ready/valid paths run in parallel and must recombine downstream (a datapath split then merged, or a control path that must stay aligned with data), inserting a different number of slices on each path shifts them by different amounts of latency, so words that were aligned at the split arrive misaligned at the merge. Each slice adds exactly one cycle of latency; if one branch gets two slices and the other gets three, the branches are one cycle out of step at the join and the merge samples mismatched words. Balance the slice count on parallel paths, or re-align explicitly at the merge — pipelining a handshake changes latency, and latency across parallel paths must stay matched.
Placing slices where the path is not actually long. Slicing is timing-driven: a slice pays one cycle of latency and one register per bit for the privilege of cutting a path. Inserting slices where no register-to-register segment exceeds the period buys nothing but latency and area; the useful slice sits on the longest valid/data run and the longest ready run so that after slicing no segment exceeds the clock period. Slice from timing reports, not by reflex — an extra slice that does not shorten the critical path is pure latency you gave away for free.
7. DebugLab
The pipeline stage that eats a word — a naive flop pretending to be a handshake slice
The engineering lesson: pipelining a handshake is not the same as pipelining a bus — a plain flop cuts a bare wire fine, but on a valid/ready link it either drops the word in flight (forward flop, combinational ready) or bubbles the stream (a stage that cannot accept-and-offer), because the link carries a contract, not just a value. The tell for the dropped-word slice is data loss that appears only when the downstream stalls mid-stream and tracks the stall pattern rather than the data — a 'register slice' that is correct under light or idle-aligned stalls but eats a word exactly on the stall boundary is a naive flop masquerading as a handshake stage, not a link fault. The rule that makes it impossible is identical across SystemVerilog, Verilog, and VHDL: a pipeline slice on a handshake link must be a proper registered handshake stage — register the direction it cuts and give the in-flight word a landing pad (the skid slot) — and a full skid slice, which registers both directions and accepts-and-offers in one cycle, is the default because it cuts both the long forward and long backward paths at full throughput. Then verify across a chain of at least three slices by stalling on the exact transfer cycle (no word lost) and counting transfers over a both-ends-ready window (one per cycle). Chaining proper skid slices is the standard way real chips pipeline a timing-critical streaming interface.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the slice-flavor / latency-vs-throughput / contract-preserving reasoning behind pipelining a handshake.
Exercise 1 — Pick the slice flavor for the long path
For each situation, say whether you would insert a forward (valid) register slice, a backward (ready) register slice, or a full skid buffer, and why in one line: (a) static timing shows the long valid/data run failing but the ready path meets timing comfortably; (b) the backward ready run through five stages is the critical path and the forward path is fine; (c) a deep, fast pipeline where both the forward and backward runs exceed the period; (d) a bandwidth-critical AXI-stream link between two blocks that must stay at full rate. Then state which flavor you would standardize on as the default pipeline slice and why.
Exercise 2 — Do the latency-vs-throughput accounting
A ready/valid datapath is sliced with four full skid stages between source and sink. With both ends continuously ready, (i) how many cycles after a word enters the head does it appear at the tail, and (ii) what is the steady-state throughput in words per cycle? Now replace two of the four with half-throughput register slices and answer (i) and (ii) again, explaining exactly where the bubbles come from. State in one line the single controller capability that gives the full skid slice its extra throughput.
Exercise 3 — Prove the naive-flop slice drops a word on paper
Take a single naive-flop slice (in_ready = out_ready, one payload register, plain flop on valid/data). Hand-trace data_reg, valid_reg, in_ready, and out_valid for this sequence: two words arrive back-to-back (in_valid high) while out_ready is high for the first and low for the second, then high again. Mark the exact cycle a word is overwritten and lost, and explain in one line why a proper skid slice would have caught that word instead. Then state which single deterministic stall pattern in a testbench directly exposes this bug.
Exercise 4 — Balance parallel pipelined paths
A datapath splits a stream into two branches that a downstream merge block recombines word-by-word, expecting the two branches to stay time-aligned. Timing forces you to insert three full skid slices on branch A and only one on branch B. (i) By how many cycles are the branches misaligned at the merge, and what does the merge sample as a result? (ii) Give two ways to restore alignment, and name the cost of each. (iii) State the general rule about slice counts on parallel paths that must stay aligned.
10. Related Tutorials
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- Credit-Based Flow Control — Chapter 9.4; the alternative to registered
readyon long links — decouple the ends with credits so the backward path is a counter, not a combinational run to slice. - Handshake Bugs & Deadlocks — Chapter 9.5; the classic valid/ready failures a correctly-sliced pipeline must still avoid — combinational loops,
validdropped before transfer, deadlock. - Case Study — Streaming FIFO — Chapter 9.7; a full streaming block where skid slices pipeline the timing-critical interfaces around a central FIFO — this page's chain in a real design.
Backward / in-track dependencies:
- Skid Buffers — Chapter 9.3; the full-throughput registered handshake stage this page chains — the two-slot slice that registers both directions and never drops a word.
- Backpressure & Stalls — Chapter 9.2; the long combinational
readychain this page breaks by inserting registered slices. - The valid/ready Handshake — Chapter 9.1; the two-wire contract every slice preserves on both faces — the composability that lets slices chain with no glue.
- Latency vs Throughput — Chapter 8.2; the accounting this page revisits — each slice adds one cycle of latency and cuts zero throughput.
- Pipelined Datapath — Chapter 8; cutting a combinational datapath with registers to raise frequency — the same move, here on a flow-controlled link.
- The Pipeline Valid Bit — Chapter 8.1; the
validqualifier that travels with data through stages — the forward half of what each slice registers. - Synchronous FIFO Architecture — Chapter 7.1; the FIFO a skid slice is a depth-2 special case of, and the buffering to reach for when you need rate-matching, not just path-cutting.
- The RTL Design Mindset — Chapter 0.2; the Datapath and Verification lenses this page applies — slices are pure timing structure, and the scoreboard is how you prove the chain never drops a word.
- What Is an RTL Design Pattern? — Chapter 0.1; why chaining registered handshake slices earns the name 'pattern' — a recurring timing problem, a reusable composition, a synthesis reality, and a signature failure.
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Physical Data Types — the
reg/wirevector widths (WIDTH) the payload, skid, and inter-slice buses are sized in. - Blocking and Non-Blocking Assignments — why every slice's payload registers and occupancy bits update with non-blocking (
<=) in their clocked block. - If-Else Statements — the mutually-exclusive
if / else ifchain each slice's three-state controller is written as. - Case Statements — the selection construct the slice controller and the chain wiring compose from.
11. Summary
- A long handshake path fails timing because both directions run combinationally. In a bare valid/ready chain the forward
valid/datapath runs from source to sink and the backwardreadypath runs from sink to source, both in one clock period and both growing with chain length. Pipelining cuts those long runs with registered handshake slices — but a handshake link carries a contract, so you cut it with a slice, not a plain flop. - Three slice flavors, by which path they break. A forward (valid) slice registers
valid/dataand cuts only the forward path; a backward (ready) slice registersreadyand cuts only the backward path; a full skid buffer registers both and, thanks to its skid slot, accepts-and-offers in the same cycle so it cuts both at full throughput. Match the flavor to the direction that is long; reach for the full skid buffer when both are, which on a deep pipe is the usual case. - Chaining slices pipelines the handshake — latency up, throughput unchanged. Each proper slice adds one cycle of latency and one pipeline register, so N slices add N cycles of latency; but each full skid slice accepts-and-offers in one cycle, so the chain still moves one word per cycle in steady state. This is 8.2's pipelining trade on a flow-controlled link — spend latency to buy frequency. Place slices from timing reports, on the longest
validandreadyruns, so every register-to-register segment fits the period. - The signature bug is a naive flop pretending to be a slice. A plain register on
valid/datawithreadywired straight through registers only the forward direction with no landing pad, so it drops the word in flight whenout_readyfalls on a transfer cycle — the 9.3 dropped-word failure re-introduced as a pipeline slice — and it does not even cut the backward path. A half-throughput register slice never drops a word but bubbles every transfer and halves the rate. The full skid slice, which registers both directions and accepts-and-offers in one cycle, avoids both. - Verify across a chain of at least three slices. Scoreboard for in-order, exactly-once delivery under random end backpressure; de-assert
out_readyon the exact transfer cycle to force the in-flight-word hazard at every internal boundary; count transfers over a both-ends-ready window to confirm one word per cycle (catching any bubbling slice); and check that each slice registers its cut so no combinational path threads the whole chain. Built and self-check-verified here in SystemVerilog, Verilog, and VHDL.