Digital Design & Verification (Verilog, SystemVerilog, VLSI) Module 11 – Lint, synthesis, timing, power and test
Linting RTL with Verilator, slang and svlint
Run Verilator, slang and svlint on one buggy SystemVerilog file, read each warning, waive one with its reason, and make lint a gate before a merge.
What you will learn
- Run Verilator --lint-only -Wall and interpret its warning classes
- Add an LRM-strict check with slang and rule-based style checks with svlint
- Write a waiver with its reason instead of switching a warning off globally
- Set up lint as a gate in continuous integration
Before you start
What you’ll learn · Before you start
What you will learn
- Run Verilator --lint-only -Wall and interpret its warning classes
- Add an LRM-strict check with slang and rule-based style checks with svlint
- Write a waiver with its reason instead of switching a warning off globally
- Set up lint as a gate in continuous integration
Before you start
On this page
fan_ctrl.sv is a 31-line fan controller, and its directed test passes all 12 of its checks. Three linters read the same file without running it and report six problems, four of them bugs: at 46 °C the fan stops, at −5 °C it runs, full mode is not full and the overheat flag never rises.
Each linter reads code in its own way. Verilator reads the design as a simulator does, slang checks it against the language standard to the letter, and svlint checks the rules a team has chosen. Here you run all three on that file, read what each one reports, fix or waive every finding, and turn the three into a gate that a change must pass before it merges.
A passing test hid four bugs that lint reports
The controller sets a fan’s duty cycle, from 0 to 15 sixteenths of full speed, from the temperature and a mode: off, quiet, auto or full. Its author tested what they had in mind, twelve cases from 20 to 45 °C in the first three modes, and built the simulation the way their script always had, with Verilator’s lint warnings switched off:
hdl/fan_ctrl.sv
// A fan controller: the duty cycle, 0 to 15 sixteenths of full speed, follows the temperature.
module fan_ctrl (
input logic clk,
input logic rst_n,
input logic signed [7:0] temp_c, // degrees Celsius, -128 to 127
input logic [7:0] start_c, // the fan starts above this temperature
input logic [1:0] mode, // 0 off, 1 quiet, 2 auto, 3 full
output logic [3:0] duty, // sixteenths of full speed
output logic hot // above 80 degrees for 300 cycles
);
logic [7:0] excess; // degrees above start_c
logic [7:0] hot_count;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) excess = '0;
else if (temp_c > start_c) excess = temp_c - start_c;
else excess = '0;
always_comb
case (mode)
2'd0: duty = 4'd0;
2'd1: duty = excess[7:1];
2'd2: duty = excess;
endcase
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) hot_count <= '0;
else if (temp_c > 8'sd80) hot_count <= hot_count + 1'b1;
assign hot = hot_count == 300;
endmodule hdl/tb_fan_ctrl.sv
// The fan controller's directed test: the twelve cases its author thought of, then, with +skipped, four that the
// test left out. Expected duty: off 0; quiet half the degrees above start_c; auto the degrees above start_c; full
// 15; never more than 15.
`timescale 1ns / 1ps
module tb_fan_ctrl;
logic clk;
logic rst_n;
logic signed [7:0] temp_c;
logic signed [7:0] start_c;
logic [1:0] mode;
logic [3:0] duty;
logic hot;
int checks = 0, failed = 0;
fan_ctrl dut (.clk(clk), .rst_n(rst_n), .temp_c(temp_c), .start_c(start_c), .mode(mode), .duty(duty), .hot(hot));
initial clk = 1'b0;
always #5 clk = ~clk;
function automatic int expected(input int t, input int m);
int above;
above = (t > 30) ? t - 30 : 0;
case (m)
0: return 0;
1: return (above / 2 > 15) ? 15 : above / 2;
2: return (above > 15) ? 15 : above;
default: return 15;
endcase
endfunction
task automatic check(input int t, input int m);
int want;
want = expected(t, m);
temp_c = 8'(t);
mode = 2'(m);
@(posedge clk);
#1;
checks++;
if (int'(duty) != want) begin
failed++;
$display("FAIL %4d C, mode %0d: duty %2d, expected %2d", t, m, duty, want);
end
endtask
initial begin
rst_n = 1'b0;
temp_c = 8'sd20;
start_c = 8'sd30;
mode = 2'd0;
#12 rst_n = 1'b1;
for (int m = 0; m < 3; m++) begin
check(20, m);
check(30, m);
check(40, m);
check(45, m);
end
if ($test$plusargs("skipped")) begin
check(46, 2);
check(-5, 2);
check(40, 3);
temp_c = 8'sd85;
repeat (300) @(posedge clk);
#1;
checks++;
if (hot !== 1'b1) begin
failed++;
$display("FAIL 85 C for 300 cycles: hot %b, expected 1", hot);
end
end
$display("%0d of %0d checks passed", checks - failed, checks);
$finish;
end
endmodule Commands · hdl/tests_session.py
from session import run
# The directed test as its author ran it: Verilator builds the simulation with its lint warnings switched off
# (-Wno-lint), the twelve checks pass, and +skipped adds the four cases the test left out. Then the fixed design,
# built with every warning on.
run("""
verilator --binary -j 0 --quiet -Wno-lint --top-module tb_fan_ctrl tb_fan_ctrl.sv fan_ctrl.sv
obj_dir/Vtb_fan_ctrl +verilator+quiet
obj_dir/Vtb_fan_ctrl +verilator+quiet +skipped
verilator --binary -j 0 --quiet -Wall --top-module tb_fan_ctrl -Mdir obj_fixed tb_fan_ctrl.sv fixed/fan_ctrl.sv
obj_fixed/Vtb_fan_ctrl +verilator+quiet +skipped
""") Output
# Verilator 5.052 $ verilator --binary -j 0 --quiet -Wno-lint --top-module tb_fan_ctrl tb_fan_ctrl.sv fan_ctrl.sv $ obj_dir/Vtb_fan_ctrl +verilator+quiet 12 of 12 checks passed - tb_fan_ctrl.sv:71: Verilog $finish $ obj_dir/Vtb_fan_ctrl +verilator+quiet +skipped FAIL 46 C, mode 2: duty 0, expected 15 FAIL -5 C, mode 2: duty 13, expected 0 FAIL 40 C, mode 3: duty 13, expected 15 FAIL 85 C for 300 cycles: hot 0, expected 1 12 of 16 checks passed - tb_fan_ctrl.sv:71: Verilog $finish $ verilator --binary -j 0 --quiet -Wall --top-module tb_fan_ctrl -Mdir obj_fixed tb_fan_ctrl.sv fixed/fan_ctrl.sv $ obj_fixed/Vtb_fan_ctrl +verilator+quiet +skipped 16 of 16 checks passed - tb_fan_ctrl.sv:71: Verilog $finish
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tests_session.py
All twelve checks passed. The four cases the test left out failed: 46 °C in auto mode gave a duty of 0, −5 °C gave
13, full mode kept the 13 it had, and 300 cycles at 85 °C never raised hot. None of them is exotic; each is one input
the author did not try.
lint: Checking source code without running it for constructs that are legal but suspicious, such as a signal that is never read or a width that does not match; a lint tool reports them as warnings. More in “How chips are designed and verified” finds such bugs from the code alone, because each one leaves a mark that a reader can see: an 8-bit value assigned to a 4-bit output, a case with a missing value, a signed number compared with an unsigned one, an 8-bit counter compared with 300. A test finds a bug only when it happens to drive the input that shows it; a linter reads every line on every run. The last run above is the same test on fixed/fan_ctrl.sv, the version that every linter of this lesson passes: 16 of 16 checks.
Switching lint off to get a simulation built
The test script built the simulation with -Wno-lint, because Verilator stops a build on warnings and these were in
the way. That one flag hid every bug in the file from the only tool that was running. Build with the warnings on,
and fix or waive each one.
Checkpoint
Check: Why a passing test still needs lint
Verilator reports lint warnings by default, and style warnings when asked
verilator --lint-only reads and elaborates the design as it would for a simulation, then stops before it writes any
C++. Its manual describes the option as a check for lint violations that creates no other output, and -Wall adds
the messages it considers stylistic, the style warning (Verilator): A Verilator warning about a way of writing code that tends to cause mistakes later, such as BLKSEQ for a blocking = in a clocked block. Style warnings are off by default and switched on with -Wall., which are off by default. Here is fan_ctrl.sv with every class on, then a count
of the warnings with and without -Wall:
from session import run
# Verilator's lint with every warning class on (-Wall), then how many warnings each setting reports. With -Wno-fatal
# the warnings are still printed, but the command succeeds, so a script that reads only the exit status sees none.
run("""
!verilator --lint-only -Wall fan_ctrl.sv
verilator --lint-only fan_ctrl.sv 2>&1 | grep -c "^%Warning"
verilator --lint-only -Wall -Wno-fatal fan_ctrl.sv > lint.log 2>&1
grep -c "^%Warning" lint.log
""") Output
# Verilator 5.052
$ verilator --lint-only -Wall fan_ctrl.sv
%Warning-WIDTHTRUNC: fan_ctrl.sv:22:18: Operator ASSIGN expects 4 bits on the Assign RHS, but Assign RHS's SEL generates 7 bits.
: ... note: In instance 'fan_ctrl'
22 | 2'd1: duty = excess[7:1];
| ^
... For warning description see https://verilator.org/warn/WIDTHTRUNC?v=5.052
... Use "/* verilator lint_off WIDTHTRUNC */" and lint_on around source to disable this message.
%Warning-WIDTHTRUNC: fan_ctrl.sv:23:18: Operator ASSIGN expects 4 bits on the Assign RHS, but Assign RHS's VARREF 'excess' generates 8 bits.
: ... note: In instance 'fan_ctrl'
23 | 2'd2: duty = excess;
| ^
%Warning-WIDTHEXPAND: fan_ctrl.sv:30:26: Operator EQ expects 32 or 9 bits on the LHS, but LHS's VARREF 'hot_count' generates 8 bits.
: ... note: In instance 'fan_ctrl'
30 | assign hot = hot_count == 300;
| ^~
... For warning description see https://verilator.org/warn/WIDTHEXPAND?v=5.052
... Use "/* verilator lint_off WIDTHEXPAND */" and lint_on around source to disable this message.
%Warning-BLKSEQ: fan_ctrl.sv:15:39: Blocking assignment '=' in sequential logic process
: ... note: In instance 'fan_ctrl'
: ... Suggest using delayed assignment '<='
15 | if (!rst_n) excess = '0;
| ^
... For warning description see https://verilator.org/warn/BLKSEQ?v=5.052
... Use "/* verilator lint_off BLKSEQ */" and lint_on around source to disable this message.
%Warning-BLKSEQ: fan_ctrl.sv:16:39: Blocking assignment '=' in sequential logic process
: ... note: In instance 'fan_ctrl'
: ... Suggest using delayed assignment '<='
16 | else if (temp_c > start_c) excess = temp_c - start_c;
| ^
%Warning-BLKSEQ: fan_ctrl.sv:17:39: Blocking assignment '=' in sequential logic process
: ... note: In instance 'fan_ctrl'
: ... Suggest using delayed assignment '<='
17 | else excess = '0;
| ^
%Warning-CASEINCOMPLETE: fan_ctrl.sv:20:5: Case values incompletely covered (example pattern 0x3)
20 | case (mode)
| ^~~~
... For warning description see https://verilator.org/warn/CASEINCOMPLETE?v=5.052
... Use "/* verilator lint_off CASEINCOMPLETE */" and lint_on around source to disable this message.
%Warning-LATCH: fan_ctrl.sv:19:3: Latch inferred for signal 'duty' (not all control paths of combinational always assign a value)
: ... Suggest use of always_latch for intentional latches
19 | always_comb
| ^~~~~~~~~~~
... For warning description see https://verilator.org/warn/LATCH?v=5.052
... Use "/* verilator lint_off LATCH */" and lint_on around source to disable this message.
%Error: Exiting due to 8 warning(s)
[exit status 1]
$ verilator --lint-only fan_ctrl.sv 2>&1 | grep -c "^%Warning"
5
$ verilator --lint-only -Wall -Wno-fatal fan_ctrl.sv > lint.log 2>&1
$ grep -c "^%Warning" lint.log
8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 verilator_session.py
Each warning starts with its code, then the file, line and column, then what Verilator expected and what it found:
Operator ASSIGN expects 4 bits on the Assign RHS, but Assign RHS's VARREF 'excess' generates 8 bits. The excerpt
under it marks the place, and the last two lines say where the code is described and how to waive it. Read the code
first, because it names the kind of mistake:
| Warning | Class | What it found in fan_ctrl.sv |
|---|---|---|
| WIDTHTRUNC | lint, on by default | 7 and 8 bits assigned to the 4-bit duty, lines 22 and 23 |
| WIDTHEXPAND | lint, on by default | the 8-bit hot_count compared with 300, which needs 9 bits |
| CASEINCOMPLETE | lint, on by default | no case item for mode 3 |
| LATCH | lint, on by default | duty keeps its last value in mode 3, which is a latch |
| BLKSEQ | style, with -Wall | blocking = in the clocked block, lines 15 to 17 |
Without -Wall Verilator reported 5 warnings; with it, 8, the three BLKSEQ lines added. The lint class is about
what the code computes, the style class about ways of writing that cause mistakes later. BLKSEQ is harmless in this
file today, because only the combinational block reads excess, and it becomes a race on the day another clocked
block reads it.
The -Wall run ended with %Error: Exiting due to 8 warning(s) and exit status: The number a program returns when it ends: 0 for success, any other value for failure. Scripts and continuous integration jobs read it to decide whether to go on. 1,
and that status is what a script can act on. -Wno-fatal keeps the messages and returns 0: the run with it wrote all
8 warnings into lint.log and still succeeded, so a job that reads only the exit status would have passed the file.
Checkpoint
Check: Verilator's lint and style warnings
slang and svlint report what Verilator does not
slang is a SystemVerilog compiler front end whose stated aim is complete support of the 1800-2023 standard, and its
warnings come in groups: -Wextra adds width and latch checks to a small default set, and a single warning can be
named, such as -Wsign-compare. svlint checks each file’s syntax tree against rules that a team switches on in a file
named .svlint.toml unless --config names another, svlint.toml here; its manual says every rule is off until it is
enabled there. Yosys, this track’s synthesis tool,
runs too, because synthesis refuses some code outright:
hdl/svlint.toml
# The project's svlint rules: every rule is off until this file switches it on.
[option]
textwidth = 100
[textrules]
style_textwidth = true
[syntaxrules]
default_nettype_none = true
blocking_assignment_in_always_ff = true
case_default = true
keyword_forbidden_always = true Commands · hdl/tools_session.py
from session import run
# The same file through slang, svlint and synthesis. slang reports warnings as errors here (-Werror), so that its
# exit status says whether it found anything; svlint reads the project's rules from svlint.toml.
run("""
!slang -Wextra -Wsign-compare -Werror fan_ctrl.sv
!svlint --config svlint.toml fan_ctrl.sv
!yosys -q -p "read_verilog -sv fan_ctrl.sv; proc"
""") Output
# slang 12.0.0 · svlint 0.9.5 · Yosys 0.69
$ slang -Wextra -Wsign-compare -Werror fan_ctrl.sv
fan_ctrl.sv:16:21: error: comparison of differently signed types ('logic signed[7:0]' and 'logic[7:0]') [-Wsign-compare]
else if (temp_c > start_c) excess = temp_c - start_c;
~~~~~~ ^ ~~~~~~~
fan_ctrl.sv:22:20: error: implicit conversion truncates from 7 to 4 bits [-Wwidth-trunc]
2'd1: duty = excess[7:1];
~ ^~~~~~~~~~~
fan_ctrl.sv:23:20: error: implicit conversion truncates from 8 to 4 bits [-Wwidth-trunc]
2'd2: duty = excess;
~ ^~~~~~
fan_ctrl.sv:21:13: error: latch inferred for 'duty' because it is not assigned on all control paths [-Winferred-latch]
2'd0: duty = 4'd0;
^~~~
Top level design units:
fan_ctrl
Build failed: 4 errors, 0 warnings
[exit status 1]
$ svlint --config svlint.toml fan_ctrl.sv
Fail: default_nettype_none
--> fan_ctrl.sv:2:1
|
2 | module fan_ctrl (
| ^^^^^^ hint : Place `` `default_nettype none`` at the top of source code.
| reason: Compiler directive `` `default_nettype none`` detects unintentional implicit wires.
Fail: blocking_assignment_in_always_ff
--> fan_ctrl.sv:14:3
|
14 | always_ff @(posedge clk or negedge rst_n)
| ^^^^^^^^^ hint : Do not use blocking assignments within `always_ff`.
| reason: Blocking assignment in `always_ff` may cause undefined event ordering.
Fail: case_default
--> fan_ctrl.sv:20:5
|
20 | case (mode)
| ^^^^ hint : Use a `default` expression in `case` statements.
| reason: Incomplete case may cause simulation/synthesis mismatch in `always_comb` and `function`.
[exit status 1]
$ yosys -q -p "read_verilog -sv fan_ctrl.sv; proc"
ERROR: Latch inferred for signal `\fan_ctrl.\duty' from always_comb process `\fan_ctrl.$proc$fan_ctrl.sv:19$5'.
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tools_session.py
slang’s first error is the bug that no other tool reported. temp_c is signed and start_c is not, and the
standard’s rule for an expression’s type (IEEE 1800-2023, 11.8.1) makes the whole comparison unsigned when one operand
is unsigned. −5 °C is the bit pattern 251, which is greater than 30, so the fan starts, and 251 − 30 = 221 leaves 13 in
the four bits of duty: the 13 of the failed check. svlint found three breaks of the team’s rules. One of them, the
missing `default_nettype none, is no bug today, but without it a misspelt name becomes a new 1-bit wire instead
of an error. Yosys stopped at the latch, so this file would never reach a netlist: A list of the cells a design uses and the wires that connect them, written by synthesis and read by place and route and by timing analysis. More in “How chips are designed and verified”.
| Finding | Reported by |
|---|---|
| signed temp_c against unsigned start_c | slang: sign-compare |
| 7 and 8 bits into the 4-bit duty | Verilator: WIDTHTRUNC; slang: width-trunc |
| no item for mode 3, a latch | Verilator: CASEINCOMPLETE and LATCH; slang: inferred-latch; svlint: case_default; Yosys: stopped |
| 8-bit count compared with 300 | Verilator: WIDTHEXPAND |
blocking = in always_ff |
Verilator: BLKSEQ; svlint: blocking_assignment_in_always_ff |
no `default_nettype none |
svlint: default_nettype_none |
Read the table down the right-hand column: each of the three linters has a row that only it reports, and each misses something. That is why a project runs more than one linter.
Key idea
Each linter reads the code a different way, so each finds problems the others miss: Verilator the widths, slang the standard’s rules, svlint the team’s own conventions. Run all three.
Checkpoint
Check: What slang and svlint add
A waiver names one warning, one place and the reason for it
Most findings are fixed in the code, and the fix usually says more than the old code did: fixed/fan_ctrl.sv compares
two signed values, saturates the duty before it takes excess[3:0], counts to 300 in 9 bits and gives mode 3 its own
item. A warning about something the design really wants, such as a truncation, is often best answered with a cast,
16'(now_us), which states the intent where it applies and leaves nothing to waive.
When the code cannot change, 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. More in “RTL coding style that scales” is the tool, and it should be as narrow as it can be: one rule, one place and a comment that says why. Each tool has its own comment form:
- Verilator:
/* verilator lint_off WIDTHTRUNC */before the line and/* verilator lint_on WIDTHTRUNC */after it. - slang:
// slang lint_off width-truncand// slang lint_on width-trunc. - svlint:
/* svlint off case_default */and/* svlint on case_default */, for its syntax rules.
For a file you may not edit, such as a supplied block, Verilator reads a
control file (Verilator): A file that begins with `verilator_config and holds commands such as lint_off -rule WIDTHTRUNC -file "*x.sv" -lines 10, so that a warning in a file you may not edit is waived without changing the file. with the same waiver for a named file and line:
lint_off -rule WIDTHTRUNC -file "*stamp_legacy.sv" -lines 10. Its manual says that such a waiver applies only to
files read after the control file, so the control file goes first on the command line.
A switch on the command line, -Wno-WIDTHTRUNC, is a waiver for every line of every file, the lines written next
year included. Walk through what that did in one project:
- Change 1
A supplied block warns
stamp_legacy.sv keeps the low 16 bits of a 32-bit time on purpose, so that its stamp wraps. Verilator reports WIDTHTRUNC on line 10 and UNUSEDSIGNAL on line 6, and the lint job fails.
- Change 2
The two warnings go off everywhere global switch
To get the job passing, -Wno-WIDTHTRUNC and -Wno-UNUSEDSIGNAL are added to the lint command. The supplied file is quiet now, and so is every file that anyone writes later.
- Change 3
A new truncation arrives
fan_override.sv sets the duty from a percentage, pct * 16 / 100. At 100 percent that is 16, which needs five bits, so the four bits of duty keep 0 and the fan stops.
- Change 4
Lint stays quiet, and it merges
The global switches hide the new WIDTHTRUNC as well, so the change passes lint and merges. Nothing in the job's output says that a warning was ever there.
- Change 5
The switches become a waiver scoped waiver
A control file, waivers.vlt, waives the two warnings for line 10 and line 6 of stamp_legacy.sv only. The next run reports line 10 of fan_override.sv, and the change is stopped until the duty saturates.
The timeline has 5 events, from Change 1 to Change 5.
The drill replays the last two changes: the old command with its two global switches, then the control file that waives the two warnings for the supplied file’s two lines only.
hdl/stamp_legacy.sv
`default_nettype none
// A supplied timestamp block: it keeps the low 16 bits of a 32-bit time on purpose, so that the stamp wraps.
module stamp_legacy (
input logic clk,
input logic take,
input logic [31:0] now_us,
output logic [15:0] stamp
);
always_ff @(posedge clk)
if (take) stamp <= now_us;
endmodule
`default_nettype wire hdl/fan_override.sv
`default_nettype none
// A later change: the fan's speed can be set by hand, in percent.
module fan_override (
input logic clk,
input logic set, // a new setting arrives
input logic [6:0] pct, // 0 to 100 percent
output logic [3:0] duty // sixteenths of full speed
);
always_ff @(posedge clk)
if (set) duty <= pct * 16 / 100;
endmodule
`default_nettype wire hdl/waivers.vlt
`verilator_config
// stamp_legacy.sv is supplied and not ours to edit: it keeps the low 16 bits of the time on purpose (its spec, 4.2).
lint_off -rule WIDTHTRUNC -file "*stamp_legacy.sv" -lines 10
lint_off -rule UNUSEDSIGNAL -file "*stamp_legacy.sv" -lines 6 Commands · hdl/drill_session.py
from session import run
# The project's old lint command switched two warnings off for every file, to quiet the supplied stamp_legacy.sv.
# Then fan_override.sv arrived. The new command waives the two warnings for the supplied file's two lines only.
run("""
for f in stamp_legacy.sv fan_override.sv; do verilator --lint-only -Wall --quiet -Wno-WIDTHTRUNC -Wno-UNUSEDSIGNAL $f; done
!for f in stamp_legacy.sv fan_override.sv; do verilator --lint-only -Wall --quiet waivers.vlt $f || exit 1; done
""") Output
# Verilator 5.052
$ for f in stamp_legacy.sv fan_override.sv; do verilator --lint-only -Wall --quiet -Wno-WIDTHTRUNC -Wno-UNUSEDSIGNAL $f; done
$ for f in stamp_legacy.sv fan_override.sv; do verilator --lint-only -Wall --quiet waivers.vlt $f || exit 1; done
%Warning-WIDTHTRUNC: fan_override.sv:10:19: Operator ASSIGNDLY expects 4 bits on the Assign RHS, but Assign RHS's DIV generates 32 or 7 bits.
: ... note: In instance 'fan_override'
10 | if (set) duty <= pct * 16 / 100;
| ^~
... For warning description see https://verilator.org/warn/WIDTHTRUNC?v=5.052
... Use "/* verilator lint_off WIDTHTRUNC */" and lint_on around source to disable this message.
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 drill_session.py
The old command printed nothing for either file, so the bug in fan_override.sv would have merged unseen: 100 × 16 / 100 = 16, which needs five bits, and the four bits of duty keep 0, so the override turns the fan off at 100 %. The scoped waiver kept the supplied file quiet and reported line 10 of the new one.
A -Wno flag to quiet one file
-Wno-WIDTHTRUNC in the lint command waives the warning in files that nobody has written yet. Waive the line, or the
supplied file in a control file, and keep the reason next to it.
Checkpoint
Check: Narrow waivers with reasons
Lint becomes a gate when its exit status stops a merge
A linter that someone runs when they remember protects nothing. A gate runs on every change, the same way on every computer, and a change merges only when the gate ends with exit status 0. lint.sh is this project’s gate: it runs all three linters on the files it is given, so that one run lists everything to fix, and it fails if any of them fails.
hdl/lint.sh
#!/bin/sh
# The lint gate: three linters on the files given. Each must be clean, or the gate fails (exit status 1) and the
# change does not merge. Every linter runs, so one run shows everything that needs fixing.
status=0
verilator --lint-only -Wall --quiet waivers.vlt "$@" > verilator.log 2>&1 ||
{ echo "verilator: $(grep -c '^%Warning' verilator.log) warnings"; status=1; }
slang -q -Wextra -Wsign-compare -Werror "$@" > slang.log 2>&1 ||
{ echo "slang: $(grep -c ': error:' slang.log) errors"; status=1; }
svlint --config svlint.toml "$@" > svlint.log 2>&1 ||
{ echo "svlint: $(grep -c '^Fail' svlint.log) rule failures"; status=1; }
if [ "$status" -eq 0 ]; then echo "lint gate: clean"; fi
exit "$status" Commands · hdl/gate_session.py
from session import run
# The gate on the original file, then on the fixed one.
run("""
!sh lint.sh fan_ctrl.sv
sh lint.sh fixed/fan_ctrl.sv
""") Output
# Verilator 5.052 · slang 12.0.0 · svlint 0.9.5 $ sh lint.sh fan_ctrl.sv verilator: 8 warnings slang: 4 errors svlint: 3 rule failures [exit status 1] $ sh lint.sh fixed/fan_ctrl.sv lint gate: clean
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gate_session.py
On the original file the gate reported 8 Verilator warnings, 4 slang errors and 3 svlint rule failures and ended
with status 1; on the fixed file it printed lint gate: clean. Each tool’s exit status needs care. Verilator fails on
any warning unless it is given -Wno-fatal, so the gate never gives it that. slang fails only on errors, so the gate
passes -Werror, which its manual describes as treating all warnings as errors; without it the four findings would
have printed and passed. svlint fails on any rule failure.
In a project the same script runs in two places: on the engineer’s computer before a commit, and in the continuous integration (CI): A service that builds and checks every change pushed to a shared repository, such as a pull request, so that a change that fails a check is stopped before it merges. job of every pull request, which the repository can be set to require before a merge. Step through one change on its way to the main branch:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The change. An engineer edits fan_ctrl.sv. Nothing has been checked yet, and the test they wrote covers the cases they had in mind.
- Lint before the commit. They run lint.sh on their own computer first. It takes seconds, so it can run before every commit, and it is the same script that the CI job runs.
- The pull request. The change goes to the team with any waiver it adds written in the code, next to the line it waives and with its reason.
- The gate. The CI job runs lint.sh on the change. On the original fan_ctrl.sv it printed 8 Verilator warnings, 4 slang errors and 3 svlint rule failures, and ended with exit status 1.
- Stopped. Exit status 1 stops the change here, before anyone spends time reading it. It goes back to the engineer with the gate's list of findings.
- Clean. On fixed/fan_ctrl.sv the gate printed "lint gate: clean" and ended with status 0, so the job goes on to the simulation regression.
- A teammate reads it. A teammate reads the change and every waiver in it. A waiver without a reason, or one wider than the line it needs, is sent back like a failing test.
- Merge. The change merges only when the gate and the tests have passed and the teammate has accepted it, and the same gate runs again on the next change.
The gate earns its place only while it stays fast and strict: seconds per change, no finding allowed, and every waiver written in the code, next to the line it waives, where the next reader of the change sees it.
Checkpoint
Check: Lint as a gate before a merge
Practise: audit a file’s waivers
A gate can check the waivers too. The exercise writes the check that every waiver names its rule, gives its reason and is closed again; the sample tests use the waiver forms of this lesson:
Exercise · Easy · Python
Audit the lint waivers of a file
A project writes its Verilator waivers as block comments, each with its reason in a line comment after it:
/* verilator lint_off WIDTHTRUNC */ // the stamp wraps on purpose
Write audit(source), the check its lint gate runs on every file. It takes the text of a file and returns a list of (line, problem) tuples, with lines numbered from 1, sorted by line:
"no rule": alint_offorlint_onthat names no rule, such as/* verilator lint_off */, which would turn every warning off. It is reported and otherwise ignored."no reason": alint_offwith no//comment after it on its line, or one with nothing but spaces in it."nothing to close": alint_onof a rule that has no openlint_off."never closed": alint_offstill open at the end of the file, reported at the line of thatlint_off.
A rule's name is upper-case letters, digits and underscores. A lint_on closes the most recent open lint_off of the same rule. A clean file gives [].
Starter code · audit.py
def audit(source):
"""The problems of the file's Verilator waivers, as (line, problem) tuples sorted by line."""
return [] The sample tests · test_audit.py
from audit import audit
STAMP = """always_ff @(posedge clk)
/* verilator lint_off WIDTHTRUNC */ // the stamp wraps on purpose
if (take) stamp <= now_us;
/* verilator lint_on WIDTHTRUNC */
"""
def test_clean_waiver():
"""accepts a waiver that names its rule, gives its reason and is closed"""
assert audit(STAMP) == []
assert audit("") == []
def test_no_reason():
"""reports a lint_off with no reason after it, or an empty one"""
src = "/* verilator lint_off UNUSEDSIGNAL */\nlogic [7:0] spare;\n/* verilator lint_on UNUSEDSIGNAL */\n"
assert audit(src) == [(1, "no reason")]
assert audit(src.replace("*/\nlogic", "*/ // \nlogic")) == [(1, "no reason")]
def test_never_closed():
"""reports a lint_off left open at the end of the file, at its own line"""
src = "logic a;\n/* verilator lint_off WIDTHTRUNC */ // supplied code\nassign y = a;\n"
assert audit(src) == [(2, "never closed")]
def test_no_rule():
"""reports a waiver without a rule, which would switch every warning off"""
assert audit("/* verilator lint_off */ // quiet, please\n") == [(1, "no rule")]
def test_nothing_to_close():
"""reports a lint_on that closes nothing, here because the rules differ"""
src = ("/* verilator lint_off WIDTHTRUNC */ // supplied code\nassign y = a;\n"
"/* verilator lint_on WIDTHEXPAND */\n")
assert audit(src) == [(1, "never closed"), (3, "nothing to close")]
def test_two_rules():
"""keeps the open waivers of each rule apart, and closes the latest one of the same rule"""
src = ("/* verilator lint_off WIDTHTRUNC */ // a\n"
"/* verilator lint_off UNUSEDSIGNAL */ // b\n"
"/* verilator lint_on WIDTHTRUNC */\n"
"/* verilator lint_off WIDTHTRUNC */ // c\n"
"/* verilator lint_on UNUSEDSIGNAL */\n"
"/* verilator lint_on WIDTHTRUNC */\n")
assert audit(src) == [] A hint
Find each waiver with one regular expression that captures lint_off or lint_on and an optional rule name. Keep a list of the open lines for each rule: a lint_off appends its line, a lint_on takes the last one off.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
Then check yourself on the five ideas, from the test that passed to the gate that stops a merge. The questions start where your answers have been weakest so far:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Why lint RTL when its testbench already passes?
A test checks only the inputs it drives, and a linter reads every line. In this lesson’s fan controller the directed test passed 12 of 12 checks while three linters reported six problems, four of them bugs: a truncated duty that stopped the fan at 46 °C, an unsigned comparison that ran it at −5 °C, a missing case item that made a latch, and a count that could never reach 300. Lint takes seconds and runs on every change, so it is the cheapest check a design has. It does not replace tests: it says whether the code is written the way its author most likely meant, not whether the design does what its specification asks.
Two harder questions follow, a global switch that let a bug through and a lint gate for a whole team:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your project's lint command has carried -Wno-WIDTHTRUNC for two years, added to quiet a supplied block. A width truncation in new RTL has just reached the lab. What do you change in the flow so that this cannot happen again, without editing the supplied block, and how do you find what else the switch has hidden?
-
Senior to staff
Design the lint gate for a team of 40 RTL engineers that uses Verilator, slang and svlint. Which flags does each tool get, where does the gate run, how are waivers controlled, and how do you keep it fast enough to run on every change?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does verilator --lint-only do, and what does -Wall add?
It reads and elaborates the design as for a simulation, then stops before it writes any C++. -Wall adds the style warnings, which are off by default, such as BLKSEQ.
-
Which lint warnings did Verilator report on fan_ctrl.sv by default?
WIDTHTRUNC for 7 and 8 bits into the 4-bit duty, WIDTHEXPAND for an 8-bit count compared with 300, CASEINCOMPLETE for the missing mode 3 item, and LATCH for duty keeping its value in mode 3.
-
What does -Wno-fatal do to a Verilator lint run?
It keeps the messages but exits with status 0, so a job that reads only the exit status passes a file full of warnings. A lint gate never passes it.
-
Which bug did only slang report on the fan controller?
Signed temp_c compared with unsigned start_c. One unsigned operand makes the comparison unsigned, so -5 °C counted as 251, above 30, and the fan started.
-
How do slang and svlint decide what to check?
slang's warnings come in groups, with -Wextra adding width and latch checks and single warnings named, such as -Wsign-compare. svlint checks the rules a team switches on in its .svlint.toml; every rule is off until enabled.
-
What is wrong with -Wno-WIDTHTRUNC on the lint command line?
It waives the warning on every line of every file, those written later included: it let a 5-bit result into a 4-bit duty and the override turned the fan off at 100 %. Waive one line, or a supplied file in a control file.
-
How is a narrow Verilator waiver written?
/* verilator lint_off WIDTHTRUNC */ before the line and /* verilator lint_on WIDTHTRUNC */ after it, with the reason beside it. A supplied file you may not edit gets a control-file line, lint_off -rule WIDTHTRUNC with its -file and -lines options.
-
Why does the lint gate pass -Werror to slang?
slang fails only on errors, so without -Werror its warnings print and the gate still passes; -Werror treats every warning as an error.
Key takeaways
- A passing directed test checks only the inputs it drives; lint reads every line, and on fan_ctrl.sv it found four bugs that the test’s twelve checks missed.
- Verilator reports lint warnings by default and style warnings with -Wall, and it exits with status 1 on any warning unless -Wno-fatal hides them from scripts.
- slang applies the standard’s rules, such as the unsigned comparison that one unsigned operand makes, and svlint applies the rules a team switches on; each finds problems the others miss.
- Fix a finding in the code where you can, and waive it only for one rule at one place with its reason, never with a -Wno flag for the whole design.
- A lint gate runs every linter on every change and lets the change merge only when all of them end with status 0.
Terms in this lesson
- continuous integration (CI)
- A service that builds and checks every change pushed to a shared repository, such as a pull request, so that a change that fails a check is stopped before it merges.
- control file (Verilator)
- A file that begins with `verilator_config and holds commands such as lint_off -rule WIDTHTRUNC -file "*x.sv" -lines 10, so that a warning in a file you may not edit is waived without changing the file.
- exit status
- The number a program returns when it ends: 0 for success, any other value for failure. Scripts and continuous integration jobs read it to decide whether to go on.
- style warning (Verilator)
- A Verilator warning about a way of writing code that tends to cause mistakes later, such as BLKSEQ for a blocking = in a clocked block. Style warnings are off by default and switched on with -Wall.
References
- Verilator documentation: errors and warnings (WIDTHTRUNC, WIDTHEXPAND, BLKSEQ, CASEINCOMPLETE, LATCH, UNUSEDSIGNAL) (Verilator)
- Verilator documentation: verilator arguments (--lint-only, -Wall, -Wno-fatal, --quiet) (Verilator)
- Verilator documentation: control files (lint_off -rule -file -lines) (Verilator)
- slang, a SystemVerilog compiler and language services (slang)
- slang warning reference (-Wextra, -Wsign-compare, -Wwidth-trunc, -Winferred-latch) (slang)
- slang command line reference (-W, -Wno, -Werror) (slang)
- slang user manual: pragmas and comment directives (lint_off, lint_on, pragma diagnostic) (slang)
- svlint 0.9.5 manual: configuration, rules and comment directives (svlint project)
- Yosys command reference: proc and proc_dlatch (YosysHQ)
- IEEE 1800-2023: IEEE Standard for SystemVerilog (11.8.1, expression type rules) (IEEE Standards Association)
Report a problem with this lesson
Kept only in this browser. Your Learn progress