Interface & Transaction
Section A covered UVM's mechanics in the abstract — class hierarchy, printing, the factory — without ever touching a real DUT. This section builds a complete testbench, one component at a time, against one running example: a small synchronous FIFO. By the end of it, every piece from this page onward assembles into the same complete testbench shown in the Example Walkthrough — this isn't a new example, it's that example, introduced one layer at a time instead of all at once.
The DUT
A synchronous FIFO with the simplest possible push/pop interface: assert wr_en with wr_data to push (unless full); assert rd_en to pop, with the result appearing on rd_data one cycle later (unless empty).
Two design decisions worth noticing before any UVM code at all:
- One clock, one reset, four control signals, two data signals. A real FIFO might have almost-full thresholds, error flags, or a parameterized width — deliberately left out here so the testbench code that follows stays about UVM mechanics, not about modeling a complicated protocol.
wr_enandrd_enare independent signals, not a single 2-bit opcode. That mirrors how most real interfaces are actually built (separate control lines per operation, not a pre-encoded command), and it's the detail the transaction class below has to actively account for.
The interface
interface fifo_if (input bit clk, input bit rst_n);
logic wr_en;
logic [7:0] wr_data;
logic full;
logic rd_en;
logic [7:0] rd_data;
logic empty;
endinterface
Every signal the DUT exposes gets a slot here, and nothing else does — clk/rst_n arrive as interface ports (driven from outside, by testbench-top later in this section) rather than signals declared inside, since clock and reset are generated once and shared by everything that connects to this interface, not owned by it.
The transaction
A transaction is one unit of stimulus — here, one push or one pop. The natural first instinct is a single struct-like class with every field a FIFO operation could ever need, but look at what the interface actually asks for: a write needs wr_data going in; a read produces rd_data coming out. Modeling that distinction directly makes for a class that can't accidentally be misused:
class fifo_txn extends uvm_sequence_item;
typedef enum {WRITE, READ} op_e;
rand op_e op;
rand bit [7:0] wr_data;
bit [7:0] rd_data;
`uvm_object_utils(fifo_txn)
function new(string name = "fifo_txn");
super.new(name);
endfunction
endclass
A few choices here are worth being deliberate about, since they generalize to almost any transaction class:
opas arandenum, not two separate transaction types. One class covering both operations keeps the sequencer/driver/monitor pipeline (coming up over the next few pages) uniform — everything downstream handles one item type, not a union of two.wr_dataisrand;rd_datais not. This is the same distinction called out in Object Utility Methods's pack/unpack discussion, applied at the transaction-design level:wr_datais stimulus the sequence decides;rd_datais a result the monitor will fill in later, from what it actually observes on the DUT's pins. Marking itrandwould let a strayrandomize()call silently overwrite a real observed value — worth avoiding by construction, not by convention.extends uvm_sequence_item, notuvm_objectdirectly — this is what givesfifo_txnthe sequencer-handshake plumbing the next few pages depend on, per the class hierarchy from Base Classes. One layer of that hierarchy is worth naming explicitly:uvm_sequence_itemitself extendsuvm_transaction(which extendsuvm_object), butuvm_transactionis a legacy carryover with no awareness of sequencers or sequences at all —uvm_sequence_itemis what actually adds the sequencer handle, the parent-sequence link, andget_sequence_id()/related methods a real driver/sequencer handshake needs. A user-defined transaction always extendsuvm_sequence_item, neveruvm_transactiondirectly, for exactly this reason.`uvm_object_utils, not the_begin/_endfield-macro form — with only three fields and no need yet for automatic print/copy/compare, the plain factory-registration macro from Utility & Field Macros is enough. Nothing stops upgrading to the field-macro form later if a component needs to print or comparefifo_txninstances in detail.
Real interfaces are often messier than a clean WRITE/READ split — simultaneous request signals, don't-care combinations, or more than two operations. The general version of the op pattern above is a mutual-exclusion constraint: something like constraint one_op_c { $countones({wr_en_req, rd_en_req}) == 1; } if a transaction needs to model several signals where exactly one may be asserted at a time, rather than encoding the choice as a clean enum from the start.
The mutual-exclusion constraint, worked out
The tip above names the pattern; here's what it actually looks like once a transaction models independent request signals directly instead of a clean enum:
class fifo_req_txn extends uvm_sequence_item;
rand bit wr_en_req;
rand bit rd_en_req;
constraint one_op_c { $countones({wr_en_req, rd_en_req}) == 1; }
`uvm_object_utils(fifo_req_txn)
function new(string name = "fifo_req_txn");
super.new(name);
endfunction
endclass
$countones({wr_en_req, rd_en_req}) == 1 reads as "exactly one of these two bits is 1" — ruling out both the all-zero (no operation) and both-one (contradictory request) combinations that a plain rand bit pair would otherwise allow randomize() to produce. This is strictly more general than the op_e enum above (it scales to three, four, or more mutually-exclusive request lines without changing shape), at the cost of needing an explicit constraint instead of getting exclusivity for free from the enum's own value set.
What's next
With a transaction class in hand, the next page builds the component that actually turns fifo_txn instances into real pin wiggles on fifo_if — the driver.