Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Digital Design & Verification (Verilog, SystemVerilog, VLSI)  Module 4 – Finite-state machines in RTL

FSM encoding, safety and state coverage

Compare binary, Gray and one-hot state encodings on an iCE40, keep recovery through synthesis, and measure state and arc coverage with Verilator.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Compare binary, Gray and one-hot encodings by area and speed with Yosys and nextpnr
  • Design state machines whose synthesised netlist recovers from illegal codes
  • Write assertions that allow only the legal transitions of a state machine
  • Measure state and transition coverage with Verilator, and check what it counted

Before you start

What you’ll learn · Before you start

What you will learn

  • Compare binary, Gray and one-hot encodings by area and speed with Yosys and nextpnr
  • Design state machines whose synthesised netlist recovers from illegal codes
  • Write assertions that allow only the legal transitions of a state machine
  • Measure state and transition coverage with Verilator, and check what it counted

Before you start

On this page

The same washing-machine controller took 25 look-up tables and ran at up to 96.68 MHz on an iCE40 with its states coded 0 to 8, and 14 look-up tables at 144.07 MHz with one flip-flop per state. The one-hot version also did something its RTL never does: after one flipped flip-flop it stopped until a reset, because synthesis had rebuilt the codes and left out the way back. How a machine’s states are coded decides its size, its speed, its power and whether it recovers, and coverage then has to show which of its states and arcs the tests reached.

You will compare binary, Gray and one-hot encodings measured with Yosys 0.69 and nextpnr 0.11.1, make the netlist keep its recovery, write assertions that allow only the diagram’s transitions, and measure state and arc coverage with Verilator 5.052. The codes and their bit changes are counted in your browser.

One machine, three encodings, three different circuits

wash_ctrl.sv controls a washing machine. Start checks the door, open is a fault, and a closed door starts a cycle of fill, wash, drain, rinse and spin, which ends waiting for the door to open: nine states, each making one two-way choice, written as an if with an else for a reason the coverage section gives. Its state’s state encoding: The codes a state machine's register uses for its states, such as binary (0 to 8 in 4 bits), Gray (one bit changes at each step of a cycle) or one-hot (one flip-flop per state). It sets the machine's size, speed and power. can be the enum’s codes 0 to 8, binary; a Gray code: A sequence of codes in which each code differs from the next in exactly one bit, such as 00, 01, 11, 10. A state machine whose cycle follows one changes a single flip-flop at each step of the cycle. sequence round the cycle, with -DGRAY, so that every step of the cycle changes one bit; or one flip-flop per state, one-hot: A code with one bit per choice and exactly one bit set, such as 0100 for choice 2 of four. A decoder makes it from a binary number with a shift; $onehot checks that a value is one. More in “Muxes, encoders and round-robin arbiters”. encodings.py lists the codes and counts the bits each encoding changes in one wash:

The washer's codes in three encodings Python · model/encodings.py
"""Three encodings of wash_ctrl.sv: the code of every state, how many flip-flops change at each step of one wash
cycle, and how many codes of each state register name no state at all. One-hot here gives each state its bit in the
order of the names."""

CYCLE = ["IDLE", "CHECK", "FILL", "WASH", "DRAIN", "RINSE", "SPIN", "DONE"]
STATES = CYCLE + ["FAULT"]
GRAY = {"IDLE": 0b0000, "CHECK": 0b0001, "FILL": 0b0011, "WASH": 0b0010, "DRAIN": 0b0110,
        "RINSE": 0b0111, "SPIN": 0b0101, "DONE": 0b0100, "FAULT": 0b1000}

ENCODINGS = {
    "binary": ({s: i for i, s in enumerate(STATES)}, 4),
    "Gray": (GRAY, 4),
    "one-hot": ({s: 1 << i for i, s in enumerate(STATES)}, 9),
}

print(f"{'state':6}  {'binary':6}  {'Gray':4}  one-hot")
for s in STATES:
    codes = [f"{code[s]:0{width}b}" for code, width in ENCODINGS.values()]
    print(f"{s:6}  {codes[0]:6}  {codes[1]:4}  {codes[2]}")

print()
for name, (code, width) in ENCODINGS.items():
    steps = [bin(code[a] ^ code[b]).count("1") for a, b in zip(CYCLE, CYCLE[1:] + CYCLE[:1])]
    print(f"{name}: {width} flip-flops; {sum(steps)} bit changes in one wash cycle {steps}; "
          f"{2**width - len(STATES)} of {2**width} codes name no state")

Output

state   binary  Gray  one-hot
IDLE    0000    0000  000000001
CHECK   0001    0001  000000010
FILL    0010    0011  000000100
WASH    0011    0010  000001000
DRAIN   0100    0110  000010000
RINSE   0101    0111  000100000
SPIN    0110    0101  001000000
DONE    0111    0100  010000000
FAULT   1000    1000  100000000

binary: 4 flip-flops; 14 bit changes in one wash cycle [1, 2, 1, 3, 1, 2, 1, 3]; 7 of 16 codes name no state
Gray: 4 flip-flops; 8 bit changes in one wash cycle [1, 1, 1, 1, 1, 1, 1, 1]; 7 of 16 codes name no state
one-hot: 9 flip-flops; 16 bit changes in one wash cycle [2, 2, 2, 2, 2, 2, 2, 2]; 503 of 512 codes name no state

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 encodings.py

Binary changed 14 bits in one wash, up to 3 at once on WASH to DRAIN and DONE to IDLE; Gray changed one at every step, 8 in all; one-hot changed two at every step, 16. One-hot also leaves 503 of its register’s 512 codes without a name, against 7 of 16 for the other two. Step round the cycle:

Interactive · step by step The washer's states, with their binary and Gray codes Step round the wash cycle and count the bits that change at each step in each encoding.
The washer's states, with their binary and Gray codes, step 10 of 10. Showing RINSE, DRAIN, SPIN, WASH, DONE, FILL, IDLE, CHECK, FAULT. In focus: FAULT.RINSEbinary 0101, Gray 0111DRAINbinary 0100, Gray 0110SPINbinary 0110, Gray 0101WASHbinary 0011, Gray 0010DONEbinary 0111, Gray 0100FILLbinary 0010, Gray 0011IDLEbinary 0000, Gray 0000CHECKbinary 0001, Gray 0001FAULTbinary 1000, Gray 1000binary 2, Gray 2

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. Nine states. Every state except FAULT lies on one cycle. Every state but CHECK also has an arc that stays, which these arrows leave out. FAULT is reached from CHECK with the door open.
  2. IDLE to CHECK. Start takes IDLE to CHECK. Binary goes from 0000 to 0001 and Gray the same way, one bit each.
  3. CHECK to FILL. With the door closed, CHECK goes to FILL. Binary 0001 to 0010 changes two bits, Gray 0001 to 0011 one.
  4. FILL to WASH. A full drum takes FILL to WASH, one bit in each code, 0010 to 0011 and 0011 to 0010.
  5. WASH to DRAIN. The binary code goes from 0011 to 0100 and changes three bits at once; the Gray code goes from 0010 to 0110, one bit.
  6. DRAIN to RINSE. An empty drum takes DRAIN to RINSE, one bit in each code.
  7. RINSE to SPIN. Binary 0101 to 0110 changes two bits, Gray 0111 to 0101 one.
  8. SPIN to DONE. One bit in each code, binary 0110 to 0111 and Gray 0101 to 0100.
  9. DONE to IDLE. Opening the door ends the cycle. Binary 0111 to 0000 changes three bits; Gray 0100 to 0000 one. Round the cycle that makes 14 changes for binary and 8 for Gray.
  10. Off the cycle. FAULT is off the cycle, and no free code is one bit from both CHECK and IDLE. CHECK to FAULT changes two bits in both codes, FAULT to IDLE one.

Synthesis does not have to keep any of these codes. Yosys’s fsm_detect recognises a state register and marks it for re-encoding, unless its fsm_encoding attribute is “none”; with -DKEEP_CODES the RTL sets that attribute. The session first asks Yosys what it makes of the washer, then synthesises it three ways for an iCE40 UP5K, binary and Gray with their codes kept and a third build left to Yosys, and places and routes each with nextpnr, which reports the fastest clock each placement allows:

The washer synthesised three ways for an iCE40, placed and routed

hdl/wash_ctrl.sv

