How to use cocotb
cocotb (coroutine-based co-simulation test bench) is a powerful Python library that enables you to write testbenches for HDL designs using Python instead of traditional HDL testbench languages like SystemVerilog or VHDL. This approach brings the rich ecosystem of Python libraries to hardware verification, making it easier to create complex test scenarios and analyze results.
What is cocotb?
cocotb allows you to write testbenches in Python while your design remains in Verilog, VHDL, or SystemVerilog. It uses coroutines to handle the concurrent nature of hardware simulation, making it intuitive for software developers to write hardware tests.
Key advantages of cocotb:
- Python ecosystem: Access to NumPy, matplotlib, pandas, and other powerful libraries
- Easier debugging: Use familiar Python debugging tools
- Better test organization: Leverage Python’s object-oriented and functional programming features
- Data analysis: Built-in support for complex data manipulation and visualization
- Cross-platform: Works on Linux, Windows, and macOS
Installation
Install cocotb using pip:
For advanced features and bus interfaces:
1
| pip install cocotb[bus]
|
Basic Setup
Project Structure
1
2
3
4
5
6
| my_project/
├── rtl/
│ └── counter.v
├── tests/
│ └── test_counter.py
└── Makefile
|
Simple Verilog Module (counter.v)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| module counter #(
parameter WIDTH = 8
)(
input wire clk,
input wire reset,
input wire enable,
output reg [WIDTH-1:0] count
);
always @(posedge clk) begin
if (reset) begin
count <= 0;
end else if (enable) begin
count <= count + 1;
end
end
endmodule
|
Basic Test (test_counter.py)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, FallingEdge, Timer
from cocotb.result import TestFailure
@cocotb.test()
async def test_counter_basic(dut):
"""Basic counter test"""
# Start the clock
cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
# Reset the counter
dut.reset.value = 1
dut.enable.value = 0
await RisingEdge(dut.clk)
await RisingEdge(dut.clk)
dut.reset.value = 0
await RisingEdge(dut.clk)
# Check initial value
assert dut.count.value == 0, f"Expected 0, got {dut.count.value}"
# Enable counter and test counting
dut.enable.value = 1
for i in range(1, 10):
await RisingEdge(dut.clk)
assert dut.count.value == i, f"Expected {i}, got {dut.count.value}"
@cocotb.test()
async def test_counter_overflow(dut):
"""Test counter overflow behavior"""
cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
# Reset
dut.reset.value = 1
dut.enable.value = 0
await RisingEdge(dut.clk)
dut.reset.value = 0
dut.enable.value = 1
# Count to overflow (assuming 8-bit counter)
for i in range(256):
await RisingEdge(dut.clk)
# Should wrap around to 0
assert dut.count.value == 0, f"Expected overflow to 0, got {dut.count.value}"
|
Makefile
1
2
3
4
5
6
7
8
9
| SIM ?= icarus
TOPLEVEL_LANG ?= verilog
VERILOG_SOURCES += $(PWD)/rtl/counter.v
TOPLEVEL = counter
MODULE = tests.test_counter
include $(shell cocotb-config --makefiles)/Makefile.sim
|
Running Tests
Advanced Features
Using Monitors and Drivers
For more complex designs, you can create reusable monitors and drivers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge
from cocotb.monitors import Monitor
class CounterMonitor(Monitor):
"""Monitor for counter outputs"""
def __init__(self, dut, callback=None):
self.dut = dut
Monitor.__init__(self, callback)
async def _monitor_recv(self):
while True:
await RisingEdge(self.dut.clk)
self._recv({
'count': int(self.dut.count.value),
'enable': int(self.dut.enable.value),
'reset': int(self.dut.reset.value)
})
class CounterDriver:
"""Driver for counter inputs"""
def __init__(self, dut):
self.dut = dut
async def reset(self):
"""Reset the counter"""
self.dut.reset.value = 1
await RisingEdge(self.dut.clk)
self.dut.reset.value = 0
await RisingEdge(self.dut.clk)
async def enable(self, state=True):
"""Enable or disable the counter"""
self.dut.enable.value = int(state)
await RisingEdge(self.dut.clk)
|
Working with Buses
cocotb provides built-in support for common bus protocols:
1
2
3
4
5
6
7
| from cocotb_bus.drivers import BusDriver
from cocotb_bus.monitors import BusMonitor
# For AXI, Avalon, Wishbone, etc.
class AxiLiteMaster(BusDriver):
# Implementation for AXI-Lite master
pass
|
Logging and Debugging
cocotb integrates with Python’s logging system:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import logging
import cocotb
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@cocotb.test()
async def test_with_logging(dut):
logger.info("Starting test")
# Your test code here
cocotb.log.info("Counter value: %d", dut.count.value)
if dut.count.value != expected:
cocotb.log.error("Mismatch detected!")
|
Parameterized Tests
Use pytest-style parameterization:
1
2
3
4
5
6
7
8
9
| import pytest
import cocotb
@cocotb.test()
@pytest.mark.parametrize("width", [4, 8, 16])
async def test_counter_widths(dut, width):
"""Test different counter widths"""
# Test implementation
pass
|
Working with Complex Data Types
Handle complex data structures and protocols:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| import struct
import cocotb
from cocotb.triggers import RisingEdge
@cocotb.test()
async def test_data_processing(dut):
"""Test complex data processing"""
# Generate test data
test_data = [random.randint(0, 255) for _ in range(100)]
# Send data to DUT
for data in test_data:
dut.data_in.value = data
dut.valid_in.value = 1
await RisingEdge(dut.clk)
# Wait for processing
while not dut.valid_out.value:
await RisingEdge(dut.clk)
result = dut.data_out.value
# Verify result
expected = process_data(data) # Your reference function
assert result == expected
|
Best Practices
Test Organization
- Use separate test files for different modules
- Group related tests using classes
- Create reusable test utilities and fixtures
Error Handling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @cocotb.test()
async def test_with_timeout(dut):
"""Test with timeout protection"""
try:
# Wait for condition with timeout
await cocotb.triggers.with_timeout(
wait_for_condition(dut),
timeout_time=1000,
timeout_unit="ns"
)
except cocotb.result.SimTimeoutError:
cocotb.log.error("Test timed out waiting for condition")
raise
|
- Use
cocotb.start_soon()
for concurrent operations - Minimize unnecessary clock edge waits
- Use efficient data structures for large datasets
Integration with CI/CD
GitHub Actions Example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| name: FPGA Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
pip install cocotb
sudo apt-get install iverilog
- name: Run tests
run: make
|
Common Patterns
Scoreboarding
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class Scoreboard:
def __init__(self):
self.expected = []
self.actual = []
def add_expected(self, data):
self.expected.append(data)
def add_actual(self, data):
self.actual.append(data)
self.check()
def check(self):
if len(self.actual) <= len(self.expected):
expected = self.expected[len(self.actual)-1]
actual = self.actual[-1]
assert actual == expected, f"Mismatch: expected {expected}, got {actual}"
|
Coverage Collection
1
2
3
4
5
6
7
8
9
| class CoverageCollector:
def __init__(self):
self.coverage_points = set()
def sample(self, point):
self.coverage_points.add(point)
def report(self):
cocotb.log.info(f"Coverage: {len(self.coverage_points)} points hit")
|
Debugging Tips
- Use waveform viewers: Generate VCD files for signal analysis
- Add debug prints: Strategic logging helps trace issues
- Step through with debugger: Use Python’s pdb for interactive debugging
- Check timing: Verify setup and hold times in your tests
- Validate assumptions: Always check your test assumptions
Conclusion
cocotb bridges the gap between software and hardware verification, bringing Python’s power to FPGA testing. Its coroutine-based approach makes it intuitive for software developers while providing the precision needed for hardware verification. By leveraging Python’s ecosystem, you can create more maintainable, powerful, and insightful testbenches.
Start with simple tests and gradually incorporate advanced features as your verification needs grow. The combination of Python’s flexibility and cocotb’s hardware-aware design makes it an excellent choice for modern FPGA verification workflows.