cocotb 协程并发:start_soon 多激励并行与 Join/Combine 时序实测
单协程测试台只能「发一段、等一段、再发一段」,而真实接口上激励与监测永远同时发生:一边在 valid 上拍数据,另一边要盯着输出口逐拍检查。cocotb 协程并发就是解决这件事的机制——多个协程挂在同一条仿真时间轴上,靠边沿和定时点交替醒来。本篇用一个两级流水寄存器当 DUT,在 cocotb 2.1.0 加 Icarus Verilog 12.0 的容器里真跑四条用例,日志时间戳直接展示交错与漏拍。第一个 cocotb 工程的闭环见 cocotb 第一个测试流程,Makefile 各变量含义见 cocotb Makefile 流程。
本文要回答的问题
- 两个协程怎样才能真正并行跑在同一条仿真时间轴上,而不是先后排队?
cocotb.start_soon和直接await coro()有什么区别,start又用在哪?Timer、RisingEdge、Join、Combine、gather各自在等什么?- cocotb 2.1 里哪些旧写法已经告警,现行推荐写法是什么?
一、cocotb 协程并发在验证流程里的位置
cocotb 的测试体本身就是一个协程;start_soon 的作用是把另外的协程登记到调度器上,让它们和测试体共享同一条仿真时间轴。协程之间没有操作系统线程,切换只发生在 await 触发器(边沿、定时点、别的任务)的一刻:协程让出,仿真核把时间推进到下一个事件,调度器再把等待该事件的协程唤醒。于是「发送」和「监测」可以各自按自己的节奏写顺序代码,时序上却是并行的。
读这张卡抓住三个颜色分区:蓝色是「怎么把协程弄起来」,紫色是「协程在哪里把执行权交出去」,红色是「怎么等它们收尾并收结果」。灰色一行提醒本质:cocotb 协程并发是协作式的,await 之外的代码不会被别的协程打断,所以共享变量不需要加锁。
二、API 逐段解读:启动、让出、汇合
先看调度关系。下图是两个协程从启动到交错恢复的完整路径:
读这张图回答「并行是怎么发生的」:两个协程都在 await RisingEdge 处让出,调度器只为它们各注册一个等待;vvp 把时间推过时钟沿后一次性回调,两个协程在同一个时间步里先后恢复,于是日志出现交错。
启动:cocotb.start_soon(coro) 返回一个 Task 句柄,协程在当前测试体让出时开始跑,这是默认选择;cocotb.start(coro) 让新协程立刻排到调度队列最前、在 start 返回前就跑到第一个让出点,只有需要「先让它跑一拍」的特殊顺序才用。注意两者都必须传入协程对象(调用后的结果),不是函数本身。
让出:await RisingEdge(dut.clk) 等下一个上升沿;await Timer(2, unit="ns") 等 2 ns 仿真时间,常用于沿后错开采样点、避开 RTL 非阻塞赋值的读竞争;ClockCycles(dut.clk, n) 是等 n 个周期的简写。仿真时间只在所有协程都让出时才向前走。
汇合(cocotb 2.1 的关键变化,实跑日志里有对应告警):直接 await task 就能等任务结束并拿到协程返回值,这是现行首选;旧码里的 Join(task) 仍能跑,但会打 Using task directly is preferred 告警;Combine(*tasks) 一次等多个任务,可 2.1 对传入 Task 的形式打了 Passing Task objects to Combine is deprecated. Use gather instead 告警,现行写法是从 cocotb.triggers 导入 gather,直接传协程:
from cocotb.triggers import RisingEdge, Timer, gather
p = cocotb.start_soon(producer(dut, beats))
w = cocotb.start_soon(watchdog(dut, 10))
await p # 等发送侧结束
got = await w # 监测侧返回值就是抓到的数据列表
# 多协程一行汇合,结果按传入顺序返回:
p_ret, w_ret = await gather(producer(dut, beats), watchdog(dut, 10))三、输入输出文件长什么样
DUT 是两级流水延迟寄存器:s 侧拍入的数据每过一个时钟沿向后挪一级,两拍后从 m 侧吐出,STAGES 个寄存器用 for 循环展开(Icarus 需 -g2012,由 Makefile 的 COMPILE_ARGS 给出)。
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < STAGES; i = i + 1) begin
data_pipe[i] <= 8'h00;
valid_pipe[i] <= 1'b0;
end
end else begin
data_pipe[0] <= s_data;
valid_pipe[0] <= s_valid;
for (int i = 1; i < STAGES; i = i + 1) begin
data_pipe[i] <= data_pipe[i-1];
valid_pipe[i] <= valid_pipe[i-1];
end
end
end测试台里两个协程各司其职。producer 复位后在四个连续周期拍入 0xA1..0xD4;watchdog 在每个上升沿后 2 ns 采样 m 侧,见到 m_valid 就记录。注意 watchdog 并不关心 producer 跑到哪——它只认自己的时钟沿,这正是并发的意义。
async def producer(dut, beats):
await reset_dut(dut)
for i, data in enumerate(beats):
dut.s_data.value = data
dut.s_valid.value = 1
dut._log.info("producer 第 %d 拍拍入 data=0x%02X", i + 1, data)
await RisingEdge(dut.clk)
dut.s_valid.value = 0
async def watchdog(dut, cycles, tag):
got = []
for _ in range(cycles):
await RisingEdge(dut.clk)
await Timer(2, unit="ns")
if dut.m_valid.value == 1:
got.append(int(dut.m_data.value))
return got四、常见错与检查点
第一类,也是本篇的主角:把协程直接顺序 await。写成 await producer(...) 之后再 await watchdog(...),监测窗口整体排在发送窗口后面,接口上的输出全错过了。纠正动作是两个都 start_soon 登记,再汇合。下一节的实测里,顺序版只抓到流水线上最后残留的一拍。
第二类:start_soon 之后不汇合就让测试体结束。测试体返回时未结束的后台任务会被调度器回收并告警,表现为激励「发了一半没了」。检查点:每个 start_soon 都要有对应的 await task、gather 或(旧码)Join/Combine。
第三类:沿后同一时刻立刻采样。await RisingEdge 返回时 RTL 的非阻塞赋值刚生效,同刻读输出可能拿到旧值。统一在沿后补 await Timer(1~2, unit="ns") 再采样;采样与驱动错开在不同时刻,也能消除同一信号的读写竞争告警。
第四类:照搬 cocotb 1.x 旧教程。@cocotb.coroutine 装饰器、yield RisingEdge、fork 那套 generator 语法在 cocotb 2 已移除,协程一律用原生 async def 加 await;Join 从 cocotb.triggers 导入也已改到 cocotb.task 并告警,2.1 直接 await 任务即可。边沿方向写反导致的等待异常见 cocotb FallingEdge 误用,沿后读写报错见 cocotb TimeoutError 驱动与读取。
五、真实跑一遍:交错时序与顺序版漏拍
工程挂进容器一条命令跑完,四条用例全过。第一条用例的日志如下,注意 producer 与 watchdog 的时间戳是穿插的:
把第一条用例的关键时间点摊开看,「一边发一边收」一目了然:
| 仿真时间 | producer | watchdog |
|---|---|---|
| 20 ns | 拍入 0xA1 | — |
| 30–40 ns | 拍入 0xB2、0xC3 | — |
| 42 ns | — | 抓到 0xA1(流水延迟两拍) |
| 50–52 ns | 拍入 0xD4 | 抓到 0xB2 |
| 60 ns | 四拍发完,Join(producer) 返回 | — |
| 62–72 ns | — | 抓到 0xC3、0xD4 |
| 92 ns | — | 窗口结束,四拍收齐 |
60 ns 处发送侧已经结束、监测侧仍在自己的窗口里运行,这一行日志证明两个协程生命周期独立,Join 只等其中一个。作为对照,顺序版用例里 producer 跑到 70 ns 结束后 watchdog 才第一次启动,只在 82 ns(对应用例起点后)逮到流水线上残留的 0xD4,前三拍全部漏过。四条用例的汇总表如下:
** TEST STATUS SIM TIME (ns) REAL TIME (s) RATIO (ns/s) **
** test_pipe.test_start_soon_then_join PASS 92.00 0.00 23674.70 **
** test_pipe.test_combine_waits_both PASS 102.00 0.00 41727.20 **
** test_pipe.test_gather_modern_style PASS 102.00 0.00 41128.87 **
** test_pipe.test_sequential_await_misses_beats PASS 172.00 0.00 71940.76 **
** TESTS=4 PASS=4 FAIL=0 SKIP=0 468.00 0.01 35981.73 **并发三种写法(Join、Combine、gather)都在约 92–102 ns 内收齐四拍数据;顺序 await 版因为两个窗口首尾相接,仿真耗时 172 ns 且漏掉三拍——功能错了,仿真还更慢。读这张表只盯 SIM TIME 一列,REAL TIME 是墙上时间,受宿主机调度影响,不用来判断并发写法的快慢;另外 gather 的结果严格按传入协程的顺序返回,发送侧没有 return 语句,对应位置就是 None。随机激励下并发回归的组织方式见 cocotb 随机种子回归流程。
实验环境
- 机器/系统:Docker 容器
geek-eda:0.4-flow(Ubuntu 24.04),宿主为 macOS - 工具版本:cocotb 2.1.0、Icarus Verilog 12.0、Python 3.12.3(vvp 内嵌解释器 3.12.12)、GNU Make 4.3
- 日期:2026-11-04
- 完整命令:
docker run --rm -v "$PWD":/work -w /work geek-eda:0.4-flow bash run.sh(run.sh 激活/opt/venvs/cocotb虚拟环境后执行make clean与make SIM=icarus)
代码获取
完整 RTL、Python 测试台、Makefile 与运行脚本位于公开仓 https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/flow-cocotb-coroutines。进入目录后执行 docker run --rm -v "$PWD":/work -w /work geek-eda:0.4-flow bash run.sh 即可复现四条用例全过,预期末尾输出 TESTS=4 PASS=4 FAIL=0 SKIP=0。
这一篇在知识体系里的位置
本篇解决「测试台里多个激励/监测怎样同时跑」。单协程最小闭环见 cocotb 第一个测试流程,Makefile 的变量与产物见 cocotb Makefile 流程,可复制的工程骨架见 cocotb 最小测试平台 Python 模板,并发协程上做随机回归见 cocotb 随机种子回归流程。
总结
cocotb 协程并发的要点是「start_soon 登记、await 让出、最后汇合」:start_soon 把 producer 与 watchdog 挂到同一条仿真时间轴,协程在 RisingEdge 与 Timer 处协作式让出,vvp 推进时间后调度器按事件交错唤醒;cocotb 2.1 的现行汇合用直接 await task 取返回值、gather 一次汇合多个协程,Join 与传 Task 的 Combine 仍能跑但已打 DeprecationWarning。容器实测并发版 92–102 ns 收齐四拍且日志时间戳穿插,顺序 await 版 172 ns 只抓到残留一拍,漏掉三拍。