MicroZed Chronicles: Getting Started with Cocotb
Verification of both the modules and top-level testing is often more complex and time consuming than creating the HDL. Previously, we’ve looked at how to use C test benches, verify AXI peripherals, and use UVVM (we predominantly use VHDL). Wherever possible for my blogs, I try to use only tools that are freely accessible. One thing I’ve been meaning to look at for some time now is cocotb which is a free, open source verification frame work.
If you are not familiar with it, cocotb (coroutine-based co-simulation testbench environment) is based on Python coroutines which enables verification of SystemVerilog and VHDL. This allows developers to create testbenches in Python and, of course, brings in all the wider Python libraries and frameworks which can be used for creating stimulus in addition to processing the output of the UUT.
At the highest level, cocotb provides an interface between our chosen simulator and Python.
To achieve this, cocotb uses coroutines and co-simulation. Coroutines are Python functions which can voluntarily suspend operation to enable multiple functions to be run simultaneously. Co-simulation means the design and testbench are simulated independently, meaning when the Python is running, simulation time is not advancing. This is achieved using the simulators’ Verilog Procedural Interface (VPI) and VHDL Procedural Interface (VHPI).
Conceptually, the framework of Python, cocotb, simulator, and UUT/DUT can be seen below.
One of the great elements of cocotb is the wide range of community-developed additions which provide a range of bus functional models that can be used to verify modules which have AXI or Wishbone interfaces, for example.
Provided we have a simulator (e.g., ModelSim, GHDL, Verilator, Rivera-PRO etc.) and meet the rest of the pre requisites, we can use cocotb.
Python development packages (Python/C API headers and embedding library)
GCC 4.8.1+, Clang 3.3+ or Microsoft Visual C++ 14.21+ and associated development packages
GNU Make 3+
For windows – Conda
Installation for my windows cocotb set up was straight forward using a Conda terminal running with admin privileges.
conda install -c msys2 m2-base m2-make pip install cocotb pip install cocotb-bus
Cocotb-bus includes additional bus interfaces and test structures.
Let’s look at a simple application using the scrubber created in the previous blog. To create a cocotb testbench, we will need a Python stimulus file and a makefile along with the DUT. The makefile tells cocotb which simulator to run, the HDL source files, along with the compilation and simulation arguments.
To create an example, I am going to use the scrubber from last week. Since this uses XPM libraries, I am going to target my ModelSim simulator and one of the freely available simulators for this demonstration.
To get started, we need to create a cocotb testbench. This is a Python file into which we import cocotb and several of its packages including clock, timer and rising edge.
The top level of our HDL file is passed into an async test function in the Python file as an argument such as design under test (dut). We identify this function as a cocotb test with the decorator @cocotb.test()
We can access dut hierarchy and signals within this test function, using a dot notion down the hierarchy. For example, if the top level is passed to the function as argument dut containing the signal clk, we can access the signal clock by using dut.clk.
Since we are using synchronous design, we need to be able to create a clock, and in this case, cocotb provides a clock generator function to generate a clock at the required frequency. This can be added to the top of the test function to provide the clock.
To accelerate the test function, we can write several other functions which can be called from the test function. When I am testing the memory scrubber in this case, I created reset, write, read, and corrupt functions which are called from the main test function.
If we need to wait on events within our test function or other functions, we can use the Python await function. For example, we could wait for rising / falling edges of signals or wait for several clock cycles.
The completed cocotb can be observed below.
import cocotb from cocotb.clock import Clock from cocotb.triggers import Timer, RisingEdge async def reset_dut(reset_n, duration_ns): reset_n.value = 1 await Timer(duration_ns, units="ns") reset_n.value = 0 reset_n._log.debug("Reset complete") async def write_mem(addra, dina, wea, ena, writes): for x in range (0,writes): addra.value = x dina.value = x wea.value = 1 ena.value = 1 await RisingEdge(clk) async def corrupt_mem(addra , dina , wea , ena , corrupt , writes): for x in range (0,writes): addra.value = x dina.value = x wea.value = 1 ena.value = 1 if x == 0 or x == 2 or x == 4 or x == 6 or x == 8: corrupt.value = 1 else: corrupt.value = 0 await RisingEdge(clk) async def read_mem(addrb , enb , reads): for x in range (0,reads): addrb.value = x enb.value = 1 await RisingEdge(clk) @cocotb.test() async def run_test(dut): PERIOD = 10 global clk clk = dut.clk dut.regceb.value = 1 dut.ena.value = 0 dut.wea.value = 0 dut.enb.value = 0 dut.dina =0 dut.addra = 0 dut.addrb = 0 dut.test_enable.value = 0 dut.sleep.value = 0 dut.test_enable.value = 0 dut.enable_scrubbing.value = 0 dut.inject_single.value = 0 dut.inject_double.value = 0 cocotb.start_soon(Clock(dut.clk, PERIOD, units="ns").start()) await reset_dut(dut.rstb , 50) dut._log.debug("After reset") await write_mem(dut.addra, dut.dina, dut.wea, dut.ena, 16) dut.ena.value = 0 dut.wea.value = 0 await Timer(20*PERIOD, units='ns') await read_mem(dut.addrb, dut.enb, 16) dut.enb.value = 0 await Timer(20*PERIOD, units='ns') await corrupt_mem(dut.addra, dut.dina, dut.wea, dut.ena, dut.inject_single , 16) dut.ena.value = 0 dut.wea.value = 0 await Timer(20*PERIOD, units='ns') dut.enable_scrubbing.value = 1 await Timer(80*PERIOD, units='ns')
To run the cocotb application, we also need to create a makefile. This makefile tells cocotb the top level language, the top level of the HDL design, and the Python module name. Within the makefile we can also define the simulator, along with simulation and compilation arguments.
TOPLEVEL_LANG ?= vhdl PWD=$(shell pwd) TOPDIR=$(PWD)/ SIM ?= modelsim WAVES ?= 1 COCOTB_HDL_TIMEUNIT = 1ps COCOTB_HDL_TIMEPRECISION = 1ps SIM_ARGS = -t 1ps VHDL_SOURCES = $(PWD)/bram_instance.vhd VHDL_SOURCES += $(PWD)/secded.vhd TOPLEVEL = secded MODULE = bram_cb include $(shell cocotb-config --makefiles)/Makefile.sim
To run the testbench, we need to be in a Conda window, in the same directory as the makefile and the python test file. Running make from this window will result in compilation and simulation of the testbench. With logging output to the Conda window, this is ideal for the self-checking test benches.
If we want to capture the waveform, we can use the WAVE=1 option in the makefile and this will create a WLF or VCD depending upon the simulator used.
Running the newly created testbench, we can capture the waveform and open it in ModelSim to observe the performance of the scrubbing module.
This shows that the waveform is very similar to the one created last week in VHDL and we can see memory accesses, corruption of memory, and the scrubbing correcting the errors.
This blog has presented a simple example of how cocotb can be used to create a testbench. It looks very impressive to me and it’s one of the frameworks we will be using a lot of going forward. There is a huge range of community-developed cocotb resources including many bus and interface models so I will come back and look at how we work with those soon.