4、仿真激励编写:时钟与复位生成、数据激励构造、文件读写操作、任务与函数封装

仿真激励,说白了就是给我们的设计“喂”信号。你想想看,一个芯片或者模块,你总得给它时钟、给它复位、给它数据,它才能动起来吧?这部分写得好不好,直接决定了仿真效率高不高、调试顺不顺。我个人习惯是把激励代码写得像“剧本”一样——清晰、可复用、好维护。

4.1 时钟与复位生成——仿真的“心跳”和“开关”

时钟是仿真的基础。没有时钟,时序逻辑就是一潭死水。我见过不少新手直接写 #5 clk = ~clk; 来产生时钟,这当然可以,但不够灵活。

我建议用参数化写法:

`timescale 1ns / 1ps

module tb_top();

parameter CLK_PERIOD = 10; // 时钟周期,单位ns
reg clk;
reg rst_n;

// 时钟生成:永远为真
initial begin
    clk = 0;
    forever #(CLK_PERIOD/2) clk = ~clk;
end

// 复位生成:上电后复位一段时间
initial begin
    rst_n = 0;
    #(CLK_PERIOD * 10); // 复位10个时钟周期
    rst_n = 1;
    #(CLK_PERIOD * 2);
    // 这里可以开始你的测试
end

endmodule
小技巧: 我习惯把时钟周期定义成参数,这样换一个设计,改一个数字就行。另外,复位信号我一般用 rst_n(低有效),这是行业惯例,不容易搞混。

嗯,这里要注意:forever 语句必须放在 initial 块里,否则仿真器会报错。我在项目中遇到过有人把 forever 直接写在 always 块外面,结果仿真器直接罢工了。

4.2 数据激励构造——给设计“喂”点像样的数据

时钟和复位搞定了,接下来就是数据激励。说白了,就是模拟真实场景下的输入信号。我个人习惯把激励分成几类:

  • 固定值激励: 比如给一个寄存器写死 0x5A。
  • 随机激励: 用 $random 生成随机数,模拟不确定的输入。
  • 序列激励: 按照特定时序,依次发送多个数据包。

举个例子,构造一个简单的 AXI 写地址通道激励:

reg [31:0] awaddr;
reg [7:0]  awlen;
reg        awvalid;

initial begin
    // 等待复位结束
    @(posedge rst_n);
    #(CLK_PERIOD * 5);

    // 发送第一个写地址
    awaddr  = 32'h1000_0000;
    awlen   = 8'd3;  // 4个数据
    awvalid = 1;
    @(posedge clk);
    awvalid = 0;

    // 随机发送一些地址
    repeat(10) begin
        #(CLK_PERIOD * $urandom_range(1, 10));
        awaddr  = $urandom;
        awlen   = $urandom % 16;
        awvalid = 1;
        @(posedge clk);
        awvalid = 0;
    end
end
核心思想: 激励不是乱写的。你要想清楚“这个场景在真实芯片里会不会出现?” 我曾经因为随机激励没加约束,生成了一个非法地址,导致仿真跑了一整天才发现问题。后来我学乖了,随机之前先做范围检查。

4.3 文件读写操作——让仿真“读得懂”外部数据

有时候,我们需要把测试数据放在文件里,仿真时读进来。或者把仿真结果写出去,方便用 Python、Matlab 分析。SystemVerilog 提供了 $fopen、$fread、$fwrite 等系统函数,非常好用。

读文件示例:

integer fd;
reg [7:0] mem [0:1023];

initial begin
    fd = $fopen("input_data.txt", "r");
    if (fd == 0) begin
        $display("Error: Cannot open file!");
        $finish;
    end
    // 读取十六进制数据
    for (int i = 0; i < 1024; i++) begin
        if ($fscanf(fd, "%h", mem[i]) != 1) break;
    end
    $fclose(fd);
end

写文件示例:

integer out_fd;
reg [31:0] result;

initial begin
    out_fd = $fopen("output_data.txt", "w");
    // 假设我们每拍产生一个结果
    repeat(100) begin
        @(posedge clk);
        $fwrite(out_fd, "%h\n", result);
    end
    $fclose(out_fd);
    $display("Data written to output_data.txt");
end
注意: 文件路径默认是仿真工作目录。如果你用不同的仿真器(VCS、Questa、Xsim),路径规则可能略有不同。我建议在 testbench 里用路径或者相对路径,并加上错误检查。否则文件没读到,仿真跑得再欢也是白搭。

4.4 任务与函数封装——把重复劳动“包”起来

写仿真最怕什么?最怕重复劳动。比如你要反复发送同一个 AXI 写事务,每次都要写一堆 awaddr、wdata、bready 的时序,烦不烦?

我建议用 task 和 function 把这些操作封装起来。这样你的 testbench 会变得非常干净,就像在写“高级命令”一样。

封装一个 AXI 写事务的 task:

task automatic axi_write(
    input [31:0] addr,
    input [31:0] data,
    input [7:0]  len
);
    // 发送地址
    awaddr  = addr;
    awlen   = len;
    awvalid = 1;
    @(posedge clk);
    awvalid = 0;

    // 发送数据
    for (int i = 0; i <= len; i++) begin
        wdata = data + i;
        wvalid = 1;
        @(posedge clk);
    end
    wvalid = 0;

    // 等待写响应
    @(posedge clk);
    bready = 1;
    @(posedge clk);
    bready = 0;
endtask

然后在 initial 块里调用:

initial begin
    @(posedge rst_n);
    #(CLK_PERIOD * 5);

    axi_write(32'h1000_0000, 32'hAABB_CCDD, 8'd3);
    axi_write(32'h2000_0000, 32'h1234_5678, 8'd0);
    // 想写多少写多少,一行一个事务
end
我的习惯: 把常用的 task 放在一个单独的 include 文件里,比如 axi_tasks.svh。这样多个 testbench 可以共用,改一个地方就行。另外,task 里的 automatic 关键字别忘了,否则递归调用时会出问题。我曾经因为这个 bug 查了整整一个下午。

4.5 本章知识体系总览

下面这张图,是我自己总结的仿真激励编写核心逻辑。你可以把它当作一个“检查清单”,每次写 testbench 时对照一下,看看有没有遗漏。

仿真激励编写核心知识体系 时钟与复位生成 参数化、forever、时序 数据激励构造 固定/随机/序列激励 文件读写操作 $fopen/$fread/$fwrite 任务与函数封装 task/function/automatic 关键实践要点 • 时钟周期参数化,便于复用 • 复位时序要覆盖所有可能的启动场景 • 随机激励必须加约束,避免非法值 • 文件操作一定要做错误检查 • task 封装时加 automatic 关键字 • 常用 task 放到独立 include 文件 • 仿真结果写文件,方便后处理 • 保持激励代码的“可读性”

好了,这一章的内容就这些。时钟和复位是基础,数据激励是核心,文件读写是工具,任务和函数是效率。把这四块练熟了,写 testbench 就跟搭积木一样简单。记住,好的激励代码,不仅能让仿真跑得快,更能让你在调试时少掉几根头发。

可可精品资料库