// A washing-machine controller. Start checks the door: open is a fault, closed starts the cycle of fill, wash,
// drain, rinse and spin, which ends waiting for the door to open. Timers and sensors outside it say when the drum is
// full or empty and when a wash, a rinse or a spin is done. Every state makes one two-way choice, written as an if
// with an else, and the state register has a block of its own with an active-high reset, the forms that the FSM
// coverage of the simulator reads. The names take the codes 0 to 8 in order; with -DGRAY each step of the cycle
// changes one bit; with -DKEEP_CODES synthesis keeps the codes as written.
module wash_ctrl (
  input  logic clk_i,
  input  logic rst_i,          // active high
  input  logic start_i,
  input  logic door_closed_i,
  input  logic full_i,
  input  logic empty_i,
  input  logic done_i,         // this wash, rinse or spin has run long enough
  input  logic clear_i,        // the fault has been seen
  output logic lock_o,         // the door is locked
  output logic valve_o,
  output logic motor_o,
  output logic pump_o,
  output logic fault_o
);
`ifdef GRAY
  typedef enum logic [3:0] {IDLE = 4'b0000, CHECK = 4'b0001, FILL = 4'b0011, WASH = 4'b0010, DRAIN = 4'b0110,
                            RINSE = 4'b0111, SPIN = 4'b0101, DONE = 4'b0100, FAULT = 4'b1000} state_t;
`else
  typedef enum logic [3:0] {IDLE, CHECK, FILL, WASH, DRAIN, RINSE, SPIN, DONE, FAULT} state_t;
`endif

`ifdef KEEP_CODES
  (* fsm_encoding = "none" *)
`endif
  state_t state_q /*verilator fsm_arc_include_cond*/;
  state_t state_d;

  always_comb begin
    state_d = state_q;
    unique case (state_q)
      IDLE:  if (start_i)       state_d = CHECK; else state_d = IDLE;
      CHECK: if (door_closed_i) state_d = FILL;  else state_d = FAULT;
      FILL:  if (full_i)        state_d = WASH;  else state_d = FILL;
      WASH:  if (done_i)        state_d = DRAIN; else state_d = WASH;
      DRAIN: if (empty_i)       state_d = RINSE; else state_d = DRAIN;
      RINSE: if (done_i)        state_d = SPIN;  else state_d = RINSE;
      SPIN:  if (done_i)        state_d = DONE;  else state_d = SPIN;
      DONE:  if (door_closed_i) state_d = DONE;  else state_d = IDLE;
      FAULT: if (clear_i)       state_d = IDLE;  else state_d = FAULT;
      default:                  state_d = IDLE;    // a code that names no state
    endcase
  end

  always_ff @(posedge clk_i or posedge rst_i)
    if (rst_i) state_q <= IDLE;
    else       state_q <= state_d;

`ifndef SYNTHESIS
  // The state diagram as assertions, which synthesis leaves out: the register holds only codes that name a state,
  // and each state either stays or moves to the state its choice names.
  a_known: assert property (@(posedge clk_i) disable iff (rst_i)
                            state_q inside {IDLE, CHECK, FILL, WASH, DRAIN, RINSE, SPIN, DONE, FAULT})
           else $error("state code %0d names no state", state_q);
  a_idle:  assert property (@(posedge clk_i) disable iff (rst_i) state_q == IDLE  |=> state_q inside {IDLE, CHECK});
  a_check: assert property (@(posedge clk_i) disable iff (rst_i) state_q == CHECK |=> state_q inside {FILL, FAULT});
  a_fill:  assert property (@(posedge clk_i) disable iff (rst_i) state_q == FILL  |=> state_q inside {FILL, WASH});
  a_wash:  assert property (@(posedge clk_i) disable iff (rst_i) state_q == WASH  |=> state_q inside {WASH, DRAIN});
  a_drain: assert property (@(posedge clk_i) disable iff (rst_i) state_q == DRAIN |=> state_q inside {DRAIN, RINSE});
  a_rinse: assert property (@(posedge clk_i) disable iff (rst_i) state_q == RINSE |=> state_q inside {RINSE, SPIN});
  a_spin:  assert property (@(posedge clk_i) disable iff (rst_i) state_q == SPIN  |=> state_q inside {SPIN, DONE});
  a_done:  assert property (@(posedge clk_i) disable iff (rst_i) state_q == DONE  |=> state_q inside {DONE, IDLE});
  a_fault: assert property (@(posedge clk_i) disable iff (rst_i) state_q == FAULT |=> state_q inside {FAULT, IDLE});
