Skip to content

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.

the-need.v — a late ready cannot stop a word already launched across a long link
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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 flow
The credit loop — spend on send, return on drain, and outstanding words are bounded by the creditscredit > 0-1 creditenqueueconsumeslot free+1 creditcredit_cnt(up/down)init = CREDITS = buffer depth (Ch 3.1)send : credit_cnt> 0spend one credit, launch a wordforward link(pipelined)word arrives cycles later — toleratedreceiver bufferenqueue on arrive; never overflowsdrain (dequeue,free a slot)one slot genuinely freedcredit_return(+1)return-on-DRAIN tops the counter back up
Read the loop as a conserved quantity. The credit counter starts equal to the receiver's buffer depth. Each send spends one credit and launches a word into the pipelined forward link; the sender never waits on a per-cycle ready, only on credit_cnt being positive. The word arrives cycles later and is enqueued into the buffer, which is why the forward latency is harmless. When the receiver drains a word it frees exactly one slot and returns exactly one credit, incrementing the sender's counter. At every instant, outstanding words equal the credits spent, so outstanding words can never exceed the initial credits, which equal the buffer depth — the buffer cannot overflow. Size the credits to at least cover the round-trip latency or the sender runs out of credit before returns arrive and throughput starves. This reasoning is identical in SystemVerilog, Verilog, and VHDL and is the flow-control scheme under PCIe, NoCs, and many chip-to-chip links.

Sizing 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.

credit_sender.sv — credit up/down counter; send when credit>0, -1 on send, +1 on return
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

credit_receiver.sv — enqueue on arrival; return ONE credit per DRAINED slot
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

credit_link_tb.sv — aggressive send across a delay; assert no overflow + in-order exactly-once
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The 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.

credit_sender.v — credit up/down counter in Verilog
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
credit_receiver.v — enqueue on arrival, return credit on DRAIN, flag overflow
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
credit_link_tb.v — self-checking; aggressive send, no overflow + in-order exactly-once
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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.

credit_sender.vhd — credit up/down counter in VHDL (numeric_std)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
credit_receiver.vhd — enqueue on arrival; return credit on DRAIN; flag overflow
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
credit_link_tb.vhd — self-checking; aggressive send across a delay; no overflow + in-order
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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.

credit_return_bug.sv — BUGGY (return on ENQUEUE) vs FIXED (return on DRAIN)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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
   endmodule

The 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.

credit_return_bug_tb.sv — slow drain, fast send; the buggy DUT overflows, the fixed one never does
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

The Verilog buggy/fixed pair is the same one-line difference — credit_return <= do_enq (bug) versus credit_return <= do_drain (fix).

credit_return_bug.v — BUGGY (return on enqueue) vs FIXED (return on drain)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   // 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
credit_return_bug_tb.v — self-checking; slow drain exposes the buggy DUT's overflow
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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
   endmodule

In 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).

credit_return_bug.vhd — BUGGY (return on enqueue) vs FIXED (return on drain)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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;
credit_return_bug_tb.vhd — self-checking; slow drain, the buggy DUT overflows
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   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. The credit_link_tb in §4 drives have_data high every cycle (maximum pressure) with a randomized consumer_ready, and asserts !overflow every cycle: SV assert (!overflow), Verilog if (overflow) $display("FAIL…"), VHDL assert 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, and 0 ≤ outstanding ≤ CREDITS, where outstanding is words sent but not yet drained. A useful conceptual assertion is that credit_cnt + (words in the link + buffer, undrained) is a conserved constant equal to CREDITS. 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 direct overflow check.
  • 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 ≥ NWORDS in a bounded run. Then re-run with CREDITS set below the round-trip latency and confirm throughput drops (the sender stalls waiting for credit) even though overflow still 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 overflow assertion, but the stimulus must hold the drain back (a low-duty consumer_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_cnt starting at CREDITS, ticking down as send fires, and ticking back up one cycle after each credit_return; the buffer count rises toward CREDITS under a gappy drain but never reaches CREDITS + 1; overflow is flat at zero throughout. The visual signature of the bug is credit_cnt recovering too early (right after an arrival, not after a drain) and the buffer count momentarily being asked to exceed CREDITS — 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.

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 ready with a granted-up-front count for links where ready returns too late.
  • Backpressure & Stall — Chapter 9.2; how a low ready propagates 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 ready deasserts 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 full flag the sender cannot see in time across a long link — the credit counter is the sender's latency-tolerant copy of not 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/reg typing behind driving send as a combinational function of the credit count versus registering the counter, the buffer, and the return pulse.

11. Summary

  • A late ready cannot stop a word already launched. valid/ready backpressure (9.1/9.2) assumes ready gets back to the sender in time. Across a long wire, a chip boundary, or a pipelined interconnect the round-trip is several cycles, so a ready that 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 while credit_cnt > 0, decrements on each send, and increments when the receiver returns a credit. The sender needs credit, not a same-cycle ready, 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. Size CREDITS ≥ round-trip latency to 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.