
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 signalFallingEdge(signal)
: Wait for falling edge of a signalEdge(signal)
: Wait for any edge of a signalTimer(time, units)
: Wait for a specific amount of timeCombine(*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.