Home How to Use cocotb
Post
Cancel

How to Use cocotb

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:

1
pip install cocotb

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

1
make

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

Performance Considerations

  • 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

  1. Use waveform viewers: Generate VCD files for signal analysis
  2. Add debug prints: Strategic logging helps trace issues
  3. Step through with debugger: Use Python’s pdb for interactive debugging
  4. Check timing: Verify setup and hold times in your tests
  5. 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.

This post is licensed under CC BY 4.0 by the author.