SystemVerilog Basics

Chapter 10 of 16

Introduction to OOP: Classes and Objects in SystemVerilog

Map what you already know about classes and objects onto SystemVerilog's specific syntax, focusing on one key point that's completely different from struct: an object is a handle (reference), not a value; then learn to write a copy() method, restrict access with local/protected, share data across objects with static members, and parameterize a class with #(type T).

Chapter 1 said SystemVerilog is Verilog plus "a whole verification-oriented language layer." This chapter formally steps into that half — classes. If you've written Python, Java, C++, or any object-oriented language before, classes, objects, and methods aren't new to you; this chapter won't re-teach "what is object orientation" from scratch. Instead it maps straight onto SystemVerilog's actual syntax, and focuses on one thing beginners reliably trip over: a class object is a handle, not a value like a struct.

Where this chapter's pieces end up being used

This chapter through chapter 12 (classes, inheritance and polymorphism, randomization) is entirely software — no hardware signals in sight, which can make it feel like you've wandered into a different, non-hardware-description language. A quick look at a classic testbench's shape helps place these pieces where they'll eventually live:

GeneratorDriverDUTMonitorScoreboard(class)(class)(hardware)(class)(class)Chapters 10-12 build the software pieces in these boxes

Every box except the DUT will end up being a class object (or a small group of cooperating ones): the generator produces stimulus, the driver actually applies it to the DUT's signals, the monitor observes the DUT's outputs, and the scoreboard judges whether those outputs are correct. This chapter's class syntax, next chapter's inheritance and polymorphism, and the chapter after that's constrained randomization are all groundwork for building these boxes — the full picture and a working example don't show up until this track's last chapter, but knowing ahead of time what these pieces eventually become should make the next few chapters feel less adrift.

Why verification code needs classes: one step beyond struct

Chapter 5's struct can bundle related data fields together, but a struct only holds data — it can't hold behavior (methods). What verification code needs is often a "transaction" — say, a bus transfer — that isn't just a handful of fields, but also needs behavior: printing itself, randomizing itself, comparing itself against another transaction. A class binds data (properties) and behavior (methods) together, which is exactly why verification code leans so heavily on object orientation.

Declaring a class: properties and methods

class transaction;
  int addr;
  int data;
 
  function void print();
    $display("addr=%0h data=%0h", addr, data);
  endfunction
endclass

addr and data are this class's properties; print() is its method — syntactically almost identical to declaring variables and functions from earlier chapters, just written inside class...endclass.

An object is a "handle" — unlike struct

This is the most important point in this chapter. Declaring a variable of a class type does not automatically create an object — it only declares a "handle," initialized to null, with no actual object existing yet:

transaction tr;   // just declares a handle — tr is null, no object yet
tr = new();       // calls the constructor, actually creates an object; tr points to it
 
tr.addr = 32'h1000;
tr.data = 32'hDEAD_BEEF;
tr.print();

You must explicitly call new() to actually allocate an object before the handle tr points to anything. This is completely different from chapter 5's struct — declaring a struct variable immediately gives it storage you can assign to directly, while declaring a class variable just gives you a handle that doesn't point to anything yet.

Even more important is the assignment semantics: assigning one handle to another doesn't copy the object — it makes both handles point to the same object:

transaction tr1, tr2;
tr1 = new();
tr1.addr = 100;
 
tr2 = tr1;         // tr2 and tr1 now point to the same object — not a copy!
tr2.addr = 200;
 
$display("tr1.addr = %0d", tr1.addr);  // 200 — tr2's change carried over

After tr2 = tr1;, tr1 and tr2 are two names for the same object — changing a property through tr2 means tr1 sees the change too. If you need an independent copy of the object, you have to copy the fields explicitly (or write your own copy() method) — plain assignment just makes two handles point at the same data. We'll come back to this when discussing inheritance in the next chapter.

Getting a real copy: writing your own copy() method

Since assignment doesn't copy the object, getting "an independent copy" means explicitly copying every field, usually wrapped in a copy() method that returns a new object:

class transaction;
  int addr;
  int data;
 
  function transaction copy();
    transaction t = new();
    t.addr = this.addr;
    t.data = this.data;
    return t;
  endfunction
endclass
 
transaction tr1 = new();
tr1.addr = 100;
 
transaction tr2 = tr1.copy();  // tr2 is an independent new object with the same field values
tr2.addr = 200;
 
$display("tr1.addr = %0d", tr1.addr);  // 100 -- unaffected by tr2's change

copy() creates a brand-new object with new(), copies each field's value across, then returns the new object — tr1 and tr2 end up as two fully independent objects that don't affect each other. Hand-writing copy() is straightforward when there aren't many fields; UVM's uvm_object::copy()/clone() are the standardized version of exactly this idea.

The constructor: new()

If a class doesn't explicitly define new(), SystemVerilog provides a default constructor that initializes every property to its type's default value (recall chapter 5: 4-state types default to x, 2-state types default to 0). You can also define your own new() to initialize the object right as it's created:

class transaction;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
endclass
 
initial begin
  transaction tr = new(32'h2000, 32'hCAFE_BABE);
  tr.print();
end

Notice the this keyword in this.addr = addr; — when the constructor's argument name (addr) matches the property name (addr), writing plain addr inside the function body refers to the argument; you need this.addr to explicitly mean "the current object's addr property." this means "the current object," and can be used inside any method.

