1. 先搞清楚 program 这个块到底想解决什么问题
第一次在别人的验证环境里翻到program ... endprogram的时候,我盯着屏幕愣了几秒——明明是个 testbench,为什么不老老实实写在 module 里,非得另起一个关键字?后来踩过几次采样竞争导致的“偶发失败、重跑就过”的坑,才真正理解 SystemVerilog 里 program 这个结构的定位:它被引入的原因,就是防止 testbench 和设计在同一个时钟沿上抢同一个信号。很多刚接触验证的朋友搜 “sv program module”,往往只看到一句“用于建模 testbench 的语法结构”就过去了,实际上这句话背后包含一整套关于仿真调度的事件区域语义。
简单说,program 是 SystemVerilog(IEEE 1800)在 Verilog module 之外新增的一类设计单元,专门用来描述激励和检查逻辑。它能做的事包括:产生驱动信号、采样设计输出、写断言检查、组织 case 流程,但它的执行时机被刻意挪到了与设计不同的仿真调度区。它解决的问题非常具体——在纯 module 写法下,你写@(posedge clk)之后去读 DUT 的输出,到底读到的是更新前的旧值还是更新后的新值,取决于代码书写顺序和工具实现,这就是经典的竞争冒险。program 配合 clocking block 把读取和驱动的时机语义固定下来,让这类问题从“靠运气”变成“靠规范保证”。
这篇文章适合谁看?如果你正在维护一份带 program 块的老环境、或者正准备在课堂作业里使用 program 写 testbench、又或者你在排查 UVM 环境中“为什么波形上采样值总差一拍”的问题,都能从下面这份拆解里拿到可用的东西。我会先把语义讲清楚,再给出一个能直接跑起来的最小例子,最后重点讲现在为什么越来越少见到它、以及真要用的话要注意哪些坑。这些坑我基本都踩过一遍,写出来能帮你省掉几个通宵。
1.1 从 module 里写 testbench 的老毛病说起
用纯 module 写激励,最典型的写法是这样:一个initial块里@(posedge clk),然后给输入信号赋值;另一个地方@(posedge clk)之后读输出做比对。看起来挺顺,但仿真器调度的时候,同一个时间点上的事件是有先后区域的:先是 active 区执行阻塞赋值、计算 RHS,然后 inactive 区处理#0,接着 NBA 区更新非阻塞赋值的结果,再往后才是 observed 区、reactive 区。DUT 里的触发器大多用非阻塞赋值,它在 NBA 区更新;而你 testbench 里那个@(posedge clk)唤醒后执行的语句,可能落在 active 区,于是你读到的是 DUT 更新之前的旧值。反过来,如果你的激励也用阻塞赋值在 active 区直接改,DUT 在同一时刻采样到这个新值还是旧值,同样说不清。
这种不确定性带来的后果非常麻烦:同一份代码在换一个仿真器、换一个优化等级、甚至只是改了个编译顺序之后,行为就可能变化。更难受的是它往往是偶发的,跑一万次错一次,你根本定位不到。当年为了绕开它,大家发明了各种土办法——在采样前加#1、用#0错开、用$strobe打印、用@(negedge clk)反向采样、甚至把所有赋值改成非阻塞再加一层延时。这些手段能用,但每一条都是经验主义的补丁,换个人接手就看不懂为什么这里有个#1。
program 的设计初衷就是把这类补丁收进语言规范里。它规定:program 里的过程代码在 reactive 区执行,而信号采样发生在 preponed 区(也就是所有设计更新之前的那一刻)。这样“采样读旧值、驱动在更新之后”就成了一条硬性保证,不再依赖代码书写顺序。你不需要再猜,也不需要写那个诡异的#1。
1.2 program 的三条核心语义
要真正用好它,必须记住三条语义,这三条决定了它和 module 的所有差异。
第一,执行区域不同。program 内部的initial、task、function里的过程语句,统一在 reactive 区执行。这个区域排在 NBA 区之后,也就是说 DUT 的所有非阻塞赋值都已经落地了,program 此时修改激励信号不会和设计里的触发器抢同一个时刻。而 clocking block 的采样动作则被安排到 preponed 区,取的是时钟沿到来之前那一刻的稳定值。
第二,变量默认静态、生命周期贯穿全程。program 里声明的变量,如果不加automatic,默认是静态存储,整个仿真期间只分配一次。这一点和 module 里的变量行为一致,但和 class 里的属性容易混淆。如果写program automatic,那么 program 内部的变量以及其子程序里的变量默认都变成 automatic,每次调用重新分配。这个关键字漏写或误写,是多线程场景下变量互相踩踏的常见根因。
第三,结构受限。program 内部不能实例化 module、interface 或另一个 program,也不能写always系列的过程块和原语实例,连续赋值语句同样不在允许范围内。它只能包含initial、final、task、function、类定义与对象、以及clocking声明。这个限制是有意为之:它是“行为级测试代码的容器”,不是“可综合的硬件描述”。所以你不能指望在 program 里搭一个小型行为模型去模拟缺失的 DUT 部分,那件事必须放回 module 或者 interface 里做。
注意:program 通常只能在顶层层次被实例化,不能像普通 module 那样随意往设计的层级里嵌。具体限制各仿真器实现略有差异,动手前先翻一下手上的工具手册,别照着博客直接抄。
1.3 program 与 module 的关键差异对照
把两者的差异列成一张表,比读十页标准文档都直观。
| 对比维度 | module | program |
|---|---|---|
| 主要用途 | 描述硬件结构与时序逻辑 | 描述激励、采样与检查 |
| 过程块执行区域 | active / NBA 区 | reactive 区 |
| 采样时机 | 无强制语义,取决于写法 | preponed 区(配合 clocking 时) |
| 能否实例化 module | 能 | 不能 |
| 能否写 always 块 | 能 | 不能 |
| 变量默认存储 | static | static(加automatic后为 automatic) |
| 连续赋值 assign | 支持 | 不支持 |
| 能否综合 | 能 | 不能 |
| 层次位置 | 任意 | 一般限定在顶层 |
这张表里最容易忽略的是最后一行。很多人第一次写 program,习惯性地把它当成 module 的替代品塞进某个子层次里,编译直接报错才开始查文档。另外“不能写 always 块”这一条也很致命——这意味着你在 program 里想做一个周期性行为,只能靠initial里套forever循环,写法和 module 里的always @(posedge clk)完全不同,风格上需要切换。
2. 时序语义与采样机制拆解
理解了“它为什么存在”,接下来要把语义精确拆开。program 之所以能避免竞争,靠的并不是它自己,而是它和 clocking block 的配合。脱离了 clocking block 单独谈 program,等于只拿到一半的答案。这一节把采样区和驱动偏移这两件事讲清楚,后面写代码时你才知道每个参数在干什么。
2.1 reactive 区与 preponed 区到底差在哪
一次时钟沿到来时,仿真器的调度大致可以描述成这样一条链路:preponed 区(采样点)→ active 区(阻塞赋值、连续赋值求值)→ inactive 区(#0延时)→ NBA 区(非阻塞赋值更新)→ observed 区(断言求值)→ reactive 区(program 过程代码)→ postponed 区($strobe、$monitor)。这个顺序是标准规定的整体框架,细节各工具会有微调,但先后关系是不变的。
关键在于两个位置。preponed 区在所有更新发生之前,所以在这里抓到的信号值一定是“时钟沿之前稳定下来的那个值”,这正是你比对时期望看到的值。reactive 区在所有更新完成之后,所以在这里改激励,绝对不会影响本拍 DUT 的输入采样结果——你已经晚了一整拍,改的是下一拍要用的值。
举个具体场景:DUT 里有个累加器,acc <= acc + din用非阻塞赋值。你在 testbench 里想验证“当valid拉高的这一拍,acc应该增加对应的din”。如果直接在 module 里@(posedge clk); if (acc === expected),你读到的是更新前的acc,检查必然失败(或者反过来,某些工具上恰好成功,换环境就挂)。而通过 clocking block 采样,读到的就是本拍更新后的结果,逻辑上正好对上。这个“差一拍”是新手最常问的问题,根源就在这里。
2.2 clocking block 的两个偏移参数
clocking block 里最常写的是default input #1step output #2;这样的形式,两个数字含义完全不同,必须分开理解。
input skew常用#1step,表示在时钟沿之前的最后一个时间精度单位采样。1step是 SystemVerilog 的特殊时间单位,等于当前的时间精度。它的意义是“尽可能贴近时钟沿,但绝不跨过去”。这样采样取到的就是沿之前那一刻的稳定值,既能捕捉到沿前的最后一次变化,也不会误采到沿后的更新。有些代码写input #5,意思是提前 5 个时间单位采样,这在需要满足建立时间的场景下有用,但一般验证环境里用#1step就够了。
output skew的取值需要算一下。如果你的时钟周期是 10ns,DUT 输入端有组合逻辑路径,那么驱动信号最好不要恰好压在时钟沿上。写output #2表示激励在时钟沿之后 2 个时间单位才真正更新到信号上,这个延迟给 DUT 内部留下了建立时间,也避免了和沿上更新的事件挤在同一个区域。具体取多少,取决于你的时钟周期和 DUT 的输入路径特性——周期 10ns 取 1~2ns 比较常见,周期 100ns 时取 5ns 也无所谓。原则是:偏移量要显著小于时钟周期,又足够避开沿附近的事件密集区。
提示:
output #0表示不额外延迟,在 reactive 区直接驱动。在很多简单场景下也能工作,但如果 DUT 输入端有组合通路,建议至少留一个时间单位的余量,实测下来更稳。
2.3 端口方向与顶层连线的关系
program 的端口方向和 module 一样,都是相对于自身而言的:input是数据流进 program,output是数据流出 program。但因为 program 不能实例化模块,它的端口必须由顶层 module 连接到 DUT 的对应信号上。
这里有个容易绕晕的地方:如果 program 的output端口连到 DUT 的input,从 DUT 的角度看这是激励输入,从 program 的角度看这是它输出的驱动值,两者是一致的,不会冲突。真正需要小心的是位宽和符号匹配——program 端口声明成output logic [7:0] din,DUT 那边是input logic [7:0] din,位宽一致才能直连,否则会隐式截断或扩展,产生一堆难查的警告。
还有一种更省事的写法是用ref端口直接引用顶层信号,或者干脆在 program 里通过层次化路径引用tb_top.u_dut.xxx。这两种做法编译上能过,但耦合度极高,改个层次名就全崩。我个人的习惯是老老实实走端口连线,虽然多写几行,但结构清晰,换人接手也不至于找不到信号从哪来。
3. 从零写一个能跑的最小例子
理论讲完,直接上代码。下面这个例子我特意做得足够小,但把该有的要素都放进去了:DUT、顶层、program、clocking block、驱动、采样、比对。你可以直接拿去改。选的是一个带valid握手的累加器,逻辑简单到不会分散注意力,但采样时机的特征足够明显。
3.1 被测设计:一个带握手的累加器
module dut_accum ( input logic clk, input logic rst_n, input logic valid, input logic [7:0] din, output logic [15:0] acc, output logic done ); always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) begin acc <= '0; done <= 1'b0; end else begin done <= 1'b0; if (valid) begin acc <= acc + din; done <= 1'b1; end end end endmodule这个 DUT 的行为很简单:复位时清零,每个valid有效的时钟沿把din累加到acc上,同时done拉高一拍表示完成。注意acc和done都用了非阻塞赋值,它们会在 NBA 区更新。这就是采样竞争产生的条件——如果 testbench 在 active 区读acc,读到的一定是旧值。
3.2 program 侧的驱动与采样代码
program automatic tb_accum ( input logic clk, input logic rst_n, input logic [15:0] acc, input logic done, output logic valid, output logic [7:0] din ); default clocking cb @(posedge clk); default input #1step output #2; input acc, done; output valid, din; endclocking initial begin logic [15:0] expect_acc; valid = 1'b0; din = '0; expect_acc = 16'h0000; ##2; // 等两个时钟沿稳定 cb.valid <= 1'b1; for (int i = 1; i <= 4; i++) begin cb.din <= 8'(i * 10); ##1; expect_acc = expect_acc + 16'(i * 10); if (cb.acc !== expect_acc) begin $display("[FAIL] cycle %0d: acc=%0d expect=%0d", i, cb.acc, expect_acc); end else begin $display("[PASS] cycle %0d: acc=%0d", i, cb.acc); end end cb.valid <= 1'b0; ##1; $display("check finished"); end endprogram几个要点值得单独拎出来说。
##1是 clocking block 的周期延时语法,等价于“等一个由 default clocking 定义的时钟事件”。它比手写@(posedge clk)更安全,因为它绑定的是 clocking block 的事件,采样和驱动都在语义框架内。如果你直接用@(posedge clk),采样时机又回到了不确定状态。
cb.acc这种写法叫 clocking block 驱动的采样,读的是 preponed 区抓到的值。而cb.valid <= 1'b1是带 output skew 的驱动,实际生效时间被推迟了 2 个时间单位。注意这里用非阻塞赋值<=,这是 clocking block 驱动的标准写法,用阻塞赋值会出现编译告警或者完全不符合预期的行为。
automatic关键字不能省。如果写成program tb_accum,expect_acc会变成静态变量,虽然在这个单 initial 块的例子里看不出问题,但一旦你在里面fork出多个线程,静态变量就会互相踩踏,症状是“每个线程打印的值都不对,但单独跑又是对的”。
3.3 顶层连线与仿真结果解读
module tb_top; logic clk; logic rst_n; logic valid; logic [7:0] din; logic [15:0] acc; logic done; dut_accum u_dut ( .clk (clk), .rst_n (rst_n), .valid (valid), .din (din), .acc (acc), .done (done) ); tb_accum u_tb ( .clk (clk), .rst_n (rst_n), .valid (valid), .din (din), .acc (acc), .done (done) ); initial begin clk = 1'b0; rst_n = 1'b0; #12 rst_n = 1'b1; forever #5 clk = ~clk; end endmodule时钟周期 10ns,复位在 12ns 释放。仿真跑起来后,控制台的输出应该是四行 PASS 加一行结束提示。如果你把cb.acc换成了直接读端口acc,很可能看到每一拍都比预期少一个值——这正是采样时机不同造成的。
看波形时有个细节:在时钟沿的位置上,你会看到valid和din的变化比沿晚 2ns(output skew 的作用),而acc的更新紧贴时钟沿。这种“驱动滞后、值更新即时”的组合看起来有点错位,但正是它保证了你采样到的是本拍的正确结果。第一次看波形的人容易以为哪里出了 bug,其实就是 skew 在起作用。
3.4 参数化与多场景扩展
真实项目里不会只有一个固定位宽。把 program 做成参数化的写法如下:
program automatic tb_param #( parameter int DW = 8, parameter int ACW = 16, parameter int N = 8 ) ( input logic clk, input logic rst_n, input logic [ACW-1:0] acc, output logic valid, output logic [DW-1:0] din ); // 内容同上,把硬编码的 8、16 换成 DW、ACW endprogram参数化之后有个坑要注意:顶层实例化时参数的传递顺序。如果 program 用了#(...)且顶层没有显式传参,它拿到的是默认值,而你 DUT 那边可能已经按不同位宽例化了,位宽不匹配的告警会淹没在日志里。我的做法是在顶层把所有关键位宽定义成localparam,DUT 和 program 都引用同一个参数,改一处全改,不会漏。
另一种扩展方式是把激励组织成任务。program 里可以定义task,在initial里按场景调用。比如写一个task automatic send(input logic [7:0] d),内部做cb.din <= d; cb.valid <= 1'b1; ##1;。这样上层脚本化流程会干净很多,也方便复用。但要注意 task 默认是静态的,参数和局部变量如果需要在并发调用时互不干扰,必须加automatic。
4. 为什么现在越来越少见到 program
写完上面的例子,如果你去看现在的开源验证环境或者主流方法论推荐的结构,会发现 program 的身影越来越少。这不是因为它错了,而是因为它的核心价值被别的东西取代了,同时它自身的一些结构限制在实践中显得越来越别扭。这一节把这个趋势背后的具体原因拆开讲,顺便说清楚什么情况下还值得用它。
4.1 与基于类的方法学的结构性冲突
现代验证方法学以类为核心组织验证组件:激励产生器、驱动器、监视器、记分板,全都是类,通过虚接口句柄访问信号。这套结构的组织单位是class,编译单位是package,例化发生在 module 的initial里或者顶层程序里。
program 在这里的问题在于它是个语法单元,不是可复用的类。你没法把 program 继承、没法参数化它的行为、没法在运行时替换它的实现。一个验证环境如果需要“同一个激励序列跑三种不同的事务类型”,用类做多态轻松搞定,用 program 只能靠if-else分支堆出来。随着验证规模变大,这个差距会越来越明显。
更实际的冲突是层次访问。类里的驱动器通过虚接口访问信号,虚接口指向的是 interface 实例。而 program 的端口访问方式和 interface 完全是两套体系,一个环境里混用,信号路径就有两份来源,调试的时候容易看错。业界逐渐形成的共识是:既然 clocking block 可以定义在 interface 里,那 testbench 侧就没有必要再用 program 来获得采样语义了。
4.2 采样语义可以被 interface 完全替代
这一点是 program 被边缘化的技术核心。clocking block 并不绑定在 program 上,它同样可以声明在 interface 和 module 内部。也就是说,你完全可以这样组织:
一个 interface 里声明clocking cb @(posedge clk),把采样偏移和驱动偏移都定义好。然后在顶层 module 里initial中拿到这个 interface 的虚接口句柄,传给类里的驱动器和监视器。驱动器通过vif.cb.valid <= 1'b1驱动,监视器通过vif.cb.acc采样。采样在 preponed 区、驱动在 reactive 区,语义和 program 里一模一样。
这样就同时得到了两样东西:program 提供的时序保证,以及类体系提供的复用能力。代价只是多写一个 interface,收益却是整个环境结构统一。我第一次把老环境从 program 迁到 interface 加虚接口的时候,最大的感受是波形窗口干净了——所有信号都在一个清晰的层次下,不用在两个地方来回跳。
4.3 调试体验与工具实现上的现实差距
抛开方法论不谈,日常调试的体验也很关键。program 里声明的变量默认是静态的,很多仿真器在波形窗口里默认不展开静态变量,想看一个循环计数器还得手动加信号。program 内部的task调用栈在部分工具里支持得不如 class 方法完整,出问题时你只能靠$display一行行加打印。
另一个实际问题是有时候initial块的执行顺序不保证。program 的initial和顶层 module 里产生时钟的initial谁先执行,标准没有强制规定。如果你的 program 里第一句就是cb.valid <= 1'b1,而时钟还没开始跑,某些工具会直接卡住等沿,看起来像是“仿真不动了”。解决办法是在最前面加##1或者wait (rst_n === 1'b1)之类的同步点,但这属于经验性的补丁,文档里一般不写。
还有一条容易被忽略:program 里不能实例化 module,意味着你不能在里面搭一个简单的行为参考模型来做在线比对,也不能挂一个小的存储器模型。这些工作在 module 里轻而易举,在 program 里就是做不到。当验证场景需要“软件参考模型 + 硬件比对”时,program 会成为阻碍。
4.4 什么情况下它还值得用
说这些不是为了否定它。有几类场景我觉得 program 依然合适。
一是教学和入门。学习者第一次接触“采样竞争”这个概念,用 program 加 clocking block 的写法能把语义讲得非常干净,不需要先理解虚接口、类、工厂这一整套。一个 30 行的例子就能把 preponed 采样和 reactive 驱动演示出来,这是它的优势。
二是小的定向测试。一个模块只有几十个信号,验证目标是跑几组固定激励看看输出对不对,用 program 写比搭一整套类环境快得多。这种场景下,环境结构的灵活性不重要,写起来快、看得懂才是关键。
三是需要与已有 module 风格环境混用的过渡期。老环境全是 module 写的激励,只有部分新模块用 program,这种混搭虽然不优雅,但迁移成本最低。只要把时钟和复位统一在顶层产生,两边的采样各自用各自的 clocking block,可以共存。
三是我见过有些团队用 program 来隔离“绝不能影响 DUT 的检查代码”,把这个块当成一个物理边界,谁往里面写驱动导致仿真行为变化,代码评审一眼就能看出来。这种用法更多是团队约定层面的价值,技术上是可替代的。
5. 常见问题与排查技巧实录
下面这些是我在实际项目和帮别人看代码时反复遇到的。每一条都对应一个具体的现象,我把排查路径按顺序写出来,照着走基本能定位。
5.1 采样值总是差一拍怎么定位
这是最高频的问题。症状是:比对永远失败,但把期望值往前挪一拍就全过了。听到这个描述,基本可以判定是采样时机错了。
排查顺序:先确认你的读取是不是走了 clocking block。如果代码里写的是acc而不是cb.acc,直接改成 clocking 采样,八成问题就解决了。第二步看 clocking block 的input skew,如果是#0或者干脆没写,采样点会落在 active 区附近,语义上就变成了“可能读到旧值也可能读到新值”。改成#1step再看。第三步检查default clocking有没有正确声明——如果同时存在多个 clocking block 而你没指定 default,##1这类周期延时语法绑定的可能是你没预期的那一个。
还有一个隐蔽情况:DUT 内部用了阻塞赋值更新输出。这种情况下acc在 active 区就变了,你在 reactive 区读到的已经是新值,此时“差一拍”的问题反而不出现,但换个 DUT 实现方式就变了。所以看代码时顺手确认一下 DUT 的赋值风格,能帮你判断现象是否自洽。
5.2 program 里的 initial 不执行或者提前结束
现象是仿真跑起来了,波形有时钟在动,但 program 里的打印一行都没有。两种情况。
第一种是层次没连上。program 的端口在顶层实例化时如果漏连了某个信号,编译期可能只给个警告,运行起来那个端口是z或者x,你在里面wait一个条件永远等不到。检查方法是编译后看 elaborated 层次的报告,确认 program 实例确实存在、端口都连上了。
第二种是initial跑完了但你没意识到。program 里如果只写了##1; $display("done");就结束了,仿真会继续跑(因为时钟的initial里是forever),但 program 那个块已经执行完毕,不会再有输出。这时候加一个$finish或者在最后wait(0)挂着,能帮你确认到底是不是这个原因。
注意:program 的
initial结束并不意味着仿真结束。如果你依赖“program 跑完就停”,必须显式调用$finish,否则会一直空转或者被看门狗超时终止。
5.3 变量串值:多线程场景下的静态存储陷阱
症状是并发跑多个激励线程,每个线程打印出来的数据互相污染,看起来像是“变量被别的线程改了”。这是静态存储的典型表现。
在program(不加automatic)里声明的变量是静态的,fork出来的每个线程共享同一份。哪怕你在initial内部声明了循环变量,只要 program 本身不是 automatic,这个变量也可能是静态的。修复方式有两个:把 program 声明改成program automatic,或者在 task 和 function 上加automatic关键字。前者一次性解决所有内部变量,后者需要逐个加,但更精确。
顺带一提,fork...join_none之后如果忘了wait fork就退出initial,后台线程还在跑但父进程已经结束,波形上会看到信号在无人监管地乱动。这种问题排查起来很费时间,因为日志是干净的,只有波形上出现莫名其妙的变化。养成在initial末尾加wait fork的习惯,能省掉很多这类困惑。
5.4 常见问题速查表
把上面这些整理成表,方便你遇到问题时直接对号入座。
| 现象 | 最可能的原因 | 处理动作 |
|---|---|---|
| 比对结果永远差一拍 | 直接读端口而非 clocking 采样 | 改用cb.sig形式读取 |
| 采样值偶尔正确偶尔错 | input skew 为#0或未声明 | 改为#1step |
| 驱动信号在沿上跳变引发误判 | output skew 太小 | 按周期比例增加偏移量 |
| 并发线程数据互相污染 | program 未加automatic | 改为program automatic |
| program 内打印完全无输出 | 端口未连接或 initial 已结束 | 检查 elaborated 层次,末尾加wait fork |
| 编译报错“不允许实例化 module” | 在 program 内放了实例 | 移到顶层 module 中 |
| 编译报错“不支持 always 块” | program 内写了 always | 改成initial+forever |
| 仿真卡住不动 | initial 首句在等时钟沿 | 前面加##1或等复位释放 |
| 波形里看不到 program 内变量 | 静态变量默认不展开 | 手动添加信号到波形窗口 |
| 换工具后行为变化 | 依赖了未规定的调度顺序 | 全部走 clocking block,消除隐式依赖 |
这张表里最后一条最值得警惕。program 的语义本身是标准化的,但“你写的那份代码有没有真正用上这些语义”是另一回事。我见过不止一个环境,名义上用了 program,实际上里面全是直接读端口加#1延时,这种代码迁移到另一个仿真器上行为变化完全是意料之中的。
提示:如果接手一份带 program 的老环境,第一件事不是读逻辑,而是搜一遍里面有多少处
#1、#0和直接端口读。这些地方就是隐藏的不确定性来源,优先改成 clocking 采样,比逐行理解业务逻辑的收益大得多。
最后分享一个我在实际使用中的体会:program 这个结构本身没有问题,问题在于它处在一个尴尬的位置——它提供的那部分价值被 interface 里的 clocking block 完整覆盖了,而它缺失的那部分能力(类、继承、层次实例化)又是现代验证环境离不开的。所以我的建议是,新写的环境优先用 interface 加虚接口的方案,把时序语义放在 interface 里统一管理;遇到已有的 program 代码不必急着推翻,但也别在里面继续加新功能,让它维持现状、慢慢退出主线就好。真正需要判断的从来不是“用不用 program”,而是“这个环境里采样和驱动的时机语义,有没有被明确地、唯一地定义下来”。