1. 先从为什么需要testbench说起
干FPGA或者数字IC这行的,谁还没被仿真折磨过。写RTL代码只是第一步,真正花时间的往往是把它测对。我做过的项目里,光调试时间就能占到总工期的百分之六七十,而这其中绝大部分时间都耗在testbench上。一个设计功能正不正确、边界条件有没有覆盖、时序有没有问题,全看你的测试平台写得怎么样。
Verilog的testbench,说白了就是一套人为构造的输入环境。你把待测模块(DUT)往那一放,给它喂时钟、喂复位、喂数据,再观察它的输出是否符合预期。问题在于,很多入门教程只教你写了“always #5 clk = ~clk;”这种最基础的东西。真到了工程里,你会发现光是造数据就够你喝一壶——几百个测试向量手工敲根本不现实,仿真结果几万行波形看得眼冒金星。这时候就体现出文件读取和写入操作的用处了。
文件操作是testbench进阶的第一道门槛,也是我今天想重点展开的部分。用好了$readmemh和$fopen这一组系统任务,你的仿真效率能翻好几倍。读文件可以把大批测试向量灌进仿真,写文件可以把仿真结果导出来跟理论值做比对,这就是自动化验证的雏形。
这篇博文适合正处于入门到进阶过渡阶段的Verilog开发者看。你已经能写简单的模块和testbench,但面对复杂激励生成、批量数据处理、结果自动比对这些场景还觉得缺手脚。接下来我按照自己做项目时的习惯,把testbench设计思路、文件读写源码、常见坑一次讲透。
2. testbench整体设计思路拆解
2.1 testbench到底在干什么
从功能上讲,testbench就是在仿真环境下构造一台“测试仪器”。它要完成的事情可以拆成四块:
- 产生激励:时钟、复位、数据信号,让DUT进入工作状态
- 监测响应:观察DUT的输出信号是否符合预期
- 判断对错:把实际输出和期望值比较,报错或者打印结果
- 记录与报告:把关键数据、错误信息、覆盖率信息记录下来,方便分析
这四件事,互相之间要解耦,不要写成一锅粥。我见过不少人喜欢把激励生成和结果判断写在同一段initial块里,看起来省事,但一旦测试用例变多,就完全没法维护,改一个信号可能牵连一片。
正确的做法是把testbench当成一个软件工程来组织。激励生成独立成块,结果检查独立成块,公共操作封装成task。尤其是多组测试数据的时候,用task把“复位→发配置→发数据→等待响应→检查结果”这个流程包起来,每个用例只改参数,不碰逻辑框架。
2.2 好testbench的三个标准
写了几年testbench,我总结出来,判断一个testbench写得好不好就看三点:
可复用性。换个DUT的端口参数,testbench改几行就能用。这要求端口例化整齐、参数用parameter定义、公共操作全进task。
可读性。三个月之后再翻开你的testbench,还能不能一眼看懂每个块在干什么。命名规范、注释到位、模块划分清晰,这都很重要。千万不要图省事写一大坨always块,那是一种灾难。
可观测性。出了错,testbench要能快速定位。波形上打标记、仿真终端打印信息、自动保存出错时的上下文数据,这些机制都要有。只靠肉眼盯波形找bug,在小工程里还能凑合,工程一大就废了。
另外补充一个容易被忽视的点:testbench本身不要被综合。它只服务于仿真,所以可以随便用initial、task、文件操作这些不可综合的语法。但反过来也提醒你,千万别把仿真通过当成板上能跑,两者之间还有一条综合和时序的鸿沟需要跨越。
2.3 常见testbench结构模板
我自己写testbench的固定套路是这样的,你可以做参考:
- 模块名统一叫
tb_加DUT名字,比如DUT叫uart_top,testbench就叫tb_uart_top - 开头是
timescale声明,固定用`timescale 1ns/1ps,精度设高一点,避免仿真时序误差 - 然后是端口定义,testbench一般没有端口,内部信号驱动DUT
- 接着是DUT例化,用名字连接,不用位置连接
- 然后是时钟和复位生成块
- 然后是激励发送块,多用task封装
- 最后是结果监测和比对块
3. 时钟与复位激励的生成细节
3.1 时钟生成别只会写always
最基础的50MHz时钟生成长这样:
`timescale 1ns/1ps parameter CLK_PERIOD = 20; // 50MHz reg clk; initial clk = 0; always #(CLK_PERIOD/2) clk = ~clk;这里其实暗藏一个问题:如果CLK_PERIOD是21呢?21除以2在整数运算里得到10,实际周期就是20ns而不是21ns。所以我习惯把半个周期也定义成参数,写法是这样的:
parameter CLK_HALF_PERIOD = 10; // 半周期10ns always #CLK_HALF_PERIOD clk = ~clk;还有一种更稳妥的做法,用forever循环放在initial块里,好处是能把时钟初始化和时钟翻转放在一起:
initial begin clk = 0; forever #CLK_HALF_PERIOD clk = ~clk; end对于多时钟系统,我的建议是每个时钟单独写一个initial块,并且给它们加上相位偏移和抖动模拟——当然,这是后话,初学阶段先保证频率正确就行。
3.2 复位的正确给法
复位信号看起来简单,实际上有讲究。首先复位要异步有效、同步释放,这是数字设计里的常识。其次在testbench里,复位的时序要保证DUT能正确响应。
我常用的复位模板:
initial begin rst_n = 0; #(CLK_PERIOD * 10); rst_n = 1; #(CLK_PERIOD * 2); end先拉低复位至少10个时钟周期,让DUT内部所有的寄存器都稳定复位;然后拉高,再等两个周期,让DUT从复位状态完全走出来再开始灌激励。这个“多等两拍”的习惯帮我避免了很多莫名其妙的初值问题。
如果你想做复位时序的边界测试,可以试试在不同的时钟沿附近拉高复位,看看DUT会不会出现亚稳态——虽然仿真环境不能精确模拟亚稳态,但功能上能看到复位释放时机对状态机跳转的影响。
3.3 用task封装激励流程
在testbench里,task是我用得最多的语法结构。它能把“给一组数据,等你准备好,再给下一组”这种交互式操作封装起来,调用起来跟写测试用例一样清爽。
假设DUT是一个简单的FIFO,我要往里面写数据,task可以这么写:
task write_fifo; input [7:0] data; begin @(posedge clk); wr_en = 1; wr_data = data; @(posedge clk); wr_en = 0; end endtask同理,读FIFO的task也可以封装。这样在initial块里就可以很直观地写测试流程:
initial begin reset_task(); write_fifo(8'hAA); write_fifo(8'h55); read_fifo(); // ... end有人说task有点像C语言的函数,但这里要说清楚:task和function有一个关键区别,task可以包含时序控制(#延迟、@事件等),function不行,function必须在一个仿真时间步内完成。所以凡是涉及时序的激励操作,一律用task;纯粹的计算和查表,才考虑function。
4. 文件读取与写入的系统任务详解
4.1 文件操作在testbench里解决什么问题
先想一个场景:你要给一个通信模块灌10000个随机数据包,每个包512字节。手工在代码里写数组?不现实。把数据放在文件里,用$fscanf读进来,仿真结束再把输出写到另一个文件,这就是标准做法。
再比如你要测一个图像处理模块,输入是一张灰度图像的像素值。把图像转成十六进制文本,一行一个像素值,仿真时按行读入,处理完之后再把输出反写回文本,用Python脚本转成图像对比效果,整个闭环就打通了。文件操作的价值在于,它把仿真的输入输出从“手工编辑代码”解放成“外部数据驱动”。
还有一个容易被忽略的场景:覆盖率分析。如果你需要统计仿真中跳过了哪些状态,可以在关键节点往文件里写标记,仿真结束后再统一分析。比起手动翻波形,这要高效得多。
4.2 $readmemh和$readmemb
这两个系统任务主要用于把文件中的数据加载到内存数组里。它们最经典的用途是初始化ROM和RAM模型,或者灌入测试向量。
基本语法:
reg [7:0] mem [0:255]; initial $readmemh("data.hex", mem);含义是把data.hex文件里的十六进制数据按地址顺序读入mem数组。如果文件里的数据个数小于数组深度,剩下的位置保持原值(通常是x或者0,取决于声明时的初始化)。
$readmemb的用法完全一样,只是文件里的数据是二进制表示。二进制的文件可读性差,占空间大,我几乎不用;十六进制文件才是主流。你要是处理的是纯数据流,用$readmemh就够了。
还有一种带地址的用法:
$readmemh("data.hex", mem, 16'h0100);从指定的地址开始加载,这在做存储器初始化和局部更新的时候很有用。另外,文件里也可以显式写地址,比如:
@0000 AA @0010 55这样对应的数据会放到指定的地址,灵活性更高。
4.3 $fopen、$fdisplay和$fwrite
$readmemh负责把文件读进仿真,那反方向,把仿真结果写出来就要用到$fopen配合$fdisplay或者$fwrite。
$fopen的返回值是一个整数句柄,也就是文件的描述符。如果打开失败,返回0。写代码时检查一下这个返回值是个好习惯:
integer fd; initial begin fd = $fopen("output.txt", "w"); if (fd == 0) begin $display("Error: cannot open file!"); $finish; end end打开模式上,Verilog也支持类似C语言的模式字符串:"w"是写模式,会清空原有内容;"a"是追加模式;"r"是读模式。
$fdisplay和$fwrite的区别在于,$fdisplay会自动换行,$fwrite不会。所以如果你想在一行里连续写多个数据,可以连续用$fwrite,最后补一个$fdisplay。举个小例子:
$fwrite(fd, "%h ", mem_data); $fwrite(fd, "%h ", addr); $fdisplay(fd, ""); // 换行4.4 $fscanf与文本解析
读文件时,如果文件内容是规则排列的数字,$fscanf是最高效的方式。它按照格式化字符串从文件里解析数据,用法跟C语言几乎一样。
integer fd_r; integer status; reg [7:0] data_in; initial begin fd_r = $fopen("stimulus.txt", "r"); while (!$feof(fd_r)) begin status = $fscanf(fd_r, "%h\n", data_in); // 这里把data_in送给DUT end $fclose(fd_r); end值得留意的是$feof这个函数,它用来判断文件是否读到末尾。每读一行就判断一次,循环结束的条件才不会出错。status变量接收$fscanf的返回值,表示成功解析的数据项个数,如果没读到数据,它会返回0或者负数。很多问题就是从这里冒出来的,文件里多了一个空行、注释或者BOM头,$fscanf的行为都可能和预期不一样。
4.5 $fclose和其他文件任务
用完文件一定要记得关闭,$fclose(fd)。在Windows环境下不关文件可能导致文件被占用、内容没刷到磁盘;而在Linux环境下,长时间不关也可能积累大量的文件描述符,尤其是循环里反复打开文件的情况。就我踩过的坑来说,仿真跑了大半天,最后发现输出文件里没写全,多数情况就是$fclose漏了或者放错了位置。
另外还有一组常用的系统任务值得了解:$fmonitor、$fstrobe和$fdisplay的区别。$fmonitor在参数列表中的任何信号发生变化时都会自动打印一行当前值,适合观察关键总线的实时变化;$fstrobe则在当前仿真时间步结束时打印,能拿到这个时间点事件稳定后的值。真正用到这些高级功能的时候不多,但有备无患。
4.6 文件路径的坑
初学的时候我经常遇到文件读不了、写不出的情况,最后发现全是路径问题。最稳妥的解决办法是使用绝对路径,或者把文件放在仿真运行目录下,用相对路径。
在仿真工具里,工作目录通常是工程文件的存放目录。如果你用Vivado,仿真文件在工程目录下的sim_1目录;如果用ModelSim/Questa,工作目录通常是启动仿真时所在的目录。这个一定要搞清。
如果要打印当前的工作路径,嗯,可以加一行$display看仿真的实际工作目录,例如输出版本信息的同时打印当前路径,这样能快速排查路径错位。不过不同仿真器写法略有差异,我就点到为止。
5. 核心参数与格式控制的实操细节
5.1 格式符对照
文件读写里,格式符是最容易写错的地方。我整理了一份常用对照表给各位收藏:
| 格式符 | 含义 | 示例输出 |
|---|---|---|
| %h / %H | 十六进制 | 1f 或 1F,取决于大小写 |
| %d / %D | 十进制 | 31 |
| %b / %B | 二进制 | 11111 |
| %o / %O | 八进制 | 37 |
| %c | ASCII字符 | 空格 |
| %s | 字符串 | hello |
| %f | 实数浮点 | 3.141593 |
| %e | 科学计数 | 3.141593e+00 |
| %m | 层级路径 | tb_top.u_dut |
写文件时,%0d表示不补零的十进制,适合做可读性好的记录文件。%t可以结合$time输出时间戳,但输出格式受仿真器设置影响。建议如果审核日志要跨工具使用,就自己规范化时间戳格式。
5.2 十六进制数据的位宽处理
$fscanf和$fdisplay在解析十六进制数据时,有一个默认规则:读入的数据宽度取决于目标变量的位宽。如果你的目标变量是8位,文件里写的是1A3,那$fscanf只会截取低8位,也就是A3,高位1丢掉。反过来,如果文件里写的是A,读入8位变量,结果是0A。
这一点在做测试向量注入时特别重要。比如你要往一个32位的寄存器写配置值,文件里写的是十六进制,那就一定要保证变量位宽是32位,否则数据会被截断,问题还特别难发现。
5.3 数据生成和比对的常用套路
在实际项目中,我最常用的文件读写组合是这三步:
- 用Python或者MATLAB脚本生成测试向量,保存为十六进制文本
- 用$readmemh或者$fscanf把向量灌进仿真
- 仿真结果用$fdisplay写文件,再用Python脚本做比对
这套流程几乎是通用标准。关键是做好数据格式约定,比如每个测试向量占几字节、大端还是小端、有没有包头包尾。定义好之后,脚本和testbench各写各的,对接不费力。
6. 可直接参考的完整源代码示例
6.1 示例一:用$readmemh做ROM模型
这里我用一个简单的查表ROM来做演示,这个模块在数字信号处理里很常见,比如波形发生器的查找表。
`timescale 1ns/1ps module rom_lut ( input wire clk, input wire [7:0] addr, output reg [15:0] data ); reg [15:0] mem [0:255]; initial begin $readmemh("lut_data.hex", mem); end always @(posedge clk) begin data <= mem[addr]; end endmodule对应的testbench:
`timescale 1ns/1ps module tb_rom_lut; reg clk; reg [7:0] addr; wire [15:0] data; rom_lut u_dut ( .clk(clk), .addr(addr), .data(data) ); initial begin clk = 0; forever #5 clk = ~clk; end initial begin addr = 0; #30; for (int i = 0; i < 8; i++) begin addr = i; #10; $display("addr=%0d data=%h", addr, data); end #10; $finish; end endmodule注意ROM的读操作在时钟上升沿之后才会更新输出,所以$display打印data的时候看到的还是上一拍的地址对应的数据,这是流水线效应的体现。我在最初做仿真的时候经常在这里困惑,先给各位打个预防针。
6.2 示例二:用$fscanf把激励从文本读入并注入DUT
这个示例模拟的是一个UART接收模块的测试,我从文件里按行读入十六进制字节,把它作为串行数据发给DUT的rx引脚。
`timescale 1ns/1ps module tb_uart_rx; parameter CLK_PERIOD = 20; parameter integer BAUD_DIV = 50; // 假设50个时钟周期对应一个bit reg clk, rst_n; reg rx; wire [7:0] rx_data; wire rx_done; uart_rx u_dut ( .clk(clk), .rst_n(rst_n), .rx(rx), .rx_data(rx_data), .rx_done(rx_done) ); integer fd; reg [7:0] byte_data; integer scan_status; initial begin clk = 0; forever #(CLK_PERIOD/2) clk = ~clk; end task send_byte; input [7:0] data; integer i; begin // 起始位 rx = 0; #(CLK_PERIOD * BAUD_DIV); // 8个数据位,LSB first for (i = 0; i < 8; i++) begin rx = data[i]; #(CLK_PERIOD * BAUD_DIV); end // 停止位 rx = 1; #(CLK_PERIOD * BAUD_DIV); end endtask initial begin rst_n = 0; rx = 1; #(CLK_PERIOD * 10); rst_n = 1; #(CLK_PERIOD * 2); fd = $fopen("tx_data.txt", "r"); if (fd == 0) begin $display("Open tx_data.txt failed!"); $finish; end while (!$feof(fd)) begin scan_status = $fscanf(fd, "%h\n", byte_data); if (scan_status > 0) begin $display("Send byte: %h", byte_data); send_byte(byte_data); wait(rx_done == 1); #(CLK_PERIOD); end end $fclose(fd); #100; $finish; end endmodule这个例子有几个细节值得说明。$fscanf用%h\n,要求文件里每一行恰好一个十六进制数,结尾换行。如果文件里有空行,scan_status可能是0,代码里判断scan_status > 0来避开。wait(rx_done == 1)的意思是等UART接收完成,这是握手式的等待方式,比盲目延迟靠谱得多。
6.3 示例三:用$fdisplay导出仿真结果
有输入就要有输出。这是一个简单的计数器模块的testbench,仿真结束后把每一拍的计数值写入文件。
`timescale 1ns/1ps module tb_counter; parameter CLK_PERIOD = 10; reg clk, rst_n; wire [7:0] count; counter_8bit u_dut ( .clk(clk), .rst_n(rst_n), .count(count) ); integer fd; initial begin clk = 0; forever #(CLK_PERIOD/2) clk = ~clk; end initial begin fd = $fopen("counter_result.txt", "w"); if (fd == 0) begin $display("Open file failed!"); $finish; end rst_n = 0; #(CLK_PERIOD * 5); rst_n = 1; repeat(20) begin @(posedge clk); #1; // 避开时钟沿附近的跳变 $fdisplay(fd, "%0t ns count=%0d", $time, count); end $fclose(fd); $finish; end endmodule这里有两个常见问题要提示一下。第一,#1的作用是让采样时刻落在时钟沿之后1ns,避免数据还在变化时采样,这是数据采样中的标准操作。第二,%0t配合$time输出的是仿真时间,格式如200 ns,具体显示跟仿真器设置有关,但不影响数据的准确性。
从工程角度讲,这个结果文件可以直接丢给脚本画波形图或者做自动比对,完全不需要手工复制粘贴仿真波形数据。
6.4 示例四:实现仿真结果自动比对
最后我来给一个自动化比对的小demo,这也是我强烈推荐的用法。仿真的意义在于发现bug,如果每一次都要人工去翻波形、对数据,效率太低。自动比对的核心思想是:在testbench里就建立期望模型,把DUT输出和期望值做对比,不一致就报错。
`timescale 1ns/1ps module tb_auto_check; parameter CLK_PERIOD = 10; reg clk, rst_n; wire [7:0] dout; dut_module u_dut ( .clk(clk), .rst_n(rst_n), .dout(dout) ); integer error_cnt; reg [7:0] golden_ref [0:255]; reg [7:0] actual_data; initial begin error_cnt = 0; $readmemh("golden_result.hex", golden_ref); end initial begin clk = 0; forever #(CLK_PERIOD/2) clk = ~clk; end initial begin rst_n = 0; #(CLK_PERIOD * 5); rst_n = 1; for (int i = 0; i < 256; i++) begin @(posedge clk); #1; actual_data = dout; if (actual_data !== golden_ref[i]) begin $display("Error at step %0d: actual=%h expected=%h", i, actual_data, golden_ref[i]); error_cnt = error_cnt + 1; end end if (error_cnt == 0) $display("All tests passed!"); else $display("Tests failed: %0d errors", error_cnt); $finish; end endmodule注意我在这里用了!==而不是!=。这个细节很关键。!==是case不等比较,对于x和z状态也敏感。如果你用的是!=,当dout中出现x态时,比较结果可能是假(仿真器把x当作未知,不等于判断在某些情况下会返回假),导致漏报错误。用!==可以确保任何位不确定都会被检测出来。
自动比对的好处是回归测试时特别明显。你改了一版RTL代码,把之前的testbench原封不动跑一遍,五分钟内就知道有没有引入新问题。没做自动比对的项目,靠人工看波形找差异,往往改一个bug又崩一个功能,搞得人心态爆炸。
7. 常见问题与排查技巧实录
7.1 文件打不开
现象:$fopen返回0,仿真器报错或文件内容写入失败。
排查思路:
- 确认文件是否存在于仿真工作目录
- 确认相对路径是否正确,必要时用绝对路径
- 确认文件权限,Linux下尤其容易碰到读权限不足
- 确认仿真器是否限制了文件访问范围
我的经验:Vivado仿真默认工作目录和波形文件目录通常不是同一个,最稳的方法是用绝对路径。写代码的时候可以这样处理:
`define SIM_PATH "D:/fpga_project/sim/" fd = $fopen(`SIM_PATH + "output.txt", "w");不过绝对路径有个问题:换一台电脑就失效。所以我一般把文件放在工程目录的sim文件夹下,用仿真的工作目录去定位。
7.2 文件读出来数据全是0或者x
现象:$readmemh把数据读进数组,仿真时发现数据全是0或x。
排查思路:
- 确认文件里是不是真的有数据,可以先
$display打印几行 - 确认文件格式和数据位宽是否匹配
- 确认
$readmemh的起始地址是否和数组索引保持一致 - 确认文件末尾是否有不可见字符(比如UTF-8 BOM头)
我的经验:在Windows上编辑的十六进制文件经常带BOM头,仿真器解析的时候会把BOM头当作数据的一部分,导致第一行数据读错。解决办法是文件另存为UTF-8 without BOM格式,或者用Linux下的工具去除BOM。
7.3 $fscanf数据读取错位
现象:文件明明有10行数据,循环里只读到8行;或者读出来的数据跟文件对不上号。
排查思路:
- 在循环里打印
scan_status的值,看每次$fscanf返回几个数据 - 确认格式字符串和文件内容完全匹配,包括空格、换行、制表符
- 如果文件里有注释或者空行,格式字符串写死了会导致解析失败
我的经验:$fscanf处理带分隔符的数据时容易踩坑。如果文件格式是逗号分隔,格式串要写%h,%h;如果数据之间有多个空格或制表符,尽量用%s跳过非数字部分,或者先统一做数据预处理,保证生成文件和解析代码的格式约定清晰一致。
7.4 仿真结束但文件内容为空
现象:testbench正常跑完,打开输出文件发现内容为空或只写了几行。
排查思路:
- 检查
$fclose是否执行到 - 检查
$fdisplay是否被执行到,可以用$display同时打印到屏幕验证 - 确认仿真是否正常结束,还是中途被
$finish截断
我的经验:仿真器对文件的写缓冲不一样。有的仿真器缓冲数据直到文件关闭才全部写盘,如果你的testbench忘掉$fclose,仿真一结束缓冲就丢了。所以我在每个写文件的任务末尾都会单独调用$fclose,而不是等到仿真最后统一关闭。如果确实需要多个地方持续写同一个文件,就用"a"追加模式打开关闭,虽然效率略低,但至少不会丢数据。
7.5 时间单位和文件时间戳对不上
现象:打印的时间戳和波形上看到的时间差了好几个数量级,或者时间显示不合理。
排查思路:
- 检查`timescale设置,组合里的单位和精度
- 检查
$time、$realtime的区别,$time是64位整数时间,$realtime是实数时间 - 不同模块的`timescale不一致可能导致时间基准错乱
我的经验:整个工程的`timescale尽量统一,尤其testbench里一定要显式声明。如果工程里确实需要混合精度,至少保证testbench的精度不低于DUT的精度,否则采样毛刺和时序模糊会让调试变得特别痛苦。
7.6 仿真速度特别慢
现象:加上文件读写之后仿真变慢几个数量级。
排查思路:
- 确认是不是在always块里频繁打开关闭文件
- 确认是不是
$display打印过于频繁,屏幕输出也是耗时大户 - 确认是不是写文件的数据量过大,尽量减少格式转换开销
我的经验:每拍都打印一次$display在数据量大的仿真里是很大的负担。建议把数据写到文件里,屏幕只打印关键信息,比如错误报告和进度节点。另外,能在事务级(transaction level)打印就不要在信号级打印,数据量会差一个量级。
8. 关于仿真的小事记和扩展方向
做仿真这几年,我自己最大的体会是:testbench不是DUT的附属品,它本身就是一道独立的工程活。文件读写这套东西看着简单,真要在项目里稳定跑起来,其实需要不少细心。比如数据格式约定的文档化,比如自动化脚本的比对流程,这些测试平台之外的工作往往才是效率提升最大的部分。
还有一个方向值得拓展:UVM验证方法学。它本质上也是把testbench的组件化发挥到极致——sequence产生激励,driver驱动信号,monitor采集数据,scoreboard做比对,参考模型算期望值。如果你把文件读写玩熟了,再去看UVM的那些组件,会发现思路是相通的,只不过后者封装得更系统化、更标准化。这也是从写testbench进阶到专业验证工程师的必经之路。
另外提一句,不同仿真器对文件系统任务的支持有细微差别。Vivado自带的xsim、ModelSim/QuestaSim、以及开源的Icarus Verilog我都用过,$readmemh、$fopen这些基础任务都能跑,但像$fscanf的某些格式化细节、大文件读写性能,各家还是有差异。写testbench时尽量用通用的、跨仿真器兼容的语法,这样你的代码才能在不同的工具间无缝切换。
最后,如果你刚开始接触Verilog仿真,别怕文件系统任务把这些代码搞复杂。大胆去看例子、去调试,把文章里的代码跑通一遍,再自己改成适合项目的写法。仿真的坑踩多了,自然也就摸到规律了。下一步,可以考虑把testbench封装成可重用的验证组件,搭配Makefile或者Tcl脚本构建自动回归环境,那时候你已经不是单纯的RTL开发,而是具备完整验证思维的工程师了。