A peripheral's DATA register sometimes holds a value that software never wrote to it. The firmware team has a trace: it happens after a write to an unmapped address, but only sometimes. Most writes to that address are harmless.
The register's write logic is three lines and looks right. What kind of bug changes behaviour depending on the access before the one that fails?
They stare at the register's always_ff, add a test that writes the unmapped address, see it pass (the test wrote CTRL first), and close the report as a firmware problem. Or, for the second bug in this lesson, they watch a simulation sit at 100% CPU and raise the timeout.
Both bugs were reported by the tools at the first synthesis or lint run. The message was a warning in a log of several hundred lines.
Bug | In a waveform or log | In a regression |
|---|---|---|
Latch | a combinational output keeps its old value when its inputs change; the same input gives different outputs depending on what came before | fails only after a particular sequence; a test that changes the order passes |
Combinational loop, odd inversions | simulation time stops advancing with no message (Icarus); Verilator stops with DIDNOTCONVERGE; with gate delays, a signal toggles every few nanoseconds with steady inputs | a hang that the bench's timeout or watchdog catches, or a run that never ends |
Combinational loop, even inversions | simulates fine and holds a value: it is a latch made of gates, with no enable and no timing check | usually passes; found by tools, or as a power-up state that differs between runs |
On the debug loop this is Localise and Hypothesise: the history dependence or the frozen time tells you which class you are in before you read the RTL.
Grep the synthesis log for Latch inferred and logic loop, and the lint log for LATCH, CASEINCOMPLETE and UNOPTFLAT. Read every hit on the failing path.
For a latch, find the unassigned path. For each output of the suspect always block, walk every branch of every if and case and check it is assigned. A missing default or else is the usual one.
For a hang, check time. If simulation time has stopped moving, it is a zero-delay loop, not a slow test. Run the design through Yosys check after flatten, or Verilator lint on the top module, and read the loop it prints.
The bench below holds both bugs and their fixes. reg_decode_bad has no branch for address 3, so its write enables keep their last value there. bus_loop is the arbiter and slave of the picture, with a 1 ns delay per gate as real gates have. With no plusargs the bench checks both claims; +BAD and +FIX run one design as an ordinary test.
`timescale 1ns/1ps
// reg_decode_bad: write enables for a small register block. Address 3 is unmapped,
// and the case has no branch for it, so both enables keep their last value: a latch.
module reg_decode_bad (input logic [1:0] addr, input logic we, output logic we_ctrl, we_data);
always_comb
case (addr)
2'd0: begin we_ctrl = we; we_data = 1'b0; end // CTRL
2'd1: begin we_ctrl = 1'b0; we_data = we; end // DATA
2'd2: begin we_ctrl = 1'b0; we_data = 1'b0; end // STATUS: read-only
endcase // 3: nothing assigned
endmodule
// The fix: assign every output first, then override. No path leaves an output unassigned.
module reg_decode_ok (input logic [1:0] addr, input logic we, output logic we_ctrl, we_data);
always_comb begin
we_ctrl = 1'b0;
we_data = 1'b0;
case (addr)
2'd0: we_ctrl = we;
2'd1: we_data = we;
default: ; // STATUS and unmapped: no write
endcase
end
endmodule
// The loop: arbiter and slave, each fine alone. Each gate has a 1 ns delay, as real gates do.
module bus_loop #(parameter bit FIX = 0) (input logic req, busy, output logic grant);
logic stall;
assign #1 grant = req & ~stall; // grant unless the slave stalls
if (FIX) assign #1 stall = busy; // fix: stall depends on busy only
else assign #1 stall = busy & grant; // bug: stall depends on the grant
endmodule
$ iverilog -g2012 -o l.vvp latch_tb.sv
$ vvp -n l.vvp +BAD
FAIL bad: DATA after a write to 3 that follows a DATA write is 99, expected 11
FAIL bad: grant changed 19 times in 40 ns with req and busy steady
FATAL: latch_tb.sv:101: buggy design: 2 checks failed
Time: 140000 Scope: latch_tb.$unm_blk_6
$ vvp -n l.vvp +FIX
PASS: fixed design, all 3 checks
latch_tb.sv:111: $finish called at 140000 (1ps)
$ vvp -n l.vvp
bad: 2 of 3 checks fail; fix: 0 of 3 fail
PASS: the latch corrupts DATA only after a DATA write, the loop oscillates; the fixes pass
latch_tb.sv:111: $finish called at 140000 (1ps)Read the first line with the scene in mind. A write to address 3 straight after a DATA write puts 0x99 into DATA. The same write after an idle cycle (check 2) does not fail. That is the history dependence: when the address moves from 1 to 3, we_data keeps the 1 it had, so the unmapped write lands in DATA. After an idle cycle it keeps a 0 instead. The firmware team's "only sometimes" was the access before. Verilator 5.048 prints the same lines.
The tools said so before any test ran. Yosys stops on always_comb, because that keyword promises no latch; with the older always @* the same code is only a warning, and the cells appear in stat:
$ yosys -p "read_verilog -sv decode.sv; synth -top reg_decode_bad; stat"
...
ERROR: Latch inferred for signal `\reg_decode_bad.\we_ctrl' from always_comb process `\reg_decode_bad.$proc$decode.sv:4$1'.
$ yosys -p "read_verilog -sv decode_star.sv; synth -top reg_decode_bad; stat" # the same code with always @*
...
Warning: Latch inferred for signal `\reg_decode_bad.\we_ctrl' from process `\reg_decode_bad.$proc$decode_star.sv:4$1': $auto$proc_dlatch.cc:543:proc_dlatch$35
Warning: Latch inferred for signal `\reg_decode_bad.\we_data' from process `\reg_decode_bad.$proc$decode_star.sv:4$1': $auto$proc_dlatch.cc:543:proc_dlatch$68
...
2 $_DLATCH_NP0_
...
Warnings: 2 unique messages, 2 total
$ verilator --lint-only -Wall --top-module reg_decode_bad decode.sv
...
%Warning-CASEINCOMPLETE: decode.sv:5:5: Case values incompletely covered (example pattern 0x3)
%Warning-LATCH: decode.sv:4:3: Latch inferred for signal 'we_ctrl' (not all control paths of combinational always assign a value)
%Warning-LATCH: decode.sv:4:3: Latch inferred for signal 'we_data' (not all control paths of combinational always assign a value)
...decode.sv is the two decoder modules from the bench. Verilator even names the missing pattern, 0x3. For reg_decode_ok Yosys prints No latch inferred for both signals and Verilator's lint has nothing to say about the logic.
The bench gives each gate 1 ns of delay, so the loop oscillates: grant changed 19 times in 40 ns with steady inputs. Without delays, which is how RTL is normally simulated, the loop has no stable value and no time passes. loop_zero.sv is the same arbiter and slave with zero-delay assigns:
// bad-example: the arbiter and the slave each look fine; together they form a loop with one inversion.
module arb_grant (input logic req, stall, output logic grant);
assign grant = req & ~stall; // grant unless the slave stalls
endmodule
module slave_stall (input logic busy, grant, output logic stall);
assign stall = busy & grant; // stall a grant that arrives while busy
endmodule
module loop_zero_tb;
logic req = 1'b0, busy = 1'b0, grant, stall;
arb_grant u_arb (.req, .stall, .grant);
slave_stall u_slv (.busy, .grant, .stall);
initial begin
#10 req = 1'b1;
#1 $display("t=%0t req=1 busy=0: grant=%b stall=%b", $time, grant, stall); $fflush;
#10 busy = 1'b1; // the slave becomes busy while req is high
#1 $display("t=%0t req=1 busy=1: grant=%b stall=%b", $time, grant, stall); $fflush;
#10 $display("PASS: end of test");
$finish;
end
endmodule$ iverilog -g2012 -o lz.vvp loop_zero.sv
$ time ../limit.sh 10 vvp -n lz.vvp; echo "exit=$?" # limit.sh: kill after 10 s
t=11 req=1 busy=0: grant=1 stall=0
../limit.sh 10 vvp -n lz.vvp 9.94s user 0.03s system 99% cpu 10.009 total
exit=142
$ obj_lz/Vloop_zero_tb # Verilator 5.048 build of the same file
t=11 req=1 busy=0: grant=1 stall=0
%Error-DIDNOTCONVERGE: loop_zero.sv:8: Active region did not converge after '--converge-limit' of 10000 tries
Aborting...Icarus burns 10 seconds of CPU and never reaches the second print, due at t=22, one time unit after busy rises: it is re-evaluating grant and stall for ever within one time step. Verilator gives up after 10,000 tries and names the file and line. Now the structural view:
$ yosys -p "read_verilog -sv loop_top.sv; hierarchy -top bus_top; proc; check"
...
Found and reported 0 problems.
$ yosys -p "read_verilog -sv loop_top.sv; hierarchy -top bus_top; proc; flatten; opt_clean; check"
...
Warning: found logic loop in module bus_top:
cell $flatten\u_arb.$and$loop_top.sv:2$2 ($and) source: loop_top.sv:2.18-2.30
B[0] --> Y[0]
wire \u_slv.grant source: loop_top.sv:4.39-4.44
cell $flatten\u_slv.$and$loop_top.sv:5$3 ($and) source: loop_top.sv:5.18-5.30
B[0] --> Y[0]
wire \u_arb.stall source: loop_top.sv:1.36-1.41
cell $flatten\u_arb.$not$loop_top.sv:2$1 ($not) source: loop_top.sv:2.24-2.30
A[0] --> Y[0]
Found and reported 1 problems.The first check looks at one module at a time and finds nothing, because neither module contains a loop. After flatten the loop is printed cell by cell with source lines: the AND in the arbiter, grant into the slave's AND, stall back through the arbiter's NOT. verilator --lint-only -Wall --top-module bus_top also reports it, as UNOPTFLAT: ... Circular combinational logic: 'grant', because Verilator lints the elaborated hierarchy.
The latch is localised by its history dependence: the two checks differ only in the access before, so the output must remember something, and the only memory in a combinational block is an unassigned path. The loop is localised by frozen time and then by the tool, which prints the cycle. Neither needed a waveform. None of dbglab's five bugs is in this class.
Latch: assign every output of a combinational block at the top, then override in the branches, as reg_decode_ok does. A default branch alone is not enough if a branch still forgets one output. If memory is really meant, use a flop with an enable, not a latch.
Loop: break the dependency. Here the slave's stall should depend on its own state (busy), not on the grant it is answering; or register one side, so the loop passes through a flop. Do not add a delay or a buffer: that only turns the hang into an oscillator.
Make the warnings fatal. Use always_comb (Yosys then errors on a latch), and fail the build on Verilator's LATCH, CASEINCOMPLETE and UNOPTFLAT. The synthesis report lesson shows where these lines sit in a full log.
Check the flattened design. Run Yosys check -assert after flatten in the build, so a loop that crosses modules fails it.
Count latch cells. Fail the flow if stat lists any $_DLATCH_ cell that is not on a reviewed list.
Review question: "Can any combinational output of this module come back, through another module, into its own inputs?"
A latch: the output depends on the previous input, which combinational logic cannot do unless some path leaves it unassigned. Grep the synthesis and lint logs for "Latch inferred" and LATCH on the interrupt logic, then walk every branch of the always block that drives it for an unassigned output. Fix it with defaults at the top of the block.
No. A delay turns the zero-delay hang into an oscillator, as the bench shows with 1 ns gates: grant toggles 19 times in 40 ns with steady inputs. The loop is still there and Yosys still reports it after flatten. Fix the dependency (stall from busy, or a register in the loop) and check that check reports no loop.
latch_tb.sv as shown, 2026-10-18: iverilog -g2012 -o l.vvp latch_tb.sv and vvp -n l.vvp (Icarus Verilog 13.0) print bad: 2 of 3 checks fail; fix: 0 of 3 fail and PASS; +BAD fails with the two lines shown, +FIX passes. verilator --binary --timing --assert -Wno-fatal --top-module latch_tb (Verilator 5.048) prints the same lines, plus CASEINCOMPLETE and LATCH warnings at compile time.
Yosys 0.69: yosys -p "read_verilog -sv decode.sv; synth -top reg_decode_bad; stat" stops with ERROR: Latch inferred for always_comb; with always @* it warns twice and stat lists 2 $_DLATCH_NP0_; reg_decode_ok reports No latch inferred. On loop_top.sv, check after hierarchy; proc reports 0 problems; after flatten; opt_clean it reports the logic loop shown. verilator --lint-only -Wall --top-module bus_top reports UNOPTFLAT.
loop_zero.sv (zero-delay loop): Icarus prints one line at t=11 and then makes no progress; killed after 10 s by a perl alarm wrapper (exit 142). Verilator: %Error-DIDNOTCONVERGE ... Active region did not converge.
Limits: small designs; the oscillation needs a delay model (1 ns per gate here), so its rate says nothing about real silicon. No timing analysis tool is installed, so latch timing is described, not shown.