Friday, June 23, 2023

CoCoTB based verification: A small example

 Verilog RTL verification

CoCoTB based verification: A small example

Introduction to CoCoTB: A Simple Testbench Example


Introduction

CoCoTB (Coroutine-based Co-simulation Test Bench) is an open-source Python library used for verification and testing of hardware designs. It provides a framework for writing testbenches using Python coroutines, allowing easy integration of simulation and test automation. In this blog post, we will explore a small example of a CoCoTB testbench simulated using free simulator icarus  and understand the key concepts and features it offers.

 Installation of Python, CoCoTB and icarus  and its dependencies is not described in this blog post. Detailed installation instructions can be found in the official CoCoTB documentation (https://docs.cocotb.org/) and on many Youtube videos ( e.g. my favorite is: cocotb tutorial Part 0 : Setting the environment)

Directory structure and files

All files are in two directories:

  • <your starting directory>/hdl

  • <your starting directory>/tests

  • <your starting directory>/tests/wrappers

Here is the list of  all the files used in this example:

  • <your starting directory>/hdl/SimpleDUT.v

  • <your starting directory>/tests/SimpleDUT_test.py

  • <your starting directory>/tests/Makefile

  • <your starting directory>/tests/wrappers/SimpleDUT_test.v

Here is a source code of all files explained

SimpleDUT.v (The DUT):

Our example involves a simple DUT with a single input 4-bit signal "din" and an output 4-bit signal "dout". The function of the DUT is simple: "dout" is always equal to "din".

module SimpleDUT (
    input clk,
    input [3:0] din,
    output [3:0] dout
);
    // Logic for the DUT
    assign dout = din;
endmodule

There is a “wrapper” for the DUT (SimpleDUT_test.v) just to add a capability to collect VCD file (SimpleDUT.vcd) to later display waveforms of verification/simulation run:

module SimpleDUT_test(
    input clk,    
input [3:0] din,
    output [3:0] dout
);
SimpleDUT SimpleDUT (
  .clk(clk),
  .din(din),
  .dout(dout));

initial begin
$dumpfile("SimpleDUT.vcd");
$dumpvars;
end
endmodule

  

A Python/CoCoTB testbench integrated with a self-checking test: SimpleDUT_test.py

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, Timer, FallingEdge
from cocotb_bus.drivers import BusDriver
from cocotb.result import TestFailure
from cocotb.triggers import Event


#The SimpleDUTDriver is responsible for driving inputs to the din signal of the DUT.
class SimpleDUTDriver(BusDriver):
    _signals = ["din"# Define the list of signals to be driven

    def __init__(self, dut, clock):
        BusDriver.__init__(self, dut, None, dut.clk)  # Connect to the clock signal
       
@cocotb.coroutine
# The stimulus coroutine drives inputs to the DUT's din signal using the dut.din assignment. It uses the FallingEdge trigger to synchronize with the DUT's clock.
async def stimulus(dut):
    dut._log.info("Running stimulus...")

    # Drive some inputs
    for i in range(16):
        dut.din = i
        await FallingEdge(dut.clk)

    dut._log.info("Stimulus complete.")

@cocotb.coroutine
# The check_output coroutine verifies the DUT's output by comparing it with the expected values. It raises a TestFailure if the output does not match the expected value.
async def check_output(dut):
    dut._log.info("Checking output...")
    await Timer(1, "ns")
    # Verify the output
    for i in range(16):
        expected_output = i
        if dut.dout != expected_output:
            raise TestFailure(f"Output mismatch: Expected {expected_output}, got {dut.dout}")
       
        await RisingEdge(dut.clk)

    dut._log.info("Output check passed.")

@cocotb.test()
async def test_simple_dut(dut):
    cocotb.start_soon(Clock(dut.clk, 10, "ns").start())
    cocotb.start_soon(stimulus(dut))


      #By using await check_output(dut), the test function will pause its execution at that point and resume only when the check_output coroutine has completed.
    await check_output(dut)

 

Testbench/self-checking test functionality is simple: 

  • on every falling edge of the clock we apply an input value to the DUT in a range of values 0-15. 

  • further on every rising edge of the clock we verify that the output of DUT is the same as the input applied.

A waveform detail of the verification run:


Makefile

A commands to execute the verification/simulation run: 

  • cd  <your starting directory>/tests/

  • make SimpleDUT

SIM ?= icarus
TOPLEVEL_LANG ?= verilog
VERILOG_SOURCES += $(PWD)/../hdl/SimpleDUT.v
VERILOG_SOURCES += $(PWD)/wrappers/SimpleDUT_test.v
all: SimpleDUT
SimpleDUT:
rm -rf sim_build
$(MAKE) sim MODULE=SimpleDUT_test TOPLEVEL=SimpleDUT_test
include $(shell cocotb-config --makefiles)/Makefile.sim


Simulation log file

rm -rf sim_build

make sim MODULE=SimpleDUT_test TOPLEVEL=SimpleDUT_test

make[1]: Entering directory '<your starting directory>/tests'

rm -f results.xml

make -f Makefile results.xml

make[2]: Entering directory '<your starting directory>/tests'

mkdir -p sim_build

/usr/bin/iverilog -o sim_build/sim.vvp -D COCOTB_SIM=1 -s SimpleDUT_test -f sim_build/cmds.f -g2012   <your starting directory>/tests/../hdl/or_gate.v <your starting directory>/tests/wrappers/or_test.v <your starting directory>/tests/wrappers/ifc_test.v <your starting directory>/tests/../hdl/ifc_or.v <your starting directory>/tests/../hdl/FIFO1.v <your starting directory>/tests/../hdl/FIFO2.v <your starting directory>/tests/../hdl/SimpleDUT.v <your starting directory>/tests/wrappers/SimpleDUT_test.v

rm -f results.xml

MODULE=SimpleDUT_test TESTCASE= TOPLEVEL=SimpleDUT_test TOPLEVEL_LANG=verilog \

         /usr/bin/vvp -M <your starting directory>/venv/lib/python3.11/site-packages/cocotb/libs -m libcocotbvpi_icarus   sim_build/sim.vvp 

     -.--ns INFO     gpi                                ..mbed/gpi_embed.cpp:105  in set_program_name_in_venv        Using Python virtual environment interpreter at <your starting directory>/venv/bin/python

     -.--ns INFO     gpi                                ../gpi/GpiCommon.cpp:101  in gpi_print_registered_impl       VPI registered

     0.00ns INFO     cocotb                             Running on Icarus Verilog version 11.0 (stable)

     0.00ns INFO     cocotb                             Running tests with cocotb v1.8.0 from <your starting directory>/venv/lib/python3.11/site-packages/cocotb

     0.00ns INFO     cocotb                             Seeding Python random module with 1687439769

     0.00ns INFO     cocotb.regression                  Found test SimpleDUT_test.test_simple_dut

     0.00ns INFO     cocotb.regression                  running test_simple_dut (1/1)

     0.00ns INFO     cocotb.SimpleDUT_test              Running stimulus...

<your starting directory>/tests/SimpleDUT_test.py:26: DeprecationWarning: Setting values on handles using the ``dut.handle = value`` syntax is deprecated. Instead use the ``handle.value = value`` syntax

  dut.din = i

     0.00ns INFO     cocotb.SimpleDUT_test              Checking output...

   155.00ns INFO     cocotb.SimpleDUT_test              Stimulus complete.

   160.00ns INFO     cocotb.SimpleDUT_test              Output check passed.

   160.00ns INFO     cocotb.regression                  test_simple_dut passed

   160.00ns INFO     cocotb.regression                  ****************************************************************************************

                                                        ** TEST                            STATUS  SIM TIME (ns)  REAL TIME (s)  RATIO (ns/s) **

                                                        ****************************************************************************************

                                                        ** SimpleDUT_test.test_simple_dut   PASS         160.00           0.00      52059.02  **

                                                        ****************************************************************************************

                                                        ** TESTS=1 PASS=1 FAIL=0 SKIP=0                  160.00           0.03       5276.97  **

                                                        ****************************************************************************************

                                                        

VCD info: dumpfile SimpleDUT.vcd opened for output.

VCD warning: $dumpvars: Unsupported argument type (vpiPackage)

make[2]: Leaving directory '<your starting directory>/tests'

make[1]: Leaving directory '<your starting directory>/tests'


The Test Flow

The test flow is orchestrated using coroutines, which are functions that can be paused and resumed asynchronously. The testbench comprises three coroutines: "stimulus", "check_output", and "test_simple_dut". Let's examine each of them in detail.

The "stimulus" Coroutine:

The "stimulus" coroutine is responsible for driving inputs to the DUT's "din" signal. In each iteration of the loop, it assigns a value to "din" and waits for a falling edge of the clock signal using the "FallingEdge" trigger. This ensures synchronization with the DUT's clock. The stimulus generation continues for 16 iterations, simulating all possible input scenarios.

The "check_output" Coroutine:

The "check_output" coroutine verifies the DUT's output by comparing it with the expected values. It uses the "RisingEdge" trigger to wait for rising edges of the clock signal, ensuring synchronization. Within the loop, it checks if the output "dout" matches the expected value. If not, it raises a "TestFailure" exception, indicating a mismatch.

The "test_simple_dut" Coroutine:

The "test_simple_dut" coroutine serves as the main test function annotated with the "@cocotb.test()" decorator. It sets up the clock, starts the "stimulus" coroutine, and then awaits the completion of the "check_output" coroutine. This ensures that the test execution pauses until the output verification is complete.

Some CoCoTB testbench features/building blocks


from cocotb.triggers import RisingEdge

For example, you can use await RisingEdge(signal) to wait until the specified signal experiences a rising edge before proceeding to the next step in your test.

RisingEdge: Waits for the rising edge of a signal to occur before proceeding.

Edge: Waits for any edge (rising or falling) of a signal to occur before proceeding.



from cocotb.triggers import Timer

The Timer class is a trigger that allows you to introduce a delay or wait for a specific amount of simulation time before proceeding to the next step in the testbench. 

e.g.

# Wait for 100 ns using the Timer trigger
    await Timer(100, "ns")


from cocotb.triggers import Event

Event: A trigger that can be used for synchronization between different parts of the testbench. It can be used to signal or wait for events to occur.

The Event trigger is useful for synchronizing different parts of the testbench, allowing one coroutine or process to wait until another process triggers the event.

e.g.  The Event trigger basic functionality example

import cocotb
from cocotb.triggers import Event

@cocotb.coroutine
async def wait_for_event(event):
    await event.wait()  # Wait for the event to be triggered
    # Perform actions after the event has occurred

@cocotb.test()
async def my_test(dut):
    # Create an event
    # the test case my_test creates an instance of the Event class called my_event
    my_event = Event()

    # Start a coroutine that waits for the event
# It then starts a coroutine wait_for_event that waits for the event to be triggered using await event.wait().
    event_waiter = cocotb.fork(wait_for_event(my_event))

    # Trigger the event after a delay
# After a delay of 100 nanoseconds, the event is triggered using my_event.set()
# This causes the coroutine to continue execution and perform actions after the event has occurred.
    await Timer(100, "ns")
    my_event.set()  # Trigger the event

    # Continue with the test
    # ...


Here are the most common functions/methods associated with the Event class in Cocotb:

  1. Event()          - Constructor: Creates an instance of the Event class.

  2. Event.is_set() - Method: Returns a boolean indicating whether the event is currently set (True) or unset (False).

  3. Event.wait()     - Method: Suspends the execution of a coroutine until the event is set. If the event is already set, the coroutine continues immediately.

  4. Event.set()    - Method: Sets the event, allowing any coroutines waiting on it to proceed.

  5. Event.clear()  - Method: Resets the event to an unset state. Any coroutines waiting on the event will be suspended until the event is set again.

e.g. The Event trigger usage of Event.wait(),Event.set(), Event.clear()  

import cocotb
from cocotb.triggers import Event

@cocotb.coroutine
# my_coroutine coroutine waits for the event using await event.wait()
async def my_coroutine(event):
    print("Coroutine: Waiting for event...")
    await event.wait()  # Suspend execution until the event is set
    print("Coroutine: Event is set!")
    await event.wait()  # Suspend execution again after the event is set
    print("Coroutine: Event is set again!")

@cocotb.test()
# Inside the my_test test case, first there is a start of coroutine my_coroutine then  the event is initially set using event.set() after a delay. Then, after some additional delay, the event is cleared using event.clear(). Later, the event is set again using event.set().
async def my_test(dut):
    event = Event()  # Create an instance of the Event class

    # Start a coroutine
    cocotb.fork(my_coroutine(event))

    # Delay for some time
    await cocotb.triggers.Timer(100, "ns")

    # Set the event to allow the coroutine to proceed
    event.set()

    # Delay for some more time
    await cocotb.triggers.Timer(100, "ns")

    # Clear the event
    event.clear()

    # Delay for additional time
    await cocotb.triggers.Timer(100, "ns")


    # Set the event again
    event.set()

    # Continue with the next steps
    # ...

Result: 

Coroutine: Waiting for event...

Coroutine: Event is set!

Coroutine: Event is set again!


from cocotb.triggers import NextTimeStep

NextTimeStep: Advances to the next simulation time step, allowing you to synchronize with the next delta cycle.

e.g. the my_test coroutine will perform some actions and then wait for the next simulation time step using the NextTimeStep trigger. 

  • This ensures that any changes or updates that occur at the current time step have taken effect before proceeding with the subsequent steps.

import cocotb
from cocotb.triggers import NextTimeStep

@cocotb.coroutine
async def my_test(dut):
    # Perform some actions

    # Wait for the next simulation time step
    await NextTimeStep()

    # Continue with the next steps
    # ...


from cocotb.triggers import Combine

Combine: Combines multiple triggers into a single trigger.

  •  It waits for all the constituent triggers to occur before proceeding.

e.g. the test function my_test creates two individual triggers: trigger1 that waits for a rising edge of the clk signal and trigger2 that introduces a 100 nanosecond delay using the Timer trigger.

The Combine trigger is then used to combine trigger1 and trigger2 into a single trigger called combined_trigger.

  •  The await statement is used to wait for the combined trigger to occur. 

    • This means the test will proceed only when both the rising edge of clk and the 100 nanosecond delay have happened.


import cocotb
from cocotb.triggers import Combine, RisingEdge, Timer

@cocotb.coroutine
async def my_test(dut):
    # Create individual triggers
    trigger1 = RisingEdge(dut.clk)
    trigger2 = Timer(100, "ns")

    # Combine the triggers
    combined_trigger = Combine(trigger1, trigger2)

    # Wait for the combined trigger to occur
    await combined_trigger

    # Continue with the next steps
    # ...



    from cocotb.triggers import ReadOnly

ReadOnly: Temporarily releases control to the simulator, allowing other processes to execute before resuming the current process.

It is particularly useful in situations where you want to allow other parts of the testbench to make progress or perform certain actions before continuing with the current process.

e.g. The most generic usage case of ReadOnly

import cocotb
from cocotb.triggers import ReadOnly
# my_process is a coroutine that performs some actions
@cocotb.coroutine
async def my_process():
    # Perform some actions
    # The await ReadOnly() statement is used to release control to the simulator temporarily, allowing other parts of the testbench (including other coroutines or processes) to execute.
    await ReadOnly()
# After the other processes have had a chance to progress, the execution will resume from the point after the await ReadOnly() statement.

    # Continue with the next steps after releasing control
    # ...

@cocotb.test()
async def my_test(dut):
    # Start the process
    proc = cocotb.fork(my_process())

    # Perform some other actions
    await ReadOnly()

    # Continue with the next steps after releasing control
    # ...

e.g. The more elaborate usage case of ReadOnly

import cocotb
from cocotb.triggers import ReadOnly

@cocotb.coroutine
async def my_process():
    # Perform some initial setup actions
# In the my_process coroutine, the initial setup actions involve setting the reset signal to a high value and waiting for it to propagate using await ReadOnly()
    dut.reset <= 1
    await ReadOnly()
# Then, the reset signal is released, followed by the generation of test stimuli with different values for a and b. Each time a value is assigned to a or b, it is followed by await ReadOnly() to allow other processes to make progress.
    # Release the reset signal and wait for a short delay
    dut.reset <= 0
    await ReadOnly()
    await Timer(10, "ns")

    # Generate test stimuli
    dut.a <= 1
    await ReadOnly()

    dut.b <= 0
    await ReadOnly()
    await Timer(5, "ns")

    dut.b <= 1
    await ReadOnly()
    await Timer(5, "ns")

    dut.a <= 0
    await ReadOnly()
    await Timer(5, "ns")

    # Continue with the next steps after releasing control
    # ...

@cocotb.test()
async def my_test(dut):
# In the my_test test case, after starting the my_process process, additional setup actions are performed, such as setting the enable signal to a high value
    # Start the process
    proc = cocotb.fork(my_process())

    # Perform some other setup actions
    dut.enable <= 1
    await ReadOnly()

    # Wait for the process to progress
    await Timer(20, "ns")

    # Perform some additional actions
    dut.enable <= 0
    await ReadOnly()
    await Timer(10, "ns")

    # Continue with the next steps after releasing control
    # ...



from cocotb.triggers import Lock

Lock: Provides a mechanism to acquire and release locks for synchronization purposes.

It allows you to control access to shared resources and ensure that only one process or coroutine can execute a critical section of code at a time.

e.g. Lock used to synchronize access to a critical section of code

import cocotb
from cocotb.triggers import Lock

@cocotb.test()
async def my_test(dut):
# the Lock instance lock is created to synchronize access to a critical section of code.
    lock = Lock()  # Create a Lock instance
# The critical_section function represents the code that should be executed in a mutually exclusive manner
    async def critical_section():
# By using the async with lock statement, each coroutine that enters the critical_section will acquire the lock before executing the critical section code. If another coroutine tries to enter the critical section while the lock is already acquired by another coroutine, it will wait until the lock is released before proceeding.
        async with lock:  # Acquire the lock
            # Perform critical section operations
            await do_something()

    # Multiple coroutines trying to access the critical section concurrently
# the coroutines are created using cocotb.fork() to run concurrently.   
    await cocotb.fork(critical_section())
    await cocotb.fork(critical_section())
    await cocotb.fork(critical_section())

    # Continue with the next steps
    # ...

from cocotb.triggers import Join


Join: Combines multiple triggers and waits for any of the constituent triggers to occur before proceeding.

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, Join
from cocotb.result import TestFailure

@cocotb.coroutine
async def check_output(dut):
    dut._log.info("Checking output...")

    # Create a Join trigger to synchronize multiple events
    join_trigger = Join([RisingEdge(dut.clk), RisingEdge(dut.reset)])

    # Wait for either a rising edge of the clock or the reset signal
    await join_trigger

    # Verify the output: e.g. is Power On Reset (active high) asserted
    if dut.reset:
        raise TestFailure("Reset signal asserted!")
    else:
        dut._log.info("Output check passed.")

@cocotb.test()
async def test_dut(dut):
    cocotb.fork(Clock(dut.clk, 10, "ns").start())

    # Start the output verification coroutine
    await check_output(dut)



Decorator of functions

@cocotb.test()

The line @cocotb.test() is a decorator in Python that is used to mark a function as a test case for Cocotb


When a function is decorated with @cocotb.test(), it indicates that the function is a test case that should be executed by Cocotb during the simulation

@cocotb.coroutine

In Cocotb, the @cocotb.coroutine decorator is used to mark a function as a coroutine

Coroutines in Cocotb allow you to write asynchronous code that can be paused and resumed based on various triggers or events.

It enables the use of await and other asynchronous features within the function(e.g. cocotb.fork(), cocotb.wait(), cocotb.join(), yield   ).


@cocotb.decorators.external

@cocotb.decorators.external: This decorator marks a function as an external function that can be called from within a test. 

  • It is used when you want to invoke functions defined outside the test scope, such as utility functions or interface functions.

import cocotb
from cocotb.decorators import external

@cocotb.external
def my_external_function(arg1, arg2):
    # Perform some external functionality
    result = arg1 + arg2
    print(f"External function result: {result}")
    return result

@cocotb.test()
async def my_test(dut):
    # Call the external function from within the testbench
    result = my_external_function(3, 5)
    print(f"Testbench: Received result from external function: {result}")

    # Continue with the testbench execution
    # ...



Driver

BusDriver

e.g.MyBusDriver is a custom driver derived from BusDriver. It inherits the _driver_send and _driver_recv methods, which define the behavior of the driver during send and receive operations

import cocotb
from cocotb.drivers import BusDriver
from cocotb.triggers import RisingEdge

class MyBusDriver(BusDriver):
    _signals = ["clk", "reset", "data"]

    def __init__(self, dut, clock, reset=None):
        BusDriver.__init__(self, dut, clock, reset)
        self.data = 0

    async def _driver_send(self, transaction, sync=True):
        self.data = transaction.data
        await RisingEdge(self.clock)

    async def _driver_recv(self, transaction, sync=True):
        transaction.data = self.data
        await RisingEdge(self.clock)

@cocotb.test()
async def my_test(dut):
    driver = MyBusDriver(dut, dut.clk, reset=dut.reset)
    driver.start()

# The _driver_send method sets the data attribute of the driver to the value provided in the transaction and waits for a rising edge of the clock.
    # Test write operation
    await driver.send({"data": 0xAB})
    await RisingEdge(dut.clk)

    # Test read operation
    transaction = await driver.recv()
    assert transaction.data == 0xAB

    driver.stop()

    # Continue with the testbench execution
    # ...



DeprecationWarning: cocotb.fork has been deprecated in favor of cocotb.start_soon and cocotb.start

cocotb.fork() : current coroutine can continue executing without waiting for the forked coroutine to complete.

When you use cocotb.start_soon(), the coroutine is started, but the execution of the current coroutine will be paused until the started coroutine completes. 

  • This means that the current coroutine will wait for the started coroutine to finish before proceeding.


Conclusion

In this blog post, we explored a small example of a CoCoTB testbench. Also we learned about some CoCoTB features/building blocks included but not limited to:  coroutines, triggers, and how to use CoCoTB to drive inputs, verify outputs, and synchronize testbench components. CoCoTB provides a powerful and flexible framework for hardware verification and testing, making it easier to write effective testbenches using Python.


© 2023 ASIC Stoic. All rights reserved.