Introduction: The Power of Static Analysis

You've written your RTL, it compiles cleanly, and your simulations pass. Surely you're ready for synthesis, right? Not so fast. Between "code that compiles" and "code that's actually good" lies a vast territory of potential issues that won't cause simulation failures but will create problems downstream. This is where linting becomes your secret weapon.

Think of linting as having a veteran hardware engineer review every line of your code, checking for patterns that, while technically legal, often indicate bugs or poor design practices. Verilator's linter is particularly valuable because it applies the same strict rules that synthesis tools will enforce, catching issues before you invest time in the physical design flow.

In this module, we'll explore the most common lint warnings you'll encounter, what they really mean, and how to fix them properly. More importantly, you'll learn to recognize the design patterns that trigger these warnings, helping you write cleaner code from the start.

Understanding Verilator's Warning System

Before diving into specific warnings, let's understand how Verilator categorizes and reports issues.

Warning Severity Levels

Verilator distinguishes between different severity levels:

Error: Fundamental issues that prevent compilation. Your code won't verilate until these are fixed.

Warning: Suspicious constructs that might indicate bugs. Code still compiles, but you should investigate.

Info: Informational messages about optimizations or design characteristics.

By default, Verilator reports warnings but continues processing. You can promote warnings to errors with -Werror-WARNINGNAME, forcing yourself to address issues rather than accumulate technical debt.

The Linting Command

The basic linting command is straightforward:

verilator --lint-only -Wall design.v

