Open any test on the DMA bench and it is short: dma_rand_test is about twenty lines, dma_truncate_test about twenty-five. A colleague reviewing them suggests deleting dma_base_test and pasting its fifty lines into each test, "so every test is self-contained and easy to read on its own." Nine tests later, someone renames the APB agent. How many files change, and how many of them will be missed?
This lesson takes dma_base_test apart: what it promises every derived test, why each line is there, what it deliberately does not do, and what a base test on a real project adds on top.
A base test is the class every scenario test extends. Its job is to make a promise: after super.build_phase() returns, the environment exists, the handles every scenario needs are in hand, and a small vocabulary of operations is ready to use. A derived test then only says what is different about its scenario. That promise is the contract, and its value is that it is written once.
Think of a film studio's standing set. The street, the lighting rig and the camera tracks are built once; each scene is shot on them by changing the actors and the script. Nobody rebuilds the street for every scene, and if the street needs a new lamp post, it goes up once and every later scene has it. The base test is the set; each derived test is a scene.
// snippet: from the course DMA bench
class dma_base_test extends uvm_test;
`uvm_component_utils(dma_base_test)
dma_env env;
virtual irq_if irq_vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
env = dma_env::type_id::create("env", this);
if (!uvm_config_db#(virtual irq_if)::get(this, "", "irq_vif", irq_vif))
`uvm_fatal("NOVIF", "irq_if not set for the test")
endfunction
function void end_of_elaboration_phase(uvm_phase phase);
uvm_root::get().print_topology();
endfunction
// Register helpers used by every test.
task reg_write(bit [7:0] addr, bit [31:0] data);
apb_rw_seq s = apb_rw_seq::type_id::create("s");
s.addr = addr; s.write = 1; s.data = data;
s.start(env.apb.sqr);
endtask
task reg_read(bit [7:0] addr, output bit [31:0] data);
apb_rw_seq s = apb_rw_seq::type_id::create("s");
s.addr = addr; s.write = 0; s.data = 0;
s.start(env.apb.sqr);
data = s.rdata;
endtask
// One complete job: program the DMA, stream n words, wait for the interrupt,
// return STATUS and clear its DONE/ERR bits.
task run_job(dma_job_cfg job, int unsigned n_words, output bit [31:0] status);
axis_pkt_seq pkt = axis_pkt_seq::type_id::create("pkt");
reg_write(KEY, job.key);
reg_write(LEN, job.len);
reg_write(CTRL, {30'b0, job.irq_en, 1'b1});
if (!pkt.randomize() with { n == n_words; })
`uvm_fatal("RAND", "axis_pkt_seq randomize failed")
pkt.start(env.in_agt.sqr);
wait (irq_vif.irq === 1'b1);
reg_read(STATUS, status);
reg_write(STATUS, 32'h6); // write 1 to clear DONE and ERR
endtask
endclasssuper.build_phase(phase) comes first. It is what applies configuration to the test's own fields (fact: uvm_component::build_phase calls apply_config_settings() when automatic configuration is on), and skipping it is a habit that bites later, in classes that do have such fields. Then the env is created with type_id::create, never new, so that a derived test's factory override can replace the env or anything inside it. Factory Overrides in Tests shows four tests that rely on it.
The interrupt interface is fetched with a fatal on failure. A test that forgets to check would continue with a null handle and die later inside wait (irq_vif.irq === 1'b1), far from the cause. The fatal at time 0 says exactly which handle is missing. Every derived test inherits that check without writing it.
print_topology() lists every component with its actual type. After a factory override that is the only proof that the override hit: in dma_backpressure_test the table shows out_agt as axis_sink_agent while in_agt stays axis_agent. It runs in end of elaboration because by then every component exists and every connection is made, but no time has passed.
reg_write and reg_read each create an apb_rw_seq and start it on env.apb.sqr. The read data comes back in the sequence's rdata field: the driver writes it into the request item and calls item_done() with no argument, so there is no response queue to drain. The driver handshake explains why.
run_job is one complete DMA job: program KEY and LEN, start with GO (and IRQ_EN from the job), stream n_words words, wait for the interrupt, read STATUS and clear DONE and ERR. Its n_words argument is separate from job.len on purpose, so a test can send fewer words than it programmed.
None of them raises an objection. They run inside a derived test's run_phase, which owns the objection.
There is no run_phase in dma_base_test, so no stimulus and no objection: every derived test decides what to run and when it is done. There is no timeout either; among the tests in this part only dma_timeout_test sets one (the debug playbooks' watchdog and hang tests set their own later). Both are choices worth arguing about, and Shared run_phase argues them. There is also no configuration object, because the block env hard-wires its topology. The Environment and Its Config Object adds one.
On a real project the base test grows a few more duties. The listing below shows them on the DMA bench: an env config object built in a fixed order (class defaults, then the scenario, then the command line, then a validity check), a global timeout, and a verdict line. The order matters: the command line comes after the scenario so that you can change a test's knob for one debug run without editing the test.
// snippet
class dma_proj_base_test extends uvm_test;
`uvm_component_utils(dma_proj_base_test)
dma_env env;
dma_env_cfg cfg; // topology knobs, see the env-architecture topic
virtual irq_if irq_vif;
int unsigned n_jobs = 3; // a scenario knob with its default
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
// Hooks for derived tests, called in this order by build_phase.
virtual function void configure_scenario(); endfunction // edit cfg and knobs
virtual function void apply_overrides(); endfunction // factory overrides
function void build_phase(uvm_phase phase);
super.build_phase(phase);
cfg = dma_env_cfg::type_id::create("cfg"); // 1. class defaults
configure_scenario(); // 2. the test's intent
void'($value$plusargs("DMA_JOBS=%d", n_jobs)); // 3. the command line wins
if (n_jobs == 0) `uvm_fatal("CFG", "DMA_JOBS must be at least 1") // 4. validate
apply_overrides();
uvm_config_db#(dma_env_cfg)::set(this, "env", "cfg", cfg);
env = dma_env::type_id::create("env", this);
if (!uvm_config_db#(virtual irq_if)::get(this, "", "irq_vif", irq_vif))
`uvm_fatal("NOVIF", "irq_if not set for the test")
uvm_root::get().set_timeout(2ms, 1); // no test may hang a farm slot
endfunction
function void report_phase(uvm_phase phase);
uvm_report_server rs = uvm_report_server::get_server();
int bad = rs.get_severity_count(UVM_ERROR) + rs.get_severity_count(UVM_FATAL);
`uvm_info("VERDICT", bad == 0 ? "TEST PASSED" : "TEST FAILED", UVM_NONE)
endfunction
endclassTwo cautions come with it. The verdict line never appears after a fatal, because report_phase does not run then; regression scripts must judge by the report summary's counts, not by the banner. And the timeout set with overridable 1 replaces a +UVM_TIMEOUT=...,YES from the command line; run_test and timeouts explains the order.
Every test prints the same topology header, because the base test prints it. Here is the start of the smoke test's run, port rows trimmed:
UVM_INFO @ 0: reporter [RNTST] Running test dma_smoke_test...
UVM_INFO uvm_root.svh(531) @ 0: reporter [UVMTOP] UVM testbench topology:
Name Type Size Value
<unnamed> uvm_root - @70
uvm_test_top dma_smoke_test - @180
env dma_env - @250
apb apb_agent - @264
drv apb_driver - @466
mon apb_monitor - @326
sqr uvm_sequencer - @343
...
UVM_INFO dma_tb.sv(411) @ 655000: uvm_test_top.env.sb [SB] 8 words matched, 0 mismatched
UVM_ERROR : 0
UVM_FATAL : 0uvm_test_top has the derived type, dma_smoke_test, but the env row under it was built by the base test's build_phase.
The @ numbers are object ids. They differ between simulators and runs; never compare them.
An engineer adds a test that needs a different driver. They copy the shape of dma_truncate_test, write a build_phase that registers the override, and in the rush leave out super.build_phase(phase). It compiles. At time 0 the run dies with a null-object access inside reg_write, on the line that starts the sequence on env.apb.sqr.
The topology print gives it away: it shows uvm_test_top and nothing under it. The derived build_phase replaced the base one entirely, so the env was never created and irq_vif was never fetched. The fix is one line, and the habit that prevents it is simple: every build_phase override starts or ends with super.build_phase(phase), and reviewers look for it. The same mistake on a component with field macros is quieter: nothing crashes, but configuration from config_db is never applied.
build_phase, calls super.build_phase(phase) first, and then sets uvm_config_db#(int)::set(this, "env.sb", "verbose", 1). Does the scoreboard see it?Yes. super.build_phase creates the env object, but the env's own build_phase, which creates the scoreboard, runs only after the test's build_phase returns (build is top-down). By the time the scoreboard's build_phase calls get, the set is already there. The same reasoning is why many overrides still work when registered after super.build_phase, as the overrides lesson shows.
dma_base_test fetch irq_vif with a fatal, when wait (irq_vif.irq === 1'b1) would fail anyway if the handle were null?Because of where and when it fails. The fatal fires at time 0 in build with a message naming the missing handle. The null access would fire later, inside run_job, possibly after minutes of simulation, with a message about a null object that sends you looking at the sequence or the DUT.
Every derived test already raises and drops its own objection, so nothing breaks immediately, but the base test's objection would have to be dropped by someone. If the base drops it at the end of an empty run_phase, it adds nothing; if it waits for a hook, the derived tests must be rewritten to use the hook instead of run_phase. The fix is a template-method base, which is a design change, not a one-line addition.
Next: the base test is the set; now read the nine scenes. Scenario Inheritance sorts the derived tests by what each one changes.