cocotb is a Python-based, platform- and simulator-agnostic testbench framework for digital hardware.1 It’s a modern, reusable way to test the correctness of designs.. cocotb primarily functions by using coroutines.

Quick links:

Installation:

choco install make
choco install iverilog
uv init
uv add cocotb

Running:

venv\Scripts\Activate.ps1
make

Basics

Each test is defined as an asynchronous Python function, annotated with the @cocotb.test() decorator. All tests are discovered if they’re annotated (much like how Cargo works), and they all take an input object dut that allows us to access the internals of the design (i.e., signals, ports, parameters). The decorator also supports a few input parameters:

  • Timeout parameters
  • Expected fails

Tests can call other Python functions (like to drive particular signals). It should be a regular synchronous function if we need it at one particular moment, and it should be async if we want to do it concurrently (i.e., we want to drive a signal in the background).

Tests run sequentially, from start to finish. We use await to suspend execution of the test/function until a trigger happens then return control back to the test.

  • The async function cocotb.start(function(param1, param2)) schedules the coroutine for concurrent execution, then resumes the calling task. And:
    • start_soon() schedules the new coroutine for future execution.
    • Note that coroutines can run indefinitely with a while True.
  • Triggers can include timers and signal edges (Edge, RisingEdge, FallingEdge). We pass in the parameter from the dut — i.e., we can await RisingEdge(dut.clk) to wait for the rising edge of the dut’s clock.

The methods of the dut object are:

  • .<SIGNAL_NAME> — we can get a reference to a specific port.
  • .value — allows us to read or write a signal value.
    • Note that this doesn’t apply the write immediately, much like HDLs. It delays until the next write cycle. We can force this with sig.setimmediatevalue(val).
    • Assignment makes no assumptions about the signedness of the signal, so it takes a valid range between the minimum signed number to the maximum unsigned number.
  • _log

Finally, tests will fail if any assert fails. With Pytest, a stack trace is printed where the assert fails. Otherwise, the test will probably pass.

Build

There are two main approaches used to run coco-tb tests. The first is via an extension of traditional Makefiles, and the other is via a Pytest runner interface. In both cases, they specify important information about the design/test, like the simulator (SIM), design language (TOPLEVEL_LANG), source files (VERILOG_SOURCES, VERILOG_INCLUDE_DIRS), top-level modules (TOPLEVEL), and the test file (MODULE). We can also specify nice-to-haves, like timescale precision (HDL_TIMEUNIT and HDL_TIMEPRECISION), arguments, and visual waveforms (GUI and WAVES).

For the Makefile, we just run make. This will compile and run the unit test. Standard make options may be unsupported (like parallel compilation). clean still works. The Pytest runner seems to be the preferred way to do things, but it’s still an experimental feature.

Triggers

In general, we can await for most things in the cocotb.triggers library. We can also await any stock Python coroutines.

  • Edge(sig) — any edge change.
    • RisingEdge(sig), FallingEdge(sig)
  • ClockCycles(sig, num_cycles, rising=True) — fires after a certain number of clock transitions.
  • Timer(val, units='step') — after a specified time period has passed. By default, this takes the value of HDL_TIMEPRECISION, but we can overload with a particular time unit.

Resources

https://0bab1.github.io/BRH/posts/TIPS_FOR_COCOTB/

Footnotes

  1. The official documentation suggests not using Windows. This suggestion does not matter.