这一篇在干嘛?
很多 FPGA 工程师写个”快速随便测一下”的仿真就直接上板调试,结果 bug 越调越多。本章教你搭建一套结构良好、全自动、可自检的测试平台(testbench),并介绍代码覆盖率、门级仿真、功耗估算等高级验证手段。验证做得好,调试时间能省一大半——很少有人会后悔仿真写得太全。
11.1 测试平台的架构
测试平台(testbench)是仿真中的顶层模块,负责把所有模型”缝”在一起。一个设计不良的 testbench(通常是当初图省事随手写的)会慢慢长成没人能读懂的怪物。所以一开始就要搭好结构。
11.1.1 测试平台的组成部分
一个抽象的顶层 testbench 包含四类组件:
- 测试过程(test procedure):住在 testbench 里,管理仿真的主线程——决定跑哪些测试、用哪些向量、数据如何记录和报告。
- 全局激励(global stimulus):作用于整个系统的基本向量,最典型的是系统时钟、复位和初始化条件。
- 硬件模型(hardware models):被测设备(DUT,Device Under Test),也就是最终要实现到 FPGA 里的模块。通常只有一个,即 FPGA 的顶层模块。
- 仿真模型(simulation models):FPGA 要交互但不实现进 FPGA 的其他系统组件的模型,如存储器、微处理器、模拟器件等。
图 11.1 测试平台的组成
11.1.2 测试平台的流程:仿真像软件,硬件像电路
这是本章最重要的概念转变:
- 硬件描述是并发的:综合器只认”同步事件触发的逻辑操作”,代码里写的任何时间信息(如
#5)综合时会被直接忽略。 - 仿真是过程式的:仿真按顺序执行——先施加激励 a,再 b,再 c,带着明确的时间关系。这更像软件设计。
所以在 testbench 里我们会大量使用综合器不支持的行为级语法(如 initial、forever、#延时),这是合法且必要的。
11.1.2.1 主线程
Verilog 中主仿真线程通常用一个包含阻塞赋值(blocking assignment,即语句按顺序逐条执行,类似软件)的 initial 块来建模:
initial begin
errors = 0; // 清零错误计数
// 复位芯片输入
chipin1 = 0;
chipin2 = 16'ha5;
// 复位仿真参数
resetsim();
// 复位芯片
reset_fpga();
//
// 在这里加入各个测试用例
//
`include "test1.v"
`include "test2.v"
$display("\nSimulation completed with %d errors\n", errors);
$stop;
end这个主线程有几个值得学习的要点:
- 全部用阻塞赋值:语句一条条按序执行,符合软件式的过程语义。
- 主线程只放全局初始化:具体功能测试全放在独立的测试文件
test1.v、test2.v里,通过`include引入。主执行路径保持”瘦”且模块化,可读性好。 - 自检查(self-checking):每个功能测试会全面检查硬件模型内部的条件,把不匹配项报告到标准输出或日志,并累加错误计数
errors。仿真结束时一条语句就能报出成败。
为什么要自检?
当新同事加入、只想验证自己改的模块没弄坏别人时,一个全自动 testbench 能让他在不完全理解整个系统的情况下完成回归验证。跳过自动化的偷懒,迟早会以”可移植性灾难”的形式还回来。
11.1.2.2 时钟与复位:全局独立生成
时钟和复位应在主线程之外用独立的 initial 块生成,不要塞进主流程:
`timescale 1ns/1ns
`define PERIOD 5 // 100MHz 时钟
initial begin
clk <= 0;
forever #(`PERIOD) clk = ~clk;
end注意:timescale 1ns/1ns 表示 # 延时单位是 1ns。这里定义 PERIOD 5,指的是翻转间隔为 5ns(完整周期 10ns)。仿真中 clk 在 0 时刻为 0,5ns 时变 1,10ns 时变 0,如此往复。
复位也可以类似地建模:
initial begin
reset <= 0;
@(posedge clk); // 复位可能需要几个时钟周期
@(negedge clk) reset = 1;
end这里有个细节:initial 块中混用了非阻塞和阻塞赋值——先非阻塞地赋初值 clk <= 0,再阻塞地翻转。在可综合的 always 块里这种混用是糟糕风格,但仿真专用的 initial 块里没问题。非阻塞赋初值确保设计中所有 always 块先于这个赋值被求值,避免初值触发的事件被漏掉。
经验法则
测试平台的时钟和复位:用非阻塞赋值初始化,用阻塞赋值更新。另外,复位总是在时钟下降沿释放——因为自由运行的时钟可能与其他仿真参数不同步,统一在下降沿释放可避免复位恢复时间问题。
11.1.2.3 测试用例:模块化、可插拔
测试用例要写得能独立存在:可以随意从主线程中删除、重排、插入新的测试。来看一个测试用例的例子:
// test1.v
// 测试场景 #1
resetsim();
reset_fpga();
$display("Begin testing scenario 1... \n");
// 比较输出与期望值,不匹配则报告
verify_output(output_value, expected_value);
$display("\nCompleted testing scenario 1 with %d errors", errors);三个要点:
- 每个用例开头都复位仿真(
resetsim()+reset_fpga()),保证用例之间互不污染。虽然会拉长仿真时间,但这是值得的工程习惯。 - 统一的检查函数
verify_output():所有用例共用同一个比较函数(通常用 Verilog 的task实现):
task verify_output;
input [23:0] simulated_value;
input [23:0] expected_value;
begin
if (simulated_value[23:0] != expected_value[23:0])
begin
errors = errors + 1;
$display("Simulated Value = %h, Expected Value = %h, errors = %d, at time = %d\n",
simulated_value, expected_value, errors, $time);
end
end
endtask传入仿真值和期望值,不一致就报错并累加计数,还带上出错时刻 $time,方便定位。
- 尽量只引用模块边界上的信号。这是很实用的一条:调试阶段经常要把布线后网表(netlist)反标(back-annotate)回仿真环境。反标时保持层次结构容易,但保留所有内部网络的名字很难——尤其当综合工具做了资源共享、寄存器平衡等优化之后。如果你的 testbench 只引用模块的输入输出端口,反标后照样能跑;引用了内部信号,反标后测试平台大概率报错。
11.2 系统激励
写 testbench 最枯燥耗时的部分就是造激励。模块级仿真里手敲几个”硬编码”向量很容易(比如给 FIR 滤波器喂十几个数测乘加),但系统级仿真就难了:怎么测滤波器的频响?怎么模拟 PCI 或 SDRAM 的标准接口时序?
11.2.1 用 MATLAB 生成大量向量
凡是有规律或能用数学描述的大规模向量,都适合用 MATLAB 生成。比如给滤波器仿真生成一组正弦波采样:
clear;
% 生成 40kHz 采样率下的 100Hz 正弦波
sinewave = sin(10.*(1:4000).*2.*pi./4000); % 10 个周期
sinehalfamp = sinewave * hex2dec('3fff'); % 归一化幅度
fid = fopen('100hz.vec', 'w'); % 存放仿真向量的文件
% 归一化为二进制补码
for i=1:length(sinehalfamp)
if(sinehalfamp(i) < 0)
sinehex(i) = floor(hex2dec('ffff') + sinehalfamp(i) + 1);
else
sinehex(i) = floor(sinehalfamp(i));
end
fwrite(fid, dec2hex(sinehex(i), 4));
fprintf(fid, '\n');
end
fclose(fid);这段脚本生成 10 个周期的 100Hz 正弦波(40kHz 采样率,每个向量一个采样点),并归一化为 16 位二进制补码的十六进制格式,Verilog 仿真可以直接用 $readmemh 之类的语句读入。
11.2.2 总线功能模型 BFM
有些系统组件必须仿真,但完整建模又太慢。HDL 网表或 Spice 模型精度高,但仿真速度惨不忍睹。对存储器、微处理器、PCI 这类标准接口,可以用总线功能模型(BFM,Bus-Functional Model):只模拟接口的时序和协议行为,不模拟器件内部。
举个例子:FPGA 一边接 PCI 总线,一边接一块突发式(burstable)Flash。
图 11.2 带总线功能模型的测试平台
BFM 的优点是简单高效——不用仿真整个器件就能验证协议。缺点是模型质量取决于写它的人。所以:能用器件厂商提供的 BFM 就用厂商的。PCI IP 核厂商(如 QuickLogic)会提供 PCI 总线 BFM,让你直接在 testbench 层发读写命令;Intel 这类 Flash 大厂也会提供 BFM 来验证握手和缓冲逻辑。
11.3 代码覆盖率
代码覆盖率(code coverage)是仿真工具提供的一种统计功能:报告哪些结构被激励覆盖过、被覆盖多少次。常见指标包括:
- 行覆盖:哪些行执行到了、哪些没有;
- 状态机覆盖:所有状态和状态跳转是否都验证过;
- 翻转覆盖(toggle coverage):设计中各区域的信号活动量。
覆盖率在 ASIC 设计里是刚需(一次流片太贵,必须穷尽验证),而随着 FPGA 规模膨胀,快速仿一下就上板的做法已经不够用了。覆盖率的价值在于:快速定位设计中还没被仿真到的角落——这些角落要么是设计弱点,要么提示你该补充新的验证任务。
11.4 门级仿真
门级仿真(gate-level simulation)要不要做,业界一直有争论——尤其如今有 Xilinx Chipscope、Synplicity Identify 这类在线调试工具。但要注意:在线调试工具看不到设计的每个单元和每根线,而且有些异常行为(特别是复位释放阶段的亚稳态类问题)静态时序分析和在线调试都难以捕捉,只有门级仿真能查。
一个设计良好的 testbench 应该能平滑迁移到门级仿真:
图 11.3 门级仿真的测试平台
对比 RTL 仿真,变化很小:仿真模型、测试过程、全局激励、外部向量都不动,只是把可综合的 RTL 文件换成网表 + 工艺库 + 时序信息三件套。
网表、库和 SDF 时序
**网表(netlist)**是布局布线后设计的门级表示。比如下面这个简单的与或逻辑:
module andor (
output oDat,
input iDat1, iDat2, iDat3);
wire ANDNET;
assign ANDNET = iDat1 & iDat2;
assign oDat = ANDNET | iDat3;
endmodule虽然看起来是”两个门”,但 FPGA 的 LUT(Look-Up Table,查找表——本质上是个小 SRAM,可以实现任意多输入组合逻辑)能把整个运算塞进一个 LUT。网表里就成了这样:
X_LUT4 #(.INIT(16'hFCF0)) oDat1 (
.ADR0(VCC),
.ADR1(iDat2_IBUF_1),
.ADR2(iDat3_IBUF_2),
.ADR3(iDat1_IBUF_0),
.O(oDat_OBUF_3)
);工艺库由 FPGA 厂商提供,定义每个底层单元的行为,让仿真器能解释网表。Xilinx 的 LUT 库模型就一行:
o_out = INIT[{a3, a2, a1, a0}];INIT 是网表里传进来的 16 位参数,a0–a3 是 LUT 输入——恰好和”LUT 是个小 SRAM”的原理吻合。
时序信息用 SDF(Standard Delay Format,标准延时格式)文件反标。ASIC 世界要关心快慢两个工艺角(hold 和 setup),但 FPGA 因为布线矩阵的固有延时和低偏斜时钟线,只需关心慢角——电压最低、温度最高的情况:
(VOLTAGE 1.14)
(TEMPERATURE 85)
(TIMESCALE 1 ps)单个 LUT 的 SDF 条目长这样:
(CELL (CELLTYPE "X_LUT4")
(INSTANCE oDat1)
(DELAY
(ABSOLUTE
(PORT ADR1 ( 499 ))
(PORT ADR2 ( 291 ))
(PORT ADR3 ( 358 ))
(IOPATH ADR0 O ( 519 ))
(IOPATH ADR1 O ( 519 ))
(IOPATH ADR2 O ( 519 ))
(IOPATH ADR3 O ( 519 ))
)
)PORT条目是互连延时(SDF 规范允许把互连延时抽象为端口延时),即从输入缓冲器到该 LUT 各输入引脚的延时;IOPATH条目是穿过 LUT 本身的延时,这里是 519ps。
三者齐备后,门级仿真就能精确复现 FPGA 内部的真实工作过程。
11.5 翻转覆盖与动态功耗估算
FPGA 验证一般不做门级节点覆盖(因为可以快速上板实测,成本上不划算),但门级的翻转覆盖有独特价值:它不仅报告”覆盖/未覆盖”,还能给出频率相关的活动信息。
在缺少专业功耗分析工具时,可以用翻转统计估算动态功耗。动态功耗的经典公式:
其中 C(电容)和 V_dd(电源电压)的统计值由芯片厂商提供,而频率 f 依赖于设计和激励——只有从仿真环境里提取统计信息才能得到准确的 f。
Xilinx 工具可以生成一个测试平台外壳(testbench shell)作为起点:
`include "C:/Xilinx/verilog/src/glbl.v"
`timescale 1 ns/1 ps
// Xilinx 生成的测试平台外壳
module testtb;
reg iDat1;
reg iDat2;
reg iDat3;
wire oDat;
test UUT (
.iDat1 (iDat1),
.iDat2 (iDat2),
.iDat3 (iDat3),
.oDat (oDat)
);
initial begin
$display(" T iiio");
$display(" i DDDD");
$display(" m aaaa");
$display(" e tttt");
$display(" 123 ");
$monitor("%t", $realtime, ,iDat1,iDat2,iDat3,oDat);
end
initial begin
#1000 $stop;
end
endmodule然后初始化数据、提供周期性激励,并把向量记录到 VCD 文件(Vector Change Dump,向量变化转储):
initial begin
$dumpfile("test.vcd");
$dumpvars(1, testtb.UUT);
$dumpon;
#1000 $dumpoff;
#10 $stop;
end
initial begin
iDat1 <= 0;
forever #5 iDat1 = ~iDat1;
end
initial begin
iDat2 <= 0;
forever #10 iDat2 = ~iDat2;
end
initial begin
iDat3 <= 0;
forever #15 iDat3 = ~iDat3;
end$dumpfile/$dumpvars 系列命令把激励记录进 test.vcd,这个文件随后可以喂给功耗估算工具。
旁注:更专业的功耗工具通常随实现工具提供。Xilinx 的 XPower 读入上面的 VCD 文件后,输出类似这样的动态功耗估算:
Power summary: I (mA) P (mW)
Total estimated power consumption: 36
Vccint 1.20V: 6 7
Vccaux 2.50V: 7 18
Vcco25 2.50V: 5 1211.6 运行期陷阱
11.6.1 Timescale:精度与速度的平衡
`timescale 1ns/100ps 的含义:时间单位 1ns,精度 100ps。此时 assign #1.1 out = in; 解析为 1.1ns。如果写成 100ps/10ps,同样的延时解析为 110ps。
两个方向的坑:
- 精度太粗:若分辨率只有 1ns,1.1ns 会被舍入到 1ns,可能引起仿真错误。带 SDF 反标时(时序常有分数值)尤其要注意。
- 精度太细:若最细时序只要 100ps,你却设 1ps 精度,仿真会明显变慢。反标网表的仿真本来就慢,这直接拖累生产力。
另外记住:timescale 指令会被综合工具完全忽略,绝对延时信息只允许出现在 testbench 和仿真专用模块里,绝不能用来定义可综合硬件的行为。
11.6.2 毛刺滤除(Glitch Rejection)
门级仿真的一个经典陷阱:仿真算法内置的自动毛刺滤除。先区分两种延时模型:
- 传输延时(transport delay):任意宽度的脉冲都能从输入传到输出。导线(wire)就是典型——没有最小脉宽要求。
- 惯性延时(inertial delay):脉冲宽度必须大于某最小值才能传到输出。逻辑门就是典型——比门延时还窄的脉冲(毛刺)会在输出端被”拒绝”。
图 11.4 传输延时:任何宽度的脉冲都能通过
图 11.5 惯性延时:过窄的脉冲被门滤除
这个特性提醒我们:给门级仿真提供正确的模型和准确的激励非常重要——尤其是当逻辑驱动异步电路时,异步电路没有中间寄存器的毛刺过滤作用,仿真里被滤掉的毛刺在真实硬件上可能真的存在。
11.6.3 组合逻辑延时建模
给行为级模型加延时常出问题。绝对不要给可综合结构的阻塞赋值加延时(综合会忽略延时,造成仿真与综合不匹配)。但仿真专用模型怎么正确加延时,也有讲究。看一个 8 位加法器的三种写法:
写法一:阻塞赋值加延时(错误)
// 错误的延时建模
module delayadd(
output reg [8:0] oDat,
input [7:0] iDat1, iDat2);
always @*
#5 oDat = iDat1 + iDat2;
endmodule简单仿真看起来正常:两输入在 T0 变化,输出在 T0+5 变化。但真正语义是:“自上一次输出更新后,always 块每被触发一次,5ns 后输出更新一次”。若 iDat1 在 T0 变化、T0+2 又变,输出会在 T0+5 用两个输入的最新值更新——这不是 5ns 传播延时的行为!
图 11.6 错误延时建模的结果:输出在最后一次触发后 5ns 才更新,吞掉了中间变化
写法二:非阻塞赋值加延时(建模传输延时)
// 编码风格不佳,但准确建模传输延时
module delayadd(
output reg [8:0] oDat,
input [7:0] iDat1, iDat2);
// 非阻塞赋值通常不用于组合逻辑
always @*
oDat <= #5 iDat1 + iDat2;
endmodule每次触发都会调度一次更新,准确表现”每次输入变化都产生一个 5ns 后的输出变化”——即传输延时行为,适合行为模型和 BFM。
图 11.7 传输延时的结果:每次输入变化都在 5ns 后如实重现
写法三:连续赋值加延时(建模惯性延时,推荐用于组合逻辑)
module delayadd(
output [8:0] oDat,
input [7:0] iDat1, iDat2);
assign #5 oDat = iDat1 + iDat2;
endmodule连续赋值(continuous assignment)天然带惯性延时语义。
图 11.8 惯性延时的结果:2ns 的窄脉冲被滤除,两输入稳定 5ns 后输出最终值 8
对比图 11.8 可以看到:iDat1 短暂保持 3 的那个 2ns 脉冲被滤除了,两输入稳定在 4 之后 5ns,输出变为最终值 8。这才是真实组合逻辑门的行为。
常见坑
给可综合的 always 块里加
#延时来”修”竞态或改变节拍行为——综合器会忽略所有延时,仿真和硬件行为就此分道扬镳,而且这种不匹配往往在板上才暴露。记住铁律:延时只存在于 testbench 和仿真专用模型中;组合逻辑的惯性延时用连续赋值(assign #5 ...)建模,别用阻塞赋值加延时。
通关标准:
学完本篇你应该能做到:
- 画出一个测试平台的组件框图(测试过程 / 全局激励 / 硬件模型 / 仿真模型),并说明自检查 testbench 的价值;
- 独立写出结构化的主线程 + 独立时钟/复位生成 + 可插拔测试用例 +
verify_output任务的完整 testbench 骨架;- 用 MATLAB 等工具批量生成有数学规律的激励向量;
- 解释 BFM 是什么、为什么优先用厂商提供的版本;
- 说出 RTL 仿真迁移到门级仿真需要替换的三件套(网表、工艺库、SDF),并理解 LUT 的 INIT 配置原理;
- 正确选择 timescale 精度,并区分传输延时与惯性延时的建模方法。
自测:主线程为什么要用阻塞赋值,而时钟初始化却用非阻塞赋值?
主线程模拟的是软件式的过程行为,需要语句按序执行、互相依赖,所以用阻塞赋值。时钟/复位的初值用非阻塞赋值,是为了保证设计中所有 always 块先于该赋值被求值——即初值引发的事件不会漏掉任何敏感的 always 块。总结成口诀:时钟和复位”非阻塞初始化、阻塞更新”。
自测:为什么 testbench 尽量只引用模块边界的信号?
因为调试阶段常把布线后网表反标回仿真环境。反标时层次结构容易保留,但内部网络和寄存器的名字在综合优化(资源共享、寄存器平衡等)后很难保留。只引用端口信号,同一套 testbench 就能在 RTL 和门级两种环境下直接运行。
自测:BFM 相比完整器件模型有什么优缺点?
优点:只建模接口的时序与协议,简单、仿真速度快,不需要整个器件的低层模型。缺点:模型质量完全取决于编写者,可能有协议 bug。所以优先使用器件/IP 核厂商提供的 BFM。
自测:FPGA 门级仿真为什么通常只关心"慢角"而不关心 hold 违例?
因为 FPGA 的布线矩阵自带固有信号延时,时钟线是低偏斜专用资源,hold 违例的风险被架构本身抑制了。所以只需仿真最坏情况:电压最低(如 1.14V)、温度最高(如 85°C),SDF 中以最小延时角给出。
自测:
always @* #5 oDat = iDat1 + iDat2;与assign #5 oDat = iDat1 + iDat2;的行为差异是什么?前者是阻塞赋值加延时,语义是”自上次输出更新后,每次触发 5ns 后用最新输入值更新输出”,会吞掉两次触发之间的输入变化,不符合真实组合逻辑。后者是连续赋值,具有惯性延时语义:小于 5ns 的输入脉冲被滤除,输入稳定 5ns 后输出最终结果,与真实门的毛刺滤除行为一致。
自测:如何利用门级仿真估算动态功耗?
在门级仿真中运行真实激励,用
$dumpvars记录信号翻转生成 VCD 文件,得到每个节点每时钟周期的翻转率;再把翻转率、电源电压、平均门电容代入 P = CV²f(C、V 由厂商提供),或直接把 VCD 喂给 Xilinx XPower 等工具得到功耗报告。关键在于频率 f 只能从仿真统计中获得。