Creating a testbench with Verilator

Exploring audio DACs in simulation – Part 2 – 2021-04-02

Having rambled on a little longer than I intended while setting the scene for these experiments, I’m going to set up a simple testbench with verilator to evaluate the performance of several different DACs.

Rather than merely simulate a testbench written in verilog and output the signals to a trace file, Verilator takes a slightly different approach: the component under test is compiled into a link library which can directly interact with a testbench written in C++. For this project this is ideal, since I can use all tools of the C or C++ languages to inject signals into the DACs (even from files if I wish), process the output and log it to a file. I also have the option of saving a trace file which can be examined in GTKWave, though I’m not going to do that just yet.

So let’s start with a very simple 1st-order DAC:

module sigma_delta_dac_1storder
(
input clk,
input reset_n,
input [15:0] d,
output q
);
reg q_reg;
assign q=q_reg;
reg [16:0] sigma;
always @(posedge clk or negedge reset_n) begin
if(!reset_n) begin
sigma <= 17'h8000;
q_reg <= 1'b0;
end else begin
sigma <= sigma + {1'b0,d} + {sigma[16],16'b0};
q_reg <= sigma[16];
end
end
endmodule

(This module, and all the other files needed to repeat these experiments can be found at https://github.com/robinsonb5/DACTests.git)

This module takes an unsigned 16-bit input and emits a bitstream which is sent from the FPGA into a passive low-pass reconstruction filter on the Minimig and MiST boards.

So let’s create a C++ simulation of this module:

verilator --trace --top-module sigma_delta_dac_1storder -cc sigma_delta_dac_1storder.v

There will now be a new directory called obj_dir which contains some c++ source and header files which we can compile along with our testbench – but better yet it also contains a makefile which creates a static linker library from those files – so let’s take advantage of that:

make -C obj_dir/ -fVsigma_delta_dac_1storder.mk

obj_dir now contains a link library, Vsigma_delta_dac_1storder__ALL, which we’ll link with shortly.

The nice thing about this is that it’s all very makefile-friendly – provided the module’s name within the Verilog file matches its filename, building this library can be done inductively with a makefile rule such as:

obj_dir/V%__ALL.a: %.v
verilator --trace --top-module $* -cc $*.v
make -C obj_dir -f V$*.mk

Now we need a testbench – something that will just create a sine-wave, feed it into the DAC, capture and emit the DAC’s output…

 include <cstdio>
include <cmath>

include DACHEADER

include "verilated.h"
include "verilated_vcd_c.h"

undef TRACE

DACHEADER is externally defined in the Makefile but it’s a header file which verilator generates, describing the module under test as a class with the various ports defined as members. Definiting it this way allows me to use the self-same testbench with several different DACS. I use the TRACE define to determine whether or not I generate a .vcd file for viewing in GTKWave. They can be both large and slow to generate, so there’s no point creating one if I’m not going to use it.

 static VerilatedVcdC *trace;

// Declare the global testbench, of a type externally defined
typedef DAC testbench;
static testbench *tb;

// Simulate at the speed of the MiST Minimig port's SDRAM clock
define MHz 113.44
static double timestamp = 0;

void tick() {
tb->clk = 1;
tb->eval();
tb->clk = 0;
tb->eval();
trace->dump(timestamp);
timestamp += 500/MHz;
}

define SAMPLERATE 44100.0
define SIGNAL_HZ 200.0
define SAMPLES 8192
define OVERSAMPLE 2048
define OUTFILTERSHIFT 12

// Return a sample from a sine wave
double sample(double s)
{
double period=SAMPLERATE/SIGNAL_HZ;
return(sin((s*s*M_PI)/period));
}

This should all be fairly self-explanatory – in the tick() function we advance the clock, and call eval() after each edge.

An oversampling ratio of 2048 is absurdly high – but necessary for a 1st-order DAC, because of the idle tones I described last time. So now we start our actual simulation function:

void run_test()
{
int outfilter=0x8000<<OUTFILTERSHIFT;
int out;
int s;
for(int i=0;i<SAMPLES;++i)
{
  int samp=(32767+32767*sample(i));
  tb->d=samp;
  for(int j=0;j<OVERSAMPLE;++j)
  {
  tick();

So far so good – we’ve fed a sample from a sinewave into the DAC, and have just entered an inner loop which we repeat as many times as we’re oversampling the sinewave.

So how do we handle the output? A high frequency bitstream isn’t ideal for subsequent analysis, and the physical board passes this bitstream through a low-pass filter to reconstruct the signal before it reaches an amplifier – so we need to mimic that filter.

      s=0xffff*tb->q;
// Single-pole approximation of the reconstruction filter
  outfilter+=((s<<OUTFILTERSHIFT)-outfilter)>>OUTFILTERSHIFT;
  } // Output a sample in signed 16-bit little-endian
  out=(outfilter>>OUTFILTERSHIFT)-32768;
  putchar(out&255);
  putchar((out>>8)&255);
}
}

This is the simplest form of Infinite Impulse Response filter, with the coefficient determined by the shift value. Here I’m using a shift of 12, so the coefficient is (1/4096). Having completed the inner loop for oversampaling, during which the outputs are accumulated in the filter, we write an output sample to stdout (yes, I’m still thinking in terms of C, not C++!) in 16-bit signed little-endian format.

Finally, we have a main() function where the basic housekeeping’s done – it looks like this:

int main(int argc, char **argv)
{
// Initialize Verilators variables
 Verilated::commandArgs(argc, argv);
Verilated::traceEverOn(true);
trace = new VerilatedVcdC;

// Create an instance of our module under test
tb = new testbench;

#ifdef TRACE
tb->trace(trace, 99);
trace->open("wave.vcd");
#endif

// Reset the testbench tick();
tb->reset_n = 0;
tick();
tick();
tb->reset_n = 1;

// Run the test
run_test();

#ifdef TRACE
trace->close();
#endif

delete tb;
return(0);
}

To compile our testbench we have to make sure two directories are in the includes search path. One is the obj_dir directory, the other is the directory where verilator’s own includes are installed. On my machine this is /usr/share/verilator/include – you can find the VERILATOR_ROOT for your installation by typing “verilator -V”

There are two C++ files in this verilator include directory which must be compiled alongside the testbench – these are verilated.cpp and verilated_vcd_c.cpp (the latter providing support for trace files.)

So assuming we’ve defined a makefile variable DAC to the particular DAC under test – in this case “sigma_delta_dac_1storder”, we compile our testbench with:

g++ -DDAC=V$(DAC) -DDACHEADER=\"obj_dir/V$(DAC).h\" -I obj_dir -I$(VERILATOR_DIR) testbench.cpp $(VERILATOR_DIR)/verilated.cpp $(VERILATOR_DIR)/verilated_vcd_c.cpp obj_dir/V$(DAC)__ALL.a -o testbench

Next time I’ll look at the results of testing with four different DACs, and how the real world can throw some surprises at us!

Leave a Reply

Your email address will not be published.