SystemVerilog 记分板手写:analysis_fifo 接事务、queue 维护期望值、逐条比对
更新时间:2026-09-25。本文是「集成电路大类 · 芯片设计 · 数字验证」序列里的一篇长尾实操文。分层验证平台里,monitor 只负责把引脚动作抽象成事务,真正决定 PASS/FAIL 的是记分板(scoreboard)。UVM 工程里通常直接继承
uvm_scoreboard,但它内部并没有魔法:一个 fifo 收事务、一个 queue 存期望值、一个循环做比对。本文用纯 SystemVerilog 把这套机制手写一遍。事务在 TLM 通道上怎么流动,先读 SystemVerilog TLM:port/export/imp 与通信管道;分层平台的整体结构见 分层验证平台架构与复用。
本文要回答的问题
- monitor 采到的事务怎么送到记分板,两边时序对不齐为什么不会丢?
- 期望模型(reference model)的输出和 DUT 的输出按什么顺序、在什么时机比对?
- 失配时日志该打哪些信息才能一次定位,纯 SV 手写版和 UVM scoreboard 差在哪?
一、记分板在平台中的位置与三件事
记分板是验证平台里唯一持有「正确答案」的组件。它同时接收两路输入:一路是参考模型对激励算出来的期望事务(expected),一路是输出侧 monitor 从 DUT 引脚上采回来的实际事务(actual)。它的全部职责可以拆成三件事:收得进、存得住、对得准。
收得进,指与 monitor 之间不能用函数直接调用——monitor 在时钟沿触发,记分板未必在那一刻方便处理,两边必须经过带缓冲的 TLM 通道。存得住,指期望事务往往比实际事务先到(参考模型一拍算出结果,DUT 要走流水线),需要一个先入先出的容器把它排队等好。对得准,指按业务约定的顺序逐条比较字段,失配时留下足够的现场信息。UVM 版的 uvm_scoreboard 只是把这三件事包装成组件,纯 SV 手写版用的是同样的骨架。
二、接收侧:analysis_port、analysis_export 与 analysis_fifo
先解决「收得进」。UVM TLM 提供了一种特殊的一对多通道:uvm_analysis_port 在上游,uvm_analysis_imp 在下游,上游调用一次 write,所有连接的下游都会收到。它是非阻塞的——上游发完就走,不等任何人。记分板若直接实现 write 方法当场比对,就会把比对逻辑塞进 monitor 的采样线程里,两边时序强耦合。
标准做法是中间插一个 uvm_analysis_fifo:它内部自带一个缓冲和一个 analysis_imp 出口,monitor 的 write 只是把事务放进队列,记分板在自己的线程里用 get 取出处理。这样 monitor 永远不会被记分板拖住,多个下游并存时也互不影响。
为什么不用普通的 put/get port?普通 TLM port 是一对一、且 get 会反向阻塞上游,本质是握手;analysis 通道是一对多、发后不管,本质是广播。记分板、覆盖率收集器、事务记录器常常要同时订阅同一个 monitor,广播语义让 monitor 完全不知道下游有几个、是谁,新增一个订阅者只需多连一根线。纯 SV 环境里即使不用 UVM 基类,也应当照这个形状自己写一个带 mailbox 的 fifo——put 用非阻塞写法、消费端独立线程取,思路完全一致。

图上两路 monitor 各写各的 fifo,记分板只有一个消费线程,所以 expected 与 actual 的汇合点被收束到一处,避免多线程同时操作队列。expected 一侧如果参考模型是独立组件,接线完全对称:它也通过 analysis_port 发事务,记分板同样用 fifo 接收。
三、用 queue 维护期望模型:入队、出队与 in-order 比对
接着解决「存得住、对得准」。SystemVerilog 的 queue(tr_t exp_q[$])天生适合这个角色:push_back 入队、pop_front 出队、size 查积压。in-order(保序)协议下规则极其简单——expected 先到就排队等着,actual 到了就和队首比,比完弹出:

