Cocotb for HDL testbench

There are times in life where you don't want to use Matlab, could be for several reasons for example you want to have reusable code outside the xilinx's devices, the problem is easier to tackle in HDL (one good example are the finite state machines) or you are just tired of the simulink interface.

Here we present a nice tool to verify if the verilog code is working properly, also we present some features of the language who allows reutilize the code for other scenarios.

Simple signed multiplier

We want to make a signed multiplier of two fix14_12 numbers, so the full scale output should be fix28_14. Knowing the bit growths of each operation is super important when writing HDLs because the language its not going to give you any advice about the truncation (there are some warnings when you compile, but thats all).

Here is the code:

`default_nettype none


module simple_signed_mult #(

parameter DIN_WIDTH = 14

) (

input wire clk,

input wire signed [DIN_WIDTH-1:0] din1,

input wire signed [DIN_WIDTH-1:0] din2,

output wire signed [2*DIN_WIDTH-1:0] dout

);

reg [2*DIN_WIDTH-1:0] dout_r=0;

assign dout = dout_r;

always@(posedge clk)begin

dout_r <= $signed(din1)*$signed(din2);

end


endmodule

The first thing you should note is the that we are using parameters to set the sizes of the inputs and outputs, so when you instantiate the module you could modify it to meet your requirements.

Also note that at the top of the script there is a `default_nettype none directive. Without that directive if you use a variable that you hadn't defined previously the compiler interpret it as wire. So for example you could get caught in the typical situation where you misspell some variable and the compiler wont give you any error, but the program wont work as you expect. To avoid that the directive specifically state that every variable must be declared.

Also its worth to mention that we had have problems with that directive when compiling in ISE, so when you module its ready remember to comment that directive before compile.


Then there is the signed word in every place in the module, that's our way to tell to the compiler that we are dealing with signed numbers.

To keep things in order we are going to use a separate file to make the testbench and instantiate out multiplier module.

`default_nettype none

`include "simple_signed_mult.v"


module simple_signed_mult_tb #(

parameter DIN_WIDTH = 14

) (

input wire clk,

input wire signed [DIN_WIDTH-1:0] din1,

input wire signed [DIN_WIDTH-1:0] din2,

output wire signed [2*DIN_WIDTH-1:0] dout

);


simple_signed_mult #(

.DIN_WIDTH(DIN_WIDTH)

) simple_signed_mult_inst (

.clk(clk),

.din1(din1),

.din2(din2),

.dout(dout)

);


initial begin

$dumpfile("traces.vcd");

$dumpvars();

end

endmodule

The instantiation of a module follows that structure where there is a point before the variable and then inside the brackets there is the variable that you are connecting with that interface, or in the case of the parameters the value of that parameter.

The "initial" part of the code just tells to the simulator that it should dump the simulation data into the traces.vcd file and that it should save all the variables.

Cocotb

The typical flow for create a new HDL module is write the module as you think it should work, and then create a second HDL to test it. Cocotb give us the alternative option to just write the first module and create the test in python, so you could use the python libraries like numpy, scipy, etc. So you could check that your system is working and compare it with a python implementation, so for example if you are worried about the accuracy of your model you could check how much differ the fixed point implementation with the floating point from python.

To install cocotb just issue: (could be pip3 also, use whatever it works)

pip install cocotb

As cocotb just talks directly with the simulator, we still need a simulator. To keep it everything open source we use icarus. Also a wavescope would be nice, we use gtkwave.

Note that icarus just allow us to use pure verilog, so in icarus we cant simulate fpga primitives like oddr, oserdes, pll, mmcm. or instantiate xilinx ips.

The code for the testbench is

import cocotb

import numpy as np

from cocotb.triggers import ClockCycles

from cocotb.clock import Clock

from cocotb.binary import BinaryValue


def two_comp_pack(values, n_bits, n_int):

""" Values are a numpy array witht the actual values that you want to set in the dut port

n_bits: number of bits

n_int: integer part of the representation

"""

