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 3 – Synthesisable RTL style and building blocks

RTL coding style that scales

Name registers _q and _d, compute next values in always_comb apart from always_ff, catch loops and second writers, and end with a done check you can run.

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

What you will learn

  • Apply a consistent naming, file and reset convention to RTL
  • Separate next-state logic from state registers
  • Write lint-clean, parameter-checked modules
  • Check an RTL change against a definition-of-done list that a script can run

Before you start

What you’ll learn · Before you start

What you will learn

  • Apply a consistent naming, file and reset convention to RTL
  • Separate next-state logic from state registers
  • Write lint-clean, parameter-checked modules
  • Check an RTL change against a definition-of-done list that a script can run

Before you start

On this page

Two versions of one reloading timer agree on all 506 cycles of their testbench and synthesise to 42 and 43 cells, so a chip would not care which one it got. Verilator’s lint reports 4 warnings for the first and none for the second, and only the second shows at a glance which signals are registers and where each one gets its next value. RTL is read far more often than it is written, and style is what keeps it readable and checkable as a design and its team grow.

You will apply one naming and file convention, split every block into next-value logic and registers, catch combinational loops and second writers before they reach a chip, and finish each change with a definition of done that a script can run. The conventions are this track’s own, informed by lowRISC’s public style guide; the checks use Verilator 5.052, Yosys 0.69 and Icarus Verilog 13.0.

Names and files that say what each signal is

timer_before.sv is the first version. Its ports are clk, rstn, en, load, reload, count and expired, the module is called tmr in a file called timer_before.sv, and a signal named spare is declared and never used. No name says which signals are registers or which are ports, and only the n of rstn hints that the reset is active low. Lint sees two of those problems:

The first timer and the refactored one, through Verilator's lint

hdl/timer_before.sv

// A reloading down-counter, written the way many first versions are written.
module tmr(clk, rstn, en, load, reload, count, expired);
  input clk, rstn, en, load;
  input [7:0] reload;
  output [7:0] count;
  output expired;
  reg [7:0] count;
  reg expired;
  reg zero;
  reg [3:0] spare;

  always @(count) zero = (count == 0);

  always @(posedge clk or negedge rstn)
    if (!rstn) begin
      count = 0;
      expired = 0;
    end else begin
      case ({load, en})
        2'b10, 2'b11: begin count <= reload; expired <= 0; end
        2'b01: if (zero) begin count <= reload; expired <= 1; end
               else begin count <= count - 1; expired <= 0; end
        2'b00: expired <= 0;
      endcase
    end
endmodule

hdl/timer.sv

