SystemVerilog · Module 9
Classes & Objects — Basics
The anatomy of a class, how objects are allocated on the heap, handles as pointers, and the construction lifecycle from new() to garbage collection.
Module 9 · Page 9.2
Declaring a Class
When you write a class in SystemVerilog, nothing happens in simulation. The simulator reads your blueprint, checks it for errors, and then forgets about it until you actually create an object from it. Think of it as registering a shape — no material is used until you start stamping things out.
Here is the complete syntax, annotated so every line is clear:
// keyword class-name optional: extends ParentClass
class BusTransaction;
// ── Properties (variables owned by each object) ──────────
bit [31:0] addr; // destination address
bit [7:0] data; // payload byte
bit write; // 1 = write, 0 = read
// ── Constructor (runs automatically on new()) ─────────────
function new();
addr = 32'h0;
data = 8'h0;
write = 0;
endfunction
// ── Method (function defined inside the class) ────────────
function void display();
$display("[BusTx] addr=0x%08h data=0x%02h %s",
addr, data, write ? "WRITE" : "READ");
endfunction
endclass // every class ends hereA class can hold any number of properties and methods. Properties are the data. Methods are the behaviour. That's all there is to the structure — everything else in OOP is built on top of this one idea.
Creating Objects with new()
Declaring a class gives you a blueprint. Calling new() is what actually builds something from that blueprint. The simulator allocates memory, runs the constructor, and returns a reference to the new object. That reference is stored in your handle.
The two-step pattern you will write hundreds of times:
// Step 1 — Declare a handle (just a name, no memory yet)
BusTransaction txn;
// ^^^ handle name
// At this point: txn == null
// Step 2 — Create the object (allocate memory, run constructor)
txn = new();
// ^^^ calls the constructor, returns object reference
// Now txn points to a real object in memory
// You can also do both on one line:
BusTransaction txn2 = new();What new() Does Internally
- 1 Allocates memory The simulator reserves enough memory to hold all the properties declared in the class — in this case, 32 bits for addr, 8 bits for data, and 1 bit for write.
- 2 Runs the constructor If you wrote a
function new(), it runs now. This is your chance to set initial values. If you did not write a constructor, SystemVerilog initialises everything to 0 / null automatically. - 3 Returns a reference new() gives back a reference (memory address) to the newly created object. This reference is stored in your handle variable. From this point forward, the handle is the only way to access the object.
Accessing Properties and Calling Methods
Once you have a live object, you access everything on it using the dot operator: handle.member. Same syntax for properties and methods — just add parentheses when calling a method.
BusTransaction txn = new();
// ── Writing to a property ─────────────────────────────────────
txn.addr = 32'hC000_0004;
txn.data = 8'hFF;
txn.write = 1;
// ── Reading a property ────────────────────────────────────────
bit [31:0] saved_addr = txn.addr; // copy the value out
// ── Calling a method ──────────────────────────────────────────
txn.display();
// Output: [BusTx] addr=0xc0000004 data=0xff WRITE
// ── Passing an object to a function ──────────────────────────
task send(BusTransaction t);
// use t.addr, t.data etc inside here
endtask
send(txn); // passes the handle, not a copy of the objectMultiple Objects — Each One Is Independent
This is where OOP pays off fast. You can create as many objects as you want from one class, and each one gets its own private copy of every property. Changing one object's data has absolutely no effect on any other object.
BusTransaction wr, rd;
// Create two completely independent objects
wr = new();
rd = new();
// Set different values on each
wr.addr = 32'h1000_0000;
wr.data = 8'hAB;
wr.write = 1; // WRITE transaction
rd.addr = 32'h2000_0000;
rd.data = 8'h00;
rd.write = 0; // READ transaction
// Modifying wr does not touch rd — they own separate memory
wr.addr = 32'hABCD_1234;
wr.display();
// [BusTx] addr=0xabcd1234 data=0xab WRITE
rd.display();
// [BusTx] addr=0x20000000 data=0x00 READ ← unchangedNow scale that up. A queue of 500 transactions, all independent, all randomised, all ready to drive in a loop — and your code is still six lines long.
BusTransaction q[$]; // queue of handles
repeat (500) begin
BusTransaction t = new(); // fresh object each iteration
void'(t.randomize());
q.push_back(t);
end
// Drive all 500 — each one has its own addr/data/write values
foreach (q[i])
q[i].display();Handle vs. Object — The Distinction That Matters
We touched on this in 9.1. Here it is in full detail, because getting this wrong is the source of some of the most confusing bugs in verification code.
Scenario 1 — Two handles, one object
BusTransaction a = new();
BusTransaction b;
a.addr = 32'hAAAA_0000;
b = a; // b now points to the SAME object as a (not a copy)
b.addr = 32'hBBBB_0000; // modifies the shared object
$display("a.addr = 0x%08h", a.addr);
// a.addr = 0xbbbb0000 ← a sees the change too!
// Both a and b point to the same memory location.
// This is called a SHALLOW COPY.If you want two independent copies, you need a deep copy — create a new object and copy each property manually. This is usually done with a copy() method. We cover that fully in Page 9.14 — Handles: Copy, Clone, Compare.
Scenario 2 — The null handle crash
BusTransaction txn; // handle declared — value is null
// ── WRONG: calling new() was forgotten ───────────────────────
txn.addr = 32'h1000;
// RUNTIME ERROR: null object dereference — simulation stops
// ── RIGHT: always call new() before use ──────────────────────
txn = new();
txn.addr = 32'h1000; // perfectly fine now
// ── Safe pattern: check for null before using ────────────────
if (txn == null)
$fatal(1, "txn was never created — call new() first");
else
txn.display();Object Lifetime and Garbage Collection
An object lives as long as at least one handle is pointing to it. The moment no handle references an object, the simulator's garbage collector can reclaim that memory. You don't need to manually free objects in SystemVerilog — there is no delete or free().
BusTransaction txn = new(); // Object A created
txn.addr = 32'h1000;
txn = new(); // Object B created — txn now points to B
// Object A has no handles left → eligible for GC
// (you can no longer reach Object A)
txn = null; // Object B also now has no handles → eligible for GC
// ── Keeping objects alive in a queue ────────────────────────
BusTransaction q[$];
begin
BusTransaction t = new();
t.addr = 32'hCAFE;
q.push_back(t);
end
// Even though local 't' goes out of scope here,
// the object is still alive because q[0] holds a reference to it.Full Working Example — APB Transaction
Here is everything from this page combined into one complete, runnable example. You can drop this directly into your simulator.
// ──────────────────────────────────────────────────────────────
// APB Transaction class
// ──────────────────────────────────────────────────────────────
class ApbTransaction;
// Properties
rand bit [31:0] addr;
rand bit [31:0] data;
rand bit write; // 1=write, 0=read
int txn_id;
// Constraint: keep address in valid APB range
constraint c_addr { addr inside {[32'h4000_0000 : 32'h4FFF_FFFF]}; }
// Constructor
function new(int id = 0);
txn_id = id;
endfunction
// Display method
function void display();
$display("[APB #%0d] %s addr=0x%08h data=0x%08h",
txn_id,
write ? "WR" : "RD",
addr, data);
endfunction
endclass
// ──────────────────────────────────────────────────────────────
// Testbench
// ──────────────────────────────────────────────────────────────
module tb;
ApbTransaction txn_q[$]; // queue to hold all transactions
initial begin
// Create 10 randomised transactions
for (int i = 0; i < 10; i++) begin
ApbTransaction t = new(i); // new object each iteration
if (!t.randomize())
$fatal(1, "Randomise failed on txn %0d", i);
txn_q.push_back(t);
end
// Print all 10
foreach (txn_q[i])
txn_q[i].display();
// Verify independence — modify txn_q[0] only
txn_q[0].addr = 32'hABCD_1234;
$display("\n--- After modifying txn_q[0].addr ---");
txn_q[0].display();
txn_q[1].display(); // still has its original addr
end
endmoduleQuick Reference
| Operation | Syntax | Notes |
|---|---|---|
| Declare a class | class MyClass; ... endclass | Blueprint only — no memory used |
| Declare a handle | MyClass h; | Handle is null until new() is called |
| Create an object | h = new(); | Allocates memory, runs constructor |
| Declare + create | MyClass h = new(); | One-liner — common shorthand |
| Access a property | h.property_name | Read or write with dot operator |
| Call a method | h.method_name(args) | Parentheses always required |
| Check for null | if (h == null) | Always check before first use in tasks |
| Release a handle | h = null; | Object GC'd if no other handles exist |
Verification Usage — Class Patterns in Real Testbenches
Class basics — declaration, new(), properties, methods — combine into the three foundational verification patterns: the transaction (a passive data record), the component (an active processor with its own loop), and the scoreboard entry (a queueable expected value). Master these three and you have the structural foundation for every UVM testbench that follows.
// ── Pattern 1: TRANSACTION — passive data record ──────────────
class axi_xact;
rand bit [31:0] addr;
rand bit [63:0] data;
rand bit [7:0] burst_len;
bit is_write;
constraint addr_aligned { addr[2:0] == 3'b000; }
constraint burst_legal { burst_len inside {1, 2, 4, 8, 16}; }
function string describe();
return $sformatf("AXI %s @0x%08h len=%0d data=0x%016h",
is_write ? "WR" : "RD", addr, burst_len, data);
endfunction
endclass
// ── Pattern 2: COMPONENT — active processor with its own loop ──
class axi_monitor;
virtual axi_if vif;
mailbox #(axi_xact) outbox;
int observed_count;
function new(virtual axi_if v, mailbox #(axi_xact) o);
vif = v;
outbox = o;
observed_count = 0;
endfunction
task run();
forever begin
@(posedge vif.clk);
if (vif.awvalid && vif.awready) begin
axi_xact tr = new; // fresh object per observation
tr.addr = vif.awaddr;
tr.is_write = 1;
tr.burst_len = vif.awlen;
// ... capture data on subsequent W beats
outbox.put(tr);
observed_count++;
end
end
endtask
endclass
// ── Pattern 3: SCOREBOARD ENTRY — queueable expected-value record ──
class axi_scoreboard;
mailbox #(axi_xact) expected_q;
mailbox #(axi_xact) observed_q;
int match_count, mismatch_count;
function new();
expected_q = new(0); // unbounded
observed_q = new(0);
match_count = 0;
mismatch_count = 0;
endfunction
task check_loop();
forever begin
axi_xact exp_tr, obs_tr;
expected_q.get(exp_tr);
observed_q.get(obs_tr);
if (exp_tr.addr === obs_tr.addr &&
exp_tr.data === obs_tr.data) begin
match_count++;
$display("[%0t] SCB MATCH: %s", $time, exp_tr.describe());
end else begin
mismatch_count++;
$display("[%0t] SCB MISMATCH: exp=%s obs=%s",
$time, exp_tr.describe(), obs_tr.describe());
end
end
endtask
endclassSimulation Behavior — How the Simulator Tracks Objects
Behind every new() and every handle assignment, the simulator maintains a reference count and a pool of live objects. Understanding what happens at the engine level helps you debug memory growth, identify reference cycles, and reason about object lifetime in long-running regressions.
class tracked_obj;
static int live_count;
int id;
function new(int i);
id = i;
live_count++;
$display("[%0t] obj#%0d created (live=%0d)", $time, id, live_count);
endfunction
function void manual_release();
live_count--;
$display("[%0t] obj#%0d manually released (live=%0d)",
$time, id, live_count);
endfunction
endclass
initial begin
tracked_obj a, b, c;
a = new(1); // [0] obj#1 created (live=1)
b = new(2); // [0] obj#2 created (live=2)
c = a; // no allocation; c and a both point to obj#1
// live_count unchanged — same single object, two handles
a = null; // a drops reference; obj#1 still kept alive by c
a = new(3); // [0] obj#3 created (live=3)
// Now live: obj#1 (via c), obj#2 (via b), obj#3 (via a)
b = null; // obj#2 has no live handles → eligible for GC
// live_count says 3 because no manual_release was called
#10ns;
$display("At t=10ns: live_count=%0d (heap may have GC'd obj#2)", live_count);
endWaveform Analysis — Tracking Class Activity Without Class Visibility
Waveform viewers can't display class objects directly. But the activity those objects generate — bus transactions, scoreboard checks, counter updates — does appear in the waveform if you mirror class events into module-level signals. This is the bridge between dynamic class behaviour and the static-signal world that Verdi understands.
module tb;
// Static signals — visible in Verdi/DVE waveform
logic [31:0] last_xact_addr;
logic [63:0] last_xact_data;
int xacts_sent;
int scb_matches;
int scb_mismatches;
axi_driver drv;
axi_scoreboard scb;
initial begin
drv = new();
scb = new();
// Every transaction sent updates the mirror signals
forever begin
axi_xact tr = new();
assert (tr.randomize());
last_xact_addr = tr.addr; // → waveform
last_xact_data = tr.data;
xacts_sent++;
drv.send(tr);
end
end
// Periodic scoreboard status snapshot
always @(posedge clk) begin
scb_matches = scb.match_count;
scb_mismatches = scb.mismatch_count;
end
endmodule
// In Verdi, browse:
// tb.last_xact_addr — most recent transaction's address
// tb.xacts_sent — running count of sends
// tb.scb_mismatches — alarm when this changesIndustry Insights — How Senior Teams Use Classes
Debugging Academy — 5 Real Class-Based Testbench Bugs
Each lab is a real failure mode from production projects. Buggy code, symptom, root cause, fix.
// Generator keeps a "current" handle and modifies it per transaction
class generator;
packet current;
task run();
current = new;
forever begin
assert (current.randomize());
outbox.put(current); // queues the SAME handle every loop
@(posedge clk);
end
endtask
endclassclass statistics;
int total_count;
function void bump();
total_count++; // ← reads/writes through this handle
endfunction
endclass
module tb;
statistics stats; // ← declared but never new()'d
initial stats.bump(); // crash or silent garbage
endmoduleclass queue_manager;
mailbox #(packet) mbox;
function new();
// Forgot to allocate mbox
endfunction
task push(packet p);
mbox.put(p); // crash: mbox is null
endtask
endclass
module tb;
queue_manager qm = new;
initial qm.push(new()); // crash
endmoduleclass counter;
static int count; // ← static — shared across ALL instances
function void increment();
count++;
endfunction
endclass
initial begin
counter c1 = new;
counter c2 = new;
c1.increment();
c1.increment();
$display("c2.count=%0d", c2.count); // prints 2, not 0
end// Producer fires a million transactions; consumer drains slowly
initial begin
repeat (1_000_000) begin
packet tr = new;
assert (tr.randomize());
outbox.put(tr); // queues every transaction
end
end
// Consumer can only process 1000/second
initial begin
forever begin
packet rx;
outbox.get(rx);
slow_process(rx);
end
endInterview Q&A — 12 Questions on Classes & Objects
Drawn from real interviews at chip-design and verification companies. Try to answer before reading each response.
A class declaration is a type definition — it describes what properties and methods objects of this class will have, but creates nothing at runtime. An object is a runtime instance — created by new(), lives in the heap, has its own copy of every non-static property. One class declaration can serve as the blueprint for zero, one, or millions of objects over a simulation. Class is to object as module is to module instance — but objects exist dynamically rather than statically.
Best Practices — Class & Object Rules to Walk Away With
- One class per file, file name matches class name. Production VIP convention; makes grep, blame, and dependency tracking work.
- Always declare classes inside a package, never in a module. Module-internal classes are not reusable.
- Pair handle declaration with construction.
my_class h = new();at declaration, orh = new();immediately after. - Initialise every dynamic property in the constructor. Mailboxes, queues, dynamic arrays, associative arrays, and nested class handles all start null — allocate them in
new(). - Allocate a fresh object every time you queue a transaction. Never put-and-mutate; always allocate-and-put.
- Use
staticdeliberately. Default to instance properties; promote to static only when the shared-across-instances semantic is intentional and documented. - Provide a
describe()method on every transaction and component class. Logs, scoreboard messages, debug prints all use it — never inline-format from raw property access. - Use bounded mailboxes for producer-consumer queues. Memory growth is bounded; producer naturally throttles to consumer rate.
- Mirror class state into module signals for waveform visibility. Verdi can't browse class memory; mirror signals can be alarmed and time-aligned.
- Master plain SV classes before reaching for UVM. Transaction + driver + monitor + scoreboard in plain SV is the foundation; UVM is "the foundation plus a factory, plus phasing, plus config_db."