cocotb: Python-Based Hardware Testing for FPGAs and ASICs

cocotb: Python-Based Hardware Testing for FPGAs and ASICs

cocotb (Coroutine-Based Co-simulation Testbench) lets you write hardware testbenches in Python instead of VHDL or SystemVerilog. Your Python code drives and monitors HDL signals via a simulator (ModelSim, Questa, Verilator, Icarus, etc.) through a VPI/VHPI interface. The result: familiar Python tooling (pytest, coverage, CI) for hardware verification.

Writing a testbench in SystemVerilog is an acquired skill that takes time to develop and requires a separate mental model from the rest of your software stack. If your team already writes Python — for scripts, data analysis, machine learning, or application code — cocotb lets you bring that same language to hardware verification without learning a new HDL dialect.

cocotb is not a toy. It is used in production by Google (OpenTitan project), lowRISC, and dozens of chip design companies. It supports every major simulator, runs in CI, and integrates with pytest for structured test reporting. This post walks through everything from installation to a real test to CI integration.

What cocotb Actually Does

cocotb sits between your Python code and a traditional HDL simulator. When you run a cocotb test:

  1. The simulator (e.g., Icarus Verilog, ModelSim) loads your HDL design as it normally would
  2. cocotb loads your Python test module via VPI (for Verilog) or VHPI/FLI (for VHDL)
  3. Your Python coroutines drive and sample HDL signals through the VPI interface
  4. The simulator advances time when Python yields control (via await)
  5. Python resumes when the requested event occurs (clock edge, signal change, timeout)

The HDL design runs in the simulator at full fidelity. cocotb just replaces the testbench HDL with Python.

Installation

cocotb requires Python 3.8+ and a supported simulator. The easiest starting point uses Icarus Verilog (open-source, widely available):

# Install Icarus Verilog
# macOS:
brew install icarus-verilog

# Ubuntu/Debian:
sudo apt-get install iverilog

# Install cocotb
pip install cocotb

# Optional but recommended: cocotb test utilities
pip install cocotb-test pytest

Verify the installation:

python -c "import cocotb; print(cocotb.__version__)"
iverilog -V

The Design Under Test

We'll use a parameterized synchronous counter in Verilog. Save this as counter.v:

// counter.v
module counter #(
    parameter WIDTH = 8
)(
    input  wire             clk,
    input  wire             rst_n,
    input  wire             enable,
    output reg  [WIDTH-1:0] count
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= {WIDTH{1'b0}};
        else if (enable)
            count <= count + 1'b1;
    end
endmodule

Writing the First cocotb Test

Create test_counter.py in the same directory:

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, FallingEdge, Timer


@cocotb.test()
async def test_reset(dut):
    """Test that reset drives count to zero."""

    # Start a 10 ns clock (100 MHz) on dut.clk
    clock = Clock(dut.clk, 10, units="ns")
    cocotb.start_soon(clock.start())

    # Assert reset
    dut.rst_n.value = 0
    dut.enable.value = 0

    # Wait 5 rising edges while in reset
    for _ in range(5):
        await RisingEdge(dut.clk)

    # Verify count is zero
    assert dut.count.value == 0, \
        f"Expected count=0 after reset, got {dut.count.value}"

    # Release reset
    dut.rst_n.value = 1
    await RisingEdge(dut.clk)


@cocotb.test()
async def test_count_up(dut):
    """Test that the counter increments on each clock edge when enabled."""

    clock = Clock(dut.clk, 10, units="ns")
    cocotb.start_soon(clock.start())

    # Reset
    dut.rst_n.value = 0
    dut.enable.value = 0
    await Timer(50, units="ns")
    dut.rst_n.value = 1
    await RisingEdge(dut.clk)

    # Enable and count for 10 cycles
    dut.enable.value = 1
    for i in range(10):
        await RisingEdge(dut.clk)

    # Sample after the clock edge settles
    await Timer(1, units="ns")
    assert dut.count.value == 10, \
        f"Expected count=10, got {dut.count.value}"


@cocotb.test()
async def test_hold_when_disabled(dut):
    """Test that count holds its value when enable is low."""

    clock = Clock(dut.clk, 10, units="ns")
    cocotb.start_soon(clock.start())

    dut.rst_n.value = 0
    dut.enable.value = 0
    await Timer(50, units="ns")
    dut.rst_n.value = 1

    # Count to 7
    dut.enable.value = 1
    for _ in range(7):
        await RisingEdge(dut.clk)
    await Timer(1, units="ns")
    assert dut.count.value == 7

    # Disable for 5 cycles
    dut.enable.value = 0
    for _ in range(5):
        await RisingEdge(dut.clk)
    await Timer(1, units="ns")

    # Count should still be 7
    assert dut.count.value == 7, \
        f"Expected count to hold at 7, got {dut.count.value}"

The Makefile

cocotb uses a Makefile-based build system. Create Makefile in the same directory:

# Makefile for cocotb
SIM      ?= icarus        # simulator: icarus, questa, modelsim, verilator
TOPLEVEL_LANG ?= verilog

VERILOG_SOURCES = $(PWD)/counter.v

# Top-level module name in the HDL
TOPLEVEL = counter

# Python test module name (without .py)
MODULE   = test_counter

include $(shell cocotb-config --makefiles)/Makefile.sim

Run the tests:

make SIM=icarus

Output:

     -.--ns INFO     cocotb.gpi                                ..._sim_utils.c:98  in _entry_point  VPI entry point hit ...
     0.00ns INFO     cocotb.gpi                                ..._sim_utils.c:98  in _entry_point  cocotb v1.9.0 running...
     0.00ns INFO     cocotb.regression                        regression.py:194   in _setup_test    running test_reset (1/3)
    50.00ns INFO     cocotb.regression                        regression.py:219   in _run_test      test_reset passed
     0.00ns INFO     cocotb.regression                        regression.py:194   in _setup_test    running test_count_up (2/3)
   160.00ns INFO     cocotb.regression                        regression.py:219   in _run_test      test_count_up passed
     0.00ns INFO     cocotb.regression                        regression.py:194   in _setup_test    running test_hold_when_disabled (3/3)
   220.00ns INFO     cocotb.regression                        regression.py:219   in _run_test      test_hold_when_disabled passed
*** TESTS PASSED ***

Async/Await Patterns for Clock Cycles

cocotb uses Python's asyncio-style coroutines. Understanding the trigger objects is essential:

from cocotb.triggers import (
    RisingEdge,    # wait for a signal rising edge
    FallingEdge,   # wait for a signal falling edge
    Edge,          # wait for any edge
    Timer,         # wait for a time duration
    ClockCycles,   # wait for N clock cycles
    First,         # wait for whichever trigger fires first
    Combine,       # wait for all triggers to fire
    ReadOnly,      # wait until end of current time step (safe for sampling)
)

# Wait for 10 rising edges on clk
await ClockCycles(dut.clk, 10)

# Wait for either a signal to go high or a timeout
result = await First(RisingEdge(dut.done), Timer(1, units="us"))
if isinstance(result, Timer):
    raise AssertionError("Timed out waiting for done signal")

# Sample a signal safely (at end of time step, after all drivers have settled)
await ReadOnly()
value = dut.count.value.integer

Writing a Bus Protocol Driver

Real hardware has protocols. Here's a reusable AXI4-Lite write transaction function:

async def axi_write(dut, addr, data, timeout_cycles=100):
    """Perform an AXI4-Lite write transaction."""

    # Set up address and data channels
    dut.awvalid.value = 1
    dut.awaddr.value  = addr
    dut.wvalid.value  = 1
    dut.wdata.value   = data
    dut.wstrb.value   = 0xF  # all byte lanes valid
    dut.bready.value  = 1

    # Wait for address handshake (awvalid & awready)
    for cycle in range(timeout_cycles):
        await RisingEdge(dut.clk)
        if dut.awready.value == 1:
            dut.awvalid.value = 0
            break
    else:
        raise TimeoutError(f"AXI write address handshake timed out after {timeout_cycles} cycles")

    # Wait for data handshake (wvalid & wready)
    for cycle in range(timeout_cycles):
        await RisingEdge(dut.clk)
        if dut.wready.value == 1:
            dut.wvalid.value = 0
            break
    else:
        raise TimeoutError(f"AXI write data handshake timed out after {timeout_cycles} cycles")

    # Wait for write response (bvalid)
    for cycle in range(timeout_cycles):
        await RisingEdge(dut.clk)
        if dut.bvalid.value == 1:
            resp = int(dut.bresp.value)
            dut.bready.value = 0
            assert resp == 0, f"AXI write response was {resp} (expected OKAY=0)"
            return
    raise TimeoutError("AXI write response timed out")


@cocotb.test()
async def test_axi_register_write(dut):
    """Write to a control register and verify it takes effect."""
    clock = Clock(dut.clk, 10, units="ns")
    cocotb.start_soon(clock.start())

    # Reset
    dut.rst_n.value = 0
    await ClockCycles(dut.clk, 5)
    dut.rst_n.value = 1
    await ClockCycles(dut.clk, 2)

    # Write 0x1 to control register at address 0x0000
    await axi_write(dut, addr=0x0000, data=0x00000001)

    # Verify the internal enable register updated
    await ReadOnly()
    assert dut.ctrl_reg.value == 1, f"Control register not updated"

Using pytest with cocotb

The cocotb-test package integrates cocotb with pytest, enabling structured test discovery, fixtures, and standard pytest reporting:

# test_counter_pytest.py
import pytest
from cocotb_test.simulator import run

def test_counter_simulation():
    """Run the cocotb simulation and assert it passes."""
    run(
        verilog_sources=["counter.v"],
        toplevel="counter",
        module="test_counter",
        simulator="icarus",
        # Extra plusargs or defines if needed
        plus_args=[],
        extra_env={
            "COCOTB_LOG_LEVEL": "WARNING",
        },
    )

Run with pytest:

pytest test_counter_pytest.py -v

This produces standard pytest output and JUnit XML if you add --junitxml=results.xml.

Parameterized Tests with pytest

import pytest
from cocotb_test.simulator import run

@pytest.mark.parametrize("width", [4, 8, 16, 32])
def test_counter_widths(width):
    """Test counter with different bit widths."""
    run(
        verilog_sources=["counter.v"],
        toplevel="counter",
        module="test_counter",
        simulator="icarus",
        parameters={"WIDTH": width},
    )

This runs the full test suite for 4-bit, 8-bit, 16-bit, and 32-bit counter instances. Each run is a separate pytest test case with its own pass/fail status.

Continuous Integration with GitHub Actions

# .github/workflows/cocotb.yml
name: cocotb Hardware Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  simulate:
    runs-on: ubuntu-22.04

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Icarus Verilog
        run: sudo apt-get install -y iverilog

      - name: Install Python dependencies
        run: |
          pip install cocotb cocotb-test pytest pytest-xdist

      - name: Run cocotb tests
        run: |
          pytest test_counter_pytest.py -v \
            --junitxml=test-results.xml \
            -n auto  # parallel execution with pytest-xdist

      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: cocotb Test Results
          path: test-results.xml
          reporter: java-junit

For designs requiring ModelSim or Questa (which need a license), you can use a self-hosted runner on a machine with the simulator installed. The CI setup is otherwise identical.

Monitoring and Checking Outputs

cocotb's Monitor pattern separates signal observation from test logic, enabling reuse across tests:

import cocotb
from cocotb.queue import Queue
from cocotb.triggers import RisingEdge


class CounterMonitor:
    """Observes counter output and records transitions."""

    def __init__(self, dut):
        self.dut = dut
        self.transitions = Queue()
        cocotb.start_soon(self._monitor())

    async def _monitor(self):
        prev = None
        while True:
            await RisingEdge(self.dut.clk)
            current = int(self.dut.count.value)
            if current != prev:
                await self.transitions.put((cocotb.utils.get_sim_time("ns"), current))
                prev = current


@cocotb.test()
async def test_with_monitor(dut):
    from cocotb.clock import Clock
    clock = Clock(dut.clk, 10, units="ns")
    cocotb.start_soon(clock.start())

    monitor = CounterMonitor(dut)

    dut.rst_n.value = 0
    dut.enable.value = 0
    await ClockCycles(dut.clk, 3)
    dut.rst_n.value = 1
    dut.enable.value = 1
    await ClockCycles(dut.clk, 10)

    # Drain the monitor queue and verify the sequence
    expected = list(range(1, 11))  # 1 through 10
    actual = []
    while not monitor.transitions.empty():
        _, value = monitor.transitions.get_nowait()
        actual.append(value)

    assert actual == expected, f"Expected {expected}, got {actual}"

cocotb vs SystemVerilog UVM

Aspect cocotb SystemVerilog UVM
Language Python SystemVerilog
Learning curve Low (if you know Python) High
Ecosystem pip, pytest, numpy, matplotlib UVM library, simulator-specific
Debug tools Python debuggers, print, pdb Simulator waveform + UVM verbosity
Performance Slower (Python overhead per cycle) Faster
Industry adoption Growing, common in open-source Dominant in ASIC/complex FPGA
Random constraints Python random + hypothesis SystemVerilog rand/constraint
Coverage Manual or Coveralls/gcov Built-in functional coverage

cocotb is the right choice when your team is Python-fluent, you're working on open-source hardware, or you want to integrate hardware verification into an existing Python CI pipeline. UVM makes more sense for large ASIC projects where you need the full verification IP ecosystem and have a team experienced with SystemVerilog.

For many FPGA projects, cocotb's pragmatic approach — Python coroutines, pytest integration, and CI-friendly output — provides the best return on investment. The overhead of UVM's factory, phasing, and component hierarchy is justified at scale, but is often overkill for a single IP block or an FPGA design that will never tapeout.

Getting Started Today

The fastest path from zero to a running cocotb test:

# Install prerequisites
pip install cocotb cocotb-test pytest
brew install icarus-verilog  # or apt-get on Linux

# Copy the counter.v and test_counter.py examples above
# Add the Makefile

# Run
make SIM=icarus

# Or with pytest
pytest test_counter_pytest.py -v

From there, add more test functions to test_counter.py — each one decorated with @cocotb.test() is automatically discovered and run. The simulator, the HDL, and the results are all handled for you.

Read more

Start now free