cocotb await 边沿误用导致挂死:RisingEdge、FallingEdge 与 Timer 的选型
cocotb 测试平台里最隐蔽的一类失败,是协程停在一句 await RisingEdge(...) 上再不动弹:没有断言、没有 traceback,make 不退出,CI 一直转,最后只能被超时杀掉。cocotb await 边沿误用导致挂死的根源只有一个——边沿触发器等的是信号值的真实跳变,而你等的那个跳变永远不会发生。本文在同一容器里真实触发四类误用,用墙钟超时退出码 124、cocotb 内部 SimTimeoutError 和 0ns SimFailure 三种证据区分不同结局,并对照「Skipping simulation 零测试」这种看起来最像挂死、其实根本没跑的现象。配置面问题可先看 cocotb TOPLEVEL_LANG 配置错误,测试中途超时则参考 cocotb TimeoutError 排查。
本文要回答的问题
- 低有效复位写成
await RisingEdge(rst_n)为什么永远等不到? - 时钟协程还没启动就等
RisingEdge(clk),到底是挂死还是立刻报错? - 在组合逻辑输出上等边沿,和在寄存器输出上等,事件来源有何不同?
- 「固定等一段时间」应该用什么触发器?
with_timeout与外部timeout 30有什么分工? - 怎样区分真挂死(被杀,退出码 124)与
Skipping simulation的零测试退出码 0?
这张流程卡对应本文全部实验:前三步描述挂起机制,最后一步是三条真实出口。读图时抓住第二步——边沿触发器不关心时间走了多久,只关心那个具体信号有没有跳变;时间走得再远,信号不动,协程就不醒。
一、四类误用的完整现象原文
实验 DUT 只有一个低有效复位的 4 位寄存器和一根组合输出 comb_gate = din_v & rst_n。每组用例只改测试代码,全部通过 bash run.sh 顺序执行,每个 make 外层包 timeout 30。第一组把 rst_n 拉低后却等它的上升沿,而释放复位的赋值写在 await 之后:
终端卡里最后一句业务日志停在仿真时间 20ns 的「about to wait RisingEdge(rst_n) while it is held low」,之后再无任何输出,直到 make 被外部信号终止:
make: *** [.../simulators/Makefile.icarus:68: results.xml] Terminated
c1-reset-polarity_EXIT=124退出码 124 是 GNU timeout 的专属返回值,表示进程是被超时杀掉的,而不是自己失败退出——这是「真挂死」的硬证据。第二组同样等一个不会来的时钟边沿,但保留一个只靠 Timer 翻转无关信号的后台协程,仿真时间因此一路空转到 16.6ms、墙钟 29.16 秒后被杀(c2-clock-not-started_EXIT=124)。把后台活动也去掉,现象立刻反转:仿真器在 0ns 耗尽所有事件,cocotb 2.1.0 直接报 SimFailure,提示语直接点了时钟的名。
0.00ns INFO cocotb.edge_dut C2B: no clock, no Timer, awaiting RisingEdge(clk) at 0ns
0.00ns WARNING cocotb.regression ... failed
cocotb.regression.SimFailure: cocotb expected it would shut down the simulation,
but the simulation ended prematurely ... or due to the simulator running out of
events to process (is your clock running?).
c2b-no-events_EXIT=2第三组在恒定不变的组合信号 comb_gate(恒为 0)上等上升沿,与第一组同构,c3-comb-edge_EXIT=124。第四组本意是「释放复位后固定等 200ns」,却写成等 busy 的上升沿而 din_v 从不拉高;外层包 with_timeout(..., 200, "ns") 后,失败在仿真时间 230ns 快速到来,墙钟几乎为零:
230.00ns WARNING cocotb.regression ... failed
File "/work/cases/c4-timer-vs-edge/test_c4.py", line 22, in ...
await with_timeout(RisingEdge(dut.busy), 200, "ns")
cocotb.triggers.SimTimeoutError
c4-timer-vs-edge_EXIT=2最后一组 c5 是对照:TOPLEVEL_LANG 与 Icarus 不匹配时只打印一行 Skipping simulation as only Verilog is supported on simulator=icarus,c5-skipping_EXIT=0——没有挂死、没有失败,但测试数为零。它最容易骗过只看退出码的 CI。
| 用例 | 错误 | 仿真时间 | 墙钟 | 退出码 | 结局 |
|---|---|---|---|---|---|
| c1 | 低有效复位等错边沿,释放语句在 await 后 | 停在 20ns | 30s | 124 | 被 timeout 杀 |
| c2 | 未启动时钟,另有 Timer 后台活动 | 空转到 16.6ms | 29.16s | 124 | 被 timeout 杀 |
| c2b | 未启动时钟且无任何事件 | 0ns | ≈0 | 2 | SimFailure |
| c3 | 在恒定组合信号上等边沿 | 停在 20ns | 30s | 124 | 被 timeout 杀 |
| c4 | 该用 Timer 却等信号边沿 | 230ns | ≈0 | 2 | SimTimeoutError |
| c5 | 语言配置不匹配 | 不启动 | ≈0 | 0 | Skipping,零测试 |
二、工具与版本:先固定基线
所有结论只对下面这组版本负责。cocotb 装在镜像的独立虚拟环境中,不激活时 cocotb-config 不在 PATH,会先得到另一类 make 错误,排查时不要把环境问题与边沿问题混为一谈。
| 组件 | 版本 | 在实验中的角色 |
|---|---|---|
| cocotb | 2.1.0 | 触发器语义与回归框架 |
| Icarus Verilog | 12.0 (stable) | 编译与 vvp 运行 |
| Python | 3.12.12 | cocotb venv 解释器 |
| GNU timeout | coreutils 8.32(容器内) | 30 秒墙钟兜底 |
| 容器系统 | Ubuntu 24.04.5 LTS | geek-eda:0.3-sta 镜像 |
版本差异里有一个真实的 API 变化:旧教程常出现的 FirstEdge(第一次任意跳变即唤醒)在 cocotb 2.1.0 的 cocotb.triggers 中已不存在,只剩 Edge、RisingEdge、FallingEdge、Timer 等。照抄旧代码会在导入期就 ImportError,那属于 API 迁移问题,不是挂死;等价语义直接用 Edge。排查前先跑 cocotb-config --version 确认版本,再谈触发器选型。
三、根本原因:值跳变与时间流逝是两条独立的轴
蓝色是边沿路径,紫色是时间路径,红色判定点是四类误用的共同卡点:跳变不存在,唤醒就永远不会发生。仿真时间可以因为别处的 Timer、时钟翻转而继续前进,但那与被挂起的协程无关——这正是 c2 仿真时间空转到 16.6ms、协程依然不醒的原因。
四类误用分别违反了不同的前提。其一,复位极性:rst_n 低有效意味着「拉低是断言、拉高是释放」。复位序列中要等的是「保持低电平一段时间」这件事,该用 Timer;若要同步释放动作,应在主动赋值 rst_n = 1 之后等一个 FallingEdge(clk) 或 RisingEdge(clk),而不是等 rst_n 自己的上升沿——尤其不能把释放赋值放在 await 之后,那是自锁。其二,时钟来源:Clock(dut.clk, 10, "ns").start() 必须先 cocotb.start_soon 启动,clk 上才有周期性事件;时钟是整个测试台的事件发动机,发动机没点火,边沿无从谈起。其三,信号是否真有跳变:组合输出 assign y = a & b 只随输入变化,输入恒定它就恒定,事件源在输入而不在它身上;寄存器输出也要有时钟和使能才可能翻转。其四,意图与触发器匹配:想要「固定时长」用 Timer(200, "ns"),想要「等到某事件」才用边沿;等待一个没人负责产生的事件,等于等待戈多。
with_timeout 适合给「我确信它该在若干仿真时间内发生」的等待加护栏,失败时停在精确的仿真时刻、给出 Python traceback,定位成本最低;外部 timeout 30 是墙钟兜底,防的是整条 run 脚本被挂死用例永久阻塞。两者应叠加使用:测试代码内用 with_timeout 表达预期上限,CI/脚本层用 timeout 兜底。
四、排查清单:先确认事件源,再改代码
协程停住不动时,按下面顺序收敛,每步只改一处:
- 先看日志最后一条业务消息的仿真时间。重跑一次,若停在同一个仿真时间不动、墙钟持续增长,就是挂死;若 0ns 立刻出现
SimFailure ... is your clock running?,直接查时钟是否启动。 - 确认时钟在跑:grep 测试代码里有没有
Clock(...).start()与cocotb.start_soon,且启动语句在第一个await RisingEdge(clk)之前。可用波形或在时钟协程外周期性打印仿真时间辅助判断。 - 确认复位极性:看 RTL 端口名(
rst_n/rst_n低有效)与 always 敏感表(negedge rst_n),再对照测试台等待的方向;低有效复位的保持段用Timer,不要等rst_n的边沿。 - 确认被等信号真会跳:组合信号回溯它的所有输入,寄存器信号确认时钟与使能条件;必要时在 await 前先打印一次信号值,并在波形里观察整个仿真区间。
- 审视意图:凡是「等 N 纳秒/周期」的念头,先写
Timer或ClockCycles;只有「等某件事发生」才用边沿,并给关键等待加with_timeout。 - 确认不是「没跑」:日志没有
running ...与TESTS=行却退出码 0,grepSkipping simulation,这是 Makefile 配置问题,按 cocotb TOPLEVEL_LANG 配置错误 修,不要去改测试代码。
| 观察到的现象 | 性质 | 首查位置 |
|---|---|---|
| 仿真时间停住、墙钟转满、退出码 124 | 真挂死 | 被等信号的事件源 |
| 0ns SimFailure 且提示 is your clock running | 事件耗尽 | 时钟协程是否启动 |
| 仿真时间精确停止、SimTimeoutError | 内部超时兜底触发 | 事件为何未在预期内发生 |
| 只有 Skipping simulation、退出码 0 | 零测试 | TOPLEVEL_LANG / SIM 配置 |
| 有 running 但断言失败 | 逻辑错误,非挂死 | 测试预期与 RTL |
五、修复代码与复跑确认
修复后的正确用例把四类等待各归其位:先启动时钟,复位用 Timer 保持再主动释放,同步沿走 clk,固定延时也走 Timer。
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, FallingEdge, Timer
@cocotb.test()
async def edge_usage_all_correct(dut):
cocotb.start_soon(Clock(dut.clk, 10, "ns").start()) # 先点火事件发动机
dut.rst_n.value = 0 # 低有效复位断言
dut.din_v.value = 0
await Timer(25, unit="ns") # 保持固定时长,用 Timer
dut.rst_n.value = 1 # 主动释放
await FallingEdge(dut.clk) # 等时钟沿完成同步
await RisingEdge(dut.clk)
dut.din_v.value = 1 # 制造真实跳变的事件源
dut.din.value = 5
await RisingEdge(dut.clk)
dut.din_v.value = 0
await RisingEdge(dut.clk)
assert int(dut.dout.value) == 5复跑后正确组的回归摘要与退出码如下,这是判断「修复成功」的唯一凭据——必须出现真实的 TESTS=1 PASS=1 FAIL=0,而不是 make 返回 0:
50.00ns INFO cocotb.edge_dut GOOD: dout sampled as 5, all edge waits returned
50.00ns INFO cocotb.regression test_good.edge_usage_all_correct passed
** TESTS=1 PASS=1 FAIL=0 SKIP=0 50.00 **
good_EXIT=0同时,脚本对错误组的预期也被一并固化:c1/c2/c3 必须为 124,c2b/c4 必须非零且 c4 含 SimTimeoutError,c5 必须只有 Skipping 且退出码 0。任何一组结局反转,说明修复改变了故障性质,需要重新定位。更多测试执行期失败的对拍方法见 cocotb 断言失败与 testfailed。
代码获取:DUT、六组错误/正确用例与一键复现脚本位于公开仓
experiments/ts-cocotb-fallingedge-misuse/。准备好文中 Docker 环境后执行bash run.sh,对照各用例的退出码与 SUMMARY 段即可完整复现。
实验环境
- 机器/系统:Linux x86_64 Docker 容器 / Ubuntu 24.04.5 LTS(镜像 geek-eda:0.3-sta)
- 工具版本:cocotb 2.1.0、Icarus Verilog 12.0 (stable)、Python 3.12.12、GNU coreutils timeout
- 日期:2026-11-02
- 完整命令:
docker run --rm -v "$PWD":/work -w /work geek-eda:0.3-sta \
bash -lc 'source /opt/venvs/cocotb/bin/activate && bash run.sh'这一篇在知识体系里的位置
本篇处理测试代码中「等待对象选错」的一类问题,现象是挂起不返回。cocotb TimeoutError 排查处理驱动/采样时刻导致的超时失败;cocotb plusargs 传参失败处理命令行参数通道;cocotb no tests to run 排查处理测试发现失败。前两者发生在测试运行之中,后两者发生在测试运行之前或根本未启动,排查入口互不相同。
总结
cocotb await 边沿误用导致挂死的机理只有一句话:边沿触发器等信号值跳变,跳变不存在就永不唤醒。实测四类误用的结局清晰可分:低有效复位等错边沿、未启动时钟但仍有 Timer 活动、在恒定组合信号上等沿,都会让墙钟耗尽、被 timeout 杀掉并返回 124;时钟与事件全无时仿真器 0ns 耗尽事件,cocotb 报 SimFailure、退出码 2;该用 Timer 的等待用 with_timeout 加护栏后在精确仿真时刻抛 SimTimeoutError;而 Skipping simulation 退出码 0 是零测试而非挂死。修复原则是时钟先启动、复位极性与等待方向对齐、边沿只等真实跳变、固定时长一律用 Timer,最终以日志中的 TESTS=1 PASS=1 FAIL=0 而非 make 退出码作为通过判据。