用Python给Verilog设计自仿进阶:仿真器是如何工作的?一文看懂时序模型

来源:AdriftCoreFPGA芯研社 电路设计 19 次阅读
摘要: 很多FPGA/IC工程师擅长设计,但在仿真方面较为薄弱。我认为主要问题在于,完整的仿真实现学习成本较高,如学习UVM需要掌握大量新的内容。而单纯使用Verilog自仿又难以满足需求,以报文仿真为例,我们需要解析报文,若仅依赖Verilog自仿,就相当于要自己编写一个报文解析模块,工作量非常庞大。而Python在数据处理方面则更加高效,如果加以利用,完全可以快速构建一个完整的仿真模型。Cocotb

很多FPGA/IC工程师擅长设计,但在仿真方面较为薄弱。我认为主要问题在于,完整的仿真实现学习成本较高,如学习UVM需要掌握大量新的内容。而单纯使用Verilog自仿又难以满足需求,以报文仿真为例,我们需要解析报文,若仅依赖Verilog自仿,就相当于要自己编写一个报文解析模块,工作量非常庞大。而Python在数据处理方面则更加高效,如果加以利用,完全可以快速构建一个完整的仿真模型。Cocotb的诞生,为硬件工程师提供了一条更轻量、更灵活的路径——用Python脚本直接驱动Verilog/VHDL仿真。

前言

无论是 UVM 仿真、Cocotb 仿真,还是其他仿真环境,都需要一个时序模型来与实际电路中的组合逻辑和时序逻辑保持同步。

在 Cocotb 的时序模型 中,它对 VPI、VHPI 和 FLI 的时序模型进行了简化,只保留了其中最核心的公共子集。不过,由于 Python 自身的特性,在 Cocotb 中使用时序模型时需要格外注意。

这里我们先抛出一个问题:

在 Verilog 中的**@(posedge clk)与 Cocotb 中的await RisingEdge(clk)**,它们是等效的吗?

很多人可能会下意识地回答:“这不就是一样的吗?” 但事实上,两者有着本质的区别。接下来,我们就带着这个问题,一起深入理解 Cocotb 的时序模型。

仿真器是如何工作的

时间步

在实际硬件中,电路的运行是由时间驱动的,因此很多人会下意识地认为仿真也是“按时间驱动”的:比如在若干纳秒时做某件事,然后再在若干纳秒后做另一件事。

然而,仿真器的工作方式并不是这样。所有的仿真器本质上都是事件驱动的。这是因为对于软件来说,事件驱动才是最高效的方式。仿真器并不会逐纳秒去计算 RTL 如何运行——那样 CPU 的负担会非常大。相反,只有当某个事件被触发时,仿真器才会计算信号的变化;没有事件触发的时间段则会被直接跳过。

理解这一点之后,我们再来看仿真的基础单位:时间步(time step) 。一个时间步对应仿真时间轴上的一个点,在这个点上会发生某些事件,比如时钟的上升沿、下降沿,或者延迟赋值(delayed assignment)的生效。所有的仿真推进都是由事件驱动的,只有在跨越时间步时,仿真时间才会向前推进。没有事件就没有时间步

仿真器的最小单位

当我们理解了仿真器的时间步后,可能会下意识地认为:时间步就是仿真的最小单位。其实并不是这样——时间步还可以被拆分成更细的部分。只有理解这一层机制,我们才能真正洞悉仿真器的工作原理。

和其他时序模型类似,每个时间步由多个 Δ循环(delta cycle) 组成。Δ循环的作用是:当一个信号的值发生变化时,相关的“依赖信号”会立即更新,而这些更新又可能触发进一步的变化。整个链式更新的过程就是一系列的 Δ循环。

需要注意的是,Δ循环本身并不会消耗仿真时间——它们在同一个时间点内瞬时完成。换句话说,Δ循环模拟的正是电路中组合逻辑的传播过程。例如组合逻辑 a → b → c,当 a 发生变化时,b 随之更新,进而触发 c 更新,这一连串传播就对应着仿真器中的 Δ循环。

