Introduction: Beyond Traditional Testbenches
You've been writing testbenches in Verilog, using initial blocks and always statements to generate stimulus. This approach works well for small designs, but as your projects grow in complexity, you'll encounter limitations: difficulty interfacing with external data files, limited debugging capabilities, and slow simulation speeds for large designs. This is where Verilator's C++ testbench capability becomes a game-changer.
Verilator takes a fundamentally different approach to simulation. Instead of interpreting your Verilog code line-by-line like traditional simulators, it compiles your design into optimized C++ code. You then write a C++ testbench that instantiates and drives this compiled design. The result? Simulation speeds 10-100x faster than event-driven simulators, with the full power of C++ at your disposal for verification.
In this module, we'll explore how to write effective C++ testbenches, understand the tradeoffs of cycle-accurate simulation, optimize for performance, and leverage Verilator's code coverage features to ensure thorough testing.
Understanding Cycle-Accurate Simulation
Before diving into code, let's understand what makes Verilator's simulation model different and why it matters.
Event-Driven vs. Cycle-Accurate Models
Traditional simulators like Icarus Verilog and ModelSim use event-driven simulation:
- They maintain event queues tracking when signals change
- They process events in delta cycles, handling causality within a single time step
- They can model arbitrary timing, including delays within a clock cycle
- They're flexible but computationally expensive
Verilator uses cycle-accurate simulation:
- The entire design is evaluated once per clock edge (or evaluation point)
- All combinational logic is assumed to settle within the cycle
- No intra-cycle timing or delta cycles are modeled
- Much faster but less detailed about within-cycle behavior
Think of it this way: event-driven simulation is like watching a movie frame-by-frame with slow motion for interesting parts. Cycle-accurate simulation is like watching every Nth frame at high speed—you see the overall action clearly but might miss brief intermediate states.
When Cycle-Accurate Simulation Works
For synchronous digital designs (which is most modern hardware), cycle-accurate simulation is perfectly adequate. You care about:
- Register values at clock edges
- Output values after combinational logic settles
- Cycle-by-cycle behavioral correctness
You typically don't need to model:
- Propagation delays through combinational paths
- Setup and hold time violations (handled by separate STA tools)
- Glitches on intermediate combinational nodes
If your design is properly synchronous with well-defined clock domains, cycle-accurate simulation provides all the verification you need—at vastly higher speeds.
When Cycle-Accurate Simulation Isn't Enough
There are scenarios where event-driven simulation is necessary:
- Asynchronous interfaces (though these should be minimized)
- Metastability studies (though these require specialized tools anyway)
- Mixed-signal designs with precise analog timing
- Debugging race conditions between blocking assignments
For these cases, stick with Icarus Verilog or commercial simulators for that portion of verification.
With that foundation in place, here are the specific quirks that will catch you off-guard.
The Two-State Reality: No X or Z
This is the single biggest shock for designers coming from industry tools. In standard simulators, any wire or register can hold one of four values: 0, 1, X (unknown), or Z (high-impedance). Verilator converts everything to two-state logic — strictly 0 or 1, nothing else.
The danger is subtle and serious. If you have an uninitialized register that a four-state simulator would flag as X — propagating that unknown forward through your logic and causing your checker to fire — Verilator will silently treat it as 0. Your simulation passes. Your logic looks correct. But you have a latent bug where you forgot to reset a signal, and real silicon or an FPGA running with a non-zero power-on state will misbehave.
Similarly, tri-state buses that float to Z when no driver is active will be treated as 0. Designs that use tri-state logic for bus arbitration or bidirectional pads will produce wrong simulation results without any warning.
The fix is to use the --x-assign unique or --x-initial unique flags during compilation. These flags tell Verilator to randomize the initial values of uninitialized signals — assigning random 0s and 1s rather than a fixed 0. By running multiple simulations with different seeds, you can statistically expose reset and initialization bugs that would otherwise be hidden. It is not the same as true X propagation, but it is the best available approximation in a two-state environment.
The INITIALDLY Warning: Nonblocking Assignments in the Wrong Place
Here is a concrete example from real user experience. Consider this module:
module top;
int a = 1;
int b = 2;
int c = 3;
initial begin
a <= b;
b <= c;
c <= a;
end
endmodule
VCS and Xcelium accept this without complaint. The nonblocking semantics cause all right-hand sides to be captured first (a=1, b=2, c=3), then all assignments to update simultaneously — so after the block, a is 2, b is 3, c is 1. The classic swap.
Verilator fires this warning and c gets the value 2 instead of 1:
%Warning-INITIALDLY: design.v:8:7: Non-blocking assignment '<=' in
initial/final block
a <= b;
^~
... This will be executed as a blocking assignment '='!
The reason is fundamental to how Verilator works. In compiled C++, there is no "end-of-delta-cycle update queue." Verilator cannot model the deferred update semantics of <= inside an initial block, so it executes each statement as a blocking = — sequentially, top to bottom. The result is that a gets b's current value, then b gets the already-updated a, which is functionally wrong compared to the standard.
The fix reflects better RTL discipline. Nonblocking assignments belong in always_ff blocks, not initial blocks:
// RTL: nonblocking in always_ff — correct
always_ff @(posedge clk) begin
a <= b;
b <= c;
c <= a;
end
// Testbench initialization: use blocking assignments
initial begin
a = 1;
b = 2;
c = 3;
end
The INITIALDLY warning is Verilator enforcing good practice. Industry simulators accept the ambiguous code because their event-driven model can handle it. Verilator is telling you to be explicit about your intent.
Case Equality (===) Becomes Regular Equality
In standard Verilog, == is a logical equality that returns X if either operand contains an X or Z bit. The case equality operator === bypasses this — it returns a clean 1 or 0 by including X and Z bits in the comparison literally. This makes === essential for testbench checkers that need to detect unknown or floating states.
In Verilator, since X and Z simply do not exist, === behaves identically to ==. The distinction evaporates. Logic that relies on detecting a floating bus with if (bus === 4'bz) will always evaluate as if the bus is driven to 0 or 1, and your check will never fire. This is another way that initialization and reset bugs can hide completely in Verilator while they would be caught immediately in VCS.
Combinatorial Loops and the UNOPTFLAT Warning
Standard event-driven simulators handle combinatorial loops by repeatedly re-evaluating the affected always blocks until the signals settle (or until the simulator detects an oscillation and aborts). It is messy and usually indicates a bug, but the simulator can at least model it.
Verilator cannot. Because it compiles your logic into a top-to-bottom C++ function, it needs to determine a static evaluation order for every signal. A combinatorial loop means signal A depends on signal B which depends on signal A — there is no valid top-to-bottom ordering, and Verilator will issue an UNOPTFLAT warning.
If the loop cannot be resolved, your simulation may enter an infinite loop at runtime and throw a DIDNOTCONVERGE error. This is not Verilator being overly strict — a real combinatorial loop in synthesized hardware would either oscillate or settle to a glitchy metastable state that no simulation would accurately predict anyway.
The fix is to break the loop in your RTL, which is what you should be doing for synthesis regardless. In cases where the loop is an artifact of how Verilator decomposes your always blocks rather than a true logical loop, the /* verilator split_var */ metacomment can help the compiler understand how to partition and order the logic correctly.
Blocking Assignments in Combinatorial Blocks: The COMBDLY Warning
You already encountered the INITIALDLY warning for nonblocking assignments in initial blocks. A closely related warning, COMBDLY, fires when you use <= inside an always_comb block (or always @(*)).
// This will generate a COMBDLY warning in Verilator
always_comb begin
result <= a & b; // <= in a combinatorial block
end
From Verilator's perspective, a combinatorial block is a pure function: given the current inputs, compute the output. Nonblocking assignment implies a deferred update — a concept that has no meaning in combinational logic and no representation in the generated C++. In a real circuit, combinational logic has no memory of previous values and no clock to schedule updates against.
The fix is straightforward: use blocking assignments = in always_comb blocks and nonblocking assignments <= in always_ff blocks. This is not just a Verilator rule — it is the standard RTL coding guideline that every synthesis and lint tool enforces. If you are seeing COMBDLY warnings, your code has a style problem that could cause simulation-synthesis mismatches even outside of Verilator.
Non-Synthesizable Constructs
Because Verilator is fundamentally a synthesis-oriented tool, it has limited or no support for constructs that exist only for simulation:
force and release: Support is improving across Verilator releases, but historically these were poorly supported. Testbenches that use force to override signals for directed testing may not behave as expected — check the release notes for your specific version.
SystemVerilog classes and UVM: Verilator has minimal support for class-based SystemVerilog. Complex UVM testbench hierarchies — agents, sequences, and scoreboards built from uvm_component and uvm_object — will not compile. Verilator is not a verification platform in the UVM sense and should not be used as a drop-in replacement for VCS when running class-based testbenches.
Hierarchical references: References like top.dut.sub_module.internal_signal work in Verilator, but can become unreliable if the referenced signal is optimized away during C++ compilation. If the optimizer determines a signal is constant or redundant, it may not exist as a named variable in the generated code, and your hierarchical reference will silently produce wrong values or fail to compile.
Sensitivity Lists: Where Verilator Is Smarter Than the Standard
This one is unusual — it is a case where Verilator's behavior is arguably more correct than a strict reading of the standard, but that divergence can still cause problems.
Consider this code with an incomplete sensitivity list:
always @(a) begin
out = a & b; // 'b' is used but not listed in the sensitivity list
end
A standards-compliant event-driven simulator will only re-evaluate this block when a changes. If b changes while a is stable, out will not update — which is technically what the code says, but probably not what the designer intended. In real hardware, the combinational gate reacts to both inputs.
Verilator, in the process of compiling to C++, typically detects that b is a read input and treats the block as if it were always_comb — updating out when either a or b changes.
The danger is the inverse of what you might expect: your Verilator simulation may work correctly, while the same code on an FPGA or in VCS would produce wrong results due to the incomplete sensitivity list. You get a false pass in Verilator that masks a real portability bug. The fix is to always use always_comb (or always @(*)) for combinational logic, letting the tool infer the complete sensitivity list rather than maintaining it by hand.
Verilator's Warnings Are a Feature, Not Noise
A theme runs through every section above: Verilator's warnings are not arbitrary strictness. They reflect real semantic differences between what your RTL says and what Verilator's compiled model can faithfully represent. INITIALDLY, STMTDLY, COMBDLY, UNOPTFLAT — each one points at a place where your code's behavior in Verilator may diverge from a four-state event-driven simulator, and often at a place where your RTL has a latent bug that synthesis will expose anyway.
The practical workflow for this tutorial series is: use Verilator to rapidly verify your synthesizable RTL logic, treat its warnings as a linting pass, fix everything it flags, and then push clean RTL into Yosys with confidence. For testbench constructs that require four-state simulation — checking for uninitialized resets, testing tri-state buses, running UVM sequences — complement Verilator with Icarus Verilog, which is event-driven and handles a much broader slice of the standard at the cost of simulation speed.
Conclusion
Verilator's C++ testbench capability transforms verification from a Verilog-only exercise into a full software engineering discipline. The cycle-accurate simulation model provides the speed needed for comprehensive testing, while C++ integration brings powerful features like file I/O, sophisticated data structures, and standard debugging tools.
Understanding when to use cycle-accurate simulation (most synchronous designs) versus event-driven simulation (asynchronous edge cases) helps you choose the right tool for each verification task. Performance optimization techniques ensure even large designs simulate efficiently, and code coverage analysis provides confidence that your tests are actually thorough.
In the next module, we'll move beyond simulation into the synthesis phase, where your verified RTL transforms into actual gates. The confidence gained from comprehensive linting and simulation—knowing your design is both clean and correct—makes synthesis a much smoother process.