3、Transaction级调试:利用 do_print/do_record/do_copy 方法打印和记录事务、在波形中查看 transaction 信息

3.1 为什么需要 Transaction 级调试?

做验证的兄弟都知道,信号级的调试太痛苦了。你盯着波形里那些 0 和 1 的跳变,眼睛都快看瞎了,还是搞不清数据对不对。我刚开始做 UVM 那会儿,就吃过这个亏。一个 AXI 总线的事务,几百个信号在波形里翻来覆去,查一个地址错误查了整整两天。

后来我才明白,我们真正关心的不是信号怎么跳,而是事务对不对。说白了,你发了一个写请求,地址是 0x1000,数据是 0xA5A5A5A5,你只想知道这个事务有没有被正确发送和接收。至于它是在哪个时钟沿采样的,那是工具该关心的事。

UVM 给我们提供了三个好用的方法:do_print、do_record 和 do_copy。用好它们,调试效率能翻好几倍。

核心思想:把信号级的调试提升到事务级。你看到的不再是波形里的 0 和 1,而是有意义的 transaction 内容。

3.2 do_print:让事务自己说话

do_print 是 UVM 中最常用的调试方法。它能把一个 transaction 对象的内容,以可读的格式打印出来。我习惯在 monitor 或者 scoreboard 里调用它,每次抓到新事务就打印一下,看看数据对不对。

先看一个简单的例子:

class my_transaction extends uvm_sequence_item;
  rand bit [31:0] addr;
  rand bit [31:0] data;
  rand bit        wr_en;  // 1: write, 0: read

  `uvm_object_utils_begin(my_transaction)
    `uvm_field_int(addr, UVM_DEFAULT)
    `uvm_field_int(data, UVM_DEFAULT)
    `uvm_field_int(wr_en, UVM_DEFAULT)
  `uvm_object_utils_end

  // 自定义打印格式
  function void do_print(uvm_printer printer);
    super.do_print(printer);
    printer.print_string("type", wr_en ? "WRITE" : "READ");
    printer.print_hex("address", addr, 32);
    printer.print_hex("data", data, 32);
  endfunction
endclass

这样打印出来的结果,一眼就能看懂:

--------------------------------------------------------------
Name       Type            Size        Value
--------------------------------------------------------------
type       string          -           WRITE
address    hex             32          0x00001000
data       hex             32          0xA5A5A5A5
--------------------------------------------------------------

嗯,这里要注意:do_print 默认会打印所有用 uvm_field_* 注册的字段。但有时候你只想看关键信息,那就自己重写 do_print,只打印你关心的内容。

我的习惯:在 do_print 里加一个时间戳。这样看 log 的时候,能清楚知道每个事务发生的时刻。比如:printer.print_time("timestamp", $time);

3.3 do_record:把事务写进波形

说实话,do_record 是我最喜欢的调试方法。它能把 transaction 的信息直接写到波形文件里。你想想看,在波形里直接看到 "WRITE 0x1000 0xA5A5A5A5" 这样的信息,比看一堆信号线舒服多了。

实现起来也很简单:

class my_transaction extends uvm_sequence_item;
  // ... 前面的定义不变 ...

  function void do_record(uvm_recorder recorder);
    super.do_record(recorder);
    recorder.record_string("type", wr_en ? "WRITE" : "READ");
    recorder.record_field("address", addr, 32);
    recorder.record_field("data", data, 32);
  endfunction
endclass

然后在你的 agent 或者 monitor 里,打开记录功能:

class my_monitor extends uvm_monitor;
  virtual task run_phase(uvm_phase phase);
    my_transaction tr;
    forever begin
      // 从接口抓取事务
      tr = my_transaction::type_id::create("tr");
      // ... 填充事务内容 ...
      
      // 记录到波形
      void'(tr.begin_tr());
      tr.do_record(tr.get_recorder());
      tr.end_tr();
      
      // 同时发送给 scoreboard
      ap.write(tr);
    end
  endtask
endclass

打开波形后,你会看到类似这样的信息:

# 在波形的事务栏里显示:
# [100ns] WRITE  addr=0x1000  data=0xA5A5A5A5
# [200ns] READ   addr=0x1000  data=0x00000000

我曾经踩过的坑:begin_tr 和 end_tr 必须成对出现。少了一个,波形里的事务信息就会乱掉。而且 begin_tr 会返回一个时间戳,你可以用它来标记事务的开始时间。

3.4 do_copy:深拷贝的正确姿势

调试的时候,经常需要复制事务对象。比如 scoreboard 里要保存一个 reference 队列,或者 monitor 里要缓存最近几个事务。这时候 do_copy 就派上用场了。

默认的 do_copy 只会复制用 uvm_field_* 注册的字段。但如果你有动态数组、队列或者自定义对象,就得自己重写:

class my_transaction extends uvm_sequence_item;
  rand bit [31:0] addr;
  rand bit [31:0] data;
  rand bit        wr_en;
  byte            payload[];  // 动态数组,需要手动复制

  `uvm_object_utils_begin(my_transaction)
    `uvm_field_int(addr, UVM_DEFAULT)
    `uvm_field_int(data, UVM_DEFAULT)
    `uvm_field_int(wr_en, UVM_DEFAULT)
  `uvm_object_utils_end

  function void do_copy(uvm_object rhs);
    my_transaction tr;
    super.do_copy(rhs);
    
    // 类型检查,确保安全
    if (!$cast(tr, rhs)) begin
      `uvm_fatal("COPY", "Type mismatch in do_copy")
    end
    
    // 复制动态数组
    payload = new[tr.payload.size()];
    foreach (tr.payload[i]) begin
      payload[i] = tr.payload[i];
    end
  endfunction
endclass

为什么要自己写 do_copy?因为 UVM 的自动复制只处理简单类型。动态数组、关联数组、队列这些,它不会帮你深拷贝。我遇到过好几次,复制后两个对象共享同一个数组,改了一个另一个也跟着变,调试起来一头雾水。

避坑指南:如果你在 transaction 里用了 randc 变量,复制后记得重新随机化。否则复制出来的对象和原对象共享同一个随机状态,可能会出问题。

3.5 知识体系总览

下面这张图,是我自己总结的 Transaction 级调试的核心逻辑。你看一眼就能明白这三个方法的关系:

Transaction 级调试核心方法 Transaction 对象 do_print() do_record() do_copy() 输出到终端/日志 可读格式展示 适合快速查看 输出到波形文件 波形中直接查看 适合时序分析 深拷贝对象 独立副本 适合缓存/比较 三个方法配合使用,覆盖打印、波形、复制三大调试场景

3.6 实战建议

说了这么多,我总结几条实战经验:

  • 先打印,后波形:调试初期先用 do_print 快速定位问题。等找到可疑事务后,再用 do_record 把相关事务写到波形里,结合时序分析。
  • 控制打印频率:别每个事务都打印。仿真跑几百万个事务,打印文件会大到爆炸。我一般只在第一次抓到事务、或者检测到错误时才打印。
  • do_copy 要谨慎:复制大对象时注意性能。如果只是临时查看,用 do_print 就够了,没必要复制一份。
  • 统一格式:团队协作时,大家约定好 do_print 和 do_record 的输出格式。这样看 log 和波形时,不会因为格式不统一而浪费时间。

小技巧:你可以在 do_print 里加一个条件判断。比如只在 wr_en == 1 时打印详细信息,读操作只打印地址。这样 log 文件不会太臃肿,关键信息又不会漏掉。

好了,Transaction 级调试就聊到这儿。这三个方法用好了,调试效率上一个台阶。下次你遇到 bug,不妨先试试用 do_print 看看事务内容,说不定一眼就能发现问题。

可可精品资料库