Cocotb: A Beginner’s Guide to Python-Based Hardware Verification

cocotb

Hardware verification has traditionally been dominated by SystemVerilog and other specialized hardware description languages. However, cocotb (Coroutine-based Cosimulation TestBench) is changing the game by bringing the power and simplicity of Python to hardware verification. This tutorial will introduce you to cocotb, show you how to set it up, and walk through practical examples that demonstrate its capabilities.

What is Cocotb?

Cocotb is a Python library that allows you to write testbenches for digital hardware designs using standard Python syntax. Instead of learning complex SystemVerilog testbench constructs, you can leverage Python’s rich ecosystem of libraries, intuitive syntax, and powerful debugging tools to verify your hardware designs.

The name “cocotb” stands for “Coroutine-based Cosimulation TestBench,” which hints at its core architecture. It uses Python coroutines to manage the asynchronous nature of hardware simulation, allowing you to write sequential-looking code that actually runs concurrently with your hardware simulation.

Why Choose Cocotb?

Familiar Language: If you already know Python, you can start writing hardware testbenches immediately without learning new languages or complex verification methodologies.

Rich Ecosystem: Python’s vast library ecosystem is at your disposal. Need to process CSV files? Use pandas. Want to create plots of your simulation results? Use matplotlib. Need complex mathematical operations? NumPy is there for you.

Rapid Development: Python’s interpreted nature means faster development cycles. You can modify your testbench and re-run simulations without the compilation overhead of traditional approaches.

Powerful Debugging: Use familiar Python debugging tools like pdb, IDE debuggers, or simply print statements to understand what’s happening in your testbench.

Cross-Platform: Cocotb works with multiple simulators (including open-source options like Icarus Verilog and Verilator) across different operating systems.

Installation and Setup

Getting started with cocotb is straightforward. You’ll need Python (3.6 or later) and a Verilog simulator. Here’s how to set everything up:

Installing Cocotb

pip install cocotb

For this tutorial, we’ll also use Icarus Verilog as our simulator, which is free and easy to install:

# On Ubuntu/Debian
sudo apt-get install iverilog

# On macOS with Homebrew
brew install icarus-verilog

# On Windows, download from http://bleyer.org/icarus/

Project Structure

A typical cocotb project has this structure:

my_project/
├── hdl/
│   └── counter.v          # Your Verilog design
├── tests/
│   └── test_counter.py    # Your Python testbench
└── Makefile              # Build configuration

Your First Cocotb Testbench

Let’s start with a simple example: testing a basic counter. First, we’ll create the hardware design, then write a cocotb testbench for it.

The Counter Design

Here’s a simple 8-bit counter in Verilog:

// hdl/counter.v
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

The Cocotb Testbench

Now let’s write a Python testbench for this counter:

# tests/test_counter.py
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):
    """Test basic counter functionality"""
    
    # 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)
    
    # Check that counter is reset
    if dut.count.value != 0:
        raise TestFailure("Counter did not reset to 0")
    
    # Release reset and enable counting
    dut.reset.value = 0
    dut.enable.value = 1
    
    # Test counting for several cycles
    for expected_count in range(1, 10):
        await RisingEdge(dut.clk)
        actual_count = dut.count.value
        
        if actual_count != expected_count:
            raise TestFailure(f"Counter mismatch: expected {expected_count}, got {actual_count}")
    
    dut._log.info("Basic counter test passed!")

@cocotb.test()
async def test_counter_disable(dut):
    """Test that counter stops when disabled"""
    
    # Start the clock
    cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
    
    # Reset and enable
    dut.reset.value = 1
    await RisingEdge(dut.clk)
    dut.reset.value = 0
    dut.enable.value = 1
    
    # Count a few cycles
    await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    count_when_disabled = dut.count.value
    
    # Disable the counter
    dut.enable.value = 0
    
    # Wait several cycles and check count hasn't changed
    for _ in range(5):
        await RisingEdge(dut.clk)
        if dut.count.value != count_when_disabled:
            raise TestFailure("Counter continued counting when disabled")
    
    dut._log.info("Counter disable test passed!")

The Makefile

The Makefile tells cocotb how to run your simulation:

# Makefile
SIM ?= icarus
TOPLEVEL_LANG ?= verilog

VERILOG_SOURCES += hdl/counter.v
TOPLEVEL = counter

MODULE = test_counter

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

Running the Test

With all files in place, run your test:

cd my_project
make

You should see output showing both tests running and passing. Cocotb automatically discovers and runs all functions decorated with @cocotb.test().

Understanding Cocotb Concepts

Before diving into more complex examples, let’s understand key cocotb concepts:

Coroutines and Async/Await