时序模型

在 Cocotb 的时序模型 中,一个时间步大致包含如下 4 种状态。这些状态在一次仿真中可能会被重复执行上百万次。它们是对 VPI、VHPI 和 FLI 时序模型的简化,只保留了最核心的公共子集。

在一个时间步(time step)中:

  • • Beginning of Time Step 和 End of Time Step 只会各进入一次;
  • • Value Change 与 Values Settle 则可能交替循环多次。

因此,真正体现 delta cycles 的部分主要发生在 Value Change 与 Values Settle 的不断交替中。接下来,我将使用 delta cycles 来特指这两个状态的交替过程。

Beginning of Time Step ---> Value Change ---> Values Settle ---> End of Time Step                            ^                            /                             \__________________________/

Beginning of Time Step(时间步开始阶段)

这是时间步的起点,此时还没有任何值发生变化。在这个状态下,你可以读取前一个时间步结束时的稳定值。如果你在这个状态写入信号,这些写入不会立刻生效,而是被缓存,直到第一个 Δ循环(delta cycle)完成后才会真正写入。

Value Change(值变化阶段)

这是一次Δ循环(delta cycle) 的起始  一个时间步里可能有 0 个或多个这种状态,它们彼此无法区分。你不能直接跳到时间步内的某个特定值变化点。在这个状态下,你可以读写信号。写入依然是缓存的,不会立即生效。类似组合逻辑传播时的中间态,某个信号变了,你看到了变化,但写操作仍延迟应用。

Values Settle(值稳定阶段)

这是一次Δ循环(delta cycle) 的结束。进入这个阶段时,cocotb 会先把之前缓存的写入全部刷新(即应用到信号),然后才进入用户的 Python 代码。这些写入可能导致新的信号变化,于是又触发新的Δ循环(delta cycle) 。一个时间步里可能有 0 个或多个这种状态,每个 Value Change 都会对应一个 Values Settle。它们之间同样不可区分。

在这个状态下,你依然可以读写信号(但是写入官方未定义行为)。相当于组合逻辑传播 settle 的过程。你写的值会真正落地,并可能引发进一步的连锁反应。

End of Time Step(时间步结束阶段)

它基本等同于时间步中最后一个 Values Settle,但有几点区别:

  • • 此阶段不能再写信号 —— 因为写入会触发新的 delta cycle,而在这里已禁止产生新的 delta。
  • • 你仍然可以安全地读取信号

因此,这一阶段可以看作是最终的只读快照点:通常用于检查结果而不再修改电路,就像组合逻辑迭代完成后,输出值已经稳定下来一样。

触发器

cocotb 提供了 基于 Python 的时序触发器(timing triggers) ,用户可以利用这些触发器来精确控制仿真流程。当我们 await 某个触发器时,当前的 cocotb 协程会挂起,直到触发条件满足才恢复执行。换句话说,触发器充当了 cocotb 与 HDL 仿真器之间的桥梁:它告诉仿真器“当某个事件发生时,请唤醒这个 Python 协程继续运行”。

例如,在 Verilog 中 @(posedge clk) 就是一个 注册在仿真内核事件队列里的监听器;而在 cocotb 中,await RisingEdge(clk) 等价于通过 VPI/FLI 向仿真器声明:“当 clk 出现上升沿这个事件发生时,请回调(告诉)我”。

Timer

