用Python给Verilog设计自仿(11):仿真的玄学问题——协程、阻塞、 事件驱动、时间驱动

来源:AdriftCoreFPGA芯研社 电路设计 15 次阅读
摘要:前言 在使用 cocotb 编写测试时,很多人可能都会遇到一些让人困惑的现象:比如说,你明明在发送一帧完整数据之后,加了 await RisingEdge(clk) 想等待 5 个周期再发下一帧,但在波形上却发现帧与帧之间并没有间隔;或者你在别的地方加了 Timer 也没起作用更甚至cocotb直接卡住不动了;又或者明明指定了发包数,却提前结束了。 这些现象的背后,其实不是 cocotb 出了问题

前言

在使用 cocotb 编写测试时,很多人可能都会遇到一些让人困惑的现象:比如说,你明明在发送一帧完整数据之后,加了 await RisingEdge(clk) 想等待 5 个周期再发下一帧,但在波形上却发现帧与帧之间并没有间隔;或者你在别的地方加了 Timer 也没起作用更甚至cocotb直接卡住不动了;又或者明明指定了发包数,却提前结束了。

这些现象的背后,其实不是 cocotb 出了问题,而是你还没有真正弄明白几个核心概念:协程的执行方式、Python 中的阻塞语义,以及事件驱动和时间驱动在仿真里的含义

接下来,我会结合具体案例,把这些机制拆开讲清楚,帮助你彻底搞懂它们。

案例说明

下面我将介绍一种非常典型的现象

for i inrange(2):await device_to_host_source.send(frame)for _ inrange(2):await RisingEdge(self.dut.clk)

上面的代码实现了一个 AXIS 发包程序,它会连续发送两帧数据,每帧持续 5 个时钟周期。按原本的设计意图,两帧之间应该间隔 2 个时钟周期。但实际运行后你会发现,代码表面上看没有问题,结果却完全没有达到预期效果,帧与帧之间并没有形成间隔。

协程和阻塞

当我们在RisingEdge后面加上utils.get_sim_time('ns'),这个函数的作用是打印当前的时间

for i inrange(2):await device_to_host_source.send(frame)for _ inrange(2):await RisingEdge(self.dut.clk)        dut._log.info(f"time={cocotb.utils.get_sim_time('ns')}, rising edge {_}")

我们会发现,第一次 RisingEdge 的时间点和 send 的时间点是重合的,似乎它们是同时发生的,这是为什么呢?

在 Verilog 中,wait 某个信号时会阻塞当前进程,直到条件满足。但 Python 与 Verilog 的机制有本质区别:在 Python 中,await 只会阻塞相同协程(如有两个RisingEdge) ,而不会阻塞其他协程。等待条件满足后,调度器会重新调度该协程继续运行。

这里的 device_to_host_source.send(frame) 和 RisingEdge(self.dut.clk) 分属不同协程, 协程可以理解为一种非阻塞的逻辑块,它们会并发运行,没有严格的先后顺序。

因此:

  • • 在第一拍(140ns),device_to_host_source.send(frame) 发送了帧的第一个周期的数据,同时 RisingEdge(self.dut.clk) 也完成了第一次触发。
  • • 在第二拍(150ns),同样是 send 发送第二拍的数据,同时另一个协程的 RisingEdge 触发。
  • • 到第三拍(160ns)时,RisingEdge 已经执行完毕,但 send 仍在继续。于是你在波形上看到的现象就是:RisingEdge(self.dut.clk) 并没有像预期那样“插入间隔”,看起来好像没有生效。”
  • • 到第六拍(190ns)时,发送第二个帧,已经没有 RisingEdge 触发了。

事件驱动和时间驱动

这时有些同学可能会灵机一动:既然帧的协程只占 5 拍,那我在两帧之间直接加上 6 个 RisingEdge,不就正好能空出 1 拍了吗?

for i inrange(2):await device_to_host_source.send(frame)for _ inrange(6):await RisingEdge(self.dut.clk)        dut._log.info(f"time={cocotb.utils.get_sim_time('ns')}, rising edge {_}")

想法听起来很完美,但现实却依旧不奏效。难道这意味着我们对协程和阻塞的理解是错误的吗?

好,那我们尝试加上 7 个 RisingEdge

for i inrange(2):await device_to_host_source.send(frame)for _ inrange(7):await RisingEdge(self.dut.clk)        dut._log.info(f"time={cocotb.utils.get_sim_time('ns')}, rising edge {_}")

惊奇地发现帧与帧之间真的空出了2 拍!

这就引出了一个疑问:为什么等待 6 个 RisingEdge 没有带来预期的 1 拍间隔,而等待 7 个 RisingEdge 却反而空出了 2 拍呢?是不是很神奇

