Moving from a Layered Testbench to UVM Concepts | FPGA Pour Tous
Summary
Moving from a Layered Testbench to UVM Concepts
Build a complete environment and connect its phases, configuration, callbacks, and component creation to the mechanisms that UVM standardizes.
What UVM adds to the pattern already built
UVM, the Universal Verification Methodology, uses a class library to standardize the responsibilities already introduced in the layered environment: data objects, scenario generation, driver, monitor, model, scoreboard, coverage, environment, and test.
It replaces neither the verification plan, the protocol, nor the reference model. It primarily standardizes construction, configuration, extension points, component creation, and communication.
This chapter is a conceptual bridge. It shows how to prepare an environment for these ideas without presenting an incomplete pseudo-UVM testbench. Sequences, sequencers, objections, and the complete phase graph require the exact UVM API used by the project and its version.
Mapping the layered environment to UVM
Course SystemVerilog environment
Mechanism standardized by UVM
transaction and copy method
data object with common copy and representation operations
driver, monitor, scoreboard, environment, test
components in a named hierarchy
build(), run(), wrap_up()
teaching subset of a richer phase mechanism
virtual interfaces passed to classes
configuration resource delivered to the appropriate component
callback calls
registered extension points that avoid deriving an entire driver
monitor publication to several consumers
transaction-level communication to scoreboard and coverage
selecting a derived class at construction
factory and type override
The responsibilities do not change. The driver applies, the monitor observes, prediction remains independent, and the test selects the scenario without rebuilding the infrastructure.
Phases: understand the purpose before memorizing names
The minimal division remains useful:
Build: create the configuration, construct and connect components, then reset and configure the DUT;
Run: start concurrent activities, execute the scenario, wait for transactions to propagate, and protect execution with a timeout;
Wrap-up: drain the final responses, check outstanding expectations, publish the verdict, and retain coverage only when the run is valid.
A real UVM library divides this cycle into more phases. The design rule remains stable: a constructor initializes an object without consuming time; timed behavior belongs in a phase task; reporting begins only after draining is complete.
Complete example: a UVM-inspired environment
Here, complete means that the example instantiates the DUT, generates transactions, applies them, observes both sides of the DUT, computes expected results, compares, measures coverage, protects the simulation with a timeout, and produces its own PASS or FAIL verdict.
This code is a layered SystemVerilog environment ready to be mapped to UVM, not a partial imitation of the library. The exact class, sequence, port, and phase names are deliberately left to the selected UVM API. The complete functional chain that must survive that migration is present and executable.
The DUT accepts a pair of bytes when in_valid && in_ready is 1. It publishes their 9-bit sum with out_valid on the following cycle. Responses are ordered, with exactly one response per command.
1. Signal boundary and DUT
The interface gives the driver a driving view and the monitors a passive view. The clocking blocks impose the same timing contract on every component.
// sum_dut.svmodule sum_dut(sum_if.dut bus); timeunit 1ns; timeprecision 1ps; assign bus.in_ready = 1'b1; always_ff @(posedge bus.clk or negedge bus.rst_n) begin if (!bus.rst_n) begin bus.out_valid <= 1'b0; bus.sum <= '0; end else begin bus.out_valid <= bus.in_valid && bus.in_ready; if (bus.in_valid && bus.in_ready) bus.sum <= {1'b0, bus.a} + {1'b0, bus.b}; end endendmodule
2. Objects, transactors, model, scoreboard, and coverage
The following package contains the entire environment. Each mailbox has one responsibility. The input monitor publishes separate copies to the model and coverage so that two consumers never steal a transaction from each other.
// sum_tb_pkg.sv - compile after sum_if.svpackage sum_tb_pkg; timeunit 1ns; timeprecision 1ps; typedef virtual sum_if.drv sum_drv_vif_t; typedef virtual sum_if.mon sum_mon_vif_t; class sum_item; randc logic [7:0] a; rand logic [7:0] b; rand int unsigned idle_cycles; logic [8:0] sum; constraint legal_idle { idle_cycles inside {[0:3]}; } function sum_item copy(); sum_item result; result = new(); result.a = a; result.b = b; result.idle_cycles = idle_cycles; result.sum = sum; return result; endfunction endclass class sum_config; int unsigned transaction_count = 80; time timeout = 20us; endclass class sum_generator; sum_item blueprint; mailbox #(sum_item) outbox; int unsigned generated_count; bit done; function new(mailbox #(sum_item) outbox); this.outbox = outbox; blueprint = new(); endfunction task run(int unsigned count); sum_item item; generated_count = 0; done = 0; repeat (count) begin if (!blueprint.randomize()) $fatal(1, "[GENERATOR] randomization failed"); item = blueprint.copy(); outbox.put(item); generated_count++; end done = 1; endtask endclass class sum_driver_callback; virtual task pre_drive(sum_item item); endtask endclass class sum_driver; sum_drv_vif_t vif; mailbox #(sum_item) inbox; sum_driver_callback callbacks[$]; int unsigned driven_count; function new(sum_drv_vif_t vif, mailbox #(sum_item) inbox); this.vif = vif; this.inbox = inbox; endfunction function void add_callback(sum_driver_callback callback); callbacks.push_back(callback); endfunction task run(); sum_item item; vif.drv_cb.in_valid <= 1'b0; forever begin inbox.get(item); foreach (callbacks[i]) callbacks[i].pre_drive(item); repeat (item.idle_cycles) @vif.drv_cb; vif.drv_cb.a <= item.a; vif.drv_cb.b <= item.b; vif.drv_cb.in_valid <= 1'b1; do @vif.drv_cb; while (vif.drv_cb.in_ready !== 1'b1); vif.drv_cb.in_valid <= 1'b0; driven_count++; end endtask endclass class sum_input_monitor; sum_mon_vif_t vif; mailbox #(sum_item) to_reference; mailbox #(sum_item) to_coverage; int unsigned accepted_count; function new(sum_mon_vif_t vif, mailbox #(sum_item) to_reference, mailbox #(sum_item) to_coverage); this.vif = vif; this.to_reference = to_reference; this.to_coverage = to_coverage; endfunction task run(); sum_item observed; forever begin @vif.mon_cb; if (vif.mon_cb.rst_n === 1'b1 && vif.mon_cb.in_valid === 1'b1 && vif.mon_cb.in_ready === 1'b1) begin observed = new(); observed.a = vif.mon_cb.a; observed.b = vif.mon_cb.b; to_reference.put(observed.copy()); to_coverage.put(observed.copy()); accepted_count++; end end endtask endclass class sum_output_monitor; sum_mon_vif_t vif; mailbox #(sum_item) to_scoreboard; int unsigned observed_count; function new(sum_mon_vif_t vif, mailbox #(sum_item) to_scoreboard); this.vif = vif; this.to_scoreboard = to_scoreboard; endfunction task run(); sum_item observed; forever begin @vif.mon_cb; if (vif.mon_cb.rst_n === 1'b1 && vif.mon_cb.out_valid === 1'b1) begin observed = new(); observed.sum = vif.mon_cb.sum; to_scoreboard.put(observed); observed_count++; end end endtask endclass class sum_reference_model; mailbox #(sum_item) inbox; mailbox #(sum_item) expected_out; int unsigned predicted_count; function new(mailbox #(sum_item) inbox, mailbox #(sum_item) expected_out); this.inbox = inbox; this.expected_out = expected_out; endfunction task run(); sum_item input_item; sum_item expected; forever begin inbox.get(input_item); expected = input_item.copy(); expected.sum = {1'b0, input_item.a} + {1'b0, input_item.b}; expected_out.put(expected); predicted_count++; end endtask endclass class sum_scoreboard; mailbox #(sum_item) expected_in; mailbox #(sum_item) actual_in; int unsigned expected_count; int unsigned observed_count; int unsigned compared_count; int unsigned errors; function new(mailbox #(sum_item) expected_in, mailbox #(sum_item) actual_in); this.expected_in = expected_in; this.actual_in = actual_in; endfunction task run(); sum_item expected; sum_item actual; forever begin expected_in.get(expected); expected_count++; actual_in.get(actual); observed_count++; compared_count++; if (actual.sum !== expected.sum) begin errors++; $error("[SCOREBOARD] a=%0h b=%0h expected=%0h actual=%0h time=%0t", expected.a, expected.b, expected.sum, actual.sum, $time); end end endtask task wrap_up(int unsigned requested, int unsigned generated, int unsigned driven, int unsigned accepted, int unsigned predicted, int unsigned output_observed, int unsigned covered, real coverage_percent); if (generated != requested || driven != requested || accepted != requested || predicted != requested || output_observed != requested || expected_count != requested || observed_count != requested || compared_count != requested || covered != requested) begin errors++; $error("[COUNTS] req=%0d gen=%0d drv=%0d in=%0d pred=%0d out=%0d exp=%0d obs=%0d cmp=%0d cov=%0d", requested, generated, driven, accepted, predicted, output_observed, expected_count, observed_count, compared_count, covered); end if (expected_in.num() != 0 || actual_in.num() != 0) begin errors++; $error("[LEFTOVERS] expected=%0d actual=%0d", expected_in.num(), actual_in.num()); end if (compared_count == 0) begin errors++; $error("[SCOREBOARD] no comparison was performed"); end if (errors == 0) $display("PASS: %0d comparisons, coverage=%0.2f%%", compared_count, coverage_percent); else $fatal(1, "FAIL: %0d error(s)", errors); endtask endclass class sum_coverage; mailbox #(sum_item) inbox; sum_item current; int unsigned sampled_count; covergroup operands_cg; option.per_instance = 1; a_cp: coverpoint current.a { bins zero = {0}; bins low = {[1:127]}; bins high = {[128:254]}; bins max = {255}; } b_cp: coverpoint current.b { bins zero = {0}; bins low = {[1:127]}; bins high = {[128:254]}; bins max = {255}; } operands_cross: cross a_cp, b_cp; endgroup function new(mailbox #(sum_item) inbox); this.inbox = inbox; operands_cg = new(); endfunction task run(); forever begin inbox.get(current); operands_cg.sample(); sampled_count++; end endtask function real percentage(); return operands_cg.get_inst_coverage(); endfunction endclass
3. Environment, test, and top
build() constructs and connects without advancing time. run() starts the components in parallel, waits for comparisons even when they fail, and then leaves two drain cycles. The watchdog can therefore distinguish a missing response from an incorrect response.
Compilation order: sum_if.sv, sum_tb_pkg.sv, sum_dut.sv, then tb_top.sv. Enable SystemVerilog and use a simulator that supports classes, mailboxes, virtual interfaces, and covergroups.
A PASS verdict means that every command in this run was observed and compared correctly. It does not mean that the verification plan reached 100%: the coverage percentage is precisely what guides the selection of subsequent scenarios.
4. Read the example using UVM vocabulary
Executable element
Responsibility preserved during migration
sum_item
copyable and randomizable transaction object
sum_generator
scenario production; UVM then separates sequence and arbitration
sum_driver
transaction-to-signal conversion through a virtual interface
monitors
passive observation and publication of accepted transactions
sum_reference_model
prediction independent of the RTL
sum_scoreboard
matching, comparison, leftovers, and verdict
sum_coverage
separate measurement triggered after real observation
sum_environment
construction, connection, execution, draining, and reporting
sum_test
scenario, configuration, and extension selection
In a UVM library, the factory replaces new at creation points that must be replaceable, configuration distributes cfg and virtual interfaces, and monitor publication feeds several subscribers. Migration must change neither the DUT contract, the oracle, nor the termination rules.
5. Prove that the environment detects real faults
Make three controlled mutations to the DUT:
temporarily replace + with -: comparisons finish and the scoreboard reports FAIL;
force out_valid to 0: the watchdog must report TIMEOUT;
produce one extra response: the counter or leftover-mailbox checks must report FAIL.
These negative tests verify the testbench itself. A nominal run is not enough to demonstrate that the oracle, drain, and verdict can discriminate between correct and faulty behavior.
Configuration: distribute resources without globals
A reusable environment must provide each component with the resources it depends on: virtual interface, transaction count, timeout, active or passive mode, and DUT configuration.
Conceptually, a configuration database associates:
a value type;
a scope in the hierarchy;
a name;
the value itself.
The component then retrieves the resource within its scope. Every required retrieval must be checked immediately and produce an explicit message. A misspelled key, mismatched type, or overly broad scope must not lead to a late and mysterious failure.
For a virtual interface, preserve the same modport in the published type and requested type. This prevents a driver from accidentally receiving a monitor view or bypassing its clocking block.
Factory: replace a type without changing its creators
A SystemVerilog constructor is not virtual. If the environment calls new everywhere, replacing a base driver with an error-injecting variant requires editing every creation site.
The factory uses a registry and a proxy object to select the concrete type when an instance is created. UVM component creation then follows this general form:
After the registration required by the library, a test can request that a derived type be produced in place of the base type. The rest of the environment continues to use a base-class handle, and virtual methods select the actual behavior.
The factory is useful only when creation actually goes through it. Purely local objects that never need replacement can still be constructed directly.
Callbacks and publication
A callback is a planned extension point: before transmission, it can delay, drop, or corrupt a transaction; after transmission or observation, it can feed the model, scoreboard, or coverage.
Order protects correctness:
apply callbacks that modify the transaction;
perform the transfer;
confirm what was actually accepted;
only then produce the expected result and sample coverage.
When a monitor feeds several independent consumers, transaction publication prevents it from directly knowing every scoreboard or coverage collector. Adding a subscriber then leaves the monitor unchanged. Publish a transaction that will no longer be modified, or give each consumer a copy if it may transform the object.
Keep the test above the environment
A specialized test sets configuration, optionally replaces a type, modifies blueprint constraints, and installs callbacks. It does not copy the shared classes. This separation allows nominal, boundary, error, and concurrency tests to run on the same environment.
UVM becomes worthwhile when several interfaces, configurations, teams, or integration levels need to reuse these components. For a small block and a few directed scenarios, the self-checking testbench from Chapter 12 is often more direct.
A concrete migration path
Make a simple testbench pass with a self-contained verdict.
Introduce an interface and clocking blocks to stabilize timing.
Separate the transaction, driver, monitors, reference model, and scoreboard.
Add Build, Run, draining, Wrap-up, and a timeout.
Centralize configuration and virtual interfaces.
Add callbacks and replaceable creation only at useful extension points.
Move to the exact UVM classes and phases when reuse justifies it.
At every step, inject a known error and confirm that the environment fails. A sophisticated architecture that allows a faulty DUT to pass remains a bad testbench.
Key takeaways
UVM standardizes a layered SystemVerilog environment; it does not change its responsibilities.
A complete example connects generation, driving, observation, prediction, comparison, coverage, timeout, and an autonomous verdict.
Build, Run, and Wrap-up form a useful mental model, but not the complete UVM phase list.
Draining waits for completed comparisons, not only successful matches; an incorrect response produces FAIL, while a missing response produces TIMEOUT.
Configuration provides resources and virtual interfaces locally, with immediate checks on every required retrieval.
The factory makes a type replaceable when objects are created through type_id::create.
Callbacks and publication extend the environment without coupling every component.
UVM is justified by reuse and scale, not by the methodology's name.