cocotb 最小测试平台上手:两行断言跑通计数器回归,2 用例全 PASS 实录
更新时间:2026-10-25。本文是「EDA 实验室 · 仿真」入门层的一篇实验文。同目录的 cocotb 第一个测试平台 讲了 cocotb 的概念与 API 地图,回答「cocotb 是什么」;本篇换一个角度——只做一件事:把最小的 cocotb 测试平台在云主机上真跑起来,用一个 8 位计数器(DUT)配两个 Python 用例,走完「写 RTL → 写 test → make → 看 PASS」的完整闭环。如果你是跟着报错页摸过来的(比如 cocotb NO_TEST_ERROR),这篇正好补「正常跑通时长什么样」的基线印象。
本文要回答的问题
- 最小 cocotb 工程到底需要哪几个文件?能不能少于三个?
- 不写一行 Verilog 测试平台,时钟和复位谁来产生?
@cocotb.test()协程里await RisingEdge(dut.clk)之后立刻读信号为什么可能读到旧值?- 官方 Makefile.sim 流程跑起来,终端输出里哪几行是判定 PASS 的关键?
先给结论卡,四个数字全部来自后文真实仿真日志:
读这张卡抓一个量级对比:两个用例一共推进了 190 ns 的仿真时间,墙钟只花了 0.03 秒——最小平台从 make 到出结论的总成本以秒计,这正是 cocotb 适合「改一行 RTL 立刻回归」工作流的原因。
一、最小闭环:三个文件,一个不能少
cocotb 工程的极简形态只有三个文件,各自承担一层职责:
| 文件 | 角色 | 本篇内容 |
|---|---|---|
counter.v | DUT(被测设计) | 8 位计数器,带同步装载 |
test_counter.py | 测试平台(Python 协程) | 时钟、复位、两个用例 |
Makefile | 胶水 | 三行变量 + include Makefile.sim |
用一张数据流图把三者在运行时的关系摆清楚——cocotb 并不是「Python 替代仿真器」,而是 Python 通过 VPI 挂在 iverilog 上:
读这张图抓一条主线:时间在 iverilog 内核里走,逻辑决策在 Python 协程里做,中间的 GPI/VPI 层负责「Python 写 dut.load.value = 1 变成仿真器里的信号赋值」「await RisingEdge(dut.clk) 变成在时钟上升沿挂回调」。Makefile 只是入口,真正干活的是它 include 的 Makefile.sim——cocotb 官方为每家仿真器预制好的编译与启动命令。理解这一层分工,后面遇到「改 RTL 不生效」「Python 报错找不到句柄」这类问题时就知道该怀疑哪一层。
DUT 刻意选带同步装载的计数器,因为它一个模块就覆盖两类最值得测的行为——自由计数与装载优先级:
always @(posedge clk or negedge rst_n) begin
if (!rst_n) count <= 8'd0;
else if (load) count <= load_val;
else count <= count + 8'd1;
endload 为高时装载优先于自增——这个「谁覆盖谁」的优先级正是第二个用例要咬住的断言点。
Makefile 本身只有三行有效配置,全部照抄 cocotb 官方约定:
SIM ?= icarus
TOPLEVEL_LANG ?= verilog
VERILOG_SOURCES = $(shell pwd)/counter.v
TOPLEVEL = counter
MODULE = test_counter
include $(shell cocotb-config --makefiles)/Makefile.sim五个变量各有明确含义:SIM 选后端仿真器(icarus / verilator / questa 等,?= 允许命令行覆盖);TOPLEVEL 指定 DUT 顶层模块名,必须和 RTL 里的 module counter 一字不差;MODULE 指定 Python 测试模块名(不带 .py 后缀);最后一行 include 用 cocotb-config --makefiles 动态定位安装路径下的官方 Makefile.sim——这意味着这份 Makefile 不绑定任何机器上的绝对路径,拷到任何装好 cocotb 的环境都能直接用。新手最常见的两处笔误恰好都在这:TOPLEVEL 写错会在编译期报「找不到模块」,MODULE 写错则要到运行期才暴露成 NO_TEST_ERROR,两者报错时间点差了一个阶段,排错时先分清是哪一层。
二、测试平台:时钟、复位与两行断言
传统 Verilog 测试平台里,时钟要手写 always #5 clk = ~clk,复位要数拍,比对要 $display 或 $fatal。cocotb 把这些全部搬进 Python,核心代码不到 40 行:
@cocotb.test()
async def counter_counts_up(dut):
"""Counter increments once per clock after reset."""
cocotb.start_soon(Clock(dut.clk, 10, units="ns").start())
dut.rst_n.value = 0
for _ in range(3):
await RisingEdge(dut.clk)
dut.rst_n.value = 1
await RisingEdge(dut.clk)
prev = int(dut.count.value)
for _ in range(10):
await RisingEdge(dut.clk)
cur = int(dut.count.value)
assert cur == (prev + 1) % 256, f"count {prev} -> {cur}"
prev = cur三个要点值得逐条说。第一,时钟是后台协程:cocotb.start_soon(Clock(...).start()) 启动一个每 10 ns 翻转一次的时钟发生器,主测试协程从此只关心「等下一个沿」。第二,复位序列就是普通 for 循环:拉低 rst_n、等 3 个上升沿、释放——读起来和测试计划文档一模一样,这是 cocotb 最大的可读性红利。第三,断言就是 Python 的 assert:(prev + 1) % 256 顺手处理了 8 位回绕,失败信息用 f-string 直接打出前后两个值,比 $display 排错快得多。
第二个用例咬装载优先级,里面藏着一个新手必踩的坑:
dut.load.value = 1
dut.load_val.value = 42
await RisingEdge(dut.clk) # count <= 42 在这个沿生效
dut.load.value = 0
await RisingEdge(dut.clk) # count <= 43 在这个沿生效
await ReadOnly() # 等 NBA 更新落定再读
assert int(dut.count.value) == 43, f"after load 42 + 1 tick expect 43"关键在最后那行 await ReadOnly()。Verilog 的非阻塞赋值(<=)在仿真器里排在「当前时刻的 NBA 区域」才更新,如果你在上升沿回调里立刻读 dut.count.value,读到的可能是更新前的旧值——明明 RTL 没错,断言却偶发失败,这是 cocotb 新手论坛里出现率最高的一类「灵异 bug」。ReadOnly() 把协程挂起到当前时刻所有赋值落定之后,读到的才是本拍的真实结果。涉及这一时序语义的更多报错场景,可以对照 cocotb TimeoutError:drive 与 read 时序 一篇。
写 cocotb 协程时还有几个常用触发器,本篇只用到了其中两个,这里一次摆全:
| 触发器 | 语义 | 典型用途 |
|---|---|---|
RisingEdge(sig) | 等下一个上升沿 | 同步时序逻辑的节拍推进 |
FallingEdge(sig) | 等下一个下降沿 | DDR、负沿采样场景 |
Timer(n, units) | 纯延时 | 异步激励、复位保持时间 |
ReadOnly() | 等本时刻赋值落定 | 沿后读信号值的定式 |
Combine(t1, t2) | 多个触发器同时满足 | 等待复合条件 |
惯例是:驱动信号在沿触发之后立刻做,读信号在读之前先 ReadOnly()。这一进一出的不对称写法来自仿真器事件区域的安排,记住定式比每次推导事件区域省心得多。本篇 counter_counts_up 里读 count 没加 ReadOnly() 仍能过,是因为读发生在「上一拍沿之后的下一拍沿」,中间隔了整整一拍、NBA 早已落定——但养成显式 ReadOnly() 的习惯,到多时钟域或零延迟组合回读的场景才不会翻车。
三、make 之后:cocotb 在后台替你做了什么
make 敲下去,Makefile.sim 实际执行了两步:先用 iverilog -o sim_build/sim.vvp -D COCOTB_SIM=1 -s counter 把 RTL 编译成 vvp 可执行映像;再用 vvp -M <cocotb libs 路径> -m libcocotbvpi_icarus sim_build/sim.vvp 启动仿真——-m 参数把 cocotb 的 VPI 库加载进仿真内核,Python 解释器随之内嵌启动,按 MODULE=test_counter 找到测试模块,扫描所有带 @cocotb.test() 装饰器的协程逐个调度。
理解了这条链路,真实执行实录里的每一行就都有出处了。实录头部还有一行容易被忽略的提示:Did not detect Python virtual environment. Using system-wide Python interpreter——这是 cocotb 在提示它内嵌的 Python 没走 venv,用的是系统解释器。在本镜像里 cocotb 装在系统级路径下,所以无碍;但在自己机器上如果用 venv 装的 cocotb 却看到这行,通常意味着 Makefile 是在 venv 激活前启动的,修正方法是先 source venv/bin/activate 再 make,否则协程里 import 的任何第三方库都会报 ModuleNotFoundError。
另一个值得知道的后台细节是 COCOTB_SIM=1 这个宏:编译命令里 -D COCOTB_SIM=1 会给 RTL 注入一个预定义宏,DUT 里可以用 `ifdef COCOTB_SIM 包住「只在 cocotb 仿真下生效」的代码(比如加速用的计数器分频旁路)。本篇的计数器没有用到它,但当仿真因为计数链太长而慢时,这是官方推荐的加速手法之一。
读这份实录抓四处关键行。Running tests with cocotb v1.8.1 与 Running on Icarus Verilog version 11.0 先交代工具链版本;随后两行 Found test test_counter.counter_counts_up / counter_load 说明 cocotb 的回归管理器正确发现了两个用例——如果你写的测试函数忘了加装饰器,这里就会一个都 Found 不到,正是 NO_TEST_ERROR 报错页 对应的场景。接着两个用例各打一行 PASSED 并附带仿真时刻(130 ns 与 190 ns),最后以总表 TESTS=2 PASS=2 FAIL=0 SKIP=0 收账。(1/2)、(2/2) 的序号前缀是回归管理器的进度提示,用例多时靠它估算剩余时间;每个用例的 docstring(如 Counter increments once per clock after reset.)会被原样打在 running 行下方——把 docstring 写成「这个用例验什么」的一句话说明,日志本身就成了一份可读的测试报告,这是零成本的可维护性收益。
四、总表与判定:PASS 的完整证据链
cocotb 回归结束时的星号总表是这类工具链里信息密度最高的一块输出,从用例发现到总账原样摘录一段:
0.00ns INFO cocotb.regression Found test test_counter.counter_counts_up
0.00ns INFO cocotb.regression Found test test_counter.counter_load
0.00ns INFO cocotb.regression running counter_counts_up (1/2)
130.00ns INFO cocotb.counter counter_counts_up PASSED
130.00ns INFO cocotb.regression counter_counts_up passed
0.00ns INFO cocotb.regression running counter_load (2/2)
190.00ns INFO cocotb.counter counter_load PASSED
190.00ns INFO cocotb.regression counter_load passed
** TEST STATUS SIM TIME (ns) REAL TIME (s) RATIO (ns/s) **
** test_counter.counter_counts_up PASS 130.00 0.00 64315.13 **
** test_counter.counter_load PASS 60.00 0.00 58013.47 **
** TESTS=2 PASS=2 FAIL=0 SKIP=0 190.00 0.03 5605.52 **这段摘录里最值得停留的是总表的 SIM TIME 列:counter_counts_up 消耗 130 ns(复位 3 拍 + 释放后 1 拍 + 10 拍计数观察,10 ns 周期下对得上),counter_load 只消耗 60 ns——两个用例的仿真时长与测试代码里数出来的拍数精确一致,说明时间推进完全受协程控制、没有一拍浪费。末尾 RATIO 列约 5-6 万 ns/s 是仿真速度与墙钟的比值,最小设计下 iverilog 的开销主要在 Python 启动上,设计规模上来后这个比值还会更高。
五、从最小平台往哪长:扩展路径与负实验
跑出 PASS 之后,这个最小平台往哪个方向长都有标准答案:加用例就是在 test_counter.py 里再写一个 @cocotb.test() 协程(回归管理器自动发现);换仿真器只改 Makefile 里 SIM=verilator 等一行;断言复杂了就引 cocotb.triggers.Timer、带超时的 with_timeout;要随机激励就直接用 Python 的 random 模块——cocotb 启动时会打印本次的种子(本篇日志里是 Seeding Python random module with 1792865353),固定种子可复现、换种子扫角落,比 Verilog 的 $random 灵活得多。
动手复现时建议做一个故意的负实验:把断言里的 43 改成 44 重跑,亲眼看一次 FAIL 总表长什么样、cocotb 如何把断言异常定位到具体用例的具体行——知道失败的形状,成功才有分量。再进一步,把 DUT 的 count <= count + 8'd1 改成 + 8'd2,counter_counts_up 会在第一拍比对就挂掉,终端直接打出 count 0 -> 2 的 f-string 信息——这种「期望值与实际值并排」的报错格式,是写断言时值得坚持的习惯,排错时省掉一半翻波形的功夫。
最后给一个最小平台与「传统 Verilog TB」的对照,帮助决定自己的项目要不要切过来:
| 维度 | Verilog TB($display/$fatal) | cocotb 最小平台 |
|---|---|---|
| 激励描述 | 过程块里数拍,可读性差 | Python 控制流,接近自然语言 |
| 断言与比对 | 手写 if + $display | assert + f-string,期望值/实际值并排 |
| 随机与数据结构 | $random + 数组 | Python 全生态(random/numpy/列表字典) |
| 复用与分层 | 靠 include 与宏 | 函数/类/协程组合,天然模块化 |
| 上手门槛 | 仿真器装好即可 | 需 Python 环境 + cocotb 安装 |
结论很直接:设计越小、验证逻辑越「算法化」(算参考值、随机激励、批量比对),cocotb 的收益越大;反过来,纯门级时序排查、需要和 schematic 级波形联动的场景,传统 TB 仍有位置。本篇的最小闭环就是切过去之前最低成本的试水——三个文件、一条 make、两分钟之内见分晓,试过之后再决定要不要把它请进自己的主项目。
这一篇在知识体系里的位置
- 概念入门:cocotb 第一个测试平台(API 地图与协程模型,本篇的上游读物);
- 仿真器基座:iverilog 第一次仿真(cocotb 底下那层编译与 vvp 的原生用法);
- 排错对照:cocotb NO_TEST_ERROR 与 cocotb TimeoutError:drive 与 read 时序(跑不通时的两个高频报错页)。
一句话总结
cocotb 最小测试平台就是「RTL + 一个 Python 测试模块 + 三行 Makefile」,时间在 iverilog 里走、决策在 Python 协程里做,读写时序记得用 await ReadOnly() 等 NBA 落定——本篇实测 2 个用例、190 ns 仿真、0.03 秒墙钟全 PASS,从 make 到结论以秒计,这就是「改一行 RTL 立刻回归」的完整闭环。
实验环境
- 机器/系统:云主机 S6.4XLARGE32 16C32G / Ubuntu 22.04(eda-golden-v1 镜像)
- 工具版本:cocotb 1.8.1(日志
Running tests with cocotb v1.8.1)/ Icarus Verilog 11.0(日志Running on Icarus Verilog version 11.0)/ Python 3.10 - 日期:2026-10-25
- 完整命令:
bash run.sh(内部执行make,由 cocotbMakefile.sim完成iverilog -o sim_build/sim.vvp -D COCOTB_SIM=1 -s counter编译与vvp -m libcocotbvpi_icarus sim_build/sim.vvp仿真,脚本最后对 make.log 做 PASS 判定 grep)
代码获取
完整 DUT、测试平台与 Makefile 已同步到公开仓库:geek-doc.cn/experiments/exp-cocotb-minimal-testbench,克隆后进入该目录执行 bash run.sh 即可复现本文全部结果(需预装 cocotb 1.8.x 与 iverilog)。