cocotb 报 SimTimeoutError / with_timeout 超时:时钟没起、条件永假、预算太短三因速查
用 cocotb 写 testbench,最常撞见的挂死就是 await with_timeout(RisingEdge(dut.xxx), 500, "ns") 最后抛出 cocotb.result.SimTimeoutError——仿真时间走完预算,测试以 FAIL 收场,而你等的那一拍永远没来。这类报错信息极短, traceback 只指到 with_timeout 那一行,看不出「为什么等不到」。本篇用一块异步 FIFO(async FIFO)RTL 故意复现「漏启动时钟」导致的超时,给出挂死与修复两版真实日志、波形对照和一张三因分诊图。同族的 cocotb 配置类报错见 cocotb 报 no tests to run 的修复,开源 EDA 报错总索引见 开源 EDA 报错导航。
答案块(赶时间先看这里)
- 报错原文:
cocotb.result.SimTimeoutError,traceback 落在await with_timeout(RisingEdge(dut.wclk), 500, "ns"),回归汇总行TESTS=2 PASS=1 FAIL=1。 - 环境:cocotb 1.8.1 + Icarus Verilog 11.0 / Ubuntu 22.04(各版本同一组根因)。
- 根因三选一:① 时钟没启动——漏写
cocotb.start_soon(Clock(dut.wclk, 7, units="ns").start()),wclk恒为 0,任何沿都等不到;② 等待条件永假——比如等 FIFOempty拉低,但写侧因full死锁从未写入,标志位永远不变;③ 超时预算太短——复位+打拍同步本身就要几十拍,预算给了 500 ns 实际不够。 - 30 秒定位:看回归日志里 FAIL 时的仿真时间戳是否恰好等于超时预算(如 500.00ns)——等于说明是被看门狗打死的;再 dump 波形看你等的信号在整个窗口里动没动过。
- 修复一句话:把该起的时钟先
start_soon起来、把「死等一个沿」改成「带上限轮询标志位」、把预算按复位+同步拍数重新估算,再跑回归确认PASS=2 FAIL=0。
本文要回答的问题
SimTimeoutError抛出来时,仿真器到底处在什么状态?- 「时钟没起」和「条件永假」在日志和波形上怎么区分?
- 异步 FIFO 场景下为什么「等标志位」比「数固定周期」更稳?
- 超时预算怎么估才不算拍脑袋?
一、报错原文与现场
下面是故意挂死版的真实回归实录:testbench 里 wait_edge_never_comes 这个用例漏了启动 wclk,直接 await with_timeout(RisingEdge(dut.wclk), 500, "ns");同文件的 wait_edge_fixed 则先把时钟起好再等同一个沿,作为组内对照。
500.00ns INFO cocotb.regression wait_edge_never_comes failed
Traceback (most recent call last):
File ".../testbench/test_async_fifo_timeout.py", line 10, in wait_edge_never_comes
await with_timeout(RisingEdge(dut.wclk), 500, "ns")
File ".../cocotb/triggers.py", line 1029, in with_timeout
raise cocotb.result.SimTimeoutError
cocotb.result.SimTimeoutError完整终端实录(含前后文与汇总表):
读这张卡抓三个要点。第一,失败时间戳恰好是 500.00ns——与 with_timeout 的预算分毫不差,说明协程不是自己出错崩的,而是被看门狗打死的,这是超时类失败和断言类失败的分水岭。第二,traceback 最后一帧停在 cocotb 内部的 triggers.py 抛 SimTimeoutError,真正的「案发行」是上一帧——你自己 testbench 里 await 的那一行,排查时盯住上一帧即可。第三,汇总行 wait_edge_never_comes FAIL 500.00 与 wait_edge_fixed PASS 0.00 形成干净对照:同一个沿、同一份 DUT,差别只在有没有启动时钟,0.00ns 通过说明时钟一起来沿立刻就到。
还有一个容易误读的细节:日志里 make 的退出码是 0,因为这套实验把两个用例放在同一回归里、失败不中断;在默认 cocotb Makefile 流程里 FAIL 会导致 results.xml 记录失败并让 make 报错。别用「make 有没有报错」判断有没有超时,以回归汇总表 PASS/FAIL 计数为准。
二、波形对照:沿为什么永远不来
挂死与修复在波形上的差别一目了然——挂死版 wclk 从头到低平,修复版 7ns 周期正常翻转:
读这张图抓住两个对照:上半组里 wclk 是一条贴地的直线,RisingEdge 触发器始终处于「未命中」的未知等待态,with_timeout 计数器走到 500ns 后拉低——协程被强制唤醒并抛异常;下半组里 wclk 以 7ns 周期翻转,第一个上升沿在 3.5ns 处到来,RisingEdge 立即命中,with_timeout 随之解除,整个过程远未到预算线。所以日志里 wait_edge_fixed 的 SIM TIME 是 0.00ns(不足 1ns 的显示截断),而挂死版顶格 500.00ns——预算线就是验尸线,波形上信号「动没动过」是区分三因的第一刀。
三、三因分诊:先看时钟,再看条件,最后看预算
SimTimeoutError 本身不说原因,但所有超时都能归进三类,按下从图从上往下分诊即可:
图中红色是三种根因,橙色是修复动作,绿色是验证通过,紫色是防复发沉淀。分诊顺序有讲究:先查时钟,因为它是成本最低的验证——dump 波形(本实验 WAVES=1 会生成 FST)看你等的信号在窗口里是否恒平;恒平就是时钟/复位没起。再查条件:时钟在走但等的事件不来,多半是和 DUT 状态耦合的条件永假——异步 FIFO 里最典型的是读侧死等 empty==0,而写侧因为每拍都看到 full==1(或复位没放)从未写入,两侧互相等,形成死锁。最后才调预算:前两者都排除后,把复位拍数、跨时钟域打拍同步级数(异步 FIFO 通常 2-3 级)、DUT 流水深度加起来乘周期,再乘 2-3 倍裕量当预算,别拍脑袋填 500。
四、病因速查表
| 报错现象 | 可能原因 | 快速验证 | 处置 |
|---|---|---|---|
| FAIL 时间戳 = 预算,波形上信号恒 0 | 时钟没启动(漏 start_soon(Clock(...).start())) | 波形看 wclk/rclk 是否翻转 | 测试开头先起时钟再 await |
| FAIL 时间戳 = 预算,时钟在走但标志不变 | 复位没释放 / 复位极性写反 | 波形看 rst_n 是否按预期拉高 | 检查 reset 协程与有效电平 |
读侧死等 empty==0 永不到来 | 写侧被 full 卡死,从未写入 | 波形看 wr_en 是否曾为 1 | 两侧都改带上限的轮询 |
写侧等 full==0 永不到来 | 读侧没在读,FIFO 被灌满 | 波形看 rd_en 与 rcount | 读写并发 start_soon,别串行等 |
| 偶发超时、加大预算就好 | 预算没算同步打拍与随机反压 | 日志比较 SIM TIME 与预算差距 | 按复位加同步级数重估预算乘裕量 |
| 沿采到但值不对、伴随超时 | 正沿后立即读信号撞上 NBA 更新 | 改用 FallingEdge 采样对比 | 采样/驱动挪到负沿或加 ReadOnly |
| 多时钟域只有一个域超时 | 只起了一个时钟 | 波形逐域检查 | 每个域各起一个 Clock |
表里第六行是个隐蔽坑:异步 FIFO 这类带寄存器输出的设计,正沿后立即读 full/empty 会读到非阻塞赋值更新前的旧值,旧 full=0 时往已满的 FIFO 写会覆盖丢数据,读侧于是永远等不齐数据而超时。本文修复版 testbench 的写法是统一在负沿采样和驱动(await FallingEdge(dut.wclk)),配合对标志位带上限轮询而不是数固定周期——因为跨时钟域打拍同步的拍数随实现而变,数周期是把测试绑定在实现细节上,轮询标志才是绑定在协议语义上。
五、修复与回归验证
修复只需一行:在 await 之前把时钟协程挂到调度器上——
@cocotb.test()
async def wait_edge_fixed(dut):
# 修复:先把时钟起起来,再等同一个沿,正常返回
cocotb.start_soon(Clock(dut.wclk, 7, units="ns").start())
await with_timeout(RisingEdge(dut.wclk), 500, "ns")同一套 async FIFO RTL 跑完整修复版回归(起双时钟、复位、单次读写 + 200 笔随机背靠背跨时钟传输),实录节选:
99.00ns INFO cocotb.regression single_write_read passed
2722.50ns INFO cocotb.async_fifo 200 tokens delivered in order across async clocks OK
2722.50ns INFO cocotb.regression random_back_to_back passed
** TESTS=2 PASS=2 FAIL=0 SKIP=0 2722.50 0.12 22423.97 **注意随机背靠背用例的 SIM TIME 是 2623.50ns——如果给这种带 200 笔随机反压的用例拍一个 500ns 的预算,它会被冤枉成「超时」,这正是「预算太短」那一因的现实版:预算必须覆盖最长合法行为,而不是最短路径。
实验环境
- 机器/系统:云主机 16C32G / Ubuntu 22.04
- 工具版本:cocotb 1.8.1(Python 3.10)+ Icarus Verilog 11.0 (stable),
COCOTB_HDL_TIMEUNIT=1ns、COCOTB_HDL_TIMEPRECISION=1ps - 日期:2026-10-24
- 完整命令:
make run MODULE=test_async_fifo_timeout(挂死复现);make run(修复版回归)
代码获取:复现实验(故意挂死版与修复版 testbench + async-fifo RTL)在公开仓 experiments/ts-cocotb-TimeoutError-drive-read,装有 cocotb + iverilog 时 make run 复现。
这一篇在知识体系里的位置
本篇属于开源 EDA 报错速查簇的 cocotb 分支,讲「测试跑起来了但等不到事件」这一族。同族配置类问题(测试根本没被发现)见 cocotb 报 no tests to run / Failed to import module;仿真器侧同类「转不动」问题见 Verilator UNOPTFLAT 组合环排查;环境类报错(容器权限)见 OpenLane docker permission denied;全部开源 EDA 报错的索引在 开源 EDA 报错导航。
一句话总结
cocotb 的 SimTimeoutError 只看 FAIL 时间戳是否顶格预算、波形上信号是否动过这两件事就能分诊:信号恒平是时钟没起(补 start_soon(Clock(...).start())),时钟在走但条件不成立是死锁或复位问题(改带上限的标志轮询、负沿采样),条件会来只是晚是预算太短(按复位加同步拍数重估乘裕量),修完以回归汇总 FAIL=0 收尾。