cocotb 第一个测试流程:Python 写 testbench 驱动 D 触发器闭环
第一次跑通 cocotb 的门槛不在 Python 语法,而在「Makefile 里几个名字对上、Python 和仿真器连上、失败真的会红」这三件事。本篇用一个只有四根信号的 D 触发器当被测设计(DUT),在 Icarus Verilog 12.0 加 cocotb 2.1.0 的容器里从头走通 cocotb 第一个测试流程,每一行命令和输出都是真机记录。想要可复制骨架的,先看 cocotb 最小测试平台 Python 模板;跑的过程中报错,按现象去 cocotb NO_TEST_ERROR 排查 或 toplevel 语言错误 对号入座。
本文要回答的问题
- cocotb 第一个测试流程的最小工程由哪几个文件组成,各自负责什么?
- Makefile 里的 SIM、TOPLEVEL、COCOTB_TEST_MODULES 分别要和谁对上?
- Python 代码并不和仿真器编在一起,它到底是怎么驱动到 DUT 信号的?
make之后哪些输出算「真的通过」,产物文件落在哪?
一、cocotb 第一个测试流程在验证链路里的位置
传统 Verilog 测试台把激励和断言也写成 HDL,和 DUT 一起编译进仿真;cocotb 换了分工:DUT 照常交给仿真器编译运行,激励、时钟、检查全部用 Python 协程写,两边通过仿真器的标准 C 接口接起来。上游你只需要一个能综合的 RTL 文件,下游产出的是终端汇总行和一个 JUnit 格式的 results.xml,可以直接接持续集成。
读这张图抓住一条单向主线:蓝色是你已有的 RTL,紫色和橙色是新写的两样东西——Python 测试台与 Makefile,红色是最终判定。四步里没有任何一步需要碰仿真器的专有脚本,这正是 cocotb 的定位:测试代码用 Python,仿真器可以整体替换而测试台不动。
二、命令逐段解读:Makefile 四变量与 VPI 这一层
整个工程只有三个人写的文件:dff.v、test_dff.py、Makefile。Makefile 全文如下。
SIM ?= icarus
TOPLEVEL_LANG ?= verilog
VERILOG_SOURCES += $(PWD)/dff.v
TOPLEVEL := dff
COCOTB_TEST_MODULES := test_dff
include $(shell cocotb-config --makefiles)/Makefile.sim逐行看。SIM ?= icarus 指定仿真器后端,换成别的机器时可以命令行 make SIM=icarus 覆盖;注意 make 变量区分大小写,写小写 sim 不是同一个变量。TOPLEVEL_LANG 声明顶层 DUT 的语言,Verilog 填 verilog,VHDL 填 vhdl,填错会在编译阶段直接报错。VERILOG_SOURCES 列出全部 RTL 源文件,多文件工程就继续追加。TOPLEVEL 必须原样等于 RTL 里顶层模块名,它决定仿真从哪个模块例化。COCOTB_TEST_MODULES 是 cocotb 2.1 的现行变量名,值等于 Python 文件名但不带 .py;旧教程里的 MODULE 已废弃,写了仍能跑但会打 DeprecationWarning。最后一行 include $(shell cocotb-config --makefiles)/Makefile.sim 把 cocotb 自带的通用规则拉进来,编译、加载、退出码都由它接管。这里的 cocotb-config 是随包装进虚拟环境的定位工具,输出 Makefile.sim 的真实安装路径,所以进容器后先激活虚拟环境这一步不能省。只想跑其中一条用例时不用改代码,命令行追加 COCOTB_TEST_FILTER=test_d_flows_on_rising_edge 即可按名字过滤;过滤条件一个用例都匹配不上时 cocotb 只打警告、退出码仍是 0,这也是后面强调 CI 必须解析汇总行的原因。
敲下 make SIM=icarus 后,规则文件实际执行两条命令:先用 iverilog -o sim_build/sim.vvp -s dff -g2012 dff.v 把 RTL 编成仿真镜像,再用 vvp -m libcocotbvpi_icarus.so sim_build/sim.vvp 启动仿真。关键全在 -m 这个参数上:它让 vvp 在启动时预加载 cocotb 的 VPI 插件。
读这张图回答「Python 怎么够得到 DUT」:VPI 是 Verilog 仿真器对外的标准 C 接口,libcocotbvpi_icarus.so 一边用 VPI 挂进 vvp 仿真核,一边在进程内嵌入一个 Python 解释器(实跑日志里能看到 Loaded libpython3.12.so 与 GPI: VPI support registered 两行)。Python 侧的 dut.d.value = 1 最终调到 VPI 的信号写接口,await RisingEdge(dut.clk) 是向仿真器注册一个值变化回调然后让出协程;仿真时间始终由 vvp 主循环推进,Python 只在沿与定时器之间醒来执行。于是测试台和 DUT 虽在两种语言里,时序却是同一条时间轴。
三、输入输出文件长什么样
吃进去的第一个文件是 RTL。DUT 选最小的时序逻辑:低电平异步复位的 D 触发器,时钟沿把 d 搬进 q。
module dff (
input wire clk,
input wire rst_n,
input wire d,
output reg q
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
endmodule第二个输入是 test_dff.py,两条用例:复位期间 q 必须恒为 0;释放复位后 d 只在上升沿进入 q,沿中间改 d 不能影响输出。
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import ClockCycles, RisingEdge, Timer
@cocotb.test()
async def test_reset_holds_q_zero(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
dut.d.value = 1
dut.rst_n.value = 0
await ClockCycles(dut.clk, 3)
assert dut.q.value == 0, f"in reset q={dut.q.value.binstr}, expected 0"
dut.rst_n.value = 1
@cocotb.test()
async def test_d_flows_on_rising_edge(dut):
cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
dut.d.value = 0
dut.rst_n.value = 0
await ClockCycles(dut.clk, 2)
dut.rst_n.value = 1
await RisingEdge(dut.clk)
await Timer(1, unit="ns")
assert dut.q.value == 0
dut.d.value = 1
await RisingEdge(dut.clk)
await Timer(1, unit="ns")
assert dut.q.value == 1, f"after edge q={dut.q.value.binstr}, expected 1"
dut.d.value = 0
await Timer(3, unit="ns")
assert dut.q.value == 1, f"q changed mid-cycle: q={dut.q.value.binstr}"
await RisingEdge(dut.clk)
await Timer(1, unit="ns")
assert dut.q.value == 0cocotb 自动把 @cocotb.test() 装饰的协程收集成用例,按文件里的顺序逐个跑,dut 句柄由框架按 TOPLEVEL 名字注入。赋值语义顺带记两个:单比特信号可以直接给整数 0 或 1,多比特向量在 cocotb 2.1 里要用 LogicArray 构造,旧教程的 BinaryValue 已不推荐;读回的 dut.q.value 既能和整数直接比较,也能取 .binstr 得到 0/1 字符串,上面断言的报错信息里两种读法都用到了。吐出来的东西有三样:sim_build/ 目录放编译产物 sim.vvp 和命令清单;工程根目录的 results.xml 是 JUnit 格式报告,实跑片段如下,每条用例带名字、仿真时长和随机种子,CI 直接解析即可;终端最后还有一张人类可读的汇总表。
<testsuites name="cocotb tests"><testsuite name="test_dff" errors="0"
failures="0" skipped="0" tests="2" time="0.001"
timestamp="2026-11-02T17:30:43+00:00">
<testcase classname="test_dff" name="test_reset_holds_q_zero" .../>
<testcase classname="test_dff" name="test_d_flows_on_rising_edge" .../>
</testsuite></testsuites>四、常见错与检查点
第一类是名字对不上,占新手报错的大多数。TOPLEVEL 不等于模块名,仿真器报找不到顶层;COCOTB_TEST_MODULES 与 py 文件名不一致,cocotb 收集不到任何用例,表现为一条 No tests 警告,细节见 cocotb NO_TEST_ERROR 排查。检查方法就是把三个名字摆成一行对:模块名、文件名、Makefile 变量值。
第二类是 TOPLEVEL_LANG 与实际语言不符,VHDL/Verilog 混填会在 GPI 注册阶段报错,纠正方式与复现见 cocotb toplevel 语言错误。
第三类是小写 sim=icarus 的假生效。make 变量大小写敏感,真正的变量叫 SIM。本文配套脚本里故意保留了 make sim=icarus 这一条:它之所以能跑通,纯粹因为 Makefile 里写了 SIM ?= icarus,默认值恰好就是 icarus;一旦你想切后端写 sim=verilator,会发现设置完全不生效。判断当前后端别信命令行,看日志里实际调用的是 vvp 还是别的仿真核。
第四类是沿后立刻读输出。RTL 用非阻塞赋值,await RisingEdge 返回的同一时刻读 q 可能拿到旧值或触发竞争警告。统一的纠正动作是沿后补一个 await Timer(1, unit="ns") 再采样,上面两条用例都照此办理。断言真正失败时的红字与退出码长什么样,见 cocotb 断言失败 TestFailure。
五、真实跑一遍:D 触发器两条用例全过
把目录挂进容器,激活自带 cocotb 的虚拟环境后一条命令跑完。
docker run --rm -v "$PWD":/work -w /work geek-eda:0.3-sta \
bash -lc 'source /opt/venvs/cocotb/bin/activate && make sim=icarus'终端卡从上往下读:cocotb 2.1.0 在 0 ns 完成初始化并声明运行在 Icarus Verilog 12.0 上,随后两条用例分别在 20 ns 和 71 ns 处通过,末尾表格给出每条用例的仿真时间与加速比,最底下一行 TESTS=2 PASS=2 FAIL=0 SKIP=0 就是整条流程的验收标记——接 CI 时解析这一行或解析 results.xml,比只看退出码可靠(过滤到零用例时退出码仍可能是 0)。
终端卡里是运行过程,测试收尾时 cocotb 打印的汇总表与 results.xml 摘要如下,两条用例均为 PASS:
71.00ns INFO cocotb.regression test_dff.test_d_flows_on_rising_edge passed
** TEST STATUS SIM TIME (ns) REAL TIME (s) RATIO (ns/s) **
** test_dff.test_reset_holds_q_zero PASS 20.00 0.00 43626.50 **
** test_dff.test_d_flows_on_rising_edge PASS 51.00 0.00 106327.97 **
** TESTS=2 PASS=2 FAIL=0 SKIP=0 71.00 0.00 41643.99 **实验环境
- 机器/系统:Docker 容器
geek-eda:0.3-sta(Ubuntu 24.04),宿主为 macOS - 工具版本:cocotb 2.1.0(容器内
/opt/venvs/cocotb虚拟环境)、Icarus Verilog 12.0、Python 3.12.12、GNU Make 4.3 - 日期:2026-11-03
- 完整命令:
docker run --rm -v "$PWD":/work -w /work geek-eda:0.3-sta bash -lc 'source /opt/venvs/cocotb/bin/activate && bash run.sh'(run.sh 内执行make sim=icarus并摘录results.xml)
代码获取
完整 RTL、Python 测试台、Makefile 与运行脚本位于公开仓 https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/flow-cocotb-first-test。进入目录后执行 docker run --rm -v "$PWD":/work -w /work geek-eda:0.3-sta bash -lc 'source /opt/venvs/cocotb/bin/activate && make SIM=icarus' 即可复现两条用例全过并生成 results.xml。
这一篇在知识体系里的位置
本篇管「cocotb 工程第一次跑通」这条最短闭环。要把它扩成日常使用的骨架,看 cocotb 最小测试平台 Python 模板,那里多了加载计数器、挂死救援和参数化用例;跑不通时按现象分流到 cocotb NO_TEST_ERROR 排查、toplevel 语言错误 与 断言失败 TestFailure 三篇报错页。
总结
cocotb 第一个测试流程的最小闭环是「一个 RTL、一个 Python 测试台、一个 Makefile」:Makefile 用 SIM、VERILOG_SOURCES、TOPLEVEL、COCOTB_TEST_MODULES 四个变量把模块名和文件名钉死,include Makefile.sim 接管编译;vvp -m 预加载的 VPI 插件在同一进程里嵌入 Python,使协程里的信号赋值和边沿等待落到真实仿真时间轴上。容器实跑两条用例,71 ns 处收齐 TESTS=2 PASS=2 FAIL=0 与 JUnit 报告;名字对不上、语言填错、小写 sim 假生效、沿后立刻采样是四个最先要排的检查点。