UVM Scoreboard: Comparing Expected vs Actual Transactions
In any constrained-random testbench, driving stimulus is only half the job. The other half is deciding, automatically, whether the design did the right thing. That decision lives in the UVM scoreboard — the component that collects transactions observed on the design under test (DUT), works out what the correct result should have been, and compares the two. If you have ever wondered how a UVM environment “knows” a test passed without a human reading every waveform, the answer is almost always a scoreboard quietly comparing expected versus actual transactions.
A quick, honest note on running these examples: the UVM blocks below depend on the UVM base-class library, which free simulators like Icarus Verilog and Verilator do not fully support. They are written for a UVM-capable simulator (industry-standard simulation tools, VCS, industry-standard simulators) or EDA Playground, and are shown here as canonical, syntax-correct structure you can paste into such a tool. The one runnable block near the end is plain SystemVerilog — no UVM library — so it executes on a free simulator and demonstrates the same comparison idea.
What a UVM scoreboard actually does
A scoreboard sits at the top of the verification environment, usually inside the uvm_env, alongside one or more agents. It is a passive, analysis-level component: it never drives pins. Instead it subscribes to the broadcasts that monitors publish. Its responsibilities are three:
- Collect every transaction the monitor sees on the DUT interface.
- Predict the correct behaviour, either by recomputing a result with a reference model or by receiving a predicted transaction from a separate predictor.
- Compare expected against actual, and flag any mismatch through the UVM reporting system.
Because it is decoupled from the driver and sequencer, the same scoreboard works whether you run one directed test or ten thousand random ones.
The three pieces: uvm_scoreboard, uvm_analysis_imp, write()
Three names do most of the work, and it helps to keep them straight:
| Element | Role |
|---|---|
uvm_scoreboard | Base class you extend. It is a thin specialisation of uvm_component that marks the component’s intent; the comparison logic is yours to write. |
uvm_analysis_imp | An analysis “implementation” port. The monitor has an analysis port; the scoreboard has an analysis imp. Connecting them lets the monitor push transactions into the scoreboard. |
write() | The callback the analysis imp invokes. Every time the monitor broadcasts a transaction, UVM calls your write() exactly once with that transaction. |
The key mental model: an analysis port is a one-to-many broadcaster, and each subscriber exposes an analysis imp whose write() runs on every broadcast. You do not call write() yourself — the connection does.
Step 1: the transaction
Expected-versus-actual comparison only makes sense if the transaction carries both the stimulus and the observed response. For a simple 8-bit adder, that means the operands and the DUT’s result.
class alu_txn extends uvm_sequence_item;
rand bit [7:0] a;
rand bit [7:0] b;
bit [8:0] result; // captured from the DUT by the monitor
`uvm_object_utils_begin(alu_txn)
`uvm_field_int(a, UVM_ALL_ON)
`uvm_field_int(b, UVM_ALL_ON)
`uvm_field_int(result, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "alu_txn");
super.new(name);
endfunction
endclassStep 2: the scoreboard and its write() method
The simplest useful scoreboard uses a single analysis imp. Because the transaction carries the operands, the scoreboard can recompute the expected result itself — its reference model is a single addition. The write() method is where the comparison happens.
class alu_scoreboard extends uvm_scoreboard;
`uvm_component_utils(alu_scoreboard)
// One analysis imp. Its write() runs for every item the monitor broadcasts.
uvm_analysis_imp #(alu_txn, alu_scoreboard) ap_imp;
int num_pass;
int num_fail;
function new(string name, uvm_component parent);
super.new(name, parent);
ap_imp = new("ap_imp", this);
endfunction
// Must be named 'write': uvm_analysis_imp calls exactly this method.
function void write(alu_txn actual);
bit [8:0] expected;
expected = actual.a + actual.b; // reference model
if (actual.result === expected) begin
num_pass++;
`uvm_info("SB", $sformatf("PASS a=%0d b=%0d result=%0d",
actual.a, actual.b, actual.result), UVM_HIGH)
end
else begin
num_fail++;
`uvm_error("SB", $sformatf("FAIL a=%0d b=%0d exp=%0d got=%0d",
actual.a, actual.b, expected, actual.result))
end
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SB", $sformatf("Totals: %0d passed, %0d failed",
num_pass, num_fail), UVM_NONE)
endfunction
endclassTwo details matter. First, the method must be called write — the analysis imp is hard-wired to that name. Second, a mismatch is reported with `uvm_error, which increments UVM’s error count so the test ends with a non-zero status. Use `uvm_info with a low verbosity for passes so clean runs stay quiet.
Step 3: connecting the monitor to the scoreboard
The wiring lives in connect_phase of the environment. The monitor’s analysis port connects to the scoreboard’s analysis imp.
class alu_env extends uvm_env;
`uvm_component_utils(alu_env)
alu_agent agt;
alu_scoreboard sb;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
agt = alu_agent::type_id::create("agt", this);
sb = alu_scoreboard::type_id::create("sb", this);
endfunction
function void connect_phase(uvm_phase phase);
// monitor's analysis PORT -> scoreboard's analysis IMP
agt.mon.item_collected_port.connect(sb.ap_imp);
endfunction
endclassWhen one write() is not enough: analysis_imp_decl
The single-imp pattern works when the scoreboard can predict the answer from the observed inputs. But many designs need two independent streams — for example a predicted stream from a reference model and an actual stream from the DUT monitor — and each stream needs its own callback. The problem: a class can hold only one method literally named write.
UVM solves this with the `uvm_analysis_imp_decl macro, which generates a specialised imp whose callback is write_<suffix>.
// Generate two specialised imps, each with its own write method
`uvm_analysis_imp_decl(_expected)
`uvm_analysis_imp_decl(_actual)
class alu_scoreboard extends uvm_scoreboard;
`uvm_component_utils(alu_scoreboard)
uvm_analysis_imp_expected #(alu_txn, alu_scoreboard) exp_imp;
uvm_analysis_imp_actual #(alu_txn, alu_scoreboard) act_imp;
alu_txn exp_q[$]; // expected items waiting to be matched
function new(string name, uvm_component parent);
super.new(name, parent);
exp_imp = new("exp_imp", this);
act_imp = new("act_imp", this);
endfunction
// Predictor broadcasts land here
function void write_expected(alu_txn t);
exp_q.push_back(t);
endfunction
// DUT monitor broadcasts land here
function void write_actual(alu_txn actual);
alu_txn exp;
if (exp_q.size() == 0) begin
`uvm_error("SB", "Actual arrived with no matching expected item")
return;
end
exp = exp_q.pop_front();
if (exp.result !== actual.result)
`uvm_error("SB", $sformatf("MISMATCH exp=%0d act=%0d",
exp.result, actual.result))
else
`uvm_info("SB", "MATCH", UVM_HIGH)
endfunction
endclassHere a queue (exp_q) decouples the two streams in time — essential for pipelined DUTs, where an expected item may be produced several cycles before the matching actual appears. The two styles cover most cases: recompute-in-place for simple blocks, dual-imp with a queue when a separate predictor or latency is involved.
Expected vs actual: in-order or out-of-order?
| Strategy | When to use |
|---|---|
| In-order queue (FIFO compare) | DUT preserves transaction order. Pop the oldest expected item and compare. Simple and fast. |
| Associative array / key match | DUT reorders items (out-of-order completion, tagged responses). Store expected items keyed by an ID and look up on arrival. |
The same idea, running without UVM
To see the comparison engine work on a free simulator, here is a scoreboard stripped of the UVM library. It keeps the essentials: a write() entry point, a reference model that predicts the sum, and an expected-versus-actual check. The third transaction carries an injected bug so you can watch a mismatch be caught.
module tb;
// A minimal scoreboard for an adder DUT, written WITHOUT the UVM library
// so it runs on a free simulator. It shows the core idea: a write()
// entry point that compares each ACTUAL result against the EXPECTED one.
// Expected transactions, filled by a reference model (one queue per field)
int exp_a[$];
int exp_b[$];
int exp_sum[$];
int pass_count = 0;
int fail_count = 0;
// write(): called once per ACTUAL transaction coming from the monitor.
task automatic write(int a, int b, int sum);
int ea, eb, es;
if (exp_sum.size() == 0) begin
$display("[SB] ERROR unexpected actual a=%0d b=%0d sum=%0d", a, b, sum);
fail_count++;
return;
end
ea = exp_a.pop_front();
eb = exp_b.pop_front();
es = exp_sum.pop_front();
if (es === sum)
begin
$display("[SB] PASS a=%0d b=%0d exp_sum=%0d act_sum=%0d", a, b, es, sum);
pass_count++;
end
else
begin
$display("[SB] FAIL a=%0d b=%0d exp_sum=%0d act_sum=%0d", a, b, es, sum);
fail_count++;
end
endtask
// reference model: what the adder SHOULD produce
task automatic expect_add(int a, int b);
exp_a.push_back(a);
exp_b.push_back(b);
exp_sum.push_back(a + b);
endtask
initial begin
// Reference model predicts each result
expect_add(2, 3);
expect_add(10, 7);
expect_add(4, 4);
// DUT feeds its ACTUAL outputs to the scoreboard via write()
write(2, 3, 5); // correct
write(10, 7, 17); // correct
write(4, 4, 9); // injected bug: 4+4 should be 8
$display("[SB] SUMMARY: %0d passed, %0d failed", pass_count, fail_count);
$finish;
end
endmodule
[SB] PASS a=2 b=3 exp_sum=5 act_sum=5
[SB] PASS a=10 b=7 exp_sum=17 act_sum=17
[SB] FAIL a=4 b=4 exp_sum=8 act_sum=9
[SB] SUMMARY: 2 passed, 1 failed
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmptmdhtuhx/tb.sv:56: $finish called at 0 (1s)
[SB] PASS a=2 b=3 exp_sum=5 act_sum=5
[SB] PASS a=10 b=7 exp_sum=17 act_sum=17
[SB] FAIL a=4 b=4 exp_sum=8 act_sum=9
[SB] SUMMARY: 2 passed, 1 failedThe structure maps one-to-one onto the UVM version: write() is the analysis-imp callback, the expect_add helper is the predictor, and the queues are the scoreboard’s memory of what it still expects to see. Swap the queues for UVM transactions and the broadcast for an analysis port, and you have the real thing.
Common mistakes to avoid
- Misnaming the callback. A single
uvm_analysis_impcallswrite— notwrite_itemorreceive. For extra streams, use`uvm_analysis_imp_decland the matchingwrite_<suffix>. - Reporting a pass as an error, or a fail silently. Route mismatches through
`uvm_errorso the test’s pass/fail status is honest. - Forgetting the connect. If
connect_phasenever links the monitor port to the scoreboard imp,write()is never called and every test “passes” — because nothing was ever checked. - No end-of-test check. If expected items remain in the queue when simulation ends, the DUT dropped responses. Verify the queue is empty in
check_phaseorreport_phase.
Quick check
Why must the scoreboard’s callback be named exactly write()?
Because uvm_analysis_imp is hard-coded to invoke a method called write on the component it belongs to. When the connected analysis port broadcasts a transaction, it calls write() on every subscriber. Rename it and the imp has nothing to call, so your comparison logic never runs.
What is the difference between an analysis port and an analysis imp?
An analysis port (on the monitor) is the broadcaster — it sends one transaction out to many listeners. An analysis imp (on the scoreboard) is the receiver that implements write(). In connect_phase you connect the monitor’s port to the scoreboard’s imp so broadcasts reach the callback.
How do you give a scoreboard two separate input streams?
Use the `uvm_analysis_imp_decl(_suffix) macro to generate specialised imps such as uvm_analysis_imp_expected and uvm_analysis_imp_actual. Each gets its own callback — write_expected() and write_actual() — so a predictor stream and a DUT stream can arrive independently.
Why keep expected transactions in a queue instead of comparing immediately?
Because the expected result is often known before the DUT produces the actual one, especially in pipelined designs. The queue stores expected items until the matching actual arrives, letting you compare in order even when there is latency between prediction and observation.
Share your question in comments or talk to our mentor team for batch guidance.
Compile and simulate this in the browser — no install. Edit the code and re-run instantly.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses