3、验证环境搭建:验证组件的搭建与Testbench架构设计

好,咱们接着聊。上一章我们把验证策略和计划定下来了,现在要动真格的了——搭环境。

说实话,我见过不少团队,计划写得天花乱坠,一到搭环境就露怯。为什么?因为验证环境的好坏,直接决定了你后面几个月的效率。环境搭得烂,你天天在改bug;环境搭得好,你天天在找bug。这区别可大了去了。

3.1 验证组件的核心:Driver、Monitor、Scoreboard

先说说这几个最基础的组件。你想想看,一个DUT(待测设计)摆在那,你怎么跟它打交道?无非就是三件事:发激励、看响应、判对错。对应的就是Driver、Monitor和Scoreboard。

3.1.1 Driver——发激励的“手”

Driver说白了就是DUT的“输入接口”。它把事务级的数据(transaction)转成DUT能认的物理信号(比如并行的数据总线、时钟、使能信号等)。

我个人习惯,Driver里一定要做两件事:

  • 协议时序的精确控制——不能多一拍,也不能少一拍
  • 驱动能力的检查——比如信号冲突、驱动强度不够等问题

我曾经在一个项目中,Driver里忘了加复位同步逻辑,结果仿真前100个周期数据全是乱的。排查了两天才发现,原来是Driver在复位还没释放完就开始发数据了。嗯,从那以后,我每个Driver里都会加一个wait(reset_done)的检查。

来个简单的代码示例,一个典型的Driver长这样:

