A Fundamental Guide to Understanding How Python Testbenches Control Verilog Hardware
This document explains the fundamental concept of how Python and Verilog work together in hardware verification using Cocotb. Understanding this interaction is crucial for mastering hardware verification with PyUVM and Cocotb.
- The Two-Layer Architecture
- The Bridge: Cocotb
- Signal Flow and Interaction
- Time Synchronization
- Complete Example Walkthrough
- Key Concepts Summary
- Common Patterns
Hardware verification with Cocotb uses a two-layer architecture:
The Verilog layer contains the actual hardware design - the circuit you want to verify. This is called the Design Under Test (DUT).
Example: Simple Register (module2/dut/registers/simple_register.v)
module simple_register (
input wire clk,
input wire rst_n,
input wire enable,
input wire [7:0] d,
output reg [7:0] q
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
q <= 8'h00;
end else if (enable) begin
q <= d;
end
end
endmoduleWhat the Verilog does:
- Defines hardware ports (inputs and outputs)
- Implements hardware behavior (registers, logic, state machines)
- Responds to clock edges and input changes
- Operates in simulation time (nanoseconds, picoseconds)
The Python layer contains the testbench code that:
- Controls the hardware (drives inputs)
- Monitors the hardware (reads outputs)
- Verifies correctness (assertions, checks)
- Orchestrates test scenarios
Example: Clock Generation Test (module2/examples/clock_generation/clock_generation_example.py)
@cocotb.test()
async def test_clock_class(dut):
"""
Demonstrates using Clock class.
"""
# Create clock with 10ns period
clock = Clock(dut.clk, 10, units="ns")
# Start clock
cocotb.start_soon(clock.start())
# Wait for a few clock cycles
for i in range(5):
await RisingEdge(dut.clk)
print(f"Clock cycle {i+1}")What the Python does:
- Generates test stimuli (clocks, data, control signals)
- Reads hardware outputs
- Checks results against expected values
- Coordinates test execution
Cocotb is the bridge that connects Python and Verilog. It provides:
- Signal Access: Python can read/write Verilog signals
- Time Synchronization: Python and Verilog share the same simulation time
- Event Coordination: Python can wait for Verilog events (clock edges, timeouts)
┌─────────────────────────────────────────┐
│ Python Testbench │
│ - Generates clock signals │
│ - Drives inputs (d, enable, rst_n) │
│ - Monitors outputs (q) │
│ - Checks correctness │
└──────────────┬──────────────────────────┘
│
│ Cocotb Bridge
│ (dut.clk, dut.d, dut.q)
│
┌──────────────▼──────────────────────────┐
│ Verilog DUT (simple_register) │
│ - Receives clock on clk port │
│ - Processes data on clock edges │
│ - Outputs q register value │
└─────────────────────────────────────────┘
The Makefile connects everything together:
# Python test file
PYTHON_FILES = clock_generation_example.py
# Verilog files
VERILOG_SOURCES = ../../dut/registers/simple_register.v
VERILOG_FILES = $(VERILOG_SOURCES)
# Cocotb variables
MODULE = clock_generation_example
TOPLEVEL = simple_register
COCOTB_REDUCED_LOG_FMT = 1
# Include cocotb makefile
include $(shell cocotb-config --makefiles)/Makefile.simWhat happens when you run make:
- Compiles Verilog → Creates simulation model (via Verilator or other simulator)
- Loads Python → Imports the testbench module
- Connects them → Cocotb creates the
dutobject that bridges Python and Verilog - Runs simulation → Python and Verilog execute together, synchronized in time
Python generates clock signals that drive Verilog:
# Python creates a clock signal
clock = Clock(dut.clk, 10, units="ns") # 10ns period = 100MHz
cocotb.start_soon(clock.start()) # Start clock in backgroundWhat happens:
- Python creates a coroutine that toggles
dut.clkevery 5ns - The Verilog
always @(posedge clk)block reacts to these clock edges - Clock runs in the background while Python test code continues
Verilog receives the clock:
always @(posedge clk or negedge rst_n) begin
// This block executes when Python drives clk from 0→1
if (!rst_n) begin
q <= 8'h00;
end else if (enable) begin
q <= d;
end
endPython drives Verilog input ports:
dut.d.value = 0xAB # Python drives data input
dut.enable.value = 1 # Python drives enable signal
dut.rst_n.value = 0 # Python drives resetWhat happens:
- Python assignments directly control Verilog input ports
- Changes are visible immediately in Verilog
- Verilog logic processes these inputs on clock edges
Verilog receives the inputs:
input wire [7:0] d, // Receives dut.d.value from Python
input wire enable, // Receives dut.enable.value from Python
input wire rst_n // Receives dut.rst_n.value from PythonPython reads Verilog output ports:
await RisingEdge(dut.clk) # Wait for clock edge
value = dut.q.value # Read output from Verilog
assert value == expected # Check correctnessWhat happens:
- Python can read Verilog output signals at any time
- Values reflect the current state of the Verilog design
- Python uses these values for verification
Verilog drives the outputs:
output reg [7:0] q // Python reads this via dut.q.valuePython and Verilog share the same simulation time. This is crucial for correct verification.
Both Python and Verilog operate in simulation time (not real time):
- Time units: nanoseconds (ns), picoseconds (ps)
- Time advances only when explicitly requested
- Python controls time advancement
await Timer(10, units="ns") # Advance time by 10ns
await RisingEdge(dut.clk) # Wait until next clock rising edge
await FallingEdge(dut.clk) # Wait until next clock falling edgeWhat happens:
awaitpauses Python execution- Simulation time advances
- Verilog processes events during this time
- Python resumes when the condition is met
# Time: 0ns
dut.d.value = 0x11
dut.enable.value = 1
# Time: 0ns (still) - inputs set, but no clock edge yet
await RisingEdge(dut.clk) # Wait for clock edge
# Time: 5ns - clock rising edge occurs
# Verilog: q <= d (register updates)
await Timer(1, units="ns") # Wait 1ns for propagation
# Time: 6ns - output is stable
assert dut.q.value == 0x11 # Check the resultLet's trace through a complete example to see how everything works together.
make SIM=verilatorWhat happens:
- Verilator compiles
simple_register.v→ creates C++ simulation model - Cocotb compiles Python extensions → creates bridge library
- Everything is linked together → creates executable simulation
@cocotb.test()
async def test_clock_class(dut):
# 'dut' is automatically created by Cocotb
# It's a handle to the Verilog module instanceWhat dut is:
dutis a Python object that represents the Verilog moduledut.clkaccesses theclkport in Verilogdut.daccesses thedport in Verilogdut.qaccesses theqport in Verilog
clock = Clock(dut.clk, 10, units="ns")
cocotb.start_soon(clock.start())What happens:
- Python creates a Clock object targeting
dut.clk - Clock coroutine starts running in background
- Every 5ns, it toggles
dut.clk(0→1→0→1...) - Verilog
always @(posedge clk)blocks react to these edges
Timeline:
Time: 0ns → dut.clk = 0
Time: 5ns → dut.clk = 1 (rising edge - Verilog processes)
Time: 10ns → dut.clk = 0
Time: 15ns → dut.clk = 1 (rising edge - Verilog processes)
Time: 20ns → dut.clk = 0
...
for i in range(5):
await RisingEdge(dut.clk)
print(f"Clock cycle {i+1}")What happens:
- Python waits for clock rising edge (pauses execution)
- When edge occurs, Python resumes
- Prints message
- Repeats 5 times
Synchronization:
- Python waits for Verilog clock edge
- Both are synchronized at the same simulation time
- Python can read Verilog state immediately after edge
The dut parameter in test functions is a handle to the Verilog module:
@cocotb.test()
async def my_test(dut):
# dut is the Verilog module instance
dut.clk # Access clk port
dut.d # Access d port
dut.q # Access q port
dut.enable # Access enable portKey points:
- Created automatically by Cocotb
- Provides bidirectional access (read/write)
- Synchronized with Verilog simulation
Reading signals:
value = dut.q.value # Read current value
int_value = dut.q.value.integer # Convert to integer
hex_value = f"0x{dut.q.value.integer:02X}" # Format as hexWriting signals:
dut.d.value = 0xAB # Assign integer
dut.enable.value = 1 # Assign single bit
dut.rst_n.value = 0 # Assign single bitAdvancing time:
await Timer(10, units="ns") # Wait 10 nanosecondsWaiting for events:
await RisingEdge(dut.clk) # Wait for clock rising edge
await FallingEdge(dut.clk) # Wait for clock falling edgeCombining time and events:
await RisingEdge(dut.clk) # Wait for edge
await Timer(1, units="ns") # Wait 1ns for propagation
# Now read outputsMultiple coroutines can run simultaneously:
# Start clock in background
cocotb.start_soon(clock.start())
# Start reset sequence in background
cocotb.start_soon(reset_sequence(dut))
# Main test continues
await Timer(100, units="ns")This simulates parallel hardware behavior:
- Clock runs continuously
- Reset sequence executes independently
- Test logic coordinates everything
@cocotb.test()
async def test_with_reset(dut):
# Start clock
clock = Clock(dut.clk, 10, units="ns")
cocotb.start_soon(clock.start())
# Apply reset
dut.rst_n.value = 0
await Timer(20, units="ns")
dut.rst_n.value = 1
await RisingEdge(dut.clk)
# Now test logic
dut.d.value = 0x42
dut.enable.value = 1
await RisingEdge(dut.clk)
assert dut.q.value == 0x42@cocotb.test()
async def test_drive_and_sample(dut):
clock = Clock(dut.clk, 10, units="ns")
cocotb.start_soon(clock.start())
# Drive inputs
dut.d.value = 0xAA
dut.enable.value = 1
# Wait for clock edge (register updates)
await RisingEdge(dut.clk)
# Wait for propagation delay
await Timer(1, units="ns")
# Sample output
assert dut.q.value == 0xAA@cocotb.test()
async def test_multiple_cycles(dut):
clock = Clock(dut.clk, 10, units="ns")
cocotb.start_soon(clock.start())
dut.enable.value = 1
for i in range(10):
dut.d.value = i
await RisingEdge(dut.clk)
await Timer(1, units="ns")
assert dut.q.value == iasync def monitor_output(dut):
"""Monitor output in background"""
while True:
await RisingEdge(dut.clk)
print(f"Output: 0x{dut.q.value.integer:02X}")
@cocotb.test()
async def test_with_monitor(dut):
clock = Clock(dut.clk, 10, units="ns")
cocotb.start_soon(clock.start())
cocotb.start_soon(monitor_output(dut))
# Test logic runs while monitor observes
for i in range(5):
dut.d.value = i * 0x11
await RisingEdge(dut.clk)When you run a Cocotb test, here's the complete flow:
-
Makefile Execution
- Compiles Verilog → simulation model
- Prepares Cocotb bridge
- Links everything together
-
Simulation Starts
- Python interpreter loads testbench
- Cocotb loads compiled Verilog
- Creates
dutobject connecting them
-
Test Execution
- Python test function runs
- Python drives inputs via
dut.signal.value - Python generates clocks via
Clockclass - Verilog processes inputs on clock edges
- Python reads outputs via
dut.signal.value - Python verifies correctness
-
Time Advancement
- Python controls time via
await Timer() - Python waits for events via
await RisingEdge() - Verilog and Python stay synchronized
- Both operate in the same simulation time
- Python controls time via
-
Test Completion
- Python assertions verify results
- Test passes or fails
- Simulation ends
- Verilog = Hardware: The actual circuit being tested
- Python = Testbench: Controls and verifies the hardware
- Cocotb = Bridge: Connects Python and Verilog
dut= Handle: Python object that accesses Verilog ports- Time is Shared: Python and Verilog operate in the same simulation time
- Signals are Bidirectional: Python can read and write Verilog signals
- Events Synchronize: Python can wait for Verilog events (clock edges)
- Module 2: cocotb Fundamentals - Detailed Cocotb concepts
- Module 1: Python and Verification Basics - Python for verification
- Module 0: Installation and Setup - Setting up the environment
- Clock Generation:
module2/examples/clock_generation/clock_generation_example.py - Signal Access:
module2/examples/signal_access/signal_access_example.py - Reset Patterns:
module2/examples/reset_patterns/reset_patterns_example.py - Basic Tests:
module1/tests/cocotb_tests/test_and_gate.py
- Cocotb Documentation - Official Cocotb docs
- PyUVM Documentation - PyUVM framework
- Verilator Documentation - Verilator simulator
Remember: Understanding how Python and Verilog interact is fundamental to hardware verification. The dut object is your window into the Verilog world, and Cocotb keeps everything synchronized in simulation time.