注意分支节点这里仍用 rectangle 表达,判定语义写在出边标签上。图里隐含两个关键判据:actual 到达时期望队列是空的,本身就是错误——说明 DUT 吐出了参考模型没预期的东西;反过来仿真结束时队列没空,说明 DUT 漏了事务,收尾检查必须兜住这一头。
in-order 这个前提值得多说一句。FIFO、仲裁器、无重排的总线都满足它:第 N 个激励的响应一定以第 N 个顺序出现,所以 queue 这种严格按序的结构是零成本的正确选择。一旦协议允许乱序完成(如带 ID 的 AXI、支持重试的总线),队首比对就会制造大量假失配——这时要先问自己「顺序是不是协议承诺的一部分」,而不是急着调 RTL。保序场景下还要确保 expected 与 actual 描述的是同一个抽象层级:两边都在事务级比较,不要一边是字节流一边是突发包,否则比的根本不是同一个东西。
四、完整可运行骨架
下面是一份不依赖 UVM 组件基类、但照搬 TLM 思想的纯 SV 骨架。fifo 用 mailbox 实现缓冲,write 任务非阻塞投递,记分板跑独立消费线程。
package sb_pkg;
// 事务:按业务字段扩充;do_print 负责自描述
class tr_t;
rand bit [31:0] addr;
rand bit [31:0] data;
rand bit rw; // 0: read 1: write
function bit do_compare(tr_t rhs, output string msg);
if (addr !== rhs.addr) begin msg="addr"; return 0; end
if (data !== rhs.data) begin msg="data"; return 0; end
if (rw !== rhs.rw) begin msg="rw"; return 0; end
return 1;
endfunction
function string to_string();
return $sformatf("rw=%0d addr=0x%08h data=0x%08h", rw, addr, data);
endfunction
endclass
// analysis fifo 的纯 SV 等价物:write 立即返回
class analysis_fifo;
mailbox #(tr_t) mbx = new;
function void write(tr_t t); mbx.put(t); endfunction // 有界 mailbox 才可能阻塞
task get(output tr_t t); mbx.get(t); endtask
endclass
class scoreboard;
analysis_fifo exp_fifo = new;
analysis_fifo act_fifo = new;
tr_t exp_q[$];
int match_cnt, mismatch_cnt;
bit stop_flag;
// reference model 的输出接这里
function void write_expected(tr_t t); exp_fifo.write(t); endfunction
// 输出 monitor 接这里
function void write_actual(tr_t t); act_fifo.write(t); endfunction
task run();
fork
collect_expected();
collect_actual();
join
endtask
task collect_expected();
tr_t t;
forever begin
exp_fifo.get(t);
exp_q.push_back(t); // 期望值先到先排队
end
endtask
task collect_actual();
tr_t act, exp;
string fld;
forever begin
act_fifo.get(act);
if (exp_q.size() == 0) begin
$error("[SCB-NULL] %0t actual 无期望事务: %s",
$time, act.to_string());
mismatch_cnt++;
continue;
end
exp = exp_q.pop_front();
if (exp.do_compare(act, fld)) begin
match_cnt++;
end else begin
mismatch_cnt++;
$error("[SCB-MIS] %0t 字段[%s] 失配\n expected: %s\n actual : %s",
$time, fld, exp.to_string(), act.to_string());
end
end
endtask
// 仿真结束前调用:队列必须清空
function void check_done();
if (exp_q.size() != 0)
$error("[SCB-TAIL] %0t 残留 %0d 个期望事务未被 DUT 输出",
$time, exp_q.size());
$display("[SCB] match=%0d mismatch=%0d", match_cnt, mismatch_cnt);
endfunction
endclass
endpackage骨架里 collect_expected 与 collect_actual 拆成两个线程,是为了让 expected 可以在任意时刻提前入队;真正的比对只发生在 actual 线程里,队列操作只有一个线程做 pop_front,另一个只做 push_back,天然不需要额外加锁。mailbox 默认无界,put 立即返回,这就是 analysis_port「发完即走」语义的来源。如果仿真是带复位的,还要在复位事件发生时清空两个 fifo 和 exp_q、复位计数器,否则复位前的旧事务会在复位后冒出来,报出莫名其妙的 SCB-NULL。
字段比较用的是 !==(case inequality)而不是 !=,因为事务里可能出现 x/z,!= 遇到 x 比较结果本身就是 x,会让 if 判断误入不可综合的歧义分支;!== 永远给出确定的 0/1。这是 testbench 代码里少数几个和 RTL 写法刻意不同的地方。
五、失配打印:双方事务、字段定位与仿真时间
第三条职责「对得准」不只是判对错,更是给调试留现场。骨架里失配日志刻意打了四样东西:失配字段名(do_compare 通过输出参数带回,不用人猜是哪一位错了)、expected 完整内容、actual 完整内容、$time 仿真时间。有了时间就能直接在波形里跳到对应时刻,有了双方事务就能判断是数据算错还是顺序错位。
顺序错位和数据错误的日志形态不同:actual 无期望报 SCB-NULL,期望残留报 SCB-TAIL,字段失配报 SCB-MIS。给错误分类前缀(对应 UVM 里 report ID 的作用),回归脚本可以直接按前缀统计失败类型,而不用解析自由文本。计数器在 check_done 里统一汇总,最终判决就是「mismatch 为 0 且无残留」。UVM 工程里这一切会走报告宏和 report phase,机制见 UVM 报告机制:severity、verbosity 与海量日志治理。
六、与 UVM scoreboard 的对应关系
手写一遍之后再看 UVM,每个零件都能对上号:
| 纯 SV 手写 | UVM 对应物 |
|---|---|
mailbox 封装的 analysis_fifo | uvm_tlm_analysis_fifo #(tr_t) |
write_expected/write_actual | fifo 的 analysis_export,monitor 的 analysis_port 连过来 |
tr_t exp_q[$] | 组件内成员队列,写法一致 |
run 中的 forever 线程 | run_phase 中的 forever begin fifo.get(t); ... end |
$error 分类前缀 | `uvm_error("SCB-MIS", ...) |
check_done 汇总 | check_phase / report_phase 检查残留与计数 |
唯一多出的是 UVM 的 phase 调度、objection 和工厂机制,比对逻辑本身一行都不用改。更复杂的协议(out-of-order、重试、合并写)只需换掉队列的组织方式:用关联数组按 ID 索引 expected,actual 到达时按键查找而不是弹队首,失配打印和双 fifo 接收的骨架原样保留。乱序场景的打法可参考 UVM 记分板与事务 trace 对齐。
这一篇在知识体系里的位置
通道机制的来历见 SystemVerilog TLM:port/export/imp 与通信管道;线程间同步的原语见 SystemVerilog event/semaphore/mailbox;UVM 组件版的搭法见 UVM 组件与 phase 和 UVM sequence/driver/scoreboard 实战;一个具体的保序比对实例见 验证 valid/ready 握手。
总结
SystemVerilog 记分板的本质是「两个 analysis_fifo 解耦接收 + 一个 queue 维护期望值 + 一个消费线程逐条比对」:monitor 经 analysis_port 非阻塞写入,期望事务先到先排队,actual 到达即弹队首做 in-order 字段比较,队列空收到 actual 与收尾时队列非空都算错误;失配日志必须带失配字段、双方事务内容和仿真时间并使用分类前缀;纯 SV 手写版与 UVM scoreboard 在 fifo、队列、run_phase、报告宏上逐一对应——先把手写骨架跑通,再切 UVM 时只需接上 phase,比对逻辑一行不动。