ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Verilog testbench文件读写实战:从零搭建自动化仿真验证环境

Verilog testbench文件读写实战:从零搭建自动化仿真验证环境 做FPGA验证做了快十年我越来越觉得testbench里的文件读写是衡量验证工程师熟练度的一道分水岭。刚入门那阵我也习惯在testbench里写死几十个激励向量仿真一跑$display一打看着波形觉得万事大吉。直到有一次遇到一个需要输入2000组图像像素数据、输出还要逐帧和Matlab参考结果对比的模块手写激励写到崩溃这才老老实实把文件读取和写入捋了一遍。作为“Verilog十大基本功”系列的第二篇这篇文章专门说清楚testbench的设计框架以及文件读取、文件写入操作中那些真正能上工程的写法所有核心代码都会给足方便直接参考。1. 为什么testbench里离不开文件读写1.1 仿真验证中的三层数据需求一个项目只要稍微做深一点验证数据基本可以分成三层激励数据、结果数据、日志数据。激励数据是喂给DUT的输入可能是一段滤波器的系数、一幅图像的像素矩阵、一条UART的报文序列也可能是随机种子结果数据是DUT的输出需要保存下来和软件模型对比日志数据则是仿真过程中的错误、计数、状态跳转记录用于自动化回归和现场排查。没有文件操作的时候这三类数据全都塞在testbench代码里。500个测试向量手写在initial块里改一个数据要重新编译输出结果只能靠波形和$display人工盯一旦仿真跑几百万拍人眼基本盯不过来。引入文件读写之后激励文本放在外部文件里testbench只负责读取和执行输出结果按固定格式写回文件最后用diff脚本一比对验证结论一目了然。这才是工程级验证该有的样子。1.2 文件操作到底省掉了哪些事我在实际项目中感受到最明显的好处是数据与逻辑分离。比如验证一个图像缩放模块激励文件里放300行像素数据testbench代码不需要改只要把文件路径换一下就能换成另一组数据回归。同样的testbench还可以跨越项目复用只要接口一致换一个文件就是一套新用例。文件操作还能解决“数据量不可控”的问题。手写激励时每条激励都是在代码里占一行的1000条还能忍10000条就非常痛苦。而文件读取时while循环配$feof就能把任意长度的文件读完数据再多也只是文件大小的问题。甚至可以用Python或Matlab预先生成激励文件在testbench里统一加载这就把验证工程师从机械劳动里解放了出来把精力放到断言、覆盖率这些更值得投入的地方。2. testbench的基础骨架时钟、复位和激励怎么组织2.1 一个能跑的testbench最小模型文件读写不是独立存在的东西它一定长在testbench骨架上。所以在讲文件操作之前我先把testbench的常规结构过一遍。一个最基础的testbench包含时钟生成、复位控制、DUT例化、激励执行四部分下面这段代码可以直接拿去做最小模板timescale 1ns/1ps module tb_basic(); reg clk; reg rst_n; reg [7:0] din; wire [7:0] dout; wire valid; // 10ns周期的时钟 initial clk 0; always #5 clk ~clk; // 复位和激励 initial begin rst_n 0; din 8h00; #20; rst_n 1; #20; din 8hA5; #100; $finish; end // DUT例化 dut_basic u_dut( .clk (clk), .rst_n (rst_n), .din (din), .dout (dout), .valid (valid) ); // 观察输出 initial begin $monitor(time%0t rst_n%b din%h dout%h valid%b, $time, rst_n, din, dout, valid); end endmodule时钟部分我用了一个initial clk 0加always #5 clk ~clk的写法10ns周期20ns一个完整脉冲配合timescale 1ns/1ps整个仿真时间单位就是1ns。复位释放的时间点通常选在时钟上升沿之后避免在时钟沿附近释放复位造成亚稳态性的仿真假象所以我给了20ns的余量。2.2 用task封装重复激励时序testbench里最忌讳把同样的时序反复写在文件读取循环里。比如写总线操作每次都要等时钟沿、拉高写使能、送数据、再拉低写使能这些重复动作应该封装成task文件读取部分只管调task代码会清爽很多。下面是一个典型的写任务task write_bus; input [7:0] addr; input [7:0] data; begin (posedge clk); #1; // 入沿后延迟1ns避开竞争 cs_n 0; wr_n 0; addr_out addr; data_out data; (posedge clk); #1; wr_n 1; cs_n 1; end endtask这里有两个细节值得注意。一是在时钟沿后加#1延迟避免testbench在时钟沿处与DUT的同时变化产生事件竞争二是task里只做信号驱动不做判断判断放在调用方这样task的可复用性更高。如果读文件时每行数据都代表一次总线写那么在文件读取循环里只需要一行write_bus(addr, data);非常直观。2.3 initial与always的配合套路testbench里有多个initial和always块时它们是并行执行的并不是从上到下顺序执行。很多初学者以为initial块之间像C语言那样有先后关系其实每个initial都在时间0开始启动它们通过事件、延迟和信号相互配合。文件读取逻辑写在initial里时钟放在always里两者并行跑完全没问题。不过要留意一点initial块里的循环读取激励如果遇到$finish整个仿真会终止不管always时钟还在不在。所以在写文件读取循环时一定要确保所有激励都处理完、所有文件都关闭之后再调用$finish。我在自己的代码里习惯把$finish放在文件操作循环之后而不是放在某个固定时间延时之后这样既不会提前结束也不会因为没有终止条件导致仿真永远停不下来。3. 文件读取把外部数据喂进仿真3.1 用$readmemh/$readmemb给存储器和查表加载初值文件读取最常用的一招就是$readmemh和$readmemb专门用来把文件里的数据加载到memory数组。$readmemh读取十六进制$readmemb读取二进制语法一样reg [7:0] mem [0:255]; initial begin $readmemh(mem_init.hex, mem); end文件内容是纯文本每行一个数据允许用//写注释也允许用空白字符分隔。比如mem_init.hex可以写成这样// 前四个字节是测试向量 00 A5 5A FF这个写法最常见的用途是RAM初始化、ROM查找表、FIR滤波器系数、指令存储器初始化。相比于在代码里用initial加repeat循环赋值外部文件的优势是修改系数不需要改testbench尤其在做算法验证时滤波器系数天天调把系数放文件里是唯一理性的选择。需要注意如果文件里的数据个数少于memory数组长度剩余空间保持x如果多于数组长度仿真器会报warning多余数据被丢弃。$readmemh还有一个隐藏技能它能指定加载的起始和结束地址。例如$readmemh(data.hex, mem, 10, 20);只把文件数据写到地址10到20之间。这个功能在装载指令文件时非常实用可以灵活控制数据落到存储器的哪一段不需要把整个数组都铺满。3.2 用$fopen $fscanf逐行读取文本激励如果激励不是简单的存储器初值而是一组带有格式的总线操作就需要$fopen配合$fscanf来逐行解析。$fscanf的用法和C语言的fscanf高度相似用格式控制符去匹配文件内容返回成功转换的项数。下面是我常用的读激励文件模板integer fd; integer ret; reg [7:0] addr; reg [7:0] wdata; initial begin fd $fopen(stimulus.txt, r); if (fd 0) begin $display(FATAL: cannot open stimulus.txt); $finish; end while (!$feof(fd)) begin ret $fscanf(fd, %h %h, addr, wdata); if (ret 2) begin write_bus(addr, wdata); end // 每个操作之间留一点间隔模拟真实总线节奏 #10; end $fclose(fd); $finish; endstimulus.txt里每行两个十六进制数例如00 A5 01 5A 02 FF$fscanf会自动跳过空白字符逐行读取直到返回EOF。这里最关键的是检查返回值ret 2说明这次成功解析出两个数。如果不检查返回值文件末尾多了一个空行某些仿真器会返回-1数据保持上一次的值很容易产生一个虚假的重复激励。这个坑我踩过不止一次所以现在所有$fscanf代码都必查返回值。3.3 用$fgets处理不定长文本指令有时候文件里的内容不是规整的“地址数据”而是带有命令字、逗号、括号的复杂描述。比如一台验证环境里有一份测试用例文档每行可能是一条命令命令后面带若干参数参数个数还不固定。这时候$fscanf虽然也能解析但不如$fgets先读整行再解析灵活。$fgets的用法如下integer fd; integer ret; integer str_len; reg [8*256-1:0] line; reg [8*64-1:0] cmd; reg [7:0] data; initial begin fd $fopen(commands.txt, r); while (!$feof(fd)) begin // 只读一行line最大255字符 ret $fgets(line, fd); if (ret 0) break; // 解析命令和参数 ret $sscanf(line, %s %h, cmd, data); if (ret 1) begin case (cmd) write: write_bus(data); read: read_bus(data); wait: #data; default: $display(unknown cmd: %0s, cmd); endcase end end $fclose(fd); end$sscanf是从字符串变量里解析格式作用和$fscanf从文件解析是一样的。把文本行读取和字符串解析分开最大的好处是可以先对整行做预处理比如删掉空行、过滤注释、判断命令再去提取关键数据。这种写法在构建“脚本化测试序列”时非常强大测试人员只需要在文本里写语义化指令testbench自动翻译引脚时序。3.4 文件结束判断和打开失败处理文件读取循环如果没有$feof很容易出现读到EOF之后还继续执行的情况。标准写法是while (!$feof(fd))但要注意$feof只有在尝试读取越过文件末尾之后才会置位所以严格来讲循环体会多执行一次。更稳妥的方法是同时检查读取返回值我习惯在while循环里面用$fscanf或$fgets的返回值break而不是只依赖$feof。上面的例子里都用到了这种“双保险”思路可以避免末尾空行造成的诡异行为。打开文件失败的判断同样重要。有些仿真器在$fopen失败时会打印warning但不会终止仿真此时fd的值可能是0或一个无效句柄。后面对这个fd做$fscanf仿真器要么静默忽略要么反复报错。所以我在每次打开文件后都会立即判断if (fd 0) begin $display(FATAL: cannot open file); $finish; end这个判断放在文件读取代码的入口能在第一时间暴露路径错误、文件名错误、权限不足等问题省得后面查半天查不到原因。4. 文件写入让结果从仿真里落盘4.1 $fopen/$fwrite/$fdisplay的完整写法结果落盘和读取一样先用$fopen拿到句柄再用$fdisplay或$fwrite写内容最后$fclose关闭。区别在于打开模式读取用r写入用w追加用a。w会覆盖旧文件a会在已有文件末尾追加回归测试时如果希望保留上一次的日志就用a。integer result_fd; initial begin result_fd $fopen(result.log, w); if (result_fd 0) begin $display(FATAL: cannot open result.log); $finish; end // 带换行写一行 $fdisplay(result_fd, time%0t din%h dout%h, $time, din, dout); // 不换行写一段 $fwrite(result_fd, part1 ); $fwrite(result_fd, part2\n); $fclose(result_fd); end$fdisplay每写一次自动带换行适合写一条记录$fwrite不自动换行适合拼接内容和写二进制片段。日常验证中$fdisplay用得更多因为输出文件按行解析最简单。注意$fclose不是可选的文件句柄是仿真器里的资源长时间不关闭会导致后续$fopen失败。4.2 $fmonitor自动追踪信号变化除了手动在指定时刻写文件Verilog还提供$fmonitor这个自动追踪工具。一旦调用它会在监视列表里的信号发生变化时自动把格式化信息写入文件非常适合记录总线活动或关键状态跳转integer trace_fd; initial begin trace_fd $fopen(trace.log, w); $fmonitor(trace_fd, %0t: wr_en%b rd_en%b din%h dout%h, $time, wr_en, rd_en, din, dout); end$fmonitor在整个仿真期间都会生效只要wr_en、rd_en、din、dout中任何一个值变化就追加一行记录。这个功能用来抓“什么时候哪个信号变了”特别好用省去了自己写一堆always 去采集信号。但要注意$fmonitor对性能有一定影响信号变化越频繁写文件越频繁不适合在超大规模仿真里全程开着。我的习惯是只在调试阶段临时打开正式回归时关掉用条件记录替代。4.3 格式化输出与时间戳记录仿真结果文件如果没有时间戳后面比对的时候会非常被动。因为你要知道某条数据是在第几个周期产生对应的是哪一条激励。所以我在写结果文件时基本都会把$time和仿真时间单位带进去$fdisplay(result_fd, [%0t] write addr%h data%h, $time, addr, data);%0t会以不带前导空格的形式打印时间配合timescale显示为ns或ps单位。有的场景还想打印相对周期数可以先定义reg [31:0] cycle_cnt在每个时钟上升沿自增再把cycle_cnt写入文件比单纯时间戳更直观。如果希望输出文件能用Excel直接打开可以考虑用逗号分隔字段比如$fdisplay(result_fd, %0t,%h,%h, $time, din, dout);这种CSV风格的输出后面用Python做数据分析会非常顺手。4.4 文件句柄与多文件管理一个稍微复杂的testbench往往要同时打开多个文件一个激励文件、一个结果文件、一个覆盖率日志、一个错误报告。每个文件都用一个integer变量保存句柄名字起清楚不要混用。我见过有同事在多个initial块里用同一个文件句柄变量结果两个initial块来回切换把文件写串了。多initial并发操作同一个句柄时Verilog的调度顺序不确定极容易造成记录顺序错乱。如果确实需要多个模块往同一个文件里写日志建议把写文件操作集中到一个task里用task内部的互斥机制或者干脆让所有日志都汇总到一个agent线程里处理。对于新手来说最简单可靠的方案是每个文件对应一个句柄每个句柄只在一个initial块中使用写完立即$fclose。这样虽然代码多一些但排查问题时会非常省心。5. 实战源代码一个带文件读写的FIFO testbench5.1 被测对象与验证方案前面的概念讲完接下来用一个完整的同步FIFO验证案例把文件读写串起来。被测模块是一个位宽8bit、深度16的同步FIFO支持写使能、读使能、满标志、空标志。验证方案很直接从stimulus.txt读入一批十六进制数据按写时序写入FIFO同时每隔一拍尝试从FIFO读取数据把读出的数据写进result.txt数据全部处理完后关闭文件输出错误计数。这个方案的关键在于激励数据和结果数据都在外部文件里testbench代码不关心具体数值只管按协议驱动信号、记录结果。如果后续想换一批数据验证只需要替换stimulus.txt完全不用改testbench。5.2 行为级FIFO模型为了演示我用行为级方式写了一个FIFO模型没有追求可综合重点是把空满标志和读写指针行为表达清楚module sync_fifo #( parameter DEPTH 16 )( input wire clk, input wire rst_n, input wire wr_en, input wire rd_en, input wire [7:0] din, output reg [7:0] dout, output reg full, output reg empty ); reg [7:0] mem [0:DEPTH-1]; integer head; integer tail; integer count; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin head 0; tail 0; count 0; full 0; empty 1; dout 8h00; for (i 0; i DEPTH; i i 1) mem[i] 8h00; end else begin // 写操作 if (wr_en !full) begin mem[tail] din; tail (tail 1) % DEPTH; count count 1; end // 读操作 if (rd_en !empty) begin dout mem[head]; head (head 1) % DEPTH; count count - 1; end // 更新空满标志 empty (count 0); full (count DEPTH); end end endmodule这个模型用count记录队列里当前有效数据的个数读写共用一套指针行为上就是一个普通FIFO。虽然阻塞赋值风格不算最好但当testbench的例子足够了。5.3 带文件读写的testbench源码核心的testbench代码在这里。我尽量把文件操作和时序控制分开方便看清楚文件读取、文件写入各占哪一段timescale 1ns/1ps module tb_fifo_file; reg clk; reg rst_n; reg wr_en; reg rd_en; reg [7:0] din; wire [7:0] dout; wire full; wire empty; integer stim_fd; integer result_fd; integer ret; integer idx; integer err_cnt; reg [7:0] stim_data; sync_fifo #(.DEPTH(16)) u_fifo( .clk (clk), .rst_n (rst_n), .wr_en (wr_en), .rd_en (rd_en), .din (din), .dout (dout), .full (full), .empty (empty) ); initial clk 0; always #5 clk ~clk; initial begin // 初始化 rst_n 0; wr_en 0; rd_en 0; din 8h00; idx 0; err_cnt 0; // 打开激励文件和结果文件 stim_fd $fopen(stimulus.txt, r); if (stim_fd 0) begin $display(FATAL: cannot open stimulus.txt); $finish; end result_fd $fopen(result.txt, w); if (result_fd 0) begin $display(FATAL: cannot open result.txt); $finish; end // 复位 #25; rst_n 1; // 从文件读取激励写入FIFO while (!$feof(stim_fd)) begin ret $fscanf(stim_fd, %h, stim_data); if (ret 1) begin // 如果FIFO满先等一个周期 wait (!full); (posedge clk); #1; wr_en 1; din stim_data; $fdisplay(result_fd, [WRITE] time%0t data%h, $time, stim_data); (posedge clk); #1; wr_en 0; end // 每写完两个数据读出一个数据 if (idx % 2 1) begin if (!empty) begin rd_en 1; (posedge clk); #1; rd_en 0; $fdisplay(result_fd, [READ ] time%0t data%h, $time, dout); end end idx idx 1; end // 剩余数据全部读出 while (!empty) begin rd_en 1; (posedge clk); #1; rd_en 0; $fdisplay(result_fd, [READ ] time%0t data%h, $time, dout); end $fclose(stim_fd); $fclose(result_fd); $display(Simulation finished, err_cnt%0d, err_cnt); $finish; end endmodule这段代码里文件读取的入口是while (!$feof(stim_fd))每一行十六进制数通过$fscanf读取只有返回值为1时才执行写FIFO时序。写入结果和读出结果都通过$fdisplay写到result.txt每个文件操作后面都紧跟数据。读操作时用if (!empty)做保护避免FIFO为空时误读产生无效数据。需要说明的是wait (!full)和wait (!empty)在行为级模型里能正常工作但在真实RTL验证中如果full/empty带时序通常需要结合时钟时序去等待。这里演示的是文件读写框架所以用了最直白的等待方式。5.4 文件格式与运行结果验证stimulus.txt文件可以这样准备01 A5 5B FF 32 47运行仿真后result.txt里会出现类似下面的内容[WRITE] time30000 data01 [WRITE] time50000 dataa5 [READ ] time70000 data01 [WRITE] time90000 data5b [READ ] time110000 dataa5 ...如果后面要验证逻辑正确性可以把result.txt里的[READ]行全部提取出来和激励文件里的原始数据按照FIFO先进先出原则做对比。Linux环境下直接用grep加diff两步就能完成大部分校验工作不需要打开波形一张张看。这也是文件读写给验证工作带来的最直接的价值回归测试可以完全自动化。6. 我在工程中踩过的文件读写坑6.1 文件路径和工作目录的问题文件读写最常见的坑就是路径。很多仿真器的工作目录并不是testbench文件所在目录而是工程目录或者运行仿真的目录。如果你用相对路径stimulus.txt但文件放在tb/子目录下$fopen就会失败。我通常的解决办法是尽量在仿真脚本里用绝对路径或者通过defineSTIM_FILExxx把路径传进testbench比如ifdef STIM_FILE stim_fd $fopen(STIM_FILE, r); else stim_fd $fopen(stimulus.txt, r); endif这样脚本控制路径testbench代码不用为不同仿真目录改来改去也方便CI里跑多个用例时切换文件。6.2 fd0不一定报错句柄检查要仔细很多人以为$fopen失败时返回值一定是空或负数但不同仿真器表现不同。有的仿真器打开失败时返回0有的返回负值还有的返回一个无效多通道描述符但不会报致命错误。我发现最稳妥的做法是打开后立刻判断fd 0并且在调用$fscanf前检查fd 0。如果后续对无效句柄做文件操作仿真器可能静默失败也可能输出一堆没有上下文的warning排查起来非常浪费时间。6.3 Windows换行符和编码问题Windows下编辑的文本文件默认用\r\n作为行尾Linux下的仿真器读到\r时可能会把它当成一个普通字符导致$sscanf解析字符串时多出一个不可见字符。用$fgets读一行再$sscanf时尤其明显字符串尾部经常挂一个\r比较字符串永远不相等。我现在的处理方式有两种一是统一用十六进制数据文件不依赖字符串解析二是如果一定要用文本指令就在解析前过滤掉\r或者直接用$fscanf跳过空白字符因为$fscanf对空白字符的处理比$fgets宽容得多。6.4 忘记刷新缓冲导致结果丢失仿真器写文件是有IO缓冲的如果仿真在$fclose之前由于断言失败或者误用$finish强制退出缓冲里的内容可能没有全部落盘。尤其是跑长仿真时跑到第100万拍崩了结果文件里可能只写到第50万拍后面的数据全丢了。解决方法是定期调用$fflush(fd)把缓冲立即刷到磁盘或者在关键检查点后手动关闭再重新打开日志文件。我现在写的testbench都习惯在每个阶段结束点加一次$fflush(result_fd)虽然会稍微影响仿真速度但换来的是崩溃时结果文件仍然可读非常值。6.5 大文件仿真时的性能取舍当激励文件达到几十万行时文件读写的性能问题会浮出水面。$fscanf每次解析文本都有额外开销如果一帧图像上百万像素逐行解析会明显拖慢仿真。我的经验是能一次性加载到memory里的数据尽量用$readmemh它比$fscanf循环快不少如果必须从文件按帧读取也要避免在时钟沿敏感代码里直接做文件IO而是在初始化阶段把整个文件读进一个大数组仿真主循环只从数组取数。这样虽然牺牲一点初始化时间但主循环的速度会非常稳定不至于让仿真速度变成项目的瓶颈。文件读写说到底就是“把耗时的数据搬运从代码里挪到外部文件”一旦熟练使用testbench会立刻从“只能跑通”变成“能自动回归、能批量换数据、能跨项目复用”。我现在的习惯是每个testbench一开始就规划好激励文件和结果文件的分工而不是等验证到中期再补文件操作。这样前期的工程量没有增加多少后期改数据、对结果都省下大把时间。如果你刚开始学Verilog验证建议先把这些文件操作函数写熟练后面写复杂测试环境和脚本化回归时会轻松很多。
返回列表