Verilator www.itsembedded.com

In Part 1, we discussed the basics of using Verilator and writing C++ testbenches for Verilog/SystemVerilog modules. In this guide, we will improve the testbench with randomized initial values for signals, add a reset signal, and lastly add input stimulus and output checking to get going with some basic verification functionality.

Getting started

This guide is a direct continuation from Part 1. You can get the finished sources for the example Verilator project used in this tutorial from Github and explore as you wish:

    git clone https://github.com/n-kremeris/verilator_basics
    git checkout verilator_pt2

Or, follow the guide below as we continue from Part 1.

Long commands are for troglodytes

Before doing anything to our testbench, it must be said that nobody likes typing the same commands over and over again. And as we’re not cavemen, we will use (Make)[https://www.gnu.org/software/make/] to quickly build and run our simulation, swiftly advancing our hardware design capabilities into the stone age.

Most of the build commands used in the Makefile below should be familiar from Part 1, but lets briefly go over them again just in case:

verilator -Wall --trace -cc alu.sv --exe tb_alu.cpp

This converts our alu.sv source to C++ and generates build files for building the simulation executable. We use -Wall to enable all C++ errors, --trace to enable waveform tracing, -cc alu.sv to convert our alu.sv module to C++, and --exe tb_alu.cpp to tell Verilator which file is our C++ testbench.

make -C obj_dir -f Valu.mk Valu

This builds our simulation executable from the testbench and the converted sources. We tell Make to change the working directory to obj_dir, use the build file named Valu.mk and build the target named Valu

./obj_dir/Valu

This runs our simulation executable which simulates the testbench and generates our waveforms.

In your work directory, create a file named Makefile and paste the following contents:

NOTE: Do not drag-select and CTRL+C, instead, hover your mouse and click on the newly appeared COPY button on the top right corner of the text box.

MODULE=alu

.PHONY:sim
sim: waveform.vcd

.PHONY:verilate
verilate: .stamp.verilate

.PHONY:build
build: obj_dir/Valu

.PHONY:waves
waves: waveform.vcd
	@echo
	@echo "### WAVES ###"
	gtkwave waveform.vcd

waveform.vcd: ./obj_dir/V$(MODULE)
	@echo
	@echo "### SIMULATING ###"
	@./obj_dir/V$(MODULE)

./obj_dir/V$(MODULE): .stamp.verilate
	@echo
	@echo "### BUILDING SIM ###"
	make -C obj_dir -f V$(MODULE).mk V$(MODULE)

.stamp.verilate: $(MODULE).sv tb_$(MODULE).cpp
	@echo
	@echo "### VERILATING ###"
	verilator -Wall --trace -cc $(MODULE).sv --exe tb_$(MODULE).cpp
	@touch .stamp.verilate

.PHONY:lint
lint: $(MODULE).sv
	verilator --lint-only $(MODULE).sv

.PHONY: clean
clean:
	rm -rf .stamp.*;
	rm -rf ./obj_dir
	rm -rf waveform.vcd

The Makefile should be straightforward for those who are familiar with Make. If you have not used Make before, please take a look another one of my guides on using Make for simulation.

Once you have saved the file, you will be able to quickly rebuild the entire simulation by running make sim in your terminal, open GTKWave using make waves, Verilate your design using make verilate, or build the verilated sources using make build.

Note that there is an aditional make lint target, which calls Verilator with --lint-only. This is useful to quickly parse your Verilog/SystemVerilog source files and check for problems. This can be used to check over your sources even if you’re not using Verilator for simulating.

Lastly there’s a make clean target that removes all of the junk generated in the build progress.

And with all of that out of the way, let’s make that testbench sparkle.

Randomized initial values

One of the observations from Part 1 was that Verilator is a two state simulator, meaning that it only supports logic values of 1 and 0, and there is no support for X (and only limited support for Z). Verilator therefore initializes all signals to 0 by default, which can be seen in Fig.1 from our previous simulation results:

Signals initialized to 0 www.itsembedded.com Fig. 1: Everything is initialized to 0 by default

Additionally, if you have code that assigns X to a wire or reg, then that by default also gets the value of 0.

We can, however, change this behavior via command-line parameters - we can have Verilator initialize all signals to 1, or, better yet, to a random value. This will allow us to check if our reset signal works, once we add it to the testbench.

To make our testbench initialize signals to random values, we first need to call Verilated::commandArgs(argc, argv); before creating the DUT object:

int main(int argc, char** argv, char** env) {
    Verilated::commandArgs(argc, argv);
    Valu *dut = new Valu;
<...>

Then, we need to update our verilation target build command by adding --x-assign unique and --x-initial unique. Line 31 of our Makefile should now therefore look like this:

verilator -Wall --trace --x-assign unique --x-initial unique -cc $(MODULE).sv --exe tb_$(MODULE).cpp

Lastly, we need to pass +verilator+rand+reset+2 to our simulation executable, to set set the runtime signal initialization technique to random. This means changing the Line 21 in our Makefile to:

@./obj_dir/V$(MODULE) +verilator+rand+reset+2

Now if we do make clean and make waves, we will see that signals are now initialized to random values at the start of the simulation:

Verilator random initialization www.itsembedded.com Fig. 2: Random initialization

With the signals now randomised, we can take a look at applying our reset signal.

DUT reset

To reset our DUT and it’s input signals, we update the main loop of our testbench to look like this:

while (sim_time < MAX_SIM_TIME) {
    dut->rst = 0;
    if(sim_time > 1 && sim_time < 5){
        dut->rst = 1;
        dut->a_in = 0;
        dut->b_in = 0;
        dut->op_in = 0;
        dut->in_valid = 0;
    }

    dut->clk ^= 1;
    dut->eval();
    m_trace->dump(sim_time);
    sim_time++;
}

On Line 3, I have arbitrarily picked that I want my reset to happen between clock edges 3 and 5. You may of course adjust this if required.

On Line 4, the reset is asserted high, and on the succeding lines all the inputs to the DUT are reset to 0.

Lines 11-14 are not modified. We tick the clock and increment the time counter.

Line 2 is added to reset the counter back to 0 on subsequent loop iterations. Together, lines 2-3-4 would be equivalent to the following in SystemVerilog code:

always_comb begin
    dut.rst = 1'b0;
    if (sim_time >= 3 && sim_time < 6) begin
        dut.rst = 1'b1;
    end
end

Re-running the simulation now gives us this:

Verilator reset www.itsembedded.com Fig. 3: Reset signal in action

As you can see from Figure 3, our reset signal is successfully generated in the testbench. To keep the main loop a bit cleaner, lets move the reset stuff into a separate function outside the main():

void dut_reset (Valu *dut, vluint64_t &sim_time){
    dut->rst = 0;
    if(sim_time >= 3 && sim_time < 6){
        dut->rst = 1;
        dut->a_in = 0;
        dut->b_in = 0;
        dut->op_in = 0;
        dut->in_valid = 0;
    }
}

Then we add a call to dut_reset to the main loop:

while (sim_time < MAX_SIM_TIME) {
    dut_reset(dut, sim_time);

    dut->clk ^= 1;
    dut->eval();
    m_trace->dump(sim_time);
    sim_time++;
}

Now that our reset works, lets take a look at some actual stimuli and verification code.

Basic Verification

At this point, we have the following in our main simulation loop:

while (sim_time < MAX_SIM_TIME) {
    dut_reset(dut, sim_time);

    dut->clk ^= 1;
    dut->eval();
    m_trace->dump(sim_time);
    sim_time++;
}

Now if we were simulating a Verilog/SystemVerilog testbench as the dut instead of our alu module, we could add a check for Verilated::gotFinish() and stop the simulation if that is set to true. This happens when $finish() gets called from Verilog/SystemVerilog. Our C++ testbench would then be sufficient for simulating a Verilog/SystemVerilog testbench.

That’s not going to be enough for us though, as we need to insert stimulus and verification code somewhere in the C++ testbench main loop to drive and check our DUT.

Clock edge counter

There are many ways to skin a dead horse with a single stone, but here is what we’re going to do for now:

Firstly, we’ll create a new variable for counting positive clock edges. This variable will be of the same type as sim_time:

    vluint64_t sim_time = 0;
    vluint64_t posedge_cnt = 0;

Next, we modify our edge generation code by adding a positive edge counter:

dut->clk ^= 1;            // Invert clock
dut->eval();              // Evaluate dut on the current edge
if(dut->clk == 1){
    posedge_cnt++;        // Increment posedge counter if clk is 1
}
m_trace->dump(sim_time);  // Dump to waveform.vcd
sim_time++;               // Advance simulation time

Adding that counter between eval and dump gives us something similar to the following in Verilog:

initial posedge_cnt <= '0;
always_ff @ (posedge clk, posedge rst) begin
    posedge_cnt <= posedge_cnt + 1'b1;
end

And at this point, we can finally start verifying our ALU.

Primitive DUT stimuli and checks

Let’s take a look again at the expected waveforms for our ALU:

itsembedded.com Fig. 4: Expected ALU behavior

Ignoring the a,b and operation inputs, as well as the data output, lets first check that our input valid signal propagates to the output.

We know that we have 2 register stages, which, when simplified, would look like this:

always_ff @ (posedge clk) begin
    in_valid_r <= in_valid;
    out_valid <= out_valid_r;
end

So if we applied 1 to in_valid on the 5th positive clock edge, we should see a 1 on out_valid after two clock cycles, or in other words, on the 7th positive clock edge. Here’s how we check for that:

while (sim_time < MAX_SIM_TIME) {
    dut_reset(dut, sim_time);

    dut->clk ^= 1;
    dut->eval();

    dut->in_valid = 0;
    if (dut->clk == 1){
        posedge_cnt++;
        if (posedge_cnt == 5){
            dut->in_valid = 1;       // assert in_valid on 5th cc
        }
        if (posedge_cnt == 7){
            if (dut->out_valid != 1) // check in_valid on 7th cc
                std::cout << "ERROR!" << std::endl;
        }
    }

    m_trace->dump(sim_time);
    sim_time++;
}

What the highlighted code accomplishes would be similar to this:

always_comb begin
    in_valid = 0;
    if (posedge_cnt == 5)
        in_valid = 1;

    if (posedge_cnt == 7)
        assert (out_valid == 1) else $error("ERROR!")
end

And here is how it all works when simulated:

Verilator waveform explanation itsembedded.com Fig. 5: Graphic explanation for the valid checking
(click here for high res version)

The main point here is to make sure that stimuli/checking code you write follows this order of operations:

  • Set clock to 1, evaluate to create positive edge, and then set inputs / check outputs before dumping and incrementing sim time.

  • On the next positive clock edge inside the while() loop, the inputs set previously will propagate into the design during eval, and then right after eval the inputs should be reset to their default values.

Assertion-like signal monitoring

Setting the in_valid on the 5th edge and checking that the out_valid is 1 definitely works, but if we wanted to check the valids on more clock cycles, we would need to add a lot more checks. Additionally, we’re not checking that out_valid is 0 where it’s supposed to be, meaning our out_valid could be stuck at 1 and the testbench would not fail. Our verification code could therefore be significantly improved by writing some C++ code to continuously monitor in_valid and out_valid, similarly to how SystemVerilog assertions work.

We can write a function for this as follows:

#define VERIF_START_TIME 7
void check_out_valid(Valu *dut, vluint64_t &sim_time){
    static unsigned char in_valid = 0; //in valid from current cycle
    static unsigned char in_valid_d = 0; //delayed in_valid
    static unsigned char out_valid_exp = 0; //expected out_valid value

    if (sim_time >= VERIF_START_TIME) {
        // note the order!
        out_valid_exp = in_valid_d;
        in_valid_d = in_valid;
        in_valid = dut->in_valid;
        if (out_valid_exp != dut->out_valid) {
            std::cout << "ERROR: out_valid mismatch, "
                << "exp: " << (int)(out_valid_exp)
                << " recv: " << (int)(dut->out_valid)
                << " simtime: " << sim_time << std::endl;
        }
    }
}

The VERIF_START_TIME is needed to make sure we are not running this checking code before or during reset, to prevent false error detection. If you refer to Fig. 5, you will see that rst goes back to 0 on 6ps (equal to sim_time of 6), so a sim_time of 7 is where we should start checking our valid.

The checking code is pretty simple - it just models the register pipeline between in_valid and out_valid. We can replace the original code with the above function as follows:

while (sim_time < MAX_SIM_TIME) {
    dut_reset(dut, sim_time);

    dut->clk ^= 1;
    dut->eval();

    if (dut->clk == 1){
        dut->in_valid = 0;
        posedge_cnt++;
        if (posedge_cnt == 5){
            dut->in_valid = 1;
        }
        check_out_valid(dut, sim_time);
    }

    m_trace->dump(sim_time);
    sim_time++;
}

If you run the simulations now, you should not get any errors, because we’ve already checked and know that the valid signal propagates correctly. However, to absolutely make sure that the new code works, we can go into our alu.sv and modify the output stage to always set out_valid to permanently be 1:

always_ff @ (posedge clk, posedge rst) begin
        if (rst) begin
                out       <= '0;
                out_valid <= '0;
        end else begin
                out       <= result;
                out_valid <= 1'b1;  //**** this should be in_valid_r ****//
        end
end

Running the simulations again we’ll get the following output:

### SIMULATING ###
./obj_dir/Valu +verilator+rand+reset+2
ERROR: out_valid mismatch, exp: 0 recv: 1 simtime: 8
ERROR: out_valid mismatch, exp: 0 recv: 1 simtime: 10
ERROR: out_valid mismatch, exp: 0 recv: 1 simtime: 14
ERROR: out_valid mismatch, exp: 0 recv: 1 simtime: 16
ERROR: out_valid mismatch, exp: 0 recv: 1 simtime: 18

Cool, now we’re really getting somewhere.

Random valid generation

Before wrapping up this part of the Verilator guide series, let’s also quickly replace that single assignment to in_valid with something that randomly sets it to a 1 or a 0.

For that, we can include the C++ header cstdlib:

#include <cstdlib>

and use the pseudo-random number generation function rand() for generating random 1’s and 0’s in a custom set_rnd_out_valid function:

void set_rnd_out_valid(Valu *dut, vluint64_t &sim_time){
    if (sim_time >= VERIF_START_TIME) {
        dut->in_valid = rand() % 2; // generate values 0 and 1
    }
}

We also need to seed the random number generator by calling srand, which can be put right at the start of the main function:

int main(int argc, char** argv, char** env) {
    srand (time(NULL));
    Verilated::commandArgs(argc, argv);
    Valu *dut = new Valu;
    <...>

We should also increase the MAX_SIM_TIME to something more substantial, like 300:

#define MAX_SIM_TIME 300

And, after running make sim and make waves, here are the results of our new self-checking random simulation:

Verilator random verification itsembedded.com Fig. 6: Updated simulations with random valid

Finished testbench

Here is the current finished version of our C++ testbench:

#include <stdlib.h>
#include <iostream>
#include <cstdlib>
#include <verilated.h>
#include <verilated_vcd_c.h>
#include "Valu.h"
#include "Valu___024unit.h"

#define MAX_SIM_TIME 300
#define VERIF_START_TIME 7
vluint64_t sim_time = 0;
vluint64_t posedge_cnt = 0;

void dut_reset (Valu *dut, vluint64_t &sim_time){
    dut->rst = 0;
    if(sim_time >= 3 && sim_time < 6){
        dut->rst = 1;
        dut->a_in = 0;
        dut->b_in = 0;
        dut->op_in = 0;
        dut->in_valid = 0;
    }
}

void check_out_valid(Valu *dut, vluint64_t &sim_time){
    static unsigned char in_valid = 0; //in valid from current cycle
    static unsigned char in_valid_d = 0; //delayed in_valid
    static unsigned char out_valid_exp = 0; //expected out_valid value

    if (sim_time >= VERIF_START_TIME) {
        out_valid_exp = in_valid_d;
        in_valid_d = in_valid;
        in_valid = dut->in_valid;
        if (out_valid_exp != dut->out_valid) {
            std::cout << "ERROR: out_valid mismatch, "
                << "exp: " << (int)(out_valid_exp)
                << " recv: " << (int)(dut->out_valid)
                << " simtime: " << sim_time << std::endl;
        }
    }
}

void set_rnd_out_valid(Valu *dut, vluint64_t &sim_time){
    if (sim_time >= VERIF_START_TIME) {
        dut->in_valid = rand() % 2;
    }
}

int main(int argc, char** argv, char** env) {
    srand (time(NULL));
    Verilated::commandArgs(argc, argv);
    Valu *dut = new Valu;

    Verilated::traceEverOn(true);
    VerilatedVcdC *m_trace = new VerilatedVcdC;
    dut->trace(m_trace, 5);
    m_trace->open("waveform.vcd");

    while (sim_time < MAX_SIM_TIME) {
        dut_reset(dut, sim_time);

        dut->clk ^= 1;
        dut->eval();

        if (dut->clk == 1){
            dut->in_valid = 0;
            posedge_cnt++;
            set_rnd_out_valid(dut, sim_time);
            check_out_valid(dut, sim_time);
        }

        m_trace->dump(sim_time);
        sim_time++;
    }

    m_trace->close();
    delete dut;
    exit(EXIT_SUCCESS);
}

You can also get all the finished sources as described here.

Conclusion

The way C++ testbenches are written is certainly different from how one would design a testbench in Verilog/SystemVerilog, but from the examples given in this guide, you can see how individual functional pieces that would be written in Verilog look similar in C++. Building an intimate understanding of the correct ordering of C++ calls to create clock edges, stimulate/check signals, and dump waveform values is therefore critical if you want to apply your Verilog testbench writing skills to C++.

And although the current version of our testbench is still quite basic, it is already starting to resemble a more advanced verification environment. The testbench now initializes all signals to random values, and contains both random stimulus and continuous assertion-like monitoring of at least one of the outputs.

What’s next?

In Part 3, we will continue expanding our C++ testbench to verify the addition and subtraction functionality of our ALU.



If you have any questions or observations regarding this guide

Feel free to add me on my LinkedIn, I’d be happy to connect!

Or send me an email: