RTL Design Patterns · Chapter 9 · Handshake & Flow Control
Skid Buffers
A skid buffer is a small two-slot pipeline stage that lets you register the ready signal without ever dropping data. When you apply backpressure by chaining ready straight through several stages, that signal becomes one long combinational path from the far end back to the front, and on a fast pipeline it fails timing. Registering ready fixes the path, but a naive version loses any word already launched upstream on the cycle ready falls. The skid buffer adds one extra register so that in-flight word always has somewhere to land. This lesson builds the two-slot datapath and its three-state control, proves it never drops a word and still moves one word per cycle in steady state, and codes it in SystemVerilog, Verilog, and VHDL.
Advanced16 min readRTL Design PatternsSkid BufferValid ReadyBackpressureRegistered ReadyPipeline Timing
Chapter 9 · Section 9.3 · Handshake & Flow Control
1. The Engineering Problem
In 9.1 you built the valid/ready handshake: a word transfers on exactly the cycles where valid && ready are both high, and the two wires point in opposite directions — valid flows downstream with the data, ready flows upstream as permission. In 9.2 you used ready to apply backpressure: when a stage cannot accept more data, it drops its out_ready, and to propagate that stall upstream it drives its own in_ready from the downstream out_ready. The simplest correct way to do that is combinational: assign in_ready = out_ready (or in_ready = out_ready || !holding_a_word). It is correct, and for one isolated stage it is fine.
The trouble starts when stages chain, which is the normal case. Each stage's in_ready depends combinationally on the next stage's out_ready, which depends on the stage after that, and so on. A stall that originates at the far downstream end of the pipeline must ripple, in a single clock period, all the way back to the front so the source knows to hold. That backward ready becomes one long combinational path threading every stage's stall logic between two flops — and its logic depth grows with the number of stages. On a deep pipeline, or a shallow one clocked fast, that path is the critical path and it does not close timing. You cannot fix it by re-arranging the boolean logic; the depth is inherent to letting one cycle's ready see the whole pipeline.
The standard remedy is to cut the path with a register: make in_ready come directly out of a flop so a downstream stall only has to reach the nearest stage's ready flop in one cycle, not the whole chain. But registering ready changes its meaning. A combinational ready says 'I can take a word this cycle, having just looked at everything downstream.' A registered ready says 'based on what I knew last cycle, I can take a word this cycle.' That one-cycle staleness is the whole problem: an upstream source that saw your in_ready high last cycle will launch a word this cycle — it has already committed — and if this is exactly the cycle your in_ready needed to fall, that word is already on the wire with nowhere to go. If your stage holds only one word, the newcomer overwrites the word you were presenting downstream, and a word is silently lost. You need a landing spot for exactly that one in-flight word. That landing spot is the skid buffer.
// From 9.2: combinational backpressure. Correct, but the ready path is LONG.
// assign in_ready = out_ready; // ties far-downstream out_ready to far-upstream in_ready
// We want to REGISTER in_ready (and out_valid) to cut that path:
// always_ff @(posedge clk) in_ready <= <can I take one next cycle?>;
// always_ff @(posedge clk) out_valid <= <do I have a word to present?>;
// But a registered ready is a promise one cycle stale. Upstream saw in_ready HIGH
// last cycle, so it LAUNCHES a word this cycle -- even if in_ready must fall now.
// With ONE payload register there is nowhere to put that in-flight word:
// data_reg <= in_data; // OVERWRITES the word we were presenting -> LOST.
// The need: ONE extra 'skid' register so the in-flight word has somewhere to land,
// so we can register BOTH out_valid and in_ready and still move 1 word/cycle.2. Mental Model
3. Pattern Anatomy
The structure is two payload registers and a small controller. Everything hard is in how the controller decides when to fill the skid slot and when to drain it, and in keeping both interface signals registered.
The two-slot datapath. There are exactly two payload storage elements. The primary register holds the word currently presented downstream — it drives out_data, and out_valid is the registered bit that says it is occupied. The skid register (skid_data, with an occupancy bit skid_valid) is the landing pad: it holds the one extra word that arrived while the downstream was stalled. In steady flow the skid register is empty and the buffer behaves like a single registered stage; it fills only when the downstream de-asserts out_ready while a fresh word is arriving, and it empties again the moment the downstream re-asserts out_ready. out_data always comes from the primary register; the skid register never drives the output directly — it only reloads the primary register on the cycle after a stall clears.
The registered accept — in_ready = !skid_valid. The stage accepts a word whenever its skid slot is free, because a free skid slot guarantees a place to land the one word that could be in flight if the downstream stalls next cycle. So in_ready is driven directly by the registered skid_valid bit — no combinational path from out_ready to in_ready. When a word arrives and the downstream is not ready to take the primary word, the arriving word goes into the skid register and in_ready drops for one cycle (skid now full). When the downstream re-asserts out_ready, the primary register is consumed, the skid word slides into the primary register, the skid empties, and in_ready rises again. That fill-then-drain is the 'skid.'
The two-slot skid datapath — primary register presents, skid register catches the in-flight word
data flowThe three-state control. The controller has exactly three reachable states, and naming them makes the whole thing legible:
- EMPTY — neither register holds a word.
out_valid = 0,in_ready = 1(skid free). An arriving word loads the primary register and moves to RUNNING. - RUNNING — the primary register holds a word (
out_valid = 1), the skid register is free (in_ready = 1). This is the steady state, and it moves one word per cycle: if the downstream takes the primary word (out_ready = 1) and a new word arrives, the new word loads the primary register and you stay in RUNNING — accept and offer in the same cycle, which is what keeps throughput at 1/cycle. If a new word arrives but the downstream is not ready, the new word goes to the skid register and you move to STALLED-FULL. - STALLED-FULL — both registers hold a word: primary presented downstream, skid holding the in-flight word (
out_valid = 1,in_ready = 0because the skid slot is taken). You stay here until the downstream assertsout_ready; on that cycle the primary word leaves, the skid word reloads the primary register, the skid empties, and you drop back to RUNNING within_readyhigh again.
The skid-buffer control FSM — EMPTY / RUNNING / STALLED-FULL
data flowWhy depth-2 is exactly enough, and the relation to a FIFO. A skid buffer is, in spirit, a depth-2 FIFO (7.1): a small circular buffer where the pointers can never be more than two entries apart. But you do not need the general FIFO machinery, because the interface is a single registered handshake stage: with in_ready registered, upstream can launch at most one unexpected word before it sees in_ready fall, so the maximum overflow is one word — hence two slots (the one being presented plus the one in flight). A depth-1 buffer has no room for the in-flight word (the dropped-word bug, §6/§7); a depth-3+ buffer stores words that a registered in_ready can never actually put in flight, so it is pure wasted area for this purpose. Depth-2 is the exact size of a registered skid stage.
The throughput proof — drains as fast as it fills. The skid slot can gain at most one word per cycle (upstream can launch one word per cycle) and can shed one word per cycle (the primary consumes one, the skid reloads it). So whenever the downstream is ready, the skid drains one word while at most one arrives — occupancy never runs away, and the moment both sides are ready the buffer is in RUNNING accepting and offering in the same cycle: one word in, one word out, every cycle. The skid buffer therefore costs one cycle of pipeline latency (a word passes through the primary register) and one payload register of area, but it does not cost throughput — steady-state rate is 1 word/cycle whenever both sides can sustain it. That is the invariant every testbench checks in §5.
4. Real RTL Implementation
This is the core of the page. We build two things — (a) a full-throughput two-slot skid buffer (registered in_ready, out_valid, out_data; a primary register plus a skid register; the three-state control; WIDTH-generic) with a self-checking clocked scoreboard testbench that applies random out_ready de-assertions (including on transfer cycles) and asserts in-order exactly-once delivery, one-word-per-cycle steady-state throughput, and that in_ready/out_valid are registered; and (b) the single-register dropped-word bug beside the two-slot fix — and each is shown in SystemVerilog, Verilog, and VHDL with an RTL block and a self-checking clocked testbench. The reasoning is identical in all three; only the syntax differs.
One discipline runs through every RTL block, inherited from the register pattern (2.1) and 9.1/9.2: both payload registers and both occupancy bits update on the clock with non-blocking assignments; reset is explicit (synchronous, active-high rst here); a word is accepted only when in_valid && in_ready, presented downstream only when out_valid, and consumed only when out_valid && out_ready. The interface signals in_ready and out_valid are driven from registered bits, never combinationally from out_ready.
4a. The full-throughput two-slot skid buffer — three ways
The buffer stores at most two WIDTH-bit words: one in the primary register (presented downstream) and at most one in the skid register (caught during a stall). out_valid is the primary register's occupancy bit; in_ready is the inverse of the skid register's occupancy bit (in_ready = !skid_valid). The single always/process block below is the whole controller.
module skid_buffer #(
parameter int WIDTH = 8 // datapath width
)(
input logic clk,
input logic rst, // synchronous, active-high
// upstream (producer) side
input logic in_valid,
output logic in_ready, // REGISTERED: = !skid_valid
input logic [WIDTH-1:0] in_data,
// downstream (consumer) side
output logic out_valid, // REGISTERED: primary occupancy
input logic out_ready,
output logic [WIDTH-1:0] out_data // primary register
);
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
// Interface signals are pure registered reads -> no comb path out_ready->in_ready.
assign out_valid = valid_reg;
assign out_data = data_reg;
assign in_ready = ~skid_valid; // accept iff the skid slot is free
// A word is accepted this cycle iff upstream offers and we are ready.
wire accept = in_valid & in_ready; // in_valid && !skid_valid
// The primary word is consumed this cycle iff we present and downstream takes it.
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 begin
if (skid_valid) begin
// STALLED-FULL: both slots held. Wait for the downstream to take the
// primary word; then the skid word slides into the primary register.
if (out_ready) begin
data_reg <= skid_data; // drain skid -> primary
skid_valid <= 1'b0; // skid now free -> in_ready rises
// valid_reg stays 1: primary is immediately reloaded from skid.
end
end else begin
// EMPTY or RUNNING (skid free -> in_ready high).
if (consume && !accept) begin
// Primary leaves, nothing new arrives -> primary empties.
valid_reg <= 1'b0;
end else if (accept && (consume || !valid_reg)) begin
// Accept-and-offer in the SAME cycle (RUNNING throughput = 1/cycle),
// or first fill from EMPTY: new word loads the primary register.
data_reg <= in_data;
valid_reg <= 1'b1;
end else if (accept && valid_reg && !consume) begin
// Primary busy and NOT consumed, but a word arrived -> SKID it.
skid_data <= in_data; // land the in-flight word
skid_valid <= 1'b1; // move to STALLED-FULL -> in_ready falls
end
end
end
end
endmoduleThe clocked scoreboard testbench is where the flagship claim is proven: it streams a known sequence in, randomly de-asserts out_ready (including on the exact cycle a word transfers in), pushes every accepted word onto a reference queue and pops it when a word transfers out, and asserts the outputs match in order, exactly once. It also counts transfers over a both-sides-ready window to check the steady-state rate is one word per cycle, and checks in_ready/out_valid are registered by sampling them just after the clock edge.
module skid_buffer_tb;
localparam int WIDTH = 8;
localparam int NWORDS = 200;
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;
skid_buffer #(.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;
// Scoreboard: expected words in the order they were accepted.
logic [WIDTH-1:0] sb [$];
int sent = 0, got = 0;
logic [WIDTH-1:0] next_word = 8'h00;
// Producer: offer a fresh, monotonically-numbered word whenever we can.
always @(negedge clk) begin
if (rst) begin in_valid <= 1'b0; end
else begin
in_valid <= (sent < NWORDS);
in_data <= next_word;
end
end
// When a word is actually ACCEPTED, record it and advance the source number.
always @(posedge clk) if (!rst && in_valid && in_ready) begin
sb.push_back(in_data); sent++; next_word <= next_word + 8'd1;
end
// Consumer: randomly stall, INCLUDING on cycles where a transfer would occur.
always @(negedge clk) out_ready <= ($urandom_range(0, 3) != 0); // ~75% ready
// Check every word that transfers out: in order, exactly once, correct value.
always @(posedge clk) if (!rst && out_valid && out_ready) begin
if (sb.size() == 0) begin
$error("delivered a word that was never accepted: %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
// Registered-interface check: sampled right after the edge, in_ready must equal
// !skid_valid and out_valid must equal the primary occupancy — pure flop outputs,
// never wiggling combinationally within the cycle from out_ready.
logic in_ready_q, out_valid_q;
always @(posedge clk) begin in_ready_q <= in_ready; out_valid_q <= out_valid; end
// Throughput: over a long window where BOTH sides are ready, one word/cycle.
int both_ready_cycles = 0, both_ready_xfers = 0;
always @(posedge clk) if (!rst) begin
if (in_valid && in_ready && out_valid && out_ready) both_ready_xfers++;
if (in_valid && out_ready) both_ready_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;
// Run until every word has been accepted AND drained out.
wait (got == NWORDS);
repeat (4) @(posedge clk);
if (sb.size() != 0) begin $error("%0d words accepted but 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 in-order exactly-once; registered ready/valid; ~1 word/cycle when both ready", NWORDS);
else
$display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog form is the same controller with reg/wire typing; the scoreboard uses a simple array + head/tail indices instead of a SystemVerilog queue, and $random in place of $urandom.
module skid_buffer #(
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 begin
if (skid_valid) begin
// STALLED-FULL: drain skid into primary when downstream takes primary.
if (out_ready) begin
data_reg <= skid_data;
skid_valid <= 1'b0;
end
end else begin
if (consume & ~accept) begin
valid_reg <= 1'b0; // primary empties
end else if (accept & (consume | ~valid_reg)) begin
data_reg <= in_data; // accept-and-offer / first fill
valid_reg <= 1'b1;
end else if (accept & valid_reg & ~consume) begin
skid_data <= in_data; // land in-flight word
skid_valid <= 1'b1; // -> STALLED-FULL
end
end
end
end
endmodule module skid_buffer_tb;
parameter WIDTH = 8;
parameter NWORDS = 200;
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;
// Scoreboard as a ring of expected words.
reg [WIDTH-1:0] sb [0:1023];
integer head, tail;
skid_buffer #(.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;
// Producer.
always @(negedge clk) begin
if (rst) in_valid <= 1'b0;
else begin
in_valid <= (sent < NWORDS);
in_data <= next_word;
end
end
// Record on actual acceptance.
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
// Consumer: random stalls, including on transfer cycles.
always @(negedge clk) out_ready <= ($random % 4 != 0);
// Check each delivered word: in order, exactly once.
always @(posedge clk) if (!rst && out_valid && out_ready) begin
if (head == tail) begin
$display("FAIL: delivered a word never accepted: %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 (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 in-order exactly-once; registered ready/valid", NWORDS);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the two payload registers are std_logic_vector signals with unsigned-free control bits; the interface signals are concurrent assignments from the registered occupancy bits, and the controller is a single clocked process.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity skid_buffer 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 skid_buffer 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
-- Interface signals: pure registered reads -> no comb path out_ready -> in_ready.
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');
else
if skid_valid = '1' then
-- STALLED-FULL: drain skid into primary when downstream takes it.
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'; -- primary empties
elsif accept_i = '1' and (consume_i = '1' or valid_reg = '0') then
data_reg <= in_data; -- accept-and-offer / first fill
valid_reg <= '1';
elsif accept_i = '1' and valid_reg = '1' and consume_i = '0' then
skid_data <= in_data; -- land in-flight word
skid_valid <= '1'; -- -> STALLED-FULL
end if;
end if;
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity skid_buffer_tb is
end entity;
architecture sim of skid_buffer_tb is
constant WIDTH : positive := 8;
constant NWORDS : integer := 200;
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');
-- Scoreboard ring of expected words.
type sb_t is array (0 to 1023) 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');
-- simple LFSR for a random stall pattern
signal lfsr : unsigned(7 downto 0) := x"A5";
begin
dut : entity work.skid_buffer
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;
-- Producer + random consumer stall, driven off the falling edge.
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;
-- Record accepted words; check delivered words in order, exactly once.
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 "skid_buffer self-check complete: in-order exactly-once, registered ready/valid" severity note;
wait;
end process;
end architecture;4b. The single-register dropped-word bug — buggy vs fixed, in all three HDLs
Pattern (b) is the signature failure, shown here as buggy vs fixed RTL in each language and dramatized in §7. The buggy design registers out_valid/out_data and drives in_ready combinationally from out_ready, but keeps only one payload register — no skid slot. When out_ready de-asserts on a cycle where a word was accepted, that just-accepted word overwrites the currently-held output and is lost. The fix is the two-slot skid buffer from 4a.
// BUGGY: registers out_valid/out_data but has NO skid slot, and drives in_ready
// combinationally from out_ready. When out_ready falls on a cycle where a
// word was accepted, the new word OVERWRITES the held output -> word LOST.
module skid_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, and only one slot
always_ff @(posedge clk) begin
if (rst) begin valid_reg <= 1'b0; data_reg <= '0; end
else begin
// Accept whenever in_ready (= out_ready) is high...
if (in_valid && in_ready) begin
data_reg <= in_data; // OVERWRITES a held, unconsumed word
valid_reg <= 1'b1;
end else if (valid_reg && out_ready) begin
valid_reg <= 1'b0;
end
end
end
endmodule
// FIXED: add the SKID register. in_ready = !skid_valid (registered). The in-flight
// word lands in the skid slot instead of overwriting the primary word.
module skid_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 skid_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 skid_good to PASS. skid_bad drops the word accepted on the stall edge.
skid_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;
// Record every accepted word; check every delivered word in order, exactly once.
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;
// Drive the EXACT hazard: hold in_valid high, then de-assert out_ready on the
// cycle a word is accepted, so the buggy DUT would overwrite the held word.
out_ready = 1'b1;
for (int i = 0; i < 8; i++) begin
@(negedge clk); in_valid = 1'b1; in_data = 8'h30 + i[7:0];
out_ready = (i % 2 == 0) ? 1'b0 : 1'b1; // stall on even cycles (transfer boundary)
@(posedge clk); #1;
end
in_valid = 1'b0;
// Drain everything still buffered.
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 across the stall boundary; every accepted word delivered once");
else $display("FAIL: %0d errors (an in-flight word was dropped)", errors);
$finish;
end
endmoduleThe Verilog buggy/fixed pair tells the same story with reg/wire typing; the difference is entirely the presence of the skid register and whether in_ready is registered (!skid_valid) or combinational (out_ready).
// BUGGY: one payload register, combinational in_ready -> in-flight word overwritten.
module skid_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, 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 a held, unconsumed word
valid_reg <= 1'b1;
end else if (valid_reg && out_ready) begin
valid_reg <= 1'b0;
end
end
end
endmodule
// FIXED: add the skid register; in_ready = !skid_valid (registered).
module skid_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 skid_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 skid_good to PASS; skid_bad drops the in-flight word.
skid_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'h30 + 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 across the stall boundary");
else $display("FAIL: %0d errors (an in-flight word was dropped)", errors);
$finish;
end
endmoduleIn VHDL the same bug appears when there is a single data_reg and 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: one payload register, combinational in_ready -> in-flight word overwritten.
entity skid_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 skid_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, 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 a 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: add the skid register; in_ready = not skid_valid (registered).
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity skid_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 skid_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 skid_bug_tb is
end entity;
architecture sim of skid_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 skid_good to PASS; skid_bad drops the in-flight word.
dut : entity work.skid_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#30# + 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 "skid_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: register both out_valid and in_ready, drive in_ready from the skid slot's emptiness (in_ready = !skid_valid), and add exactly one skid register so the word already in flight when ready falls has somewhere to land — then no word is lost and throughput stays at one word per cycle.
5. Verification Strategy
A skid buffer's correctness is a data-integrity-under-backpressure property, so verification lives at the stall boundary: connect through the buffer, drive backpressure that de-asserts out_ready at every phase — including the exact cycle a word transfers in — and prove that every accepted word comes out in order, exactly once, that steady-state throughput is one word per cycle, and that the interface signals are registered.
Every word accepted on
in_valid && in_readyis delivered onout_valid && out_readyexactly once and in FIFO order under ALL backpressure patterns; steady-state throughput is one word per cycle when both sides are ready; andin_readyandout_validare registered — there is no combinational path fromout_readytoin_ready.
Everything below makes that invariant checkable and maps onto the testbenches in §4.
- Scoreboard: in-order, exactly-once delivery under random backpressure. Push every word accepted on
in_valid && in_readyonto a reference queue; pop and compare on everyout_valid && out_ready. Driveout_readyfrom a random source that de-asserts at every phase, explicitly including the cycle on which a word is accepted — that stall boundary is where the dropped-word bug lives. At the end assert the queue is empty (no accepted word was dropped) and every delivered word matched (no reorder, no duplication). The §4 scoreboards do exactly this: SV queuepush_back/pop_frontwithassert, Verilog ring buffer withif (…) $display("FAIL"), VHDL ring withassert … severity error. - The exact hazard: stall on a transfer cycle. Beyond random stalls, drive the deterministic worst case directly — hold
in_validhigh and de-assertout_readyon precisely the cycle a word is accepted, so a single-register design would overwrite the held word. A correct skid buffer lands the in-flight word in the skid slot and delivers it later; the §4b bug testbench forces this pattern and requires the earliest words to survive. - Throughput: one word per cycle when both sides are ready. Count transfers over a window where
in_validandout_readyare continuously high; the delivered count must equal the number of cycles (minus the fixed one-cycle fill latency). A design that inserts a bubble every transfer (the half-throughput register slice, §6) delivers a word only every other cycle and fails this check — this is what separates a true skid buffer from a naive registered stage. - Registered-interface check.
in_readymust be a pure function of the registeredskid_validbit andout_validof the registered primary occupancy — neither may wiggle combinationally within a cycle in response toout_ready. Verify structurally (the RTL drives them from flops) and in simulation by confirming they only change at clock edges. The whole point of the pattern is that this registration breaks the long backpressure path from 9.2; a testbench that findsin_readyresponding toout_readyin the same cycle has caught a design that did not actually cut the path. - Corner cases to exercise. Downstream stalls for a long run then releases (skid holds one word the whole time, drains on release); upstream idles mid-stream (
in_validlow — the buffer holds and delivers what it has); simultaneous fill-and-drain in RUNNING (accept and offer in the same cycle — the throughput case); reset asserted mid-flight (both registers clear,out_validandskid_validreturn to zero,in_readyreturns high); and back-to-back stall/release toggling every cycle (out_readyalternating), which repeatedly crosses the RUNNING to STALLED-FULL boundary and is the harshest ordering test. - Expected waveform. On a correct run, as words stream in with
out_readyhigh, each word appears onout_dataone cycle after acceptance (the fill latency) andout_valid/out_readytransfer one per cycle. Whenout_readydrops mid-stream,out_validstays high holding the primary word, the next accepted word loads the skid register, andin_readyfalls for exactly the cycles the skid is full; whenout_readyre-asserts, the primary word transfers, the skid word slides into the primary register, andin_readyrises again — no gap in the delivered sequence, no lost word. The visual signature of the single-register bug is a delivered stream that is missing the word accepted on a stall edge; the signature of the half-throughput slice is a delivered word only every other cycle even with both sides ready.
6. Common Mistakes
The single-register 'skid' that drops the in-flight word. The signature failure. A designer registers out_valid/out_data and drives in_ready = out_ready combinationally but keeps only one payload register — no skid slot. Because in_ready tracks out_ready, on the cycle out_ready (hence in_ready) falls, an upstream that committed to a word off the previous cycle's high in_ready still drives it in, and the block's single register overwrites the currently-held, unconsumed output — the word it was presenting is destroyed and the newcomer takes its place, so one word is silently lost at the stall boundary. It passes when the downstream never stalls mid-stream (nothing is ever in flight when ready falls) and corrupts exactly on the stall boundary. The fix is the second register: in_ready = !skid_valid, land the in-flight word in the skid slot. (Post-mortem in §7.)
A correct-but-half-throughput register slice. A legitimate, data-safe design that pays for its safety with throughput: a single-register stage that refuses to accept a new word in the same cycle it presents its held word, so it can only either offer or accept per cycle, never both. It never drops a word — but it inserts a bubble on every transfer, so a stream that should move at one word per cycle moves at one word every two cycles: half throughput. It is a fine choice when area is scarce and you can tolerate half rate, but it is not a skid buffer, and reaching for it when you needed full throughput silently halves your bandwidth. The true skid buffer's RUNNING state accepts-and-offers in the same cycle precisely to avoid this bubble. (Second DebugRow in §7.)
Combinational in_ready that defeats the timing purpose. The entire reason to build a skid buffer is to register in_ready so a downstream stall reaches only the nearest ready flop, cutting the long combinational backpressure path from 9.2. If you leave in_ready = out_ready (or otherwise make in_ready a combinational function of out_ready) — even with a correct two-slot datapath — you have re-introduced the very path you set out to break, and the pipeline still fails timing. in_ready must come from the registered skid_valid bit. Registering the data without registering the ready is doing the work and keeping the bug.
Off-by-one in the skid control — draining or accepting one cycle wrong. The subtle failures cluster at the STALLED-FULL to RUNNING transition. Draining the skid one cycle early (before the downstream actually took the primary word) loses the primary word; draining one cycle late wastes a throughput slot and can wedge if a new word is arriving. Accepting into the skid when the primary is being consumed the same cycle (so the primary slot is actually about to free) needlessly fills the skid and drops in_ready a cycle too long. The condition set must be exact: skid the new word only when the primary is held and not being consumed; drain the skid on the cycle the primary is consumed; accept-and-offer in the same cycle when the primary is consumed and a word arrives. The §4a controller encodes precisely these.
Making the skid register drive the output. A tempting but wrong shortcut is to assign out_data = skid_valid ? skid_data : data_reg — presenting the skid word directly. That reintroduces a mux (and often a combinational out_valid path) on the output, and it breaks the invariant that out_data is a clean registered value. The skid register must never drive the output; it only reloads the primary register on the drain cycle. Keeping the output a pure register read is part of what makes the interface clean and the timing predictable.
7. DebugLab
The pipeline that eats a word at the stall boundary — a single-register 'skid' with no landing pad
The engineering lesson: a registered ready is a promise made one cycle stale, so an upstream that saw it high has always committed one word you must be able to catch — give that in-flight word exactly one landing pad (the skid register) or you lose it, and let RUNNING accept-and-offer in the same cycle or you halve your throughput. The tell for the dropped-word bug is data loss that appears only when the downstream stalls mid-stream and tracks the stall pattern rather than the data — a stage correct under light or idle-aligned stalls that eats a word exactly on the stall boundary is a missing skid slot, not a link fault. The tell for the throughput bug is a data-perfect stage that nonetheless runs at half rate with both sides ready. Two rules make both impossible, and they are identical across SystemVerilog, Verilog, and VHDL: register both out_valid and in_ready, drive in_ready = !skid_valid, and add one skid register so the in-flight word lands instead of overwriting, and let the RUNNING state accept a new word and present the held word in the same cycle so no bubble is inserted. Then verify by stalling on the exact transfer cycle (no word lost) and by counting transfers over a both-ready window (one per cycle). The skid buffer is the pattern to internalize because it is the standard registered-handshake buffer that appears at nearly every timing-critical interface on a real chip.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the in-flight-word / registered-ready reasoning and the boundary discipline behind it.
Exercise 1 — Trace the stall boundary and prove no word is lost
For an 8-bit skid buffer, hand-trace data_reg, valid_reg, skid_data, skid_valid, in_ready, and out_valid cycle by cycle for this sequence: three words arrive back-to-back (in_valid high) while out_ready is high for the first, low for the second and third, then high again. Mark the exact cycle the skid register fills, the cycle in_ready falls, and the cycle the skid drains into the primary register. Confirm in one line each that no accepted word is overwritten and that every accepted word eventually leaves on out_valid && out_ready.
Exercise 2 — Prove one word per cycle
With both in_valid and out_ready held continuously high, hand-trace the buffer from EMPTY through five words and show that after the one-cycle fill latency, exactly one word transfers out per cycle. Then repeat the trace for the half-throughput register slice (in_ready = !valid_reg, one register) and show it delivers a word only every other cycle. State the single controller condition that gives the skid buffer its extra throughput.
Exercise 3 — Choose the buffer
For each requirement, pick a full-throughput skid buffer, a half-throughput register slice, or plain combinational backpressure, and justify in one line: (a) a deep pipeline whose backward ready path fails timing and must run at full bandwidth; (b) a single, isolated stage where the ready path already meets timing and area is at a premium; (c) a timing-critical interface that can tolerate half rate but not the extra register; (d) a bandwidth-sensitive AXI-stream register slice between two clock-gated blocks. Then state which one you would standardize on for a generic registered-handshake buffer and why.
Exercise 4 — Verify without shipping the bug
Write the test plan (not the RTL) that would have caught the §7 dropped-word bug before tape-out. Specify: the scoreboard mechanism and what you push/pop; the exact deterministic stall pattern (which cycle you de-assert out_ready) that forces the in-flight-word hazard; the throughput check that would catch a half-throughput slice; the registered-interface check that confirms the backpressure path was actually cut; and the end-of-test assertions that prove no accepted word was dropped. State which single check is the direct detector for the dropped-word bug.
10. Related Tutorials
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- Valid/Ready Handshake — Chapter 9.1; the two-wire contract this page buffers — a word transfers on
valid && ready,validflowing downstream andreadyupstream, the interface the skid buffer registers on both sides. - Backpressure & Stalls — Chapter 9.2; the stall propagation that drives
in_readyfromout_readyand exposes the long combinational backpressure path this page cuts by registeringready. - Credit-Based Flow Control — Chapter 9.4; the alternative to
ready/backpressure that decouples the two ends with credits, removing the round-tripreadypath entirely for long links. - Handshake Bugs & Deadlocks — Chapter 9.5; the classic valid/ready failures — combinational loops,
validdropped before transfer, deadlock — that a correctly registered skid buffer avoids. - Ready/Valid Pipelining — Chapter 9.6; chaining registered handshake stages (skid buffers and register slices) to pipeline a
ready/validdatapath without losing throughput. - Case Study — Streaming FIFO — Chapter 9.7; a full streaming block where skid buffers sit at the timing-critical interfaces around a central FIFO.
Backward / in-track dependencies:
- Synchronous FIFO Architecture — Chapter 7.1; the circular-buffer FIFO the skid buffer is a depth-2 special case of — same push/pop discipline, bounded to two entries.
- First-Word Fall-Through FIFO — Chapter 7.5; the read-timing style where data is valid before
rd_en, closely related to how the skid buffer presentsout_dataa registered cycle ahead. - The Register Pattern (D-FF) — Chapter 2.1; the flops the primary and skid registers are built from, and the reset-first clocked-update discipline every block here follows.
- Register with Enable / Load — Chapter 2.2; the enable-gated load the primary and skid registers use — a register that captures only on its accept/drain condition.
- The Pipeline Valid Bit — Chapter 8.1; the
validbit that travels with data through pipeline stages — the same occupancy semanticsvalid_regandskid_validcarry here. - Moore vs Mealy Machines — Chapter 4.1; the state-machine lens on the EMPTY/RUNNING/STALLED-FULL controller — a small Moore FSM whose outputs (
in_ready,out_valid) are registered functions of the state. - The RTL Design Mindset — Chapter 0.2; the State and Verification lenses this page applies — the two registers are the buffer's state, and the scoreboard is how you prove it never drops a word.
- What Is an RTL Design Pattern? — Chapter 0.1; why the skid buffer earns the name 'pattern' — a recurring timing problem, a reusable two-slot form, 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 and skid registers are sized in. - Blocking and Non-Blocking Assignments — why the payload registers and occupancy bits update with non-blocking (
<=) in their clocked block. - If-Else Statements — the mutually-exclusive
if / else ifchain the three-state skid controller is written as. - Initial & Always Blocks — the clocked
alwaysblock the controller lives in and theinitialblock the testbenches drive stimulus from.
11. Summary
- Registering
readybreaks timing but risks losing a word. Combinational backpressure (9.2) ties the far-downstreamout_readyto the far-upstreamin_readythrough a long path that fails timing on deep or fast pipelines. Registeringin_readycuts that path — but a registeredreadyis a promise one cycle stale, so an upstream that saw it high launches a word on the cycle it falls, and a single-register stage overwrites its held output and drops that in-flight word. - The skid buffer adds exactly one landing pad. Two payload registers: a primary register that drives
out_data(occupancyout_valid) and one skid register that catches the in-flight word during a stall. Drivein_ready = !skid_valid(registered) andout_valid = valid_reg(registered), so both interface signals come out of flops and there is no combinational path fromout_readytoin_ready. One skid slot is exactly enough because a registeredin_readycan only ever have one word in flight when it falls — depth-2, the primary plus one skid. - Three states, full throughput. EMPTY (nothing held), RUNNING (primary held, skid free — the steady state that accepts a new word and presents the held word in the same cycle, sustaining one word/cycle), and STALLED-FULL (both held,
in_readylow). The buffer skids one entry when the downstream stalls and drains it the instantout_readyreturns, so it costs one cycle of latency and one payload register but no throughput. - The signature bug is the dropped in-flight word. A single-register stage with combinational
in_ready = out_readyoverwrites its held word whenout_readyfalls on a transfer cycle — silent, intermittent loss that tracks the stall pattern and passes any test without mid-stream stalls. A correct-but-half-throughput register slice (in_ready = !valid_reg) never drops a word but inserts a bubble every transfer and runs at half rate. The skid buffer avoids both. - Verify at the stall boundary. Scoreboard for in-order, exactly-once delivery under random backpressure; de-assert
out_readyon the exact transfer cycle to force the in-flight-word hazard; count transfers over a both-ready window to confirm one word per cycle; and checkin_ready/out_validare registered so the backpressure path was truly cut. Built and self-check-verified here in SystemVerilog, Verilog, and VHDL.