The Transaction Class
Every component in the block diagram from The FIFO Testbench Plan — generator, driver, monitor, scoreboard — passes the same thing back and forth: one transaction, representing a single FIFO operation. This page defines it, deliberately as plainly as SystemVerilog allows, before anything else gets built on top of it.
A transaction, with no base class at all
class fifo_txn;
typedef enum {WRITE, READ} op_e;
rand op_e op;
rand bit [7:0] wr_data;
bit [7:0] rd_data;
function void display();
$display("op=%s wr_data=%0h rd_data=%0h", op.name(), wr_data, rd_data);
endfunction
endclass
oppicks which operation this transaction represents;wr_datais randomized for writes, andrd_datais filled in later — by the monitor, from what it actually observes on the DUT's pins, never randomized by anything upstream.class fifo_txn;— noextendsat all. This is deliberate: UVM's ownfifo_txnextendsuvm_sequence_item, which is what gives itprint(),copy(),compare(), and factory registration for free. This version has none of that —display()above is a hand-written method covering exactly one of those, because nothing here provides it automatically.
Every convenience uvm_object bundles in — printing, copying, comparing, cloning, factory-based construction — is a specific answer to "what does every transaction-like class need, that shouldn't be rewritten by hand each time." Building fifo_txn without any of that is what makes UVM's Object Utility Methods land, later, as solving a problem you'll have already felt directly here.
A class, not a struct — handle semantics matter
fifo_txn is a class, not a struct, and that choice has a real consequence: a variable of type fifo_txn holds a handle (a reference), not the object's data directly. Assigning one fifo_txn variable to another (txn2 = txn1;) copies the handle, not the transaction — both variables then point at the exact same object, and randomizing or modifying one changes what the other sees too, since there's only ever one underlying object between them.
This is exactly why The Generator's run() task calls fifo_txn txn = new(); inside its repeat loop, once per transaction, rather than constructing one txn before the loop and reusing the same handle across every randomize()/put() call. Reusing a single handle wouldn't just be inefficient — it would be a real bug: every entry already sitting in gen2drv's mailbox is a handle to the same one object, so by the time the driver gets around to reading transaction #1, randomize() calls for transactions #2 through #20 have already silently overwritten its fields. new() every iteration is what gives each transaction its own separate object, with its own separate data, safe to randomize independently of whatever the mailbox is still holding from earlier iterations.
Writing copy() by hand — what uvm_object would give for free
Handle semantics explain why new() every loop iteration is necessary; they also explain why a genuine duplicate of a transaction — same field values, but a completely separate object — needs its own explicit method here, since fifo_txn has no base class supplying one automatically:
function fifo_txn copy();
fifo_txn c = new();
c.op = this.op;
c.wr_data = this.wr_data;
c.rd_data = this.rd_data;
return c;
endfunction
Plain SystemVerilog class assignment (txn2 = txn1;) only ever performs a shallow copy in the loose sense already covered above — for a class this simple (three scalar fields, no nested handles) that's really just handle-sharing, not a partial copy, but the distinction matters more the moment a transaction's fields start including handles to other objects (a nested class, a queue of sub-packets): a true deep copy has to explicitly construct and copy those too, field by field, or the "copy" still ends up sharing the nested object's handle underneath. copy() above is deliberately hand-written for exactly that reason — nothing in plain SystemVerilog derives it automatically. This is precisely the second gap Object Utility Methods closes: uvm_object's copy() (built on field-macro registration) performs a correct deep copy without this class needing to hand-write one.
A related trap: == compares handles, not content
There's a second gap alongside copy(), worth naming explicitly because it's an easy mistake to make once a scoreboard needs to check whether two transactions match: txn1 == txn2 does not compare the two transactions' field values. Since fifo_txn is a class, a variable of that type holds a handle, and == on two class handles asks whether they point at the same object in memory — not whether the objects they point at happen to hold equal data. Two separately-new()'d transactions with identical op/wr_data/rd_data fields will compare == as false, because they're genuinely two different objects, even though a human reading their contents would call them equal.
The fix mirrors copy() above — write a compare() method by hand, field by field:
function bit compare(fifo_txn rhs);
return (this.op == rhs.op) &&
(this.wr_data == rhs.wr_data) &&
(this.rd_data == rhs.rd_data);
endfunction
This is exactly the comparison a real scoreboard needs — checking whether the DUT's actual output transaction matches a predicted one — and exactly why it's a bad idea to rely on == for that check. It's also the third specific gap, alongside print()/copy(), that UVM's uvm_object.compare() (built on the same field-macro registration Object Utility Methods covers) closes automatically, without a class needing to hand-write field-by-field logic like this.
What's next
A transaction on its own does nothing — something has to create instances of it, randomize them, and hand them off to whatever drives the DUT. The next page builds that: the generator.