A small timer block passes its RTL regression. Before tape-out the team runs the same bench on the synthesised netlist for the first time. Two things come back: the timer counts from the first cycle after reset, before software has loaded it, and a status read returns 0x0x where the RTL returned 0101.
The RTL has not changed and its tests still pass. Is one of these a real bug, are both, or is the netlist simulation just being pessimistic?
The common reflex is to call every gate-level difference "X pessimism" and make it go away by initialising every flop to 0. The run goes clean, and both findings disappear, including the real one. The opposite reflex is to debug the netlist net by net, through names like _072_, skipping the cheapest answers: the synthesis log and the RTL.
Gate-level simulation is not a slower RTL simulation. It asks what synthesis built, and how real cells handle unknown values. With a delay file it also checks timing, the subject of timing-only bugs; there is none here, so this lesson is about function and X.
Run the same bench on both. The RTL run is the reference. The first cycle where the netlist differs is the place to start, exactly as in comparing a good and a bad run.
Sort each difference. A wrong known value means synthesis built something the RTL simulation did not show. An X the RTL did not have means the cells propagate unknowns differently. They need different checks.
For a wrong value, read the synthesis log for that register. Synthesis is allowed to choose any value for anything the RTL leaves unknown, and the log says what it chose.
For an X, find its source flop and try both values. Start it at 0 and at 1 (random start values do this). If an output changes, the X is real; if it never does, it is pessimism. Never blanket-initialise: that simulates one particular chip and hides every bug that needs the other value.
On the debug loop this is Observe and Localise at a later stage: the same first-difference search, on names and structure chosen by the tool.
Verilog's ? : with an unknown select keeps the bits on which both choices agree, so the RTL and the mux cell give 1. Built from gates, each half is X & 1, which is X, and X OR X is X. The chip reads 1 for either value of sel_q; the gate model is right not to know sel_q, and wrong to conclude it does not know rd.
blk has two registers without reset. en_q is the timer enable, set by load; leaving it without reset is the bug, and FIX = 1 gives it one. sel_q picks which of two status registers a read returns; it has no reset either, but both registers reset to IDLE, so a read before the first swap is IDLE whatever sel_q is. The bench checks that the timer does not count before load and warns if a read is not IDLE.
// blk: a small timer block with two registers that have no reset.
// en_q : the timer enable; software sets it with load. FIX = 0 leaves it without reset (the bug).
// sel_q : which of two status registers a read returns; no reset. Both status registers reset
// to IDLE, so a read before the first swap returns IDLE whatever sel_q powered up as.
module blk #(parameter bit FIX = 0) (
input logic clk, rst_n,
input logic load, swap,
input logic [3:0] wdata,
output logic [3:0] cnt, rd
);
localparam logic [3:0] IDLE = 4'b0101;
logic en_q, sel_q;
logic [3:0] st0_q, st1_q;
if (FIX) begin : g_en
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) en_q <= 1'b0;
else if (load) en_q <= 1'b1;
end else begin : g_en
always_ff @(posedge clk)
if (load) en_q <= 1'b1; // bug: no reset
end
always_ff @(posedge clk)
if (swap) sel_q <= ~sel_q; // no reset: harmless, both choices are IDLE
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin
cnt <= '0; st0_q <= IDLE; st1_q <= IDLE;
end else begin
if (en_q) cnt <= cnt + 4'd1;
if (load) begin st0_q <= wdata; st1_q <= ~wdata; end
end
assign rd = sel_q ? st1_q : st0_q;
endmodule
// tb: the timer must not count before software loads it, and a read before the first swap
// returns IDLE. The same bench runs the RTL and the netlists (+define+NET selects blk_net).
`timescale 1ns/1ps
module tb;
logic clk = 1'b0, rst_n = 1'b0, load = 1'b0, swap = 1'b0;
logic [3:0] wdata = 4'h3, cnt, rd;
int errs = 0;
always #5 clk = ~clk;
`ifdef NET
blk_net dut (.clk, .rst_n, .load, .swap, .wdata, .cnt, .rd);
`else
blk #(.FIX(`ifdef FIX 1 `else 0 `endif)) dut (.clk, .rst_n, .load, .swap, .wdata, .cnt, .rd);
`endif
initial begin
repeat (2) @(posedge clk);
#1 rst_n = 1'b1;
$display("start: en_q=%b sel_q=%b", dut.en_q, dut.sel_q);
for (int c = 1; c <= 4; c++) begin // four cycles before software loads it
@(posedge clk); #1;
$display("cycle %0d cnt=%b rd=%b", c, cnt, rd);
if (cnt !== 4'd0) begin errs++; $display("FAIL cycle %0d: cnt=%b before load, expected 0000", c, cnt); end
if (rd !== 4'b0101) $display("WARN cycle %0d: rd=%b before the first swap, expected 0101", c, rd);
end
load = 1'b1; @(posedge clk); #1 load = 1'b0;
repeat (3) @(posedge clk); #1;
$display("after load: cnt=%0d", cnt);
if (cnt !== 4'd3) begin errs++; $display("FAIL: cnt=%0d three cycles after load, expected 3", cnt); end
if (errs) $fatal(1, "%0d errors", errs);
$display("PASS: the timer waits for load, then counts");
$finish;
end
endmoduleThe listing is two files, blk.sv and tb.sv. Two netlists are made from the buggy RTL. The first uses Yosys's default generic cells, which include a mux and enable flip-flops. The second is mapped to plain flip-flops and simple gates only, standing in for a library with no mux cell on this path. Both are simulated with Yosys's own cell models, simcells.v, as in the RTL course's ECO lesson.
$ yosys -q -p "read_verilog -sv blk.sv; chparam -set FIX 0 blk; synth -flatten -top blk; rename blk blk_net; write_verilog -noattr -noexpr net_dflt_fix0.v"
$ yosys -q -p "read_verilog -sv blk.sv; chparam -set FIX 0 blk; synth -flatten -top blk; dfflegalize -cell \$_DFF_P_ x -cell \$_DFF_PN0_ x; abc -g AND,NAND,OR,NOR,XOR,XNOR,ANDNOT,ORNOT; opt_clean; rename blk blk_net; write_verilog -noattr -noexpr net_gates_fix0.v"
$ iverilog -g2012 -o rtl.vvp tb.sv blk.sv && vvp -n rtl.vvp
$ iverilog -g2012 -DNET -o n.vvp tb.sv net_dflt_fix0.v $(yosys-config --datdir)/simcells.v && vvp -n n.vvpstart: en_q=x sel_q=x
cycle 1 cnt=0000 rd=0101
cycle 2 cnt=0000 rd=0101
cycle 3 cnt=0000 rd=0101
cycle 4 cnt=0000 rd=0101
after load: cnt=3
PASS: the timer waits for load, then countsBoth registers start unknown, and the RTL hides both. if (en_q) with an X condition takes the else path, so the counter holds: the X optimism of an if. The ternary gives 0101 because both choices agree.
start: en_q=1 sel_q=x
cycle 1 cnt=0001 rd=0101
FAIL cycle 1: cnt=0001 before load, expected 0000
cycle 2 cnt=0010 rd=0101
FAIL cycle 2: cnt=0010 before load, expected 0000
...
after load: cnt=8
FAIL: cnt=8 three cycles after load, expected 3
FATAL: tb.sv:28: 5 errorsThe first line already says it: in the netlist, en_q is 1 before anything has written it. The timer counts from cycle 1 and has reached 8 by the end. This is not X propagation; the netlist simply has no unknown here. The synthesis log says why:
3.10.6. Executing OPT_DFF pass (perform DFF optimizations).
...
Adding EN signal on $procdff$43 ($dff) from module blk (D = 1'1, Q = \en_q).
Setting constant 1-bit at position 0 on $auto$ff.cc:337:slice$47 ($dffe) from module blk.
$ grep "assign en_q" net_dflt_fix0.v
assign en_q = 1'h1;en_q has no reset and no initial value, and the only value ever written to it is 1. Its start value is unknown, so synthesis may take it to be 1, and then it never changes: a constant, with no warning, because nothing illegal happened. The RTL simulation chose "holds" for the unknown; synthesis chose 1, and a chip built from this netlist counts from reset every time. This is a simulation-synthesis mismatch caused by an X, and only the netlist run shows it.
Yosys's sequential equivalence would not catch it either: equiv_make, equiv_simple and equiv_induct on this RTL and netlist prove all 18 compare points, because a flop with an unknown start value may equal the constant 1. A production LEC flow would list en_q as a register removed as constant, a line in a report that is easy to waive. The netlist is a legal implementation of RTL that is wrong.
start: en_q=1 sel_q=x
cycle 1 cnt=0001 rd=0x0x
FAIL cycle 1: cnt=0001 before load, expected 0000
WARN cycle 1: rd=0x0x before the first swap, expected 0101
...
FATAL: tb.sv:28: 5 errorsThe timer fails in the same way. The read is new: the bits where IDLE is 1 are X, and the bits where it is 0 are 0. That is the picture above, bit by bit. (X & 0) | (X & 0) is 0, so only the 1 bits go unknown.
Fix the timer (FIX = 1, a reset on en_q) and rebuild both netlists. The default-cell netlist passes with rd=0101. The simple-gate netlist passes its checks, but the warning stays:
start: en_q=0 sel_q=x
cycle 1 cnt=0000 rd=0x0x
WARN cycle 1: rd=0x0x before the first swap, expected 0101
...
after load: cnt=3
PASS: the timer waits for load, then countsIs that X a bug? The test is to run the netlist as real chips would start: sel_q at 0 in some runs, at 1 in others. Verilator with --x-initial unique gives every unreset flop a random value per seed; it is a two-state simulator, so no X can appear. The same treatment on the buggy RTL shows what a real dependence looks like (the bench's start: line prints the start values each seed drew):
$ verilator --binary --timing -Wno-fatal -Wno-lint -Wno-style --x-initial unique -DNET --top-module tb \
tb.sv net_gates_fix1.v $(yosys-config --datdir)/simcells.v -Mdir obj_g1 # fixed netlist, simple gates
$ verilator --binary --timing -Wno-fatal -Wno-lint -Wno-style --x-initial unique --top-module tb \
tb.sv blk.sv -Mdir obj_r0 # buggy RTL
$ for s in 1 2 3 4 5 6; do echo "net fix seed $s: $(obj_g1/Vtb +verilator+rand+reset+2 +verilator+seed+$s \
| grep -E "start|cycle 1 |PASS|FAIL" | head -3 | tr '\n' ' ')"; done
$ for s in 1 2 3 4 5 6; do echo "rtl bug seed $s: $(obj_r0/Vtb +verilator+rand+reset+2 +verilator+seed+$s \
| grep -E "start|cycle 1 |PASS|FAIL" | head -3 | tr '\n' ' ')"; donenet fix seed 1: start: en_q=0 sel_q=0 cycle 1 cnt=0000 rd=0101 PASS: the timer waits for load, then counts
net fix seed 2: start: en_q=0 sel_q=0 cycle 1 cnt=0000 rd=0101 PASS: the timer waits for load, then counts
net fix seed 3: start: en_q=0 sel_q=1 cycle 1 cnt=0000 rd=0101 PASS: the timer waits for load, then counts
...
net fix seed 6: start: en_q=0 sel_q=1 cycle 1 cnt=0000 rd=0101 PASS: the timer waits for load, then counts
rtl bug seed 1: start: en_q=0 sel_q=0 cycle 1 cnt=0000 rd=0101 PASS: the timer waits for load, then counts
rtl bug seed 3: start: en_q=1 sel_q=1 cycle 1 cnt=0001 rd=0101 FAIL cycle 1: cnt=0001 before load, expected 0000
...
rtl bug seed 6: start: en_q=1 sel_q=1 cycle 1 cnt=0001 rd=0101 FAIL cycle 1: cnt=0001 before load, expected 0000In the fixed netlist, sel_q starts at 0 in three seeds and at 1 in three, and rd is 0101 in all six: no chip could read anything else. The X was pessimism. In the buggy RTL, the result follows en_q's start value: seeds 3, 4 and 6 start it at 1 and fail. That X was hiding a real bug. One flop has only two start values, so here six seeds cover both; a block with many unreset flops needs many seeds, or a formal check, to cover the combinations that matter.
For the pessimistic X there are two honest choices: a waiver with this evidence ("rd does not depend on sel_q until the first swap"), or a reset on sel_q so every later run is clean. On a control bit the reset is usually the better trade.
Run | Timer before load | Read before swap | What it means |
|---|---|---|---|
RTL, bug | 0 (holds) | 0101 | Optimism hides the missing reset |
Netlist, default cells, bug | counts 1, 2, 3 ... | 0101 | Synthesis made en_q the constant 1: a real bug |
Netlist, simple gates, bug | counts 1, 2, 3 ... | 0x0x | The same bug, plus gate pessimism |
Netlist, simple gates, fix | 0 | 0x0x | Pessimism only: same read for either sel_q |
Netlist, default cells, fix | 0 | 0101 | Clean |
en_q does not need a reset because "software always writes load before using the timer". What does the netlist run say about that argument?The argument assumes en_q starts at 0 and waits for software. Without a reset it starts at an unknown value, and synthesis was free to choose 1, which turned the flop into a constant: the timer runs from reset no matter what software does. A register that gates behaviour before software's first write needs a reset; "software writes it first" only works for registers nothing reads before that write.
Neither alone. Both are the same logic and the chip reads 0101 either way, which only the two-value runs proved. The mux-cell model happened to be optimistic in the same way as the RTL; the simple-gate model was pessimistic. What the chip does is settled by trying both start values, not by which cell model you simulated.
blk.sv and tb.sv as shown, 2026-10-18. RTL: iverilog -g2012 -o rtl.vvp tb.sv blk.sv && vvp -n rtl.vvp (Icarus 13.0): PASS. Netlists (Yosys 0.69+post): yosys -q -p "read_verilog -sv blk.sv; chparam -set FIX 0 blk; synth -flatten -top blk; rename blk blk_net; write_verilog -noattr -noexpr net_dflt_fix0.v", and the simple-gate netlist with dfflegalize -cell $_DFF_P_ x -cell $_DFF_PN0_ x; abc -g AND,NAND,OR,NOR,XOR,XNOR,ANDNOT,ORNOT; opt_clean after synth. Each run as iverilog -g2012 -DNET -o n.vvp tb.sv <netlist> $(yosys-config --datdir)/simcells.v && vvp -n n.vvp.
Results: both FIX = 0 netlists fail with cnt=0001 at cycle 1 (5 errors), the simple-gate one also shows rd=0x0x; both FIX = 1 netlists pass, the simple-gate one with rd=0x0x warnings. yosys -p "...; synth -flatten -top blk" log, OPT_DFF pass: Setting constant 1-bit at position 0 on $auto$ff.cc:337:slice$47 ($dffe) from module blk.; the netlists contain assign en_q = 1'h1;. equiv_make/equiv_simple/equiv_induct/equiv_status -assert of the FIX = 0 RTL against net_dflt_fix0.v: 18 of 18 proven.
Random start values (Verilator 5.048): verilator --binary --timing -Wno-fatal -Wno-lint -Wno-style --x-initial unique [-DNET] --top-module tb tb.sv <blk.sv | net_gates_fix1.v simcells.v>, run with +verilator+rand+reset+2 +verilator+seed+N, N = 1 to 6: FIX = 0 RTL fails for seeds 3, 4 and 6 (en_q started at 1) and passes for 1, 2 and 5; FIX = 1 simple-gate netlist passes all six with rd=0101, sel_q starting at 0 in three and 1 in three.
Limits: zero-delay gate models (no SDF, no timing; see the timing-only bugs lesson), Yosys generic cells instead of a vendor library, and a simple-gate mapping chosen to stand in for a library without a mux cell. Six random seeds test two start values of one flop, which is all there are here; a real block needs every X source checked.