Part 2 · OOP for Verification · Intermediate
The Transaction Base Class
The copy/compare/convert2string/pack discipline every transaction must implement, with a full bus_txn example.
Why every transaction implements the same four methods
A transaction object is created once but used everywhere : the generator randomizes it, the driver consumes it, the monitor reconstructs an observed copy, and the scoreboard compares expected against actual. Each of those uses needs a capability the language does not give classes for free: copy() (deep duplication — assignment only copies the handle), compare() (field-by-field equality — == on handles compares identity, not content), convert2string() (one-line debug rendering), and pack() / unpack() (serialization to a bit stream for pin-level driving or DPI transport).
The discipline is to implement all four on every transaction class, even when a particular bench does not yet call one of them. The first time a scoreboard mysteriously "passes" because it compared handles, or a monitor txn mutates because someone stored an alias instead of a copy, the value of the discipline becomes obvious — usually at 2 a.m. before tapeout.
WHY HANDLE SEMANTICS FORCE THE DISCIPLINE
txn a = new(); a.addr = 'h10;
txn b = a; // COPY? No — both handles → one object
b.addr = 'h20; // a.addr is now ALSO 'h20
a ──┐
├──► [ object: addr='h20 ] one object, two names
b ──┘
if (a == b) → compares HANDLES → true even if fields differ
a.compare(b) → compares FIELDS → what the scoreboard needs
b = a.copy() → second OBJECT → safe to mutate independently
a ──► [ addr='h20 ] b ──► [ addr='h20 ] (detached twin)The full bus_txn reference implementation
typedef enum bit { READ, WRITE } dir_e;
class bus_txn;
rand dir_e dir;
rand bit [15:0] addr;
rand bit [31:0] data;
rand bit [3:0] len;
constraint c_len { len inside {[1:8]}; }
// ---- copy: construct-then-fill, virtual so subclasses extend it ----
virtual function bus_txn copy();
bus_txn t = new();
t.dir = dir;
t.addr = addr;
t.data = data;
t.len = len;
return t;
endfunction
// ---- compare: field equality, tolerant of null ----
virtual function bit compare(bus_txn rhs);
if (rhs == null) return 0;
return (dir == rhs.dir ) &&
(addr == rhs.addr) &&
(data == rhs.data) &&
(len == rhs.len );
endfunction
// ---- convert2string: ONE line, dense, greppable ----
virtual function string convert2string();
return $sformatf("%s addr=%04h data=%08h len=%0d",
dir.name(), addr, data, len);
endfunction
// ---- pack/unpack: fixed field order is the contract ----
virtual function void pack(output bit bits[$]);
bits = {};
bits = {bits, dir};
for (int i = 15; i >= 0; i--) bits.push_back(addr[i]);
for (int i = 31; i >= 0; i--) bits.push_back(data[i]);
for (int i = 3; i >= 0; i--) bits.push_back(len[i]);
endfunction
virtual function void unpack(input bit bits[$]);
int k = 0;
dir = dir_e'(bits[k]); k++;
for (int i = 15; i >= 0; i--) begin addr[i] = bits[k]; k++; end
for (int i = 31; i >= 0; i--) begin data[i] = bits[k]; k++; end
for (int i = 3; i >= 0; i--) begin len[i] = bits[k]; k++; end
endfunction
endclassSubclass extension — the part interviews probe
Every method is virtual so a derived transaction (say, an error-injecting subclass) can extend rather than break the contract: its copy() copies the new fields after delegating the base fields, and its compare() calls the base compare first. Without virtual, a scoreboard holding base-class handles would silently run the base methods on derived objects — comparing half the fields.
class err_txn extends bus_txn;
rand bit force_err;
virtual function bus_txn copy();
err_txn t = new();
void'(super_fields_into(t)); // or repeat base assignments
t.force_err = force_err;
return t;
endfunction
protected function bus_txn super_fields_into(bus_txn t);
t.dir = dir; t.addr = addr; t.data = data; t.len = len;
return t;
endfunction
endclassUsage in the dataflow — who calls what
Generator: t = new(); t.randomize(); mb.put(t.copy()) — the copy detaches the queued object so re-randomizing t cannot corrupt it.
Monitor: builds a FRESH txn from observed pins each transfer and sends a copy to subscribers — never a handle it will reuse.
Scoreboard: exp.compare(act) decides pass/fail; on mismatch, prints exp.convert2string() and act.convert2string() side by side.
Pin-level / DPI boundary: pack() flattens the txn into bits for a serial driver or a C-side checker; unpack() rebuilds it.
Interview angle
"What is wrong with b = a for objects?" — handle alias; mutation through either name hits the one object.
"Why must copy/compare be virtual?" — base-handle polymorphism: derived txns through base handles must run derived methods.
"This maps to which UVM methods?" — do_copy/do_compare/convert2string/do_pack under uvm_object, driven by field automation or manual overrides.
Key takeaways
Assignment copies handles, == compares handles — transactions need explicit copy() and compare().
Implement copy/compare/convert2string/pack on every txn class, virtual, from day one.
Subclasses delegate to the base implementation and extend it — never re-implement from scratch.
This is the plain-SV ancestor of uvm_object's do_copy/do_compare/do_pack contract.
Common pitfalls
Shallow-copying a txn that contains other class handles — the inner objects stay shared.
compare() that forgets a newly added field — scoreboard goes blind to bugs in exactly that field.
Non-virtual copy() in the base — derived objects copied via base handles silently lose their extra fields.
pack/unpack with mismatched field order — every value lands in the wrong field, sometimes plausibly.