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
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
    • The Off-by-One That Fails at Wrap
    • The Reset Value That Changed
    • The X That Hid for Thousands of Cycles
    • The Race Two Simulators Disagree On
    • The Bus Value That Was Never Sent
    • The Response to the Wrong Master
    • The Deadlock Under Back-Pressure
    • The Credit Leak That Hangs After an Hour
    • The 2x Throughput Regression
    • The Missing Isolation
    • The Test-Mode Leak
    • The Testbench That Was Wrong
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 10 · Case Studies
  1. Topics
  2. The Art of Hardware Debug
  3. Part 10 · Case Studies
  4. The Test-Mode Leak

Part 10 · Case Studies · Senior (S)

The Test-Mode Leak

A sample-and-hold register that holds in every block test stops holding in the full boot sequence. A differential run points at the boot self-test, the cone of the register's gated clock leads to the clock gate's test enable, and the test glue turns out to use the wrong BIST signal. A software workaround, the real fix and the check that fires 21 cycles earlier.

On this page

    Why this matters

    Test features that run in mission mode, like a boot self-test, leave state behind. If a test control stays on afterwards, function breaks only on the boot path, far from any block test, and the data path looks innocent.

    After this lesson you can: use a differential run to tie a functional failure to a test feature, follow a gated clock's cone to its test enable, separate a workaround from a fix, and add a check on the test control.

    Note

    Level: Senior (S) · Debug-loop step: The whole loop (won at Localise: the differential run and the gated-clock cone)

    Before this: DFT-Mode Bugs, Differential Debugging

    The report

    A sensor interface has an 8-bit sample-and-hold register: a one-cycle cap pulse captures the ADC value din, and the register keeps it while the ADC moves on. To save power, the register sits behind a hand-instantiated clock gate, so when cap is 0 it gets no clock at all. Block tests of the hold behaviour have passed for months. The full-chip boot test, which runs the boot-time memory self-test (BIST) first and then the same functional checks, fails:

    Terminal
    $ iverilog -g2012 -o l.vvp leak_tb.sv && vvp -n l.vvp +SHOW=3
    FAIL cycle 41: held=68, but 8c was captured at cycle 40 and cap has been 0 since
    FATAL: leak_tb.sv:67: the sample-and-hold register did not hold
    ...

    The register captured 8c and, one cycle later, holds 68, which is the ADC's next value. Nobody pulsed cap. The register's code is one line.

    Stop here if you want to try it yourself. The design and bench are in the hunt. What can make a register take a new value when its only enable is off, and why would it need the boot sequence?

    Check yourself Want a hint before you read the hunt?

    This register's enable is not a mux in front of it: it is its clock. Ask what else can open a clock gate, and what the boot self-test might have left behind.

    The hunt

    This hunt is won at Localise (a differential run, then the gated clock's cone) and confirmed at Test.

    Reproduce: the design, and a run without the self-test

    The bench below has the clock gate, the self-test controller, the sampler with its test glue, and one functional test that runs with and without the boot self-test. The BUG parameter selects the glue; you are debugging rig 3, the design as built, after a boot self-test.

    icg.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
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    `timescale 1ns/1ps
    // icg: a latch-based integrated clock gate, as the library cell behaves. The enable is captured
    // while clk is low, so gclk has no glitches. te (test enable) forces the clock on: scan uses it to
    // clock every flop during shift, and memory self-test uses it to keep the clocks running.
    module icg (input logic clk, en, te, output logic gclk);
      logic en_l;
      always_latch if (!clk) en_l = en | te;
      assign gclk = clk & en_l;
    endmodule
    
    // bist_ctrl: a boot-time memory self-test engine. Software sets bist_en; the engine runs once
    // (busy for 16 cycles), then sets done and stays idle until the next reset. Its programming
    // guide says bist_en may be left set: the engine never restarts without a reset.
    module bist_ctrl (input logic clk, rst_n, bist_en, output logic busy, done);
      logic [4:0] n;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) begin busy <= 1'b0; done <= 1'b0; n <= '0; end
        else if (busy) begin
          n <= n + 5'd1;
          if (n == 5'd15) begin busy <= 1'b0; done <= 1'b1; end
        end else if (bist_en && !done) busy <= 1'b1;
    endmodule
    
    // sampler: an 8-bit sample-and-hold register on a hand-instantiated clock gate. cap captures din;
    // otherwise the gate is shut and held keeps its value. The test glue drives the gate's te.
    //   BUG = 0: te = scan_mode | bist_busy   (on only while the self-test runs)
    //   BUG = 1: te = scan_mode | bist_en     (the software enable bit, which may stay set)
    module sampler #(parameter bit BUG = 0) (
      input  logic       clk, rst_n, scan_mode, bist_en, cap,
      input  logic [7:0] din,
      output logic [7:0] held,
      output logic       bist_busy, bist_done, te, gclk
    );
      bist_ctrl u_bist (.clk, .rst_n, .bist_en, .busy(bist_busy), .done(bist_done));
      assign te = BUG ? (scan_mode | bist_en) : (scan_mode | bist_busy);
      icg u_cg (.clk, .en(cap), .te, .gclk);
      always_ff @(posedge gclk) held <= din;      // no enable here: the clock gate is the enable
    endmodule
    
    // rig: one sampler and one functional test. Reset, optionally the boot self-test, then the
    // test proper: capture din once (cycle 40) and check that held keeps it for ten cycles while
    // din keeps changing. A test-glue check states the functional-mode rule for te.
    module rig #(parameter bit BUG = 0, parameter bit BOOT_BIST = 0, parameter int ID = 0) (
      input logic clk, input int show, trace_from, trace_to, input bit clear
    );
      logic       rst_n = 1'b0, scan_mode = 1'b0, bist_en = 1'b0, cap = 1'b0;
      logic [7:0] din = 8'h00, held, want = 8'h00;
      logic       busy, done, te, gclk;
      int         cyc = 0, gedges = 0, win_edges = 0;
      string      fail = "", techeck = "";
      sampler #(.BUG(BUG)) u (.clk, .rst_n, .scan_mode, .bist_en, .cap, .din, .held,
        .bist_busy(busy), .bist_done(done), .te, .gclk);
      always @(posedge gclk) gedges++;
      always @(posedge clk) begin                  // after this edge's updates
        #1;
        cyc++;
        if (show == ID && cyc >= trace_from && cyc <= trace_to)
          $display("%5d      %b      %b     %b    %b    %b  %b   %h   %h    %0d", cyc, scan_mode, bist_en, busy, done, te,
                   cap, din, held, gedges);
        // the functional-mode rule: te is on only in scan mode or while the self-test runs
        if (techeck == "" && te && !scan_mode && !busy)
          techeck = $sformatf("cycle %0d: te=1 with scan_mode=0 and bist_busy=0 (bist_en=%b done=%b)", cyc, bist_en, done);
        if (cyc > 40 && cyc <= 50 && held !== want && fail == "") begin
          fail = $sformatf("cycle %0d: held=%h, but %h was captured at cycle 40 and cap has been 0 since", cyc, held, want);
          if (show == ID) begin
            $display("FAIL %s", fail);
            $fatal(1, "the sample-and-hold register did not hold");
          end
        end
      end
      initial begin
        int unsigned r;
        r = 32'h2545_F491;
        repeat (2) @(negedge clk);
        rst_n = 1'b1;
        @(negedge clk) bist_en = BOOT_BIST;        // boot: start the self-test, or not
        if (clear) fork                            // +CLEAR=1: software clears bist_en once done
          begin wait (done); @(negedge clk) bist_en = 1'b0; end
        join_none
        while (cyc < 39) begin                     // an ADC that never stops moving
          @(negedge clk);
          r ^= r << 13; r ^= r >> 17; r ^= r << 5;
          din = 8'(r);
        end
        cap = 1'b1; want = din;                    // edge 40 captures din
        for (int i = 0; i < 10; i++) begin         // then cap is 0 and din keeps moving
          @(negedge clk);
          if (i == 0) begin cap = 1'b0; win_edges = gedges; end
          r ^= r << 13; r ^= r >> 17; r ^= r << 5;
          din = 8'(r);
        end
        @(negedge clk) win_edges = gedges - win_edges;
      end
    endmodule
    

    The failure is deterministic: cycle 41 in Icarus Verilog and in Verilator. The report already contains a differential, so use it (differential debugging): the same functional test passes in the block regression, which never runs the self-test. Rig 2 is the same design with the boot self-test skipped. Same ADC values, same capture:

    Terminal
    $ vvp -n l.vvp +SHOW=2 +FROM=40 +TO=43
    cycle  scan_mode bist_en busy done te cap din  held  gclk edges
       40      0      0     0    0    0  1   8c   8c    1
       41      0      0     0    0    0  0   68   8c    1
       42      0      0     0    0    0  0   7b   8c    1
       43      0      0     0    0    0  0   a2   8c    1
    ...

    Without the self-test, held keeps 8c, and the last column, a count of rising edges on the gated clock, moves once: the capture. So the difference is not in the functional sequence. It is something the self-test left behind.

    Observe: the register is being clocked

    Now the failing run, around the capture and then around the end of the self-test:

    Terminal
    $ vvp -n l.vvp +SHOW=3 +FROM=1 +TO=45
    cycle  scan_mode bist_en busy done te cap din  held  gclk edges
        1      0      0     0    0    0  0   00   xx    0
    ...
        4      0      1     1    0    1  0   00   00    1
        5      0      1     1    0    1  0   3a   3a    2
    ...
       18      0      1     1    0    1  0   3e   3e    15
       19      0      1     1    0    1  0   bc   bc    16
       20      0      1     0    1    1  0   d2   d2    17
       21      0      1     0    1    1  0   ef   ef    18
    ...
       39      0      1     0    1    1  0   ed   ed    36
       40      0      1     0    1    1  1   8c   8c    37
       41      0      1     0    1    1  0   68   68    38
    FAIL cycle 41: held=68, but 8c was captured at cycle 40 and cap has been 0 since
    ...

    Two things stand out before you open any RTL. First, from cycle 4 on, held equals din on every line, not only at the capture. Second, from cycle 4 on, the gated-clock counter goes up by one on every cycle. The register is not being written by mistake; it is being clocked on every edge. The DFT-mode bugs signature table calls this a forced-open clock gate: activity with no enable.

    Localise: walk the gated clock's cone

    Take the cone of influence one stage at a time, but of the clock, not the data. held is clocked by gclk, the output of u_cg. The gate opens when its latched enable is 1, and that enable is cap | te. cap is 0 at cycle 41, so te must be 1, and the trace agrees. te is test glue: scan_mode | bist_en. scan_mode is 0. So the gate is open because `bist_en` is 1.

    Now walk back in time along te:

    • Cycle 4: software sets bist_en, the self-test starts (busy), and te goes to 1. That is intended: the memories and their wrappers need their clocks running during the self-test, which is what a clock gate's test enable is for. Scan uses the same pin to clock every flop during shift.

    • Cycle 20, the first wrong cycle: the self-test finishes (busy 0, done 1). Nothing is testing any more, but te stays 1, because bist_en is still 1.

    • Why is `bist_en` still 1? Because the controller's programming guide allows it: the engine runs once per reset and ignores the bit afterwards. Boot software, correctly, never clears it. The glue's author read bist_en as "the self-test is running". It means "software asked for a self-test".

    The bad glue in a reduced copy (the same clock gate, a 3-cycle self-test) shows the whole mechanism in twelve slots:

    Look for the slot where busy falls and te does not.

    • Slot 2: software sets bist_en; the self-test starts and te rises with it, as intended.

    • Slot 5: the self-test is over (bist_busy falls), but te stays high because it follows bist_en.

    • Slot 6: cap captures 8c.

    • Slots 7 to 10: cap is 0, yet held takes 68, 2f, d9 and 3e, every new din: the gate is still open.

    Hypothesise and test: a prediction, and a workaround that hides it

    Hypothesis: the clock gate is forced open by te, because te follows bist_en, which stays set after the self-test. It predicts something checkable without changing the RTL: if software clears bist_en as soon as the self-test is done, the hold test passes and the gated clock stops in the hold window. +CLEAR=1 makes the bench's software do exactly that:

    Terminal
    $ vvp -n l.vvp +CLEAR=1
    good glue, no boot BIST: hold test clean; 0 gated-clock edges in the hold window
    good glue, boot BIST:    hold test clean; 0 gated-clock edges in the hold window
    bad glue,  no boot BIST: hold test clean; 0 gated-clock edges in the hold window
    bad glue,  boot BIST:    hold test clean; 0 gated-clock edges in the hold window
    te check, good glue, boot BIST: clean
    te check, bad glue,  boot BIST: cycle 20: te=1 with scan_mode=0 and bist_busy=0 (bist_en=1 done=1)
    END: experiment finished (software clears bist_en after the self-test)
    ...

    The prediction holds: the hold test passes and the gated clock is quiet. But read the sixth line. Even with the software change, the te check (in the bench, explained below) still fires at cycle 20, because software can only clear the bit after it sees done, so te stays on for at least that cycle. The software change hides the symptom; the glue still leaks test mode into function for as long as software takes to react, and on any boot path that forgets. It is a useful workaround for silicon that already exists, and a way to confirm the hypothesis. It is not the fix. See fix the cause, not the symptom.

    Common pitfall

    Adding an enable mux in front of the register. if (cap) held <= din; on top of the gated clock makes this register hold again, and every other register behind a clock gate with the same te keeps running: more power, and any of them that relies on gating to hold is still wrong. Fix the test control once, at its source.

    The fix and the lesson

    Fix: drive te from what the test logic is doing

    design.sv
    // snippet: sampler's test glue
      assign te = scan_mode | bist_busy;   // was: scan_mode | bist_en

    bist_busy is high only while the engine runs, so te returns to 0 the cycle the self-test ends, whatever software does with the enable bit.

    Prove: all four rigs and the te check

    Terminal
    $ vvp -n l.vvp
    good glue, no boot BIST: hold test clean; 0 gated-clock edges in the hold window
    good glue, boot BIST:    hold test clean; 0 gated-clock edges in the hold window
    bad glue,  no boot BIST: hold test clean; 0 gated-clock edges in the hold window
    bad glue,  boot BIST:    hold test cycle 41: held=68, but 8c was captured at cycle 40 and cap has been 0 since; 10 gated-clock edges in the hold window
    te check, good glue, boot BIST: clean
    te check, bad glue,  boot BIST: cycle 20: te=1 with scan_mode=0 and bist_busy=0 (bist_en=1 done=1)
    PASS: only the bad glue after a boot self-test fails the hold test; the te check names it first
    ...
    • Line 2, the fix: with the corrected glue and the boot self-test, the register holds and the gated clock is silent for the ten hold cycles.

    • Line 4, the bug: ten gated-clock edges in ten cycles. On silicon that is the power symptom of the same bug: every clock gate driven by this te stays open after boot.

    • Line 3: the bad glue passes without the self-test. That is why the block regression never saw it, and why a differential run found it so quickly.

    • Lines 5 and 6: the te check is silent on the fix and names the bug at cycle 20.

    Verilator 5.048 prints the same lines.

    Prevent: check the test control, not the victim

    The te check is the functional-mode rule from DFT-mode bugs, written for this glue: outside scan mode, `te` is 1 only while the self-test is running. It fired at cycle 20, 21 cycles before the hold test, and it would fire in any test that runs the boot self-test, whatever the function is doing. The hold test needed a capture, a moving input and a check in the right window; the te check needs none of that. Run it in every full-chip test, and ask in review, for every test control: what is its value in mission mode after each test feature has run? The test-mode integration checklist is the place for that question.

    Which step won this hunt? Localise, twice: the differential run cut the suspects to what the self-test leaves behind, and following the clock, not the data, led from the register to te in three hops.

    Why this matters

    Senior lens. Test features that run in mission mode, such as a boot self-test, in-field logic test or a memory repair load, are where test paths meet function. Each one needs a specified exit state: which controls go back to what, and when. Here the self-test controller was correct and the glue was one word wrong, at the boundary between the DFT and RTL owners.

    This sampler instantiates its clock gate by hand, so RTL simulation could show the bug. When synthesis inserts clock gates instead, the RTL has an ordinary enable and passes; the bug appears only in gate-level simulation, a power report that shows clock gates stuck open, or on silicon. That is a strong reason to keep a te check in gate-level runs, as described in test clocks, resets and modes.

    Interview

    Asked: "After the chip's boot self-test was enabled, a register that should hold its value starts following its input. How do you debug it?"

    Weak answer: I would check the register's enable logic and the data path for glitches, and maybe add an enable mux so it cannot be written unless enabled.

    Strong answer: First I compare runs with and without the boot self-test, because the report says that is the difference. If the register takes new data every cycle with its enable off, it is being clocked, so I follow the clock, not the data: a gated clock means a clock gate, and its test enable is the first suspect. I would trace te back to its source and check what it does after the self-test ends. Here it followed the software enable bit instead of the engine's busy signal. I fix the glue at the source, keep any software workaround only for existing silicon, and add a check that te is 0 outside scan mode and active self-test in every functional test.

    Follow-ups: (1) Why might this bug never appear in RTL simulation? (2) How would you see it on silicon without a waveform?

    Check yourself The firmware team ships the workaround: clear bist_en right after the self-test. Can you close the bug?

    Not as fixed. The +CLEAR=1 run shows the hold test passes but the te check still fires at cycle 20: te is on until software reacts, and any boot path that skips the clear (a debug image, a warm reset flow, a future firmware) brings the whole bug back. Close it as worked around for existing silicon, with the glue fix (scan_mode | bist_busy) and the te check in place for the next revision.

    Check yourself A colleague's version of the hold test captures 8c while din is held at 8c for the whole hold window. Would it have caught the bug?

    No. The gate is just as open and held is clocked every cycle, but it keeps loading 8c, so its value never changes and the data check passes. Only the gated-clock count (10 edges instead of 0) or the te check would show it. A hold test must move the input while it checks; better still, check the control that causes the leak.

    Remember

    • A register that changes with its enable off is being clocked. On a gated clock, look at the gate's test enable before the data path.

    • When a failure needs one boot step, compare the run with and without it. What that step leaves behind is the suspect list.

    • Drive test controls from what the test logic is doing (busy), not from what software asked for (enable). Check them in every functional test.

    Rule of thumb: after any test feature runs, every test control must return to its functional value: check it, do not assume it.

    How this was checked

    leak_tb.sv as shown, 2026-10-18: iverilog -g2012 -o l.vvp leak_tb.sv && vvp -n l.vvp (Icarus Verilog 13.0) prints the seven lines shown and PASS; +SHOW=3, +SHOW=3 +FROM=1 +TO=45, +SHOW=2 +FROM=40 +TO=43 and +CLEAR=1 give the other outputs shown. verilator --binary --timing --assert -Wno-fatal --top-module leak_tb -Mdir vl leak_tb.sv (Verilator 5.048) prints the same result, FAIL and trace lines, except that, having no X, it shows held as 00 instead of xx before the first gated clock.

    Waveform: COURSE=dbg node scripts/tools/gen-waves.mjs case-studies:the-scan-mode-leak on wave-src/the-scan-mode-leak/hold.sv, a reduced copy (the same clock gate and bad glue, a 3-cycle self-test). It matched its expectations and a second run changed nothing.

    Limits: the clock gate is a behavioural latch model and the self-test is a 16-cycle stand-in; no library cell, netlist, DFT tool or power analysis was run. dbglab has no scan or self-test logic, so it is not used here.

    Keep going

    • Practice: Integrated clock gating (ICG) cell usage in RTL

    • Practice: Safe clock gating cell with a latch-based enable

    • Practice: BIST-compatible RAM design

    • Practice: Disabling assertions based on a test-mode signal

    • Code lab: BIST march step

    • Code lab: DFT scan enable

    • Interview: run a mock interview on this topic

    Related topics

    • Part 10 · Case Studies

    • Part 5: DFT-mode bugs

    • RTL: the ICG cell

    • RTL: test-mode integration checklist

    • Counters and performance probes

    • Cone-of-influence tracing

    Previous

    96 / 107

    Next: The Testbench That Was Wrong

    Up next

    Up nextThe Testbench That Was Wrong

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • The Off-by-One That Fails at Wrap
    • The Reset Value That Changed
    • The X That Hid for Thousands of Cycles
    bist_enbist_busytecapdin08c682fd93eheld08c682fd93e
    Reduced copy: the self-test ends but te stays high because it follows bist_en, so the gate stays open and held copies every new din