What is Linting and Why Should You Care?

Before we dive into Verilator specifically, let's talk about a concept borrowed from software development: linting. The term comes from the original Unix lint program, which examined C code for suspicious constructs that, while technically legal, often indicated bugs or poor practices.

In hardware design, linting serves the same purpose. Your Verilog code might be syntactically correct—it compiles and simulates without errors—but still contain problematic patterns that will cause headaches during synthesis, lead to simulation-hardware mismatches, or simply represent poor coding style that makes your design harder to understand and maintain.

Common Issues Linting Catches

Consider these examples of code that simulates fine but is problematic:

Unintentional Latches:

always @(*) begin
    if (enable)
        output_reg = input_data;
    // Missing else clause - creates a latch!
end

This simulates without error, but synthesis infers a latch instead of combinational logic because output_reg retains its value when enable is low. Latches are timing nightmares in synchronous designs.

Blocking Assignments in Sequential Logic:

always @(posedge clk) begin
    a = b;  // Should be non-blocking (<=)
    c = a;  // Creates ordering dependency
end

This might simulate correctly due to event scheduling, but represents poor coding style and can lead to synthesis-simulation mismatches.

Width Mismatches:

wire [7:0] data;
assign data = 16'hFFFF;  // Truncation - only lower 8 bits assigned

The simulator handles this, but you've likely made a mistake. Linters flag width mismatches so you can verify they're intentional.

Unused Signals:

module example (
    input wire clk,
    input wire reset,
    input wire enable,  // Never used
    output wire done
);

An unused input often indicates incomplete implementation or copy-paste errors.

A good linter catches these issues before you waste time debugging mysterious synthesis results or hardware failures. It's like having an experienced colleague review your code automatically.

Enter Verilator: More Than Just a Linter

Verilator is a unique tool in the EDA landscape. Created by Wilson Snyder and maintained as open-source software, it serves dual purposes:

  1. Comprehensive Verilog linting - Static analysis of your code for common issues
  2. High-performance cycle-accurate simulation - Extremely fast simulation by compiling Verilog to C++

This dual nature makes Verilator particularly valuable. You can use it just for linting (quick static checks), or leverage its simulation capabilities for verification that needs to run billions of cycles.

The Verilator Philosophy

Verilator takes a fundamentally different approach than traditional simulators like Icarus Verilog or ModelSim. Instead of interpreting your Verilog code event-by-event, Verilator:

  1. Compiles your Verilog to optimized C++ or SystemC code
  2. Builds that C++ into a native executable using GCC or Clang
  3. Executes the resulting binary at native CPU speed

This compilation approach has profound implications for both simulation speed and the types of issues Verilator can detect.

What Makes Verilator Fast?

Traditional event-driven simulators maintain event queues and process signal changes dynamically at runtime. Every wire change triggers the simulator to evaluate which logic depends on that wire, schedule new events, and process them according to delta cycle rules.

Verilator, by contrast, analyzes your entire design at compile time. It determines all dependencies, creates optimized evaluation orders, and generates straight-line code for each clock cycle. The result? Simulation speeds 10-100x faster than interpretive simulators for large designs.

This makes Verilator ideal for:

  • Long-running simulations (millions to billions of cycles)
  • Software-driven testing (running actual software on a CPU model)
  • Co-simulation (verifying hardware-software interaction)
  • Performance modeling (architectural exploration)

The Tradeoff: Synthesizable Code Only

Here's the catch: Verilator's compilation approach means it only supports synthesizable Verilog. Language features that don't represent actual hardware—delays (#10), event controls (@(a or b) in continuous assignments), hierarchical references across module boundaries—aren't supported or are limited.

This isn't a bug; it's fundamental to Verilator's design philosophy. It focuses on the subset of Verilog that represents real hardware, which happens to be exactly what you should be writing for synthesis anyway. Unsynthesizable constructs in your RTL? That's a linting error in Verilator's view.

Verilator's Linting Capabilities

Let's explore what Verilator checks when you use it as a linter.

Basic Linting Mode

The simplest way to use Verilator is pure linting—no simulation, just static analysis:

verilator --lint-only design.v

This command parses your Verilog, performs numerous checks, and reports warnings and errors without generating any C++ code or simulation infrastructure.

Categories of Warnings

Verilator organizes warnings into categories, each representing a different class of potential issues:

UNDRIVEN: Signals that are never assigned a value

wire [7:0] status;  // Declared but never driven
// UNDRIVEN warning: signal never gets a value

UNUSED: Signals that are assigned but never read

wire [7:0] debug_info;
assign debug_info = some_expression;
// UNUSED warning: computed but never used

UNOPTFLAT: Variables in combinational loops that Verilator can't optimize away

assign a = b & c;
assign b = a | d;  // Combinational loop
// UNOPTFLAT warning: circular dependency

WIDTH: Width mismatches in assignments or expressions

wire [7:0] data;
wire [15:0] extended = data;  // Implicit extension
// WIDTH warning: might want explicit padding

CASEINCOMPLETE: Case statements without default clauses

always @(*) begin
    case (state)
        2'b00: output = 1'b0;
        2'b01: output = 1'b1;
        // Missing 2'b10 and 2'b11 cases
    endcase
end
// CASEINCOMPLETE warning: potential latch inference

LATCH: Unintentional latch inference

always @(*) begin
    if (condition)
        result = a;
    // Missing else - creates a latch
end
// LATCH warning: combinational logic infers latch

Controlling Warning Behavior

You can fine-tune Verilator's warning behavior using command-line flags:

Promote warnings to errors:

verilator --lint-only -Werror-WIDTH design.v

This treats width mismatches as errors, failing the lint check.

Disable specific warnings:

verilator --lint-only -Wno-UNUSED design.v

Useful when you have intentionally unused signals (like reserved ports).

Adjust warning levels:

verilator --lint-only -Wall design.v

Enable all warnings for maximum strictness.

Inline Warning Control

You can also control warnings within your Verilog code using special comments:

module example (
    input wire [7:0] data,
    /* verilator lint_off UNUSED */
    input wire [3:0] reserved,  // Intentionally unused
    /* verilator lint_on UNUSED */
    output wire valid
);

// Suppress specific warning for one statement
assign status = data[7:4];  /* verilator lint_off WIDTH */

endmodule

This gives you fine-grained control while documenting why certain patterns exist.

Verilator's Simulation Capabilities

Beyond linting, Verilator excels at cycle-accurate simulation. Let's understand what this means and how it differs from event-driven simulation.

Cycle-Accurate vs. Event-Driven

Event-driven simulation (Icarus Verilog, ModelSim):

  • Processes events as they occur
  • Maintains event queues and delta cycles
  • Can model intra-cycle behavior
  • Slower but more detailed

Cycle-accurate simulation (Verilator):

  • Evaluates the entire design once per clock cycle
  • Assumes all combinational logic settles within the cycle
  • Doesn't model delta cycles or timing within a cycle
  • Much faster but less detailed about intra-cycle behavior

For synchronous digital designs, cycle-accurate simulation is usually sufficient. You care about state at clock edges, not the intermediate combinational transitions between edges.

The C++ Interface Advantage

When Verilator compiles your design, it generates a C++ class representing your top module. You then write C++ testbench code that instantiates this class and drives it:

#include "Vdesign.h"  // Generated from design.v
#include "verilated.h"

int main(int argc, char** argv) {
    Verilated::commandArgs(argc, argv);
    Vdesign* top = new Vdesign;  // Instantiate design
    
    // Simulation loop
    for (int cycle = 0; cycle < 1000; cycle++) {
        top->clk = 0;
        top->eval();  // Evaluate with clk=0
        
        top->clk = 1;
        top->eval();  // Evaluate with clk=1
        
        // Read outputs
        printf("cycle %d: result = %d\n", cycle, top->result);
    }
    
    delete top;
    return 0;
}

This C++ interface provides powerful capabilities:

Direct integration with software: Test your hardware with the actual software that will run on it.

Performance: C++ testbenches compile to native code, eliminating simulation overhead.

Flexibility: Use the full power of C++—file I/O, networking, complex data structures—in your testbenches.

