The Invisible Include: case study from a custom BYOL for simulator migration

The Invisible Include: Why SystemVerilog Migration Drag Demands a "Build Your Own Linter" (BYOL) Approach

Tool migration is rarely a weekend project. When a team decides to switch SystemVerilog (SV) simulators—whether for faster execution, better coverage metrics, or licensing reasons—the expectation is usually a straightforward port. The RTL compiles in Tool A, so it should compile in Tool B, right?

Then reality sets in. The migration timeline begins to stretch, not because of major architecture rewrites, but because of a grinding stream of minor environment and language compliance discrepancies.

One of the most elusive "gotchas" in large codebases stems from how different simulators handle a seemingly simple task: finding an include file.

The Local Include Trap

Consider a standard file structure where an RTL file includes a local header located in the exact same directory:

src/
└── core/
    ├── cpu_top.sv
    └── cpu_defines.svh

Inside cpu_top.sv, you have:

`include "cpu_defines.svh"

In some simulators/compilers, this compiles perfectly without any extra arguments. The tool implicitly looks in the directory of the compiling source file first. As always, one has to go all the way back to Gateway Design's Verilog-XL - that did NOT resolve to local includes (Unlike classical C/CPP).

The ambiguity was such a headache for multi-tool toolchains that the IEEE 1800-2012 (and later 2017/2023) SystemVerilog LRM finally intervened to clarify the behavior. When a project has generic header names like defines.vh duplicated across multiple folders, variations in the compiler search algorithm stop causing clean "file not found" errors. Instead, they cause the tools to grab completely different files entirely without warning.

The Friction in the Field

When migrating a massive, multi-team codebase, this discrepancy creates a death-by-a-thousand-cuts scenario.

Imagine a team spinning up a new simulation environment. The codebase consists of tens of clusters and third-party VIPs. Suddenly, a block that has run flawlessly for three years stalls the new simulator line. The error logs start filling up with mismatches.

It doesn't cause a spectacular explosion; instead, it introduces a low-grade, persistent friction:

  • Engineers waste hours manually tracking down where the include file lives.
  • Scripting pipelines break iteratively, block by block, extending the tool bring-up by weeks.
  • The team is forced into a reactive cycle of guessing which +incdir+ arguments are missing from the new tool's file lists.

Standard commercial linters rarely flag this during development because, to them, the file is found in the current environment. They aren't looking at the code from the perspective of a different vendor's default behavior.

Wanna see a real user report? Visit: https://github.com/MikePopoloski/slang/discussions/1857#discussioncomment-17124059 

Why BYOL is the Only Pragmatic Solution

This is exactly where a Build Your Own Linter (BYOL) framework shines. Off-the-shelf linters enforce standard syntax rules (like variable naming styles or blocking vs. non-blocking assignments). They don't understand the specific environmental migration constraints of your specific toolset pipeline.

By building a lightweight, custom lint rule on top of a modern compiler front-end like pyslang, you can proactively map out every single dependency before a single file is handed to the new simulator.

When building a BYOL rule for this, you aren't boxed into a single implementation style. Depending on your workflow, you can architect the check in a couple of clever ways:

Approach 1: Full-Path Structural Analysis

Instead of relying on compiler errors, your linter can analyze the syntax tree directly. It looks up every include directive node, grabs the absolute path of the main source file, and compares it against the absolute path of the file the preprocessor resolved. If they share the same directory but that directory isn't explicitly listed in the global +incdir+ array, the linter flags it as a "portability risk."

Approach 2: The Two-Pass Diffing Method

Alternatively, you can leverage front-end compiler switches for environment simulation.

  1. Pass One: Compile the codebase normally so the parser resolves all local includes silently.
  2. Pass Two: Re-run the compilation with a strict flag like --disable-local-includes, which forces the front-end to ignore the local directory.

By mining the difference between the two passes, your custom linter can instantly isolate every header relying on implicit local resolution. You get a clean, automated database of every missing include folder needed to complete a clean compile in your new tool.

Conclusion

SystemVerilog is a massive, complex language with decades of legacy simulator behavior baked into different vendor tools. Relying on an official tool's error output to guide a migration means you are always playing catch-up.

With a BYOL strategy, you treat code portability as a first-class citizen. Instead of letting environment mismatches dictate your engineering timelines, you write the rules that guarantee your code is truly tool-agnostic.

Comments

Popular posts from this blog

Cracking the UVM-Verilator Code: 50+ IPs, AI Guardrails, and the Open-Source North Star

Call for Collaboration: Seeking Public UVM Environments for Verilator 5.0+ Porting

Breaking the License Barrier: The World’s First UVM + Verilator Hands-On Bootcamp - Mar-1, Sunday 3-5 PM PST