3、RTL仿真进阶:任务与函数,系统任务($display, $monitor, $time),文件读写

各位同学,咱们继续往下走。RTL仿真基础打牢了,接下来要聊点更实用的东西。说白了,就是怎么让你的仿真代码更聪明、更高效。

我个人习惯把这一节叫做「仿真调试三板斧」——任务与函数、系统任务、文件读写。这三样东西,你几乎每天都会用到。尤其是做后仿真的时候,没有它们,你连波形都看不懂。

3.1 任务(Task)与函数(Function)

先说说任务和函数。很多初学者搞不清它俩的区别,我刚开始也迷糊过。其实很简单:

  • 函数:不能带时序,不能有延迟,必须返回一个值。适合做纯组合逻辑的计算。
  • 任务:可以带时序,可以有延迟,可以没有返回值。适合做复杂的激励序列。

嗯,这里要注意:函数里不能写 #10 这种延迟语句,也不能用 @(posedge clk)。任务就没这个限制。

我的经验:写验证环境时,我习惯把「检查结果」的逻辑写成函数,把「发送激励」的逻辑写成任务。这样代码结构清晰,不容易出错。

来看个例子:

// 函数:计算CRC校验值
function [7:0] calc_crc;
  input [31:0] data;
  integer i;
  begin
    calc_crc = 8'h00;
    for (i = 0; i < 32; i = i + 1) begin
      if (data[i])
        calc_crc = calc_crc ^ (8'h07 << (i % 8));
    end
  end
endfunction

// 任务:发送一组数据包
task send_packet;
  input [31:0] addr;
  input [7:0]  data;
  begin
    @(posedge clk);
    valid <= 1'b1;
    addr_out <= addr;
    data_out <= data;
    @(posedge clk);
    valid <= 1'b0;
  end
endtask

你想想看,如果不用任务,每次发数据都要重复写那几行时序控制代码,多累啊。封装成任务后,一行 send_packet(addr, data); 就搞定了。

小技巧:函数可以调用任务吗?不行。但任务可以调用函数。所以如果你不确定用哪个,优先用任务,灵活性更高。

3.2 系统任务:$display, $monitor, $time

系统任务是Verilog/SystemVerilog内置的「打印函数」。我最常用的三个:$display$monitor$time

$display:打印一次

$display 就像C语言的 printf。每次执行到它,就打印一行信息。格式控制符跟C语言几乎一样:

$display("time = %0t, data = %h, addr = %h", $time, data, addr);

这里 %0t 打印时间,%h 打印十六进制。我个人习惯用 %0t 而不是 %t,因为前者不会打印多余的空格。

$monitor:持续监控

$monitor 是个好东西。它会在仿真过程中,只要监控的变量发生变化,就自动打印一次。你想想看,不用写循环,不用写条件判断,一行代码搞定全程监控。

initial begin
  $monitor("time=%0t, clk=%b, rst=%b, data=%h", $time, clk, rst, data);
end

我曾经在一个复杂的DMA验证项目中,用 $monitor 监控了十几个关键信号。结果发现一个地址跳变异常,顺着这个线索找到了一个地址对齐的bug。嗯,这招确实省了不少事。

注意:$monitor 在每个时间步长内只打印一次。如果同一个时间点多个变量同时变化,它只会打印一次,但会显示所有变化后的值。别指望它帮你区分变化的先后顺序。

$time:获取当前仿真时间

$time 返回当前仿真时间,单位是仿真器的时间精度。配合 $display$monitor 使用,可以精确定位事件发生的时间点。

我建议你在每个关键打印信息里都加上 $time。为什么?因为后仿真时,时间信息就是你的「坐标」。没有时间戳的打印信息,基本等于废纸。

3.3 文件读写

文件读写是验证中绕不开的一环。尤其是做后仿真时,你需要把仿真结果写出来,跟预期值做对比。

打开文件

integer fd;
fd = $fopen("output.txt", "w");

模式跟C语言一样:"r" 读,"w" 写,"a" 追加。

写入文件

$fwrite(fd, "time=%0t, data=%h\n", $time, data);
$fdisplay(fd, "time=%0t, data=%h", $time, data);

$fwrite$fdisplay 的区别?$fdisplay 会自动换行,$fwrite 不会。我个人习惯用 $fdisplay,少写一个 \n

读取文件

integer fd;
reg [7:0] mem [0:1023];
fd = $fopen("stimulus.hex", "r");
$readmemh("stimulus.hex", mem);  // 读取十六进制文件
$readmemb("stimulus.bin", mem);  // 读取二进制文件

这里有个坑:$readmemh$readmemb 读取的文件,每行只能放一个数据。如果你从EDA工具导出的文件格式不对,读进来全是乱的。

避坑指南:我曾经在做一个图像处理IP的验证时,用 $readmemh 读取测试图像数据。结果仿真结果一直不对,查了两天才发现是文件里多了个空行。从那以后,我每次读文件前都会先检查文件格式。

关闭文件

$fclose(fd);

别忘了关文件。虽然仿真器会在仿真结束时自动关闭,但如果你在同一个仿真中反复打开文件而不关闭,文件描述符会耗尽。嗯,我见过有人因为这个导致仿真崩溃。

3.4 综合示例

最后,给一个综合的例子。假设你要验证一个FIFO,需要把写入的数据和读出的数据分别记录到文件里,然后对比:

module fifo_tb;
  reg clk, rst, wr_en, rd_en;
  reg [7:0] wr_data, rd_data;
  integer wr_fd, rd_fd;

  initial begin
    wr_fd = $fopen("write_data.txt", "w");
    rd_fd = $fopen("read_data.txt", "w");
    
    // 监控写入
    always @(posedge clk) begin
      if (wr_en)
        $fdisplay(wr_fd, "%0t %h", $time, wr_data);
    end
    
    // 监控读出
    always @(posedge clk) begin
      if (rd_en)
        $fdisplay(rd_fd, "%0t %h", $time, rd_data);
    end
    
    // 仿真结束关闭文件
    #10000;
    $fclose(wr_fd);
    $fclose(rd_fd);
    $finish;
  end
  
  // ... 其他激励代码
endmodule

仿真结束后,用 diff 命令对比两个文件,就能快速定位数据不一致的地方。这个方法我在多个项目中用过,效率很高。

进阶技巧:如果你用SystemVerilog,可以考虑用 $fscanf 逐行读取文件,配合 assert 做自动化比对。这样连手动 diff 都省了。

好了,这一节的内容就到这里。任务与函数帮你组织代码,系统任务帮你调试,文件读写帮你记录和比对。这三样东西组合起来,你的仿真验证效率至少提升一倍。

下一节我们聊聊覆盖率分析,那是验证的「体检报告」,告诉你到底测全了没有。