RTL Design Patterns · Chapter 9 · Handshake & Flow Control
Credit-Based Flow Control
The valid/ready handshake stops a producer with a backward ready, which works beautifully when ready gets back in time. But across a long wire, a chip boundary, or a deeply pipelined interconnect, the round-trip takes many cycles, and a ready that arrives late cannot stop a word already launched, so the receiver buffer overflows. The fix is a different contract. Instead of asking may I send this cycle, the sender is told up front how many words the receiver can hold, its credits. It keeps a credit counter, sends only while credit is positive, and decrements on each send, while the receiver returns a credit each time it drains a slot. The words in flight can never exceed the starting credits, so overflow is impossible by construction across any latency. This lesson builds the sender and receiver, proves the invariant, and shows the early-return bug, in SystemVerilog, Verilog, and VHDL.
Intermediate14 min readRTL Design PatternsCredit-Based Flow ControlBackpressureFlow ControlLink LatencyBuffer Overflow
Chapter 9 · Section 9.4 · Handshake & Flow Control
1. The Engineering Problem
In 9.1 you built the valid/ready handshake: a word moves on exactly the cycle valid && ready are both high, and a consumer that cannot keep up drives ready low to stall the producer. In 9.2 you saw that low ready propagate back to hold the producer in place. Both patterns share one silent assumption — that ready gets back to the sender in time. When the sender and receiver sit next to each other, ready is a combinational wire that turns around in the same cycle, and the assumption holds. The moment they do not, it breaks.
Here is the concrete need. You are streaming data to a receiver at the far end of a long on-chip link — several millimetres of wire, or across a clock-domain boundary, or through an interconnect that has pipeline registers on both the forward and the return paths for timing closure. The forward path from sender to receiver is, say, two register stages; the return path carrying ready back is another two. A word the sender launches this cycle does not even arrive at the receiver's buffer for two cycles, and if the receiver's buffer fills and it deasserts ready, that ready=0 does not reach the sender for two more. In those four cycles the sender — seeing ready still high, because the low ready is still in flight — keeps launching words. By the time backpressure lands, several words are already committed to a buffer that has no room for them. The receiver overflows, and a word is silently dropped.
You cannot fix this by making ready combinational again — the wire is long because the design needs it long, and forcing a same-cycle turnaround would blow timing. Nor can you fix it by simply making the buffer bigger and hoping, because "bigger" is a guess and a guess that is one word short still drops data. What you need is a scheme where the sender knows, before it sends, exactly how many words the receiver can absorb — so it never launches a word that has nowhere to land, no matter how late the backward signal is. That scheme is credit-based flow control: replace the per-cycle may I? with a running count of how many more I am allowed to, granted up front and topped up as the receiver drains.
// 9.1/9.2: the sender stops when ready goes low. But across a pipelined link,
// ready comes back LATE:
// sender --[fwd reg][fwd reg]--> receiver buffer
// sender <--[ret reg][ret reg]-- ready
//
// Receiver buffer fills and drops ready THIS cycle...
// ...but the sender does not SEE ready=0 for 2 more cycles.
// In the meantime the sender keeps launching words it thinks are welcome:
//
// always @(posedge clk) if (valid && ready_seen_late) send <= 1'b1; // OVERFLOWS
//
// The words already in flight land in a full buffer -> a word is DROPPED.
//
// The need: the sender must know how many words the receiver can hold BEFORE it
// sends -- a CREDIT count granted up front, spent per send, returned per drain --
// so it never launches a word with nowhere to land, however late the return path.2. Mental Model
3. Pattern Anatomy
The interface is deliberately small: a forward send/data pair from sender to receiver, and a single backward credit_return pulse from receiver to sender. The intelligence lives in one register on each side — the sender's credit counter and the receiver's buffer — and in the invariant that ties them together.
The credit counter (an up/down counter). The sender owns a counter, credit_cnt, initialized at reset to the receiver's buffer depth (CREDITS). Two events move it: a send decrements it (credit_cnt <= credit_cnt - 1), and a returned credit increments it (credit_cnt <= credit_cnt + 1). When both happen on the same cycle they cancel and the count holds. The send-enable is have_data && (credit_cnt > 0) — the sender is permitted whenever it holds a positive count, and spends one unit per word. This is exactly the up/down counter of Chapter 3.1, applied to the abstract resource free slots at the far end: the counter is a local, always-current estimate of how much room the receiver still has.
The credit-return path. Flowing backward is a single small signal — credit_return, one pulse per freed slot (for a depth-CREDITS link, a $clog2(CREDITS+1)-bit count if you can free several at once, but one bit for the common one-at-a-time drain). It carries no data, only the receiver freed a slot. Because it is small and its meaning is cumulative rather than per-cycle-critical, it is itself pipelineable: you may put registers on it, delay it, even batch several returns into one wider return, and the scheme still works — a returned credit that arrives late merely means the sender is briefly more conservative than it needs to be, never that it over-sends. Latency on the return path costs throughput headroom, never correctness.
The overflow invariant. The single property the whole pattern rests on:
Outstanding words (sent but not yet drained) ≤ initial credits ≤ receiver buffer depth — always. Therefore the receiver buffer can never be asked to hold more than it has, and overflow is impossible.
It holds because every word the sender launches has already spent a credit (so it is accounted a slot), and a credit is only returned after a word is drained (so a spent-then-returned credit corresponds to a slot that emptied and refilled). The count is a conserved quantity: credits + words-in-flight-and-buffered is constant and equal to CREDITS. This is why the buffer never overflows regardless of link latency.
The credit loop — spend on send, return on drain, and outstanding words are bounded by the credits
data flowSizing credits for the round-trip. Correctness needs only CREDITS ≥ 1, but throughput needs more. Consider a full pipe: the sender sends a word, spends a credit, and the earliest that credit can come back is one round-trip later (forward latency + buffer + drain + return latency). If the sender has fewer credits than the round-trip in cycles, it spends them all before the first one returns and then stalls waiting for credit — the link runs at a fraction of one word per cycle even though nothing is actually full. So the design rule is CREDITS ≥ round-trip latency (in cycles) to keep the pipe full, and often the receiver buffer is sized to that number for exactly this reason. Too few credits is correct but slow; the bug is never overflow, always starvation. This is the same depth-vs-rate reasoning as FIFO sizing (Chapter 7.4), applied to the credit count.
Contrast with valid/ready. Credit flow control is not a replacement for the handshake; it is what you reach for when the handshake's ready cannot get back in time. Valid/ready trades nothing extra for a same-cycle answer — cheap when the link is short. Credit trades a small counter on the sender and a one-bit return wire for latency tolerance: the sender no longer needs an instantaneous ready, only a credit it was granted cycles ago, so the scheme survives arbitrary forward and return latency without overflow. On a short link, valid/ready is simpler and just as correct; on a long or pipelined one, credit is what makes full throughput without overflow possible at all.
4. Real RTL Implementation
This is the core of the page. We build two things — (a) a credit-based sender (an up/down credit counter, sends when credit_cnt > 0, decrements on send) and a receiver buffer that returns one credit per drained slot, connected across a register delay (WIDTH and CREDITS/depth generic), with a testbench asserting the receiver never overflows and delivers every word in order, exactly once under aggressive sending; and (b) the early-credit-return overflow bug beside its return-on-dequeue 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 and counter patterns: registers update on the clock with non-blocking assignments, reset is explicit (synchronous, active-high rst), the credit counter is an up/down counter that never goes negative, and a credit is returned only when a slot is actually freed by a drain — never on arrival.
4a. A credit sender and receiver across a register delay — three ways
The sender holds a credit counter initialized to CREDITS. It asserts send whenever it has a word and credit_cnt > 0, decrements on send, and increments on an incoming credit_return. The receiver is a small FIFO of depth CREDITS: it enqueues an arriving word and, on each dequeue (drain), asserts credit_return for exactly the slot it freed. Between them sits a one-cycle register delay on both the forward and the return paths, to model the pipelined link.
module credit_sender #(
parameter int WIDTH = 32, // datapath width
parameter int CREDITS = 4 // = receiver buffer depth
)(
input logic clk,
input logic rst, // synchronous, active-high
input logic have_data, // sender has a word to offer
input logic credit_return, // +1 credit: receiver freed a slot
output logic send, // 1 = launching a word this cycle
output logic [WIDTH-1:0] data // the word being launched
);
// credit_cnt is an UP/DOWN counter (Ch 3.1): its value is how many more words
// the sender is ALLOWED to have outstanding. One extra bit so CREDITS fits.
logic [$clog2(CREDITS+1)-1:0] credit_cnt;
logic [WIDTH-1:0] word; // the running payload
// SEND PERMISSION is the credit, held locally -- never a per-cycle ready.
assign send = have_data & (credit_cnt != 0);
assign data = word;
always_ff @(posedge clk) begin
if (rst) begin
credit_cnt <= CREDITS[$clog2(CREDITS+1)-1:0]; // start with a full tab
word <= '0;
end else begin
// Up/down: -1 on a send, +1 on a returned credit. Simultaneous cancel.
case ({send, credit_return})
2'b10: credit_cnt <= credit_cnt - 1'b1; // spent a credit
2'b01: credit_cnt <= credit_cnt + 1'b1; // got one back
default: credit_cnt <= credit_cnt; // 00 hold, 11 net zero
endcase
if (send) word <= word + 1'b1; // advance the stream on send
end
end
endmoduleThe receiver is a depth-CREDITS buffer. It enqueues a word when send (delayed by the link) arrives, drains one word per cycle whenever it is non-empty and its consumer has room, and — the load-bearing line — returns a credit on the drain, not on the enqueue. If it is ever asked to enqueue while full, that is an overflow, which the testbench watches for and which the invariant guarantees can never happen.
module credit_receiver #(
parameter int WIDTH = 32,
parameter int CREDITS = 4 // buffer depth == advertised credits
)(
input logic clk,
input logic rst,
input logic in_send, // delayed send from the link
input logic [WIDTH-1:0] in_data,
input logic consumer_ready, // downstream can take a word this cycle
output logic credit_return, // +1 per slot actually freed (on DRAIN)
output logic overflow, // 1 iff asked to enqueue while full (must never fire)
output logic drain_valid, // pulses per drained word
output logic [WIDTH-1:0] drain_data
);
localparam int AW = $clog2(CREDITS);
logic [WIDTH-1:0] mem [CREDITS];
logic [AW:0] wptr, rptr; // extra MSB to tell full from empty
wire [AW:0] count = wptr - rptr;
wire full = (count == CREDITS[AW:0]);
wire empty = (count == 0);
wire do_enq = in_send; // a word arrived from the link
wire do_drain = ~empty & consumer_ready; // we free a slot this cycle
// OVERFLOW is a check, not an action: enqueue while full means credits lied.
assign overflow = do_enq & full;
always_ff @(posedge clk) begin
if (rst) begin
wptr <= '0; rptr <= '0;
credit_return <= 1'b0; drain_valid <= 1'b0;
end else begin
if (do_enq) begin mem[wptr[AW-1:0]] <= in_data; wptr <= wptr + 1'b1; end
// Return a credit EXACTLY when a slot is freed by draining -- never on enqueue.
credit_return <= do_drain; // one credit per drained slot
drain_valid <= do_drain;
if (do_drain) begin drain_data <= mem[rptr[AW-1:0]]; rptr <= rptr + 1'b1; end
end
end
endmoduleThe testbench wires the two together across a one-cycle forward register (on send/data) and a one-cycle return register (on credit_return), drives have_data high every cycle (aggressive sending), and gates the receiver's drain with a gappy consumer_ready so the buffer genuinely fills and the credit loop is exercised. It checks the two invariants that matter: overflow never asserts, and every drained word is the next expected word, in order, exactly once.
module credit_link_tb;
localparam int WIDTH = 32, CREDITS = 4, NWORDS = 40;
logic clk = 1'b0, rst;
logic have_data, credit_return_r, send, overflow, drain_valid, consumer_ready;
logic [WIDTH-1:0] data, drain_data;
// Link pipeline registers: forward send/data and backward credit.
logic send_r; logic [WIDTH-1:0] data_r; logic cret;
int errors = 0, received = 0;
logic [WIDTH-1:0] expect_word = 0;
credit_sender #(.WIDTH(WIDTH), .CREDITS(CREDITS)) snd (
.clk(clk), .rst(rst), .have_data(have_data),
.credit_return(credit_return_r), .send(send), .data(data));
credit_receiver #(.WIDTH(WIDTH), .CREDITS(CREDITS)) rcv (
.clk(clk), .rst(rst), .in_send(send_r), .in_data(data_r),
.consumer_ready(consumer_ready), .credit_return(cret),
.overflow(overflow), .drain_valid(drain_valid), .drain_data(drain_data));
always #5 clk = ~clk;
// One-cycle forward and return link delays (the whole point of the pattern).
always_ff @(posedge clk) begin
if (rst) begin send_r <= 1'b0; data_r <= '0; credit_return_r <= 1'b0; end
else begin send_r <= send; data_r <= data; credit_return_r <= cret; end
end
initial begin
rst = 1'b1; have_data = 1'b0; consumer_ready = 1'b0;
repeat (2) @(posedge clk); #1; rst = 1'b0; have_data = 1'b1;
for (int t = 0; t < 6*NWORDS; t++) begin
@(negedge clk);
consumer_ready = ($urandom_range(0, 2) != 0); // gappy drain -> buffer fills
@(posedge clk); #1;
// INVARIANT 1: the receiver must NEVER overflow, under any send pattern.
assert (!overflow)
else begin $error("t=%0d OVERFLOW: enqueue while full", t); errors++; end
// INVARIANT 2: each drained word is the next expected, in order, once.
if (drain_valid) begin
assert (drain_data === expect_word)
else begin $error("t=%0d out-of-order/dup: got %h exp %h", t, drain_data, expect_word); errors++; end
expect_word = expect_word + 1; received++;
end
end
assert (received >= NWORDS)
else begin $error("throughput: only %0d words drained", received); errors++; end
if (errors == 0) $display("PASS: %0d words, in order exactly once, ZERO overflow across the delay", received);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleThe Verilog form is the same structure with reg/wire typing. The credit counter is still an up/down counter, send is still gated only by credit_cnt != 0 (never a per-cycle ready), and the credit is still returned on the drain.
module credit_sender #(
parameter WIDTH = 32,
parameter CREDITS = 4
)(
input wire clk,
input wire rst,
input wire have_data,
input wire credit_return,
output wire send,
output wire [WIDTH-1:0] data
);
reg [31:0] credit_cnt; // wide enough for CREDITS
reg [WIDTH-1:0] word;
assign send = have_data & (credit_cnt != 0); // permission = local credit, not ready
assign data = word;
always @(posedge clk) begin
if (rst) begin
credit_cnt <= CREDITS; // full tab at reset
word <= 0;
end else begin
// up/down: -1 send, +1 return, cancel when both, hold when neither
case ({send, credit_return})
2'b10: credit_cnt <= credit_cnt - 1'b1;
2'b01: credit_cnt <= credit_cnt + 1'b1;
default: credit_cnt <= credit_cnt;
endcase
if (send) word <= word + 1'b1; // advance the stream on send
end
end
endmodule module credit_receiver #(
parameter WIDTH = 32,
parameter CREDITS = 4,
parameter AW = 2 // = clog2(CREDITS)
)(
input wire clk,
input wire rst,
input wire in_send,
input wire [WIDTH-1:0] in_data,
input wire consumer_ready,
output reg credit_return,
output wire overflow,
output reg drain_valid,
output reg [WIDTH-1:0] drain_data
);
reg [WIDTH-1:0] mem [0:CREDITS-1];
reg [AW:0] wptr, rptr; // extra MSB: full vs empty
wire [AW:0] count = wptr - rptr;
wire full = (count == CREDITS);
wire empty = (count == 0);
wire do_enq = in_send;
wire do_drain = ~empty & consumer_ready;
assign overflow = do_enq & full; // must never be true
always @(posedge clk) begin
if (rst) begin
wptr <= 0; rptr <= 0; credit_return <= 1'b0; drain_valid <= 1'b0;
end else begin
if (do_enq) begin mem[wptr[AW-1:0]] <= in_data; wptr <= wptr + 1'b1; end
credit_return <= do_drain; // ONE credit per drained slot (not per enqueue)
drain_valid <= do_drain;
if (do_drain) begin drain_data <= mem[rptr[AW-1:0]]; rptr <= rptr + 1'b1; end
end
end
endmodule module credit_link_tb;
parameter WIDTH = 32, CREDITS = 4, AW = 2, NWORDS = 40;
reg clk, rst, have_data, consumer_ready;
wire send, overflow, drain_valid, cret;
wire [WIDTH-1:0] data, drain_data;
reg send_r, credit_return_r;
reg [WIDTH-1:0] data_r, expect_word;
integer t, errors, received;
credit_sender #(.WIDTH(WIDTH), .CREDITS(CREDITS)) snd (
.clk(clk), .rst(rst), .have_data(have_data),
.credit_return(credit_return_r), .send(send), .data(data));
credit_receiver #(.WIDTH(WIDTH), .CREDITS(CREDITS), .AW(AW)) rcv (
.clk(clk), .rst(rst), .in_send(send_r), .in_data(data_r),
.consumer_ready(consumer_ready), .credit_return(cret),
.overflow(overflow), .drain_valid(drain_valid), .drain_data(drain_data));
always #5 clk = ~clk;
always @(posedge clk) begin // one-cycle forward + return delays
if (rst) begin send_r <= 0; data_r <= 0; credit_return_r <= 0; end
else begin send_r <= send; data_r <= data; credit_return_r <= cret; end
end
initial begin
errors = 0; received = 0; expect_word = 0;
clk = 0; rst = 1'b1; have_data = 1'b0; consumer_ready = 1'b0;
@(posedge clk); @(posedge clk); #1; rst = 1'b0; have_data = 1'b1;
for (t = 0; t < 6*NWORDS; t = t + 1) begin
@(negedge clk);
consumer_ready = ($random % 3) != 0; // gappy drain -> the buffer fills
@(posedge clk); #1;
if (overflow) begin
$display("FAIL: t=%0d OVERFLOW -- enqueue while full", t);
errors = errors + 1;
end
if (drain_valid) begin
if (drain_data !== expect_word) begin
$display("FAIL: t=%0d out-of-order/dup: got %h exp %h", t, drain_data, expect_word);
errors = errors + 1;
end
expect_word = expect_word + 1; received = received + 1;
end
end
if (received < NWORDS) begin
$display("FAIL: throughput -- only %0d words drained", received);
errors = errors + 1;
end
if (errors == 0) $display("PASS: %0d words, in order exactly once, ZERO overflow across the delay", received);
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleIn VHDL the sender is a clocked process over an integer credit count; send is a concurrent function of have_data and credit_cnt /= 0 (never of a per-cycle ready), and the up/down step is a case on the send/return pair.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity credit_sender is
generic (
WIDTH : positive := 32;
CREDITS : positive := 4 -- = receiver buffer depth
);
port (
clk : in std_logic;
rst : in std_logic; -- synchronous, active-high
have_data : in std_logic;
credit_return : in std_logic; -- +1: receiver freed a slot
send : out std_logic;
data : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of credit_sender is
signal credit_cnt : integer range 0 to CREDITS := CREDITS; -- up/down counter
signal word : unsigned(WIDTH-1 downto 0) := (others => '0');
signal send_i : std_logic;
begin
-- permission is the LOCAL credit count, never a per-cycle ready
send_i <= have_data when credit_cnt /= 0 else '0';
send <= send_i;
data <= std_logic_vector(word);
process (clk)
begin
if rising_edge(clk) then
if rst = '1' then
credit_cnt <= CREDITS; -- full tab at reset
word <= (others => '0');
else
-- up/down: -1 send, +1 return, cancel when both, hold when neither
if send_i = '1' and credit_return = '0' then
credit_cnt <= credit_cnt - 1;
elsif send_i = '0' and credit_return = '1' then
credit_cnt <= credit_cnt + 1;
end if;
if send_i = '1' then word <= word + 1; end if; -- advance the stream
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity credit_receiver is
generic ( WIDTH : positive := 32; CREDITS : positive := 4 );
port (
clk : in std_logic;
rst : in std_logic;
in_send : in std_logic;
in_data : in std_logic_vector(WIDTH-1 downto 0);
consumer_ready : in std_logic;
credit_return : out std_logic; -- +1 per slot freed by a DRAIN
overflow : out std_logic; -- 1 iff enqueue while full (must never fire)
drain_valid : out std_logic;
drain_data : out std_logic_vector(WIDTH-1 downto 0)
);
end entity;
architecture rtl of credit_receiver is
type mem_t is array (0 to CREDITS-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
signal wptr, rptr : integer range 0 to CREDITS := 0; -- indices
signal count : integer range 0 to CREDITS := 0; -- occupancy
begin
-- overflow is a CHECK: an enqueue while full means the credits were wrong.
overflow <= '1' when (in_send = '1' and count = CREDITS) else '0';
process (clk)
variable do_enq, do_drain : std_logic;
begin
if rising_edge(clk) then
if rst = '1' then
wptr <= 0; rptr <= 0; count <= 0;
credit_return <= '0'; drain_valid <= '0';
else
do_enq := in_send;
do_drain := '0';
if count /= 0 and consumer_ready = '1' then do_drain := '1'; end if;
if do_enq = '1' then
mem(wptr) <= in_data;
if wptr = CREDITS-1 then wptr <= 0; else wptr <= wptr + 1; end if;
end if;
-- return ONE credit per DRAINED slot -- never on enqueue
credit_return <= do_drain;
drain_valid <= do_drain;
if do_drain = '1' then
drain_data <= mem(rptr);
if rptr = CREDITS-1 then rptr <= 0; else rptr <= rptr + 1; end if;
end if;
-- occupancy update (net of enqueue and drain)
if do_enq = '1' and do_drain = '0' then count <= count + 1;
elsif do_enq = '0' and do_drain = '1' then count <= count - 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 credit_link_tb is
end entity;
architecture sim of credit_link_tb is
constant WIDTH : positive := 32; constant CREDITS : positive := 4;
constant NWORDS : positive := 40;
signal clk : std_logic := '0';
signal rst, have_data, consumer_ready : std_logic := '0';
signal send, overflow, drain_valid, cret : std_logic;
signal data, drain_data : std_logic_vector(WIDTH-1 downto 0);
signal send_r, credit_return_r : std_logic := '0';
signal data_r : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
begin
snd : entity work.credit_sender
generic map (WIDTH => WIDTH, CREDITS => CREDITS)
port map (clk => clk, rst => rst, have_data => have_data,
credit_return => credit_return_r, send => send, data => data);
rcv : entity work.credit_receiver
generic map (WIDTH => WIDTH, CREDITS => CREDITS)
port map (clk => clk, rst => rst, in_send => send_r, in_data => data_r,
consumer_ready => consumer_ready, credit_return => cret,
overflow => overflow, drain_valid => drain_valid, drain_data => drain_data);
clkgen : process begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process;
-- one-cycle forward + return link delays
linkreg : process (clk) begin
if rising_edge(clk) then
if rst = '1' then send_r <= '0'; data_r <= (others => '0'); credit_return_r <= '0';
else send_r <= send; data_r <= data; credit_return_r <= cret; end if;
end if;
end process;
stim : process
variable expect_word : integer := 0;
variable received : integer := 0;
variable errors : integer := 0;
variable seed1, seed2 : positive := 5;
variable r : real;
begin
rst <= '1'; have_data <= '0'; consumer_ready <= '0';
wait until rising_edge(clk); wait until rising_edge(clk); wait for 1 ns;
rst <= '0'; have_data <= '1';
for t in 0 to 6*NWORDS - 1 loop
wait until falling_edge(clk);
uniform(seed1, seed2, r); -- gappy drain -> buffer fills
if r < 0.66 then consumer_ready <= '1'; else consumer_ready <= '0'; end if;
wait until rising_edge(clk); wait for 1 ns;
-- INVARIANT 1: never overflow.
assert overflow = '0'
report "OVERFLOW: enqueue while full" severity error;
-- INVARIANT 2: in-order, exactly-once delivery.
if drain_valid = '1' then
assert drain_data = std_logic_vector(to_unsigned(expect_word, WIDTH))
report "out-of-order or duplicate word" severity error;
expect_word := expect_word + 1; received := received + 1;
end if;
end loop;
assert received >= NWORDS
report "throughput: too few words drained" severity error;
report "credit link self-check complete" severity note;
wait;
end process;
end architecture;4b. The early-credit-return overflow 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 bug returns a credit the moment a word enqueues (arrives) instead of when it drains (departs). Now the sender's credit_cnt counts a slot as free while a word still sits in it, so under sustained traffic the sender sends more words than the buffer can hold and the receiver overflows. The fix is one line: return the credit on the dequeue, exactly one per slot genuinely freed.
// BUGGY: credit_return pulses on ENQUEUE. The sender gets a credit back the instant
// a word ARRIVES -- before that slot is freed -- so it thinks it has more room than
// it does. Under sustained sending the buffer fills and the next enqueue OVERFLOWS.
module credit_ret_bad #(parameter WIDTH = 32, parameter CREDITS = 4, parameter AW = 2)(
input wire clk, rst, in_send, consumer_ready,
input wire [WIDTH-1:0] in_data,
output reg credit_return, output wire overflow,
output reg drain_valid, output reg [WIDTH-1:0] drain_data
);
reg [WIDTH-1:0] mem [0:CREDITS-1];
reg [AW:0] wptr, rptr; wire [AW:0] count = wptr - rptr;
wire full = (count == CREDITS), empty = (count == 0);
wire do_enq = in_send, do_drain = ~empty & consumer_ready;
assign overflow = do_enq & full;
always @(posedge clk) begin
if (rst) begin wptr<=0; rptr<=0; credit_return<=0; drain_valid<=0; end
else begin
if (do_enq) begin mem[wptr[AW-1:0]]<=in_data; wptr<=wptr+1'b1; end
credit_return <= do_enq; // WRONG: credit back on ARRIVAL, slot NOT freed
drain_valid <= do_drain;
if (do_drain) begin drain_data<=mem[rptr[AW-1:0]]; rptr<=rptr+1'b1; end
end
end
endmodule
// FIXED: credit_return pulses on the DRAIN -- one credit per slot actually freed.
module credit_ret_good #(parameter WIDTH = 32, parameter CREDITS = 4, parameter AW = 2)(
input wire clk, rst, in_send, consumer_ready,
input wire [WIDTH-1:0] in_data,
output reg credit_return, output wire overflow,
output reg drain_valid, output reg [WIDTH-1:0] drain_data
);
reg [WIDTH-1:0] mem [0:CREDITS-1];
reg [AW:0] wptr, rptr; wire [AW:0] count = wptr - rptr;
wire full = (count == CREDITS), empty = (count == 0);
wire do_enq = in_send, do_drain = ~empty & consumer_ready;
assign overflow = do_enq & full;
always @(posedge clk) begin
if (rst) begin wptr<=0; rptr<=0; credit_return<=0; drain_valid<=0; end
else begin
if (do_enq) begin mem[wptr[AW-1:0]]<=in_data; wptr<=wptr+1'b1; end
credit_return <= do_drain; // RIGHT: credit back only when a slot is FREED
drain_valid <= do_drain;
if (do_drain) begin drain_data<=mem[rptr[AW-1:0]]; rptr<=rptr+1'b1; end
end
end
endmoduleThe testbench sends aggressively into a receiver whose consumer drains slowly (so the buffer stays near full), and watches overflow. The fixed DUT never overflows; the buggy DUT — which handed credits back on arrival — over-sends and trips overflow.
module credit_return_bug_tb;
localparam int WIDTH = 32, CREDITS = 4, AW = 2, NWORDS = 60;
logic clk = 1'b0, rst, have_data, consumer_ready;
logic send, overflow, drain_valid, cret;
logic [WIDTH-1:0] data, drain_data;
logic send_r, credit_return_r; logic [WIDTH-1:0] data_r;
int errors = 0, overflows = 0;
credit_sender #(.WIDTH(WIDTH), .CREDITS(CREDITS)) snd (
.clk(clk), .rst(rst), .have_data(have_data),
.credit_return(credit_return_r), .send(send), .data(data));
// Bind to credit_ret_good to PASS. Swap to credit_ret_bad to watch it OVERFLOW.
credit_ret_good #(.WIDTH(WIDTH), .CREDITS(CREDITS), .AW(AW)) rcv (
.clk(clk), .rst(rst), .in_send(send_r), .in_data(data_r),
.consumer_ready(consumer_ready), .credit_return(cret),
.overflow(overflow), .drain_valid(drain_valid), .drain_data(drain_data));
always #5 clk = ~clk;
always_ff @(posedge clk) begin
if (rst) begin send_r<=0; data_r<='0; credit_return_r<=0; end
else begin send_r<=send; data_r<=data; credit_return_r<=cret; end
end
initial begin
rst = 1'b1; have_data = 1'b0; consumer_ready = 1'b0;
repeat (2) @(posedge clk); #1; rst = 1'b0; have_data = 1'b1;
for (int t = 0; t < 6*NWORDS; t++) begin
@(negedge clk);
consumer_ready = ($urandom_range(0, 3) == 0); // SLOW drain (~1/4) -> stays full
@(posedge clk); #1;
if (overflow) begin overflows++; errors++; end
end
if (overflows == 0) $display("PASS: no overflow -- credit returned only on a freed slot");
else $display("FAIL: %0d overflow cycles -- credit returned too early (on enqueue)", overflows);
$finish;
end
endmoduleThe Verilog buggy/fixed pair is the same one-line difference — credit_return <= do_enq (bug) versus credit_return <= do_drain (fix).
// BUGGY: credit back on ARRIVAL -> sender over-reports room -> buffer overflows.
module credit_ret_bad #(parameter WIDTH=32, parameter CREDITS=4, parameter AW=2)(
input wire clk, rst, in_send, consumer_ready,
input wire [WIDTH-1:0] in_data,
output reg credit_return, output wire overflow,
output reg drain_valid, output reg [WIDTH-1:0] drain_data
);
reg [WIDTH-1:0] mem [0:CREDITS-1];
reg [AW:0] wptr, rptr; wire [AW:0] count = wptr - rptr;
wire full=(count==CREDITS), empty=(count==0);
wire do_enq=in_send, do_drain=~empty & consumer_ready;
assign overflow = do_enq & full;
always @(posedge clk) begin
if (rst) begin wptr<=0; rptr<=0; credit_return<=0; drain_valid<=0; end
else begin
if (do_enq) begin mem[wptr[AW-1:0]]<=in_data; wptr<=wptr+1'b1; end
credit_return <= do_enq; // WRONG: slot not yet freed
drain_valid <= do_drain;
if (do_drain) begin drain_data<=mem[rptr[AW-1:0]]; rptr<=rptr+1'b1; end
end
end
endmodule
// FIXED: credit back on the DRAIN -- exactly one per freed slot.
module credit_ret_good #(parameter WIDTH=32, parameter CREDITS=4, parameter AW=2)(
input wire clk, rst, in_send, consumer_ready,
input wire [WIDTH-1:0] in_data,
output reg credit_return, output wire overflow,
output reg drain_valid, output reg [WIDTH-1:0] drain_data
);
reg [WIDTH-1:0] mem [0:CREDITS-1];
reg [AW:0] wptr, rptr; wire [AW:0] count = wptr - rptr;
wire full=(count==CREDITS), empty=(count==0);
wire do_enq=in_send, do_drain=~empty & consumer_ready;
assign overflow = do_enq & full;
always @(posedge clk) begin
if (rst) begin wptr<=0; rptr<=0; credit_return<=0; drain_valid<=0; end
else begin
if (do_enq) begin mem[wptr[AW-1:0]]<=in_data; wptr<=wptr+1'b1; end
credit_return <= do_drain; // RIGHT: credit only when a slot frees
drain_valid <= do_drain;
if (do_drain) begin drain_data<=mem[rptr[AW-1:0]]; rptr<=rptr+1'b1; end
end
end
endmodule module credit_return_bug_tb;
parameter WIDTH=32, CREDITS=4, AW=2, NWORDS=60;
reg clk, rst, have_data, consumer_ready;
wire send, overflow, drain_valid, cret;
wire [WIDTH-1:0] data, drain_data;
reg send_r, credit_return_r; reg [WIDTH-1:0] data_r;
integer t, overflows;
credit_sender #(.WIDTH(WIDTH), .CREDITS(CREDITS)) snd (
.clk(clk), .rst(rst), .have_data(have_data),
.credit_return(credit_return_r), .send(send), .data(data));
// Bind to credit_ret_good to PASS; credit_ret_bad overflows.
credit_ret_good #(.WIDTH(WIDTH), .CREDITS(CREDITS), .AW(AW)) rcv (
.clk(clk), .rst(rst), .in_send(send_r), .in_data(data_r),
.consumer_ready(consumer_ready), .credit_return(cret),
.overflow(overflow), .drain_valid(drain_valid), .drain_data(drain_data));
always #5 clk = ~clk;
always @(posedge clk) begin
if (rst) begin send_r<=0; data_r<=0; credit_return_r<=0; end
else begin send_r<=send; data_r<=data; credit_return_r<=cret; end
end
initial begin
overflows = 0;
clk=0; rst=1'b1; have_data=1'b0; consumer_ready=1'b0;
@(posedge clk); @(posedge clk); #1; rst=1'b0; have_data=1'b1;
for (t = 0; t < 6*NWORDS; t = t + 1) begin
@(negedge clk);
consumer_ready = ($random % 4) == 0; // SLOW drain -> buffer stays near full
@(posedge clk); #1;
if (overflow) overflows = overflows + 1;
end
if (overflows == 0) $display("PASS: no overflow -- credit returned only on a freed slot");
else $display("FAIL: %0d overflow cycles -- credit returned too early (on enqueue)", overflows);
$finish;
end
endmoduleIn VHDL the same one-line difference decides it: credit_return <= do_enq returns the credit on arrival (buggy), credit_return <= do_drain returns it on the freed slot (fixed).
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- BUGGY: credit returned on ENQUEUE -> the sender over-counts free slots -> overflow.
entity credit_ret_bad is
generic ( WIDTH : positive := 32; CREDITS : positive := 4 );
port ( clk, rst, in_send, consumer_ready : in std_logic;
in_data : in std_logic_vector(WIDTH-1 downto 0);
credit_return, overflow, drain_valid : out std_logic;
drain_data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of credit_ret_bad is
type mem_t is array (0 to CREDITS-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
signal wptr, rptr, count : integer range 0 to CREDITS := 0;
begin
overflow <= '1' when (in_send = '1' and count = CREDITS) else '0';
process (clk)
variable de, dd : std_logic;
begin
if rising_edge(clk) then
if rst = '1' then wptr<=0; rptr<=0; count<=0; credit_return<='0'; drain_valid<='0';
else
de := in_send; dd := '0';
if count /= 0 and consumer_ready = '1' then dd := '1'; end if;
if de = '1' then mem(wptr) <= in_data;
if wptr = CREDITS-1 then wptr<=0; else wptr<=wptr+1; end if; end if;
credit_return <= de; -- WRONG: credit on arrival, slot not freed
drain_valid <= dd;
if dd = '1' then drain_data <= mem(rptr);
if rptr = CREDITS-1 then rptr<=0; else rptr<=rptr+1; end if; end if;
if de = '1' and dd = '0' then count <= count + 1;
elsif de = '0' and dd = '1' then count <= count - 1; end if;
end if;
end if;
end process;
end architecture;
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- FIXED: credit returned on the DRAIN -- one per slot genuinely freed.
entity credit_ret_good is
generic ( WIDTH : positive := 32; CREDITS : positive := 4 );
port ( clk, rst, in_send, consumer_ready : in std_logic;
in_data : in std_logic_vector(WIDTH-1 downto 0);
credit_return, overflow, drain_valid : out std_logic;
drain_data : out std_logic_vector(WIDTH-1 downto 0) );
end entity;
architecture rtl of credit_ret_good is
type mem_t is array (0 to CREDITS-1) of std_logic_vector(WIDTH-1 downto 0);
signal mem : mem_t;
signal wptr, rptr, count : integer range 0 to CREDITS := 0;
begin
overflow <= '1' when (in_send = '1' and count = CREDITS) else '0';
process (clk)
variable de, dd : std_logic;
begin
if rising_edge(clk) then
if rst = '1' then wptr<=0; rptr<=0; count<=0; credit_return<='0'; drain_valid<='0';
else
de := in_send; dd := '0';
if count /= 0 and consumer_ready = '1' then dd := '1'; end if;
if de = '1' then mem(wptr) <= in_data;
if wptr = CREDITS-1 then wptr<=0; else wptr<=wptr+1; end if; end if;
credit_return <= dd; -- RIGHT: credit only when a slot is freed
drain_valid <= dd;
if dd = '1' then drain_data <= mem(rptr);
if rptr = CREDITS-1 then rptr<=0; else rptr<=rptr+1; end if; end if;
if de = '1' and dd = '0' then count <= count + 1;
elsif de = '0' and dd = '1' then count <= count - 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 credit_return_bug_tb is
end entity;
architecture sim of credit_return_bug_tb is
constant WIDTH : positive := 32; constant CREDITS : positive := 4;
constant NWORDS : positive := 60;
signal clk : std_logic := '0';
signal rst, have_data, consumer_ready : std_logic := '0';
signal send, overflow, drain_valid, cret : std_logic;
signal data, drain_data : std_logic_vector(WIDTH-1 downto 0);
signal send_r, credit_return_r : std_logic := '0';
signal data_r : std_logic_vector(WIDTH-1 downto 0) := (others => '0');
begin
snd : entity work.credit_sender
generic map (WIDTH => WIDTH, CREDITS => CREDITS)
port map (clk => clk, rst => rst, have_data => have_data,
credit_return => credit_return_r, send => send, data => data);
-- Bind to credit_ret_good to PASS; credit_ret_bad overflows.
rcv : entity work.credit_ret_good
generic map (WIDTH => WIDTH, CREDITS => CREDITS)
port map (clk => clk, rst => rst, in_send => send_r, in_data => data_r,
consumer_ready => consumer_ready, credit_return => cret,
overflow => overflow, drain_valid => drain_valid, drain_data => drain_data);
clkgen : process begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process;
linkreg : process (clk) begin
if rising_edge(clk) then
if rst = '1' then send_r <= '0'; data_r <= (others => '0'); credit_return_r <= '0';
else send_r <= send; data_r <= data; credit_return_r <= cret; end if;
end if;
end process;
stim : process
variable overflows : integer := 0;
variable seed1, seed2 : positive := 3;
variable r : real;
begin
rst <= '1'; have_data <= '0'; consumer_ready <= '0';
wait until rising_edge(clk); wait until rising_edge(clk); wait for 1 ns;
rst <= '0'; have_data <= '1';
for t in 0 to 6*NWORDS - 1 loop
wait until falling_edge(clk);
uniform(seed1, seed2, r);
if r < 0.25 then consumer_ready <= '1'; else consumer_ready <= '0'; end if; -- SLOW drain
wait until rising_edge(clk); wait for 1 ns;
if overflow = '1' then overflows := overflows + 1; end if;
end loop;
assert overflows = 0
report "OVERFLOW: credit returned too early (on enqueue)" severity error;
report "credit-return self-check complete" severity note;
wait;
end process;
end architecture;Across all three languages the lesson is one sentence: return a credit only for a slot that is genuinely free — one per word drained, never per word received — because the credit count is the entire overflow guarantee.
5. Verification Strategy
Credit flow control has one property that dwarfs all others, and it is a safety property: the receiver must never overflow, under any sender pattern and any link latency. Everything below makes that checkable and ties directly to the §4 testbenches.
- The no-overflow invariant, self-checked. The single property the pattern exists to guarantee:
overflow(an enqueue into a full buffer) is asserted on zero cycles, forever, regardless of how aggressively the sender drives or how the drain is gappy. Thecredit_link_tbin §4 driveshave_datahigh every cycle (maximum pressure) with a randomizedconsumer_ready, and asserts!overflowevery cycle: SVassert (!overflow), Verilogif (overflow) $display("FAIL…"), VHDLassert overflow = '0' … severity error. Because the invariant is structural (outstanding ≤ credits ≤ depth), a correct design passes this for any stimulus — that universality is what you are checking. - The outstanding-words invariant. Stated as a property that holds every cycle:
outstanding = initial_credits − credit_cnt, and0 ≤ outstanding ≤ CREDITS, where outstanding is words sent but not yet drained. A useful conceptual assertion is thatcredit_cnt + (words in the link + buffer, undrained)is a conserved constant equal toCREDITS. If it ever drifts, a credit was created (return-without-drain → the §7 overflow) or lost (drain-without-return → starvation). - In-order, exactly-once delivery. The buffer must not lose, reorder, or duplicate a word. Model the expected stream as a counter and compare each drained word:
drain_data === expect_word, then advance. A dropped word (overflow) or a double-return that lets the sender overwrite an undrained slot shows up here as an out-of-order or missing word, complementing the directoverflowcheck. - Throughput vs credit-starvation. A liveness check, not a safety one: with enough credits to cover the round-trip, a continuously-offering sender and an always-draining receiver keep the link at one word per cycle — verify
received ≥ NWORDSin a bounded run. Then re-run withCREDITSset below the round-trip latency and confirm throughput drops (the sender stalls waiting for credit) even thoughoverflowstill never fires — demonstrating that too few credits is correct-but-slow, never unsafe. This is the credit analogue of FIFO depth sizing (Chapter 7.4). - The failure mode and its catch. The §7 bug returns a credit on enqueue instead of drain, so under a slow drain the buffer stays near full and eventually overflows. The catch is the same
overflowassertion, but the stimulus must hold the drain back (a low-dutyconsumer_ready) so the buffer actually fills — a fast-draining testbench hides the bug because the slots free before the over-counted credits are spent. That is exactly why the §4b testbench uses a ~1/4-duty drain. - Expected waveform. A correct run shows
credit_cntstarting atCREDITS, ticking down assendfires, and ticking back up one cycle after eachcredit_return; the buffercountrises towardCREDITSunder a gappy drain but never reachesCREDITS + 1;overflowis flat at zero throughout. The visual signature of the bug iscredit_cntrecovering too early (right after an arrival, not after a drain) and the buffercountmomentarily being asked to exceedCREDITS— the overflow pulse.
6. Common Mistakes
Returning a credit before the slot is truly free. The signature bug (full post-mortem in §7). If the receiver returns a credit when a word arrives (enqueues) rather than when it departs (drains), the sender's credit_cnt counts a slot as available while a word still occupies it. The sender over-sends, and under sustained traffic the buffer overflows and drops a word. A credit must represent a genuinely free slot; return exactly one credit per word drained, never per word received.
Losing a credit return (a credit leak). The mirror failure. If a returned credit is dropped — a return pulse that coincides with a reset, a wider return not fully accounted, a batched drain that frees two slots but returns one credit — the sender's count is permanently one low. It is safe (it never overflows; the count is only ever conservative), but the sender slowly starves: over time it thinks it has fewer credits than it does, throughput collapses, and in the limit the count reaches zero and the link deadlocks with a receiver that is actually empty. Every freed slot must return exactly one credit, and none may be lost — count the returns as carefully as the drains that produce them.
Double-counting a return. Incrementing credit_cnt twice for one freed slot (a glitchy multi-cycle return pulse, or counting both the drain and a spurious echo) creates a credit — it is the same class of error as returning early and leads to the same overflow: the sender ends up permitted to have more words outstanding than there are slots. A return must be a clean one-per-slot event; if the return path can produce a level rather than a one-cycle pulse, edge-detect it, and never let one drain generate two increments.
Sizing credits below the round-trip. Not a correctness bug — a performance one, but a common surprise. If CREDITS is less than the round-trip latency in cycles, the sender spends all its credits before the first one returns, then stalls; the link runs at a fraction of one word per cycle even though nothing is full. The buffer never overflows (the invariant still holds), so simulation passes and only a throughput measurement reveals it. Size credits (and the matching buffer) to at least the round-trip so the pipe stays full; too few is correct-but-starved.
Making send depend on a per-cycle ready anyway. Defeating the whole point. If the sender gates send on an incoming per-cycle ready in addition to (or instead of) credit_cnt, it reintroduces the very timing dependence credit flow control removed — and a late ready again stalls or, worse, if the credit path is then treated as advisory, over-sends. The sender's permission must be the credit count alone; the return path may be arbitrarily pipelined precisely because nothing on the sender waits on a same-cycle signal.
7. DebugLab
The link that dropped words only at line rate — a credit returned too early
The engineering lesson: a credit must represent a buffer slot that is genuinely free, so return exactly one credit per word drained — never per word received — and never lose or double-count a return. The tell is a bug that appears only under load: a link that is perfect at low rate and drops words at line rate is almost always a flow-control accounting error, because the accounting only matters when the buffer is actually near full. Return-on-arrival over-counts free space and overflows; losing a return under-counts and starves; both come from the same root — the credit count is the entire overflow guarantee, and it is correct only if every credit corresponds one-to-one with a slot that is truly free. Verify it by sending aggressively while deliberately starving the drain, so the buffer fills and any accounting error is forced to surface.
8. Interview Q&A
9. Exercises
Design and reasoning exercises — no syntax drills. Each rehearses the credit-count / return-on-drain / round-trip-sizing reasoning behind the pattern.
Exercise 1 — Trace the credit counter across the delay
A link has CREDITS = 3 and a one-cycle forward and one-cycle return delay. The sender offers a word every cycle; the receiver drains one word every third cycle. Hand-trace credit_cnt, send, the buffer occupancy, and credit_return for the first eight cycles after reset. Mark the cycle the sender first stalls for lack of credit, confirm the buffer occupancy never exceeds 3, and state in one line why no word is ever dropped even though the sender offers far faster than the receiver drains.
Exercise 2 — Size the credits for the pipe
A streaming link needs full throughput (one word per cycle) and has a measured round-trip of 6 cycles (3 forward pipeline stages + drain + 2 return stages). (a) What is the minimum CREDITS to keep the pipe full, and why? (b) If you build it with CREDITS = 4, what steady-state throughput do you get, and does the buffer ever overflow? (c) Name the two costs of setting CREDITS far higher than the round-trip. (Hint: think about what the sender waits on, and where the credits' worth of buffering physically lives.)
Exercise 3 — Classify the two accounting failures
For each of these three receiver behaviours, say whether the link overflows, starves, or is correct, and why: (a) returns one credit on every enqueue; (b) returns one credit on every drain but occasionally, when a drain coincides with a reset pulse, drops the return; (c) a glitchy return path that produces a two-cycle-wide pulse per drain, and the sender counts each cycle as a credit. For the two broken ones, name the exact stimulus (fast vs slow drain, long vs short run) that would make the failure appear in a testbench, and the one that would hide it.
Exercise 4 — Credit vs valid/ready under latency
You must connect a producer to a consumer across an interconnect that inserts 3 forward and 3 backward pipeline registers. Engineer A insists on a plain valid/ready handshake; Engineer B proposes credit flow control with a small receiver buffer. (i) Describe precisely how A's design fails (which signal is late, and what the receiver does when it does). (ii) State the minimum receiver buffer / credit count B needs for correctness and the count for full throughput. (iii) Explain what B trades — in gates and wires — to buy the latency tolerance, and on what kind of link A's simpler design would actually be the better choice.
10. Related Tutorials
Continue in RTL Design Patterns (sibling and forward links unlock as they ship):
- The valid/ready Handshake — Chapter 9.1; the two-way contract this page is an alternative to — credit replaces the per-cycle backward
readywith a granted-up-front count for links wherereadyreturns too late. - Backpressure & Stall — Chapter 9.2; how a low
readypropagates back to stall the producer, and why that propagation is too slow across a pipelined link — the exact gap credits close. - Skid Buffer — Chapter 9.3; the registered stage that absorbs one in-flight word when
readydeasserts a cycle late; credit flow control generalizes this to many in-flight words across arbitrary latency. - Handshake Bugs & Anti-Patterns — Chapter 9.5; the catalog of flow-control failures, including the credit-leak and early-return overflow from this page's DebugLab.
- Ready/Valid Pipelining — Chapter 9.6; pipelining the handshake for throughput — the registered-backpressure problem that credit flow control sidesteps with a count instead of a chained
ready. - Case Study — Streaming FIFO — Chapter 9.7; a full streaming block where credits and FIFOs combine to move data across a latency-bearing link.
Backward / in-track dependencies:
- Up/Down Counters — Chapter 3.1; the exact counter the credit counter is — down on send, up on a returned credit — the arithmetic that makes overflow impossible.
- Synchronous FIFO Architecture — Chapter 7.1; the receiver buffer is a small sync FIFO; its enqueue/dequeue pointers and occupancy are what a returned credit tracks from a distance.
- FIFO Full/Empty Generation — Chapter 7.2; the
fullflag the sender cannot see in time across a long link — the credit counter is the sender's latency-tolerant copy ofnot full. - FIFO Depth Calculation — Chapter 7.4; the depth-versus-rate sizing that, applied to the credit count, becomes
credits ≥ round-trip latency— too few starves, not overflows. - The RTL Design Mindset — Chapter 0.2; the Interface and Verification lenses this page applies — the credit loop is the interface, and the no-overflow invariant is how you prove it correct.
- What Is an RTL Design Pattern? — Chapter 0.1; why credit-based flow control earns the name — a recurring latency problem, a reusable counter-plus-return form, a synthesis reality, and a signature accounting failure.
Verilog v1 prerequisites (the grammar this pattern transcribes into):
- Blocking and Non-Blocking Assignments — why the credit counter and the buffer pointers update with non-blocking (
<=) so send, drain, and return resolve atomically on the clock edge. - If-Else Statements — the reset / send / return priority that clears, decrements, or increments the credit counter.
- Case Statements — the
case ({send, credit_return})that resolves the up/down step (spend, return, cancel, or hold) in one place. - Physical Data Types — the
wire/regtyping behind drivingsendas a combinational function of the credit count versus registering the counter, the buffer, and the return pulse.
11. Summary
- A late
readycannot stop a word already launched. valid/ready backpressure (9.1/9.2) assumesreadygets back to the sender in time. Across a long wire, a chip boundary, or a pipelined interconnect the round-trip is several cycles, so areadythat deasserts this cycle arrives too late and the sender overflows the receiver. The fix is not a faster wire but a different contract: grant send-permission up front as a count. - Credits decouple permission from a per-cycle answer. The receiver advertises its buffer depth as credits. The sender keeps a credit counter — an up/down counter (Chapter 3.1) initialized to
CREDITS— sends only whilecredit_cnt > 0, decrements on each send, and increments when the receiver returns a credit. The sender needs credit, not a same-cycleready, so the scheme tolerates arbitrary forward and return latency. - Return a credit only when a slot is truly free — on the drain. A credit represents one genuinely-available slot; the receiver returns exactly one credit per word it drains (dequeues), never per word it receives (enqueues). The return path carries no data, is small, and is freely pipelineable — a late return costs throughput headroom, never correctness — but a lost return leaks a credit and starves the sender.
- Overflow is impossible by construction. At every instant, outstanding words =
initial_credits − credit_cnt, and since the count never goes negative, outstanding ≤ initial credits ≤ buffer depth — so the buffer can never be asked to hold more than it has, regardless of link latency. The credit counter is the overflow guarantee: a conserved quantity, not a timing hope. SizeCREDITS ≥ round-trip latencyto keep the pipe full; too few is correct-but-starved, never unsafe. - The signature bug is the early credit return. Returning a credit on enqueue instead of drain makes the sender over-report free space, so under sustained traffic it over-sends and the buffer overflows — passing at low rate, dropping words at line rate. The mirror bug, a lost return, starves and can deadlock. Both are prevented by returning exactly one credit per genuinely-freed slot; verify by sending aggressively while starving the drain so the buffer fills — built and self-check-verified here in SystemVerilog, Verilog, and VHDL.