class driver extends uvm_driver #(packet_t);
  virtual dut_if vif;

  function void build_phase(uvm_phase phase);
    if(!uvm_config_db #(virtual dut_if)::get(this, "", "vif", vif))
      `uvm_fatal("DRV", "No vif found")
  endfunction

  task run_phase(uvm_phase phase);
    packet_t pkt;
    forever begin
      seq_item_port.get_next_item(pkt);
      // 等待DUT准备好
      @(posedge vif.clk);
      while(!vif.ready) @(posedge vif.clk);
      // 驱动数据
      vif.data <= pkt.data;
      vif.valid <= 1'b1;
      @(posedge vif.clk);
      vif.valid <= 1'b0;
      seq_item_port.item_done();
    end
  endtask
endclass
小技巧:Driver里尽量用@(posedge clk)而不是#10这样的延时。为什么?因为延时是死的,时钟是活的。你换个时钟频率,延时就得全改,太傻了。

3.1.2 Monitor——看响应的“眼”

Monitor是DUT的“输出监听器”。它不驱动任何信号,只负责“看”。把DUT输出的物理信号,重新转回事务级的数据包,然后发给Scoreboard去比对。

这里有个坑,我踩过好几次:Monitor的采样时机。你采样早了,数据还没稳定;采样晚了,下一拍数据已经来了。我个人建议,Monitor里一定要用@(negedge clk)来采样,或者用时钟的采样边沿(比如DDR就用双边沿)。

另外,Monitor里最好加一个monitor_policy的配置项,用来控制它是否检查协议违规。比如:

  • MONITOR_ONLY:只收集数据,不检查协议
  • MONITOR_CHECK:收集数据的同时,检查协议是否违规

我在一个PCIe项目中,就是靠Monitor的协议检查功能,抓到了一个DUT在TLP包长度字段上的bug。那个bug在功能仿真里根本看不出来,但Monitor一查协议就报错了。

3.1.3 Scoreboard——判对错的“脑”

Scoreboard是整个验证环境的“裁判”。它接收来自Driver的激励数据和来自Monitor的响应数据,然后比对两者是否一致。

但这里有个问题:激励和响应不是一一对应的。比如一个流水线设计的DUT,你发10个包进去,它可能在第5个周期才吐出第一个结果。所以Scoreboard里必须有一个预测模型(predictor),用来计算“如果DUT是对的,它应该输出什么”。

我常用的Scoreboard结构是这样的:

class scoreboard extends uvm_scoreboard;
  // 两个fifo,一个存期望值,一个存实际值
  uvm_tlm_analysis_fifo #(packet_t) exp_fifo;
  uvm_tlm_analysis_fifo #(packet_t) act_fifo;

  task run_phase(uvm_phase phase);
    packet_t exp_pkt, act_pkt;
    forever begin
      // 同时从两个fifo取数据
      exp_fifo.get(exp_pkt);
      act_fifo.get(act_pkt);
      // 比对
      if(!exp_pkt.compare(act_pkt))
        `uvm_error("SCOREBOARD", 
          $sformatf("Mismatch: exp=%0h, act=%0h", 
            exp_pkt.data, act_pkt.data))
    end
  endtask
endclass
注意:Scoreboard的比对不能太死板。比如有些DUT的输出有延迟,或者有乱序输出。你需要在Scoreboard里加一个排序器(reorder buffer),或者用compare_with_mask来忽略某些位。

3.2 Agent——组件的“管家”

Agent是什么?说白了,它就是Driver、Monitor、Sequencer的“集合体”。一个Agent对应一个接口协议。比如你有AXI接口,就建一个AXI Agent;有I2C接口,就建一个I2C Agent。

Agent的好处是封装和复用。你想想看,如果每个测试用例都要自己去例化Driver和Monitor,那得多麻烦?Agent把这些都包好了,你只需要在环境里例化Agent,然后通过配置来控制它是active(主动发数据)还是passive(只监听)。

我一般把Agent设计成可配置的:

配置项 取值 说明
is_active UVM_ACTIVE / UVM_PASSIVE 是否包含Driver和Sequencer
interface_type AXI / AHB / APB / 自定义 接口协议类型
check_enable 0 / 1 是否启用协议检查
coverage_enable 0 / 1 是否收集覆盖率

Agent的代码结构大致如下:

class agent extends uvm_agent;
  driver    drv;
  monitor   mon;
  sequencer seqr;

  function void build_phase(uvm_phase phase);
    // Monitor总是需要
    mon = monitor::type_id::create("mon", this);
    // Driver和Sequencer只在active模式下创建
    if(get_is_active() == UVM_ACTIVE) begin
      drv  = driver::type_id::create("drv", this);
      seqr = sequencer::type_id::create("seqr", this);
    end
  endfunction

  function void connect_phase(uvm_phase phase);
    if(get_is_active() == UVM_ACTIVE)
      drv.seq_item_port.connect(seqr.seq_item_export);
  endfunction
endclass
核心思想:Agent的设计要遵循“高内聚、低耦合”。每个Agent只负责一种协议,不要在一个Agent里混入多种接口。否则复用性会大打折扣。

3.3 Testbench架构设计——搭积木的艺术

好,组件都有了,怎么把它们拼起来?这就是Testbench架构设计。

我习惯把Testbench分成三层:

  1. 底层:接口层(Interface Layer)——包含DUT的接口声明、时钟生成、复位逻辑
  2. 中间层:验证组件层(Verification Component Layer)——包含Agent、Scoreboard、Reference Model等
  3. 顶层:测试用例层(Test Layer)——包含具体的测试用例、sequence、配置

为什么要分层?说白了就是解耦。你改一个测试用例,不需要动到底层的接口;你换一个DUT版本,只需要改接口层,验证组件层基本不动。

我见过最糟糕的Testbench,是把所有代码都写在一个文件里,Driver、Monitor、Scoreboard全混在一起。结果呢?改一个bug,引入三个新bug。嗯,那滋味,谁改谁知道。

一个典型的Testbench架构图(用文字描述):

+--------------------------------------------------+
|                  Test Layer                        |
|  +-----------+  +-----------+  +-----------+      |
|  | test_case1|  | test_case2|  | test_case3|      |
|  +-----------+  +-----------+  +-----------+      |
+--------------------------------------------------+
|              Verification Component Layer          |
|  +--------+  +--------+  +-----------+            |
|  | Agent1 |  | Agent2 |  | Scoreboard|            |
|  | (AXI)  |  | (APB)  |  |           |            |
|  +--------+  +--------+  +-----------+            |
+--------------------------------------------------+
|              Interface Layer                       |
|  +--------+  +--------+  +--------+               |
|  | AXI IF |  | APB IF |  | Clk/Rst|               |
|  +--------+  +--------+  +--------+               |
+--------------------------------------------------+
|                    DUT                             |
+--------------------------------------------------+

在实际项目中,我还会加一个环境配置类(env_config),用来统一管理所有组件的配置。比如:

  • 是否启用覆盖率收集
  • 仿真超时时间
  • 日志级别(INFO / WARNING / ERROR)
  • 接口的时序参数(比如时钟周期、建立时间、保持时间)

这样做的好处是,你只需要改一个配置文件,就能控制整个验证环境的行为。不用到处找代码去改参数。

避坑指南:我曾经在一个项目中,把所有的配置都写死在代码里。结果换了一个DUT版本,时钟频率变了,我花了整整一天去改所有Driver和Monitor里的时序参数。从那以后,我再也不写死任何参数了。全部用uvm_config_db来传递。

3.4 小结

好了,这一章的内容就这些。总结一下:

  • Driver:发激励,注意时序和复位同步
  • Monitor:看响应,注意采样时机和协议检查
  • Scoreboard:判对错,注意预测模型和乱序处理
  • Agent:封装Driver+Monitor+Sequencer,可配置active/passive
  • Testbench架构:分层设计,接口层、组件层、测试层各司其职

下一章,我们会讲验证环境的启动流程和仿真控制。到时候我会聊聊怎么用UVM的phase机制来控制整个仿真流程。嗯,那个东西,用好了是真香,用不好是真坑。