Cocotb testbenches are built using Python coroutines (async functions). The async def syntax defines a coroutine, and await is used to wait for events or time to pass.

@cocotb.test()
async def my_test(dut):
    # This is a coroutine
    await RisingEdge(dut.clk)  # Wait for clock edge
    await Timer(100, units="ns")  # Wait for time

Triggers

Triggers are events that your testbench can wait for:

  • RisingEdge(signal): Wait for rising edge of a signal
  • FallingEdge(signal): Wait for falling edge of a signal
  • Edge(signal): Wait for any edge of a signal
  • Timer(time, units): Wait for a specific amount of time
  • Combine(*triggers): Wait for multiple triggers simultaneously

Signal Assignment and Reading

Interacting with your design’s signals is straightforward:

# Assign values
dut.reset.value = 1
dut.data_in.value = 0xAB

# Read values
current_count = dut.count.value
if dut.ready.value == 1:
    # Signal is high

Advanced Example: Testing a FIFO

Let’s create a more complex example by testing a FIFO (First-In-First-Out) buffer. This will demonstrate more advanced cocotb features.

FIFO Design

// hdl/fifo.v
module fifo #(
    parameter WIDTH = 8,
    parameter DEPTH = 16
) (
    input wire clk,
    input wire reset,
    input wire wr_en,
    input wire rd_en,
    input wire [WIDTH-1:0] din,
    output reg [WIDTH-1:0] dout,
    output wire full,
    output wire empty
);

reg [WIDTH-1:0] memory [0:DEPTH-1];
reg [$clog2(DEPTH):0] wr_ptr, rd_ptr;
reg [$clog2(DEPTH):0] count;

assign full = (count == DEPTH);
assign empty = (count == 0);

always @(posedge clk) begin
    if (reset) begin
        wr_ptr <= 0;
        rd_ptr <= 0;
        count <= 0;
    end else begin
        if (wr_en && !full) begin
            memory[wr_ptr[3:0]] <= din;
            wr_ptr <= wr_ptr + 1;
            count <= count + 1;
        end
        
        if (rd_en && !empty) begin
            dout <= memory[rd_ptr[3:0]];
            rd_ptr <= rd_ptr + 1;
            count <= count - 1;
        end
    end
end

endmodule

Advanced FIFO Testbench

# tests/test_fifo.py
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, Timer
from cocotb.result import TestFailure
import random

class FifoTestbench:
    """A reusable testbench class for FIFO testing"""
    
    def __init__(self, dut):
        self.dut = dut
        self.expected_data = []
    
    async def reset(self):
        """Reset the FIFO"""
        self.dut.reset.value = 1
        self.dut.wr_en.value = 0
        self.dut.rd_en.value = 0
        self.dut.din.value = 0
        await RisingEdge(self.dut.clk)
        await RisingEdge(self.dut.clk)
        self.dut.reset.value = 0
        self.expected_data.clear()
    
    async def write_data(self, data):
        """Write a single data item to FIFO"""
        if self.dut.full.value == 1:
            raise TestFailure("Attempted to write to full FIFO")
        
        self.dut.din.value = data
        self.dut.wr_en.value = 1
        await RisingEdge(self.dut.clk)
        self.dut.wr_en.value = 0
        self.expected_data.append(data)
    
    async def read_data(self):
        """Read a single data item from FIFO"""
        if self.dut.empty.value == 1:
            raise TestFailure("Attempted to read from empty FIFO")
        
        self.dut.rd_en.value = 1
        await RisingEdge(self.dut.clk)
        self.dut.rd_en.value = 0
        
        # Data appears one cycle after read enable
        await RisingEdge(self.dut.clk)
        actual_data = self.dut.dout.value
        expected_data = self.expected_data.pop(0)
        
        if actual_data != expected_data:
            raise TestFailure(f"Data mismatch: expected {expected_data}, got {actual_data}")
        
        return actual_data

@cocotb.test()
async def test_fifo_basic(dut):
    """Test basic FIFO read/write operations"""
    
    # Start clock
    cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
    
    # Create testbench instance
    tb = FifoTestbench(dut)
    await tb.reset()
    
    # Test basic write/read
    test_data = [0x12, 0x34, 0x56, 0x78]
    
    # Write data
    for data in test_data:
        await tb.write_data(data)
    
    # Read data back
    for expected in test_data:
        actual = await tb.read_data()
        dut._log.info(f"Read: 0x{actual:02x}")
    
    dut._log.info("Basic FIFO test passed!")

