cocotb 最小测试平台 Python 模板:时钟复位驱动断言四件套
第一次用 cocotb 给一个模块写测试,最难的不是 API,而是凑齐一份「能编译、能驱动、失败会红」的骨架。本文给一份 cocotb 2.1.0 口径的最小测试平台,被测对象是一个 4 位可加载计数器,正文的每一行代码都在 Icarus Verilog 12.0 上真跑过。环境搭建和第一个 cocotb 工程的常见报错可以先看 cocotb toplevel 语言错误排查,边沿语义的坑见 cocotb FallingEdge 误用,信号值的新旧写法见 cocotb BinaryValue binstr 报错。
首屏答案:每个测试自己 start_soon(Clock(...).start()) 起时钟,先把复位拉低过两拍再释放;驱动封装成协程,在时钟沿之间施加 en/load/din;断言直接比较 dut.q.value 与整数或 LogicArray;对可能永远等不到的边沿,一律套 with_timeout。Makefile 用 COCOTB_TEST_MODULES 指定测试模块,include $(shell cocotb-config --makefiles)/Makefile.sim 收尾。
本文要回答的问题
- 一个「最小但完整」的 cocotb 测试平台由哪几部分组成?
- cocotb 2.1 里时钟、复位、边沿等待的现行写法是什么?
- 测试挂死和「零测试假绿」分别长什么样,怎么在持续集成里拦住?
- 怎样在模板上长出参数化测试,而不是复制粘贴十个用例?
一、什么时候直接复制这份模板
适用场景很明确:单个时序模块的功能验证,接口只有时钟、复位和几根控制信号,比如计数器、移位寄存器、简单协议状态机。你想用 Python 写激励和断言,暂时不引入 UVM 那一套重型结构。
读这张图抓住一条时间线:蓝色是平台基础设施,紫色让 DUT 回到确定初态,橙色是真正的测试行为,红色判定决定这一条用例的生死。四步在每个 @cocotb.test() 里都完整走一遍,用例之间互不共享状态。
不适用的边界也要说清:这份模板不是完整验证环境。它没有记分板(scoreboard)、没有功能覆盖率收集、没有随机激励约束框架,多事务的预期比对要另加一层,可从 记分板 SV 模板借鉴思路;回归结果的批量汇总见 仿真日志解析 Python 模板。
二、Makefile 与 DUT 约定
cocotb 2.1 的 Makefile 只有几行,但每个变量都要对上,TOPLEVEL_LANG 与 HDL 实际语言不符是新手最常见的第一个报错。
# cocotb 2.1.0 minimal testbench Makefile (SIM=icarus)
SIM ?= icarus
TOPLEVEL_LANG ?= verilog
VERILOG_SOURCES += $(PWD)/counter.v
TOPLEVEL := counter
# cocotb 2.1 names; the old MODULE / TESTCASE variables are deprecated.
COCOTB_TEST_MODULES := test_counter
include $(shell cocotb-config --makefiles)/Makefile.sim注意 2.1 的改名:旧教程里的 MODULE 已废弃,新变量是 COCOTB_TEST_MODULES;按名字筛用例从 TESTCASE 变成 COCOTB_TEST_FILTER,旧名仍能用但会打印 DeprecationWarning。通用的仿真器参数与目标组织可参考 仿真 Makefile 模板。
DUT 是一个 4 位可加载计数器:低电平同步复位,en=1 时递增,load=1 优先把 din 装入 q。
module counter (
input wire clk,
input wire rst_n,
input wire en,
input wire load,
input wire [3:0] din,
output reg [3:0] q
);
always @(posedge clk) begin
if (!rst_n)
q <= 4'd0;
else if (load)
q <= din;
else if (en)
q <= q + 4'd1;
end
endmodule三、四件套测试平台的完整文本
下面是 test_counter.py 的全部内容,没有省略。它演示了时钟、复位、驱动、断言四件套,外加一个 with_timeout 救援用例。
"""Minimal cocotb 2.1 testbench: clock, reset, driver coroutine, assertions."""
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import (
RisingEdge,
FallingEdge,
Timer,
with_timeout,
SimTimeoutError,
)
from cocotb.types import LogicArray
async def reset_seq(dut, cycles=2):
"""Synchronous active-low reset: keep rst_n low for a few clock edges."""
dut.rst_n.value = 0
dut.en.value = 0
dut.load.value = 0
dut.din.value = LogicArray("0000")
for _ in range(cycles):
await RisingEdge(dut.clk)
dut.rst_n.value = 1
async def drive_cfg(dut, en, load, din):
"""Driver: apply one config word and wait one settled clock edge."""
dut.en.value = en
dut.load.value = load
dut.din.value = LogicArray(din)
await RisingEdge(dut.clk)
await Timer(1, unit="ns") # sample after RTL nonblocking updates settle
@cocotb.test()
async def test_reset_value(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_seq(dut)
await RisingEdge(dut.clk)
await Timer(1, unit="ns")
assert dut.q.value == 0, f"after reset q={dut.q.value.binstr}, expected 0000"
@cocotb.test()
async def test_count_and_load(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_seq(dut)
# Count 3 cycles: 0 -> 1 -> 2 -> 3.
for _ in range(3):
await drive_cfg(dut, en=1, load=0, din="0000")
assert dut.q.value == 3, f"count q={int(dut.q.value)}, expected 3"
# load takes priority over en: q becomes din.
await drive_cfg(dut, en=1, load=1, din="1010")
assert dut.q.value == LogicArray("1010"), (
f"load q={dut.q.value.binstr}, expected 1010"
)
# Held with en=0: q must stay.
await drive_cfg(dut, en=0, load=0, din="0000")
assert dut.q.value == LogicArray("1010"), "q changed while en was low"
@cocotb.test()
async def test_timeout_rescue(dut):
"""A wait that can never complete must be bounded by with_timeout."""
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_seq(dut)
dut.load.value = 0 # held constant, so FallingEdge never happens
rescued = False
try:
await with_timeout(FallingEdge(dut.load), 50, timeout_unit="ns")
except SimTimeoutError:
rescued = True
dut._log.info("wait rescued by with_timeout after 50 ns")
assert rescued, "unreachable edge wait returned without SimTimeoutError"
await Timer(10, unit="ns")数据在测试平台里单向流动,理解这张图就理解了四件套各自的位置。
测试用例编排顺序,驱动协程只负责把配置字按时钟节拍送上接口,DUT 在沿上更新,监视与断言在沿后 1 ns 采样输出。复位和时钟是旁路基础设施,不经过驱动协程,这样驱动代码里不必反复处理初态。
四个写法细节值得照抄。第一,时钟在每个测试内部启动而不是用全局 fixture,cocotb 会为每个用例重新 elaborate DUT,各自起时钟最直观。第二,复位时把所有输入驱动到确定值,不要留下 x。第三,沿后加 Timer(1, unit="ns") 再读输出,避开 RTL 非阻塞赋值的采样竞争;本模板第一次实跑时少这一拍,q 稳定读到 2 而不是 3。第四,LogicArray 是 cocotb 2.1 表示向量的现行类型,从 cocotb.types 导入;向量可以直接与 LogicArray("1010") 比较,也可以用 int(dut.q.value) 转整数。旧代码里的 BinaryValue 仍兼容,但新代码没有理由再用。
四、实跑:正常通过、挂死救援与零测试假绿
三组真实运行收在同一份日志里:正常套件、等待永不到来的边沿、过滤后没有任何用例。
终端卡中三条用例依次通过,test_timeout_rescue 在 162 ns 处打印救援日志,汇总行 TESTS=3 PASS=3 FAIL=0 是持续集成真正应该解析的内容。
挂死场景单独用 hang.py 演示:时钟正常运行,load 先置 0 并等过初始的 x 到 0 跳变,随后 await FallingEdge(dut.load) 永远等不到。这里有个真实陷阱——信号上电初值是 x,若不先 settle,x 到 0 的跳变会被当成一个边沿,挂死测试会在 0 ns「假通过」,这一点在排错篇 cocotb FallingEdge 误用里有更细的复现。外层 timeout 15 make 最终杀掉仿真。
4349095.00ns WARNING cocotb.regression hang.test_hang_on_constant failed
cocotb.regression.SimFailure: cocotb expected it would shut down the
simulation, but the simulation ended prematurely ...
** hang.test_hang_on_constant FAIL 4349095.00 14.19 306421.06 **
** TESTS=1 PASS=0 FAIL=1 SKIP=0 **
HANG_RUN_EXIT=124 (expect 124)仿真时间推进到 434 万纳秒、真实耗时 14 秒后被外层超时杀死,退出码 124。正常用例里对应的防御写法就是 with_timeout:等不到边沿时 50 ns 抛 SimTimeoutError,测试自己接住并断言「确实超时了」,把无限挂死变成一次有界失败。
最反直觉的是第三组:用 COCOTB_TEST_FILTER=does_not_exist 过滤后一个用例都没跑,cocotb 2.1.0 只打一条警告,退出码仍是 0。
0.00ns WARNING cocotb.regression No tests left after filtering with: does_not_exist$
EMPTY_RUN_EXIT=0 (0 even though no test ran; CI must parse the TESTS= line)这就是零测试假绿:脚本退出 0,持续集成放绿,但实际上什么都没验证。模块名拼错、装饰器漏写、过滤条件写错都会落到这条路径。拦截办法是在 CI 步骤里解析汇总行,确认 TESTS= 数量与预期一致且 FAIL=0,而不是只看退出码。另外要记住:正常套件存在失败用例时 cocotb 的 make 目标返回非零,退出码只对「跑了且全过」与「环境问题」有区分度,对「没跑」没有保护。
五、使用注意事项与修改示范
模板落地时还有五个高频坑。TOPLEVEL_LANG 必须与 DUT 语言一致,Verilog 写 verilog、SystemVerilog 写 verilog 但源码需要仿真器支持 SV、VHDL 写 vhdl。SimTimeoutError 在 2.1 已从 cocotb.result 迁到 cocotb.triggers,从旧位置导入会得到 DeprecationWarning。Clock(...).start() 要用 cocotb.start_soon() 调度,不能直接 await 一个会无限循环的时钟。容器镜像里的 Verilator 是 5.020,低于 cocotb 2.1 要求的 5.036,本文只用 SIM=icarus,不要盲目照抄旧教程切 Verilator。
修改示范:给计数器加一组参数化测试,一次覆盖计数 1、4、15 拍和 16 拍回绕。cocotb 2.1 的装饰器是 cocotb.parametrize(注意拼写),多个参数做笛卡尔积;想要「配对」取值就把元组列表塞进单个参数,在用例体内解包。
@cocotb.parametrize(
("case", [(1, 1), (4, 4), (15, 15), (16, 0)]),
)
async def test_count_steps(dut, case):
steps, expected = case
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
await reset_seq(dut)
for _ in range(steps):
await drive_cfg(dut, en=1, load=0, din="0000")
assert dut.q.value == expected, f"after {steps} cycles q={int(dut.q.value)}"把这段追加进 test_counter.py 后真实运行,4 个参数实例与原有 3 条用例一起被收集,16 拍那组验证 4 位回绕到 0:
running test_counter.test_count_steps/case=0 (4/7) ... passed
running test_counter.test_count_steps/case=1 (5/7) ... passed
running test_counter.test_count_steps/case=2 (6/7) ... passed
running test_counter.test_count_steps/case=3 (7/7) ... passed
** TESTS=7 PASS=7 FAIL=0 SKIP=0 **如果直接声明 ("steps", [...]) 与 ("expected", [...]) 两个参数,会得到 4 乘 4 共 16 条用例、其中 12 条失败——这是本次实跑踩过的写法,参数化前先想清楚要积还是要配对。
实验环境
- 机器/系统:Docker 容器 geek-eda:0.3-sta,Ubuntu 24.04
- 工具版本:cocotb 2.1.0(/opt/venvs/cocotb 虚拟环境)、Icarus Verilog 12.0、Python 3.12
- 日期:2026-11-02
- 完整命令:
source /opt/venvs/cocotb/bin/activate && make SIM=icarus;挂死与零测试演示为bash run.sh(内含timeout 15 make SIM=icarus COCOTB_TEST_MODULES=hang与make SIM=icarus COCOTB_TEST_FILTER=does_not_exist)
代码获取
完整 DUT、测试平台、挂死演示与运行脚本位于公开仓 https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/tpl-cocotb-minimal-python。进入目录后执行 docker run --rm -v "$PWD":/work -w /work geek-eda:0.3-sta bash -lc 'source /opt/venvs/cocotb/bin/activate && make SIM=icarus' 可复现 3 条用例全过;执行 bash run.sh(在容器内同样先激活虚拟环境)可复现挂死退出 124 与零测试假绿。
这一篇在知识体系里的位置
本篇管「单个时序模块的第一版 Python 测试平台」。往工具细节走,环境与编译问题看 cocotb toplevel 语言错误排查,信号值读写看 cocotb BinaryValue binstr 报错;往验证规模走,事务级自比对要加 记分板 SV 模板 的思路,批量回归用 仿真日志解析 Python 模板 收口结果,构建接线参考 仿真 Makefile 模板。
总结
cocotb 最小测试平台 Python 模板由每个用例自起的 Clock、过两拍释放的复位序列、沿间施激励的驱动协程和沿后 settle 的断言四件事组成;cocotb 2.1 用 COCOTB_TEST_MODULES、LogicArray 和 cocotb.triggers.SimTimeoutError 三套现行名字。实跑三组证据:正常套件 3 条全过,等待恒定信号边沿的挂死在 14 秒后以退出码 124 被杀,而过滤后零用例仍退出 0——所以 CI 必须解析 TESTS= 汇总行。模板只解决「能驱动、失败会红」,记分板和覆盖率要在此基础上另加。