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:
- The simulator (e.g., Icarus Verilog, ModelSim) loads your HDL design as it normally would
- cocotb loads your Python test module via VPI (for Verilog) or VHPI/FLI (for VHDL)
- Your Python coroutines drive and sample HDL signals through the VPI interface
- The simulator advances time when Python yields control (via
await) - 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 pytestVerify the installation:
python -c "import cocotb; print(cocotb.__version__)"
iverilog -VThe 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
endmoduleWriting 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.simRun the tests:
make SIM=icarusOutput:
-.--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.integerWriting 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 -vThis 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-junitFor 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 -vFrom 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.