@cocotb.test()
async def test_fifo_full_empty(dut):
    """Test FIFO full and empty flag behavior"""
    
    cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
    
    tb = FifoTestbench(dut)
    await tb.reset()
    
    # Check empty flag after reset
    if dut.empty.value != 1:
        raise TestFailure("FIFO not empty after reset")
    
    # Fill the FIFO completely
    fifo_depth = 16
    for i in range(fifo_depth):
        await tb.write_data(i)
    
    # Check full flag
    if dut.full.value != 1:
        raise TestFailure("FIFO full flag not asserted when full")
    
    # Empty the FIFO completely
    for i in range(fifo_depth):
        await tb.read_data()
    
    # Check empty flag
    if dut.empty.value != 1:
        raise TestFailure("FIFO empty flag not asserted when empty")
    
    dut._log.info("FIFO full/empty test passed!")

@cocotb.test()
async def test_fifo_random(dut):
    """Stress test with random read/write patterns"""
    
    cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
    
    tb = FifoTestbench(dut)
    await tb.reset()
    
    # Random stress test
    operations = 1000
    for _ in range(operations):
        # Randomly choose to read or write
        if len(tb.expected_data) == 0:
            # Must write if empty
            operation = "write"
        elif dut.full.value == 1:
            # Must read if full
            operation = "read"
        else:
            # Random choice
            operation = random.choice(["read", "write"])
        
        if operation == "write":
            data = random.randint(0, 255)
            await tb.write_data(data)
        else:
            await tb.read_data()
        
        # Add some random delays
        if random.random() < 0.1:  # 10% chance
            await Timer(random.randint(1, 5) * 10, units="ns")
    
    dut._log.info(f"Random stress test with {operations} operations passed!")

Debugging and Best Practices

Logging and Debug Output

Cocotb provides excellent logging capabilities:

# Different log levels
dut._log.debug("Debug message")
dut._log.info("Info message")
dut._log.warning("Warning message") 
dut._log.error("Error message")

# You can also use Python's logging module
import logging
logging.info("Standard Python logging works too!")

Waveform Generation

To generate waveforms for debugging, add this to your Makefile:

SIM_ARGS += --vcd=simulation.vcd

This creates a VCD file you can view with tools like GTKWave.

Common Pitfalls and Solutions

Forgetting to use await: All trigger waits must use await. Without it, your test will race ahead without waiting for the hardware.

Signal assignment timing: Remember that signal assignments take effect immediately, but the hardware response occurs on the next clock edge.

Clock domain considerations: Make sure you understand which clock domain your signals belong to and wait for the appropriate clock edges.

Testing Best Practices

Use classes for complex testbenches: As shown in the FIFO example, classes help organize reusable testbench components.

Separate concerns: Create separate test functions for different aspects of functionality.

Use meaningful test names: Test function names should clearly describe what they test.

Add comprehensive logging: Good logging makes debugging much easier when tests fail.

Integration with Python Ecosystem

One of cocotb’s greatest strengths is seamless integration with Python’s ecosystem:

Data Analysis with Pandas

import pandas as pd

# Collect simulation data
results = []
for cycle in range(100):
    # ... run simulation ...
    results.append({
        'cycle': cycle,
        'input': input_value,
        'output': output_value
    })

# Analyze with pandas
df = pd.DataFrame(results)
print(df.describe())

Visualization with Matplotlib

import matplotlib.pyplot as plt

# Plot simulation results
plt.figure(figsize=(10, 6))
plt.plot(cycles, values)
plt.title('Signal Values Over Time')
plt.xlabel('Clock Cycles')
plt.ylabel('Signal Value')
plt.savefig('simulation_results.png')

Configuration Management

Use Python’s configuration capabilities for parameterized testing:

import json

# Load test configuration
with open('test_config.json') as f:
    config = json.load(f)

@cocotb.test()
async def parametrized_test(dut):
    for test_case in config['test_cases']:
        # Run test with different parameters
        await run_test_case(dut, test_case)

Conclusion

Cocotb brings the power and simplicity of Python to hardware verification, making it accessible to a broader range of engineers while providing powerful capabilities for complex verification tasks. By leveraging Python’s rich ecosystem, familiar syntax, and excellent debugging tools, cocotb enables rapid development of comprehensive testbenches.

The examples in this tutorial demonstrate cocotb’s progression from simple signal manipulation to complex, reusable testbench architectures. As you become more comfortable with cocotb, you’ll discover that its Python foundation opens up possibilities that traditional verification languages struggle to match.

Whether you’re a hardware engineer looking to leverage Python skills, a software engineer entering the hardware world, or an experienced verification engineer seeking more productive tools, cocotb provides a modern, powerful approach to hardware verification that grows with your needs.

Start with simple examples like the counter test, gradually incorporate more advanced features like classes and random testing, and don’t hesitate to leverage Python’s vast ecosystem of libraries. With cocotb, hardware verification becomes not just more approachable, but more powerful and flexible than ever before.

Scroll to Top