Breaking this down:

  • --lint-only: Perform only static analysis, don't generate simulation code
  • -Wall: Enable all warnings (like GCC's -Wall flag)
  • design.v: Your Verilog source file(s)

The output shows warnings with file locations, line numbers, and descriptions. Each warning has a category name (like LATCH, WIDTH, UNUSED) that you can use to selectively enable or disable checking.

LATCH Inference Detection: The Silent Design Killer

Unintentional latch inference is one of the most common and problematic issues in RTL design. Latches are level-sensitive storage elements—they're transparent (passing input to output) when the enable is active and hold their value when the enable is inactive. While occasionally useful, they're almost always unintentional in synchronous digital designs.

Why Latches Are Problematic

In synchronous designs built around edge-triggered flip-flops, latches create timing nightmares:

Timing Analysis Complexity: Static timing analysis tools struggle with latches because they're level-sensitive. Setup and hold times become more complex.

Glitch Sensitivity: Since latches are transparent during part of the clock cycle, they can capture glitches from combinational logic, leading to unreliable behavior.

Clock Gating Issues: Latches can accidentally create clock gating structures that violate design rules.

Synthesis Variations: Different synthesis tools handle latches differently, creating portability issues.

Common Latch Inference Patterns

Incomplete If Statement:

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

Verilator Warning:

%Warning-LATCH: design.v:5: Latch inferred for signal 'output_reg'

What's happening? When enable is low, the code doesn't specify what output_reg should be, so synthesis infers that it should hold its previous value—that's a latch.

The Fix: Add a complete specification:

always @(*) begin
    if (enable)
        output_reg = input_data;
    else
        output_reg = 8'h00;  // or whatever default makes sense
end

Or better yet, use a default assignment:

always @(*) begin
    output_reg = 8'h00;  // Default value
    if (enable)
        output_reg = input_data;  // Override when enabled
end

Incomplete Case Statement:

always @(*) begin
    case (state)
        2'b00: next_state = 2'b01;
        2'b01: next_state = 2'b10;
        2'b10: next_state = 2'b00;
        // Missing 2'b11 case
    endcase
end

Verilator Warning:

%Warning-CASEINCOMPLETE: design.v:8: Case values incompletely covered
%Warning-LATCH: design.v:8: Latch inferred for signal 'next_state'

When state is 2'b11, no case matches, so next_state isn't assigned. Again, a latch.

The Fix: Always include a default case:

always @(*) begin
    case (state)
        2'b00: next_state = 2'b01;
        2'b01: next_state = 2'b10;
        2'b10: next_state = 2'b00;
        default: next_state = 2'b00;  // Explicit handling
    endcase
end

Partial Signal Assignment:

reg [7:0] data;

always @(*) begin
    if (write_enable)
        data[3:0] = input_nibble;
    // Upper bits data[7:4] never assigned - latches inferred
end

You must assign all bits of a signal in combinational logic, or the unassigned bits become latches.

When Latches Are Intentional

Occasionally, you do want a latch (though this is rare). If so, document it explicitly and suppress the warning:

/* verilator lint_off LATCH */
always @(*) begin
    if (enable)
        data_hold = data_in;
    // Intentional transparent latch
end
/* verilator lint_on LATCH */

But think three times before doing this. In modern designs, there's almost always a better solution using edge-triggered logic.

Combinational Loop Detection: Breaking the Circle

Combinational loops occur when a signal depends on itself through combinational logic, creating circular dependencies that have no stable solution or oscillate.

Understanding Combinational Loops

Consider this code:

assign a = b & c;
assign b = a | d;

Signal a depends on b, but b depends on a. This is a combinational loop. In real hardware, this could:

  • Oscillate continuously (if the feedback is inverting)
  • Settle to an unpredictable state
  • Violate timing constraints
  • Be impossible to synthesize

Verilator's Loop Detection

module loop_example (
    input wire x,
    input wire y,
    output wire z
);

assign a = b & x;
assign b = c | y;
assign c = a ^ z;
assign z = c;

endmodule

Verilator Warning:

%Warning-UNOPTFLAT: design.v:6: Signal unoptimizable: Feedback to clock or circular logic
                               : In instance: loop_example
                               : Circular path detected: a <- b <- c <- a

The UNOPTFLAT warning indicates Verilator detected a circular dependency it cannot optimize away. This is almost certainly a design error.

Common Causes and Fixes

Accidental Feedback:

// Wrong: combinational feedback
always @(*) begin
    counter = counter + 1;
end

This creates a combinational path from counter to itself. You probably meant to create sequential logic:

// Correct: sequential logic with clock
always @(posedge clk) begin
    counter <= counter + 1;
end

Bidirectional Signals Modeled Incorrectly:

// Problematic tristate modeling
assign data = data_out_enable ? data_out : 8'bz;
assign data_in = data;  // Creates loop through 'data'

Better approach:

// Separate input and output signals
assign data = data_out_enable ? data_out : 8'bz;
assign data_in = data;  // But use data_in, not data, internally

Inadvertent Loops in Large Designs:

In complex designs, loops can arise from subtle interactions. Verilator's error message traces the circular path, helping you identify the problematic signals. Follow the dependency chain and break it by inserting registers or restructuring logic.

Verification of Fix

After fixing a suspected loop, re-run the linter:

verilator --lint-only -Wall design.v

The UNOPTFLAT warning should disappear. If it persists, examine the dependency trace more carefully.

Width Mismatches and Truncation: The Subtle Data Corruptor

Width mismatches occur when you assign a value of one bit-width to a signal of a different width. While Verilog handles this automatically (extending or truncating as needed), these automatic conversions often hide bugs.

Understanding Width Matching

Verilog's width-matching rules:

  • Narrower to wider: Zero-extends unsigned values, sign-extends signed values
  • Wider to narrower: Truncates, keeping only the lower bits
  • Expression width: Determined by the widest operand

These rules work, but they're applied silently. If you make a mistake, the code compiles and simulates, but produces wrong results.

Common Width Mismatch Scenarios

Assignment Truncation:

wire [7:0] byte_data;
wire [3:0] nibble_data;

assign byte_data = 8'hFF;
assign nibble_data = byte_data;  // Truncates to 4'hF

Verilator Warning:

%Warning-WIDTH: design.v:5: Bit extraction of 8-bit value with 4-bit result

If this truncation is intentional, make it explicit:

assign nibble_data = byte_data[3:0];  // Clear intent

If it's a mistake, fix the width:

wire [7:0] nibble_data;  // Should have been 8 bits
assign nibble_data = byte_data;

Expression Width Issues:

reg [7:0] a, b;
wire [6:0] product;

assign product = a * b;  // LHS vs RHS width mismatch

This is subtle. The multiplication a * b produces a 16-bit result, but Verilator throws an error when it finds the result width to be too narrow.

Verilator Warning:

%Warning-WIDTHTRUNC: design.v:3:20: Operator ASSIGNW expects 7 bits on the Assign RHS, but Assign RHS's MUL generates 8 bits.

The Fix: Ensure result is atleast as wide as the operand:

wire [15:0] product;

Verilator’s width checking, often triggered by WIDTH warnings, is primarily designed to act as a linting tool to identify potentially unintentional (suspicious) truncation or unexpected behavior, rather than strictly enforcing a full mathematical result width in every scenario.

Comparison Width Mismatches:

wire [7:0] count;
wire done;

assign done = (count == 1000);  // Comparing 8-bit to 10-bit constant

Verilator Warning:

%Warning-WIDTH: design.v:4: Comparison is constant due to width

This comparison can never be true because count is 8 bits (max value 255) and 1000 requires 10 bits. This is almost certainly a bug—maybe count should be wider, or the constant should be smaller.

Best Practices for Width Management

Explicit Sizing: Always specify bit widths explicitly:

// Bad: implicit width
assign result = 100;

// Good: explicit width
assign result = 8'd100;

Concatenation for Extension:

// Explicit zero-extension
assign wide = {8'h00, narrow};

// Explicit sign-extension
assign wide = {{8{narrow[7]}}, narrow};

Parameters for Consistency:

parameter DATA_WIDTH = 8;
parameter ADDR_WIDTH = 10;

reg [DATA_WIDTH-1:0] data;
reg [ADDR_WIDTH-1:0] addr;

This ensures widths remain consistent and makes changes easier.

Unused Signals and Dead Code: Cleaning Up Your Design

Unused signals and dead code clutter your design, make it harder to understand, and often indicate incomplete implementation or copy-paste errors.

UNUSED Warnings

Signals Assigned But Never Read:

module example (
    input wire clk,
    input wire [7:0] data_in,
    output wire [7:0] data_out
);

reg [7:0] temp_buffer;  // Assigned but never used

always @(posedge clk) begin
    temp_buffer <= data_in;  // Computed but never referenced
    data_out <= data_in;     // Direct assignment
end

endmodule

Verilator Warning:

%Warning-UNUSED: design.v:7: Signal is not used: 'temp_buffer'

This suggests temp_buffer is either:

  • Dead code from a previous design iteration
  • A planned feature not yet implemented
  • An actual bug where you forgot to use it

The Fix: Either use the signal properly or remove it:

// Option 1: Use it
assign data_out = temp_buffer;

// Option 2: Remove it entirely
// (Delete temp_buffer declaration and assignment)

UNDRIVEN Warnings

Signals Read But Never Assigned:

module counter (
    input wire clk,
    output reg [7:0] count
);

reg [7:0] next_count;  // Declared but never assigned

always @(posedge clk) begin
    count <= next_count;  // Uses uninitialized value!
end

endmodule

Verilator Warning:

%Warning-UNDRIVEN: design.v:6: Bits of signal are not driven: 'next_count'

Reading an undriven signal gives unpredictable results (X in simulation, random values in hardware). This is almost always a bug.

The Fix:

always @(*) begin
    next_count = count + 8'h01;
end

always @(posedge clk) begin
    count <= next_count;
end

Intentional Unused Signals

Sometimes signals are intentionally unused, such as:

Reserved Ports:

module interface (
    input wire [7:0] data,
    /* verilator lint_off UNUSED */
    input wire [3:0] reserved_bits,  // For future use
    /* verilator lint_on UNUSED */
    output wire valid
);

Unused Function Return Values:

task write_data;
    input [7:0] data;
    reg status;  /* verilator lint_off UNUSED */
begin
    status = write_to_memory(data);
    // We don't care about status in this context
end
endtask

Use lint directives to suppress warnings for intentional cases, but document why.

Practical Linting Workflow

Step 1: Initial Lint Run

Start with a comprehensive check:

verilator --lint-only -Wall design.v > lint_report.txt 2>&1

Review all warnings. Don't be discouraged if there are many—cleaning them up systematically improves your design significantly.

Step 2: Prioritize Critical Warnings

Address in this order:

  1. LATCH warnings: Usually indicate actual bugs
  2. UNOPTFLAT (combinational loops): Serious design errors
  3. WIDTH mismatches: Often hide data corruption bugs
  4. UNDRIVEN signals: Using uninitialized data
  5. UNUSED signals: Code cleanup, lower priority but important

Step 3: Iterative Fixing

Fix warnings in batches and re-lint after each batch. Sometimes fixing one warning eliminates others (e.g., fixing a latch might resolve related UNDRIVEN warnings).

Step 4: Establish a Clean Baseline

Your goal: zero warnings with -Wall. Once achieved, maintain this standard. Configure your build system to fail if linting produces warnings:

verilator --lint-only -Wall -Werror-LATCH -Werror-UNOPTFLAT design.v

This treats latches and combinational loops as errors, preventing them from creeping back into your codebase.

Step 5: Integration into Development Flow

Make linting automatic:

Pre-commit hooks: Run linting before allowing code commits CI/CD integration: Include linting in your continuous integration pipeline Editor integration: Many editors can run Verilator and display warnings inline

Conclusion

Linting with Verilator transforms RTL development from "code that compiles" to "code that's actually correct." The warnings we've explored—latch inference, combinational loops, width mismatches, and unused signals—represent the most common pitfalls in hardware design. By understanding what these warnings mean and how to fix them, you'll write cleaner, more synthesizable RTL from the start.

Think of linting not as a chore but as having an expert colleague continuously reviewing your code. The few minutes spent addressing lint warnings save hours of debugging synthesized designs or, worse, discovering bugs in fabricated silicon.

In the next section, we'll explore Verilator's simulation capabilities, learning how to write C++ testbenches that leverage Verilator's exceptional performance for comprehensive verification. With clean, lint-free RTL as our foundation, we'll be ready to simulate with confidence.

Did you find what you were looking for?

Quiz

No quiz questions available for this article.