Timer 触发器允许用户在仿真时间中向前跳转(任意时长)。它总是会让用户停在 时间步的开始(Beginning of Time Step) 。

  • • 不能倒退时间,所以负数和 0 时间值是非法的。Timer(0) 会给出警告但不报错(因为历史上很多测试用过),但实际上只有在 时间步开始状态 才可能有意义(相当于无操作),其他情况下是 未定义行为(甚至可能导致仿真器崩溃!)。Timer不能 用来在 delta cycle 之间跳转,只能在时间步之间移动。
  • • 用来延时,比如 await Timer(10, units="ns")。适合做时钟周期延时,而不是微小组合逻辑传播的等待。
  • • 如果跳转到的时间点上没有任何事件(即该时间步不存在有效活动),仿真就会卡死,停在那里一直等待事件发生。也正因如此,在 cocotb 仿真的结尾处不能只写一个**Timer** 来结束,否则会让仿真悬空在无事件的时间步上

NextTimeStep

NextTimeStep 类似于 Timer,但它一定会跳到 下一个时间步的开始

  • • 下一个时间步可能在未来某个时间点,也可能永远不会发生。
  • • 只有当能确定会有新的时间步时才安全使用,否则就是未定义行为。

比 Timer 更“语义化”,不用写具体延时。比如等到下一个时钟沿或赋值事件产生的新时间步。

Edge / RisingEdge / FallingEdge

这些触发器会阻塞协程,直到某个信号在未来发生变化。触发器会让用户停在 一个 delta cycle 的开始(即值刚变化,还没传播到相关信号,Value Change)。

  • • 未来的这个点可能在 同一个时间步的不同 delta cycle,也可能在 不同时间步,甚至可能永远不会发生(如果信号永远不变)。
  • • 如果等的是一个永远不变的信号,就是未定义行为。

用来等边沿。常用来等时钟上升沿,但要注意此时组合逻辑的输出可能还没 settle。

ReadWrite

ReadWrite 允许用户同步到 当前 delta cycle 的结束(Values Settle) 。在这里,所有值变化都会被解析完成(自上次值变化以来)。

  • • 用途:反应组合逻辑对寄存器输出的响应。
  • • 示例 1(推荐,抗 glitch):
while True:    await RisingEdge(dut.clk)    await ReadWrite()    dut.valid.value = 0    if dut.ready.value == 1:        dut.valid.value = 1
  • • 示例 2(容易出 glitch):
while True:    await RisingEdge(dut.ready)    dut.valid.value = 1    await FallingEdge(dut.ready)    dut.valid.value = 0

注意: 如果你在 ReadWrite 之后又等 ReadWrite,就是未定义行为,将来可能报错。

这一步相当于“等组合逻辑 settle 完再读”。非常重要,可以避免因为瞬时毛刺 (glitch) 导致误判。

ReadOnly

翻译:ReadOnly 让用户跳到 时间步的结束(End of Time Step) ,可以看到最终值(在消耗仿真时间前的最后状态)。

  • • 用途:采样可能 glitch 的信号。
  • • 注意:在 ReadOnly 之后再等 ReadWrite 或 ReadOnly 是未定义行为,将来可能报错。
  • • 在这个阶段,你不能写入值。

这是 只读快照点,保证你看到的是本时间步的最终值,不会再变化。

State Transitions (状态转移图)

你可以用不同触发器在 时间步 / delta cycle / 最终点 之间导航。比如我们在value change阶段可以通过await ReadOnly跳转到End of Time Step。

状态 Timer NextTimeStep Edge、RisingEdge、FallingEdge ReadWrite ReadOnly
Beginning of Time Step
Value Change
Values Settle
End of Time Step

符号:

  • • N = 时间步
  • • M = delta cycle
N := time stepM := delta cycleBEGIN{N} ->    BEGIN{>N} : Timer/NextTimeStep    CHANGE{>=N}{M} : Edge/RisingEdge/FallingEdge    SETTLE{N}{0} : ReadWrite    END{N} : ReadOnlyCHANGE{N}{M} ->    BEGIN{>N} : Timer/NextTimeStep    CHANGE{N}{>M} : Edge/RisingEdge/FallingEdge    CHANGE{>N}{M} : Edge/RisingEdge/FallingEdge    SETTLE{N}{M} : ReadWrite    END{N} : ReadOnlySETTLE{N}{M} ->     BEGIN{>N} : Timer/NextTimeStep    CHANGE{N}{>M} : Edge/RisingEdge/FallingEdge    CHANGE{>N}{M} : Edge/RisingEdge/FallingEdge    END{N} : ReadOnlyEND{N} ->    BEGIN{>N} : Timer/NextTimeStep

