SystemVerilog · Module 9
Encapsulation — public, protected, local
Access modifiers, the encapsulation contract, getter/setter patterns, and why local is strictly stronger than protected.
Module 9 · Page 9.5
What Encapsulation Actually Means
Imagine you are handing a transaction object to a junior engineer on your team. You wrote the class. He is writing the test. He sees 15 properties. Without any guidance, he sets pkt.internal_crc = 0 directly, bypassing the calculation logic you wrote. The test passes. Coverage looks fine. Then silicon fails at the customer's lab because CRC was never being checked.
That is the problem encapsulation prevents. It is not about hiding things for the sake of hiding them. It is about defining a contract: here is what you are allowed to touch, here is what you are not, and here is the only correct path through which data changes.
SystemVerilog gives you three access qualifiers to express that contract. If you do not write one, public is assumed.
The Three Access Levels
publicSame class✓Child class✓Outside (testbench)✓ Default — no keyword needed. Anyone can read or write. protectedSame class✓Child class✓Outside (testbench)✗ Visible to the family — this class and anything that extends it. localSame class✓Child class✗Outside (testbench)✗ Completely private — only this exact class can touch it. The syntax is simple — just put the keyword before the data type on the property or method declaration. One keyword, applied once.
class MyClass;
// public (default — no keyword needed)
bit [31:0] addr;
// public — written explicitly (same effect)
public bit [7:0] data;
// protected — accessible to this class and child classes only
protected int seq_num;
// local — accessible ONLY inside this class
local bit [31:0] internal_crc;
// methods follow the same rules
local function void compute_crc(); // private method
internal_crc = addr ^ data;
endfunction
protected function void init_seq(); // child classes can call this
seq_num = 0;
endfunction
endclasspublic — The Default
Everything is public unless you say otherwise. For a simple transaction class with a few data fields that your testbench directly sets and reads, public is fine and you do not need to write anything extra.
class MemTransaction;
rand bit [31:0] addr; // public by default
rand bit [31:0] data; // public by default
bit write; // public by default
endclass
module tb;
MemTransaction t = new();
initial begin
// All fine — everything is public
t.addr = 32'h5000_0000;
t.data = 32'h1234_5678;
t.write = 1;
$display("addr=0x%08h", t.addr);
end
endmodulelocal — Completely Private
local is the strongest restriction. Only the methods inside the exact class where the property is declared can access it. Child classes cannot. The testbench cannot. Not even friend classes (SystemVerilog does not have friend anyway).
Use local for implementation details that no one outside the class should ever depend on — internal counters, cached computed values, temporary state that is only meaningful during a method call.
class EccPacket;
// Public — testbench works with these directly
rand bit [63:0] payload;
int pkt_id;
// local — internal implementation detail
// no one outside should ever touch the raw ECC bits directly
local bit [7:0] ecc_bits;
local int encode_count;
function new(int id = 0);
pkt_id = id;
ecc_bits = 8'h00;
encode_count = 0;
endfunction
// local method — called only by this class internally
local function void compute_ecc();
ecc_bits = payload[7:0] ^
payload[15:8] ^
payload[23:16] ^
payload[31:24]; // simplified
encode_count++;
endfunction
// Public API — this is the only correct way to finalise the packet
function void finalise();
compute_ecc(); // calls the local method — fine from inside
endfunction
function void display();
$display("[ECC #%0d] payload=0x%016h ecc=0x%02h encodes=%0d",
pkt_id, payload, ecc_bits, encode_count);
endfunction
endclass
module tb;
EccPacket p = new(1);
initial begin
void'(p.randomize());
p.finalise(); // correct path — triggers ECC computation
p.display();
// p.ecc_bits = 8'hFF; ← COMPILE ERROR: local
// p.compute_ecc(); ← COMPILE ERROR: local method
end
endmoduleThe testbench can only call finalise(). It cannot touch ecc_bits or compute_ecc() directly. This means ECC is always computed through the correct logic — it cannot be silently skipped or overwritten.
protected — For the Class Family
protected sits between public and local. The outside world cannot touch it, but child classes can. This is how you share internal state between a base class and its subclasses without exposing it everywhere.
Think of it as writing a base transaction class that all your protocol-specific transactions extend. They all need access to a common sequence counter. You do not want the testbench touching it directly, but every child class needs to read and increment it.
// ── Base class ────────────────────────────────────────────────
class BaseTxn;
// Public — testbench can read the ID
int txn_id;
// protected — shared with child classes but not with testbench
protected int retry_count;
protected bit is_error_injected;
function new(int id = 0);
txn_id = id;
retry_count = 0;
is_error_injected = 0;
endfunction
function void display();
$display("[BaseTxn #%0d] retries=%0d err=%0b",
txn_id, retry_count, is_error_injected);
endfunction
endclass
// ── Child class — can access protected members ────────────────
class ErrorTxn extends BaseTxn;
rand bit [1:0] error_type; // 0=none 1=crc 2=timeout 3=parity
function new(int id = 0);
super.new(id);
endfunction
function void inject_error();
// protected members are fully accessible inside child class
is_error_injected = 1; // ← OK — protected
retry_count = 3; // ← OK — protected
endfunction
function void display();
super.display();
$display(" error_type=%0d", error_type);
endfunction
endclass
module tb;
ErrorTxn e = new(10);
initial begin
e.inject_error(); // fine — public method
e.display();
// e.retry_count = 5; ← COMPILE ERROR: protected
// e.is_error_injected; ← COMPILE ERROR: protected
end
endmoduleGetter and Setter Pattern
Once a property is local or protected, the outside world cannot touch it directly. But sometimes you still want to allow controlled read or write access. The answer is getter and setter methods — public functions that provide a single, validated path to the data.
This is where encapsulation earns its keep. The setter can validate the incoming value, log the change, or trigger a side effect. The getter can return a computed view of the data. Neither is possible when the property is simply public.
class RegTransaction;
// local — no direct access from outside
local bit [31:0] addr;
local bit [31:0] data;
local int write_count;
function new();
addr = 32'h0;
data = 32'h0;
write_count = 0;
endfunction
// ── Setter with validation ────────────────────────────────
function void set_addr(bit [31:0] a);
// reject unaligned addresses immediately
if (a[1:0] !== 2'b00)
$fatal(1, "[RegTxn] Unaligned address 0x%08h — must be word-aligned", a);
addr = a;
write_count++;
$display("[RegTxn] addr set to 0x%08h (write #%0d)", addr, write_count);
endfunction
// ── Getter ────────────────────────────────────────────────
function bit [31:0] get_addr();
return addr;
endfunction
// ── Setter with side-effect ───────────────────────────────
function void set_data(bit [31:0] d);
data = d;
endfunction
// ── Read-only computed getter ─────────────────────────────
function int get_write_count();
return write_count; // count is local — nobody can reset it externally
endfunction
function void display();
$display("addr=0x%08h data=0x%08h writes=%0d",
addr, data, write_count);
endfunction
endclass
module tb;
RegTransaction r = new();
initial begin
r.set_addr(32'h4000_0004); // validated — passes
r.set_data(32'hA0B1_C2D3);
r.display();
$display("write_count = %0d", r.get_write_count());
r.set_addr(32'h4000_0003);
// ^^^ FATAL: unaligned address — caught immediately at the setter
end
endmoduleNotice what the setter bought us: the unaligned address is caught the moment it is set — not three clock cycles later when the DUT sends back an error response that might look like something else entirely. That is the difference between a two-minute debug and a two-hour one.
Full Example — All Three Levels in One Class
Here is a realistic SoC register model transaction that uses all three access levels with a clear rationale for each choice.
class SocRegTxn;
// ── public: testbench fills these in directly ─────────────
rand bit [31:0] addr;
rand bit [31:0] wdata;
rand bit wr_en; // 1=write 0=read
int txn_id;
// ── protected: sub-classes (e.g. AhbRegTxn) need these ───
protected bit [3:0] byte_en; // protocol-specific byte enables
protected int burst_len; // sub-class may override for bursts
// ── local: pure implementation — nobody else's business ───
local bit [31:0] shadow_rdata; // last read-back value (cache)
local int error_code; // internal error state
constraint c_align { addr[1:0] == 2'b00; }
function new(int id = 0);
txn_id = id;
byte_en = 4'hF; // full word by default
burst_len = 1;
shadow_rdata = 32'h0;
error_code = 0;
endfunction
// ── local method — internal use only ─────────────────────
local function void cache_rdata(bit [31:0] rdata);
shadow_rdata = rdata;
endfunction
// ── public API ────────────────────────────────────────────
function void set_read_response(bit [31:0] rdata, int err = 0);
cache_rdata(rdata); // local method OK from inside
error_code = err;
endfunction
function bit has_error();
return (error_code != 0);
endfunction
function void display();
$display("[SocReg #%0d] %s addr=0x%08h data=0x%08h err=%0d",
txn_id,
wr_en ? "WR" : "RD",
addr, wr_en ? wdata : shadow_rdata,
error_code);
endfunction
endclass
module tb;
SocRegTxn t = new(1);
initial begin
void'(t.randomize());
// Public — direct access, fine
t.addr = 32'h5000_0008;
t.wr_en = 1;
t.wdata = 32'hF0F0_A1B2;
// Simulate DUT responding after a read
t.wr_en = 0;
t.set_read_response(32'h0000_00FF); // public method
t.display();
$display("Has error: %0b", t.has_error());
// t.shadow_rdata = 0; ← COMPILE ERROR: local
// t.byte_en = 4'h3; ← COMPILE ERROR: protected
end
endmoduleQuick Reference
| Qualifier | Same class | Child class | Outside (tb) | Use for |
|---|---|---|---|---|
public (default) | ✅ | ✅ | ✅ | Data the testbench needs to set or read directly |
protected | ✅ | ✅ | ❌ | Internal state that subclasses legitimately need |
local | ✅ | ❌ | ❌ | Pure implementation detail — computed fields, caches, counters |
| Pattern | When to use |
|---|---|
| Plain public property | Simple transaction fields the testbench sets and reads freely |
| local + setter function | Property that needs validation or logging on every write |
| local + getter function | Read-only property — outside world can read but never write |
| protected property | Internal counter or flag shared across a class hierarchy |
| local method | Helper function used only inside this class — hide it completely |
Verification Usage — Encapsulation in a Real VIP Stack
A protocol agent in production verification IP is a textbook case for encapsulation. The agent's public surface is small: a handful of methods callers invoke to send transactions, query statistics, change runtime configuration. Everything else — the mailbox holding the pending queue, the per-transaction counters, the bus-state machine, the internal coverage collector — is locked behind local. Subclasses get protected hooks to customise per-protocol behaviour. The discipline pays off across releases: every consumer integrates the agent the same way; internal refactors are invisible.
class axi_agent;
// ── Public API — every caller uses these and nothing else ─────
function new(virtual axi_if v);
vif = v;
inbox = new(0);
sent_ok = 0;
sent_fail = 0;
endfunction
task send(axi_xact tr);
inbox.put(tr);
endtask
function int get_success_count();
return sent_ok;
endfunction
function int get_failure_count();
return sent_fail;
endfunction
task start();
fork run_loop(); join_none
endtask
// ── Protected hooks — subclasses can override behaviour ─────
protected virtual task drive_one(axi_xact tr);
@(posedge vif.clk);
vif.awaddr <= tr.addr;
vif.awvalid <= 1;
while (!vif.awready) @(posedge vif.clk);
vif.awvalid <= 0;
endtask
protected virtual function bit is_legal_xact(axi_xact tr);
return (tr.addr[1:0] == 2'b00); // align check
endfunction
// ── Local internals — locked, refactor-safe ─────────────────
local virtual axi_if vif;
local mailbox #(axi_xact) inbox;
local int sent_ok;
local int sent_fail;
local task run_loop();
forever begin
axi_xact tr;
inbox.get(tr);
if (is_legal_xact(tr)) begin
drive_one(tr);
sent_ok++;
end else begin
sent_fail++;
end
end
endtask
endclass
// Subclass for a specialised AXI variant (e.g., AXI4-Lite without bursts)
class axi4_lite_agent extends axi_agent;
function new(virtual axi_if v); super.new(v); endfunction
// Override the protected hook to add a per-variant constraint
protected virtual function bit is_legal_xact(axi_xact tr);
return super.is_legal_xact(tr) && (tr.burst_len == 1);
endfunction
endclassSimulation Behavior — How Access Checks Run at Compile Time
Access modifiers (local, protected, public) are compile-time checks. They are enforced by the compiler when it parses references to class members; once compilation succeeds, the runtime has no concept of "this member is private" — every reference has been verified at compile time. This means access modifiers cost zero simulation cycles and zero memory; they exist purely to constrain what code the compiler will accept.
class vault;
local int secret;
protected int family;
int open; // public by default
function void internal_use();
secret = 1; // ✓ access to own local — fine
family = 2; // ✓ access to own protected — fine
open = 3; // ✓ access to own public — fine
endfunction
endclass
class child extends vault;
function void subclass_use();
secret = 1; // ✗ COMPILE ERROR: local not visible to subclass
family = 2; // ✓ protected visible to subclass
open = 3; // ✓ public visible to subclass
endfunction
endclass
initial begin
vault v = new;
v.secret = 1; // ✗ COMPILE ERROR: local not visible externally
v.family = 2; // ✗ COMPILE ERROR: protected not visible externally
v.open = 3; // ✓ public — fine
endWaveform Analysis — Encapsulation Doesn't Affect the Waveform
Encapsulation is a source-code concept; it has no waveform footprint. Whether a property is local or public doesn't change how the simulator represents it in heap memory, doesn't change whether you can mirror it to a module signal, doesn't change what waveform tools can display. The whole point of access modifiers is to constrain what code you can write; once written, the runtime behaviour is unchanged.
class agent;
local int internal_count; // local — invisible to external code
function int get_count(); // public accessor
return internal_count;
endfunction
task tick();
internal_count++;
endtask
endclass
module tb;
int tb_agent_count; // module signal — visible in Verdi
agent ag = new;
always @(posedge clk)
tb_agent_count = ag.get_count(); // reads through the public accessor
endmoduleIndustry Insights — Encapsulation Practices in Production VIP
Debugging Academy — 5 Real Encapsulation Bugs
Each lab is a real failure mode from production projects. Buggy code, symptom, root cause, fix.
class arbiter;
int grant_count; // ← implicitly public (no access modifier)
function void record_grant();
grant_count++;
endfunction
endclass
// Some test code in a corner of the codebase:
initial begin
arb.grant_count = 999; // ← writes the counter directly
end
// Scoreboard expects monotonic increasing grant_count for its rate calculation
// and reports impossible rates after this write.class base;
local int internal_id;
protected int family_data;
endclass
class child extends base;
// Subclass tries to expose parent's protected via a public method:
function int leak_family();
return family_data; // ← legal access from subclass
endfunction
// And tries to expose parent's local (compile error — but author tries anyway):
function int try_leak_internal();
return internal_id; // ✗ compile error — local not visible to subclass
endfunction
endclass
// External code now has full access to family_data through child:
child c = new;
int x = c.leak_family(); // bypasses base's protectionclass history_log;
local string entries [$]; // local queue
function void record(string msg);
entries.push_back(msg);
endfunction
// "Public read access" — returns the queue handle directly
function string get_entries [$] ();
return entries; // ← returns reference, not copy
endfunction
endclass
// External code now has a mutable reference to the internal queue:
history_log hl = new;
string list [$] = hl.get_entries();
list.push_back("INJECTED"); // mutates history_log's internal state
list.delete(); // wipes the history// scoreboard.sv:
class scoreboard;
bit [31:0] match_threshold = 1000; // public default — used by environment
endclass
// reset_test.sv (different team, different file):
initial begin
scb.match_threshold = 50; // ← reset for faster testing
end
// long_run_test.sv (yet another team):
initial begin
scb.match_threshold = 10000; // ← long test wants higher bar
end
// When tests run in the same regression, the second-running test wins.
// First test's expectations are silently overwritten.class base_driver;
local task drive_one(packet p); // ← local: subclasses can't override
@(posedge clk);
bus_signal <= p.data;
endtask
endclass
class burst_driver extends base_driver;
// Wants to override drive_one to send a burst — can't!
// function void drive_burst(packet p);
// drive_one(p); // ✗ compile error — drive_one is local
// ...
// endfunction
endclassInterview Q&A — 12 Questions on Encapsulation
Drawn from real interviews at chip-design and verification companies. Try to answer before reading each response.
local: visible only inside the declaring class — even subclasses can't access it. protected: visible inside the class and any subclass; external code can't access it. public: visible everywhere — the default when no modifier is specified. Use local for implementation details, protected for subclass hooks, public for the documented external API.
Best Practices — Encapsulation Rules to Walk Away With
- Default every member to
local. Promote with intent; document the reason in a comment. - Always specify the access modifier explicitly. Never rely on the public-by-default behaviour; "no modifier" is a code-review red flag.
- Use
protectedonly for subclass-extension hooks. Pair withvirtualfor methods that subclasses should override. - Expose
localproperties through getters and setters. Zero runtime cost; full access control. - Never return handles to internal collections. Return a copy or a specific query result; protect the live container.
- Document the public API in the class header comment. Every public method gets a one-line description; the comment is the integration spec.
- Setters validate; getters format. The accessor is the place to add bounds checks, normalisation, or computed views.
- Subclass exposure of parent's protected fields requires base-owner review. The leak is a contract extension; treat it like an API change.
- Tests interact only through the public API. If a test needs to inspect internals, add a public observation method — never reach into
localstate. - Lock down configuration via
configure()methods that fatal on re-configuration. Prevents test order from accidentally mutating shared state.