SystemVerilog 验证专用语法
SystemVerilog验证专用语法指南,涵盖动态数组、关联数组、队列、对象复制、乱序scoreboard、class、SVA、coverage与UVM
SystemVerilog 验证专用语法
本章语法主要用于 Testbench 和验证环境。class、coverage、UVM 和大多数断言不可综合;
repeat、事件控制等是否可综合则取决于具体 RTL 写法和综合工具。
层级总览
第七层 验证语法 repeat, $random, $display格式符, @(posedge), #N延时
定长数组、动态数组、关联数组、队列与容器选型
↓ (Testbench专用, 验证工程师必会)
第八层 面向对象 class, extends, $cast, randomize, constraint
↓ (验证环境的基础)
第九层 断言 assert property, sequence, ##n
↓ (检查协议行为)
第十层 覆盖率 covergroup, coverpoint, cross
↓ (衡量验证进度)
第十一层 UVM框架 uvm_component, uvm_driver, uvm_monitor
(工业级验证方法学)
第七层:验证语法(Testbench专用)
7.1 repeat — 循环执行N次
// 在下降沿驱动,使信号在下一个上升沿前稳定
repeat(5) begin
@(negedge clk);
wr_en = 1;
wr_data = $urandom; // $urandom 是 SystemVerilog 内建无符号随机数函数
end
@(negedge clk);
wr_en = 0;
当循环体只需要固定重复执行,且不关心当前是第几次循环时,以上代码也可以写成:
for (int i = 0; i < 5; i++) begin
@(negedge clk);
wr_en = 1;
wr_data = $urandom;
end
@(negedge clk);
wr_en = 0;
在这个例子中,两者的执行效果相同:都在连续 5 个时钟周期准备随机写数据,信号会在随后的上升沿被 DUT 稳定采样。测试平台若在 posedge 与 DUT 同时驱动/采样,可能产生竞争(race)。
repeat(5)强调“固定重复 5 次”,不提供循环计数变量。for提供计数变量i,适合地址递增、按轮次生成数据或打印循环编号等场景。
因此,简单重复时 repeat 更直观;需要使用具体轮次或编号时,应使用 for。
7.2 随机数生成
$random // 返回32位有符号随机整数(可能为负)
$urandom // 返回32位无符号随机整数
$urandom_range(255, 0) // 0~255随机数(推荐,含边界)
$urandom_range(16) // 0~16随机数(含边界)
$urandom_range(max, min) 的参数顺序是“最大值、最小值”;省略 min 时默认为 0。若把两个参数反向书写,语言会自动交换它们,但建议仍按 max, min 的顺序书写。不要用 $random % 256 生成字节随机数:由于 $random 可能为负,结果也可能为负。
常用写法:
wr_data = $urandom_range(255, 0); // 8位随机数据:0~255
address = $urandom_range(1023, 0); // 10位随机地址:0~1023
7.3 格式化输出
$display("写入: %0d", wr_data); // 十进制,不补空格
$display("地址: %h", addr); // 十六进制
$display("二进制: %b", data); // 二进制
$display("时间: %0t", $time); // 仿真时间
格式符对比(假设 logic [7:0] value = 8'd255):
| 格式符 | 含义 | 典型输出 |
|---|---|---|
%d | 十进制,使用表达式位宽对应的默认字段宽度 | 255 |
%0d | 十进制,取消默认字段宽度 | 255 |
%h | 十六进制,通常保留表达式位宽对应的前导零 | ff |
%0h | 十六进制紧凑显示,省略不必要的前导零 | ff |
%b | 二进制,通常保留表达式位宽对应的前导零 | 11111111 |
%0b | 二进制紧凑显示,省略不必要的前导零 | 11111111 |
%t | 按 $timeformat 设置显示仿真时间,使用默认字段宽度 | 取决于 $timeformat |
%0t | 按 $timeformat 设置显示仿真时间,取消默认字段宽度 | 如 12345 |
这里的 0 表示取消默认字段宽度,并不是“补零”。由于 8 位变量的值 255 已经占满所需宽度,上表中加不加 0 看不出区别;若值为 5,典型输出分别为 %d → 5、%0d → 5、%h → 05、%0h → 5、%b → 00000101、%0b → 101。%t 和 %0t 的时间单位、精度及后缀均由 $timeformat 决定。
7.6 线程与并发控制
什么是线程
SystemVerilog 中的线程(process)是一条可以独立执行、等待和结束的仿真流程。不同线程在同一段仿真时间内并发推进;一个线程执行 #10、@(posedge clk) 或 wait(...) 时,只会暂停自己,不会阻塞其他线程。
initial 和 always 各自会启动一个独立线程:
fork...join 创建子线程
fork...join 会把其中的每条顶层语句作为一个子线程并发启动:
fork
drive_transactions(); // 子线程 1
monitor_outputs(); // 子线程 2
check_timeout(); // 子线程 3
join
三种结束方式的区别如下:
| 写法 | 父线程何时继续 | 子线程状态 |
|---|---|---|
fork...join | 所有子线程结束后 | 全部已经结束 |
fork...join_any | 任意一个子线程结束后 | 其他子线程继续运行 |
fork...join_none | 启动子线程后立即继续 | 所有子线程在后台运行 |
join_any 不会自动停止尚未结束的子线程。若这些线程不再需要,可紧接着使用 disable fork 停止当前线程创建的剩余子线程:
fork
send_packet();
wait_for_timeout();
join_any
disable fork;
经典 Testbench:Driver、Monitor 与 Timeout Watchdog 并行
下面的简化示例同时运行激励、监测和超时监控线程(Timeout Watchdog)。这里的 Watchdog 属于验证环境,用于发现仿真卡死,不是 SoC 内部的硬件看门狗定时器(WDT)。为了让示例独立展示并发关系,使用 assign ready = rst_n 模拟 DUT 始终可以接收请求;实际项目中 ready 应由 DUT 驱动。
module tb_threads;
timeunit 1ns;
timeprecision 1ps;
logic clk = 0;
logic rst_n;
logic valid;
logic ready;
logic [7:0] data;
// 独立线程:持续产生时钟
always #5ns clk = ~clk;
// 仅用于简化示例;实际验证时 ready 来自 DUT
assign ready = rst_n;
// module 中的 task 默认使用静态存储。
// 声明为 automatic 后,每次调用都有独立的参数和局部变量,
// 便于任务今后被多个线程并行或重复调用。
// 本例中的任务都只调用一次且没有局部变量,因此不写 automatic 也不影响当前行为。
task automatic reset_dut();
rst_n = 0;
valid = 0;
data = '0;
repeat (2) @(posedge clk);
@(negedge clk);
rst_n = 1;
endtask
task automatic drive_transactions();
repeat (5) begin
// 在下降沿驱动,保证信号在上升沿采样前稳定
@(negedge clk);
valid = 1;
data = $urandom_range(255, 0);
// 本例 ready 始终为 1,因此在下一个上升沿完成握手
@(posedge clk);
@(negedge clk);
valid = 0;
end
endtask
task automatic monitor_transactions();
forever begin
@(posedge clk);
if (valid && ready)
$display("t=%0t:监测到 data=0x%0h", $time, data);
end
endtask
task automatic timeout_watchdog();
#1000ns;
$fatal(1, "测试超时");
endtask
initial begin
reset_dut();
fork
drive_transactions(); // 有限线程:发送 5 笔事务后结束
monitor_transactions(); // 永久线程:持续监测接口
timeout_watchdog(); // 超时监控线程:防止仿真卡死
join_any
// 正常情况下 Driver 最先结束,停止 Monitor 和 Timeout Watchdog
disable fork;
$finish;
end
endmodule
这里不能直接使用 join:monitor_transactions() 中有 forever,它不会主动返回,父线程会一直等待。采用 join_any 后,Driver 正常发送完 5 笔事务即可让父线程继续;随后 disable fork 清理 Monitor 和 Timeout Watchdog。这个结构体现了经典 Testbench 的并发分工:
Testbench
├── Driver 产生并发送激励
├── Monitor 持续采集 DUT 行为
└── Timeout Watchdog 检查仿真是否超时或卡死
需要注意,线程描述的是仿真程序的并发执行流程,不代表一定综合出硬件。fork...join、类任务和带时间控制的 Testbench 代码通常只用于验证。
7.7 系统函数速查
| 函数 | 作用 | 示例 |
|---|---|---|
$random | 32位有符号随机整数 | $random |
$urandom_range | 指定闭区间内的无符号随机数 | $urandom_range(15, 0) |
$display | 打印信息 | $display("data=%h", data) |
$time | 仿真时间 | $display("t=%0t", $time) |
$finish | 结束仿真 | $finish |
$dumpfile | 指定波形文件 | $dumpfile("wave.vcd") |
$dumpvars | 指定dump信号 | $dumpvars(0, tb) |
$cast | 运行时检查并转换类型 | if ($cast(child, parent)) |
7.8 验证常用容器:数组与队列
验证环境需要保存 payload、采样数据、期望结果和未完成事务。选择容器时,先确定:数量是否固定、按位置还是按键访问、是否需要保持顺序。
7.8.1 四种容器的区别
| 类型 | 声明示例 | 下标和大小 | 常用操作 | 验证应用 |
|---|---|---|---|---|
| 定长数组 | int a[4]; | 本例下标为 0~3,元素数量固定 | a[i]、foreach、$size(a) | 固定 lane 数的计数器、固定长度采样窗口 |
| 动态数组 | int a[]; | 下标为 0~size()-1,运行时确定数量 | new[N]、size()、delete() | 可变长度 packet payload、burst 数据 |
| 关联数组 | int a[int unsigned]; | 按键查找,键可以不连续;也可使用字符串等类型作为键 | exists()、num()、delete(key) | 稀疏参考内存、按 ID 查找未完成事务 |
| 队列 | int a[$]; | 下标为 0~size()-1,插入和删除时改变数量 | push_back()、pop_front()、size() | 顺序 scoreboard、事务缓存、历史记录 |
注意三种相似写法:a[4] 中的 4 是固定数量;a[int] 中的 int 是键的类型;a[$] 表示队列。动态数组的 [] 则留空。
packed / unpacked 是另一个分类角度:
logic [31:0] word; // packed:一个 32 位向量
logic [31:0] lanes[4]; // unpacked 定长数组,每个元素是 32 位向量
logic [31:0] payload[]; // unpacked 动态数组
logic [31:0] memory[longint unsigned]; // unpacked 关联数组
logic [31:0] history[$]; // unpacked 队列
变量名左侧的 [31:0] 描述元素的位宽,右侧的维度描述容器。多维数组也是另一个维度概念,可以与上述容器组合。这里说下标连续,并不规定仿真器底层如何分配物理内存。
下面带有 module ... endmodule 的基础示例可以分别作为独立 SV 文件运行;类和约束片段则需要放入相应的测试环境。
7.8.2 定长数组与动态数组:固定数量和运行时分配
module tb_dynamic_array;
int fixed_a[4] = '{10, 20, 30, 40};
int data_a[];
int copied_a[];
initial begin
data_a = new[4]; // 创建 4 个 int 元素,初值均为 0
foreach (data_a[i])
data_a[i] = fixed_a[i];
data_a = new[6](data_a); // 扩到 6 个元素,保留原有的前 4 个
$display("size=%0d data=%p", data_a.size(), data_a);
// size=6,内容为 10、20、30、40、0、0
copied_a = data_a; // 复制数组元素,并自动调整目标动态数组大小
copied_a[0] = 99;
$display("original=%0d copy=%0d", data_a[0], copied_a[0]);
// original=10 copy=99:两个 int 数组独立
data_a = new[2](data_a); // 缩到 2 个,只保留前两个元素
data_a = new[2]; // 未提供旧数组:重新初始化为两个 0
data_a.delete(); // 清空,size() 变为 0
end
endmodule
new[N]创建或重新分配动态数组;new[N](old_array)在分配时复制能放得下的旧元素。- 写
data_a[1000]不会自动扩容。必须先确保下标在当前有效范围内。 - 动态数组的
delete()清空整个数组,没有delete(i)这种删除单个位置的用法;频繁插入、删除元素时优先考虑队列。 - 定长数组可以使用任意合法固定范围,例如
int a[8:5],所以并非所有定长数组都从 0 开始。遍历时使用foreach更方便。 - 上例元素为
int,因此数组赋值后数据独立。如果元素是 class 句柄,数组赋值复制的仍是句柄,详见第 8.1 节的对象快照说明。
应用:可随机化的 packet payload。
class packet;
rand byte unsigned payload[];
constraint c_payload {
payload.size() inside {[1:64]};
foreach (payload[i])
payload[i] inside {[0:255]};
}
endclass
对该对象调用 randomize() 时,求解器会按 size() 约束决定随机动态数组的大小,再随机化元素,无需先手动 new[]。这里的字节范围约束用于展示 foreach,对 byte unsigned 本身是冗余的;实际项目可换成协议允许的取值或元素之间的关系。始终检查 randomize() 的返回值。
7.8.3 关联数组:按键查找与稀疏存储
module tb_associative_array;
int expected[int unsigned];
int count_by_name[string];
initial begin
expected[3] = 10;
expected[1000] = 20;
$display("entries=%0d", expected.num()); // 2,不是 1001
if (expected.exists(1000))
$display("value=%0d", expected[1000]);
expected[3] = 99; // 更新已有键,数量仍是 2
foreach (expected[id])
$display("id=%0d value=%0d", id, expected[id]);
count_by_name["read"] = 5;
count_by_name["write"] = 2;
expected.delete(3); // 只删除键 3
expected.delete(); // 删除所有条目
end
endmodule
关联数组不需要 new[]。写入不存在的键会创建条目,但普通读取不存在的键不会自动创建条目;读取结果遵循元素的默认值或数组指定的默认值规则,仿真器还可能给出警告。不能用“读出来是 0”判断键是否存在,应使用 exists()。 对 class 元素尤其要先检查存在性和非空句柄。
| 方法 | 含义 |
|---|---|
aa.num() / aa.size() | 已存在条目的数量,不是最大键值加一 |
aa.exists(key) | 键是否存在,返回 0 或 1 |
aa.delete(key) | 删除指定键,不改变其他键的含义 |
aa.delete() | 清空整个关联数组 |
aa.first(key) / aa.last(key) | 把第一个/最后一个键写入参数,返回是否成功 |
aa.next(key) / aa.prev(key) | 从当前键寻找后一个/前一个键,返回是否成功 |
遍历整数键时按数值顺序,字符串键按字典序,并非按照写入先后顺序。需要记录先后顺序时应使用队列。优先写明键类型,如 [int unsigned]、[string],避免通配键 [*] 带来的遍历和类型限制。
应用:稀疏参考内存。
module tb_sparse_memory;
logic [7:0] ref_mem[longint unsigned]; // 按字节地址保存期望值
initial begin
ref_mem[64'h0000_1000] = 8'hA5;
ref_mem[64'h8000_1000] = 8'h5A;
$display("stored bytes=%0d", ref_mem.num()); // 只记录两个字节地址
if (ref_mem.exists(64'h8000_1000))
$display("expected=%h", ref_mem[64'h8000_1000]);
end
endmodule
参考模型还需按设计规格处理字节使能、地址映射、未写地址的初始值和并发读写顺序。稀疏内存记录“某地址当前应有的数据”;未完成事务表记录“某请求还在等待哪一个响应”,两者不能混为一张表。
7.8.4 队列:按顺序插入、取出和删除
module tb_queue;
int q[$];
int value;
initial begin
q.push_back(10); // [10]
q.push_back(20); // [10, 20]
q.push_front(5); // [5, 10, 20]
q.insert(1, 7); // [5, 7, 10, 20]
q.delete(2); // [5, 7, 20],后续元素的位置会前移
if (q.size() > 0) begin
value = q.pop_front(); // value=5,q=[7, 20]
$display("first=%0d remaining=%p", value, q);
end
if (q.size() > 0)
value = q.pop_back(); // value=20,q=[7]
q.delete(); // 清空
end
endmodule
push_back()配合pop_front()实现 FIFO;push_back()配合pop_back()实现栈。- 队列也能用
q[i]随机访问,但下标表示当前位置,不是稳定的事务 ID。删除队头后,其余元素的下标会改变。 pop_front()/pop_back()不会阻塞等待数据。空队列时应先检查size(),不能把返回的默认值当成有效事务。int q[$:15]的上限为 16 个元素。有界队列满了不会提供硬件 FIFO 式的反压或阻塞等待;应用需要显式处理容量,避免截断或丢弃数据。- 队列适合保存历史记录,但不是线程同步机制。需要生产者/消费者阻塞通信时,可用
mailbox或 UVM TLM FIFO;它们传递 class 对象时同样需要约定对象所有权。
7.8.5 遍历、查找和排序
module tb_array_methods;
int values[$] = '{30, 10, 20, 10};
int indices[$];
initial begin
foreach (values[i])
$display("index=%0d value=%0d", i, values[i]);
indices = values.find_index() with (item == 10);
$display("matching indices=%p", indices); // 两个位置:1、3
values.sort(); // 原地排序:10、10、20、30
values.reverse(); // 反转当前顺序:30、20、10、10
end
endmodule
find_index() 返回的是匹配下标组成的队列;没有匹配时结果为空。find() 则返回匹配元素组成的队列。数组方法是否适用还取决于容器和元素类型,例如关联数组不支持上述 sort()、reverse() 排序方法。
在 scoreboard 中,按明确 ID 查表通常比自行遍历所有未完成事务更直接。但不要声称所有仿真器的关联数组查询都固定为 O(1),底层实现不由这种声明保证。也不要先把实际事务排序或按 payload 随意寻找“能比对成功的一项”,这样可能掩盖 DUT 的非法乱序。
7.8.6 根据验证需求选容器
| 验证需求 | 推荐容器 | 选择原因 |
|---|---|---|
| 8 个固定端口的计数器 | 定长数组 | 端口数量固定,按端口号访问 |
| 长度运行时确定的数据包 | 动态数组 | 一次确定或随机化整个 payload 的大小 |
| 最近 N 笔采样记录 | 队列+显式长度限制 | 新记录入队,超出窗口后删除最旧记录 |
| 严格按请求顺序返回的结果 | 一个期望队列 | 实际结果与队头比较 |
| 未完成事务匹配键唯一,允许乱序返回 | 关联数组 | 根据响应键直接找到期望事务 |
| 不同 ID 可乱序,同 ID 内多笔事务保序 | 关联数组,每个键对应一个队列 | 按 ID 定位,再按该 ID 的先后顺序比较 |
| 大地址空间,只访问少量位置 | 关联数组 | 只为已记录的地址保存数据 |
乱序 scoreboard 的具体例子见第 11.6 节。声明为数组或队列只解决保存方式;匹配规则、合法顺序、超时和结束检查仍需要另外实现。
#1step 和 #0
default input #1step output #0;
input #1step:在 clocking event 前一个最小仿真步长采样输入,得到边沿前稳定值;output #0:在 clocking event 对应时刻驱动输出,不增加额外时间延迟;1step不是一个时钟周期,也不是固定的1ns,而是由仿真时间精度决定的最小步长。
通过明确采样和驱动偏移,可以减少 Testbench 与 DUT 在同一时间槽访问相同信号时产生的 race。
在 Testbench 中使用
fifo_if vif(clk);
// Driver进程:等待drv_cb的下降沿事件,然后通过时钟块驱动
initial begin
@(vif.drv_cb);
vif.drv_cb.wr_en <= 1'b1;
vif.drv_cb.wr_data <= 8'h55;
@(vif.drv_cb);
vif.drv_cb.wr_en <= 1'b0;
end
// Monitor进程:每个mon_cb事件读取本次采样快照
always @(vif.mon_cb) begin
$display("wr_en=%b wr_data=%h full=%b",
vif.mon_cb.wr_en,
vif.mon_cb.wr_data,
vif.mon_cb.full);
end
Assertion 中的 default clocking
default clocking cb @(posedge clk);
endclocking
property p_not_both;
!(full && empty);
endproperty
assert property (p_not_both);
default clocking 为当前作用域内没有显式写采样事件的 property 提供默认时钟,避免每条断言重复写 @(posedge clk)。
是否必须使用
第八层:面向对象(class)
8.1 class 基础
什么是方法
方法 = 类能做的事情(操作数据的函数)
内存模型
句柄 txn 对象(堆内存)
┌────────┐ ┌─────────────────┐
│ 0x1000 │──────>│ addr = 0x100 │
└────────┘ │ data = 0xABCD │
│ wr = 1 │
└─────────────────┘
句柄 vs 对象
句柄(handle)= 对象的地址(类似指针)
句柄 txn 对象(内存中)
┌────────┐ ┌─────────────┐
│ 0x1000 │──────>│ addr = 0x100│
└────────┘ │ data = 0xFF │
└─────────────┘
transaction txn; // 声明句柄(还没指向对象)
transaction txn2;
txn = new(); // 创建对象,句柄指向它
txn2 = txn; // 两个句柄指向同一个对象
txn2.addr = 32'hFF; // 修改txn.addr也会变(同一个对象)
重要区别:
| 操作 | 含义 |
|---|---|
transaction txn; | 声明句柄(空的) |
txn = new(); | 创建对象,句柄指向它 |
txn2 = txn; | 两个句柄指向同一个对象 |
简单比喻:句柄是”遥控器”,对象是”电视”
遥控器A ──┐
│
遥控器B ──┼──> 同一台电视
│
遥控器C ──┘
一个对象可以有多个句柄。通过任意句柄修改对象成员,其他句柄都能看到变化;但给某个句柄重新赋值、让它指向另一个对象,不会改变其他句柄的指向。
容器里的句柄与对象快照
把 transaction 放进数组或队列,不会自动复制 transaction 对象。
class sample_item;
int data;
endclass
module tb_handle_snapshot;
sample_item q[$];
sample_item source;
sample_item snapshot;
initial begin
source = new();
source.data = 10;
q.push_back(source); // 保存句柄,两者指向同一个对象
source.data = 20;
$display("queued=%0d", q[0].data); // 20,并非入队时的 10
snapshot = new source; // SV 浅拷贝:新建对象并复制其成员
q.push_back(snapshot);
source.data = 30;
$display("shared=%0d snapshot=%0d", q[0].data, q[1].data);
// shared=30 snapshot=20;本例只有 int 成员,所以快照独立
end
endmodule
| 操作 | 容器/外层对象 | 内部 class 对象 |
|---|---|---|
b = a,a/b 是 class 句柄 | 指向同一个对象 | 共享 |
queue_b = queue_a | 复制队列元素,两个队列可分别增删 | 若元素是句柄,所指对象仍共享 |
b = new a | 创建新对象并进行浅拷贝 | 若成员是句柄,所指子对象仍共享 |
| 正确实现的递归复制 | 创建独立快照 | 按复制策略复制需要独立的子对象 |
动态数组、关联数组的整体赋值也要区分“复制元素”和“复制元素指向的对象”。在 UVM 中通常用 clone() 创建快照,或先创建目标对象再调用 copy();默认 clone() 通过 create() 和 copy() 完成复制。需要通过字段宏或 do_copy() 正确实现成员复制,只有 factory 注册宏并不代表所有成员都会被复制。包含子对象时,还要检查它们的复制策略。
Scoreboard 应在接收事务、保存到容器时建立稳定快照,或者与发送方约定“发布后不再修改该对象”。第 11.6 节使用 clone() 保存快照。
当一个 class 把另一个 class 的句柄保存为成员,并负责创建和管理该对象时,这些成员句柄还可以形成**合成(composition)**关系。句柄是实现机制,合成是对象之间的组织关系,详见后面的“继承与合成的选择”。
8.2 继承(extends)
| 优势 | 说明 |
|---|---|
| 代码复用 | 父类5个属性不用重复写 |
| 易于维护 | 修改父类,所有子类自动生效 |
| 扩展方便 | 子类只需关注特有属性 |
| 统一接口 | 所有transaction可以用同一个句柄 |
关键点
| 关键字 | 作用 |
|---|---|
extends | 继承父类 |
super.new() | 调用父类构造函数(必须第一行) |
virtual | 允许子类重写方法 |
virtual 关键字
// 父类方法不加virtual
class base;
function void display();
$display("base");
endfunction
endclass
class child extends base;
function void display(); // 这是隐藏,不是重写
$display("child");
endfunction
endclass
// 父类方法加virtual
class base;
virtual function void display(); // 允许重写
$display("base");
endfunction
endclass
class child extends base;
function void display(); // 这是重写
$display("child");
endfunction
endclass
| 情况 | 行为 |
|---|---|
不加virtual | 隐藏(hiding) |
加virtual | 重写(override) |
建议:只有需要通过父类句柄体现多态行为的方法才加 virtual。构造函数等不参与多态分派的方法无需声明为 virtual。
继承关系
base_transaction extended_transaction
┌──────────────┐ ┌──────────────────┐
│ addr │ │ addr (继承) │
└──────────────┘ │ data (新增) │
│ wr (新增) │
└──────────────────┘
使用示例
extended_transaction ext_txn;
initial begin
ext_txn = new(32'h100, 32'hABCD);
// 可以访问父类和子类的属性
$display("addr = %h", ext_txn.addr); // 父类
$display("data = %h", ext_txn.data); // 子类
$display("wr = %b", ext_txn.wr); // 子类
end
方法重写(override)
仅看类的定义不容易看出重写的效果。关键场景是:父类句柄指向子类对象,然后通过父类句柄调用方法。
class base_transaction;
logic [31:0] addr;
virtual function void display();
$display("base: addr=%h", addr);
endfunction
endclass
class extended_transaction extends base_transaction;
logic [31:0] data;
function void display(); // 重写父类的虚方法
$display("extended: addr=%h, data=%h", addr, data);
endfunction
endclass
module tb;
initial begin
base_transaction base_txn; // 父类句柄
extended_transaction ext_txn; // 子类句柄
ext_txn = new();
ext_txn.addr = 32'h1000;
ext_txn.data = 32'hABCD;
// 父类句柄指向子类对象
base_txn = ext_txn;
base_txn.display();
end
endmodule
父类的 display() 带有 virtual,因此根据 base_txn 指向的实际对象类型调用子类版本:
extended: addr=00001000, data=0000abcd
如果删除父类方法前面的 virtual,其他代码保持不变:
function void display();
$display("base: addr=%h", addr);
endfunction
再次执行同一条语句:
base_txn.display();
输出将变为:
base: addr=00001000
| 父类方法 | base_txn.display() 的结果 | 判断依据 |
|---|---|---|
不加 virtual | 调用父类版本 | 看句柄的声明类型 |
加 virtual | 调用子类版本 | 看句柄指向的实际对象类型 |
**记忆:**没有
virtual看句柄类型;有virtual看实际对象类型。
安全的向下转换:$cast
父类句柄可以直接接收子类句柄,这称为向上转换。反过来,父类句柄不一定真的指向目标子类对象,因此不能直接赋给子类句柄,需要用 $cast 在运行时检查:
base_transaction base_txn;
extended_transaction ext_txn;
extended_transaction generated_txn;
generated_txn = new();
generated_txn.addr = 32'h1000;
generated_txn.data = 32'hABCD;
base_txn = generated_txn; // 向上转换:子类 -> 父类,隐式且安全
// 向下转换:父类 -> 子类,检查实际对象是否兼容
if ($cast(ext_txn, base_txn)) begin
$display("data = %h", ext_txn.data);
end
else begin
$error("base_txn实际指向的对象不是extended_transaction");
end
语法为 $cast(目标变量, 源表达式),目标变量的声明类型就是要转换到的类型。其工作过程如下:
- 仿真器查看源句柄当前指向对象的实际类型;
- 若该对象与目标类型兼容,将同一个对象的句柄赋给目标变量并返回
1; - 若不兼容,不修改目标变量并返回
0。
$cast 只转换句柄,既不会调用 new(),也不会复制对象。转换成功后,base_txn 和 ext_txn 仍然指向同一个对象。
base_txn ──┐
├──> 同一个 extended_transaction 对象
ext_txn ──┘
失败场景如下。普通父类对象不是子类对象,所以转换失败:
base_transaction plain_txn;
extended_transaction ext_txn;
plain_txn = new();
ext_txn = null;
if (!$cast(ext_txn, plain_txn)) begin
$display("类型不兼容"); // ext_txn仍为null
end
常用写法有两种:
// 函数形式:主动处理成功或失败,验证代码中更常用
if (!$cast(ext_txn, base_txn))
$fatal(1, "类型转换失败");
// 任务形式:忽略返回值;失败时由仿真器报告运行时错误
$cast(ext_txn, base_txn);
| 写法 | 检查时机 | 失败行为 | 典型用途 |
|---|---|---|---|
target_type'(expr) | 不检查对象的运行时类型 | 直接按目标类型解释或转换 | 数值位宽、符号等静态转换 |
$cast(dst, src) | 仿真运行时检查 | 返回 0,可自行报错或跳过 | 父类句柄向子类句柄转换、UVM 基类接口取回具体对象 |
在 UVM 中,factory、clone()、uvm_object 形参等经常返回或接收基类句柄;要访问具体 transaction 的字段时,通常先用 $cast 检查实际类型。不要在未检查返回值的情况下继续解引用目标句柄,否则类型错误会变成更难定位的空句柄或旧句柄问题。
继承与合成的选择
继承和合成都能复用已有代码,但表达的关系不同:
继承:A is-a B,A是一种B
合成:A has-a B,A拥有一个B
继承使用 extends。例如 write_transaction 是一种更具体的 base_transaction:
class write_transaction extends base_transaction;
logic [31:0] data;
endclass
合成通常通过 class 的成员句柄实现。例如一个 agent 拥有 driver 和 monitor:
class simple_driver;
endclass
class simple_monitor;
endclass
class simple_agent;
// 成员句柄:声明后初始为null
simple_driver driver;
simple_monitor monitor;
function new();
// agent负责创建和管理自己的组成对象
driver = new();
monitor = new();
endfunction
endclass
对应关系为:
simple_agent
├── driver
└── monitor
8.3 随机化(randomize)
基本语法
// 定义可随机化的transaction类
class random_transaction;
rand logic [31:0] addr; // rand: 可随机化(每次随机可能重复)
rand logic [31:0] data; // rand: 可随机化
randc bit [7:0] id; // randc: 随机循环(不重复,遍历所有值后重来)
// 约束:限制随机范围
constraint c_addr {
addr inside {[32'h0000:32'h00FF]}; // 地址范围:0x0000~0x00FF
}
constraint c_data {
data != 0; // 数据不能为0
}
endclass
// 使用:生成随机激励
random_transaction txn; // 声明句柄
initial begin
txn = new(); // 创建对象
repeat(10) begin // 重复10次
assert(txn.randomize()) // 随机化成功返回1,失败返回0
$display("addr=%h, data=%h", txn.addr, txn.data); // 打印随机值
else
$error("随机化失败"); // 约束冲突时报错
end
end
randomize() 返回值
// 返回1:随机化成功
// 返回0:随机化失败(约束冲突)
assert(txn.randomize())
$display("成功");
else
$error("失败");
内联约束(inline constraint)
// 只随机化某些字段
assert(txn.randomize() with {
addr == 32'h100; // 强制addr=0x100
data inside {[0:100]}; // data在0~100
})
8.4 约束详解
class constrained_transaction;
rand logic [7:0] cmd;
rand logic [31:0] addr;
rand logic [31:0] data;
rand bit [2:0] burst_len;
constraint c_cmd {
cmd inside {8'h01, 8'h02, 8'h03}; // 枚举值
}
constraint c_addr {
addr[1:0] == 2'b00; // 地址对齐
addr < 32'h1000; // 地址范围
}
constraint c_burst {
burst_len inside {[1:8]}; // 突发长度1~8
soft data == 0; // soft: 软约束(可被覆盖)
}
// 条件约束
constraint c_cond {
if (cmd == 8'h01)
addr inside {[32'h000:32'h0FF]};
else
addr inside {[32'h100:32'h1FF]};
}
endclass
条件约束
constraint c_cond {
if (cmd == 8'h01)
addr inside {[32'h000:32'h0FF]};
else
addr inside {[32'h100:32'h1FF]};
}
约束块命名
constraint c1 { ... } // 命名约束块
constraint c2 { ... } // 可以单独禁用/启用
地址对齐详解
什么是地址对齐?
地址对齐 = 地址必须是某个边界的整数倍
// 4字节对齐:地址必须是4的倍数
addr[1:0] == 2'b00; // 低2位必须为00
// 8字节对齐:地址必须是8的倍数
addr[2:0] == 3'b000; // 低3位必须为000
为什么需要对齐?
CPU、总线和存储器可能按字(word)访问数据。常见的 32 位访问一次为 4 字节,因此经常要求 4 字节对齐;实际对齐要求仍以 ISA、总线协议和存储器属性为准。
不对齐会怎样?
| 情况 | 后果 |
|---|---|
| 对齐 | 一次读取完成,效率高 |
| 不对齐 | 需要读两次再拼接,效率低,甚至报错 |
地址0x0000(对齐):
一次读取:[0x0000-0x0003] → 完成
地址0x0001(不对齐):
读取1:[0x0000-0x0003]
读取2:[0x0004-0x0007]
拆分拼接 → 效率低
CPU位宽与对齐关系
| CPU位宽 | 对齐要求 | 检查位 | 一次读取 |
|---|---|---|---|
| 8位 | 1字节对齐 | 无要求 | 1字节 |
| 16位 | 2字节对齐 | addr[0]==0 | 2字节 |
| 32位 | 4字节对齐 | addr[1:0]==00 | 4字节 |
| 64位 | 8字节对齐 | addr[2:0]==000 | 8字节 |
并非总是 4 的倍数:对齐要求取决于访问宽度和具体协议
// 16位CPU:2字节对齐
constraint c_align_16 {
addr[0] == 1'b0; // 低1位为0
}
// 32位CPU:4字节对齐
constraint c_align_32 {
addr[1:0] == 2'b00; // 低2位为0
}
// 64位CPU:8字节对齐
constraint c_align_64 {
addr[2:0] == 3'b000; // 低3位为0
}
二进制规律:低N位为0,就是2^N的倍数
十进制 二进制 低2位 低3位
0 0000 00 ✅ 000 ✅ 是4和8的倍数
4 0100 00 ✅ 100 ❌ 是4的倍数,不是8的
8 1000 00 ✅ 000 ✅ 是4和8的倍数
1 0001 01 ❌ 001 ❌ 都不是
2 0010 10 ❌ 010 ❌ 都不是
3 0011 11 ❌ 011 ❌ 都不是
约束写法
// 地址对齐 + 范围约束
constraint c_addr {
addr[1:0] == 2'b00; // 4字节对齐
addr inside {[32'h000:32'h1FF]}; // 地址范围
}
// 用取模运算符写对齐约束(效果相同)
constraint c_align_mod {
addr % 4 == 0; // 等价于 addr[1:0] == 0
}
位操作 vs 取模,两种写法对比
// 写法1:位操作(推荐,表达清晰且通常更利于约束求解)
constraint c1 { addr[1:0] == 2'b00; }
// 写法2:取模运算(更直观)
constraint c2 { addr % 4 == 0; }
核心概念总结
| 概念 | 本质 | 目的 |
|---|---|---|
| 约束 | 加条件限制随机值范围 | 让随机值有意义,符合协议规范 |
| 随机 | 自动生成测试向量 | 提高测试效率和覆盖率 |
| 对齐 | 让CPU一次读完数据 | 提高访问效率,减少读取次数 |
示例4:scoreboard - 比较结果
// scoreboard类:比较预期结果和实际结果
class scoreboard;
int pass_count = 0; // 通过计数
int fail_count = 0; // 失败计数
// 检查函数:比较expected和actual
function void check(input logic [31:0] expected, // 预期值
input logic [31:0] actual); // 实际值
if (expected === actual) begin // 全等比较
$display("PASS: %h == %h", expected, actual); // 打印通过
pass_count++; // 通过计数+1
end else begin
$display("FAIL: %h != %h", expected, actual); // 打印失败
fail_count++; // 失败计数+1
end
endfunction
endclass
使用示例
scoreboard sb;
initial begin
sb = new();
sb.check(32'hDEAD_BEEF, actual_data); // 比较
end
// 最后统计
$display("Pass: %0d, Fail: %0d", sb.pass_count, sb.fail_count);
示例5:parameterized class - 参数化类
class fifo #(parameter WIDTH = 8, DEPTH = 16);
logic [WIDTH-1:0] queue[$];
function bit push(input logic [WIDTH-1:0] data);
if (queue.size() >= DEPTH) begin
$error("FIFO model overflow");
return 0;
end
queue.push_back(data);
return 1;
endfunction
function bit pop(output logic [WIDTH-1:0] data);
if (queue.size() == 0) begin
$error("FIFO model underflow");
return 0;
end
data = queue.pop_front();
return 1;
endfunction
endclass
// 使用
fifo #(8, 16) fifo8; // 8位宽,深度16
fifo #(32, 64) fifo32; // 32位宽,深度64
深度、地址位数、数据位宽、字节数详解
四个核心概念
| 概念 | 定义 | 示例 |
|---|---|---|
| 深度 | 能存多少个数据 | 16个 |
| 地址位数 | 寻址深度需要几位 | 4位(2^4=16) |
| 数据位宽 | 每个数据多少位 | 8位(1字节) |
| 字节数 | 总容量 | 16×1=16字节 |
关系公式
地址位数 = $clog2(深度)
字节数 = 深度 × 数据位宽 / 8
示例计算
fifo #(8, 16) fifo8;
// 深度:16
// 地址位数:4位(2^4=16)
// 数据位宽:8位(1字节)
// 字节数:16 × 1 = 16字节
fifo #(32, 64) fifo32;
// 深度:64
// 地址位数:6位(2^6=64)
// 数据位宽:32位(4字节)
// 字节数:64 × 4 = 256字节
常见配置表
| 深度 | 地址位数 | 数据位宽 | 字节数 |
|---|---|---|---|
| 4 | 2位 | 8位 | 4字节 |
| 8 | 3位 | 8位 | 8字节 |
| 16 | 4位 | 8位 | 16字节 |
| 64 | 6位 | 32位 | 256字节 |
| 1024 | 10位 | 32位 | 4096字节 |
第九层:断言(SVA)
9.1 基础断言
// 简单断言
assert property (@(posedge clk) disable iff (!rst_n)
req |-> ##[1:3] ack
) else $error("req 后 1~3 个周期内未收到 ack");
// 含义:req 有效后,1~3 个周期内必须有 ack
9.2 序列(sequence)
// 定义序列
sequence s_wr;
awvalid && awready;
endsequence
sequence s_data;
wvalid && wready;
endsequence
// 使用序列
property p_wr_data;
@(posedge clk) disable iff (!rst_n)
s_wr |-> ##[0:1] s_data;
endproperty
assert property (p_wr_data);
这里仅演示 sequence 的定义和组合,不是完整 AXI 协议断言:AXI 的 AW 和 W 通道可独立握手,不能强制所有设计都满足上述先后关系。
稍复杂的例子:描述一个跨多个周期的完整事务
// 子序列1:发出 req 后,1~3 个周期内必须收到 grant
sequence s_req_grant;
req ##[1:3] grant;
endsequence
// 子序列2:连续两个周期完成数据握手
sequence s_two_data_beats;
(data_valid && data_ready)[*2];
endsequence
// 组合序列:
// 请求授权完成后的下一周期开始传输两个数据,
// 最后一个数据传输完成后的下一周期出现 done
sequence s_complete_transfer;
s_req_grant ##1 s_two_data_beats ##1 done;
endsequence
property p_complete_transfer;
@(posedge clk) disable iff (!rst_n)
start |=> s_complete_transfer;
endproperty
assert property (p_complete_transfer)
else $error("完整事务的时序不符合要求");
这里的 sequence 不再只是给单个条件起名字,而是描述并组合一段跨周期过程:
start
→ 下一周期出现 req
→ req 后 1~3 个周期内出现 grant
→ 下一周期开始连续完成两次数据握手
→ 下一周期出现 done
例如,下面是一种满足断言的时序:
周期 T0 T1 T2 T3 T4 T5 T6
start 1
req 1
grant 1
data_valid/ready 1 1
done 1
其中,##[1:3] 表示等待 1~3 个周期,[*2] 表示前面的条件必须连续匹配两个周期。s_complete_transfer 又把两个子序列组合成了一个可以复用的完整时序模式。
9.3 常用断言操作符
蕴含符号(Implication)
// |-> 重叠蕴含:前提成立的同一周期检查结论
assert property (@(posedge clk)
req |-> ack
);
// 含义:如果 req 为真,那么同一周期 ack 必须为真
// |=> 非重叠蕴含:前提成立的下一周期开始检查结论
assert property (@(posedge clk)
req |=> ack
);
// 含义:如果 req 为真,那么下一周期 ack 必须为真
// 对比时序图:
// clk: ↑ ↑ ↑
// req: 1 0 0
// ack(|->): 1 0 0 ← 同一周期检查
// ack(|=>): 0 1 0 ← 下一周期检查
延迟操作符(##)
// ##n 延迟n个周期
assert property (@(posedge clk) disable iff (!rst_n)
req |-> ##3 ack
);
// 含义:req 为真后,恰好 3 个周期时 ack 必须为真
// ##[min:max] 延迟范围
assert property (@(posedge clk) disable iff (!rst_n)
req |-> ##[1:5] ack
);
// 含义:req 为真后,1~5个周期内必须有 ack
// ##0 同一周期(与 |-> 类似)
assert property (@(posedge clk) disable iff (!rst_n)
req |-> ##0 ack
);
// 含义:req 为真时,同一周期 ack 必须为真
// 无界延迟:表达“最终发生”,不能替代有上限的 timeout 检查
assert property (@(posedge clk) disable iff (!rst_n)
req |-> strong(##[1:$] ack)
);
// 含义:req 为真后,最终必须有 ack;工程中的 timeout 通常应写成有界范围
组合使用
// 多步延迟
assert property (@(posedge clk) disable iff (!rst_n)
req |-> ##1 req_ack ##2 data_valid
);
// 含义:req → 1周期后 req_ack → 2周期后 data_valid
// 范围延迟 + 蕴含
assert property (@(posedge clk) disable iff (!rst_n)
$rose(valid) |-> ##[1:3] ready && (data == expected)
);
// 含义:valid 上升沿后,1~3周期内 ready 为真且数据正确
系统函数
高频(必须掌握):
// $past(signal) - 前1个周期的值
assert property (@(posedge clk) disable iff (!rst_n)
wr_data == $past(wr_data) + 1
);
// 含义:复位释放后,数据每周期递增1;disable iff 也避免仿真起始时 $past 无历史值
// $past(signal, n) - 前n个周期的值
assert property (@(posedge clk)
$past(valid, 2) |-> ack
);
// 含义:2个周期前的 valid 为真时,当前 ack 必须为真
// $rose(signal) - 信号上升沿(从0变1)
assert property (@(posedge clk)
$rose(valid) |-> ##[1:3] ready
);
// 含义:valid 上升沿后,1~3周期内 ready 必须为真
// $fell(signal) - 信号下降沿(从1变0)
assert property (@(posedge clk)
$fell(valid) |-> !ready
);
// 含义:valid 下降沿时,ready 必须为假
// $stable(signal) - 信号稳定(没有变化)
assert property (@(posedge clk)
$stable(data) |-> ack
);
// 含义:data 没变时,ack 必须为真
// $changed(signal) - 信号变化(有改变)
assert property (@(posedge clk)
$changed(data) |-> ##1 valid
);
// 含义:data 变化后,下一周期 valid 必须为真
中频(偶尔使用):
// $countones(signal) - 统计1的个数
assert property (@(posedge clk)
$countones(flags) == 4 |-> done
);
// 含义:flags 中有4个1时,done 必须为真
// $onehot(signal) - 只有1位为1
assert property (@(posedge clk)
$onehot(sel) |-> valid
);
// 含义:sel 只有1位为1时,valid 必须为真
低频(很少使用):
// $onehot0(signal) - 最多1位为1(可以全0)
assert property (@(posedge clk)
$onehot0(sel) |-> valid
);
// $isunknown(signal) - 是否有未知态X/Z
assert property (@(posedge clk)
!$isunknown(data) |-> valid
);
// 含义:data 没有X/Z时,valid 必须为真
// $sampled(signal) - 取得并发断言当前采样值(常用于 assertion 的 action block)
assert property (@(posedge clk)
$sampled(cmd) == 8'h01 |-> rd_en
);
其他操作符
// within:左侧序列必须完全落在右侧序列的匹配窗口内
sequence s_valid_burst;
valid[*1:3];
endsequence
sequence s_transfer_window;
$rose(start) ##[1:5] done;
endsequence
assert property (@(posedge clk) s_valid_burst within s_transfer_window);
// throughout:在右侧序列匹配的每个周期,左侧表达式都必须为真
sequence s_reset_active;
!rst_n[*1:$];
endsequence
assert property (@(posedge clk) !wr_en throughout s_reset_active);
9.4 覆盖断言
// cover属性:统计场景出现次数
property p_cover_wr;
@(posedge clk)
wr_en && !full |=> wr_en && (wr_data == $past(wr_data) + 1);
endproperty
cover property (p_cover_wr);
// assume属性:假设条件(用于形式验证)
assume property (@(posedge clk)
reset |=> !wr_en
);
第十层:覆盖率(covergroup)
10.1 基础covergroup
class transaction_coverage;
rand logic [7:0] cmd;
rand logic [31:0] addr;
rand logic [31:0] data;
covergroup cg;
// 命令覆盖点
cp_cmd: coverpoint cmd {
bins read = {8'h01};
bins write = {8'h02};
bins reset = {8'h03};
}
// 地址覆盖点
cp_addr: coverpoint addr {
bins low = {[0:32'hFF]};
bins mid = {[32'h100:32'h1FF]};
bins high = {[32'h200:32'h2FF]};
}
// 交叉覆盖
cx_cmd_addr: cross cp_cmd, cp_addr;
endgroup
// 类内 covergroup 在构造函数中实例化
function new();
cg = new();
endfunction
// 采样
function void sample();
cg.sample();
endfunction
endclass
10.2 覆盖点详解
covergroup cg;
// 基本覆盖点
cp_data: coverpoint data {
bins zero = {0};
bins small = {[1:127]};
bins large = {[128:255]};
bins others = default; // 其他值
}
// 带权重
cp_cmd: coverpoint cmd {
bins low_weight = {1} weight 1;
bins high_weight = {2} weight 10;
}
// 自动分箱
cp_auto: coverpoint addr {
bins auto[] = {[0:255]}; // 自动分成N个bin
}
// 序列覆盖
cp_seq: coverpoint data {
bins seq1 = (0 => 1 => 2);
bins seq2 = (3[*3]); // 3连续出现3次
}
endgroup
10.3 覆盖率目标
// 设置覆盖率目标
covergroup cg;
cp_cmd: coverpoint cmd {
bins read = {8'h01};
bins write = {8'h02};
}
// 全局目标
option.goal = 100; // 100%覆盖率
// 单个覆盖点目标
cp_cmd.option.goal = 90;
endgroup
// 在模块、program 或 interface 作用域中创建 covergroup 实例
cg cg_inst = new();
// 检查覆盖率
initial begin
// 运行测试...
#10000;
if (cg_inst.get_coverage() >= 100)
$display("覆盖率达标: %0f%%", cg_inst.get_coverage());
else
$warning("覆盖率不足: %0f%%", cg_inst.get_coverage());
end
第十一层:UVM框架
build_phase()、connect_phase()、run_phase() 本质上都是 UVM 父类中的虚方法。子类写出同名方法是在运用前面介绍的方法重写;UVM 通过父类句柄调用不同组件的 phase,是多态的实际应用。
11.3 接口与 transaction
import uvm_pkg::*;
`include "uvm_macros.svh"
// interface 把同一协议的信号集中在一起
interface my_bus_if(input logic clk);
logic wr;
logic valid;
logic ready;
logic [31:0] addr;
logic [31:0] data;
endinterface
// transaction 只描述“传什么”,不描述具体时钟时序
class my_transaction extends uvm_sequence_item;
rand bit wr;
rand bit [31:0] addr;
rand bit [31:0] data;
// 注册到 UVM factory,并注册字段。
// 注册字段后,可直接使用 print()、sprint()、copy()、compare() 等方法。
`uvm_object_utils_begin(my_transaction)
`uvm_field_int(wr, UVM_ALL_ON)
`uvm_field_int(addr, UVM_ALL_ON)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "my_transaction");
super.new(name);
endfunction
endclass
关联前文知识:
extends:继承uvm_sequence_item已有的打印、复制和比较能力;rand:允许 sequence 调用randomize()产生随机激励;bit:transaction 通常只保存 0/1,使用二态类型可减少仿真开销;interface:连接静态的模块世界和动态的 class 世界;virtual interface:类不能直接用层次路径连接普通 interface,因此保存一个虚接口句柄。
宏与 factory 的关系:
`uvm_object_utils(my_transaction)
my_transaction::type_id::create("txn");
type_id::create() 会通过 factory 创建对象,使测试可以在不修改原组件源码的情况下进行类型替换(factory override)。它最终仍会调用类的 new() 构造函数。
11.4 Driver:把 transaction 转成引脚信号
class my_driver extends uvm_driver #(my_transaction);
`uvm_component_utils(my_driver)
// virtual 表示这是 interface 的句柄,不是新的硬件实例
virtual my_bus_if vif;
function new(string name = "my_driver", uvm_component parent = null);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
// 从 UVM 配置数据库取得顶层 testbench 放入的接口句柄
if (!uvm_config_db#(virtual my_bus_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "my_driver 未取得虚接口 vif")
endfunction
task run_phase(uvm_phase phase);
my_transaction txn;
// 先给输出信号确定的初始值
vif.valid <= 1'b0;
forever begin
// 1. 阻塞等待 sequencer 提供下一个 transaction
seq_item_port.get_next_item(txn);
// 2. 在下降沿改变信号,避免与 DUT 在上升沿采样产生竞争
@(negedge vif.clk);
vif.wr <= txn.wr;
vif.addr <= txn.addr;
vif.data <= txn.data;
vif.valid <= 1'b1;
// 3. valid && ready 表示一次传输在该上升沿完成
do @(posedge vif.clk); while (!vif.ready);
// 4. 撤销 valid,并通知 sequencer 当前 transaction 已完成
@(negedge vif.clk);
vif.valid <= 1'b0;
seq_item_port.item_done();
end
endtask
endclass
uvm_driver #(my_transaction) 中的 #(...) 是参数化类,表示这个 driver 只接收 my_transaction 类型的数据。get_next_item() 与 item_done() 必须成对使用,否则 sequence 可能一直等待。
11.5 Monitor:把引脚信号还原成 transaction
class my_monitor extends uvm_monitor;
`uvm_component_utils(my_monitor)
virtual my_bus_if vif;
// analysis_port 是广播端口:一个 monitor 可以把数据发给
// scoreboard、coverage collector 等多个订阅者。
uvm_analysis_port #(my_transaction) ap;
function new(string name = "my_monitor", uvm_component parent = null);
super.new(name, parent);
ap = new("ap", this);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db#(virtual my_bus_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "my_monitor 未取得虚接口 vif")
endfunction
task run_phase(uvm_phase phase);
my_transaction txn;
forever begin
@(posedge vif.clk);
// 只在真正握手成功的时钟沿采集一次
if (vif.valid && vif.ready) begin
txn = my_transaction::type_id::create("txn");
txn.wr = vif.wr;
txn.addr = vif.addr;
txn.data = vif.data;
// write() 是非阻塞广播,不需要等待 scoreboard 返回
ap.write(txn);
end
end
endtask
endclass
Driver 是主动组件,会改变 DUT 输入;Monitor 是被动组件,只观察信号,不应驱动 DUT。
11.6 Scoreboard:比较预期结果和实际结果
下面先展示按顺序比较的单输入版本。它假设 DUT 的结果严格保序,而且每笔实际结果到达前,其期望结果已进入队列。Monitor 调用 ap.write(txn) 后,最终会调用 scoreboard 的 write()。允许乱序时,应使用后面的按 ID 匹配方案。
class my_scoreboard extends uvm_scoreboard;
`uvm_component_utils(my_scoreboard)
// imp 是接收端;第二个类型参数表示 write() 在哪个类中实现
uvm_analysis_imp #(my_transaction, my_scoreboard) actual_imp;
// 队列适合按发送顺序保存预期事务
my_transaction expected_q[$];
function new(string name = "my_scoreboard",
uvm_component parent = null);
super.new(name, parent);
actual_imp = new("actual_imp", this);
endfunction
// 实际项目通常由 reference model/predictor 写入预期结果
function void push_expected(my_transaction expected);
my_transaction snapshot;
if (expected == null) begin
`uvm_fatal("SCOREBOARD", "不能保存空的期望事务")
return;
end
if (!$cast(snapshot, expected.clone())) begin
`uvm_fatal("SCOREBOARD", "期望事务 clone 类型错误")
return;
end
expected_q.push_back(snapshot);
endfunction
// actual_imp 收到数据后,UVM 自动调用这个 write()
function void write(my_transaction actual);
my_transaction expected;
if (actual == null) begin
`uvm_error("SCOREBOARD", "收到空的实际事务")
return;
end
if (expected_q.size() == 0) begin
`uvm_error("SCOREBOARD", "收到实际事务,但预期队列为空")
return;
end
expected = expected_q.pop_front();
// compare() 来自 uvm_object;transaction 字段已通过宏注册
if (!actual.compare(expected))
`uvm_error("SCOREBOARD",
$sformatf("比较失败\nexpected:\n%sactual:\n%s",
expected.sprint(), actual.sprint()))
endfunction
function void check_phase(uvm_phase phase);
super.check_phase(phase);
if (expected_q.size() != 0)
`uvm_error("SCOREBOARD",
$sformatf("仍有 %0d 笔期望事务未匹配", expected_q.size()))
endfunction
endclass
数据方向如下:
同一输入事务 ──> Reference Model/Predictor ──> expected_q ──┐
DUT 输出 ──────> Monitor ──> actual_imp ────────────────────┤
↓
Scoreboard 比较
expected:参考模型根据同一输入事务和设计规格独立计算;actual:Monitor 从 DUT 输出端采集;Scoreboard:负责比较二者。
11.6.1 为什么乱序 scoreboard 适合关联数组
请求顺序:A(ID=3) → B(ID=8) → C(ID=5)
返回顺序:B(ID=8) → A(ID=3) → C(ID=5)
一个全局期望队列会先弹出 A,与第一个返回的 B 错配。关联数组允许按实际响应携带的 ID 查找期望事务:返回 B 时查键 8,返回 A 时查键 3。
前提是匹配键能从协议观察到,而且能够确定对应关系。 不应直接把 UVM 的 get_transaction_id() 当成总线上的 AXI ID;前者是验证框架中的事务标识,DUT 不会自动把它带回响应。也不能给 expected/actual 各自按到达顺序编号,再把编号当作共同匹配键。
| 协议和未完成事务情况 | 保存方式 | 比较规则 |
|---|---|---|
| 所有事务严格保序 | 一个队列 | 与队头比较 |
| 每个键最多一笔未完成事务,键之间可乱序 | transaction expected[key_t] | 按键取出对应对象;插入前检查键是否已存在 |
| 同 ID 有多笔未完成事务,同 ID 内保序 | transaction expected[key_t][$] | 按键定位队列,再与队头比较 |
| 同 ID 内也允许乱序 | 更细的可观测键或协议规定的匹配机制 | 不能简单按该 ID 的队头比较 |
单对象关联数组反复执行 expected[id] = txn 会覆盖同键的旧事务。因此“有 ID”不等于“每笔事务的 ID 都唯一”。
11.6.2 关联数组+队列:ID 之间乱序,ID 内保序
请求顺序:A(ID=3)、B(ID=8)、C(ID=3)
expected[3] → [A, C]
expected[8] → [B]
允许返回:B → A → C
不允许返回:C → A → B,因为同 ID=3 的顺序被颠倒
在普通 AXI 读响应/写响应的相应排序域内,同 ID 的响应按请求顺序返回,不同 ID 的响应可以乱序。读和写需要分别跟踪,多个源端口的同数值 ID 也不能直接视为同一来源。这种规则适合用“关联数组+队列”表示,具体仍以接口采用的 AXI 版本及功能为准。
下面用 typedef 把类型拆开,比直接写多个维度更容易理解:
// 示意声明;transaction 是工程中已有的事务类型
typedef int unsigned key_t;
typedef transaction txn_queue_t[$];
txn_queue_t expected[key_t]; // 外层按键查找,每个键的值是一整个队列
关联数组负责找到哪个 ID,队列负责找到该 ID 的第几笔事务。
11.6.3 UVM 示例:两路缓存,允许实际结果先到 scoreboard
参考模型与输出 Monitor 是两条独立的数据路径。即使 DUT 的请求先于响应发生,实际结果仍可能因为模型处理延迟或同一时间槽中的回调先后,先进入 scoreboard。下面同时缓存 expected 和 actual,两侧任何一侧到达都尝试匹配。
示例范围:一个来源、一个响应类别、8 位 ID;同 ID 内 expected 和 actual 各自按协议顺序提交;每个对象已经代表一笔完整响应。id/data/resp 为演示字段,完整协议还需添加相应字段和检查。两路各自的顺序必须来自 Monitor/参考模型对真实协议的观察,不能靠排序“修正”。
import uvm_pkg::*;
`include "uvm_macros.svh"
class ooo_item extends uvm_sequence_item;
logic [7:0] id;
logic [31:0] data;
logic [1:0] resp;
// 注册复制和比较需要的字段;保留四态,避免过早丢失 X/Z
`uvm_object_utils_begin(ooo_item)
`uvm_field_int(id, UVM_ALL_ON)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_field_int(resp, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "ooo_item");
super.new(name);
endfunction
endclass
// 在同一编译作用域中,每个后缀只声明一次
`uvm_analysis_imp_decl(_ooo_expected)
`uvm_analysis_imp_decl(_ooo_actual)
class ooo_scoreboard extends uvm_scoreboard;
`uvm_component_utils(ooo_scoreboard)
typedef bit [7:0] id_t;
typedef ooo_item item_queue_t[$];
item_queue_t expected_by_id[id_t];
item_queue_t actual_by_id[id_t];
uvm_analysis_imp_ooo_expected #(ooo_item, ooo_scoreboard) expected_imp;
uvm_analysis_imp_ooo_actual #(ooo_item, ooo_scoreboard) actual_imp;
function new(string name = "ooo_scoreboard",
uvm_component parent = null);
super.new(name, parent);
expected_imp = new("expected_imp", this);
actual_imp = new("actual_imp", this);
endfunction
function ooo_item make_snapshot(ooo_item txn);
ooo_item snapshot;
if (txn == null) begin
`uvm_fatal("OOO_NULL", "收到空事务")
return null;
end
// 在转换为二态键以前检查,不能让未知 ID 悄悄变成合法 ID
if ($isunknown(txn.id)) begin
`uvm_error("OOO_ID", "事务 ID 含 X/Z")
return null;
end
if (!$cast(snapshot, txn.clone())) begin
`uvm_fatal("OOO_COPY", "clone 返回的对象类型不兼容")
return null;
end
return snapshot;
endfunction
function void write_ooo_expected(ooo_item txn);
ooo_item snapshot;
id_t key;
snapshot = make_snapshot(txn);
if (snapshot == null) return;
key = id_t'(snapshot.id);
if (!expected_by_id.exists(key))
expected_by_id[key] = {}; // 新键对应一个空队列
expected_by_id[key].push_back(snapshot);
try_match(key);
endfunction
function void write_ooo_actual(ooo_item txn);
ooo_item snapshot;
id_t key;
snapshot = make_snapshot(txn);
if (snapshot == null) return;
key = id_t'(snapshot.id);
if (!actual_by_id.exists(key))
actual_by_id[key] = {};
actual_by_id[key].push_back(snapshot);
try_match(key);
endfunction
function void try_match(id_t key);
ooo_item expected;
ooo_item actual;
if (!expected_by_id.exists(key) || !actual_by_id.exists(key))
return;
while (expected_by_id[key].size() > 0 &&
actual_by_id[key].size() > 0) begin
expected = expected_by_id[key].pop_front();
actual = actual_by_id[key].pop_front();
if (!actual.compare(expected))
`uvm_error("OOO_MISMATCH",
$sformatf("ID=%0d\nexpected:\n%sactual:\n%s",
key, expected.sprint(), actual.sprint()))
end
// 队列为空后删除键,使 num() 表示仍有记录的 ID 数量
if (expected_by_id[key].size() == 0)
expected_by_id.delete(key);
if (actual_by_id[key].size() == 0)
actual_by_id.delete(key);
endfunction
function void check_phase(uvm_phase phase);
super.check_phase(phase);
foreach (expected_by_id[key])
`uvm_error("OOO_MISSING",
$sformatf("ID=%0d 仍有 %0d 笔期望结果未匹配",
key, expected_by_id[key].size()))
foreach (actual_by_id[key])
`uvm_error("OOO_UNEXPECTED",
$sformatf("ID=%0d 仍有 %0d 笔实际结果无对应期望",
key, actual_by_id[key].size()))
endfunction
endclass
在 Env 的 connect_phase() 中,把 Predictor 的 uvm_analysis_port #(ooo_item) 连接到 expected_imp,输出 Monitor 的同类型端口连接到 actual_imp。示例展示比较器,不包含完整的 Driver、DUT 或协议参考模型。
如果 B 的 actual 先到,actual_by_id[8] 先缓存 B;B 的 expected 到来后才比较,比较后删除两侧已用条目。双路缓存解决的是scoreboard 接收先后问题,并未放宽协议要求的响应顺序。
11.6.4 完整工程需要补齐的检查
| 问题 | 处理方式 |
|---|---|
| 多端口或读写共用 ID 数值 | 分开实例/分表,或把来源、响应类别和 ID 组成键;跨互联观察时处理 ID 映射 |
| 同 ID 多笔未完成事务 | 每个键存队列,不用单对象覆盖;不要为了通过比较而在该队列中任意搜索 |
| burst 按 beat 到达 | 先重建完整事务,或另存 beat 计数和期望长度,检查 LAST 后才结束该事务 |
| Monitor 复用对象 | 接收时建立快照;对象含子对象时检查 do_copy() 或字段宏的复制策略 |
| 漏响应、额外响应、重复响应 | 跟踪请求/响应数量、未匹配记录和必要的生命周期信息,检查内容与顺序;仅有 ID 不能区分所有重复或过期响应 |
| 长时间不匹配 | 用时间戳、周期计数和独立 watchdog 检查规范/测试定义的超时,避免一直缓存 |
| 复位、取消和 ID 重用 | 按规格处理未完成事务,必要时记录 epoch/代次;若协议不可观察代次,还需约束重用时机或建立请求生命周期跟踪 |
| 仿真过早结束 | sequence 结束不等于所有响应到齐;测试结束前等待在途事务处理完或超时,再由 check_phase() 检查残留 |
expected_by_id.num() 统计的是键数,不是未完成事务总数;后者需要把各键对应队列的 size() 相加。上述示例包含快照、匹配和结束残留检查,超时、复位及具体协议规则需要按工程补齐。
11.6.5 适合用来验证 scoreboard 自身的场景
- 不同 ID 按反向顺序返回,应正常匹配。
- 同 ID 连续发两笔不同内容的事务,按顺序返回应通过,交换返回顺序应报错。
- actual 先于 expected 进入 scoreboard,应先缓存并在另一侧到达后匹配。
- 入队后修改发送方原对象,已保存的期望快照不应变化。
- 漏掉一笔响应或多出一笔响应,超时/结束检查应报错。
- burst 的 beat 数量或 LAST 位置错误,协议/重组检查应报错,不能只比较最终拼接出的 payload。
11.7 Sequencer、Agent 和 Env:创建并连接组件
11.8 Sequence:产生一组 transaction
class my_sequence extends uvm_sequence #(my_transaction);
`uvm_object_utils(my_sequence)
function new(string name = "my_sequence");
super.new(name);
endfunction
task body();
my_transaction txn;
repeat (10) begin
// 1. 通过 factory 创建 transaction
txn = my_transaction::type_id::create("txn");
// 2. 等待 sequencer 和 driver 准备好
start_item(txn);
// 3. 随机化;示例要求地址按 4 字节对齐
if (!txn.randomize() with { addr[1:0] == 2'b00; })
`uvm_fatal("RANDFAIL", "transaction 随机化失败")
// 4. 把 transaction 交给 sequencer,等待 driver 完成
finish_item(txn);
end
endtask
endclass
11.9 Test:创建环境并启动 sequence
class my_test extends uvm_test;
`uvm_component_utils(my_test)
my_env env;
function new(string name = "my_test", uvm_component parent = null);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
env = my_env::type_id::create("env", this);
endfunction
task run_phase(uvm_phase phase);
my_sequence seq;
// objection 大于 0 时,UVM 不会结束 run_phase
phase.raise_objection(this);
seq = my_sequence::type_id::create("seq");
seq.start(env.agent.sequencer);
// sequence 结束后撤销 objection,允许 UVM 结束测试
phase.drop_objection(this);
endtask
endclass
11.10 顶层模块:连接 DUT 世界与 UVM 世界
module tb_top;
import uvm_pkg::*;
logic clk = 0;
always #5 clk = ~clk;
// interface 是静态实例,实际项目中还要连接 DUT
my_bus_if bus_if(clk);
initial begin
// 把静态 interface 的句柄存入 config_db。
// 路径中的 * 表示 driver 和 monitor 等所有下层组件。
uvm_config_db#(virtual my_bus_if)::set(null, "uvm_test_top.env.agent.*", "vif", bus_if);
// 根据名字通过 factory 创建 my_test,并启动全部 UVM phase
run_test("my_test");
end
endmodule
config_db::set() 与 get() 必须在以下三方面完全匹配:
- 类型匹配:都是
virtual my_bus_if; - 字段名匹配:都是
"vif"; - 路径匹配:设置路径必须覆盖 driver 和 monitor 的组件路径。
11.11 把前面知识串起来
| 前面学过的知识 | 在 UVM 中的应用 |
|---|---|
| class 与对象句柄 | 所有 transaction 和 UVM 组件 |
继承 extends | 自定义类继承 UVM 基类 |
| 虚方法与重写 | 重写 build_phase()、connect_phase()、run_phase() |
| 多态 | UVM 用基类句柄统一调用各组件的 phase |
| 动态数组 | transaction 的可变长度 payload、burst 数据 |
| 队列 | 顺序 scoreboard、采样历史、同 ID 内的事务顺序 |
| 关联数组 | 按 ID 查找乱序事务、稀疏参考内存 |
| 关联数组+队列 | 多笔同 ID 未完成事务,以及 ID 之间乱序的匹配 |
| 对象复制与快照 | 避免 Monitor/Predictor 后续修改已入队事务 |
| 随机化与约束 | sequence 产生受约束的 transaction |
| interface | 封装 DUT 引脚 |
| virtual interface | class 访问静态 interface 实例 |
参数化类 #(...) | 规定 driver、sequencer、port 传递的 transaction 类型 |
$cast | factory override 或基类/子类句柄转换时检查类型 |
最核心的数据转换只有两次:
transaction --Driver--> DUT 引脚信号
DUT 引脚信号 --Monitor--> transaction
其余 UVM 组件主要负责产生、调度、连接、检查和配置这两次转换。
验证流程
1. 编写Transaction(定义数据结构)
2. 编写Driver(驱动DUT)
3. 编写Monitor(采集信号)
4. 编写Scoreboard(比较结果)
5. 编写Coverage(统计覆盖率)
6. 编写Sequence(生成激励)
7. 编写Test(组织验证环境)
8. 运行仿真,分析覆盖率
附录:面试高频问题
| 模块 | 常见问题 |
|---|---|
| class | 静态属性vs动态属性?继承和多态? |
| 数组与队列 | a[4]、a[]、a[int]、a[$] 有什么区别?new[N](old) 如何保留数据? |
| 对象复制 | 容器赋值是否复制 class 对象?句柄赋值、浅拷贝与 UVM clone() 有何区别? |
| scoreboard | 何时用队列、关联数组或关联数组+队列?如何处理同 ID 多笔事务、两路到达顺序和残留检查? |
| randomize | 约束如何编写?如何覆盖约束? |
| SVA | |->和|=>的区别?sequence如何定义? |
| coverage | coverpoint和cross的区别?如何提高覆盖率? |
| UVM | 组件层次结构?phase机制?factory机制? |
数组与乱序匹配参考资料
- IEEE 1800-2012 SystemVerilog 标准,第 7 章(标准文本镜像):数组、队列与数组方法的语言定义。
- Arm AMBA AXI 规范,第 A6 章:事务 ID 与请求/响应的顺序规则;使用时核对工程采用的规范版本。
- Accellera UVM 1.2 类参考手册,uvm_object:
clone()、copy()、do_copy()和compare()的接口与行为。
最后更新: 2026-09-07