// A reloading down-counter: counts down from reload_i while en_i is 1 and pulses expired_o for one cycle when it
// reloads from 0. load_i loads reload_i at once and wins over en_i.
module timer #(
  parameter int unsigned Width = 8
) (
  input  logic             clk_i,
  input  logic             rst_ni,
  input  logic             en_i,
  input  logic             load_i,
  input  logic [Width-1:0] reload_i,
  output logic [Width-1:0] count_o,
  output logic             expired_o
);
  if (Width < 2) begin : g_width_check
    $error("timer: Width must be at least 2");
  end

  logic [Width-1:0] count_d, count_q;
  logic             expired_d, expired_q;

  // Next values: computed from the registers and the inputs, nothing else.
  always_comb begin
    count_d   = count_q;
    expired_d = 1'b0;
    if (load_i) begin
      count_d = reload_i;
    end else if (en_i) begin
      if (count_q == '0) begin
        count_d   = reload_i;
        expired_d = 1'b1;
      end else begin
        count_d = count_q - 1'b1;
      end
    end
  end

  // Registers: reset values, then the next values.
  always_ff @(posedge clk_i or negedge rst_ni) begin
    if (!rst_ni) begin
      count_q   <= '0;
      expired_q <= 1'b0;
    end else begin
      count_q   <= count_d;
      expired_q <= expired_d;
    end
  end

  assign count_o   = count_q;
  assign expired_o = expired_q;
endmodule

Commands · hdl/style_session.py

from session import run

# Verilator lint of both timers: the first version (module tmr in timer_before.sv), then the refactored timer.sv.
# grep keeps the first line of each message.
run("""
verilator --lint-only -Wall --quiet timer_before.sv 2>&1 | grep "^%"
verilator --lint-only -Wall --quiet timer.sv
""")

Output

# Verilator 5.052
$ verilator --lint-only -Wall --quiet timer_before.sv 2>&1 | grep "^%"
%Warning-DECLFILENAME: timer_before.sv:2:8: Filename 'timer_before' does not match MODULE name: 'tmr'
%Warning-BLKSEQ: timer_before.sv:16:13: Blocking assignment '=' in sequential logic process
%Warning-BLKSEQ: timer_before.sv:17:15: Blocking assignment '=' in sequential logic process
%Warning-UNUSEDSIGNAL: timer_before.sv:10:13: Signal is not driven, nor used: 'spare'
$ verilator --lint-only -Wall --quiet timer.sv

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

DECLFILENAME and UNUSEDSIGNAL are style warnings that -Wall turns on: the module’s name does not match its file, and spare does nothing. The two BLKSEQ warnings come from the reset branch, which the next section deals with. timer.sv produces no message at all, because it follows a convention that answers the questions a reader asks of a name:

Rule In timer.sv What it tells the reader
_q on a register, _d on the value it loads next count_q, count_d which signals are flip-flops
_n on an active-low signal, _i and _o on ports rst_ni, en_i, count_o the polarity and the direction
one module per file, named after it timer in timer.sv where to find any module
logic for every signal, widths from parameters logic [Width-1:0] count_d one type, widths that follow Width
parameters in UpperCamelCase Width a constant, not a signal
only always_comb and always_ff both blocks what each block is, checked by the tools

lowRISC’s guide uses the same suffixes and combines them in the same order, the polarity before the direction, as in rst_ni. Which public guide a team follows matters less than everybody writing it the same way, because the payoff is that nobody has to open a module to know what a name means.

Checkpoint

Check: Names and files that say what a signal is

  1. Question 1 of 2 Following this track's convention, what is rst_ni?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An active-low input, the reset

    The suffixes put the polarity and the direction in the name: rst_ni is the active-low reset input, count_o an output, count_q a register and count_d its next value.

  2. Question 2 of 2 Which of the four lint warnings of timer_before.sv come from its names and files? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • DECLFILENAME, the module tmr in a file called timer_before.sv
    • UNUSEDSIGNAL, the declared and unused spare

    Two warnings came from names and files and two from the clocked block's blocking assignments. timer.sv fixed all four without changing what the timer does.

Compute next values in one block, load them in another

The first version computes everything inside one clocked block, the case on {load, en}, the count down and the reload, and its reset branch uses blocking assignments, which Verilator flagged as BLKSEQ. timer.sv splits the same behaviour into two blocks, the two-process style: Writing a block as one always_comb that computes each register's next value, the _d signals, from the registers and the inputs, and one always_ff that loads those values into the registers, the _q signals, at the clock edge.. The always_comb block computes count_d and expired_d from the registers and the inputs, with defaults first, so that every path gives both a value. The always_ff block only loads: reset values while rst_ni is 0, the next values otherwise. Step through one reload of the testbench’s directed start:

Interactive · step by step One reload of timer.sv, block by block Step through the clock edges of the testbench's start and watch always_comb prepare each value that always_ff loads.
One reload of timer.sv, block by block, step 6 of 6. Showing en_i, load_i, reload_i, clk_i rising edge, always_comb (count_d 2, expired_d 0), always_ff (count_q 2, expired_q 0), count_o, expired_o (count 2, expired 0). In focus: always_comb, always_ff._d values_q valuesen_i, load_i, reload_iclk_i rising edgealways_combcount_d 2, expired_d 0always_ffcount_q 2, expired_q 0count_o, expired_ocount 2, expired 0

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

Every step as text
  1. After the load. The first edge loaded reload_i, so count_q is 3 and expired_q is 0. en_i is 1 now, so always_comb starts from its defaults, count_d = count_q and expired_d = 0, and takes the count-down branch.
  2. The edge loads 2. At the rising edge always_ff loads count_d into count_q and nothing else. The new count_q, 2, reaches always_comb, which computes the next value, 1.
  3. Down to 1, then 0. Two more edges load 1 and then 0. Each time always_comb works from the value just loaded and the inputs; it never reads anything else, so its result depends on nothing but them.
  4. The reload is prepared. With count_q at 0 and en_i at 1, always_comb takes the reload branch, count_d = reload_i = 3, and sets expired_d to 1. Both values wait at the inputs of the registers until the next edge.
  5. The pulse. At the edge always_ff loads both, so count_q is 3 and expired_q is 1, and the outputs show the pulse. They come straight from registers, so they change only at the edge.
  6. One cycle only. The default expired_d = 0 is back in force, since count_q is no longer 0, and always_comb computes count_d = 2. At the next edge expired_q falls to 0, the one-cycle pulse the testbench checked.

The testbench drives both versions with the same inputs, checks them against a model of the timer for 506 cycles, and then each version is synthesised:

Both timers against one model, then their netlists

hdl/tb_timer.sv

// Drives the refactored timer and the first version (module tmr) with the same inputs, and checks both against a
// model of what the timer must do: a directed start, then 500 cycles of seeded random inputs.
module tb_timer;
  logic       clk = 0, rst_n = 0, en = 0, load = 0;
  logic [7:0] reload = 0;
  logic [7:0] count_new, count_old, count_want = 0;
  logic       expired_new, expired_old, expired_want = 0;
  int         errors = 0, seed = 1;

  timer dut (.clk_i(clk), .rst_ni(rst_n), .en_i(en), .load_i(load), .reload_i(reload),
             .count_o(count_new), .expired_o(expired_new));
  tmr   old (.clk(clk), .rstn(rst_n), .en(en), .load(load), .reload(reload),
             .count(count_old), .expired(expired_old));

  always #5 clk = ~clk;

  // The model: what the timer must load at each rising edge.
  always @(posedge clk) begin
    if (!rst_n)          begin count_want <= 0;          expired_want <= 0; end
    else if (load)       begin count_want <= reload;     expired_want <= 0; end
    else if (en && count_want == 0) begin count_want <= reload; expired_want <= 1; end
    else if (en)         begin count_want <= count_want - 1; expired_want <= 0; end
    else                 expired_want <= 0;
  end

  task automatic step(input logic e, input logic l, input logic [7:0] r, input bit show);
    en = e; load = l; reload = r;
    @(posedge clk); #1;
    if (count_new !== count_want || expired_new !== expired_want) errors++;
    if (count_old !== count_want || expired_old !== expired_want) errors++;
    if (show) $display("en=%b load=%b reload=%0d | timer %0d %b | tmr %0d %b", e, l, r,
                       count_new, expired_new, count_old, expired_old);
  endtask

  initial begin
    @(posedge clk); #1 rst_n = 1;
    step(0, 1, 3, 1);                       // load 3
    repeat (4) step(1, 0, 3, 1);            // 2, 1, 0, then reload to 3 with expired
    step(1, 0, 3, 1);                       // 2 again, expired back to 0
    repeat (500) step($random(seed) % 4 != 0, $random(seed) % 8 == 0, 8'($random(seed) % 6), 0);
    $display("%0d cycles checked, %0d mismatches with the model", 506, errors);
    if (errors == 0) $display("PASS"); else $fatal(1, "FAIL");
    $finish;
  end
endmodule

Commands · hdl/cycle_session.py

from session import run

# Both timers on one testbench, checked against its model, then the hardware each one synthesises to.
run("""
iverilog -g2012 -Wall -o sim.vvp tb_timer.sv timer.sv timer_before.sv
vvp -n sim.vvp
yosys -q -p "read_verilog -sv timer.sv; synth -top timer; tee -q -o timer.txt stat"
yosys -q -p "read_verilog -sv timer_before.sv; synth -top tmr; tee -q -o tmr.txt stat"
grep -H -E "cells|DFF" timer.txt tmr.txt
""")

Output

# Icarus Verilog 13.0 (stable) · Yosys 0.69
$ iverilog -g2012 -Wall -o sim.vvp tb_timer.sv timer.sv timer_before.sv
$ vvp -n sim.vvp
en=0 load=1 reload=3 | timer 3 0 | tmr 3 0
en=1 load=0 reload=3 | timer 2 0 | tmr 2 0
en=1 load=0 reload=3 | timer 1 0 | tmr 1 0
en=1 load=0 reload=3 | timer 0 0 | tmr 0 0
en=1 load=0 reload=3 | timer 3 1 | tmr 3 1
en=1 load=0 reload=3 | timer 2 0 | tmr 2 0
506 cycles checked, 0 mismatches with the model
PASS
tb_timer.sv:43: $finish called at 5066 (1s)
$ yosys -q -p "read_verilog -sv timer.sv; synth -top timer; tee -q -o timer.txt stat"
$ yosys -q -p "read_verilog -sv timer_before.sv; synth -top tmr; tee -q -o tmr.txt stat"
$ grep -H -E "cells|DFF" timer.txt tmr.txt
timer.txt:       42 cells
timer.txt:        8   $_DFFE_PN0P_
timer.txt:        1   $_DFF_PN0_
tmr.txt:       43 cells
tmr.txt:        8   $_DFFE_PN0P_
tmr.txt:        1   $_DFF_PN0_

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

The two agree on every cycle, the reload with its one-cycle pulse included, and synthesise to 42 and 43 cells with the same nine flip-flop: A storage element that takes the value of its input at a clock edge and holds it until the next edge. A register is a row of flip-flops clocked together. More in “Sequential logic, always_ff and nonblocking”: the refactor changed the code and not the circuit. What it bought is a shape in which the next mistakes are hard to write. Every register sits in one always_ff block with one reset value, so a register without a reset stands out. The defaults at the top of always_comb mean no path can leave a value unassigned, so no latch can appear. And always_ff computes nothing, so no statement in it can read a value that another statement of the same block has just changed.

Key idea

Compute every next value in always_comb from the registers and the inputs, defaults first, and let always_ff only load them: the timer kept its behaviour and its nine flip-flops and lost its warnings.

The habit this shape removes most often is the one Verilator flagged in the first version’s reset branch:

Blocking assignments in a clocked block

Wrong: count = 0; in the reset branch, as in timer_before.sv. A blocking assignment changes the variable at once, so any statement after it, or another block woken by the same edge, may read the new value: the race of the scheduler lesson. Right: count_q <= '0;: in an always_ff block every assignment is nonblocking, the reset branch included.

Checkpoint

Check: Next values apart from registers

  1. Question 1 of 2 In timer.sv, what does the always_comb block read when it computes count_d?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The registers and the inputs, count_q, en_i, load_i and reload_i, and nothing else

    always_comb starts from count_d = count_q and expired_d = 0, then takes the load, reload or count-down branch. always_ff loads its results at the edge.

  2. Question 2 of 2 The refactored timer synthesised to 42 cells and the first version to 43. How many flip-flops did each have?

    Type a number.

    A hint

    Count the bits the timer remembers between edges.

    Show the answer: Question 2 of 2

    Answer: 9 flip-flops

    Both had 8 $_DFFE_PN0P_ cells for the count and 1 $_DFF_PN0_ for expired: the same registers, so the refactor changed the code and not the circuit.

Loops and second writers: what lint and synthesis catch

Two mistakes that the two-process style keeps out are easy to write without it. loop_demo.sv computes p from q and q from p through two gates with no register between them, a combinational loop: A path from a signal back to itself through gates alone, with no register on it, such as p = a & q with q = b | p. It can hold a value or oscillate, so lint and synthesis checks report it.. two_writers.sv writes count from an always_ff block and also, while clr is 1, from an always_comb block. The session simulates the loop first, then runs both files through the three tools:

A loop and a variable with two writers, on three tools

hdl/loop_demo.sv

// p needs q and q needs p: a loop through two gates with no register in it.
module loop_demo (
  input  logic a,
  input  logic b,
  output logic y
);
  logic p, q;
  assign p = a & q;
  assign q = b | p;
  assign y = q;
endmodule

hdl/tb_loop.sv

// What the loop of loop_demo.sv does when b falls while a is 1.
module tb_loop;
  logic a = 1, b = 1, y;
  loop_demo dut (.a(a), .b(b), .y(y));
  initial begin
    #1 $display("a=%b b=%b: y=%b", a, b, y);
    b = 0;
    #1 $display("a=%b b=%b: y=%b", a, b, y);
    a = 0;
    #1 $display("a=%b b=%b: y=%b", a, b, y);
    a = 1;
    #1 $display("a=%b b=%b: y=%b", a, b, y);
    $finish;
  end
endmodule

hdl/two_writers.sv

// count is written by a clocked block and by a combinational block.
module two_writers (
  input  logic       clk,
  input  logic       clr,
  output logic [3:0] count
);
  always_ff @(posedge clk) count <= count + 1'b1;
  always_comb if (clr) count = '0;
endmodule

Commands · hdl/hazards_session.py

from session import run

# A loop through two gates, simulated and then checked, and a variable written by two blocks, on all three tools.
# Icarus Verilog compiles both files without a message.
run("""
iverilog -g2012 -Wall -o loop.vvp tb_loop.sv loop_demo.sv
vvp -n loop.vvp
verilator --lint-only -Wall --quiet loop_demo.sv 2>&1 | grep "^%"
!yosys -q -p "read_verilog -sv loop_demo.sv; proc; check -assert"
iverilog -g2012 -Wall -o writers.vvp two_writers.sv
verilator --lint-only -Wall --quiet two_writers.sv 2>&1 | grep "^%"
!yosys -q -p "read_verilog -sv two_writers.sv; proc; check -assert"
""")

Output

# Icarus Verilog 13.0 (stable) · Verilator 5.052 · Yosys 0.69
$ iverilog -g2012 -Wall -o loop.vvp tb_loop.sv loop_demo.sv
$ vvp -n loop.vvp
a=1 b=1: y=1
a=1 b=0: y=1
a=0 b=0: y=0
a=1 b=0: y=0
tb_loop.sv:13: $finish called at 4 (1s)
$ verilator --lint-only -Wall --quiet loop_demo.sv 2>&1 | grep "^%"
%Warning-UNOPTFLAT: loop_demo.sv:5:16: Signal unoptimizable: Circular combinational logic: 'y'
$ yosys -q -p "read_verilog -sv loop_demo.sv; proc; check -assert"
Warning: found logic loop in module loop_demo:
    cell $or$loop_demo.sv:9$2 ($or) source: loop_demo.sv:9.14-9.19
      B[0] --> Y[0]
    cell $and$loop_demo.sv:8$1 ($and) source: loop_demo.sv:8.14-8.19
      B[0] --> Y[0]
ERROR: Found 1 problems in 'check -assert'.
[exit status 1]
$ iverilog -g2012 -Wall -o writers.vvp two_writers.sv
$ verilator --lint-only -Wall --quiet two_writers.sv 2>&1 | grep "^%"
%Warning-MULTIDRIVEN: two_writers.sv:8:24: Variable written to in always_comb also written by other process (IEEE 1800-2023 9.2.2.2): 'count'
%Warning-MULTIDRIVEN: two_writers.sv:8:24: Variable also written to in always_ff (IEEE 1800-2023 9.2.2.4): 'count'
%Warning-LATCH: two_writers.sv:8:3: Latch inferred for signal 'count' (not all control paths of combinational always assign a value)
%Error-BLKANDNBLK: two_writers.sv:5:22: Unsupported: Blocking and non-blocking assignments to potentially overlapping bits of same packed variable: 'count'
$ yosys -q -p "read_verilog -sv two_writers.sv; proc; check -assert"
ERROR: Latch inferred for signal `\two_writers.\count' from always_comb process `\two_writers.$proc$two_writers.sv:8$3'.
[exit status 1]

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

The simulation shows why a loop is trouble. With a = 1 and b = 0 the output was 1 on one line and 0 on another: the loop holds whatever it had, which is storage that nobody declared and no testbench expects. Here is what each tool said:

Construct Icarus Verilog -Wall Verilator lint -Wall Yosys proc and check
a loop through two gates compiled, no message UNOPTFLAT: circular combinational logic “found logic loop”, check failed
count written by always_ff and always_comb compiled, no message MULTIDRIVEN twice, LATCH, a BLKANDNBLK error stopped: latch inferred

Icarus Verilog compiled both without a word, so a regression that runs only there would carry both on. Verilator’s MULTIDRIVEN messages quote the rules of IEEE 1800-2023 that the second file breaks, sections 9.2.2.2 and 9.2.2.4: a variable that an always_comb or always_ff block writes may be written by no other process. The two-process style prevents both mistakes by construction. Next values come only from registers and inputs, so every path inside the module that returns to where it started passes through a flip-flop, and each register has exactly one writer, its always_ff block.

Checkpoint

Check: Combinational loops and second writers

  1. Question 1 of 2 Which tool compiled both loop_demo.sv and two_writers.sv without a single message?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Icarus Verilog 13.0 with -Wall

    A clean compile on one simulator proves little. Verilator's lint and Yosys's check found both problems in seconds.

  2. Question 2 of 2 Why can a design written in the two-process style not contain the bug of two_writers.sv?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each register is written only in its always_ff block, and always_comb writes only the _d signals

    IEEE 1800-2023 9.2.2.2 and 9.2.2.4 forbid any other process from writing a variable that always_comb or always_ff writes. The style obeys the rule by putting every write of a register in one place.

A definition of done that a script can check

Style rules hold only when a machine checks them on every change. This track’s definition of done for an RTL change has five parts, and the first three run in seconds:

  1. verilator --lint-only -Wall prints nothing. A warning that is really harmless gets a lint waiver: A comment or a configuration line that turns off one lint warning at one named place in the code, with the reason for it. A flag that turns a warning off for a whole design hides every later instance of it too. on its one line, with the reason, never a -Wno-… flag for the whole design.
  2. Yosys synthesises the module without a latch error, and check -assert finds no loop, no second driver and no undriven wire.
  3. A parameter value that would build a broken module stops elaboration with $error.
  4. A self-checking testbench passes and ends with an exit status a script can read.
  5. Every interface carries assertions, which the assertions module of this track writes.
The first three checks of the definition of done on timer.sv Python · hdl/done_session.py
from session import run

# The definition of done for timer.sv, one check per line: lint, then synthesis with its structural checks; then the
# parameter check refusing a Width the timer cannot have, on all three tools. The testbench is cycle_session.py's run.
run("""
verilator --lint-only -Wall --quiet timer.sv
yosys -q -p "read_verilog -sv timer.sv; synth -top timer; check -assert"
!verilator --lint-only -Wall --quiet -GWidth=1 timer.sv
!iverilog -g2012 -Wall -Ptimer.Width=1 -o bad.vvp timer.sv
!yosys -q -p "read_verilog -sv timer.sv; chparam -set Width 1 timer; synth -top timer"
""")

Output

# Verilator 5.052 · Yosys 0.69 · Icarus Verilog 13.0 (stable)
$ verilator --lint-only -Wall --quiet timer.sv
$ yosys -q -p "read_verilog -sv timer.sv; synth -top timer; check -assert"
$ verilator --lint-only -Wall --quiet -GWidth=1 timer.sv
%Warning-USERERROR: timer.sv:15:5: timer: Width must be at least 2
                                 : ... note: In instance 'timer'
   15 |     $error("timer: Width must be at least 2");
      |     ^~~~~~
                    ... For warning description see https://verilator.org/warn/USERERROR?v=5.052
                    ... Use "/* verilator lint_off USERERROR */" and lint_on around source to disable this message.
[exit status 1]
$ iverilog -g2012 -Wall -Ptimer.Width=1 -o bad.vvp timer.sv
ERROR: timer.sv:15: timer: Width must be at least 2
       During elaboration  Scope: g_width_check
1 error(s) during elaboration.
[exit status 1]
$ yosys -q -p "read_verilog -sv timer.sv; chparam -set Width 1 timer; synth -top timer"
timer.sv:15: ERROR: timer: Width must be at least 2.
[exit status 1]

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

timer.sv passes the first two checks with no output at all, which is what a clean result looks like. The parameter check is the if (Width < 2) at the top of the module body, a generate block that elaboration works out for each instance. Given Width = 1 through each tool’s override, -G for Verilator, -P for Icarus Verilog and chparam for Yosys, all three stop with the module’s own message before anything is built. tb_timer.sv, the testbench of the two-block section, is the fourth check: it ends with PASS or with $fatal(1, …), so a regression script needs to read only its exit status.

One script, run by everybody

Put the commands of the done check in one script that both the regression and every engineer run, so that “lint-clean” means the same thing on every computer and in every change.

Checkpoint

Check: A definition of done a script can run

  1. Question 1 of 2 What did the lint and the synthesis check print for timer.sv in the done session?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing at all

    A check that prints nothing when all is well is easy to automate: any output, or a non-zero exit status, fails the change.

  2. Question 2 of 2 Width was set to 1 with -G, -P and chparam. What stopped all three tools?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The $error in the generate if at the top of timer.sv, which elaboration evaluated for that Width

    An elaboration-time $error turns an impossible parameter into an error with the module's own words, on every tool, instead of a broken netlist.

Practise: model the timer in two processes

The exercise builds the timer’s two blocks in Python, as separate pieces: a function that computes the next values from the registers and the inputs, as always_comb does, and a loop that loads them once per clock edge, as always_ff does. Its first sample test replays the directed start of tb_timer.sv.

Exercise · Easy · Python

Model the timer in two processes

Model timer.sv in Python the way its RTL is written, as two separate pieces:

  • timer_next(count_q, en, load, reload, width=8) is the always_comb block. It returns (count_d, expired_d) from the register's value and the inputs, and nothing else: load loads reload and wins over en; with en set, a count of 0 loads reload and sets expired_d to 1, and any other count goes down by one; with neither, the count stays. expired_d is 0 unless that reload happens. reload keeps only its low width bits, as the port does.
  • run_timer(inputs, width=8) is the always_ff block. It starts from the reset values, count_q = 0 and expired_q = 0, and for each (en, load, reload) in inputs, one per rising clock edge, loads the next values that timer_next computed from the values before that edge. It returns the list of (count_q, expired_q) after every edge.

run_timer([(0, 1, 3), (1, 0, 3), (1, 0, 3), (1, 0, 3), (1, 0, 3), (1, 0, 3)]) replays the directed start of tb_timer.sv and gives [(3, 0), (2, 0), (1, 0), (0, 0), (3, 1), (2, 0)].

Starter code · timer.py

def timer_next(count_q, en, load, reload, width=8):
    """The always_comb block of timer.sv: (count_d, expired_d) from the register and the inputs."""
    count_d = count_q
    expired_d = 0
    return count_d, expired_d


def run_timer(inputs, width=8):
    """The always_ff block: the (count_q, expired_q) after each rising edge, for one (en, load, reload) per edge."""
    return []
The sample tests · test_timer.py
from timer import run_timer, timer_next


def test_directed_start():
    """replays the directed start of tb_timer.sv: load 3, count down, reload with one expired pulse"""
    edges = [(0, 1, 3)] + [(1, 0, 3)] * 5
    assert run_timer(edges) == [(3, 0), (2, 0), (1, 0), (0, 0), (3, 1), (2, 0)]


def test_next_values_only():
    """timer_next computes the next values and changes nothing else"""
    assert timer_next(5, 1, 0, 9) == (4, 0)
    assert timer_next(0, 1, 0, 9) == (9, 1)
    assert timer_next(5, 0, 0, 9) == (5, 0)


def test_load_wins():
    """load loads reload even when en is set and the count is 0, without an expired pulse"""
    assert timer_next(0, 1, 1, 7) == (7, 0)
    assert run_timer([(1, 1, 7), (1, 0, 7)]) == [(7, 0), (6, 0)]


def test_pause_clears_pulse():
    """with en at 0 the count holds and expired goes back to 0 after its one cycle"""
    edges = [(0, 1, 1), (1, 0, 1), (1, 0, 1), (0, 0, 1), (0, 0, 1)]
    assert run_timer(edges) == [(1, 0), (0, 0), (1, 1), (1, 0), (1, 0)]


def test_reset_values_and_width():
    """starts from reset, gives no values without edges, and keeps only width bits of reload"""
    assert run_timer([]) == []
    assert run_timer([(1, 0, 0)]) == [(0, 1)]
    assert run_timer([(0, 1, 5)], width=2) == [(1, 0)]
A hint

Write timer_next with defaults first, as the always_comb block does: count_d = count_q and expired_d = 0, then the cases that change them. run_timer then only keeps the two register values and replaces both with what timer_next returns, once per edge.

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 names, the two blocks, loops and second writers, and the done check. Its questions begin with the idea your answers have handled worst, get harder as you get them right, and repeat a missed one before the end:

Practice that adapts to you

Practise this lesson

13 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 13 What does count_d hold in timer.sv?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The value that count_q will load at the next rising edge

    Every register of timer.sv has a _q signal, its output, and a _d signal, the value always_comb computes for it.

  2. Question 2 of 13 Following the same convention, what is irq_no?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: An active-low output port

    Suffixes combine without extra underscores, polarity first and direction last: rst_ni for an active-low input, irq_no for an active-low output.

  3. Question 3 of 13 A build script finds modules by file name. Which rule of the convention does it depend on, and which lint warning checks it?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: One module per file, named after the module; DECLFILENAME reports a file that breaks it

    A convention is useful to tools as well as people: a module in a file of its own name can be found, listed and linted without opening anything.

  4. Question 4 of 13 Why does the always_comb block of timer.sv begin with count_d = count_q and expired_d = 1'b0?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Every path then gives both outputs a value, so no latch appears and expired is a pulse unless the reload sets it

    Defaults first is the habit that keeps combinational blocks free of latches; the expired_d default is also what makes the pulse last exactly one cycle.

  5. Question 5 of 13 Outside reset, what does the always_ff block of timer.sv do?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: It loads count_d into count_q and expired_d into expired_q, and nothing else

    A clocked block that only loads has nothing for a nonblocking assignment to race with, and every register's reset value is in one place.

  6. Question 6 of 13 Why did Verilator warn BLKSEQ about count = 0; in the reset branch of timer_before.sv?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: It is a blocking assignment in a clocked block, which changes count at once for anything that reads it at the same edge

    The refactored timer writes count_q <= '0; in reset. Verilator's BLKSEQ is a style warning that -Wall turns on, and it points at exactly the habit that causes races.

  7. Question 7 of 13 The two timers synthesised to 42 and 43 cells. What does that tell you about the refactor?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: It kept the circuit: style changed how the code reads and what the tools can check, not what is built

    A refactor should be invisible to the hardware and to the testbench. That is exactly what lets a team refactor safely: the same tests and nearly the same netlist confirm it.

  8. Question 8 of 13 In tb_loop.sv, y was 1 on one line and 0 on another for the same inputs, a = 1 and b = 0. Why?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: The loop of p and q holds whatever value it had, so its output depends on the past, like storage

    A combinational loop can remember, which no testbench expects from combinational logic. Lint reported it as UNOPTFLAT and Yosys's check as a logic loop.

  9. Question 9 of 13 Which messages reported the loop of loop_demo.sv?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Verilator's UNOPTFLAT, circular combinational logic, and Yosys's "found logic loop" from check -assert

    Run lint and a synthesis check on every change, because the simulator a regression runs on may say nothing.

  10. Question 10 of 13 How should two_writers.sv clear its counter, written in the two-process style?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Compute count_d = clr ? '0 : count_q + 1'b1 in always_comb, and let one always_ff load count_q <= count_d

    Every condition that changes a register, a clear, a load or an enable, goes into the computation of its next value, and the register keeps one writer.

  11. Question 11 of 13 Why does tb_timer.sv end a failing run with $fatal(1, ...) instead of only printing FAIL?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: So that the simulation ends with exit status 1, which a regression script can test without reading the log

    The fourth check of the definition of done is a testbench whose result a machine can read: PASS with status 0, or $fatal with status 1.

  12. Question 12 of 13 A warning at one line of a module is genuinely harmless. What does the definition of done allow?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: A lint waiver on that line, with the reason written next to it

    The goal is a lint run that prints nothing: real problems are fixed, and the few harmless ones are waived where they are, with a reason a reader can judge.

  13. Question 13 of 13 If two_writers.sv were part of a change, which check of the definition of done would stop it first?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The first, lint: Verilator reported MULTIDRIVEN, LATCH and a BLKANDNBLK error

    The definition of done orders its checks from fastest to slowest, so the cheap static checks catch structural mistakes before any simulation runs.

Interview questions

Warm-up (fresher): what is the two-process coding style, and what does it buy you?

It splits each block into one always_comb, which computes every register’s next value, the _d signals, from the registers and the inputs, and one always_ff, which loads those values into the registers, the _q signals, at the clock edge after their reset values. It buys three things. Every register has one writer and one reset, so a missing reset or a second driver stands out. The combinational block starts with defaults, so no path leaves a value unassigned and no latch appears. And the clocked block only loads, so blocking and nonblocking assignments never mix in it. It changes the code, not the hardware: in this track’s timer the two-process version synthesised to 42 cells against 43 for the one-block version, with the same nine flip-flops.

Next come two questions for experienced engineers: cleaning up an inherited module, and writing a team’s definition of done. Sketch your opening move for each before you look at its first step:

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

  1. Mid to senior

    You inherit a 2,000-line module with blocking assignments in its clocked blocks, no naming convention and about 300 lint warnings that were switched off with -Wno flags. How do you bring it to a clean state without changing its behaviour?

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

  2. Mid to senior

    Your team asks you to write the definition of done for RTL changes. What goes on it, which tool checks each item, and what stays a human judgement?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

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

  1. What do the suffixes _q, _d, _n, _i and _o mean in the track's naming convention?

    _q marks a register and _d the value it loads next; _n marks an active-low signal; _i and _o mark input and output ports, the polarity before the direction, as in rst_ni.

  2. What does the two-process style split a block into?

    One always_comb that computes every next value from the registers and inputs, defaults first, and one always_ff that only loads those values, or the reset values while reset is active.

  3. Did splitting the timer into two processes change its hardware?

    No. Both versions agreed on all 506 cycles and synthesised to 42 and 43 cells with the same nine flip-flops; only the code and its lint warnings changed.

  4. What do Verilator's DECLFILENAME and UNUSEDSIGNAL warnings report?

    Style problems that -Wall turns on: a module whose name does not match its file, and a signal that is declared and never used.

  5. What did each tool report for a combinational loop through two gates?

    Icarus Verilog compiled it without a message, Verilator's lint reported UNOPTFLAT, circular combinational logic, and Yosys found a logic loop and failed check. In simulation the loop held a value nobody declared.

  6. What rule does a variable written by both an always_ff and an always_comb block break?

    IEEE 1800 lets no other process write a variable that an always_comb or always_ff block writes. Verilator reported MULTIDRIVEN and Yosys stopped on a latch; Icarus Verilog said nothing.

  7. What five checks make up the track's definition of done for an RTL change?

    Lint with -Wall prints nothing; Yosys builds it without a latch and check -assert passes; impossible parameters stop elaboration with $error; a self-checking testbench passes with a readable exit status; every interface carries assertions.

Key takeaways

  • Names carry what a reader needs: _q and _d for a register and its next value, _n for active low, _i and _o for ports, one module per file. The first timer, tmr in timer_before.sv with an unused spare, earned two of its four warnings from names and files alone.
  • In the two-process style always_comb computes every next value, defaults first, and always_ff only loads them; the refactored timer agreed with the first one on all 506 cycles and synthesised to 42 cells against 43, with the same nine flip-flops.
  • Icarus Verilog compiled a combinational loop and a variable with two writers without a word; Verilator’s lint and Yosys’s checks caught both, and the loop held a value that nobody declared.
  • A definition of done is a list that a script can run: lint with no warnings, synthesis without a latch and with a passing check -assert, $error on impossible parameters, and a self-checking testbench.

Terms in this lesson

combinational loop
A path from a signal back to itself through gates alone, with no register on it, such as p = a & q with q = b | p. It can hold a value or oscillate, so lint and synthesis checks report it.
lint waiver
A comment or a configuration line that turns off one lint warning at one named place in the code, with the reason for it. A flag that turns a warning off for a whole design hides every later instance of it too.
two-process style
Writing a block as one always_comb that computes each register's next value, the _d signals, from the registers and the inputs, and one always_ff that loads those values into the registers, the _q signals, at the clock edge.

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.