The fatal migration mistake is the big-bang rewrite: weeks pass with zero working tests, and every failure is ambiguous between the rewrite and the RTL. Migrate as a staged refactor instead, with the full test list green after every stage.
STAGED MIGRATION LADDER (regression green after EVERY rung)
stage 0 hand-built class TB, tests passing ← start: a known-good base
│
stage 1 TXN: txn class extends uvm_sequence_item
│ (field macros or do_copy/do_compare/convert2string)
stage 2 COMPONENTS: drv/mon/scb extend uvm_component family;
│ still wired by hand, still driven by your env
stage 3 PHASES: new()/connect()/run() code moves into
│ build_phase / connect_phase / run_phase; objections
│ replace the done-counter
stage 4 FACTORY + CONFIG DB: type_id::create everywhere,
│ virtual interfaces via uvm_config_db, +UVM_TESTNAME
stage 5 SEQUENCES: generator logic becomes sequences on a
│ sequencer; mailbox handoff becomes seq_item_port
▼
full UVM env — and tests ran at every stageNever start a stage from a red base. Where a stage is long, typically sequences, keep the old data path alive behind a config flag so a mid-stage RTL bug can still be chased with a working testbench.
End every stage cleanly in both worlds. The hand-built checklist is: signal stimulus exhausted, drain with a watchdog timeout, sweep leftovers in the scoreboard, then print one verdict banner.
Hand-built: generator signals exhausted, then drain until the driver is idle and the expected queue empties, with a watchdog so a hang is loud.
Hand-built: scoreboard final sweep treats leftover expected or orphan actual transactions as defects, then one PASS/FAIL banner plus exit code closes the run.
UVM: components raise objections while work is pending, and run_phase ends when all are dropped, with drain time absorbing tail traffic.
UVM: check_phase and report_phase evaluate leftovers after run_phase, and a watchdog calling uvm_fatal("ID", "msg") on timeout is still required.
Both endings are votes-outstanding schemes: a done counter is a single vote, while objections let every component vote independently.
The ladder order follows dependencies, with behavior-neutral changes first and the shape change last.
Transaction first: everything touches it, and extending uvm_sequence_item changes no behavior.
Components second: uvm_component buys reporting and hierarchy while the existing environment still wires by hand.
Phases third: the first behavioral change, moving construction and run control into phase callbacks, with objections replacing the done counter.
Factory and config db fourth: mechanical substitution of new() and constructor plumbing, enabling +UVM_TESTNAME test selection.
Sequences last: the largest shape change, taken once everything around it is stable.
Stage 1 wraps the transaction with field macros, which generate the copy, compare, and print routines. The UVM course owns the sequence item detail.
The UVM transaction below is not run in this course's checks because it needs the UVM library, which the course's simulators here do not have.
// not-run: needs the UVM library, which is not installed for the course runner
class bus_txn extends uvm_sequence_item;
`uvm_object_utils_begin(bus_txn)
`uvm_field_int(write, UVM_ALL_ON)
`uvm_field_int(addr, UVM_ALL_ON)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_object_utils_end
rand bit write;
rand bit [31:0] addr, data;
function new(string name = "bus_txn");
super.new(name);
endfunction
endclassThe plain check below runs under Icarus and checks the same compare on a struct. Transactions built by one path must equal golden values written out separately, both ways, and an altered transaction must compare unequal.
module tb_bus_txn;
typedef struct packed {
bit write;
bit [31:0] addr;
bit [31:0] data;
} bus_txn_t;
function automatic bit txn_equal(bus_txn_t a, bus_txn_t b);
return (a.write == b.write) && (a.addr == b.addr) && (a.data == b.data);
endfunction
// The "actual" side: transactions built by a separate path
function automatic bus_txn_t make_txn(int i);
bus_txn_t t;
t.write = i[0];
t.addr = 32'h1000 + (i * 4);
t.data = 32'hA5A50000 + i;
return t;
endfunction
bus_txn_t golden[4];
bus_txn_t got[4];
bus_txn_t odd;
int bad;
initial begin
bad = 0;
// golden values, written out by hand: {write, addr, data}
golden[0] = {1'b0, 32'h0000_1000, 32'hA5A5_0000};
golden[1] = {1'b1, 32'h0000_1004, 32'hA5A5_0001};
golden[2] = {1'b0, 32'h0000_1008, 32'hA5A5_0002};
golden[3] = {1'b1, 32'h0000_100C, 32'hA5A5_0003};
for (int i = 0; i < 4; i++) begin
got[i] = make_txn(i);
if (!txn_equal(golden[i], got[i])) bad = bad + 1;
if (!txn_equal(got[i], golden[i])) bad = bad + 1;
end
// a compare that cannot say "different" proves nothing
odd = golden[2];
odd.data[0] = ~odd.data[0];
if (txn_equal(odd, golden[2])) bad = bad + 1;
if (bad == 0) $display("PASS: 4 transactions matched golden values both ways, 1 altered one compared unequal");
else $fatal(1, "FAIL: bad=%0d of 9 compares", bad);
$finish;
end
endmoduleOnly components needing phasing, factory overrides, or config db access should extend uvm_component. Pure computation such as reference models and checkers stays plain, which keeps it reusable outside UVM.
The harness below runs under Icarus and checks the same predict body that the scoreboard adapter calls.
module tb_alu_ref;
// Bit 32 says whether the opcode is known. An X sentinel would not
// work: a 2-state type (or a 2-state simulator such as Verilator)
// turns X into 0, and 0 would then pass as a real prediction.
function automatic bit [32:0] alu_predict(bit [3:0] op, bit [31:0] a, bit [31:0] b);
case (op)
4'h0: return {1'b1, a + b};
4'h1: return {1'b1, a - b};
4'h2: return {1'b1, a & b};
default: return {1'b0, 32'h0}; // unknown opcode: no prediction
endcase
endfunction
int bad;
bit [32:0] got;
bit [31:0] want [0:2];
initial begin
bad = 0;
want[0] = 32'd13; want[1] = 32'd7; want[2] = 32'd2; // 10 op 3, by hand
for (int i = 0; i < 3; i++) begin
got = alu_predict(i[3:0], 32'd10, 32'd3);
if (!got[32] || got[31:0] !== want[i]) bad = bad + 1;
end
// the unknown opcode must be reported, never read as a value
got = alu_predict(4'h3, 32'd10, 32'd3);
if (got[32]) bad = bad + 1;
if (bad == 0) $display("PASS: reference model matched 3 opcodes and reported the unknown one");
else $fatal(1, "FAIL: bad=%0d of 4 checks", bad);
$finish;
end
endmoduleThe UVM scoreboard below is a thin adapter around that plain brain. It is not run in this course's checks because it needs the UVM library, which the course's simulators here do not have.
// not-run: needs the UVM library, which is not installed for the course runner
class alu_scoreboard extends uvm_scoreboard;
`uvm_component_utils(alu_scoreboard)
uvm_analysis_imp #(alu_txn, alu_scoreboard) imp;
alu_ref_model model; // plain class, just new() it
function void build_phase(uvm_phase phase);
super.build_phase(phase);
imp = new("imp", this);
model = new(); // no factory, no config db needed
endfunction
function void write(alu_txn t);
bit [32:0] exp = model.predict(t.op, t.a, t.b); // {known, value}
if (!exp[32])
`uvm_error("SCB", $sformatf("op=%0h has no prediction", t.op))
else if (exp[31:0] !== t.result)
`uvm_error("SCB", $sformatf("op=%0h exp=%0h act=%0h", t.op, exp[31:0], t.result))
endfunction
endclassA proven legacy bus model is wrapped the same way: a thin driver translates sequencer items into BFM calls. Reads return through a response object so the sequence sees the data. The response must carry the request's ids (set_id_info), or the sequencer stops with a fatal error, and the sequence must call get_response() for each read so the response queue does not fill up.
The wrapper below is not run in this course's checks because it needs the UVM library, which the course's simulators here do not have.
// not-run: needs the UVM library, which is not installed for the course runner
class legacy_bfm_driver extends uvm_driver #(bus_txn);
`uvm_component_utils(legacy_bfm_driver)
bus_bfm bfm; // legacy plain-class model, handed in by the env
task run_phase(uvm_phase phase);
bus_txn rsp;
forever begin
seq_item_port.get_next_item(req);
if (req.write) begin
bfm.write(req.addr, req.data);
seq_item_port.item_done();
end else begin
rsp = bus_txn::type_id::create("rsp");
bfm.read(req.addr, rsp.data); // read value lands in rsp
rsp.addr = req.addr;
rsp.set_id_info(req); // route the response to its sequence
seq_item_port.item_done(rsp); // sequence reads it with get_response()
end
end
endtask
endclassPORTABILITY BOUNDARY
┌── UVM WORLD ───────────────────────┐ ┌── PLAIN WORLD ───────────┐
│ sequences sequencer │ │ │
│ │ │ │ alu_ref_model (compute) │
│ ▼ │ │ protocol_checker (rules)│
│ thin uvm_driver ── api calls ──────► bus_bfm (pin wiggling) │
│ uvm_scoreboard ── predict() ──────► packet_lib (utilities) │
│ (adapters: UVM plumbing only) │ │ (no uvm_* anywhere) │
└────────────────────────────────────┘ └──────────────────────────┘
keep dependencies pointing LEFT→RIGHT only:
UVM adapters may use plain classes; plain classes never import uvm_pkgThe transaction touches everything and the change is behavior-neutral, so it unblocks later stages at near-zero risk. Sequences first lands the largest shape change with no stable base under it.
RTL bugs land unnoticed in exactly that window. The rule is a green regression after every rung, with the scoreboard live throughout.
uvm_pkg for one reporting call. Why does that matter?It is no longer portable: it now compiles only inside UVM. Report through return values or callbacks instead, and keep the model free of UVM.