Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
    • Hang, Livelock or Starvation
    • Who Is Waiting for Whom
    • Finding the Cycle
    • Credit and Token Loss
    • Valid/Ready Deadlock Patterns
    • Watchdogs and Hang Detectors
    • A Full Hang on dbglab
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 7 · Hangs and Deadlocks
  1. Topics
  2. The Art of Hardware Debug
  3. Part 7 · Hangs and Deadlocks
  4. Credit and Token Loss

Part 7 · Hangs and Deadlocks · Senior (S)

Credit and Token Loss

Credits and tokens are conserved, so check the conservation law, not the hang. How to write the law for a credit loop, why the one-domain sum check fails across clocks, and a ledger that gives every credit in flight a deadline, on a real link that loses credits only when its receiver clock gets faster.

On this page

    Why this matters

    A lost credit does not fail; it slows the link until the last one goes, which can take hours, and by then the cause is far behind. Across two clocks the simple sum check gives false alarms, so teams switch it off; a ledger that gives each credit a deadline fires at the first loss.

    After this lesson you can: write the conservation law of a credit loop, check it continuously even when the loop crosses clock domains, and find a loss that appears only at one clock ratio by sweeping the ratio.

    Note

    Level: Senior (S) · Debug-loop step: Observe, Localise

    Before this: Hangs and Deadlocks (Part 5), Who Is Waiting for Whom

    Nothing in the RTL changed

    A credit-based link between two blocks has shipped in three designs. In the fourth, the receiving block runs at 250 MHz instead of 50, and the link hangs after a few dozen words. The number of words before the hang changes with the seed. The credit logic is byte-for-byte the same as in the chips that work, and so is its bench.

    A hang that depends on the seed, in logic that has not changed. Where is the credit going?

    What people usually do, and why it fails

    The first move is the one from Part 5: look for a counter update that can be skipped. There is none; the sender adds every return it sees. The second is to add Part 5's invariant, credits + buffer level = depth, and check it on every sender clock. It fires on the very first word, on the good design too, because a word or a credit inside a synchroniser is in neither term. The check gets switched off as noisy. The third is to start the sender with five credits instead of four "for margin", which overruns the buffer the first time the receiver stalls.

    The method: the law, its terms, and a deadline

    1. Follow one token through its life. Where is it created (reset), spent (a word sent), stored (in the buffer), moved (on its way back) and returned (added to the count)? Each place is a term in the law.

    2. Write the law: credits at the sender + words in the buffer + credits on their way back = depth, at all times.

    3. In one clock domain, check the law every cycle. That is Part 5's check, and it is exact there.

    4. Across domains, split the law. Each side checks its local part (the sender's count against what it sent and got back; the receiver's level against what arrived and left). The crossing gets a ledger: every credit the receiver releases must arrive at the sender within a deadline, the synchroniser's latency plus margin.

    5. Sweep the rates the integration will use, and beyond. A crossing that is correct at one clock ratio can lose events at another.

    6. Know what a crossing can carry. A toggle or pulse crosses one event per destination sample; two events between samples become none. A Gray-coded count crosses any number, because the receiver of the count only needs the difference.

    Check

    Where it runs

    What it catches

    Across clocks

    credits + level = depth

    one clock edge

    any loss or duplicate, at once

    false alarms: words and credits in a synchroniser are in neither term

    local laws at each end

    each domain

    losses inside one side

    exact, but blind to the crossing

    ledger with a deadline

    both domains (bench) or both counts (design)

    a credit that never arrives

    exact, if the deadline exceeds the crossing latency

    watchdog

    anywhere

    the hang after the last credit

    always late, says nothing about where

    On the debug loop this sits between Observe and Localise: the law says a credit is gone, the ledger says which one, and when.

    The picture: two events, one sample

    The link below, built with the toggle return, and a directed stimulus: the sender sends four words, then the receiver takes two of them inside one sender cycle, and later the other two the same way. Sampled on the sender's clock:

    Read taken against rtog_seen: every word the receiver takes flips rtog, and the sender counts flips of what it sees.

    • Slots 0 to 3: the sender spends its four credits and stops; credits reaches 0.

    • Slots 5 and 6: the receiver takes two words (taken goes 0, 1, 2). rtog flipped up and back down between two sender edges; it shows 1 in slot 5 only because the picture samples just after the edge.

    • rtog_seen, the sender's first synchroniser flop, never changes: both flips happened between its samples. Slots 9 and 10 repeat it.

    • Slot 12: four words taken, the buffer empty, credits still 0. The link is hung with the receiver idle, the shape of a chain that ends.

    Do it: the link, the hang and the ledger

    FIX=0 returns credits as a toggle, the classic pulse-to-toggle crossing, designed when the receiver ran at 50 MHz. FIX=1 sends the receiver's read pointer back in Gray code, exactly as an asynchronous FIFO does. +RXNS sets the receiver's half period. The bench has a scoreboard, a watchdog and the ledger.

    ccdc_link.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    `timescale 1ns/1ps
    // ccdc_link: a credit-based link across two clocks. The sender (clk_t) writes words into a
    // four-entry buffer that the receiver (clk_r) reads; the buffer has no full flag, because the
    // sender sends only while it holds a credit. It starts with four, spends one per word, and gets
    // one back for every word the receiver takes. The return path crosses from clk_r to clk_t:
    //   FIX=0: the receiver flips a toggle per word taken; the sender counts edges of the
    //          synchronised toggle (one credit per edge).
    //   FIX=1: the receiver's read pointer crosses as Gray code; the sender adds the difference
    //          between the pointer it sees now and the one it saw last cycle.
    module ccdc_link #(parameter bit FIX = 1) (
      input  logic       clk_t, rst_t_n, have_data,
      input  logic [7:0] din,
      output logic       send,
      output logic [2:0] credits,
      input  logic       clk_r, rst_r_n, take,
      output logic       pop,
      output logic [7:0] dout,
      output logic [2:0] level
    );
      function automatic logic [2:0] g2b(input logic [2:0] g);
        return {g[2], g[2] ^ g[1], g[2] ^ g[1] ^ g[0]};
      endfunction
      logic [7:0] mem [4];
      logic [2:0] rbin, rgray, wg1, wg2;              // receiver: read pointer, its Gray copy, wgray synchronised
      logic       rtog;
      // ---- sender (clk_t)
      logic [2:0] wbin, wgray, rg1, rg2, seen, ret;
      logic       t1, t2, t3;
      assign send = have_data && (credits != 3'd0);
      assign ret  = FIX ? g2b(rg2) - seen : {2'b00, t2 ^ t3};   // credits returned this cycle
      always_ff @(posedge clk_t or negedge rst_t_n)
        if (!rst_t_n) begin
          wbin <= '0; wgray <= '0; credits <= 3'd4; seen <= '0; {rg1, rg2} <= '0; {t1, t2, t3} <= '0;
        end else begin
          if (send) begin
            mem[wbin[1:0]] <= din;
            wbin  <= wbin + 3'd1;
            wgray <= (wbin + 3'd1) ^ ((wbin + 3'd1) >> 1);
          end
          credits <= credits - 3'(send) + ret;
          seen <= g2b(rg2);
          {rg1, rg2} <= {rgray, rg1};
          {t1, t2, t3} <= {rtog, t1, t2};
        end
      // ---- receiver (clk_r)
      assign level = g2b(wg2) - rbin;
      assign pop   = take && (level != 3'd0);
      assign dout  = mem[rbin[1:0]];
      always_ff @(posedge clk_r or negedge rst_r_n)
        if (!rst_r_n) begin rbin <= '0; rgray <= '0; rtog <= 1'b0; {wg1, wg2} <= '0; end
        else begin
          if (pop) begin
            rbin  <= rbin + 3'd1;
            rgray <= (rbin + 3'd1) ^ ((rbin + 3'd1) >> 1);
            rtog  <= ~rtog;
          end
          {wg1, wg2} <= {wgray, wg1};
        end
    endmodule
    

    The designed clock, and the new one

    Terminal
    $ iverilog -g2012 -Pccdc_tb.FIX=0 -o cb.vvp ccdc_tb.sv
    $ vvp -n cb.vvp
    PASS: 400 words, credits back to 4 (FIX=0, receiver half period 10.0 ns)
    $ vvp -n cb.vvp +RXNS=2 +NOLEDGER
    FAIL t=2355 ns: WATCHDOG: no word moved for 200 cycles; sent=18 taken=18 credits=0 level=0
    ...

    At 50 MHz the toggle return is fine: the receiver can take at most one word per 20 ns, so the sender sees every flip. At 250 MHz it hangs after 18 words, with everything sent taken and nothing in the buffer. Watchdog line, wait-for table: the sender waits for a credit, the receiver waits for a word, and nobody owes either. Credits were lost; the watchdog cannot say where.

    The obvious check, and why it lies

    Terminal
    $ iverilog -g2012 -o c.vvp ccdc_tb.sv
    $ vvp -n c.vvp +SUMCHK
    FAIL t=35 ns: SUM: credits=3 + level=0 != 4
    ...

    This is the correct design. At 35 ns the sender has sent one word, so it holds three credits, but the receiver cannot see the word yet: its write pointer is still in the two synchroniser flops on the way across, so the receiver's level is 0. Later in the run the same thing happens to credits on their way back. The single-domain sum has no term for either, so it fails on the first word. This is the noisy check from the story, and it is noisy because it is wrong for a crossing, not because the design is.

    The ledger

    Terminal
    $ vvp -n cb.vvp +RXNS=2
    FAIL t=275 ns: CREDIT_LOST: the word taken at 206 ns has not returned its credit; taken 16, credits back 14, credits 2
    ...
    • t=275 ns, not 2355. The ledger fires 2,080 ns earlier, while the link is still running on its two remaining credits.

    • The word taken at 206 ns is the first credit that missed its 60 ns deadline. The receiver took 16 words and the sender got 14 credits back: two flips cancelled each other.

    • credits 2: the sender is still working. A link with more credits would run on for much longer, slower and slower, which is how a leak becomes a once-an-hour hang.

    The deadline of 60 ns is six sender cycles: the toggle needs three flops to become a return, so a credit that is not back after six cycles is not late, it is gone.

    Sweep the clock ratio

    Ten seeds at each receiver half period, both return paths:

    Receiver half period

    Receiver clock

    Toggle return (FIX=0)

    Gray return (FIX=1)

    10 ns

    50 MHz

    10 of 10 pass

    10 of 10 pass

    7 ns

    71 MHz

    10 of 10 pass

    not run

    5 ns

    100 MHz, same as the sender

    10 of 10 pass

    10 of 10 pass

    4 ns

    125 MHz

    0 of 10

    10 of 10 pass

    3 ns and 2.5 ns

    167 and 200 MHz

    0 of 10

    not run

    2 ns

    250 MHz

    0 of 10

    10 of 10 pass

    1.3 ns

    385 MHz

    not run

    10 of 10 pass

    The edge is sharp and has a reason: once the receiver is faster than the sender, it can take two words between two sender edges, and two flips are no flip. The Gray return passes at every ratio tried, because the sender adds the difference of a count: three words taken between samples add three.

    What the evidence says

    The loss is a property of the crossing at a clock ratio, not of any counter, which is why reading the counters found nothing and why the design worked for three chips. The law found it once written in a form that respects the crossing: the ledger was silent on the good design at every ratio and named the first lost credit on the bad one, 2,080 ns before the hang.

    Traps

    Common pitfall

    Switching off a noisy invariant. When a conservation check fails on a design you believe is correct, find the missing term (here, words and credits inside a synchroniser) and add it or split the check. A check that is off catches nothing.

    Common pitfall

    Adding credits for margin. Starting with five credits for a four-entry buffer hides a slow leak for a while and creates a real overflow the first time the receiver stalls. Credits are the buffer's size; they are not a tuning knob.

    Common pitfall

    Trusting a crossing at one ratio. A pulse or toggle synchroniser has a rate limit written nowhere in the RTL. Put it in the block's integration notes, and add a bench assertion that the source never produces two events within one destination period.

    Why this matters

    Senior lens. Carry the ledger into the design, cheaply: each end keeps a free-running count of credits it has released or received, and a debug register compares them after the crossing. When traffic stops, the two counts must agree within the synchroniser latency. A difference that stays is a lost credit, readable on silicon with no bench at all, as in watchdogs and hang detectors.

    Interview

    Asked: "A credit-based link that crosses clock domains hangs after a random number of transfers, only in one product. How do you debug it?"

    Weak answer: I would check that the credit counter increments and decrements correctly, and increase the number of credits so it does not run out.

    Strong answer: A random hang point suggests a leak, so I write the conservation law: credits at the sender, words in the buffer and credits in flight back must add to the depth. Across clocks I cannot check that sum at one edge, because credits in the synchroniser are in neither place, so I keep local checks at each end and a ledger: every credit released must arrive within the crossing latency plus margin. That names the first lost credit. Then I look at the crossing itself and at the clock ratio of the failing product: a toggle or pulse return can carry one event per destination sample, so a faster receiver merges returns. The fix is to send a Gray-coded count and add the difference.

    Follow-ups: (1) How would you check the same law on silicon? (2) Why is a Gray-coded count safe to synchronise when a binary one is not?

    Check yourself The receiver in this link is slowed back to 50 MHz, and the toggle return passes every test. Is the design fixed?

    No. It is back inside the rate the toggle can carry: at most one word per destination sample. Any later change that lets the receiver take two words between sender edges, a faster clock, or a burst mode, brings the loss back. The ledger and a bench assertion on the event rate make the assumption visible; the Gray return removes it.

    Check yourself Where would you put the missing term so that the sum check works on the correct design?

    Two terms are missing, one per direction: words in flight forward (sent, not yet in the receiver's level) and credits in flight back (taken by the receiver, not yet added by the sender). A bench can compute both from the counters at the two ends, because it sees both domains. Even then the sum is exact only when sampled where both counts are settled, which is why the ledger, a deadline per credit, is the more robust form across clocks.

    Remember

    • Every credit spent must come back exactly once. Write that law for each loop and check it all the time; the hang is only its last symptom.

    • Across clocks no edge sees the whole sum. Count at each end, keep the local laws, and give every credit in flight a deadline to come back.

    • Token losses hide behind rate assumptions. A one-bit event crossing carries one event per sample; a count carries any number.

    Rule of thumb: for every credit loop, write the conservation law and check it continuously, with a deadline for credits in flight.

    How this was checked

    ccdc_tb.sv as shown, 2026-10-18, Icarus Verilog 13.0: iverilog -g2012 -o c.vvp ccdc_tb.sv and -Pccdc_tb.FIX=0 -o cb.vvp. vvp -n cb.vvp PASS; vvp -n cb.vvp +RXNS=2 +NOLEDGER WATCHDOG at 2355 ns; vvp -n cb.vvp +RXNS=2 CREDIT_LOST at 275 ns; vvp -n c.vvp +SUMCHK SUM at 35 ns. Sweep, seeds 1 to 10 each: the toggle return passes 10 of 10 at receiver half periods of 10, 7 and 5 ns and 0 of 10 at 4, 3, 2.5 and 2 ns (+NOLEDGER); the Gray return passes 10 of 10 at 10, 5, 4, 2 and 1.3 ns with the ledger on.

    Verilator 5.048 (verilator --binary --timing --assert -Wno-fatal [-GFIX=0] --top-module ccdc_tb) prints the same PASS, CREDIT_LOST and WATCHDOG lines (with a ZERODLY warning for the variable clock delay).

    Waveform: COURSE=dbg node scripts/tools/gen-waves.mjs hangs-and-deadlocks:credit-and-token-loss on wave-src/credit-and-token-loss/toggle-merge.sv (the same link, a directed stimulus); it matched its expectations and a second run changed nothing. Limits: a four-entry link, simulated without metastability; the Gray return also needs the usual synchroniser rules in silicon.

    Keep going

    • Practice: Credit-based CDC: cross-domain credit return

    • Practice: Pulse synchroniser: toggle vs handshake, lost pulses

    • Practice: Reusable sequences for credit-based flow control

    • Practice: Credit-based arbiter: credit return handshake

    • Code lab: Credit-based FIFO

    • Code lab: Gray code sync bus

    • Code lab: Credit counter VIP

    • Interview: run a mock interview on this topic

    Related topics

    • Part 7 · Hangs and Deadlocks

    • Protocol First Principles: credit bugs

    • RTL: pulse synchronisers and event toggles

    • Part 5: hangs and deadlocks

    • CDC/RDC: debug and case studies

    Previous

    68 / 107

    Next: Valid/Ready Deadlock Patterns

    Up next

    Up nextValid/Ready Deadlock Patterns

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Hang, Livelock or Starvation
    • Who Is Waiting for Whom
    • Watchdogs and Hang Detectors
    sendcredits3210taken01234rtogrtog_seen
    Toggle credit return with a 250 MHz receiver, sampled on the 100 MHz sender clock: four words taken, no credit returned