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
    • RTL Simulation
    • Formal Counterexamples as Traces
    • Gate-Level Simulation
    • Equivalence Failures
    • Emulation and FPGA Prototyping
    • The First Failing Test on Silicon
    • Silicon vs Simulation Correlation
    • Marginal and Environmental Failures
    • Moving the Failure to the Cheapest Stage
  • Part 7 · Hangs and Deadlocks7
  • 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 6 · Debug by Stage
  1. Topics
  2. The Art of Hardware Debug
  3. Part 6 · Debug by Stage
  4. Emulation and FPGA Prototyping

Part 6 · Debug by Stage · Senior (S)

Emulation and FPGA Prototyping

Emulators and FPGA prototypes run a design a thousand to a million times faster than RTL simulation and show you a few chosen signals around a trigger. How to debug with that: choose probes and triggers from a hypothesis, use deterministic replay to walk back, and move the failure into simulation. The trace-buffer situation is modelled with a real simulation.

On this page

    Why this matters

    A hang that needs minutes of firmware to appear is out of reach of RTL simulation, so it is chased on an emulator or FPGA, where you see a few signals for a few cycles around a trigger. Engineers who trigger on the symptom capture the silence after the bug and burn rebuilds and runs finding nothing.

    After this lesson you can: explain the speed against visibility trade of emulation and FPGA prototyping, design a trigger from a hypothesis, use deterministic replay to walk a failure back to its cause, and move it into simulation.

    Note

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

    Before this: RTL Simulation, Bisection in Time

    A hang after forty minutes of firmware

    The SoC boots its firmware on an FPGA prototype and runs a traffic test. Once in a few runs, after about forty minutes, the DMA stops issuing requests and never recovers. At the prototype's speed that is billions of cycles; in RTL simulation it would take years (illustrative orders of magnitude).

    The prototype has a logic analyser built into the FPGA: a few dozen probe bits, a trace buffer a few thousand samples deep, and a trigger. Which signals do you probe, and what do you trigger on?

    What people usually do, and why it fails

    The obvious move is to probe the symptom and trigger on the hang: no request issued for a long time. The trigger fires, and the buffer holds a few thousand idle cycles; the bug happened earlier, outside the window. So the buffer is made deeper or probes are added, a rebuild of hours on an FPGA, and the window is still in the wrong place. The other habit is to dump everything, which an emulator allows for a window at a large cost in speed and an FPGA does not allow at all.

    The real question is the one from bisection in time: when did it first go wrong? A trace buffer answers that only if you aim it.

    The trade: speed against visibility

    Platform

    Speed (illustrative)

    What you can see

    Changing what you see

    RTL simulation

    hundreds of Hz to kHz for an SoC

    every signal, every cycle

    a recompile: seconds to minutes

    Emulator

    around a MHz

    most signals, over a captured window, at a cost in speed

    often without a rebuild; capture depth is limited

    FPGA prototype

    tens of MHz

    only the probes chosen at build time, in an on-chip buffer

    a rebuild: hours

    Silicon

    full speed

    pins, debug registers, scan dumps, on-chip trace if designed in

    a new chip, or design-in for the next one

    Every row trades visibility for speed; the method is the same in each, and only the cost of an experiment changes. On an FPGA an experiment is a run with a trigger and a new probe is a rebuild, so you plan more carefully and lean on what fast platforms do well: running long, and running the same thing again.

    The method: aim the window

    • Probe for the hypothesis, not only the symptom. Spend probe bits on the state your hypothesis says went wrong first (a counter, a state register, a handshake), even at the cost of some symptom signals.

    • Trigger on the symptom once, to learn when. The first run's job is a cycle number. It rarely shows the cause.

    • Then trigger on what must have happened earlier. Each window suggests what came before it: a counter wrapped, two events coincided. That is the next trigger. Sequence triggers (A, then B within N cycles) and occurrence counters (the 3rd A) make it precise.

    • Place the trigger in the window: mostly-before on a consequence, mostly-after on a suspected cause. (The model's window is fixed at 11 before and 4 after, so run 3 does not follow this; one sample after its cause was enough.)

    • Insist on deterministic replay. Walking back works only if each run reaches the same cycle in the same state. Control or record every external input (recording everything to replay) and check that one trigger fires on the same cycle twice.

    • Bring it back to simulation as soon as a window shows the first wrong cycle: a directed test that reaches that state in a few hundred cycles (moving the failure to the cheapest stage).

    On the debug loop this is Observe and Localise with a very small window: each trigger is a bisection in time step that you choose instead of computing.

    The picture: three windows in one long run

    Run 1 triggers on the symptom at cycle 83,043, run 2 on the counter wrap at 79,533, run 3 on a request and a response together at 78,644. Each window holds 16 samples: 11 before the trigger, the trigger, and 4 after.

    The run is 83,047 cycles long and each window is 16 samples, so one window sees less than 0.02 % of the run. The three windows do not overlap: each run had to be aimed by what the previous one showed.

    Warning

    What is modelled here. There is no emulator or FPGA in this course's tool set. The listing below is a simulation of the situation: a block with a rare bug, a trace buffer that samples 8 probe bits into a 16-entry ring, a trigger, and a read-out after the run, as a JTAG read of an on-chip logic analyser would give. The runs, the cycle numbers and the windows are real output of that simulation. The speeds, the rebuilds and the probe routing of a real platform are not modelled. For the instruments themselves, see the bring-up course's debug interfaces.

    Do it: a hang, three triggers, three replays

    outs_ctr counts requests still waiting for a response and stops issuing at 4. Its bug: when a request and a response come on the same cycle, it counts only the response. That loses one request; later a response arrives while the counter reads 0, the counter wraps to 7, and the block never issues again. Requests and responses are rare, so the coincidence is rare. trace_buf is the trace-buffer model. The bench runs the same seeded traffic four times, as four replays would: three buggy runs with different triggers, and one fixed run.

    outs_ctr.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
    95
    96
    97
    98
    99
    100
    101
    102
    `timescale 1ns/1ps
    // outs_ctr: counts requests still waiting for a response, and stops issuing at 4.
    // The bug: a request and a response on the same cycle count as a response only, so the
    // counter loses a request. Later a response arrives with outs = 0, outs wraps to 7, and the
    // block never issues again.
    module outs_ctr #(parameter bit FIX = 0) (
      input  logic       clk, rst_n,
      input  logic       want,             // a new request is wanted
      input  logic       rsp,              // a response returns
      output logic       req,              // a request is issued
      output logic [2:0] outs
    );
      assign req = want && (outs < 3'd4);
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n)   outs <= '0;
        else if (FIX && req && rsp) outs <= outs;   // the fix: both together leave it alone
        else if (rsp) outs <= outs - 3'd1;            // bad (FIX = 0): req && rsp counts as rsp only
        else if (req) outs <= outs + 3'd1;
    endmodule
    
    // trace_buf: a model of an emulator's or FPGA's on-chip trace buffer: NPROBE bits sampled every
    // cycle into a ring of DEPTH entries. When the trigger fires it keeps POST more samples, then
    // freezes; the window is read out afterwards, as over JTAG. Nothing else is visible.
    module trace_buf #(parameter int NPROBE = 8, DEPTH = 16, POST = 4) (
      input  logic              clk, rst_n,
      input  logic [NPROBE-1:0] probe,
      input  logic              trig,
      output logic              frozen
    );
      logic [NPROBE-1:0] ring [DEPTH];
      int unsigned       cyc [DEPTH];      // the cycle number of each sample (a timestamp counter)
      int unsigned       wp, left, now, trig_at;
      logic              armed;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) begin
          wp <= 0; now <= 0; armed <= 1'b1; frozen <= 1'b0; left <= 0; trig_at <= 0;
        end else begin
          now <= now + 1;
          if (!frozen) begin
            ring[wp] <= probe; cyc[wp] <= now;
            wp <= (wp + 1) % DEPTH;
            if (armed && trig) begin armed <= 1'b0; left <= POST; trig_at <= now; end
            else if (!armed) begin
              if (left == 1) frozen <= 1'b1;
              left <= left - 1;
            end
          end
        end
      // Read the window out, oldest sample first. The probe word is {req, rsp, want, outs[2:0], 2'b0}.
      task automatic readout(input string name);
        $display("%s: trigger at cycle %0d, %0d samples before it and %0d after", name, trig_at, DEPTH - POST - 1, POST);
        for (int k = 0; k < DEPTH; k++) begin
          int unsigned i = (wp + k) % DEPTH;
          $display("  cycle %7d  req=%b rsp=%b want=%b outs=%0d%s", cyc[i], ring[i][7], ring[i][6], ring[i][5],
                   ring[i][4:2], cyc[i] == trig_at ? "   <- trigger" : "");
        end
      endtask
    endmodule
    
    // tb: three runs of the same seeded traffic, as three emulation runs replay the same stimulus,
    // each with a different trigger. Run 1: the symptom (16 wanted requests refused since the last issue).
    // Run 2: the counter wraps (outs == 7). Run 3: the first cycle with req and rsp together.
    // A fourth copy has the fix and the symptom trigger; it must never fire in 200,000 cycles.
    module tb;
      logic clk = 1'b0, rst_n = 1'b0;
      always #5 clk = ~clk;
      for (genvar r = 0; r < 4; r++) begin : g_run
        logic       want, rsp, req, trig, frozen, rsp_q;
        logic [2:0] outs;
        int unsigned rng = 32'h1234_5678, real_outs = 0, stall = 0;
        outs_ctr #(.FIX(r == 3)) u_dut (.clk, .rst_n, .want, .rsp, .req, .outs);
        trace_buf #(.NPROBE(8), .DEPTH(16), .POST(4)) u_tb (.clk, .rst_n, .probe({req, rsp, want, outs, 2'b00}), .trig, .frozen);
        // The world around it: a request is wanted in 1 cycle of 256; a response returns for a real
        // outstanding request in 1 cycle of 128. Every run uses the same seed, so the same traffic.
        always_ff @(posedge clk) begin
          rng = rng ^ (rng << 13); rng = rng ^ (rng >> 17); rng = rng ^ (rng << 5);
          want  <= (rng[7:0] == 8'd0);
          rsp_q <= (rng[14:8] == 7'd0);
        end
        assign rsp = rsp_q && (real_outs > 0);
        always_ff @(posedge clk) if (rst_n) begin
          real_outs <= real_outs + (req ? 1 : 0) - (rsp ? 1 : 0);
          stall     <= (want && !req) ? stall + 1 : (req ? 0 : stall);
        end
        assign trig = (r == 0 || r == 3) ? (stall >= 16) : (r == 1) ? (outs == 3'd7) : (req && rsp);
      end
      initial begin
        repeat (2) @(posedge clk);
        #1 rst_n = 1'b1;
        wait (g_run[0].u_tb.frozen && g_run[1].u_tb.frozen && g_run[2].u_tb.frozen);
        g_run[0].u_tb.readout("run 1, trigger on the symptom");
        g_run[1].u_tb.readout("run 2, trigger on outs == 7");
        g_run[2].u_tb.readout("run 3, trigger on req && rsp");
        while (g_run[3].u_tb.now < 200000) @(posedge clk);
        $display("fix, trigger on the symptom: %s after %0d cycles", g_run[3].u_tb.frozen ? "FIRED" : "never fired", g_run[3].u_tb.now);
        if (g_run[3].u_tb.frozen) $fatal(1, "the fixed counter stalled");
        if (!(g_run[2].u_tb.trig_at + 16 < g_run[1].u_tb.trig_at && g_run[1].u_tb.trig_at + 16 < g_run[0].u_tb.trig_at))
          $fatal(1, "the lesson's claims do not hold");
        $display("PASS: each 16-sample window misses the step before it; three replays walk back from the hang to its cause");
        $finish;
      end
    endmodule
    terminal
    $ iverilog -g2012 -o t.vvp trace.sv && vvp -n t.vvp

    Run 1: trigger on the symptom

    Terminal
    run 1, trigger on the symptom: trigger at cycle 83043, 11 samples before it and 4 after
      cycle   83032  req=0 rsp=0 want=0 outs=7
      cycle   83033  req=0 rsp=0 want=0 outs=7
    ...
      cycle   83042  req=0 rsp=0 want=1 outs=7
      cycle   83043  req=0 rsp=0 want=0 outs=7   <- trigger
    ...
      cycle   83047  req=0 rsp=0 want=0 outs=7

    The trigger fired when 16 wanted requests had been refused since the last issue: want rises at 83,042, req stays 0. outs reads 7 in every sample, which is impossible in a block that stops at 4. So this run gives a time (the trouble began before 83,032) and a clue (outs = 7), and nothing about how 7 happened.

    Run 2: trigger on the impossible value

    Hypothesis: the counter went wrong at some moment and has been stuck since. Prediction: at that moment it changes to 7. Trigger on outs == 7.

    Terminal
    run 2, trigger on outs == 7: trigger at cycle 79533, 11 samples before it and 4 after
      cycle   79522  req=0 rsp=0 want=0 outs=0
    ...
      cycle   79532  req=0 rsp=1 want=0 outs=0
      cycle   79533  req=0 rsp=0 want=0 outs=7   <- trigger
    ...

    The same traffic, replayed, and the window has moved back 3,510 cycles. At 79,532 a response arrives while outs is 0, and the counter wraps to 7. A response needs a request, so the counter must have lost one earlier. Its obvious corner is a request and a response on the same cycle.

    Run 3: trigger on the suspected cause

    Terminal
    run 3, trigger on req && rsp: trigger at cycle 78644, 11 samples before it and 4 after
      cycle   78633  req=0 rsp=0 want=0 outs=1
    ...
      cycle   78643  req=0 rsp=0 want=0 outs=1
      cycle   78644  req=1 rsp=1 want=1 outs=1   <- trigger
      cycle   78645  req=0 rsp=0 want=0 outs=0
    ...

    The first coincidence in the run is at cycle 78,644, 889 cycles before the wrap. With one request outstanding, a new request and a response arrive together; the count should stay at 1 and it drops to 0. That is the first wrong cycle, and the RTL behind it is three lines. Notice that none of the windows could have shown the one before it: the cause is 889 cycles before the wrap and the wrap is 3,510 before the symptom, both far more than 16 samples.

    Fix, and replay the original trigger

    Terminal
    fix, trigger on the symptom: never fired after 200000 cycles
    PASS: each 16-sample window misses the step before it; three replays walk back from the hang to its cause

    With FIX = 1 (both together leave the count alone) the symptom trigger never fires in 200,000 cycles of the same traffic, more than twice the length of the failing run. Verilator 5.048 prints the same output for all four runs. On a real platform the last step would be a directed simulation test: one outstanding request, then a request and a response on the same cycle, which reproduces the bug in ten cycles instead of 78,644.

    What the evidence says

    A 16-sample window on an 83,000-cycle run found a one-cycle cause, because each run was aimed by a hypothesis and each replay reached the same cycles. Determinism did as much work as the trigger: without it, the windows of different runs could not be lined up.

    Traps

    Common pitfall

    Probing only the symptom. On an FPGA, the probes are fixed at build time. If the first build probes only the bus that stopped, every later run sees the same silence. Spend some probe bits on the state behind the symptom, such as counters, state registers and credit counts, in the first build.

    Common pitfall

    Assuming the replay is the same run. A prototype with a real Ethernet link, an asynchronous clock or a free-running timer may not reach the same cycle twice. Check by arming the same trigger twice and comparing the cycle number before you start walking back.

    Common pitfall

    Debugging on the platform for too long. Once a window shows the first wrong cycle, every further experiment is cheaper in simulation. Leave the fast platform as soon as you can describe the state that triggers the bug.

    Why this matters

    Principal lens. The cost of this lesson's three runs is set before the project starts. Which state is observable on the prototype, how many probe bits and how much buffer, whether triggers can be sequences and counters, and whether the platform replays deterministically are architecture decisions, the same ones that observability by design makes for silicon. A design with a debug bus that can route any of its counters and state registers to the trace buffer without a rebuild turns a three-day hunt into an afternoon.

    Interview

    Asked: "A bug shows up only after an hour on the FPGA prototype. You have an on-chip logic analyser with 64 probe bits and 4,096 samples. How do you find it?"

    Weak answer: I would probe the interface that hangs, trigger when it stops, and look at the capture. If the cause is not in the window, I would make the buffer deeper and add more signals.

    Strong answer: First I make sure the run is reproducible: the same image, seeds and inputs, and the same trigger firing on the same cycle twice. I trigger once on the symptom to learn when the failure becomes visible. Then I form a hypothesis about what must have happened before, such as a counter reaching an impossible value or two events coinciding, and trigger on that, with the trigger late in the window when I trigger on a consequence. Each replay moves the window back. I spend some probe bits on internal state from the start, because adding probes costs a rebuild. As soon as I know the first wrong cycle and the state around it, I write a directed simulation test that reaches it quickly and continue there.

    Follow-ups: (1) What do you do if the replay is not deterministic? (2) When do you put the trigger at the start of the window rather than the end?

    Check yourself Run 2 showed a response arriving with outs = 0. Name two hypotheses that explain it, and the trigger that would split them.

    Either the counter lost a request earlier (it undercounted), or a spurious response arrived for a request that never existed (the responder misbehaved). They predict different things: an undercount means some earlier cycle where outs failed to go up for an issued request; a spurious response means the number of responses exceeds the number of requests. A trigger on "request and response together" (where an undercount is likely) or a sequence trigger on "response while the responder had nothing pending" tells them apart; here the first found the coincidence.

    Check yourself The second replay's trigger fires at cycle 79,533 on Monday and at 81,207 on Tuesday, with the same image. What does that tell you, and what do you do before walking back further?

    The run is not deterministic, so windows from different runs cannot be lined up, and a trigger aimed by one run may miss in the next. Find the source: an asynchronous clock, a timer, an external link, or uninitialised state. Control it or record it so it can be replayed, and confirm the trigger fires on the same cycle twice, before you use replays to walk back.

    Remember

    • Fast platforms trade visibility for speed: on an FPGA you see only the signals you chose before the build, in a window of a few thousand samples.

    • Trigger on the symptom first to learn when; then trigger on what the hypothesis says happened earlier. Each replay moves the window back.

    • Replay is only useful if the run is deterministic. Record or control every input that is not, then bring the cycle back to simulation.

    Rule of thumb: a trigger is a hypothesis: write down what you expect to see in the window before you arm it.

    How this was checked

    No emulator or FPGA is available or used. trace.sv as shown is a simulation model of a trace buffer (8 probe bits, 16 samples, 4 after the trigger) on a small block, 2026-10-18: iverilog -g2012 -o t.vvp trace.sv && vvp -n t.vvp (Icarus Verilog 13.0, 1.6 s) and verilator --binary --timing --assert -Wno-fatal --top-module tb trace.sv (Verilator 5.048): identical output. Triggers fire at cycles 83,043 (symptom), 79,533 (outs == 7) and 78,644 (req and rsp together); the fixed copy's symptom trigger never fires in 200,000 cycles; PASS.

    Speeds, capacities and build times in the comparison table are illustrative orders of magnitude, not measurements; they vary widely with the design, the platform and the vendor.

    Limits: the model has none of a real platform's costs (rebuild time, probe routing, clock limits, I/O speed bridges), and its stimulus is deterministic by construction. Real trace buffers are deeper and wider; the 16 samples are chosen so the output fits the page, and the walk-back works the same way at any depth.

    Keep going

    • Practice: Memory DFT and debug: trace, trigger, non-intrusive observability

    • Code lab: Silicon bring-up smoke

    • Interview: run a mock interview on this topic

    Related topics

    • Part 6 · Debug by Stage

    • Bring-up: debug interfaces

    • Observability by design

    • Recording everything to replay

    Previous

    60 / 107

    Next: The First Failing Test on Silicon

    Up next

    Up nextThe First Failing Test on Silicon

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • RTL Simulation
    • Formal Counterexamples as Traces
    • Gate-Level Simulation