local and protected: restricting what can access a property or method

So far, transaction/packet's properties could be read and written directly from outside the class (like tr.addr = 100;). Real verification code often wants some internal state to be off-limits to outside code — access modifiers restrict visibility:

class packet;
  local     int checksum;  // only packet's own methods can access this
  protected int addr;      // packet itself, and any subclass, can access this
 
  function new(int addr);
    this.addr     = addr;
    this.checksum = addr ^ 32'hFFFF_FFFF;  // derived internally
  endfunction
endclass
  • local: only this class's own methods can access it — not even a subclass can. Writing p.checksum from outside code is a straight compile error.
  • protected: this class itself and any subclass can access it, but code outside the class hierarchy cannot — the next chapter on inheritance uses exactly this distinction.
  • No modifier at all (like addr/data in earlier examples) defaults to public: accessible from inside the class, from subclasses, and from outside code.

Same idea as in most object-oriented languages — this is encapsulation in practice: mark state you don't want touched directly as local/protected, and expose only what needs exposing through methods, reducing the risk of outside code accidentally breaking internal consistency.

static members: data shared across every object

A static property doesn't belong to any one object — every object of the class shares the same single storage location. A common use is auto-assigning each new object an incrementing ID:

class packet;
  static int next_id = 0;   // belongs to the class itself, shared by every packet object
  int id;
 
  function new();
    id = next_id;
    next_id++;
  endfunction
endclass
 
initial begin
  packet p0 = new();
  packet p1 = new();
  packet p2 = new();
  $display("p0.id=%0d p1.id=%0d p2.id=%0d", p0.id, p1.id, p2.id);  // 0 1 2
end

next_id has exactly one storage location, incremented once per new() — a natural fit for "assign each transaction a unique sequence number." static methods (a function/task marked static) can also be called without any object existing, typically to operate on static properties.

Classes are verification-only: never synthesized

This connects back to chapter 1's core mental model: class is a purely verification-oriented language feature — it cannot be synthesized, exists only in testbench code, and never becomes any hardware circuit. That's exactly why everything from this chapter onward (object orientation, randomization, assertions, and more) belongs to the "verification half" of the language that chapter 1 promised.

Upgrading an earlier struct into a class

Recall the pkt_t struct from the end of chapter 6 — it only had data, no behavior. Here it is rewritten as a class, with a print() method added:

class packet;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
 
  function void print();
    $display("packet: addr=%0h data=%0h", addr, data);
  endfunction
endclass
 
initial begin
  packet p = new(32'h3000, 32'hFEED_FACE);
  p.print();  // packet: addr=3000 data=feedface
end

Still "bundling related fields together," but packet the class adds behavior on top of what pkt_t the struct offered — along with two semantic differences worth remembering: you need new() to create an object, and assignment shares a handle instead of copying data.

Parameterizing a class: #(type T)

Chapter 2 covered using parameter/#(...) to let a module be configured with different widths/depths at instantiation; a class can be parameterized the same way, except the parameter is usually a type, meaning "what kind of data can this class hold":

class fifo_model #(type T = int);
  T queue[$];
 
  function void push(T item);
    queue.push_back(item);
  endfunction
endclass
 
fifo_model #(int)    int_fifo    = new();
fifo_model #(string) string_fifo = new();

#(type T = int) declares a type parameter T, defaulting to int; a fifo_model #(int) object's queue/push() work on int data, while fifo_model #(string) works on string. This is exactly what mailbox #(int) means in later chapters — mailbox is itself a built-in parameterized class in SystemVerilog, and #(int) sets its type parameter to int, meaning that particular mailbox can only carry int data.

Summary

  • A class binds data (properties) and behavior (methods) together — one step beyond struct.
  • Declaring a class variable only declares a handle, initialized to null; you must call new() to actually create an object.
  • Handle assignment doesn't copy the object — both handles end up pointing to the same object, so modifying a property through either one is visible through the other. For an independent copy, write an explicit copy() method that copies each field.
  • Without an explicit new(), SystemVerilog provides a default constructor that initializes properties to their type's default value; you can also define your own new() to initialize at creation time.
  • this refers to the current object, commonly used when a constructor argument name matches a property name.
  • local restricts a member to the class itself; protected additionally allows subclasses; no modifier defaults to public.
  • static properties/methods belong to the class itself, shared across every object — commonly used for cross-object counting (like auto-assigned IDs).
  • class is a purely verification-oriented feature and can't be synthesized.
  • A class can also be parameterized with #(type T), meaning "what type of data this class holds" — mailbox #(int) is an instance of exactly this mechanism.

After executing transaction tr; (without calling new() yet), what state is tr in?

After tr2 = tr1; (tr1 and tr2 are handles of the same class, and tr1 already points to an object), what happens if you then modify a property through tr2?

Which keyword, used inside a method, refers to 'the current object' — commonly used to disambiguate an argument from a property of the same name? (lowercase)

How do you get a new object with the same field values as an existing one, but fully independent of it?

Which statement about the local and protected access modifiers is correct?

A class has static int next_id = 0;, and every new() runs id = next_id++;. What does this static property actually do?

In fifo_model #(int) int_fifo = new();, #(int) sets which kind of parameter declared in the class to int? (one word, lowercase)