Debugging: Use standard C++ debuggers (GDB, LLDB) to debug your testbench and even step through your hardware execution.

Verilator vs. Icarus Verilog: Choosing the Right Tool

Both Verilator and Icarus Verilog are excellent open-source tools, but they serve different purposes. Understanding when to use each is crucial.

Use Icarus Verilog When:

Learning and Initial Development: Icarus has a gentler learning curve. You write simple Verilog testbenches without needing C++ knowledge.

Quick Functional Tests: For rapid iteration on small designs, Icarus's immediate interpret-and-run model is convenient. No compilation time.

Using Testbench Features: If your testbench uses timing delays (#10), fork-join, or other unsynthesizable features, Icarus handles these naturally.

Teaching: For educational settings, Icarus's straightforward approach and pure-Verilog workflow is often clearer for beginners.

Small to Medium Designs: For designs that simulate adequately fast with event-driven simulation, Icarus's simplicity is an advantage.

Use Verilator When:

Linting and Code Quality: Always. Running verilator --lint-only should be part of your workflow regardless of which simulator you use for actual simulation.

Long-Running Simulations: If you need millions or billions of cycles (like booting Linux on a CPU model), Verilator's speed is essential.

Software Co-Verification: When verifying hardware-software interaction, Verilator's C++ interface allows integrating actual embedded software.

Performance-Critical Testing: For regression tests that run frequently, Verilator's speed means faster feedback.

Large Designs: As designs grow, the compilation overhead becomes worthwhile due to simulation speedup.

Production Verification: For final pre-synthesis verification, Verilator's strict synthesizability checking catches issues that might slip through more permissive simulators.

A Practical Workflow: Using Both

Many designers use both tools in complementary ways:

  1. Early Development: Use Icarus for quick iteration and debugging with simple Verilog testbenches
  2. Code Quality: Run Verilator lint regularly to catch issues early
  3. Comprehensive Testing: Use Icarus for medium-length tests with full waveform dumping
  4. Long-Running Tests: Use Verilator for extensive tests (like running software workloads)
  5. Pre-Synthesis Check: Final Verilator lint and simulation before synthesis

This approach balances convenience, speed, and thoroughness.

The Compilation Trade-off

Verilator requires compilation: Verilog → C++ → binary. For small changes, this overhead can exceed Icarus's interpret time. But for longer simulations, the break-even point comes quickly:

  • Small test (100 cycles): Icarus faster (no compilation)
  • Medium test (10,000 cycles): About equal
  • Large test (1,000,000 cycles): Verilator dramatically faster

Consider your typical use cases when choosing.

Getting Started with Verilator

The basic Verilator workflow involves three steps:

1. Lint your design:

verilator --lint-only -Wall design.v

Fix all warnings before proceeding. Clean code lints cleanly.

2. Verilate (compile to C++):

verilator -Wall --cc design.v --exe testbench.cpp

This generates C++ files in obj_dir/ and a Makefile.

3. Build and run:

make -C obj_dir -f Vdesign.mk
./obj_dir/Vdesign

The resulting binary is your simulation executable.

Conclusion

Verilator occupies a unique and valuable position in the open-source EDA ecosystem. As a linter, it provides static analysis that catches common RTL issues before they become expensive debugging sessions. As a simulator, it offers unprecedented performance for cycle-accurate verification of large designs.

Understanding Verilator's strengths—strict synthesizability checking, compilation-based speed, C++ integration—and limitations—no delta cycles, synthesizable subset only—helps you use it effectively alongside tools like Icarus Verilog.

For serious RTL development, Verilator should be in your toolbox. Even if you primarily use Icarus or commercial simulators for day-to-day simulation, running Verilator's linter should be a standard step in your workflow. The issues it catches—unintentional latches, width mismatches, incomplete case statements—are exactly the bugs that create synthesis surprises and hardware failures.

In the next section, we'll dive deeper into writing Verilator testbenches in C++, exploring advanced simulation techniques, and leveraging Verilator's unique capabilities for comprehensive verification.

Did you find what you were looking for?

Quiz

No quiz questions available for this article.