在verilog仿真中,我们会理所当然的认为verilog是时间驱动的,也就是说在什么时刻做什么事,但是恰恰相反verilog仿真底层实际是事件驱动的,硬件人常说 Verilog 是时间驱动,确实我们硬件上板是基于时钟周期的硬件并行,看到的是时间流逝。甚至于仿真时,写 #10@(posedge clk),也看到的是 时间流逝。但实际上,verilog仿真是事件驱动的,仿真器维护着一个巨大的事件队列(Event Queue) 。仿真过程就是不断从队列中取出“下一个要发生的事件”进行处理。事件包括:

  • • 时钟信号变化
  • • 某个寄存器被赋值
  • • 某个线网(wire)因输入变化而需要重新计算值

同时为了提高仿真效率当没有任何事件触发的时间片会被跳过, 如果在一个给定的仿真时间点(比如 15ns),没有任何事件发生(没有信号变化,没有时钟边沿),仿真器就完全不会为此时间点安排任何时间片,而是直接跳到下一个有事件发生的时间点(比如 20ns 的下一个时钟上升沿)。这说明如果没有事件仿真会无法继续

所以,当我们观察 Send 和 RisingEdge 时,不应该只考虑它们发生在同一时刻的表面现象。即便它们在同一个物理时刻启动,它们背后实际上是不同的事件:

  • • Send 的结束事件是等待第 6 个上升沿完成后再拉低**tvalid**;
  • • 而 RisingEdge 的结束事件只是等待第 6 个上升沿触发

也就是说,Send 实际上比 RisingEdge 要慢,因此在波形上会把 RisingEdge 的效果“覆盖掉”。

如果我们改为等待 7 个 RisingEdge,情况就不同了:第一个 Send 早已完成,但 RisingEdge 还没有结束,它会在第 7 个上升沿触发后才完成。此时,第二个 Send 才会开始,由于第 7 个上升沿已经过去,所以第二个 Send 需要等待到第 8 个上升沿才真正启动。

没有事件仿真会无法继续

在使用 cocotb 运行测试时,如果协程末尾只写了 await Timer(100, "us"),你会发现仿真可能会卡住。而如果在其后加上 await RisingEdge(dut.clk),仿真就可以正常结束。

这是因为 Timer 本质上是时间驱动的,它只在指定的仿真时间到达时触发一次事件,但在这段时间内,如果没有其他事件发生,仿真器可能没有活跃事件去推进仿真。加入 RisingEdge 事件后,协程就依赖时钟沿触发,确保事件队列中始终有活动事件,从而让仿真能够继续运行。

# 等待更长时间,确保响应完成await Timer(100,"us")for _ inrange(10000):await RisingEdge(dut.clk)# 取消任务    monitor_task.kill()    responder_task.kill()    dut._log.info("--------------------------------SATA device test completed--------------------------------")

写在最后

通过上面的案例,其实我们可以看清楚:cocotb 并不是有 bug,而是它背后运行的 协程模型Python 的 await 语义,以及 事件驱动的仿真机制,和我们日常直觉(特别是做硬件时习惯的时间驱动思维)并不一样。

如果你没有搞清楚这些概念,就会遇到各种“玄学问题”:

  • • 为什么 await RisingEdge 看起来没有生效?
  • • 为什么 Timer 会让仿真卡住?
  • • 为什么 6 个 RisingEdge 没有间隔,7 个却多了 2 拍?

本质上,答案都在于:

  1. 1. Verilog 仿真是事件驱动的,不是时间驱动的。 没有事件,仿真就不会往前推进。
  2. 2. Python 的 await 只阻塞当前协程,不会阻塞其他协程。 这就导致 send 和 RisingEdge 能够“并发执行”,产生一些看似反直觉的现象。
  3. 3. cocotb 是事件驱动与 Python 协程调度的结合。 事件决定仿真何时推进,协程决定代码块如何交替执行。

理解了这三点,你就能把那些看不懂的现象串联起来。以后再遇到类似问题时,就不会简单地以为是 cocotb 出了 bug,而是可以冷静地去思考:当前阻塞的是哪个协程?**事件队列里有哪些事件在等待?**仿真是否有活跃事件去驱动继续运行?

当你能这样拆开来看,很多玄学问题就不再神秘,而是变成了理所当然。

所以说,写 cocotb 不只是写 Python,更是一次在 软件思维 和 硬件思维 之间来回切换的训练。如果你能彻底掌握协程 + 事件驱动仿真的逻辑,不仅 cocotb 会更顺手,你在做 软硬件协同验证 时的思维也会更加清晰。

评论区

登录后即可参与讨论

立即登录