91.【SV】SystemVerilog Testbench Example Adder
➕ SystemVerilog 加法器测试平台 —— 从零开始构建可扩展的验证环境
作为芯片验证工程师,我们的目标是构建一个模块化、可重用的测试平台,能够高效地验证设计功能。
今天,以一个非常简单的加法器为例,演示如何用 SystemVerilog 的面向对象特性搭建一个完整的验证环境。这个环境包含了所有标准组件:事务对象、生成器、驱动器、监视器、计分板、环境、测试用例,以及它们之间的通信机制(mailbox、event、virtual interface)。
🎯 一、设计规格:我们要验证什么?
设计功能
我们有一个组合逻辑加法器 my_adder,它的功能很简单:
- 当
rstn为 1(复位无效)时,计算sum = a + b,并产生进位carry。 - 当
rstn为 0(复位有效)时,输出sum和carry均为 0。
接口:它使用了一个接口 adder_if 来连接所有信号。
module my_adder (adder_if _if);
always_comb begin
if (_if.rstn) begin
_if.sum <= 0;
_if.carry <= 0;
end else begin
{_if.carry, _if.sum} <= _if.a + _if.b;
end
end
endmodule
注意:加法器本身没有时钟,是纯组合逻辑。但为了模拟实际电路中的时序(比如输入来自寄存器),我们在测试平台中引入了一个独立的时钟接口 clk_if,用于同步驱动和采样。
🏗️ 二、验证环境架构
我们的测试平台采用标准的分层结构,各个组件职责单一,通过标准接口(mailbox、event)通信。整体架构如下:
┌─────────────────────────────────────────────────────────────┐
│ Test │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Generator │ │ Driver │ │ Monitor │ │Scoreboard│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ drv_mbx │ scb_mbx │ │
│ │ │ │ │ │
│ └──────────────┼──────────────┼──────────────┘ │
│ │ │ │
└──────────────────────┼──────────────┼───────────────────────┘
│ │
┌─────▼──────┐ ┌─────▼──────┐
│ Driver │ │ Monitor │
│ (线程) │ │ (线程) │
└─────┬──────┘ └─────┬──────┘
│ │
▼ ▼
┌───────────────────────────────┐
│ Interface (_if) │
└───────────────┬───────────────┘
│
▼
┌───────────────┐
│ DUT │
│ my_adder │
└───────────────┘
组件职责:
- 生成器(Generator):产生随机事务(包括
rstn、a、b),放入drv_mbx。 - 驱动器(Driver):从
drv_mbx获取事务,在时钟沿驱动到 DUT 接口。 - 监视器(Monitor):在每个时钟沿采样 DUT 的输入和输出,打包成事务放入
scb_mbx。 - 计分板(Scoreboard):从
scb_mbx获取事务,计算预期结果并与实际输出比较。 - 环境(Environment):实例化所有组件,连接 mailbox 和事件。
- 测试(Test):创建环境并启动它。
- 接口(Interface):封装 DUT 信号和测试时钟,供组件通过 virtual interface 访问。
📦 三、核心组件详解
1. 事务对象(Packet)—— 数据信封
事务对象定义了所有需要交换的数据:输入激励(rstn, a, b)和预期/实际输出(sum, carry)。它还提供了打印和复制方法。
class Packet;
rand bit rstn; // 复位信号,随机化
rand bit[7:0] a; // 输入a,随机化
rand bit[7:0] b; // 输入b,随机化
bit [7:0] sum; // 和(从DUT捕获)
bit carry; // 进位(从DUT捕获)
function void print(string tag="");
$display("T=%0t %s a=0x%0h b=0x%0h sum=0x%0h carry=0x%0h",
$time, tag, a, b, sum, carry);
endfunction
function void copy(Packet tmp);
this.a = tmp.a;
this.b = tmp.b;
this.rstn = tmp.rstn;
this.sum = tmp.sum;
this.carry = tmp.carry;
endfunction
endclass
比喻:事务对象就像一个信封,里面装着要寄出的信(a, b, rstn)和回信(sum, carry)。生成器写好信,驱动器寄出,监视器收回回信,计分板检查回信是否正确。
2. 生成器(Generator)—— 写信员
生成器负责产生指定数量(loop)的随机事务,并通过 mailbox 发送给驱动器。它使用 drv_done 事件来同步,确保驱动器处理完一个事务后才产生下一个。
class generator;
int loop = 10; // 要产生的事务数量
event drv_done; // 驱动器完成事件
mailbox drv_mbx; // 通向驱动器的邮箱
task run();
for (int i = 0; i < loop; i++) begin
Packet item = new;
item.randomize(); // 随机化 a, b, rstn
$display("T=%0t [Generator] Loop:%0d/%0d create next item", $time, i+1, loop);
drv_mbx.put(item); // 放入邮箱
$display("T=%0t [Generator] Wait for driver to be done", $time);
@(drv_done); // 等待驱动器完成
end
endtask
endclass
比喻:生成器就像写信员,他不断写新信(随机化),然后放进邮筒(mailbox),并等待邮差(driver)回信号(drv_done)表示信已寄出,才写下一封。
3. 驱动器(Driver)—— 邮差
驱动器从邮箱获取事务,在时钟沿将 rstn、a、b 驱动到 DUT 接口,然后触发 drv_done 事件。
class driver;
virtual adder_if m_adder_vif; // 连接 DUT 的接口
virtual clk_if m_clk_vif; // 测试时钟接口
event drv_done;
mailbox drv_mbx;
task run();
forever begin
Packet item;
drv_mbx.get(item); // 阻塞等待事务
@(posedge m_clk_vif.tb_clk); // 等待时钟上升沿
item.print("Driver");
m_adder_vif.rstn <= item.rstn;
m_adder_vif.a <= item.a;
m_adder_vif.b <= item.b;
->drv_done; // 通知生成器
end
endtask
endclass
比喻:驱动器就是邮差,他从邮筒取信,然后在指定时间(时钟沿)把信投递到用户(DUT)的信箱,然后按铃(drv_done)告诉写信员可以写下一封。
4. 监视器(Monitor)—— 监控摄像头
监视器在每个时钟沿采样 DUT 的输入和输出,打包成事务,然后放入发给计分板的邮箱。
class monitor;
virtual adder_if m_adder_vif;
virtual clk_if m_clk_vif;
mailbox scb_mbx; // 通向计分板的邮箱
task run();
forever begin
Packet m_pkt = new;
@(posedge m_clk_vif.tb_clk); // 等待时钟上升沿
#1; // 小延迟,确保采样到稳定值
m_pkt.a = m_adder_vif.a;
m_pkt.b = m_adder_vif.b;
m_pkt.rstn = m_adder_vif.rstn;
m_pkt.sum = m_adder_vif.sum;
m_pkt.carry = m_adder_vif.carry;
m_pkt.print("Monitor");
scb_mbx.put(m_pkt); // 发送给计分板
end
endtask
endclass
比喻:监视器就像监控摄像头,它对准用户(DUT)的信箱,每次有人取信或放信(时钟沿),就拍一张照片(采样),然后把照片存入档案袋(mailbox)交给保安室(scoreboard)。
5. 计分板(Scoreboard)—— 保安室
计分板从邮箱获取事务,根据输入 a、b、rstn 计算预期的 sum 和 carry,然后与实际值比较,并打印 PASS/ERROR 信息。
class scoreboard;
mailbox scb_mbx;
task run();
forever begin
Packet item, ref_item;
scb_mbx.get(item);
item.print("Scoreboard");
// 复制一份用于计算预期值
ref_item = new;
ref_item.copy(item);
// 根据复位状态计算预期结果
if (ref_item.rstn)
{ref_item.carry, ref_item.sum} = ref_item.a + ref_item.b;
else
{ref_item.carry, ref_item.sum} = 0;
// 比较
if (ref_item.carry != item.carry)
$display("[%0t] Scoreboard Error! Carry mismatch ref_item=0x%0h item=0x%0h", $time, ref_item.carry, item.carry);
else
$display("[%0t] Scoreboard Pass! Carry match", $time);
if (ref_item.sum != item.sum)
$display("[%0t] Scoreboard Error! Sum mismatch ref_item=0x%0h item=0x%0h", $time, ref_item.sum, item.sum);
else
$display("[%0t] Scoreboard Pass! Sum match", $time);
end
endtask
endclass
比喻:计分板就是保安室的记录员,他拿到监控照片后,根据照片上的输入(a, b, rstn)自己算一遍(预期结果),然后和照片上的输出(sum, carry)对比,如果一致就说“正常”,不一致就报警(打印错误)。
6. 环境(Environment)—— 控制中心
环境将所有组件实例化,并连接它们的 mailbox、event 和 virtual interface。
class env;
generator g0;
driver d0;
monitor m0;
scoreboard s0;
mailbox scb_mbx;
mailbox drv_mbx;
event drv_done;
virtual adder_if m_adder_vif;
virtual clk_if m_clk_vif;
function new();
g0 = new; d0 = new; m0 = new; s0 = new;
scb_mbx = new; drv_mbx = new;
endfunction
virtual task run();
// 连接 virtual interface
d0.m_adder_vif = m_adder_vif;
m0.m_adder_vif = m_adder_vif;
d0.m_clk_vif = m_clk_vif;
m0.m_clk_vif = m_clk_vif;
// 连接 mailbox
d0.drv_mbx = drv_mbx;
g0.drv_mbx = drv_mbx;
m0.scb_mbx = scb_mbx;
s0.scb_mbx = scb_mbx;
// 连接事件
d0.drv_done = drv_done;
g0.drv_done = drv_done;
// 并发启动所有组件
fork
s0.run(); // 计分板一直运行
d0.run(); // 驱动器一直运行
m0.run(); // 监视器一直运行
g0.run(); // 生成器运行固定次数后结束
join_any // 当生成器结束后,环境继续
endtask
endclass
比喻:环境就像控制中心,把所有岗位(写信员、邮差、摄像头、保安)召集起来,给他们分配工作地点(interface)、通信渠道(mailbox)和信号(event),然后一声令下(fork),大家开始各司其职。
7. 测试(Test)—— 老板的指令
测试用例实例化环境并启动它。
class test;
env e0;
function new();
e0 = new();
endfunction
virtual task run();
e0.run();
endtask
endclass
比喻:测试就是老板,他建好控制中心(env),然后说:“开始工作!”(run)。
8. 接口(Interface)—— 接线板
我们有两个接口:
adder_if:连接 DUT 的所有信号(rstn,a,b,sum,carry)。clk_if:提供测试时钟tb_clk,用于同步驱动和采样。
interface adder_if();
logic rstn;
logic [7:0] a;
logic [7:0] b;
logic [7:0] sum;
logic carry;
endinterface
interface clk_if();
logic tb_clk;
initial tb_clk <= 0;
always #10 tb_clk = ~tb_clk; // 产生周期20ns的时钟
endinterface
9. 顶层(Testbench Top)—— 总装车间
顶层模块实例化 DUT、接口、测试对象,并启动测试。
module tb;
clk_if m_clk_if (); // 实例化时钟接口
adder_if m_adder_if (); // 实例化DUT接口
my_adder u0 (m_adder_if); // 实例化DUT
initial begin
test t0;
t0 = new;
t0.e0.m_adder_vif = m_adder_if; // 传递接口给环境
t0.e0.m_clk_vif = m_clk_if;
t0.run(); // 启动测试
#50 $finish; // 运行一段时间后结束
end
endmodule
比喻:顶层就是总装车间,把芯片(DUT)、接口板(interface)、测试台(test)都组装起来,然后按下启动按钮。
🧪 四、仿真结果分析
正常仿真输出
当设计正确时,仿真输出如下(部分截取):
T=10 Driver a=0x16 b=0x11 sum=0x0 carry=0x0
T=11 Monitor a=0x16 b=0x11 sum=0x0 carry=0x0
[11] Scoreboard Pass! Carry match ref_item=0x0 item=0x0
[11] Scoreboard Pass! Sum match ref_item=0x0 item=0x0
...
T=71 Monitor a=0x63 b=0xfb sum=0x5e carry=0x1
T=71 Scoreboard a=0x63 b=0xfb sum=0x5e carry=0x1
[71] Scoreboard Pass! Carry match ref_item=0x1 item=0x1
[71] Scoreboard Pass! Sum match ref_item=0x5e item=0x5e
观察:
- 驱动器在时间 10ns 驱动输入,监视器在 11ns 采样到输出。
- 计分板计算预期结果(如 0x63+0xfb=0x15e,即 sum=0x5e, carry=1)与实际匹配,打印 PASS。
Buggy Design:故意引入错误验证检查器
为了证明检查器能发现设计错误,我们修改设计,让复位逻辑颠倒:rstn 为 1 时输出为 0,rstn 为 0 时才正常计算。
if (_if.rstn) begin // 错误:应为 !_if.rstn
_if.sum <= 0;
_if.carry <= 0;
end else begin
{_if.carry, _if.sum} <= _if.a + _if.b;
end
仿真输出:
T=11 Monitor a=0x16 b=0x11 sum=0x27 carry=0x0
T=11 Scoreboard a=0x16 b=0x11 sum=0x27 carry=0x0
[11] Scoreboard Pass! Carry match ref_item=0x0 item=0x0
[11] Scoreboard Error! Sum mismatch ref_item=0x0 item=0x27
...
T=71 Monitor a=0x63 b=0xfb sum=0x0 carry=0x0
T=71 Scoreboard a=0x63 b=0xfb sum=0x0 carry=0x0
[71] Scoreboard Error! Carry mismatch ref_item=0x1 item=0x0
[71] Scoreboard Error! Sum mismatch ref_item=0x5e item=0x0
观察:
- 当
rstn为 1(复位无效)时,设计本应计算加法,但错误地输出 0,导致计分板报错。 - 当
rstn为 0(复位有效)时,设计本应输出 0,但错误地计算了加法,同样报错。 - 这证明计分板正确地检测到了设计错误。
🧠 五、关键概念与设计模式
1. 面向对象的封装
每个组件都是一个类,内部封装了 mailbox、event、virtual interface 等成员,以及 run() 任务。这使得组件可以独立开发、重用,且易于维护。
2. mailbox 通信
组件之间通过 mailbox 传递事务对象,实现生产者-消费者解耦。生成器(生产者)和驱动器(消费者)之间用 drv_mbx,监视器(生产者)和计分板(消费者)之间用 scb_mbx。
3. event 同步
生成器和驱动器之间通过 drv_done 事件同步,实现流水线式激励生成:生成器放一个事务到邮箱,然后等待驱动器完成,再放下一个。这避免了邮箱无限堆积,也模拟了真实系统中“握手”的过程。
4. virtual interface
所有组件通过 virtual interface 访问物理信号。这使得组件可以在不修改代码的情况下连接到不同的 DUT 实例(例如在顶层传递不同的接口句柄)。
5. fork-join_any 控制
在 env.run() 中,使用 fork join_any 启动所有组件。因为生成器有固定循环次数,它会在完成后退出,而其他组件(driver、monitor、scoreboard)都是 forever 循环,所以 join_any 会在生成器完成后立即继续,允许环境结束(尽管其他组件还在后台运行)。然后在顶层用 $finish 结束仿真。
6. 分层结构
- 事务层:Packet 类。
- 组件层:generator、driver、monitor、scoreboard。
- 环境层:env 类。
- 测试层:test 类。
- 物理层:interface、DUT、top module。
这种分层结构让验证环境易于扩展和维护。例如,要改变激励产生方式,只需修改 generator;要改变检查逻辑,只需修改 scoreboard;其他组件不受影响。
💡 六、总结与心法
| 组件 | 比喻 | 作用 |
|---|---|---|
| Packet | 信封 | 封装输入激励和输出结果 |
| Generator | 写信员 | 产生随机激励 |
| Driver | 邮差 | 将激励驱动到 DUT |
| Monitor | 摄像头 | 采样 DUT 输出 |
| Scoreboard | 保安 | 比对预期和实际结果 |
| Environment | 控制中心 | 组装并连接所有组件 |
| Test | 老板 | 启动环境 |
| Interface | 接线板 | 连接 DUT 和验证组件 |
验证工程师的心法:
一个好的测试平台应该像一个高效的工厂流水线:每个工位(组件)只做一件事,通过标准传送带(mailbox)和信号灯(event)协调工作。当设计有改动时,我们只需调整对应的工位,其他工位照常运行。这种模块化设计让验证环境变得可扩展、可重用、易调试。
最后的小贴士:
- 在监视器中加一个
#1延迟,确保采样到稳定的输出(因为组合逻辑在时钟沿后可能有短暂的不稳定)。 - 在 scoreboard 中复制事务对象时,使用自定义的
copy()方法,而不是直接赋值,避免引用别名问题。 - 使用
$display加上时间戳和标签,方便跟踪各个组件的执行顺序。 - 始终验证你的检查器本身是否正确——故意引入设计错误,观察它是否能检测到。
掌握了这个示例,你就掌握了构建任何复杂验证环境的核心设计模式。无论面对的是简单的加法器还是复杂的处理器,这些思想和方法都能帮助你快速搭建出高质量的测试平台!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)