1. 为什么要把 Stimulus 和 Checker 拆开
做 SoC 验证的人应该都有这种感觉:验证环境写到后期,最难受的不是功能点没覆盖到,而是环境本身改不动了。一个新特性加进来,牵一发而动全身,激励生成和结果检查纠缠在一起,修一个 bug 带出三个新问题。
我早期做某个图像处理模块的验证时,犯过一个特别典型的错误。当时为了图省事,把激励发送和结果比对写在了同一个 driver 里,激励里面携带的配置信息直接被 scoreboard 拿来做参考值,两边共享同一份数据结构。项目前期跑得很爽,到了覆盖率收敛阶段就开始痛苦了——每加一个异常场景,我得同时改 driver、sequence、scoreboard 三个地方,还经常因为参考值被激励逻辑“污染”而误报。后来整个环境重写,才彻底想明白一个道理:Stimulus 只负责“怎么把 DUT 置于某个状态”,Checker 只负责“DUT 在这个状态下是否表现正确”,两边中间只通过一条清晰的信息渠道通信。这个原则说起来一句话,真正落地到架构里,涉及的细节非常多。
我写这篇东西的契机,是某 SoC 项目的验证环境做了一次大重构,核心主题就是“Stimulus / Checker 职责分离”。重构之后环境松绑了很多,新场景的加入速度明显变快。这篇博文就把整套拆解思路、实现方案和踩过的坑完整整理出来,给同样在跟验证环境搏斗的兄弟们做个参考。
先说清楚这套思路适合什么人。如果你做的是 IP 级验证,一个 agent 包打天下还能撑得住,但一旦涉及到多个 IP 集成、软硬件协同、多个时钟域交互的场景,Stimulus 和 Checker 缠在一起的环境必然成为瓶颈。如果你正准备从零搭建一个新的 SoC 验证环境,那这套分离思路可以直接作为架构设计的起点。
这里有一个容易混淆的点要先理顺。职责分离不是简单地把 driver 里的代码拆成两个文件,或者把 scoreboard 单独拎出来类化,这些都只是形式。真正的分离发生在数据流层面:激励端产生的信息,和检查端消费的信息,必须是两套独立的、语义清晰的、接口稳定的数据契约。如果只是物理上拆了类、逻辑上还共享着一份全局配置,那等于换汤不换药。
另外一个常见的误解是,有些人认为职责分离会增加代码量。实际上,分离后的环境在初期确实会多一些封装和适配层的代码,但收益在后半程。验证项目的生命周期里,环境搭建只占三成工作量,剩下七成都在加场景、跑回归、定位失败。分离架构把后七成里最痛苦的“改一处动全身”问题解决掉了,这笔账怎么算都划算。
2. 职责分离的边界到底画在哪
2.1 先给 Stimulus 和 Checker 一个清晰的定义
模糊的讨论没有意义,先把概念钉死。我一般这样定义这两块:
Stimulus是环境中一切“主动改变 DUT 状态”的逻辑集合。它包含 sequence 的生成策略、driver 的时序驱动、配置寄存器的写入、中断的触发、以及各类总线上发起的传输事务。Stimulus 的核心目标是让 DUT 进入一个预期的状态,至于这个状态之后会不会出问题,它不关心。
Checker是环境中一切“被动观察并判断正确性”的逻辑集合。它包含 reference model、scoreboard、assertion、protocol checker、覆盖率收集中的某些复查逻辑。Checker 的核心目标是判断 DUT 在某个状态下的表现是否符合预期,至于这个状态是怎么来的,它不关心。
一句话总结协作关系:Stimulus 负责提问,Checker 负责批改。问题的内容和答案的标准必须分开产生,否则就是自己出题自己判卷,没有意义。
2.2 三种信息流与一组控制流
从数据流的角度来看,环境里跑着三类信息,职责分离的本质就是给这三类信息各开一条独立通道:
第一类是配置信息,描述“这次测试要做什么”。它从 test 层向下流向 sequence 层,再流向 driver,最后作用到 DUT 上。配置信息同时也应该有一个副本流向 checker 侧,让检查端知道“这次测试的预期是什么”。
第二类是激励事务(transaction),描述“具体往总线上发了什么”。它从 sequence 生成,经 driver 转成引脚时序,在总线上传输。激励事务本身不是 checker 要检查的对象,但它会间接决定 DUT 的输出。
第三类是观测信息,描述“DUT 实际做了什么”。它从 DUT 的接口上被 monitor 采集下来,流向 checker。观测信息是唯一允许直接源于 DUT 的信息源。
控制流是另一回事,主要包括复位、时钟开关、仿真结束机制和覆盖率统计开关。控制流不应该被当成数据在环境主体里传递,否则又会变成隐式耦合。
我在重构某个数模混合项目时,发现原有的环境完全没有这条通道概念。sequence 里直接 new 了一个 scoreboard 对象,把激励内容塞进去当参考数据用;scoreboard 反过来又会让 sequence 停住等待比较结果。这种双向依赖,在调试一个问题的时候特别折磨人,经常一个失败点要顺着调用链翻五六个文件。
2.3 分离的判定标准:单向依赖
重构完成之后,我总结出一个很简单的判定标准:把环境图里 Stimulus 相关模块和 Checker 相关模块之间的所有依赖箭头画出来,如果存在从 Checker 指向 Stimulus 的箭头,说明分离没做干净。
检查端可以知道“激励是什么”——通过配置信息通道,这是合法的。检查端不能要求激励端“再发一遍”或者“等一下再跑”,这会引入控制流反向依赖。反过来,激励端也不应该读取检查端的比较结果来决定下一步行为。仿真的进程控制应该由 test 级的调度统一管理。
这个判定标准有一个看起来很绕但实际很实用的操作方式:在一个测试用例跑完之前,试着把整个代码库里所有 checker 相关的文件删除,重新编译。如果编译能通过,说明分离合格。挑一个用例试一次就知道咬合有多紧,我当时重构完做这个试验,改了十几个只读引用才算通过。
2.4 分离到什么粒度才算合适
边界的粒度直接决定重构的工作量。我的经验是分三层来拆:
最粗的一层,面向测试场景的分离。典型场景是低功耗测试,激励端负责唤醒事件序列的生成,checker 需要验证唤醒后的寄存器状态和时钟行为。场景级的分离意味着一个测试用例可以被两个环境复用,激励环境跑唤醒,检查环境跑状态确认。
中间一层,面向协议模型的分离。以总线协议为例,driver 负责按协议时序把 transaction 发出去,protocol monitor 负责采集总线行为并检查时序违规,这两者的边界在协议标准上是天然清晰的。把协议解析逻辑单独做成 monitor,而不是塞进 driver,后面接新协议扩展时特别有用。
最细一层,面向数据语义的分离。激励端的 transaction 里带了 CRC 字段,driver 在发送前计算并填充,checker 端从总线上解析出报文 CRC 后独立计算核对。这种检查本质上是对数据语义的验证,必须在 checker 侧有独立的实现,绝不能用激励端的计算结果来比对。
粒度的选择要和团队实际情况匹配。我见过一个项目把分离做得过于极端,每一个字段都独立一个类,结果环境里类的数量超过用例数量,管理成本反而高过耦合时的状态。合理的做法是:先保证数据通道独立,再考虑类的粒度,通道干净了,类怎么拆都是后话。
3. 数据面上的分离实现:激励端配置生成
3.1 用一张配置表统一描述“这次测试要做什么”
实现职责分离的第一步,是给激励端设计一张测试配置表(scenario config)。这张表不关心 DUT 内部结构,它只是描述测试意图的标准格式。
我用 SystemVerilog 的 class 来承载,放在 env 包的一个独立头文件里,Stimulus 侧和 Checker 侧各自 include,但只读不改。关键做法是:config 对象在 test 层创建并“冻结”,之后只允许被读取,不允许被修改。这个约定靠注释和 code review 来维护,虽然没有编译器强制,但一旦约定被破坏,checker 侧拿到的预期值就可能和实际激励不一致,这是最隐蔽的一类 bug。
// scenario_config.svh class scenario_config extends uvm_object; rand int unsigned num_bursts; rand bit [7:0] payload_seed; rand burst_kind_e burst_kind; bit [31:0] base_address; bit check_payload_crc; bit check_response_timeout; `uvm_object_utils_begin(scenario_config) `uvm_field_int(num_bursts, UVM_ALL_ON) `uvm_field_int(payload_seed, UVM_ALL_ON) `uvm_field_enum(burst_kind_e, burst_kind, UVM_ALL_ON) `uvm_field_int(base_address, UVM_ALL_ON) `uvm_field_int(check_payload_crc, UVM_ALL_ON) `uvm_field_int(check_response_timeout, UVM_ALL_ON) `uvm_object_utils_end endclass这里的 payload_seed 很有意思。激励端用它来生成随机数据,Checker 端只需要拿到这个 seed 值,配合相同的随机算法,就能独立生成一份“期望数据”,而不需要激励端把每条 burst 的全部 payload 都发给 checker。这在数据量大的场景下能省掉大量存储和参数传递。
3.2 激励端如何消费这张配置表
激励端拿到 scenario_config 之后,只负责把它“翻译”成具体的 sequence 和 transaction。这里有个核心技巧:sequence 从配置表里取值,而不是从 sequence 基类的约束里硬编码。
早期我看到很多 sequence 直接把测试参数写在约束块里,比如constraint c { num_bursts == 32; },一旦要改成 64,就得新建一个 sequence 子类。配置表方案的逻辑是反过来的——sequence 保持通用,让 config 来决定参数。测试用例只需要 new 一个 config、把参数随机化两次、然后固定下来,序列层自然就生成了对应的场景。
class my_test extends uvm_test; virtual function void build_phase(uvm_phase phase); scenario_config cfg = scenario_config::type_id::create("cfg"); cfg.num_bursts = 32; cfg.payload_seed = 100; cfg.burst_kind = BURST_CRC_ERROR; cfg.base_address = 32'h8000_0000; // 关键一步:创建激励端的 sequence 时,把 config 的句柄传进去, // 但 sequence 只能读,不能改 my_sequence seq = my_sequence::type_id::create("seq"); seq.cfg = cfg; // 同时把同一份 config 挂在顶层 env 里,供所有 checker 访问 uvm_config_db#(scenario_config)::set(this, "env.*", "scenario_cfg", cfg); endfunction endclass激励端的 driver 拿到 transaction 之后,就只做一件事:按照协议时序把数据放到总线上。它不知道也不关心这些数据到了 DUT 之后会怎么样。这样 driver 就变成了一个可复用的协议引擎,换个 DUT 项目,driver 基本不用动。
3.3 配置缓冲队列与后门写寄存器
激励端还有一类容易被忽略的职责——配置寄存器的写入。在 SoC 验证里,很多场景需要先配置 DUT 的寄存器,再发起数据传输。寄存器配置写错了,后面的所有行为都是空中楼阁。
这个环节容易跟 checker 纠缠。寄存器值本身既是激励(被写入 DUT),又是预期(checker 需要知道寄存器里现在是什么值才能判断 DUT 行为)。我的做法是维护一个独立的配置跟踪队列,写在 env 层,激励端的 reg write sequence 每写一次,就往队列里 push 一条记录,checker 端通过队列查询当前寄存器值。
// config_tracker.svh class config_tracker extends uvm_component; // 队列里存的是 (地址, 数据, 写入时刻) protected config_item queue[$]; function void push_back(bit [31:0] addr, bit [31:0] data, time t); config_item item = new(); item.addr = addr; item.data = data; item.write_time = t; queue.push_back(item); endfunction function bit [31:0] read_reg(bit [31:0] addr, time t); // 返回在 t 时刻该寄存器的最新值 foreach (queue[i]) begin if (queue[i].addr == addr && queue[i].write_time <= t) begin rdata = queue[i].data; end end endfunction endclass这个队列有个很大的好处:它天然支持了时序回溯。比如我们在验证中要检查“某个信号 D 在寄存器配置之后 10 个时钟内变高”,checker 可以直接查 t-10ns 时刻的配置值,不用依赖激励端为每个检查点单独传参。
4. 检查端的模块化拆分与对比策略
4.1 检查端的三件套:期望生成、行为采集、对比判定
分离架构下,检查端本身也需要内部再拆三个角色:
期望生成(reference model)接收 scenario_config,配合从配置跟踪队列里读到的寄存器值,生成“DUT 在这种状态下应该输出什么”的期望数据。行为采集(monitor)监听 DUT 的真实输出,格式化后放在一个标准接口上。对比判定(checker)拉取两侧数据,进行逐字段核对。
这里有一个原则必须守住:期望生成不允许读取激励端中间变量的值。比如激励端计算了一个 CRC 放在 transaction 的某个字段里,期望生成必须自己从头算一遍,哪怕算出来的结果一模一样,也不能直接拿激励端的字段。一旦形成“期望值直接抄激励值”的习惯,事务级别的错误就会被系统性掩盖,这正是职责分离最忌讳的情况。
我见过的最极端的抄近路写法是,激励端构造一个带错误 CRC 的报文,期望生成直接用这个错误字段当期望值,结果 DUT 的纠错逻辑没有触发任何报警,整个用例形同虚设。正确做法是:激励端故意填错 CRC,期望生成算的是“正确 CRC”,checker 对比 DUT 的纠错结果到底是正确值还是错误值。
4.2 按接口切片,而不是按功能切片
SoC 的 checker 不能只做一个巨型 scoreboard,把所有接口的数据全倒进去再比。这个做法在接口少、数据量小的 IP 验证里还能用,到 SoC 级就会因为时序域差异和调度不确定性导致海量误报。
我重构时的做法是按接口切片:每一个标准接口(AXI、APB、中断线、流接口)配一个独立的 monitor + checker 对,各自检查各自的协议和数据。跨接口的交互行为,单独设计一个顶层的 cross-checker 来负责。
比如基带模块的验证环境,我当时同时接了 AXI 配置口和流数据口。AXI 侧的 checker 只管配置写入是否符合协议、寄存器值是否正确;流数据口的 checker 管数据流的格式、长度和 CRC。两者之间唯一共享的信息是 scenario_config 里的数据长度字段,不存在直接的数据耦合。后来项目加了一个新的流接口,工作量比预想少了一半,就是切片的收益。
4.3 集合比较和有序比较的取舍
数据对比里最容易忽略的细节是:顺序是否需要严格一致。有些 DUT 处理完数据之后会把结果缓存起来,在某个时刻集中输出,输出顺序可能和输入顺序不完全一致。这种情况下 checker 如果坚持有序比较,会产生大量假失败。
我的标准做法是,在期望生成阶段就明确比较模式,并把模式写进 scenario_config:
- 严格有序模式,用于协议顺序有强约束的场景;
- 无序集合模式,用于数据并行处理、输出顺序无关的场景;
- 窗口滑动模式,用于有时延但整体有序的场景。
无序集合比对在 SystemVerilog 里用关联数组加计数就能高效实现,不需要遍历整个队列。我把待匹配的期望数据建成一个 hash bag,monitor 每采到一个结果就从 bag 里扣除一个,全部扣完且无剩余即通过。这个思路在实际实现里跑得很快,也解决了重复数据导致的配对问题。
// checker_core.svh:无序比对核心 class unordered_checker extends uvm_component; protected int unsigned expect_bag[string]; function void push_expect(string key); if (expect_bag.exists(key)) expect_bag[key]++; else expect_bag[key] = 1; endfunction function bit try_match(string key); if (!expect_bag.exists(key) || expect_bag[key] == 0) return 0; expect_bag[key]--; return 1; endfunction endclass这个按 key 计数的做法对大量数据的处理效果很好。数据流接口上来一个报文,提取关键字段拼成 key,直接 try_match,耗时是 O(1) 量级,对比传统的队列里逐条 find 性能差距巨大。
5. 检查端的关键校验点:协议、数据、配置、时序
5.1 协议级检查
协议检查的对象是总线信号层面的时序规范,跟 DUT 的功能正确性没有直接关系。地址是否在相位上稳定、读写信号不能同时拉高、burst 长度是否符合协议限制,这些都归协议 monitor 管。
一个常见的错误是把协议检查放在 driver 里做。driver 在激励发送阶段自己已经保证了时序的合法性,如果它自己也做检查,等于用同一个逻辑既当运动员又当裁判,抓不到自己写时序时埋下的错误。所以在我的环境里,driver 只管“怎么发”,协议 monitor 独立采集总线波形、独立做检查,两边各看各的。
协议 checker 用 SystemVerilog assertion 来写是最顺手的。SVA 天然适合描述周期相关的时序属性,配合$rose、$fell、##运算符能比较简洁地表达时序要求。这个部分一定要独立成文件,不要混在 scoreboard 里,因为 assertion 的调试方式、触发时机跟数据比较完全不同,混在一起时,一个时序断言失败会干扰数据比较的排查。
5.2 数据级检查
数据级检查是 scoreboard 的主战场,主要验证 DUT 的输出数据和期望数据是否一致。数据级检查最常见的坑是参考模型和 DUT 使用的舍入规则不同。我做过一个图像缩放模块,DUT 内部用的是定点运算,参考模型如果用浮点算完再截断,两者的边界值偶尔会差 1。这种“差 1”不是 bug,是参考模型不够精确。
解决方法是把参考模型的数据通路精度和位宽对齐到 DUT,甚至在算法层面用完全相同的定点格式重新实现一遍。这个工作量不小,但如果不做,所有边界点都会变成“可接受差异到不可接受差异”之间的模糊地带,非常烦人。我的经验是,参考模型宁可实现得慢,也要严格对齐 DUT 的算法精度,为的是拿到干净的比较结果,这比把参考模型优化到跟 DUT 一样快更有意义。
数据级检查还要特别注意不定态。功能仿真的早期阶段,时序没完全收敛时,DUT 的输出出现 X 态是常事。一个负责任的 scoreboard 会在数据比较前先检查数据是否有效,而不是直接拿 X 值和期望值比。否则,一次未复位状态的仿真会刷出几百个重复的误报,把真正有价值的失败淹没在噪声里。
5.3 配置级检查
配置级检查检查的是寄存器写入后 DUT 行为是否符合配置意图。这个层面最容易验证不到位,因为它要求 checker 理解“配置和硬件行为之间的因果关系”。
实现上我用一个配置回调机制:config_tracker 里可以注册一组回调函数,当特定寄存器被写入特定值时,触发对应的事件检查。比如低功耗模式的开关寄存器,一旦写入使能,checker 就应该在接下来的若干个时钟周期内观察到时钟关断信号。这种关联不可能靠通用 scoreboard 来做,得靠场景专用的配置检查器。
// power_checker.svh:配置触发式检查 class power_checker extends uvm_component; function void init_tracker(config_tracker tracker); tracker.register_callback(32'h4A, 1'b1, this.write_enable_cb()); endfunction task write_enable_cb(); // 写入使能后 100us 内必须看到 clock_en 拉低 @(posedge vif.clock_en iff vif.clock_en === 0); // 断言或显式计数均可 endtask endclass配置级检查还有一个优先级规则:一位配置字段可能同时影响多个功能点,checker 在组织检查项时应该按“配置事件”维度组织,而不是按“功能点”维度组织。否则,同一个配置写入会导致多个检查器收到通知,但各自的判断逻辑又无法确定“这个功能点到底该不该在这个配置下被检查”,最终只能被迫接受所有配置为检查的充分条件,造成假失败。
5.4 时序检查
时序检查在 SoC 层面是最容易“查漏”也最容易“误杀”的部分。查漏是因为 SoC 的时序关系往往跨越多个时钟域,单看一个接口的断言无法发现问题;误杀是因为仿真环境里时钟树的建模不完整,实际芯片能工作的时序在仿真里可能略有偏差。
我对时序检查的经验是先定尺度:只有协议规范里明确的、可量化的时序约束才写入断言,比如“读数据必须在读响应后 1-4 拍内有效”。那些模糊的“尽量在若干拍内完成”的描述不要写成硬断言,改成覆盖率属性来观察,避免误杀。时序断言尽量放在接口边界上,不要深入 DUT 内部去检查内部节点时序,内部时序检查应该留给 gate-level 仿真,RTL 级做功能验证时插得太多只会拖慢仿真速度。
跨时钟域的检查则必须显式地统一时间基准。我的做法是给 checker 里所有和纳秒时间相关的参数统一使用绝对时间变量,在 test 层传入时钟频率,checker 计算时用频率参数来换算,而不是硬编码一个周期数。这样同一个 checker 既能跑在 200MHz 的配置下,也能跑在 400MHz 的配置下。
6. 实操中的三个关键决策点
6.1 决策点一:激励端的数据类型与 checker 端的数据类型是否要共用基类
这个问题的答案不是绝对的,它取决于你的项目是否需要激励数据和期望数据在字段级别上共享定义。如果共用基类,可以少写很多字段复制逻辑;但容易形成隐式耦合,激励端改了字段名,checker 端跟着编译报错,虽然能通过编译器提醒来定位,可语义上就失去了解耦的意义。
我的实际建议是共用公共字段定义,但不共用完整 transaction 类型。拿总线写请求举例,地址、数据、突发长度的定义可以放在一个公共包 reducer_pkg 里,激励端的 write_req 和 checker 端的 expected_write_req 各自继承或者包含这些公共字段,但各自增加私有信息。这样字段定义可以统一更新,而两边的行为建模是隔离的。这个折中在大型 SoC 环境里操作性最强。
6.2 决策点二:参考模型应该放在事务级还是行为级
参考模型放在事务级还是行为级,取决于 checker 需要验证的是 DUT 的接口行为还是数据算法。事务级参考模型输入的是“一帧数据”,输出的是“处理之后的帧”,比较的对象是接口语义;行为级参考模型则要求模拟 DUT 内部的周期级行为,输出的是“在哪个周期哪个信号变成了什么值”。
SoC 验证和 IP 验证的需求是不一样的。SoC 验证一般不需要周期级模型,因为整个芯片的数据通路行为本来就是乱序、多流水、跨时钟域的,参考模型做一个事务级的功能镜像就足够,检查时放宽到有序比对或窗口模式。真正需要在周期级较劲的是那些单一 IP、时序严格、输出窗口很短的类型,这时候才需要行为级参考模型。
我的底线是:事务级模型优先,除非有明确的周期级检查需求才升级为行为级模型。行为级模型写起来难度大、调试难、对仿真性能影响也大,为了一两个边界序贯行为引入一整条周期级链路,通常不值得。
6.3 决策点三:比较的触发是由 monitor 采集驱动,还是由 checker 轮询发起
比较逻辑的调度方式对误报率影响巨大。我有过一个项目,scoreboard 里写了一个 forever 循环,每 10 个仿真纳秒扫描一次输出队列。当时它既然能过,就没在意,后来系统时钟改成 50MHz,扫描周期没改,结果大量事件漏采,误报一堆。
稳妥的做法是事件驱动:monitor 在总线上采到一个完整事务后,通过 TLM 端口把事务推给 checker,checker 收到一个事务就开始一次比较。这种 push 模型不依赖时间粒度,天然与事务边界对齐,漏采问题基本绝迹。只有那些“在某个时间窗口内应该没有活动”的检查(比如看门狗超时),才在 check 阶段用显式的延时等待来实现。
7. 验证环境重构的效果与踩坑实录
7.1 重构结果:三个指标的真实变化
重构完成后我记录了三个数据,对比重构前和重构后的表现:
第一个指标是新增用例的平均耗时。之前加一个需要新配置加新数据格式的用例,平均开发时间是 3 到 4 天;重构后同样的工作压缩到 1.5 天左右。原因很直观:新增用例时只需要在 test 层构造 scenario_config 并挂到 env 上,激励端和检查端都已经按 config 驱动了,不需要动环境主体代码。
第二个指标是失败定位的平均耗时。之前一个随机种子失败,定位时间动辄五六个小时,因为需要同时看激励日志、scoreboard 日志、断言日志,而这三种日志并没有时间对齐。重构后,checker 在比较失败时会在 log 里打出“期望值来源”“采集时刻”“关联的 config 指纹”,经常两个小时甚至几十分钟就定位到问题模块。
第三个指标是回归稳定性。之前每个版本的回归都会有几个偶发的“flaky fail”,复跑一次就过,但查不出原因。重构后这类问题下降了很多,主要原因是 checker 不再依赖激励端的中间变量,采集到的信号是干净的 DUT 行为数据,不存在跨模块的时间竞争。
7.2 踩坑实录一:剪不断理还乱的 reference 依赖
第一次做分离时,我把 motivation 做得太激进,把所有 checker 依赖的公共包全部拆成独立文件,结果 include 顺序一团糟,编译报了几百个 unresolved reference,花了一整天整理头文件依赖。当时我才反应过来,职责分离的核心是数据流分离,不是代码文件分离。适当保留公共包是合理的,只要在数据层面每个模块的读和写是清楚的就行。
之后我归纳了一个简单的编译依赖规则:公共底层(配置类、枚举、公共字段)放最底层;激励端和检查端分别 include 公共底层,但彼此之间禁止 include。这个规则通过编译器和 CI 检查来约束,保证单向依赖,基本杜绝了类文件层面的隐式耦合。
7.3 踩坑实录二:checker 的期望数据被主动修改
重构后有一段时间发现某些用例的误报率反复波动,排查了很久发现是一段历史遗留代码,在 checker 对象初始化时从某个 sequence 里拉取了一个句柄,然后直接修改了期望队列里的数据。虽然代码写得很隐蔽,但它本质上是让检查端“听从”激励端,破坏了双方独立的判断。
这种问题在单人维护环境时不容易出现,但多人协作时风险很大。我的建议是:checker 内部队列的数据结构声明为 protected,对外只提供查询和匹配操作,不提供修改接口。编译期就能挡住大部分越权操作。
7.4 踩坑实录三:覆盖率收集与 checker 的边界
覆盖率收集虽然不属于 Stimulus 也不属于 Checker,但它和两侧都有关系。覆盖率的“触发”来自激励端产生的场景,“判定”依赖 checker 是否发生关键事件。重构时一度把覆盖率组挂在了 scoreboard 里面,后来发现覆盖率数据会随比较窗口的变化而丢失,不好回溯。
最终我把覆盖率独立成第三类模块,挂在整个 env 的顶层。它只通过 config 和 monitor 的 TLM 端口拿数据,和两侧都不产生控制依赖。这样覆盖率回归时的可重复性明显提升,调试覆盖率收集问题时也不会干扰 Stimulus / Checker 的正常运行。
7.5 踩坑实录四:跨时钟域的检查调度
SoC 级别的 checker 最棘手的实际问题是跨时钟域的检查。一个接口的事件永远无法精确预测另一个时钟域中哪个周期会观察到该事件。强行用单一周期对齐比较,必然导致大量时序差异引发的误报。处理这种场景我最后选择了绝对时间比较策略:检查端先记录事件发生时刻($realtime),比较时左右各留一个小的允许偏差窗口。
偏差窗口的宽度要根据系统和接口规范来确定,过优太紧会误杀。我一般先从规范约定的 ±5% 开始,如果回归中确认了这个窗口漏掉了真实的违规,再逐步收紧。另外,跨时钟域的 comparison 必须显式地把两个事件的时间基准统一到同一个仿真绝对时间上,很多误报的根源就是拿一个时钟域的相对相位去跟另一个时钟域比,比错了基准也查不出来。
写在最后的一点体会
这套 Stimulus / Checker 职责分离的架构,我前前后后在两代模拟与数字混合的 SoC 项目里应用和修正过。最大的感受是:验证环境的核心不是代码写得多花哨,而是信息流向清晰、依赖关系单向、修改局部化。一旦做到这三点,环境就像一台组装良好的机器,加一个新零件不再需要拆掉半个机箱。很多刚入行的同学以为验证的活就是把用例跑通、把覆盖率收敛到指标,实际上真正的硬功夫在环境架构的未雨绸缪。你在项目中期感受到的每一次“这里怎么改不动”,都大概率是早期职责边界没有划清楚的代价。
最后再分享一个小技巧。如果你正准备重构旧环境,别急着动手,先把现有环境的所有跨模块数据访问画成一个清单,然后问自己一个问题:“这里的数据传递,语义上是配置、是激励、还是观测?” 把这三种信息流分开标识之后,你会发现绝大多数重构的切分点会自然浮出来。这个准备工作花两个小时,能省掉后面两周的返工。