setimmediatevalue

普通写法 dut.sig.value = 1 是 缓存写,直到进入 Value Change 状态才生效。如果立即读,会读到旧值。setimmediatevalue 会立即写入(下一次读就能读到新值)。

  • • 风险:可能导致竞争条件 (race condition),所以不建议常用。
  • • 用途:主要在 测试初始化阶段 用来给没有初值的信号赋值,避免 X/U/未定义。

相当于“强制立即写”,但会打破正常的时序语义,所以只推荐初始化时用。

总结

触发器 到达位置 典型用途 注意事项
Timer 下一个时间步开始 延时仿真时间 Timer(0) = no-op;不能跳 delta
NextTimeStep 下一个时间步开始 跳转到下一个时间步 必须保证有事件触发新时间步
Edge / RisingEdge / FallingEdge 值变化点(delta 开始) 等待时钟沿或信号变化 信号输出可能未 settle
ReadWrite delta cycle 结束 读取组合逻辑稳定值 连续用会报错
ReadOnly 时间步结束 获取最终值,避免 glitch 后面不能再等 RW/RO
setimmediatevalue 立即写入 初始化信号 可能引起竞争,不推荐常用

写在最后

揭晓答案

相信各位已经知道了答案——在 Verilog 中的**@(posedge clk)与 Cocotb 中的await RisingEdge(clk)**,它们并不等效 ,真正与verilog等效的是 @(posedge clk) :

await RisingEdge(dut.clk)await ReadOnly()

cocotb的设计哲学

可能很多人会有这样的疑问:在 Verilog 里写一行 @(posedge clk) 就够了,而在 Cocotb 中却需要写两行才能实现相同的效果。为什么 Cocotb 不能像 Verilog 那样,一个 RisingEdge 就直接得到结果呢?

在 Verilog 语言标准中,定义了 仿真时间轮和 event regions(Active、Inactive、NBA、Observed、Postponed 等)。当你写下 @(posedge clk) 时,仿真器会保证:触发点在 clk 上升沿发生,而在你真正读取信号之前,所有组合逻辑的传播与稳定(settle)已经完成。因此,一个 @(posedge clk) 就能拿到干净、稳定的结果。这是因为语言标准明确规定了 event region 的执行顺序,所有 Verilog 仿真器必须遵循。

而 Cocotb 本质上是通过 VPI/FLI/DPI 插件把 Python 层挂接到 HDL 仿真器上,它自己并不是仿真器。await RisingEdge(clk) 只是告诉仿真器:“当 clk 有上升沿事件时通知我”。此时仿真器会在 信号刚翻转时回调给 Cocotb,但 HDL 内部的组合逻辑和其他 driver 可能尚未完全 settle。

理论上,Cocotb 完全可以在 RisingEdge 之后自动补一个 ReadOnly,但这样会带来两个问题:

  • • 灵活性受限:有些驱动逻辑希望在信号翻转的瞬间立刻驱动,而不是等到 settle 完成(比如某些握手协议中,driver 需要在时钟边沿立即响应)。
  • • 兼容性问题:不同仿真器的回调时机制可能略有差异,Cocotb 必须保持对执行阶段的精细控制。

因此,Cocotb 的设计哲学是:

  • • 提供最基本的触发器(RisingEdgeFallingEdgeTimer…)
  • • 提供明确的阶段控制(ReadOnlyNextTimeStep 等)

Verilog 的 @(posedge clk) 是语言标准帮你封装好的,而 Cocotb 则把控制权交还给用户,让你自己决定何时采样、何时驱动。

相关推荐
评论区

登录后即可参与讨论

立即登录