4. 仿真调度与事件机制

Verilog 仿真不是“按行执行”的软件程序,而是一个由仿真调度器驱动的时间推进系统。理解 VCS 的调度器与事件队列,是判断一段 RTL 或 TB 代码“为什么这样跑”的根本依据。本节围绕 IEEE 1364/1800 定义的分层事件队列展开,说明 VCS 如何在时间槽内组织事件,以及阻塞赋值、非阻塞赋值、时序控制与竞争冒险之间的因果关系。

一、仿真调度器与分层事件队列

仿真时间由若干“时间槽(time slot)”组成,每个时间槽内又划分为若干有序区域。VCS 的调度器在每个时间槽中按固定顺序处理事件,顺序不可颠倒,这是所有仿真行为的基准。IEEE 标准定义的主要区域依次为:

  • Active(活动):执行阻塞赋值、连续赋值、$display、原语求值等。这是绝大多数过程语句运行的区域。
  • Inactive(非活动):处理带 #0 延迟的事件,位于 Active 之后。
  • NBA(非阻塞赋值更新):执行非阻塞赋值左值的更新,位于 Inactive 之后。
  • Observed(观察):供 SystemVerilog 断言与采样使用。
  • Reactive / Re-Inactive / Re-NBA:处理 program 块中的事件。
  • Postponed(推迟):$monitor、$strobe 等在此执行,读取的是本时间槽稳定后的最终值。

关键点在于:同一时间槽内,Active 区的事件可能触发新的 Active 事件,形成“迭代—收敛”过程。只有当 Active、Inactive、NBA 全部清空后,调度器才推进到下一个时间槽。若 Active 区事件不断自我触发,仿真会陷入“零延迟死循环”,表现为时间不前进但 CPU 持续占用。

VCS 单个时间槽内的分层事件队列 时间 T 时间 T+1 Active 活动区 阻塞赋值 =、连续赋值 assign、$display、门原语、#0 之前的过程语句 可迭代 Inactive 非活动区 带 #0 延迟的事件,位于 Active 之后执行 NBA 非阻塞赋值更新区 非阻塞赋值 <= 的左值更新,读取的是 Active 区采样到的旧值 Observed 观察区 SystemVerilog 断言、采样,读取本时间槽稳定值 Reactive / Re-NBA 区 program 块中的事件处理 Postponed 推迟区 $monitor、$strobe,读取本时间槽最终稳定值 时间推进

二、阻塞赋值与非阻塞赋值的调度差异

阻塞赋值(=)在 Active 区立即完成“求值—更新”,语句执行完左值即改变;非阻塞赋值(<=)在 Active 区只完成“求值—排队”,真正的左值更新被推迟到 NBA 区。这一差异直接决定了同一时间槽内多个赋值语句的可见性。

考虑一个典型的两级触发器链:

always @(posedge clk) begin
    q1 <= d;      // NBA:Active 区采样 d,NBA 区更新 q1
    q2 <= q1;     // NBA:Active 区采样 q1 的旧值,NBA 区更新 q2
end

在同一个时钟沿,q1 与 q2 的右值都在 Active 区被采样,此时 q1 尚未更新,因此 q2 得到的是 q1 的旧值,形成正确的两级移位。若把这两句改成阻塞赋值,q1 在 Active 区立即更新,q2 就会读到新值,两级触发器被“压扁”成一级,这是 RTL 设计中最常见的语义错误之一。

经验规则:时序逻辑(always @(posedge clk))内部统一使用非阻塞赋值 <=;组合逻辑(always @(*))内部统一使用阻塞赋值 =。混用是竞争冒险的主要来源。

三、时序控制与竞争冒险

时序控制分为三类:延迟控制(#N)、事件控制(@(posedge clk) 或 @(event))、电平敏感等待(wait(cond))。它们决定过程语句何时被调度到 Active 区。竞争冒险的本质,是两个过程块对同一信号的读写顺序依赖于调度器在同一时间槽内的执行次序,而该次序在标准中并未完全确定。

典型竞争场景:一个 always 块用阻塞赋值驱动信号 a,另一个 always 块在同一时钟沿读取 a。由于两个块的执行顺序不确定,读取到的可能是旧值也可能是新值,仿真结果在不同工具、不同优化级别下可能不一致。VCS 提供 +race 编译选项,可在编译期检测潜在的读写竞争并给出报告。

竞争类型典型写法后果修正方法
同沿读写竞争块 A 用 = 写 a,块 B 同沿读 a读到旧值或新值不确定写侧改用 <=,或读侧加一级寄存
零延迟循环always @(a) a = ~a;时间不推进,仿真挂死引入 #1 或改用时钟沿触发
#0 滥用多处 #0 赋值Inactive 区顺序依赖用 NBA 替代 #0
TB 与 DUT 同沿驱动TB 在 posedge 直接改激励DUT 采样值不确定TB 用 negedge 或 #1 后驱动

四、竞争冒险的调试方法

调试竞争冒险不能靠“猜”,要依靠可观测的证据链。推荐按以下顺序推进:

  1. 编译期检测:使用 vcs -race 或 +race 选项,VCS 会在编译阶段报告潜在的读写竞争位置,先消除报告中的高危项。
  2. 波形定位:在 DVE 或 Verdi 中打开波形,把时钟沿放大到最小时间精度,观察同一时刻多个信号的跳变顺序。若某信号在时钟沿处出现“毛刺式”跳变,往往就是竞争点。
  3. 插入探针:在可疑过程块内加入 $display 或 $strobe。$display 在 Active 区打印,$strobe 在 Postponed 区打印,两者输出对比即可判断信号是在 Active 区还是 NBA 区被更新。
  4. 隔离复现:把可疑的两个 always 块抽到最小测试平台中,逐步替换阻塞/非阻塞赋值,观察行为是否改变,从而确认竞争根因。
  5. 回归验证:修正后在不同随机种子、不同优化级别(-O0 与 -O2)下重跑,确认结果稳定一致。
不要用“加 #1 延迟”来掩盖竞争。延迟只是把事件推到下一个时间槽,改变了时序语义,可能让原本正确的设计在综合后失效。正确做法是从赋值方式和时序控制上消除竞争。

五、一个可复现的调试示例

下面是一段存在竞争的测试平台片段,用于演示 $display 与 $strobe 的差异:

module race_demo;
    reg clk = 0;
    reg a, b;
    always #5 clk = ~clk;

    always @(posedge clk) a = 1'b1;   // 阻塞赋值
    always @(posedge clk) b = a;      // 同沿读取 a

    initial begin
        #12;
        $display("T=%0t Active  a=%b b=%b", $time, a, b);
        $strobe ("T=%0t Postponed a=%b b=%b", $time, a, b);
        #10 $finish;
    end
endmodule

在 VCS 中运行后,$display 与 $strobe 的输出可能不同:$display 反映的是 Active 区执行到该语句时的瞬时值,而 $strobe 反映的是本时间槽结束后的稳定值。若两者对 b 的显示不一致,说明 b 的更新跨越了区域边界,竞争确实存在。把 a = 1'b1; 改为 a <= 1'b1; 后,a 的更新被推迟到 NBA 区,b 在 Active 区采样到的是 a 的旧值,行为变得确定,$display 与 $strobe 的输出也趋于一致。

掌握调度器与事件队列,本质上是在建立“时间—区域—赋值方式”三维的思维模型。当你能预判一段代码在哪个区域执行、读到的是哪个版本的值,竞争冒险就不再是玄学,而是可以被定位、被修正、被回归验证的工程问题。