这一篇在干嘛?

很多 FPGA 工程师写个”快速随便测一下”的仿真就直接上板调试,结果 bug 越调越多。本章教你搭建一套结构良好、全自动、可自检的测试平台(testbench),并介绍代码覆盖率、门级仿真、功耗估算等高级验证手段。验证做得好,调试时间能省一大半——很少有人会后悔仿真写得太全。

11.1 测试平台的架构

测试平台(testbench)是仿真中的顶层模块,负责把所有模型”缝”在一起。一个设计不良的 testbench(通常是当初图省事随手写的)会慢慢长成没人能读懂的怪物。所以一开始就要搭好结构。

11.1.1 测试平台的组成部分

一个抽象的顶层 testbench 包含四类组件:

  1. 测试过程(test procedure):住在 testbench 里,管理仿真的主线程——决定跑哪些测试、用哪些向量、数据如何记录和报告。
  2. 全局激励(global stimulus):作用于整个系统的基本向量,最典型的是系统时钟、复位和初始化条件。
  3. 硬件模型(hardware models):被测设备(DUT,Device Under Test),也就是最终要实现到 FPGA 里的模块。通常只有一个,即 FPGA 的顶层模块。
  4. 仿真模型(simulation models):FPGA 要交互但不实现进 FPGA 的其他系统组件的模型,如存储器、微处理器、模拟器件等。

测试平台的组成 图 11.1 测试平台的组成

11.1.2 测试平台的流程:仿真像软件,硬件像电路

这是本章最重要的概念转变:

  • 硬件描述是并发的:综合器只认”同步事件触发的逻辑操作”,代码里写的任何时间信息(如 #5)综合时会被直接忽略
  • 仿真是过程式的:仿真按顺序执行——先施加激励 a,再 b,再 c,带着明确的时间关系。这更像软件设计。

所以在 testbench 里我们会大量使用综合器不支持的行为级语法(如 initialforever#延时),这是合法且必要的。

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.vtest2.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);

三个要点:

  1. 每个用例开头都复位仿真resetsim() + reset_fpga()),保证用例之间互不污染。虽然会拉长仿真时间,但这是值得的工程习惯。
  2. 统一的检查函数 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,方便定位。

  1. 尽量只引用模块边界上的信号。这是很实用的一条:调试阶段经常要把布线后网表(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 12

11.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 ...)建模,别用阻塞赋值加延时。

通关标准:

学完本篇你应该能做到:

  1. 画出一个测试平台的组件框图(测试过程 / 全局激励 / 硬件模型 / 仿真模型),并说明自检查 testbench 的价值;
  2. 独立写出结构化的主线程 + 独立时钟/复位生成 + 可插拔测试用例 + verify_output 任务的完整 testbench 骨架;
  3. 用 MATLAB 等工具批量生成有数学规律的激励向量;
  4. 解释 BFM 是什么、为什么优先用厂商提供的版本;
  5. 说出 RTL 仿真迁移到门级仿真需要替换的三件套(网表、工艺库、SDF),并理解 LUT 的 INIT 配置原理;
  6. 正确选择 timescale 精度,并区分传输延时与惯性延时的建模方法。