bin_pt = n_bits-n_int

quant_data = (2**bin_pt*values).astype(int)

ovf = (quant_data>2**(n_bits-1)-1)&(quant_data<2**(n_bits-1))

if(ovf.any()):

raise "Cannot represent one value with that representation"

mask = np.where(quant_data<0)

quant_data[mask] = 2**(n_bits)+quant_data[mask]

return quant_data


def two_comp_unpack(values, n_bits, n_int):

""" values: numpy array with the dout.values

"""

bin_pt = n_bits-n_int

mask = values>2**(n_bits-1)-1 ##negative values

out = values.copy()

out[mask] = values[mask]-2**n_bits

out = 1.*out/(2**bin_pt)

return out


@cocotb.test()

async def simple_signed_mult_test(dut, iters=10, thresh=0.05, din_width=14, din_pt=12):

clk = Clock(dut.clk, 10, units="ns")

cocotb.fork(clk.start())

din_int = din_width-din_pt

np.random.seed(10)

dat = (np.random.random([iters,2])-0.5)*(2**(din_int-1)-1)

test_out = await test(dut, dat, iters,thresh, din_width, din_int)


async def test(dut, dat,iters,thresh, din_width, din_int):

data1 = two_comp_pack(dat[:,0],din_width, din_int)

data2 = two_comp_pack(dat[:,1],din_width, din_int)

dut.din1 <= int(data1[0])

dut.din2 <= int(data2[0])

out_vals = []

await ClockCycles(dut.clk,1)

for i in range(len(data1)-1):

dut.din1 <= int(data1[i+1])

dut.din2 <= int(data2[i+1])

await ClockCycles(dut.clk, 1)

out_vals.append(int(dut.dout.value))

#checking loop

out_vals = np.array(out_vals)

out_vals = two_comp_unpack(out_vals, 2*din_width, 2*din_int)

for i in range(len(out_vals)):

print("python floating: %0.5f" %(dat[i,0]*dat[i,1]))

print("hdl fixed: %0.5f \n"%(out_vals[i]))

assert (np.abs(dat[i,0]*dat[i,1]-out_vals[i])<thresh), "fail in {}".format(i)

return 1

Even it seems awful, the structure is simple. The functions two_comp_pack and two_comp_unpack are used to transform python numpy arrays to two complements values who are going to give as input to our module.

The @cocotb.test() decorator marks who is our main test function, in our case is the simple_signed_mult_test, then we initialize the clock of the system. The cocotb.fork lets us run the clock input separated from our main function.

The await keyword means we wait until the conditions is meet to follow with the next instructions, so for example we could write the input values and await for the next clock cycle where I could read the data that is sent by the module.

To access to the verilog variables you could use a sort of oop where the "dut" variable represents our module and the variables are encoded as objects of that class. So for example dut.din1 <= 1 means set din1 equals to one, or dut.dout.value means get the dout output.

With that in mind we enter to our simple_signed_mult_test, initialize the clocks, generate a random set of numbers call the function test and wait until it returns.

Inside the test function we start to write the din1, din2 values each clock cycle and reads the output. Finally we make a final loop where we read the output data and compare it with the python implementation.

The assert keyword will halt the program if it found that the affirmation inside is false, so its a sort of verification that let us automatize the process for different settings.

We are almost there... The last part is make a Makefile which tells to the workflow which verilog module test, which python code look at, etc.

SIM=icarus

TOPLEVEL_LANG = verilog

VERILOG_SOURCES = $(shell pwd)/simple_signed_mult_tb.v

TOPLEVEL = simple_signed_mult_tb

MODULE = simple_signed_mult_test

And finally run:

make clean && make

Then you should see something like this:

And also you should have a generated "traces.vcd" file, which have all the signals involved in this test. We can open it using gtkwave inspect them.

Summary

In this tutorial we show a simple parameterizable multiplier and how to test it using the python library cocotb.