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
    • Reading a Waveform with a Question
    • Dump Strategy
    • Logging That Helps
    • Assertions as Always-On Probes
    • Scoreboards Report Late
    • Comparing a Good and a Bad Run
    • X in a Waveform
    • Counters and Performance Probes
    • Observability by Design
  • 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
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 2 · Observe
  1. Topics
  2. The Art of Hardware Debug
  3. Part 2 · Observe
  4. X in a Waveform

Part 2 · Observe · Foundation (F)

X in a Waveform

What X means in simulation, where it comes from, how if/else hides it and a ternary shows it, and how an $isunknown check at the point of use catches dbglab's unreset register 7,461 cycles before the scoreboard.

On this page

    Why this matters

    An X is the simulator telling you it cannot know a value that the chip will pick at random. Read as noise or hidden by an if/else, it becomes a power-up bug that passes every regression and fails on silicon.

    After this lesson you can: explain what X means in simulation, name its usual sources, spot where an if/else hides one, and place an $isunknown check where a value is used so the X is caught when it is created.

    Note

    Level: Foundation (F) · Debug-loop step: Observe

    Before this: The Debug Loop, Comparing a Good and a Bad Run

    An X that two simulators disagree about

    dbglab's random test fails on seed 17 in Icarus: a RAM word reads back as xxxxxxxx at cycle 7949. A colleague runs the same seed in Verilator and it passes. "Icarus is being fussy about X again," they say.

    Is this a tool quirk to waive, or a bug the chip will have? And where did the X come from, 7,949 cycles into the run?

    What people usually do, and why it fails

    X values are everywhere early in a run, so people learn to ignore them. They read X as "don't care", or as a simulator artefact that synthesis will clean up. When one simulator shows an X and another does not, they trust the one that passes.

    Both habits get it backwards. An X is the simulator saying I do not know this bit. The chip will have a value there, and nobody controls which. Verilator passes seed 17 only because its flip-flops start at zero, which happens to be the value the test expects. Silicon makes no such promise.

    What X means, and where it comes from

    In four-state simulation (Icarus, and every event-driven commercial simulator) a bit can be 0, 1, X or Z. X means the value is unknown: it could be 0 or 1 in hardware. Z means nothing drives the wire. A two-state simulator such as Verilator has no X at all; it picks 0, or a random value if you ask it to.

    Source

    How it looks

    Typical cause

    No reset

    A register is X from time 0 until something writes it

    A flop left out of the reset "because software writes it first"

    Multiple drivers

    A wire is X while two drivers disagree

    Two assigns, or two enabled tri-state drivers, on one net

    Out-of-range index

    A read returns X

    mem[idx] with idx past the end of the array

    Uninitialised memory

    A RAM read returns X until the word is written

    A buffer read before it is filled; a missing preload

    Power-down

    Outputs of an off domain are X

    A missing isolation clamp (a later bug class)

    Once created, an X spreads through logic, but not faithfully. X optimism: some constructs turn an unknown into a known value. An if whose condition is X takes the else branch, so the output looks clean although the chip could have taken either branch. X pessimism: other constructs turn a known result into X. a & ~a is 0 in hardware for any a, but X in simulation when a is X; gate-level netlists are full of such cases. Optimism hides bugs; pessimism produces false alarms. The RTL course's X-propagation discipline compares if, case and the ternary operator under X in detail.

    The method: rules for an X

    • Never waive an X you cannot explain. Find where it was created and prove it harmless, usually by showing a write always comes before the first read.

    • Trust the four-state run for X questions. When Icarus shows X and Verilator passes, rerun Verilator with random start values (XRAND=1 in dbglab). If it then fails, the bug is real.

    • Check at the point of use, not at the end. Put $isunknown on values the moment they matter: write data on a bus, enables, selects, addresses. The scoreboard may read the damage much later.

    • Distrust a clean output fed by an X input. If an if condition can be X, the output proves nothing. Check the condition itself.

    On the debug loop this is Observe: an X check is a probe that turns "something read wrong at 7949" into "this value was unknown when it was used at 488".

    The picture: an if/else hides the X, a ternary shows it

    The small design below has one register without reset, the enable en_q. Two counters, both with a proper reset, read it in two ways: one with if (en_q), one with a ternary.

    Read it slot by slot (slot n is just after clock edge n): watch cnt_if and cnt_tern while en_q is X.

    • Slots 0 to 3: en_q is X. cnt_if stays 0, because if (X) takes the else branch and holds. cnt_tern goes X at slot 1: its two choices (1 and 0) disagree in the last bit.

    • Slot 3: software raises load, and en_q becomes 1 at slot 4.

    • Slots 5 to 9: cnt_if counts 1 to 5 and looks perfect. cnt_tern stays X: once a counter is unknown, adding one keeps it unknown. That is correct, because the chip's count really depends on what en_q powered up as.

    Do it: the same design in three simulations

    tb.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
    // xmask: one register without reset, read two ways. The if/else hides its X (X optimism);
    // the ternary shows it. Both counters have a reset; only the enable en_q does not.
    `timescale 1ns/1ps
    module tb;
      logic       clk = 1'b0, rst_n = 1'b0, load = 1'b0;
      logic       en_q;                         // bad: no reset, X until the first load
      logic [3:0] cnt_if, cnt_tern;
      int         cyc = 0, x_en = 0, x_if = 0, x_tern = 0;
      always #5 clk = ~clk;
    
      always_ff @(posedge clk) if (load) en_q <= 1'b1;
    
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n)    cnt_if <= '0;
        else if (en_q) cnt_if <= cnt_if + 1'b1;  // en_q = X: the condition is false, cnt_if holds
    
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) cnt_tern <= '0;
        else        cnt_tern <= en_q ? cnt_tern + 1'b1 : cnt_tern;  // en_q = X: bits that differ go X
    
      always @(negedge clk) if (rst_n) begin
        $display("cycle %0d  en_q=%b  cnt_if=%h  cnt_tern=%h", cyc, en_q, cnt_if, cnt_tern);
        if ($isunknown(en_q))     x_en++;
        if ($isunknown(cnt_if))   x_if++;
        if ($isunknown(cnt_tern)) x_tern++;
        cyc++;                                   // cycle n is printed after clock edge n
      end
    
      initial begin
        $dumpfile("wave.vcd");
        $dumpvars(0, tb);
        @(posedge clk) #1 rst_n = 1'b1;          // released just after edge 0
        repeat (3) @(posedge clk);
        #1 load = 1'b1;                          // software enables the counter at the 4th edge
        @(posedge clk) #1 load = 1'b0;
        repeat (5) @(posedge clk);
        #1;
        // The if/else never shows an X, so a check on cnt_if can never see the missing reset.
        // Whenever en_q was unknown, the ternary showed it.
        if (x_if != 0 || (x_en != 0 && x_tern == 0)) $fatal(1, "unexpected X behaviour");
        $display("PASS: en_q unknown for %0d sampled cycles; X seen on cnt_if in %0d, on cnt_tern in %0d",
                 x_en, x_if, x_tern);
        $finish;
      end
    endmodule
    Terminal
    $ iverilog -g2012 -o xmask.vvp xmask.sv && vvp -n xmask.vvp
    VCD info: dumpfile wave.vcd opened for output.
    cycle 0  en_q=x  cnt_if=0  cnt_tern=0
    cycle 1  en_q=x  cnt_if=0  cnt_tern=X
    cycle 2  en_q=x  cnt_if=0  cnt_tern=x
    ...
    cycle 4  en_q=1  cnt_if=0  cnt_tern=x
    cycle 5  en_q=1  cnt_if=1  cnt_tern=x
    ...
    cycle 8  en_q=1  cnt_if=4  cnt_tern=x
    PASS: en_q unknown for 4 sampled cycles; X seen on cnt_if in 0, on cnt_tern in 8
    ...
    $ verilator --binary --timing --assert -Wno-fatal --top-module tb -Mdir vl xmask.sv > /dev/null && vl/Vtb
    -Info: xmask.sv:31: $dumpvar ignored, as Verilated without --trace
    cycle 0  en_q=0  cnt_if=0  cnt_tern=0
    ...
    cycle 8  en_q=1  cnt_if=4  cnt_tern=4
    PASS: en_q unknown for 0 sampled cycles; X seen on cnt_if in 0, on cnt_tern in 0
    ...
    $ verilator --binary --timing -Wno-fatal --x-assign unique --x-initial unique --top-module tb -Mdir vlx xmask.sv > /dev/null
    $ vlx/Vtb +verilator+rand+reset+2 +verilator+seed+8
    -Info: xmask.sv:31: $dumpvar ignored, as Verilated without --trace
    cycle 0  en_q=1  cnt_if=0  cnt_tern=0
    cycle 1  en_q=1  cnt_if=1  cnt_tern=1
    ...
    cycle 8  en_q=1  cnt_if=8  cnt_tern=8

    Icarus shows the X on the ternary counter and hides it on the if/else counter (uppercase X: some bits unknown; lowercase: all). Verilator, two-state, shows no X anywhere: en_q starts at 0 and both counters read 4 at cycle 8. With random start values, seed 8 powers en_q up as 1, and the counter reads 8. That last run is one possible chip, and it disagrees with the clean-looking cnt_if of the other two. The bug is the missing reset on en_q. In the Icarus run only en_q and cnt_tern point at it; in the default Verilator run nothing does.

    The real case: dbglab's FILL register

    Back to seed 17. The previous lesson diffed it against commit 13 and found the FILL register X from reset. Now catch it from the design side, with two X checks compiled next to dbglab. One checks the value where the DMA uses it (a fill run starting with an unknown fill value); the other checks every write on the bus.

    x_check.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // not-run: needs dbglab's sources and bench; compile it next to them (command in the Evidence box)
    // x_check: two X checks compiled next to dbglab. Each prints its first hit; neither stops the run.
    //   XCHK-USE  the DMA starts a fill run while the fill value it is about to load is unknown
    //   XCHK-BUS  a master's write is accepted with unknown write data
    `timescale 1ns/1ps
    module x_check;
      bit use_seen = 0, bus_seen = 0;
      always @(negedge dbglab_tb.clk) begin                // mid-cycle: values are settled
        if (!use_seen && dbglab_tb.dut.u_dma.start && !dbglab_tb.dut.u_dma.busy
            && dbglab_tb.dut.u_dma.cfg_ctrl[dbglab_pkg::CTRL_FILL] && $isunknown(dbglab_tb.dut.u_dma.cfg_fill)) begin
          $display("XCHK-USE cycle=%0d: fill run starts with cfg_fill=%h (DST=%h LEN=%0d)", dbglab_tb.cycle,
                   dbglab_tb.dut.u_dma.cfg_fill, dbglab_tb.dut.u_dma.cfg_dst, dbglab_tb.dut.u_dma.cfg_len);
          use_seen = 1;
        end
        for (int m = 0; m < 2; m++)
          if (!bus_seen && dbglab_tb.dut.m_valid[m] && dbglab_tb.dut.m_ready[m] && dbglab_tb.dut.m_write[m]
              && $isunknown(dbglab_tb.dut.m_wdata[m])) begin
            $display("XCHK-BUS cycle=%0d: master %0d writes %h to %h", dbglab_tb.cycle, m,
                     dbglab_tb.dut.m_wdata[m], dbglab_tb.dut.m_addr[m]);
            bus_seen = 1;
          end
      end
    endmodule
    Terminal
    $ sh ../probes/run-probe.sh ../probes/x_check.sv random 17
    dbglab: test=random seed=17
    XCHK-USE cycle=483: fill run starts with cfg_fill=xxxxxxxx (DST=00001ca8 LEN=9)
    XCHK-BUS cycle=488: master 1 writes xxxxxxxx to 00001ca8
    FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read xxxxxxxx, expected 00000000 (last written by DMA run #4)

    Read the three lines after the banner in order. At cycle 483 a zero-fill run of 9 words starts while the fill value is unknown: that is where the X is used. At cycle 488 the first unknown word goes out on the bus to 0x1ca8: that is where it is written. At cycle 7949 the CPU reads word 816, one of the nine, and the scoreboard complains: that is where it is seen, 7,461 cycles after it was written. dbglab's own bus checker tests address and write enable for X, but not write data, which is why nothing fired at 488.

    Why Verilator passes and what random power-up values show is measured in symptom, fault and cause; here the question is where an X check catches it. Run over seeds 1 to 60, the bus check fires in exactly the nine seeds that fail with an X, always earlier:

    Seed

    XCHK-BUS fires

    Test fails

    Cycles earlier

    16

    298

    686

    388

    17

    488

    7949

    7461

    19

    288

    415

    127

    23

    2229

    2385 (STREAM)

    156

    29

    418

    1820

    1402

    31

    336

    5693

    5357

    41

    6769

    7656

    887

    43

    1025

    2793

    1768

    51

    959

    1155

    196

    What the evidence says

    The X is real, not a tool quirk: a random power-up value reproduces the failure in the two-state simulator. It was created at reset in one register, used at cycle 483 and written at 488. The checks moved the failure to the start of the chain and name the register (cfg_fill, from fill_q). Next comes Localise: who removed FILL's reset?

    Traps

    Common pitfall

    Waiving an X because the other simulator passes. Two-state simulation turns every X into 0 (or a random value you choose), so it cannot show this class of bug unless you randomise start values. A pass there is not evidence against an X failure.

    Common pitfall

    Fixing the symptom with a default. Changing the bench to expect X, or making the RAM model ignore X writes, makes the regression green and ships the bug. The fix is a reset on fill_q, or a guarantee that FILL is written before any fill run.

    Why this matters

    Senior lens. Decide where X checks live as a team rule, not a debug afterthought: every bus port checks payload for X when valid, every FSM checks its state, every module boundary checks enables. In dbglab, one missing check on write data cost 7,461 cycles of distance here; on a larger design it costs days.

    Removing resets saves area and routing on wide datapath registers, and it is often right. Make it safe by writing down the rule it depends on ("written before first use") and adding the assertion that checks it. Commit 14's rule was true for SRC, DST and LEN, and false for FILL.

    Interview

    Asked: "Your test fails with an X on a read in one simulator and passes in another. What do you do?"

    Weak answer: X usually comes from the simulator's handling of uninitialised values, so I would trust the simulator that passes, or force the signal to 0 at time zero.

    Strong answer: I treat the X as real until proven otherwise. The passing simulator is probably two-state and starts flops at zero, so I rerun it with random start values; if it then fails, the chip can fail too. Then I find where the X was created: I put $isunknown checks where the value is used, on bus write data and on the control that consumed it, which moves me from the read back to the write and the register. Finally I check whether that register has a reset or a guaranteed write before use, fix the cause, and keep the X check as an assertion.

    Follow-ups: (1) What is X optimism, and where does it hide bugs? (2) When is it safe to leave a register without reset?

    Check yourself In the small example, someone adds an assertion !$isunknown(cnt_if) and it never fires. They conclude the reset problem is fixed. What is wrong?

    cnt_if cannot show the X: if (en_q) with an unknown en_q takes the else branch and holds a clean value. The assertion is on the wrong signal. Check the condition, !$isunknown(en_q) whenever the counter is out of reset, or read the counter with a construct that propagates X. The fix is still a reset on en_q.

    Check yourself Seed 51 fails with an X at HEAD, but it also failed at commit 13, before FILL lost its reset. Is it the same bug?

    At HEAD its first failure is the X, and the bus check fires at cycle 959, so FILL's missing reset is what shows first. At commit 13 it fails later, at cycle 11722, with a real but wrong value (15d021ca where DMA run #75 should have left zero): a different signature, so a different bug until proven otherwise. One seed can carry two bugs; fix the first one it shows, then rerun and see what remains. Sort by signature, not by seed.

    Remember

    • X means unknown: the chip will have a 0 or a 1 there, and the simulation cannot say which. It never means "don't care".

    • An if/else on an X condition takes the else branch and shows a clean value. A clean output does not prove a clean input.

    • Check for X where a value is used (bus write data, enables, selects), not only at the scoreboard, which may read it thousands of cycles later.

    Rule of thumb: treat every X that reaches a control or an output as a bug until a reset or a write proves it harmless.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), 2026-10-17. tb/run.sh random 17 (Icarus Verilog 13.0) fails at cycle 7949 with xxxxxxxx; tb/run.sh random 17 verilator (Verilator 5.048) passes; XRAND=1 tb/run.sh random 17 verilator fails at cycle 7949 with 5d88e2dd.

    x_check.sv compiled next to dbglab (iverilog -g2012 -Wno-timescale -I tb -o build/probe.vvp <rtl/files.f> tb/bus_rules.sv tb/dbglab_tb.sv x_check.sv, vvp -n build/probe.vvp +TEST=random +SEED=17): XCHK-USE at cycle 483, XCHK-BUS at 488. Silent at commit 13 (03ebdc1), which passes seed 17 (seed 51 fails there at cycle 11722 with a wrong, known value). Over seeds 1 to 60 at HEAD, XCHK-BUS fires in exactly the nine seeds that fail with an X and in none of the others.

    xmask.sv: iverilog -g2012 and vvp -n; verilator --binary --timing --assert -Wno-fatal (two-state, starts at 0); and verilator --binary --timing -Wno-fatal --x-assign unique --x-initial unique run with +verilator+rand+reset+2 +verilator+seed+8. The waveform comes from COURSE=dbg node scripts/tools/gen-waves.mjs observe:x-in-a-waveform, which matched its expectations; a second run changed nothing.

    Limits: simulation only, one unreset register. Verilator's random start values stand in for silicon power-up; they show one possible chip, not all of them. Gate-level X pessimism is described, not run here.

    Keep going

    • Practice: Inline checkers: X detection and handshake liveness

    • Practice: Decoder with enable: X-propagation

    • Practice: Reset strategies in RTL

    • Code lab: DFF with asynchronous reset

    • Interview: run a mock interview on this topic

    Related topics

    • Part 2 · Observe

    • RTL: X-propagation discipline

    • Comparing a good and a bad run

    • Part 5: bug classes

    Previous

    21 / 107

    Next: Counters and Performance Probes

    Up next

    Up nextCounters and Performance Probes

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Reading a Waveform with a Question
    • Dump Strategy
    • Logging That Helps
    loaden_qcnt_if012345cnt_tern0xx
    A register without reset read two ways: the if/else counter stays clean while its enable is X, the ternary counter goes X