Cocotb Bus Functional Models

TinyALU and its BFMs

In this series we’re talking about using Python as the testbench software in this testbench diagram:

Proxy-driven testbench
Proxy-driven Testbench

The testbench software uses blocking Python function calls to interact with the proxy and eventually the bus functional models (BFMs). In this example we’re going to look a testbench for a simple design: the TinyALU.

TinyALU and its BFMs
The TinyALU and BFMs

The example above shows three bus functional models (BFMs). A driver, a command monitor, and a result monitor. These provide three functions for our testbench software:

  • send_op(A, B, op)—Sends a command to the ALU and blocks if the ALU is busy.
  • get_cmd()—Gets commands in the order they appeared at the TinyALU and blocks if there are none available.
  • get_result()—Gets results in the order they appeared at the TinyALU.

We’ll create a Python class named CocotbProxy that implements the BFMs. Before we show the code in Python, let’s look at the result monitor written in SystemVerilog. Let’s assume we have an interface named dut and a result_queue. We could write our SystemVerilog BFM like this:

task done_bfm();
   prev_done = 0;
   forever begin
       @(negedge clk)
       done = dut.done;
       if (done == 1 and prev_done == 0)
           result_queue.push_back(dut.result);
       prev_done = done;
   end
 endtask;

This task loops indefinitely and waits at the negative edge of the clock. At each clock it looks at done. If done is high and prev_done is low then we push dut.result into the queue.

How do we write this in cocotb?

The Result Monitor BFM

We’re going to create a proxy class that contains the BFMs and the associated queues and access functions. We import resources from cocotb and then start with a class declaration:

from cocotb.clock import Clock
from cocotb.triggers import FallingEdge
from cocotb.result import *
import cocotb
import queue

class CocotbProxy:
    def __init__(self, dut):
        self.dut = dut
        self.driver_queue = queue.Queue(maxsize=1)
        self.cmd_mon_queue = queue.Queue(maxsize=0)
        self.result_mon_queue = queue.Queue(maxsize=0)

The CocotbProxy above has three queues that will store data. Now we create the get_result() function call:

    def get_result(self):
        return self.result_mon_queue.get()

That’s all there is to that. This function returns the results in the order the TinyALU generated them, and blocks if the queue is empty.

Now we need to fill the queue using a BFM written with a coroutine. Notice that we were passed a dut object in the __init__() method. This object is analogous to a SystemVerilog interface. It gives us access to the DUT’s top -level signals. Now we can recreate SystemVerilog result monitor in Python:

    async def result_mon_bfm(self):
        prev_done = 0
        while True:
            await FallingEdge(self.dut.clk)
            try:
                done = int(self.dut.done)
            except ValueError:
                done = 0

            if done == 1 and prev_done == 0:
                self.result_mon_queue.put_nowait(int(self.dut.result))
            prev_done = done

Here we see the same code as SystemVerilog. We loop forever and await FallingEdge(self.dut.clk). This is the same as @(negedge dut.clk). The FallingEdge coroutine came from cocotb which provides a variety of triggers such as Edge, RisingEdge, and FallingEdge. You can also ask cocotb to count a number of clocks as we’ll see below.

The TinyALU Driver BFM

We send commands to the TinyALU with the send_op() function and associated driving BFM.

    async def send_op(self, aa, bb, op):
        self.driver_queue.put((aa, bb, op))

In this example send_op() is a coroutine because we’ll be calling it from a coroutine. If we were calling it from another Python thread then we’d make it a simple function. We put two operands an an operation into the driver_queue where the driver BFM will retrieve it.

Here is the driver BFM:

    async def driver_bfm(self):
        self.dut.start = self.dut.A = self.dut.B = 0
        self.dut.op = 0
        while  True:
            await FallingEdge(self.dut.clk)
            if self.dut.start == 0 and self.dut.done == 0:
                try:
                    (aa, bb, op) = self.driver_queue.get_nowait()
                    self.dut.A = aa
                    self.dut.B = bb
                    self.dut.op = op
                    self.dut.start = 1
                except queue.Empty:
                    pass
            elif self.dut.start == 1:
                if self.dut.done.value == 1:
                    self.dut.start = 0

As with the monitoring BFM we create a state machine and here we see a cocotb requirement. The coroutines cannot block, otherwise they hang the testbench and even the simulator. So, in this example we wait for the falling edge of the clock and try to get data from the queue with get_nowait(). If driver_queue is empty then get_nowait() raises a queue.Empty exception. We catch the exception with the except statement and execute a pass statement, which does nothing. Then we loop to the top and wait for the next clock edge.

If we get data to send to the TinyALU, we put it on the appropriate signals and raise the start signal. This state machine waits to see the done signal go high before lowering start.

The cocotb Test

Tests in cocotb are coroutines decorated with the @cocotb.test decorator. When we run a simulation with cocotb it searches any number of Python files for decorated functions and runs them as tests. Here is our TinyALU test:

@cocotb.test()
async def test_alu(dut):
    clock = Clock(dut.clk, 2, units="us")
    cocotb.fork(clock.start())
    proxy = CocotbProxy(dut)
    await proxy.reset()

All tests receive a handle to the DUT as their first argument. In this case we then create a Clock using the dut.clk signal and start it. Then we create a CocotbProxy object and use it to reset the TinyALU. That function lowers reset_n, waits a clock, and raises it again.

Launching the BFMs

We saw above that our BFMS are implemented as coroutines. This means we need to launch them and let them do their thing. We’re not going to await them because they never return. Instead we’re going to fork them off in their own threads:

    cocotb.fork(proxy.driver_bfm())
    cocotb.fork(proxy.cmd_mon_bfm())
    cocotb.fork(proxy.result_mon_bfm())
    await FallingEdge(dut.clk)

Now the BFMs are running. The driver is waiting for us to give it an operation:

    await proxy.send_op(0xAA, 0x55, 1)
    await cocotb.triggers.ClockCycles(dut.clk, 5)
    cmd = proxy.get_cmd()
    result = proxy.get_result()
    print("cmd:", cmd)
    print("result:", result)
    if result != 0xFF:
        raise TestFailure(f"ERROR: Bad answer {result:x} should be 0xFF")
    else:
        raise TestSuccess

Remember that we made send_op() a coroutine so we could use it here. We give it the operation, wait five clocks, and then read the command and the result using get_cmd() and get_result(). We check that we get 0xFF as the answer.

Buried within all that we see when cocotb runs we see this:

cmd: (170, 85, 1)
result: 255
Test Passed: test_alu

Our BFM works!

Next Step

In this blog post we learned how to create a simple BFM with cocotb and create functions that deliver the data to testbench software. One can write a testbench from this point that builds upon these ideas.

However, this give us a reuse problem in that engineers will write widely different testbenches without a common methodology such as the UVM in the SystemVerilog world.

If only we had a UVM for Python . . . and it turns out that we do. pyuvm is a Python implementation of the UVM specification IEEE 1800.2 that can use cocotb to connect to a simulation. We’ll turn to pyuvm in the next blog post.

Leave a Reply