`endif

  assign lock_o  = (state_q == CHECK) || (state_q == FILL) || (state_q == WASH) || (state_q == DRAIN) ||
                   (state_q == RINSE) || (state_q == SPIN);
  assign valve_o = (state_q == FILL) || (state_q == RINSE);
  assign motor_o = (state_q == WASH) || (state_q == RINSE) || (state_q == SPIN);
  assign pump_o  = (state_q == DRAIN) || (state_q == SPIN);
  assign fault_o = (state_q == FAULT);
endmodule

Commands · hdl/encodings_session.py

from session import run

# What Yosys makes of the washer's states, and how many transitions the table it extracted holds; then the washer
# synthesised for an iCE40 UP5K three ways and placed and routed with seed 1. grep keeps the flip-flops and look-up
# tables of each, and the last clock report of each run.
run("""
yosys -q -p "read_verilog -sv wash_ctrl.sv; proc; opt -nodffe -nosdff; tee -q -o fsm.txt fsm -nomap"
grep -E "Found FSM|encoding to|state bits" fsm.txt
grep -c "transition:" fsm.txt
yosys -q -p "read_verilog -sv -DKEEP_CODES wash_ctrl.sv; synth_ice40 -top wash_ctrl -json binary.json; tee -q -o binary.txt stat"
yosys -q -p "read_verilog -sv -DKEEP_CODES -DGRAY wash_ctrl.sv; synth_ice40 -top wash_ctrl -json gray.json; tee -q -o gray.txt stat"
yosys -q -p "read_verilog -sv wash_ctrl.sv; synth_ice40 -top wash_ctrl -json onehot.json; tee -q -o onehot.txt stat"
grep -H -E "SB_(DFF|LUT4)" binary.txt gray.txt onehot.txt
nextpnr-ice40 --up5k --package sg48 --seed 1 --json binary.json 2>&1 | grep "Max frequency" | tail -n 1
nextpnr-ice40 --up5k --package sg48 --seed 1 --json gray.json 2>&1 | grep "Max frequency" | tail -n 1
nextpnr-ice40 --up5k --package sg48 --seed 1 --json onehot.json 2>&1 | grep "Max frequency" | tail -n 1
""")

Output

# Yosys 0.69 · nextpnr-ice40 0.11.1
$ yosys -q -p "read_verilog -sv wash_ctrl.sv; proc; opt -nodffe -nosdff; tee -q -o fsm.txt fsm -nomap"
$ grep -E "Found FSM|encoding to|state bits" fsm.txt
Found FSM state register wash_ctrl.state_q.
  mapping auto encoding to `one-hot` for this FSM.
  Number of state bits:       9
$ grep -c "transition:" fsm.txt
18
$ yosys -q -p "read_verilog -sv -DKEEP_CODES wash_ctrl.sv; synth_ice40 -top wash_ctrl -json binary.json; tee -q -o binary.txt stat"
$ yosys -q -p "read_verilog -sv -DKEEP_CODES -DGRAY wash_ctrl.sv; synth_ice40 -top wash_ctrl -json gray.json; tee -q -o gray.txt stat"
$ yosys -q -p "read_verilog -sv wash_ctrl.sv; synth_ice40 -top wash_ctrl -json onehot.json; tee -q -o onehot.txt stat"
$ grep -H -E "SB_(DFF|LUT4)" binary.txt gray.txt onehot.txt
binary.txt:        4   SB_DFFR
binary.txt:       25   SB_LUT4
gray.txt:        4   SB_DFFR
gray.txt:       21   SB_LUT4
onehot.txt:        8   SB_DFFR
onehot.txt:        1   SB_DFFS
onehot.txt:       14   SB_LUT4
$ nextpnr-ice40 --up5k --package sg48 --seed 1 --json binary.json 2>&1 | grep "Max frequency" | tail -n 1
Info: Max frequency for clock 'clk_i$SB_IO_IN_$glb_clk': 96.68 MHz (PASS at 12.00 MHz)
$ nextpnr-ice40 --up5k --package sg48 --seed 1 --json gray.json 2>&1 | grep "Max frequency" | tail -n 1
Info: Max frequency for clock 'clk_i$SB_IO_IN_$glb_clk': 100.14 MHz (PASS at 12.00 MHz)
$ nextpnr-ice40 --up5k --package sg48 --seed 1 --json onehot.json 2>&1 | grep "Max frequency" | tail -n 1
Info: Max frequency for clock 'clk_i$SB_IO_IN_$glb_clk': 144.07 MHz (PASS at 12.00 MHz)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 encodings_session.py

Encoding Flip-flops Look-up tables Fastest clock
binary, codes kept 4 25 96.68 MHz
Gray, codes kept 4 21 100.14 MHz
one-hot, Yosys’s choice 9 14 144.07 MHz

Left to itself, Yosys chose one-hot, with 9 state bits, for a table of 18 transitions, two out of each state. That machine took five more flip-flops and 11 fewer look-up tables than binary, and ran 49 % faster. Each one-hot bit’s next value depends only on the few arcs into its own state, while each binary bit depends on all four state bits and on the inputs of every state whose arcs change it; logic with fewer inputs fits in fewer look-up tables of four inputs, in fewer levels. Which matters more depends on where the machine runs:

Interactive · answer and compare Which encoding for this machine? Answer three questions about where the machine runs and what it must survive; the table marks what fits.
Where will the machine run?
Does it spend most of its time stepping round one fixed cycle, with power to save?
Must it find its way back when a flip-flop flips?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three encodings of the washer, against the measurements of this lesson
Encoding Flip-flops Logic and speed on an iCE40 Bits changed in one wash Codes that name no state
Binary Codes: 0 to 8, in 4 bits Good 4, the fewest for 9 states Poor 25 look-up tables, 96.68 MHz Mixed 14, up to 3 at once Good 7 of 16
Gray Codes: one bit per step of the cycle, 4 bits Good 4, as many as binary Mixed 21 look-up tables, 100.14 MHz Good 8, one at every step Good 7 of 16
One-hot Codes: one bit per state, 9 bits Poor 9, one per state Good 14 look-up tables, 144.07 MHz Poor 16, two at every step Poor 503 of 512
How the answers decide
  • “Where will the machine run?” An FPGA, with flip-flops to spare: adds a caveat to Binary. On the iCE40 one-hot took 14 look-up tables and reached 144.07 MHz against 25 and 96.68 for binary; flip-flops are cheap there.
  • “Where will the machine run?” A chip where every flip-flop costs area: adds a caveat to One-hot. Nine flip-flops against four cost area where each is a cell of its own; measure on the library you build with.
  • “Does it spend most of its time stepping round one fixed cycle, with power to save?” Yes: Gray changed one bit at every step of the cycle, 8 in a wash, against 14 for binary and 16 for one-hot.
  • “Must it find its way back when a flip-flop flips?” Yes, it must recover by itself: adds a caveat to One-hot. 503 of the one-hot register's 512 codes name no state, and the netlist Yosys re-encoded did not recover from the one with no bit set. Keep the codes with fsm_encoding "none", so that the default branch is built.

Checkpoint

Check: Binary, Gray and one-hot encodings

  1. Question 1 of 2 On the iCE40 the one-hot washer took 9 flip-flops and 14 look-up tables, the binary one 4 and 25. Why fewer look-up tables with more flip-flops?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each one-hot bit's next value depends only on the arcs into its own state, while each binary bit depends on all four state bits and many inputs

    One-hot moves cost from logic into flip-flops: nine simple next-state functions instead of four complex ones. An FPGA has a flip-flop beside every look-up table, so the trade usually pays there.

  2. Question 2 of 2 In binary, WASH is 0011 and DRAIN is 0100. How many flip-flops change on the step from WASH to DRAIN?

    Type a number.

    A hint

    Compare the two codes bit by bit.

    Show the answer: Question 2 of 2

    Answer: 3 flip-flops

    0011 and 0100 differ in their three lowest bits, so three flip-flops change at once. The Gray codes of the same two states, 0010 and 0110, differ in one, which is why Gray changed 8 bits in a wash against 14 for binary.

Synthesis can drop the way back

The RTL of wash_ctrl.sv has a default branch that sends every code without a name to IDLE, like the lock of the first lesson of this module. tb_upset.sv tests what was built: it runs the RTL beside two netlists Yosys wrote, wash_kept with the codes kept and wash_hot with Yosys’s one-hot codes, and in the middle of a wash flips one flip-flop of each state register. In the binary register that turns WASH, 0011, into 1011, code 11; in the one-hot register it clears the one bit that was set:

One upset in the RTL and in two netlists of it

hdl/tb_upset.sv

// One upset, three builds of wash_ctrl.sv side by side: the RTL; wash_kept, the netlist synthesised with its codes
// kept (fsm_encoding "none"); and wash_hot, the netlist synthesised with the defaults, in which Yosys re-encoded the
// states as one-hot. During the wash one flip-flop of each state register flips: bit 3 of the binary code 0011, which
// makes code 11, and the one set bit of the one-hot code, which leaves none set. Then the door opens and start is
// pressed, to see which builds still answer.
module tb_upset;
  logic clk = 1'b0, rst = 1'b1, start = 1'b0, door_closed = 1'b1, full = 1'b1, empty = 1'b0, done = 1'b0,
        clear = 1'b0;
  logic [4:0] o_rtl, o_kept, o_hot;            // lock, valve, motor, pump, fault of each build

  wash_ctrl u_rtl  (.clk_i(clk), .rst_i(rst), .start_i(start), .door_closed_i(door_closed), .full_i(full),
                    .empty_i(empty), .done_i(done), .clear_i(clear), .lock_o(o_rtl[4]), .valve_o(o_rtl[3]),
                    .motor_o(o_rtl[2]), .pump_o(o_rtl[1]), .fault_o(o_rtl[0]));
  wash_kept u_kept (.clk_i(clk), .rst_i(rst), .start_i(start), .door_closed_i(door_closed), .full_i(full),
                    .empty_i(empty), .done_i(done), .clear_i(clear), .lock_o(o_kept[4]), .valve_o(o_kept[3]),
                    .motor_o(o_kept[2]), .pump_o(o_kept[1]), .fault_o(o_kept[0]));
  wash_hot  u_hot  (.clk_i(clk), .rst_i(rst), .start_i(start), .door_closed_i(door_closed), .full_i(full),
                    .empty_i(empty), .done_i(done), .clear_i(clear), .lock_o(o_hot[4]), .valve_o(o_hot[3]),
                    .motor_o(o_hot[2]), .pump_o(o_hot[1]), .fault_o(o_hot[0]));

  always #5 clk = ~clk;

  task automatic show(input string what);
    $display("RTL %b code %2d | kept %b code %2d | one-hot %b code %b | %s", o_rtl, u_rtl.state_q,
             o_kept, u_kept.state_q, o_hot, u_hot.state_q, what);
  endtask

  initial begin
    logic [3:0] rtl_code, kept_code;
    $display("outputs: lock, valve, motor, pump, fault");
    @(negedge clk) rst = 1'b0;
    @(negedge clk) start = 1'b1;
    @(negedge clk) start = 1'b0;               // CHECK, then FILL, which is full at once, then WASH
    repeat (3) @(negedge clk);
    show("washing");
    rtl_code  = u_rtl.state_q ^ 4'b1000;      // the codes with bit 3 flipped
    kept_code = u_kept.state_q ^ 4'b1000;
    force u_rtl.state_q  = type(u_rtl.state_q)'(rtl_code);
    force u_kept.state_q = kept_code;
    force u_hot.state_q  = '0;
    #1;
    release u_rtl.state_q;
    release u_kept.state_q;
    release u_hot.state_q;
    show("the upset");
    repeat (2) begin
      @(negedge clk);
      show("one edge on");
    end
    door_closed = 1'b0;
    @(negedge clk);
    door_closed = 1'b1;
    start = 1'b1;
    @(negedge clk) start = 1'b0;
    @(negedge clk);
    show("start again");
    $finish;
  end
endmodule

Commands · hdl/safety_session.py

from session import run

# Two netlists of the washer, one with its codes kept and one re-encoded by Yosys, beside the RTL, with one upset in
# each. The testbench's force and release write the state registers, so Verilator's MULTIDRIVEN warning is off, and
# the error limit lets the run go on past the assertions of the RTL.
run("""
yosys -q -p "read_verilog -sv -DKEEP_CODES wash_ctrl.sv; synth -top wash_ctrl; rename wash_ctrl wash_kept; write_verilog -noattr wash_kept.v"
yosys -q -p "read_verilog -sv wash_ctrl.sv; synth -top wash_ctrl; rename wash_ctrl wash_hot; write_verilog -noattr wash_hot.v"
verilator --binary -j 0 --quiet -Wno-MULTIDRIVEN --top-module tb_upset tb_upset.sv wash_ctrl.sv wash_kept.v wash_hot.v
obj_dir/Vtb_upset +verilator+quiet +verilator+error+limit+10
""")

Output

# Yosys 0.69 · Verilator 5.052
$ yosys -q -p "read_verilog -sv -DKEEP_CODES wash_ctrl.sv; synth -top wash_ctrl; rename wash_ctrl wash_kept; write_verilog -noattr wash_kept.v"
$ yosys -q -p "read_verilog -sv wash_ctrl.sv; synth -top wash_ctrl; rename wash_ctrl wash_hot; write_verilog -noattr wash_hot.v"
$ verilator --binary -j 0 --quiet -Wno-MULTIDRIVEN --top-module tb_upset tb_upset.sv wash_ctrl.sv wash_kept.v wash_hot.v
$ obj_dir/Vtb_upset +verilator+quiet +verilator+error+limit+10
outputs: lock, valve, motor, pump, fault
RTL 10100 code  3 | kept 10100 code  3 | one-hot 10100 code 010000000 | washing
RTL 00000 code 11 | kept 00000 code 11 | one-hot 00000 code 000000000 | the upset
[65] %Error: wash_ctrl.sv:60: Assertion failed in tb_upset.u_rtl.a_known: state code 11 names no state
-Info: wash_ctrl.sv:60: Verilog $stop, ignored due to +verilator+error+limit
[65] %Error: wash_ctrl.sv:64: Assertion failed in tb_upset.u_rtl.a_wash: 'assert' failed.
RTL 00000 code  0 | kept 00000 code  0 | one-hot 00000 code 000000000 | one edge on
RTL 00000 code  0 | kept 00000 code  0 | one-hot 00000 code 000000000 | one edge on
RTL 11000 code  2 | kept 11000 code  2 | one-hot 00000 code 000000000 | start again
- tb_upset.sv:57: Verilog $finish

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 safety_session.py

The RTL and wash_kept were in IDLE one edge after the upset, code 0, and answered the next start by locking the door and opening the valve, 11000. wash_hot stayed at no bit set, with every output 0, and ignored start; it would have stayed so until a reset. The two %Error lines come from the RTL’s assertions, the subject of the next section. Step through the two netlists:

Interactive · step by step One upset in two netlists of the same RTL Step through the run; each column is one netlist, its register on top, its next-state logic below.
One upset in two netlists of the same RTL, step 6 of 6. Showing codes kept: state_q (IDLE 0000), one-hot: state_q (still no bit set), next state as written (default: IDLE), next state from arcs (no arc is active), outputs (lock and valve on, FILL), outputs (all off, ignores start). In focus: outputs.codes kept: state_qIDLE 0000one-hot: state_qstill no bit setnext state as writtendefault: IDLEnext state from arcsno arc is activeoutputslock and valve on, FILLoutputsall off, ignores start

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Washing. Both netlists hold WASH. The one that kept the codes holds 0011; in the one Yosys re-encoded, one bit of nine is set.
  2. The upset. One flip-flop of each register flips. The kept register now reads 1011, code 11, which names no state. The one-hot register loses its only set bit.
  3. Kept codes, next edge. The netlist that kept the codes has the default branch exactly as written, so code 11 leads to IDLE, 0000, at the next edge.
  4. One-hot, next edge. In the re-encoded netlist each bit is set only by the arcs into its state, and every arc starts from a set bit. With none set, every next bit is 0 again.
  5. Start again. The door opens and start is pressed. The netlist with its codes kept goes to CHECK and then FILL, valve open, just as the RTL does.
  6. Dead until a reset. The re-encoded netlist does nothing, and will do nothing until a reset. Its RTL had a default branch, but the codes it was written for do not exist in this register.

The cause is the re-encoding. fsm_extract turned the register and its next-state logic into that table of 18 transitions out of the nine codes the RTL assigns, fsm_recode gave the nine states new codes, and the logic Yosys built covers the table alone. The default branch, written for the binary codes 9 to 15, had nothing left to apply to, and a register with no bit set has no transition out of it. A safe state machine: A state machine whose netlist detects every register code that names no state and moves to a known state at the next clock edge, so that an upset or a power-up without a reset cannot leave it stuck. needs its recovery in the netlist. With Yosys, keep the codes with fsm_encoding “none”, as wash_kept did, and the default branch is built as written.

Key idea

The recovery that counts is the one in the netlist: the RTL’s default branch recovered from code 11, the netlist that kept its codes did too, and the netlist Yosys re-encoded stayed stopped until a reset.

No default branch can see a flip that lands on another legal code: in binary, a flipped bit 0 turns WASH, 0011, into FILL, 0010, and the machine simply fills again. In simulation a_wash would still fail, since WASH must be followed by WASH or DRAIN, but the hardware has no assertions. AMD’s Vivado has an attribute for both cases, FSM_SAFE_STATE, placed on the state register. It adds logic that detects invalid states and moves the machine to a valid one at the next clock edge. With reset_state or power_on_state that is the reset or the power-on state, with codes two flips apart so that any single flip is detected; with default_state it is the state of the RTL’s default branch; with auto_safe_state, codes three flips apart let it correct a single flip instead.

Checkpoint

Check: Recovery that survives synthesis

  1. Question 1 of 2 After the upset, why did wash_hot stay at no bit set while the RTL went back to IDLE?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its logic was built for the 18 transitions out of the nine one-hot codes, and every next bit is set by an arc out of a set bit, so with none set every next bit stays 0

    A netlist recovers only from the codes its logic was built for. Re-encoding left 503 codes with no name, and the empty one leads nowhere until a reset.

  2. Question 2 of 2 You need a washer built by Yosys that finds its way back from any code by itself. What does this lesson's run support?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Keep the codes with fsm_encoding "none", so that the default branch is built as written, as in wash_kept

    The recovery has to be in the netlist. With Yosys, the way that kept it here was to keep the codes, so that the default branch's logic exists for the codes without a name.

Assertions write the state diagram into the code

The end of wash_ctrl.sv holds ten concurrent assertion: A SystemVerilog assertion that is checked at every clock edge, such as assert property (@(posedge clk) a |=> b), and reports the edge at which the property fails. More in “Valid/ready handshakes and skid buffers”, inside ifndef SYNTHESIS: Yosys’s read_verilog defines that macro by default, so synthesis leaves them out, and Verilator runs them. a_known asserts at every clock edge that state_q holds one of the nine names. The nine others write the arcs of the diagram, one per state; a_wash, for one, is state_q == WASH |=> state_q inside {WASH, DRAIN}.

|=> means “at the next clock edge”: whenever the state is WASH at one edge, it must be WASH or DRAIN at the next. Every assertion is clocked by @(posedge clk_i) and has disable iff (rst_i), which holds it off during a reset, when the register goes to IDLE whatever the arcs say. In the upset run, a_known and a_wash both failed at the first edge after the flip, t = 65:

Interactive · message by message When the assertions sample, and what they saw Step through the upset run edge by edge, then the power-up without a reset.
When the assertions sample, and what they saw, step 6 of 6. Messages so far: state_q to a_wash: WASH at 55; state_q to a_wash: 11 at 65; state_q to a_known: 11 at 65; state_q to a_known: IDLE at 75; state_q to a_known: 12 at 5.state_qsampled at each edgea_washWASH |=> WASH or DRAINa_knowna code with a nameWASH at 5511 at 6511 at 65IDLE at 7512 at 5failed at 5

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Edge at 55. Assertions read state_q as it was just before the edge. At 55 that is WASH, so a_wash's antecedent holds, and its check falls due at the next edge.
  2. The upset at 60. Between two edges the testbench flips bit 3 of the register, and WASH, 0011, becomes code 11. No assertion runs between edges.
  3. Edge at 65, a_wash. At 65 a_wash reads 11, which is neither WASH nor DRAIN, so it fails, one edge after its antecedent, which is what |=> asks for.
  4. Edge at 65, a_known. a_known checks every edge, and 11 names no state, so it fails at 65 too, with its own message. The default branch loads IDLE at the same edge.
  5. Edge at 75. The register now holds IDLE, a name, and neither assertion fails again. The run goes on, because the error limit was raised.
  6. A power-up without reset. In the power-up test no reset holds the checks off, and the register starts at code 12 from seed 1. a_known fails at the very first edge, at 5, and the default branch loads IDLE at that edge.

An assertion and a default branch do different jobs. The default branch recovers quietly, which is right for the hardware and wrong for a simulation, where a code without a name means a bug in the design or the testbench, unless a test put it there on purpose. The assertions turn that quiet recovery into a failure with a time and a name. A slip in the next-state logic, say DRAIN going straight to SPIN, would trip a_drain at the first edge after it in the same way.

Writing the assertion from the RTL instead of the diagram

Wrong: copy each case item into an assertion, which then agrees with any slip in the case. Right: write the assertions from the state diagram of the specification, so the RTL and the assertions are two descriptions that a slip in one will make disagree.

Checkpoint

Check: Assertions of the legal transitions

  1. Question 1 of 2 a_wash, state_q == WASH |=> state_q inside {WASH, DRAIN}, failed at t = 65 in the upset run. What did it see?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: WASH at the edge at 55, and code 11 at the edge at 65

    A |=> property looks at two edges in a row: if the left side held at one, the right side must hold at the next. The flip came between them.

  2. Question 2 of 2 The default branch already takes every code without a name to IDLE. Why keep a_known as well?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In simulation such a code means a bug, or an upset a test put there; a_known reports it with a time and a name, where the default branch would hide it by recovering

    Recovery belongs in the hardware, reports in the simulation. A machine that recovers quietly in every test can hide a next-state bug for ever.

State and arc coverage measure what the tests never did

state coverage: The share of a state machine's states that a set of tests visited, given as a count and a percentage. A total of 0 states means the tool found no machine to count. is the share of a machine’s states the tests visited, and arc coverage: The share of a state machine's transitions, the arcs that stay in a state included, that a set of tests took. Also called transition coverage. the share of its arcs, its transitions, they took, the arcs that stay in a state included. Verilator added both in release 5.048 as --coverage-fsm, which its documentation still calls experimental. First the lock of the first lesson of this module, with every kind of coverage switched on:

All coverage of the lock, keyed with its right code

hdl/door_lock.sv

// A keypad door lock. The four digits of Code unlock it for OpenTicks cycles. A wrong code is still read to its
// fourth digit, so the keypad never shows which digit was wrong, and the third wrong code in a row locks the keypad
// for LockTicks cycles. Two-process style: the next state and the Mealy output in always_comb, the registers in
// always_ff, then the output logic.
module door_lock #(
  parameter logic [15:0] Code              = 16'h5913,  // one digit per four bits, the first digit on top
  parameter logic [3:0]  OpenTicks         = 4'd3,
  parameter logic [3:0]  LockTicks         = 4'd8,
  parameter bit          RegisteredOutputs = 1'b0       // 1: every output from a flip-flop
) (
  input  logic       clk_i,
  input  logic       rst_ni,
  input  logic       key_valid_i,  // a key press, for one cycle
  input  logic [3:0] key_i,        // its digit, 0 to 9
  output logic       accept_o,     // Mealy: the key of this cycle is taken
  output logic       unlock_o,     // Moore: the door is unlocked
  output logic       lockout_o     // Moore: keys are ignored for now
);
  typedef enum logic [3:0] {IDLE, OK1, OK2, OK3, BAD1, BAD2, BAD3, OPEN, LOCKOUT} state_t;

  state_t     state_q, state_d;
  logic [1:0] fails_q, fails_d;    // wrong codes in a row
  logic [3:0] timer_q, timer_d;    // cycles left in OPEN or LOCKOUT
  logic       accept_d;            // the key of this cycle is taken
  logic       wrong;               // the fourth digit of a wrong code is being taken

  // Next-state logic: start from "stay", then let the present state's branch decide.
  always_comb begin
    state_d  = state_q;
    fails_d  = fails_q;
    timer_d  = timer_q;
    accept_d = 1'b0;
    wrong    = 1'b0;
    unique case (state_q)
      IDLE: if (key_valid_i) begin
        accept_d = 1'b1;
        if (key_i == Code[15:12]) state_d = OK1;
        else                      state_d = BAD1;
      end
      OK1: if (key_valid_i) begin
        accept_d = 1'b1;
        if (key_i == Code[11:8]) state_d = OK2;
        else                     state_d = BAD2;
      end
      OK2: if (key_valid_i) begin
        accept_d = 1'b1;
        if (key_i == Code[7:4]) state_d = OK3;
        else                    state_d = BAD3;
      end
      OK3: if (key_valid_i) begin
        accept_d = 1'b1;
        if (key_i == Code[3:0]) begin
          state_d = OPEN;
          fails_d = '0;
          timer_d = OpenTicks - 4'd1;
        end else begin
          wrong = 1'b1;
        end
      end
      BAD1: if (key_valid_i) begin
        accept_d = 1'b1;
        state_d  = BAD2;
      end
      BAD2: if (key_valid_i) begin
        accept_d = 1'b1;
        state_d  = BAD3;
      end
      BAD3: if (key_valid_i) begin
        accept_d = 1'b1;
        wrong    = 1'b1;
      end
      OPEN, LOCKOUT: begin         // keys are ignored until the timer runs out
        if (timer_q == '0) state_d = IDLE;
        else               timer_d = timer_q - 4'd1;
      end
`ifndef NO_RECOVERY
      default: state_d = IDLE;     // codes 9 to 15 name no state: back to IDLE
`endif
    endcase
    if (wrong) begin
      if (fails_q == 2'd2) begin   // the third wrong code in a row
        state_d = LOCKOUT;
        fails_d = '0;
        timer_d = LockTicks - 4'd1;
      end else begin
        state_d = IDLE;
        fails_d = fails_q + 2'd1;
      end
    end
  end

  // The registers: reset values, then the values the next-state logic chose.
  always_ff @(posedge clk_i or negedge rst_ni) begin
    if (!rst_ni) begin
      state_q <= IDLE;
      fails_q <= '0;
      timer_q <= '0;
    end else begin
      state_q <= state_d;
      fails_q <= fails_d;
      timer_q <= timer_d;
    end
  end

  // Output logic: the Moore outputs decoded from the state and the Mealy output passed on, or, with
  // RegisteredOutputs, all three loaded into flip-flops from the next-state logic's values.
  if (!RegisteredOutputs) begin : g_decoded
    assign unlock_o  = (state_q == OPEN);
    assign lockout_o = (state_q == LOCKOUT);
    assign accept_o  = accept_d;
  end else begin : g_registered
    logic unlock_q, lockout_q, accept_q;
    always_ff @(posedge clk_i or negedge rst_ni) begin
      if (!rst_ni) begin
        unlock_q  <= 1'b0;
        lockout_q <= 1'b0;
        accept_q  <= 1'b0;
      end else begin
        unlock_q  <= (state_d == OPEN);     // from the next state: no cycle lost
        lockout_q <= (state_d == LOCKOUT);
        accept_q  <= accept_d;              // a Mealy output: one cycle later
      end
    end
    assign unlock_o  = unlock_q;
    assign lockout_o = lockout_q;
    assign accept_o  = accept_q;
  end
endmodule

Commands · hdl/lock_coverage_session.py

from session import run

# Every kind of coverage on the lock of the first lesson of this module, keyed with its right code.
run("""
verilator --binary -j 0 --quiet --coverage --top-module tb_moore_mealy tb_moore_mealy.sv door_lock.sv
obj_dir/Vtb_moore_mealy +verilator+quiet +verilator+coverage+file+lock.dat > lock.log
verilator_coverage lock.dat
""")

Output

# Verilator 5.052
$ verilator --binary -j 0 --quiet --coverage --top-module tb_moore_mealy tb_moore_mealy.sv door_lock.sv
$ obj_dir/Vtb_moore_mealy +verilator+quiet +verilator+coverage+file+lock.dat > lock.log
$ verilator_coverage lock.dat
Coverage Summary:
  line      : 80.0% (16/20)
  toggle    : 71.4% (60/84)
  branch    : 75.0% (24/32)
  expr      : 100.0% ( 4/ 4)
  fsm_state : 0.0% ( 0/ 0)
  fsm_arc   : 0.0% ( 0/ 0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lock_coverage_session.py

Lines scored 80 %, toggles 71.4 %, branches 75 % and expressions 4 of 4, and fsm_state and fsm_arc 0 out of 0: Verilator found no state machine in the lock at all. The documentation calls the extraction intentionally narrow, and the lock is far from the washer’s plain form: a wrong code’s next state is set after the case, through the flag wrong, its branches nest, and its reset is active low. A percentage is only as good as its denominator.

The forms Verilator 5.052 read

wash_ctrl.sv keeps to forms this release read: an active-high reset, if (rst_i) state_q <= IDLE; else state_q <= state_d;, a case on state_q, and every next-state choice a single assignment, an if with an else, or ?:. The metacomment /*verilator fsm_arc_include_cond*/ on state_q keeps arcs the extractor would otherwise skip, here the arc of the default branch. Check every report’s totals against your own diagram.

The washer’s three tests run on one build with --coverage-fsm: one whole wash, a start with the door open, and a power-up with no reset whose random start values come from seed 1. After the first test the session also prints the annotated listing of the arcs, with the number of times each was taken:

The washer's state and arc coverage, test by test

hdl/tb_wash.sv

// Directed tests of wash_ctrl.sv, picked with +test=: "cycle" runs one whole wash with the door closed and opens the
// door two cycles after the end; "fault" presses start with the door open, then clears the fault; "powerup" runs with
// no reset at all, so the state register keeps whatever it powered up with (random under Verilator's
// +verilator+rand+reset+2), and shows it at the start and one edge later. The testbench models what the controller
// drives: the drum fills by one level a cycle while the valve is open and empties while the pump runs, and a timer
// that restarts whenever the outputs change gives done_i after 4 cycles of the motor.
module tb_wash;
  logic       clk = 1'b0, rst, start = 1'b0, door_closed = 1'b1, clear = 1'b0;
  logic       lock, valve, motor, pump, fault, full, empty, done;
  logic [2:0] outs, outs_q = '0;
  int         level = 0, run_for = 0;

  wash_ctrl u_wash (.clk_i(clk), .rst_i(rst), .start_i(start), .door_closed_i(door_closed), .full_i(full),
                    .empty_i(empty), .done_i(done), .clear_i(clear), .lock_o(lock), .valve_o(valve),
                    .motor_o(motor), .pump_o(pump), .fault_o(fault));

  always #5 clk = ~clk;
  assign full  = (level == 4);
  assign empty = (level == 0);
  assign outs  = {valve, motor, pump};
  assign done  = motor && outs == outs_q && run_for >= 3;
  always @(posedge clk) begin
    if (valve && level < 4) level <= level + 1;
    if (pump && level > 0) level <= level - 1;
    run_for <= (outs == outs_q) ? run_for + 1 : 1;
    outs_q  <= outs;
  end

  task automatic press_start();
    @(negedge clk) start = 1'b1;
    @(negedge clk) start = 1'b0;
  endtask

  task automatic press_clear();
    @(negedge clk) clear = 1'b1;
    @(negedge clk) clear = 1'b0;
  endtask

  initial begin
    string test;
    if (!$value$plusargs("test=%s", test)) test = "cycle";
    rst = (test != "powerup");
    if (test == "powerup") begin               // no reset: the register keeps what it powered up with
      $display("powerup: the state register starts at code %0d", u_wash.state_q);
      @(negedge clk);
      $display("powerup: one edge later, code %0d", u_wash.state_q);
      $finish;
    end
    @(negedge clk) rst = 1'b0;
    if (test == "cycle") begin
      press_start();
      wait (lock);
      wait (!lock);                            // the cycle is over: DONE unlocks the door
      repeat (2) @(negedge clk);
      door_closed = 1'b0;
      @(negedge clk);
      $display("cycle: washed, then the door opened; locked %b", lock);
    end else if (test == "fault") begin
      door_closed = 1'b0;
      press_start();
      @(negedge clk);
      $display("fault: started with the door open, fault %b", fault);
      press_clear();
      $display("fault: cleared, fault %b", fault);
    end
    $finish;
  end
endmodule

Commands · hdl/coverage_session.py

from session import run

# FSM coverage of the washer on Verilator: its three tests, merged one by one. The annotation lists the arcs, with how
# often the first test took each.
run("""
verilator --binary -j 0 --quiet --coverage-fsm --top-module tb_wash tb_wash.sv wash_ctrl.sv
obj_dir/Vtb_wash +verilator+quiet +test=cycle +verilator+coverage+file+cycle.dat
verilator_coverage cycle.dat
verilator_coverage --annotate ann cycle.dat > ann.log
grep -E "fsm_arc|DEFAULT" ann/wash_ctrl.sv | grep -v -E "reset|include_cond"
obj_dir/Vtb_wash +verilator+quiet +test=fault +verilator+coverage+file+fault.dat
verilator_coverage cycle.dat fault.dat
obj_dir/Vtb_wash +verilator+quiet +test=powerup +verilator+rand+reset+2 +verilator+seed+1 +verilator+error+limit+10 +verilator+coverage+file+powerup.dat
verilator_coverage cycle.dat fault.dat powerup.dat
""")

Output

# Verilator 5.052
$ verilator --binary -j 0 --quiet --coverage-fsm --top-module tb_wash tb_wash.sv wash_ctrl.sv
$ obj_dir/Vtb_wash +verilator+quiet +test=cycle +verilator+coverage+file+cycle.dat
cycle: washed, then the door opened; locked 0
- tb_wash.sv:66: Verilog $finish
$ verilator_coverage cycle.dat
Coverage Summary:
  line      : 0.0% ( 0/ 0)
  toggle    : 0.0% ( 0/ 0)
  branch    : 0.0% ( 0/ 0)
  expr      : 0.0% ( 0/ 0)
  fsm_state : 88.9% ( 8/ 9)
  fsm_arc   : 80.0% (16/20)
$ verilator_coverage --annotate ann cycle.dat > ann.log
$ grep -E "fsm_arc|DEFAULT" ann/wash_ctrl.sv | grep -v -E "reset|include_cond"
%000000        // [fsm_arc tb_wash.u_wash.state_q::CHECK->FAULT]
%000001        // [fsm_arc tb_wash.u_wash.state_q::CHECK->FILL]
%000001        // [fsm_arc tb_wash.u_wash.state_q::DONE->DONE]
%000001        // [fsm_arc tb_wash.u_wash.state_q::DONE->IDLE]
%000004        // [fsm_arc tb_wash.u_wash.state_q::DRAIN->DRAIN]
%000001        // [fsm_arc tb_wash.u_wash.state_q::DRAIN->RINSE]
%000000        // [fsm_arc tb_wash.u_wash.state_q::FAULT->FAULT]
%000000        // [fsm_arc tb_wash.u_wash.state_q::FAULT->IDLE]
%000004        // [fsm_arc tb_wash.u_wash.state_q::FILL->FILL]
%000001        // [fsm_arc tb_wash.u_wash.state_q::FILL->WASH]
%000001        // [fsm_arc tb_wash.u_wash.state_q::IDLE->CHECK]
%000003        // [fsm_arc tb_wash.u_wash.state_q::IDLE->IDLE]
%000003        // [fsm_arc tb_wash.u_wash.state_q::RINSE->RINSE]
%000001        // [fsm_arc tb_wash.u_wash.state_q::RINSE->SPIN]
%000001        // [fsm_arc tb_wash.u_wash.state_q::SPIN->DONE]
%000003        // [fsm_arc tb_wash.u_wash.state_q::SPIN->SPIN]
%000001        // [fsm_arc tb_wash.u_wash.state_q::WASH->DRAIN]
%000003        // [fsm_arc tb_wash.u_wash.state_q::WASH->WASH]
%000000        // [SYNTHETIC DEFAULT ARC: tb_wash.u_wash.state_q::default->IDLE]
$ obj_dir/Vtb_wash +verilator+quiet +test=fault +verilator+coverage+file+fault.dat
fault: started with the door open, fault 1
fault: cleared, fault 0
- tb_wash.sv:66: Verilog $finish
$ verilator_coverage cycle.dat fault.dat
Coverage Summary:
  line      : 0.0% ( 0/ 0)
  toggle    : 0.0% ( 0/ 0)
  branch    : 0.0% ( 0/ 0)
  expr      : 0.0% ( 0/ 0)
  fsm_state : 100.0% ( 9/ 9)
  fsm_arc   : 95.0% (19/20)
$ obj_dir/Vtb_wash +verilator+quiet +test=powerup +verilator+rand+reset+2 +verilator+seed+1 +verilator+error+limit+10 +verilator+coverage+file+powerup.dat
powerup: the state register starts at code 12
[5] %Error: wash_ctrl.sv:60: Assertion failed in tb_wash.u_wash.a_known: state code 12 names no state
-Info: wash_ctrl.sv:60: Verilog $stop, ignored due to +verilator+error+limit
powerup: one edge later, code 0
- tb_wash.sv:47: Verilog $finish
$ verilator_coverage cycle.dat fault.dat powerup.dat
Coverage Summary:
  line      : 0.0% ( 0/ 0)
  toggle    : 0.0% ( 0/ 0)
  branch    : 0.0% ( 0/ 0)
  expr      : 0.0% ( 0/ 0)
  fsm_state : 100.0% ( 9/ 9)
  fsm_arc   : 100.0% (20/20)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 coverage_session.py

The whole wash visited 8 of the 9 states and took 16 of 20 arcs. The 20 are the 18 arcs of the machine’s choices, its reset arc and the default branch’s arc; in the listing, the four counts still at 0 were CHECK to FAULT, FAULT staying, FAULT to IDLE and the default arc. The fault test brought the merged count to 9 of 9 states and 19 of 20 arcs. No legal input reaches the last one. The power-up did: the register started at code 12, a_known reported it at the first edge, and the default branch took it to IDLE at that edge, for 20 of 20.

Interactive · step by step What each test covered of the washer's states and arcs Step through the three tests; covered states and arcs take the done mark, with the summary after each test.
What each test covered of the washer's states and arcs, step 5 of 5. Showing RINSE, DRAIN, SPIN, WASH, DONE, FILL, IDLE (9/9 states, 20/20 arcs), CHECK, FAULT. In focus: IDLE.RINSEDRAINSPINWASHDONEFILLIDLE9/9 states, 20/20 arcsCHECKFAULT

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Nothing yet. Verilator counts 9 states and 20 arcs for this machine, the 18 of its choices, the reset arc and the arc of the default branch. No test has run.
  2. The cycle test. One whole wash, door closed, then the door opened. It visits 8 of the 9 states and takes 16 of the 20 arcs, the reset arc, the 8 round the cycle and 7 that stay.
  3. What it missed. The annotated listing names the four arcs the cycle never took. Three are the fault's, CHECK to FAULT, FAULT staying and FAULT to IDLE, and the fourth is the default branch's.
  4. The fault test. Start with the door open, then clear the fault. Merged with the first test, that is 9 of 9 states and 19 of 20 arcs.
  5. The default arc. No legal input reaches the default branch; only a code that names no state does. A power-up without a reset, random start values from seed 1, started the register at code 12 and took it.

Key idea

Read a coverage report’s totals before its percentages: the lock scored 0 of 0 arcs, a machine the tool never found, and the washer’s last arc needed a code that no legal test can produce.

Checkpoint

Check: State and arc coverage

  1. Question 1 of 2 The lock's report gave fsm_state and fsm_arc 0 out of 0. What does that say about its tests?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing; Verilator found no state machine in the lock, so it had nothing to count

    Read the totals before the percentage. Zero states found means the tool saw no machine, so the report says nothing about the tests.

  2. Question 2 of 2 The cycle and fault tests together took 19 of the washer's 20 arcs. Which arc was left, and how did the session take it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The default branch's arc; no legal input reaches it, so a power-up without a reset started the register at code 12

    The default branch is the one arc that only a code without a name can take. Reaching it needs a forced code or random start values, and the assertions then report it.

Practise: close the washer’s arc coverage

The exercise hands you the washer as a Python model and asks for the shortest input sequence that takes every one of its 18 arcs from reset, in 19 cycles or fewer. Its sample tests replay your sequence on their own copy of the machine and list every arc it never took.

Exercise · Medium · Python

Every arc of the washer in as few cycles as you can

Close the arc coverage of wash_ctrl.sv in as few clock cycles as you can. cover.py holds the machine as a model: CHOICE gives, for each state, the input it reads at a clock edge and where it goes when that input is 1 and when it is 0, and step(state, inputs) applies it. The machine has 9 states and 18 arcs: two out of every state, and for the eight states that wait, one of the two is the arc that stays.

Write cover_all(). It returns the inputs of each cycle after reset, as a list of dicts such as {"start": 1}, one per clock edge; an input a dict leaves out is 0. Starting from IDLE, the machine must take every one of the 18 arcs at least once, in 19 cycles or fewer. The sample tests replay your list on their own copy of the machine, list the arcs it never took, and count the cycles.

Starter code · cover.py

# The choice each state of wash_ctrl.sv makes at a clock edge: (the input it reads, the next state when that input is
# 1, the next state when it is 0).
CHOICE = {
    "IDLE": ("start", "CHECK", "IDLE"),
    "CHECK": ("door_closed", "FILL", "FAULT"),
    "FILL": ("full", "WASH", "FILL"),
    "WASH": ("done", "DRAIN", "WASH"),
    "DRAIN": ("empty", "RINSE", "DRAIN"),
    "RINSE": ("done", "SPIN", "RINSE"),
    "SPIN": ("done", "DONE", "SPIN"),
    "DONE": ("door_closed", "DONE", "IDLE"),
    "FAULT": ("clear", "IDLE", "FAULT"),
}


def step(state, inputs):
    """The next state, from the present state and one cycle's inputs (a dict; an input left out is 0)."""
    name, if_one, if_zero = CHOICE[state]
    return if_one if inputs.get(name, 0) else if_zero


def cover_all():
    """The inputs of each cycle after reset, one dict per cycle, so that the machine takes every arc."""
    return [{"start": 1}, {"door_closed": 1}, {"full": 1}]
The sample tests · test_cover.py
from cover import cover_all

# The machine again, so that the tests do not depend on the copy you may have changed.
CHOICE = {
    "IDLE": ("start", "CHECK", "IDLE"),
    "CHECK": ("door_closed", "FILL", "FAULT"),
    "FILL": ("full", "WASH", "FILL"),
    "WASH": ("done", "DRAIN", "WASH"),
    "DRAIN": ("empty", "RINSE", "DRAIN"),
    "RINSE": ("done", "SPIN", "RINSE"),
    "SPIN": ("done", "DONE", "SPIN"),
    "DONE": ("door_closed", "DONE", "IDLE"),
    "FAULT": ("clear", "IDLE", "FAULT"),
}
ARCS = {(s, nxt) for s, (_, a, b) in CHOICE.items() for nxt in (a, b)}
INPUTS = {"start", "door_closed", "full", "empty", "done", "clear"}


def walk(cycles):
    """The arcs the machine takes from IDLE, one per cycle."""
    state, taken = "IDLE", []
    for inputs in cycles:
        name, if_one, if_zero = CHOICE[state]
        nxt = if_one if inputs.get(name, 0) else if_zero
        taken.append((state, nxt))
        state = nxt
    return taken


def test_inputs_are_known():
    """gives one dict per cycle, naming only the six inputs, each 0 or 1"""
    cycles = cover_all()
    assert isinstance(cycles, list) and cycles
    for inputs in cycles:
        assert set(inputs) <= INPUTS
        assert all(value in (0, 1) for value in inputs.values())


def test_every_arc():
    """takes all 18 arcs, the 8 that stay in a state among them"""
    missing = ARCS - set(walk(cover_all()))
    assert not missing, f"arcs never taken: {sorted(missing)}"


def test_shortest():
    """does it in 19 cycles or fewer"""
    assert len(cover_all()) <= 19
A hint

Every cycle takes exactly one arc, so 18 cycles would be perfect. Count the arcs into CHECK and out of it: one way in, two ways out. How many times must the walk enter CHECK, and what does that cost?

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The self-check covers encodings, recovery in the netlist, assertions and coverage, starting with the idea you have answered least well:

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 A one-hot machine has 12 states. How many flip-flops does its state register have?

    Type a number.

    Show the answer: Question 1 of 12

    Answer: 12 flip-flops

    One flip-flop per state, exactly one of them set. The washer's 9 states took 9 flip-flops one-hot.

  2. Question 2 of 12 A binary machine has 12 states in the fewest flip-flops. How many codes of its register name no state?

    Type a number.

    A hint

    Find the fewest bits whose codes reach 12 first.

    Show the answer: Question 2 of 12

    Answer: 4 codes

    Four bits give 16 codes, the first width that reaches 12; 16 − 12 = 4 codes have no name, and the default branch has to cover them.

  3. Question 3 of 12 A 20-state controller on an iCE40 must run at 120 MHz, and its binary build misses that. What would you try first, and what must you then check?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: One-hot, which ran fastest here; then check that the netlist recovers from codes without a name, since Yosys's one-hot netlist did not

    One-hot shortens the next-state logic, which is what limits the clock, at the cost of flip-flops and of the recovery Yosys's re-encoding drops. Measure both.

  4. Question 4 of 12 What did the netlist Yosys re-encoded lose compared with its RTL?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The way back from codes without a name

    The netlist behaved like its RTL in every legal state, and differently in the one case a test of legal inputs never shows.

  5. Question 5 of 12 Yosys re-encodes a 6-state machine as one-hot. How many codes of its new register name no state?

    Type a number.

    A hint

    One-hot gives each state a bit.

    Show the answer: Question 5 of 12

    Answer: 58 codes

    Six bits give 64 codes, and only the 6 with exactly one bit set are states: 64 − 6 = 58 have no name, and the re-encoded logic was built for none of them.

  6. Question 6 of 12 In the binary washer a flip of bit 0 turns WASH, 0011, into FILL, 0010. What catches it?
    Show the answer: Question 6 of 12

    Answer: In simulation a_wash fails, since WASH must be followed by WASH or DRAIN; in the hardware nothing does, because FILL is a legal code

    A flip that lands on a legal code is invisible to recovery. Codes two flips apart make it detectable; codes three flips apart, as Vivado's auto_safe_state uses, make it correctable.

  7. Question 7 of 12 What does the property a |=> b, clocked on a rising edge, require?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Whenever a holds at a clock edge, b holds at the next one

    Each arc assertion of the washer reads "if this state now, then one of these states at the next edge".

  8. Question 8 of 12 Why does every assertion of wash_ctrl.sv have disable iff (rst_i)?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: A reset takes any state to IDLE, whatever the arcs say, so the checks are held off while rst_i is 1

    An assertion states what must hold in normal running. A reset is outside the diagram's arcs, so it switches the checks off while it lasts.

  9. Question 9 of 12 A designer writes each arc assertion by copying the matching case item of the RTL. What is wrong with that?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: A slip in the case item is copied into its assertion, so the two agree on the slip and nothing fails

    A check is worth as much as its independence. Write the arcs from the diagram, never from the code they check.

  10. Question 10 of 12 Verilator counted 20 arcs for the washer. How many of them are arcs of the machine's own choices?

    Type a number.

    Show the answer: Question 10 of 12

    Answer: 18 arcs

    Nine states with two arcs each give 18; the other two are the reset arc and the default branch's arc.

  11. Question 11 of 12 After the cycle test the summary read fsm_state 88.9 % (8/9). Which state was missing, and which test reaches it?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: FAULT, reached by pressing start with the door open

    The cycle test exercised every state on the happy path; only the fault path was left, and the fault test took it.

  12. Question 12 of 12 A team shows 100 % arc coverage of this washer from a regression of legal tests only. What do you ask first?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: How the default branch's arc was covered, since no legal input reaches it, and what the totals counted

    Legal tests can reach every arc of the diagram but never the default branch's. A full score from them means the totals left that arc out, or something else forced a code.

Interview questions

Warm-up (fresher): when would you encode a state machine one-hot rather than binary?

When flip-flops are cheap and speed matters, which is the usual case on an FPGA. A one-hot machine needs a flip-flop per state, 9 for this lesson’s washer against 4 in binary, but each next-state bit depends only on the few arcs into its own state, so it needs less logic, in fewer levels: on an iCE40 the washer took 14 look-up tables at 144.07 MHz one-hot, against 25 at 96.68 MHz in binary. Binary, or Gray for a machine that steps round a fixed cycle, suits designs where every flip-flop costs area, as on a chip, and machines with many states. One-hot has a price in safety too: 503 of its 512 codes name no state, so check that the netlist recovers from them.

The harder questions are about a one-hot machine that froze in the field, a coverage report of 0 out of 0, and the assertions you would add to a state machine. For each, settle on how you would start before you open the first step given:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An FPGA controller passes every simulation, yet a few boards in the field freeze now and then until they are power-cycled. The RTL of its state machine has a default branch that returns to IDLE. What do you suspect, how do you confirm it, and what do you change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A coverage report shows line and toggle coverage for a block, but fsm_state and fsm_arc read 0 out of 0. A colleague wants to add tests to raise them. What do you do instead, and how do you make the report count the state machine?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How did binary, Gray and one-hot encodings of the washer controller compare on an iCE40?

    Binary took 4 flip-flops, 25 LUTs and ran at 96.68 MHz; Gray 4, 21 and 100.14 MHz; one-hot, Yosys's own choice, 9 flip-flops, 14 LUTs and 144.07 MHz.

  2. Why does a one-hot machine need less logic than a binary one?

    Each one-hot bit's next value depends only on the few arcs into its own state, while each binary bit depends on every state bit and on the inputs of every state whose arcs change it; fewer inputs fit fewer 4-input LUTs.

  3. How many state bits did each encoding change in one wash?

    Gray changed one bit at every step, 8 in all; binary 14, up to 3 at once; one-hot two at every step, 16.

  4. How do you stop Yosys from re-encoding a state machine?

    Give the state register the attribute fsm_encoding "none". Otherwise fsm_detect marks it, and the rebuilt logic covers only the transitions of the codes the RTL assigns.

  5. Why did the one-hot netlist stay stopped after one flipped flip-flop?

    Yosys had given the states new codes, so the default branch written for the binary codes 9 to 15 had nothing to apply to, and a register with no bit set has no transition out. The netlist that kept its codes went back to IDLE.

  6. What does state_q == WASH |=> state_q inside {WASH, DRAIN} check?

    |=> means at the next clock edge: whenever the state is WASH at one edge, it must be WASH or DRAIN at the next.

  7. What do state coverage and arc coverage measure?

    The share of a machine's states the tests visited, and the share of its transitions they took, arcs that stay in a state included. Verilator 5.048 added both as --coverage-fsm.

  8. Why did the lock score 0 of 0 arcs in Verilator's FSM coverage?

    The extraction is intentionally narrow and found no state machine in the lock's form, so there was nothing to count. Read a report's totals before its percentages.

Key takeaways

  • The same washer took 25 look-up tables at 96.68 MHz in binary, 21 at 100.14 MHz in Gray and 14 at 144.07 MHz one-hot, at the cost of 9 flip-flops against 4, and Gray changed 8 bits in a wash against 14 and 16.
  • Yosys re-encodes a machine it recognises unless its fsm_encoding is “none”: its one-hot netlist stayed stopped after a flip that left no bit set, while the RTL and the netlist that kept its codes went back to IDLE at the next edge.
  • Assertions write the diagram into the code: a_known and a_wash reported the upset at the first edge after it, where the default branch only recovered quietly, and synthesis leaves them out.
  • A coverage report needs a denominator you trust: the lock scored 0 of 0, a machine the tool never found, while the washer went from 16 to 19 to 20 of 20 arcs, the last reached only by a code without a name.

Terms in this lesson

arc coverage
The share of a state machine's transitions, the arcs that stay in a state included, that a set of tests took. Also called transition coverage.
Gray code
A sequence of codes in which each code differs from the next in exactly one bit, such as 00, 01, 11, 10. A state machine whose cycle follows one changes a single flip-flop at each step of the cycle.
safe state machine
A state machine whose netlist detects every register code that names no state and moves to a known state at the next clock edge, so that an upset or a power-up without a reset cannot leave it stuck.
state coverage
The share of a state machine's states that a set of tests visited, given as a count and a percentage. A total of 0 states means the tool found no machine to count.
state encoding
The codes a state machine's register uses for its states, such as binary (0 to 8 in 4 bits), Gray (one bit changes at each step of a cycle) or one-hot (one flip-flop per state). It sets the machine's size, speed and power.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.