SystemVerilog · Module 6
Pass by Value vs. Pass by Reference
Copy vs alias semantics, `ref` and `const ref`, inout-vs-ref timing, large-array performance, and when each is required.
Module 6 · Page 6.5
When you pass a variable to a task or function, does it get its own copy or does the callee share the original? The answer determines whether the caller sees changes, whether arrays are duplicated in memory, and whether your testbench behaves correctly under concurrent execution. This page walks through every direction (input / output / inout / ref / const ref), the simulator's copy-in/copy-out timing, performance, five real debug labs, and seven interview-ready questions.
The Core Concept — Copy vs Alias
Every argument you pass to a task or function travels in one of two ways. In pass by value, a copy of the data is made — the callee works on the copy, leaving the original unchanged (for inputs), or the result is copied back when the call ends (for outputs). In pass by reference, no copy is made — both caller and callee share the same variable in memory. Any change the callee makes is immediately visible to the caller.
Pass by Value — input / output / inout | Pass by Reference — ref |
|---|---|
| A copy of the data is made at the point of call or return | No copy — callee gets a direct alias to the caller's variable |
input: copy-in at call, caller's variable unchanged | Caller sees changes immediately, not just at return |
output: copy-out at return, undefined inside until written | Cannot pass constants or expressions — must be a variable |
inout: copy-in AND copy-out | Required for large arrays (avoid copying megabytes of data) |
| Caller does NOT see changes until the task returns | Task/function must be automatic |
| Works with constants and expressions as arguments | const ref: alias, but callee cannot write |
| Safe: callee cannot accidentally corrupt caller's data (for input) |
All Four Argument Directions — Compared
| Direction | Mechanism | Copy made? | Callee can write? | Caller sees change when? | Accepts expression? |
|---|---|---|---|---|---|
input | Pass by value (in) | Yes — at call | Yes (private copy only) | Never — input is isolated | Yes — fn(a+b) |
output | Pass by value (out) | Yes — at return | Yes — must write | After task returns | No — must be a variable |
inout | Pass by value (both) | Yes — at call and return | Yes — read and write | After task returns | No — must be a variable |
ref | Pass by reference | No — direct alias | Yes — read and write | Immediately — any write is live | No — must be a variable |
const ref | Pass by reference | No — direct alias | No — read only | Immediately (reads only) | No — must be a variable |
ref — Pass by Reference in Practice
The ref keyword is placed before the type in the argument list, just like input or output. The task or function must be automatic when using ref — a static task cannot safely alias a caller's variable.
// ── ref: caller sees change IMMEDIATELY, not just at return ──────
task automatic increment_live (ref int counter, input int amount);
counter += amount; // ← modifies caller's variable directly
endtask
int my_count = 10;
increment_live(my_count, 5);
$display("my_count = %0d", my_count); // prints: my_count = 15
// ── inout: caller only sees change AFTER task returns ─────────────
task automatic increment_copy (inout int counter, input int amount);
counter += amount; // modifies the LOCAL COPY
// copy-out happens when endtask is reached
endtask
// Behaviour LOOKS identical for simple sequential calls...
int cnt = 10;
increment_copy(cnt, 5);
$display("cnt = %0d", cnt); // prints: cnt = 15 — same result
// ...but NOT when a concurrent task reads while we're inside
task automatic slow_increment_inout (inout int x);
x = x + 1;
@(posedge clk); // suspend here
x = x + 1; // caller's x still shows OLD value during the wait
endtask
task automatic slow_increment_ref (ref int x);
x = x + 1;
@(posedge clk); // suspend here
x = x + 1; // caller's x shows UPDATED value immediately
endtaskinout vs ref — The Timing Difference
For simple, sequential calls with no timing controls inside, inout and ref produce identical results. The difference only becomes visible when a task suspends mid-execution and another process reads the variable while the task is waiting.
ref for Large Arrays — Avoid Expensive Copies
The most important practical use of ref is with large arrays. When you pass an array as input or inout, SystemVerilog copies the entire array into the task's local storage. For a 64 KB packet buffer or a 1 M-entry scoreboard this copy takes significant simulation time and memory. With ref, no copy is made at all — the task works directly on the caller's array.
// ── BAD: input copies the whole packet array every call ───────────
function automatic logic [7:0] crc_bad (input logic [7:0] pkt[0:1023]);
// ❌ 1024 bytes copied into pkt[] on every call
logic [7:0] crc = 8'hFF;
foreach (pkt[i]) crc ^= pkt[i];
return crc;
endfunction
// ── GOOD: const ref — zero copy, read-only inside ─────────────────
function automatic logic [7:0] crc_good (const ref logic [7:0] pkt[0:1023]);
// ✅ zero copy — direct alias, but read-only (const)
logic [7:0] crc = 8'hFF;
foreach (pkt[i]) crc ^= pkt[i];
return crc;
// pkt[0] = 8'hFF; ← compile error: const ref is read-only
endfunction
logic [7:0] my_packet[0:1023];
logic [7:0] checksum = crc_good(my_packet); // fast — no array copy
// ── ref (writable) — task that fills a large array in-place ───────
task automatic fill_random (ref logic [7:0] buf[0:1023]);
// Writes directly into caller's array — no copy back needed
foreach (buf[i])
buf[i] = $urandom_range(0, 255);
endtask
logic [7:0] tx_buf[0:1023];
fill_random(tx_buf); // tx_buf is filled directly — zero overhead
// ── ref for a scoreboard queue — avoid copying huge data ─────────
task automatic compare_queues (
const ref logic [31:0] expected[$], // read-only: no copy
const ref logic [31:0] actual[$] // read-only: no copy
);
if (expected.size() != actual.size())
$error("Size mismatch: exp=%0d got=%0d", expected.size(), actual.size());
foreach (expected[i])
if (expected[i] !== actual[i])
$error("[%0d] exp=0x%h got=0x%h", i, expected[i], actual[i]);
endtaskconst ref — Zero-Copy, Read-Only
const ref is the best of both worlds: you get the performance of a reference (no copy) with the safety of an input (the callee cannot accidentally modify the caller's data). Use it whenever you want to read a large object without paying for a copy.
// ── Three ways to pass a struct — which is best? ─────────────────
typedef struct {
logic [31:0] addr;
logic [7:0] data[0:511]; // 512-byte payload
logic valid;
} packet_t;
// ① input — copies 512+ bytes every call ─────────────────────────
function automatic logic validate_input (input packet_t pkt);
return pkt.valid && (pkt.addr[1:0] == 2'b00);
endfunction // ❌ expensive copy every call
// ② const ref — zero copy, read-only: PREFERRED ───────────────────
function automatic logic validate_cref (const ref packet_t pkt);
// pkt.valid = 0; ← compile error: const ref cannot be written
return pkt.valid && (pkt.addr[1:0] == 2'b00);
endfunction // ✅ zero copy, compiler guarantees no writes
// ③ ref — zero copy, writable ─────────────────────────────────────
function automatic logic validate_ref (ref packet_t pkt);
pkt.valid = 1; // accidental write — modifies caller's pkt!
return pkt.addr[1:0] == 2'b00;
endfunction // ⚠ writable ref: dangerous unless writes are intentional
// ── const ref with scalars (less common but valid) ────────────────
function automatic void print_val (const ref int x);
$display("x = %0d", x); // ✅ reads x from caller — no copy
// x = 0; ← illegal: const
endfunctionDecision Guide — Which to Use
| Direction | Use when |
|---|---|
input | Passing a small value (scalar, enum, short struct) you want the callee to read but never change. The default and safest choice for data flowing in. |
output | The callee needs to compute and return a result through the argument. The caller reads it after the task ends. Replaces a return value in tasks. |
inout | The callee needs to both read the current value AND write a new value back. Example: increment a counter that already has a starting value. |
ref | Passing large arrays/structs (avoid copy cost) OR when the caller needs to see changes while the task is still running — not just at return. |
const ref | Passing large arrays/structs for reading only. Zero copy + compiler enforces no accidental writes. Best practice for large read-only data. |
Common Mistakes
// ════ MISTAKE 1: Passing a constant to a ref argument ══════════════
task automatic t (ref int x); x++; endtask
t(5); // ❌ cannot alias a literal constant — compile error
int v = 5;
t(v); // ✅ must pass a variable
// ════ MISTAKE 2: ref in a static task (requires automatic) ════════
task bad_ref_task (ref int x); // ❌ static task with ref — illegal
x++;
endtask
// ✅ FIX: add automatic
task automatic good_ref_task (ref int x);
x++;
endtask
// ════ MISTAKE 3: Using ref when you want input ════════════════════
function automatic logic check_bad (ref packet_t pkt);
pkt.valid = 0; // ❌ accidentally clears caller's valid flag!
return pkt.addr[1:0] == 0;
endfunction
// ✅ FIX: use const ref — same speed, no accidental writes
function automatic logic check_good (const ref packet_t pkt);
// pkt.valid = 0; ← compile error now — much safer
return pkt.addr[1:0] == 0;
endfunction
// ════ MISTAKE 4: Expecting inout to show live updates ═════════════
task automatic slow_update_inout (inout int x);
x++;
@(posedge clk); // another process reads x here — but sees OLD value!
x++;
endtask
// ✅ FIX: use ref if live visibility is needed
task automatic slow_update_ref (ref int x);
x++;
@(posedge clk); // another process reads x — sees UPDATED value ✅
x++;
endtaskQuick Reference — Argument Passing Cheat Sheet
// ── input: copy-in, read-only inside, caller unchanged ────────────
input logic [7:0] data // accepts variables OR expressions: fn(a+b)
// ── output: copy-out at return, write-only inside ─────────────────
output logic [7:0] result // must be a variable at call site
// ── inout: copy-in AND copy-out (caller sees update at return) ────
inout int counter
// ── ref: zero-copy alias, caller sees changes immediately ─────────
ref logic [7:0] buf[] // must be automatic task/function
ref int count // callee can read AND write
// ── const ref: zero-copy, read-only — BEST for large read-only data
const ref packet_t pkt // compiler blocks accidental writes
const ref logic [7:0] arr[] // no array copy, read-only
// ── Decision summary ──────────────────────────────────────────────
// Small scalar, callee reads only → input
// Return a result through argument → output
// Read current value + write new value → inout
// Large array, read only → const ref ← preferred
// Large array, writable → ref
// Caller needs live updates during task → ref
// ── Rules ─────────────────────────────────────────────────────────
// • ref and const ref require an automatic task/function
// • ref/const ref cannot accept expressions — only variables
// • inout: caller sees change only AFTER task returns
// • ref: caller sees change IMMEDIATELY (even mid-task)Simulation Timing Analysis — ref vs inout Under the Scheduler
Understanding exactly when each argument direction copies data tells you how concurrent processes see each other. This is the difference between a race condition and correct testbench behaviour.
// ── input: copy at CALL TIME ──────────────────────────────────────
//
// T=0: caller evaluates argument expression → result = 42
// T=0: simulator writes 42 into task's local storage
// T=0→X: task runs, may modify local copy as it likes
// T=X: task ends — no copy-back ever
// T=X: caller's variable: still 42
//
// ── output: copy at RETURN TIME ──────────────────────────────────
//
// T=0: caller passes variable (address noted, no copy yet)
// T=0: task's local variable = X (undefined — must write before read)
// T=0→X: task runs, writes local variable
// T=X: endtask → simulator copies local value → caller variable
// TRAP: if task is disabled at T=5 (before T=X), copy-back is SKIPPED
// caller's variable stays at its pre-call value forever
//
// ── inout: copy-in at CALL, copy-out at RETURN ────────────────────
//
// T=0: copy-in → local = caller_var (value at T=0)
// T=3: @posedge → task suspends. Parallel process reads caller_var
// parallel process sees OLD value (T=0 snapshot)
// T=10: task resumes, modifies local
// T=15: endtask → copy-out → caller_var updated
// parallel process at T=15 finally sees the new value
//
// ── ref: NO copy — live alias from T=0 ───────────────────────────
//
// T=0: task receives pointer/alias to caller_var
// T=2: task writes ref_arg = 1 → caller_var = 1 IMMEDIATELY
// T=3: @posedge → task suspends. Parallel process reads caller_var
// parallel process sees 1 (live value) ✅
// T=10: task resumes, writes ref_arg = 2 → caller_var = 2 live
// T=15: endtask. No copy-back needed — already live
// If task is disabled at T=7 — partial write (=1) STAYS in caller_varReal Verification Patterns — How ref Changes Architecture
Pass by reference is not just a performance optimisation — it fundamentally changes how you structure verification components. These are patterns used in production testbenches.
// ═══════════════════════════════════════════════════════════════════
// PATTERN 1: Scoreboard — const ref for zero-copy packet comparison
// ═══════════════════════════════════════════════════════════════════
typedef struct {
logic [31:0] addr;
logic [7:0] data[0:255];
logic [15:0] length;
} pkt_t;
task automatic scoreboard_check (
const ref pkt_t expected, // zero-copy read-only
const ref pkt_t actual, // zero-copy read-only
output int errors // scalar: copy-out fine
);
errors = 0;
if (expected.length !== actual.length) begin
$error("[SB] Length mismatch: exp=%0d got=%0d", expected.length, actual.length);
errors++;
end
foreach (expected.data[i]) begin
if (expected.data[i] !== actual.data[i]) begin
$error("[SB] Byte[%0d]: exp=0x%h got=0x%h", i, expected.data[i], actual.data[i]);
errors++;
end
end
endtask
// ═══════════════════════════════════════════════════════════════════
// PATTERN 2: Driver — ref for live progress visibility
// ═══════════════════════════════════════════════════════════════════
int g_bytes_sent = 0; // module-level — visible everywhere
bit g_tx_active = 0;
task automatic apb_burst_write (
const ref pkt_t pkt, // read packet without copying
ref int bytes_sent, // watchdog can monitor live
ref bit active // monitor sees tx in-progress
);
active = 1; // immediately visible to any parallel process
for (int i = 0; i < pkt.length; i++) begin
@(posedge clk);
paddr = pkt.addr + i;
pwdata = pkt.data[i];
psel = 1;
penable = 0;
@(posedge clk); penable = 1;
@(posedge clk); psel = 0; penable = 0;
bytes_sent++; // watchdog sees this increment live
end
active = 0; // immediately visible — no wait for return
endtask
// ═══════════════════════════════════════════════════════════════════
// PATTERN 3: Watchdog using live ref visibility
// ═══════════════════════════════════════════════════════════════════
task automatic watchdog (ref int bytes_sent, input int timeout_cycles);
int last_count = 0;
int stall_cnt = 0;
forever begin
@(posedge clk);
if (bytes_sent == last_count) begin
stall_cnt++;
if (stall_cnt >= timeout_cycles) begin
$fatal(1, "[WDG] Driver stalled: no progress for %0d cycles", timeout_cycles);
end
end else begin
last_count = bytes_sent;
stall_cnt = 0;
end
end
endtask
// Watchdog sees bytes_sent increment live via ref — without ref,
// it would always read the pre-call snapshot of 0
// Wire them together in fork:
initial begin
fork
apb_burst_write(my_pkt, g_bytes_sent, g_tx_active);
watchdog(g_bytes_sent, 50);
join_any
disable fork;
end
// ═══════════════════════════════════════════════════════════════════
// PATTERN 4: const ref for read-heavy utility functions
// ═══════════════════════════════════════════════════════════════════
function automatic logic [7:0] compute_xor (const ref logic [7:0] arr[]);
logic [7:0] acc = 8'h00;
foreach (arr[i]) acc ^= arr[i];
return acc;
endfunction
function automatic bit is_all_zeros (const ref logic [7:0] arr[]);
foreach (arr[i]) if (arr[i] !== 8'h00) return 0;
return 1;
endfunction
// Both read large arrays with zero copy — called thousands of times
// without measurable overheadPerformance Impact — When the Copy Cost Is Real
| Data Type | Typical Size | input/inout Cost | const ref Cost | Recommendation |
|---|---|---|---|---|
int, bit, logic | 1–64 bits | negligible | negligible | Use input — cleaner semantics |
Small struct (<16 bytes) | 8–128 bits | negligible | negligible | Use input — no benefit from ref |
| Medium struct / packed array | 64–512 bytes | noticeable at scale | zero | Use const ref if called >10K times |
| Packet buffer (256–1K bytes) | 256B–1KB | measurable | zero | Always use const ref |
| Large array / scoreboard queue | 1KB–1MB | severe | zero | Always use const ref or ref |
Dynamic queue [$] | variable | severe for large queues | zero | Always use const ref |
Associative array [string] | variable | very slow to copy | zero | Always use const ref |
// Called in a loop 100,000 times
task automatic dump_log (
input string log[$] // copies ENTIRE queue
);
foreach(log[i])
$display("%s", log[i]);
endtask
// 10,000 entries × 100,000 calls
// = 1 billion string copies
// Simulation wall-clock: very slow// Same call count — zero copy overhead
task automatic dump_log (
const ref string log[$] // alias — no copy
);
foreach(log[i])
$display("%s", log[i]);
endtask
// 100,000 calls × 0 copy bytes
// Simulation wall-clock: normalDebugging Academy — Argument Passing Bugs
These bugs come from real verification project post-mortems. Each one has a signature that makes it recognisable once you've seen it.
output Argument Silently Skipped — disable Trap
SEVERITY: HIGHA task with an output logic err argument always returns err=0 (no error) even when the transaction clearly failed on the waveform. The testbench's error counter never increments.
The task is running inside a fork...join_any. A timeout fork branch fires first, triggering disable fork. The task is killed before endtask. The output copy-back never executes — err retains its pre-call value of 0.
The err variable is flat at 0 throughout the waveform. pslverr shows 1 on the bus at the timeout point but err never captures it. The task waveform shows activity up to the timeout, then stops abruptly.
Change output logic err to ref logic err. With ref, every write to err inside the task immediately updates the caller's variable. The disable path then at least preserves whatever partial state was written.
Any time you use disable fork and a task has output arguments, audit all of them. Either switch to ref, or move the status into a module-level variable that the task writes directly.
Accidental Write Through ref — Data Corruption
SEVERITY: HIGHA packet struct that was freshly randomised shows corrupted field values after being "checked" by a validation function. The randomisation itself is confirmed correct. The corruption happens inside a function call.
function automatic bit validate(ref pkt_t pkt) was used instead of const ref. The function contains a line like pkt.valid = 0; — an "initialisation" that the engineer didn't realise was writing through the reference and corrupting the caller's struct.
Change to const ref pkt_t pkt. The compiler immediately flags the write as an error: "assignment to const ref argument". Fix the bug, and the corruption disappears. const ref provides compiler-enforced protection against exactly this class of mistake.
Rule: Default to const ref for any large data structure you are only reading. Upgrade to plain ref only when you explicitly intend to write back to the caller. Never use plain ref for validation/checking functions.
Concurrent inout Corruption — Race on Shared Output
SEVERITY: HIGHA shared error counter int errors accumulates incorrect totals. The final count is always less than expected. Multiple concurrent test threads are each calling a task that uses inout int err_count.
Each concurrent call takes a snapshot of errors (copy-in at call time: say, errors=5). All three tasks run concurrently. All three increment their local copy to 6. All three copy-out 6 at endtask. Final value: 6. Expected: 8 (5 + one per task). The inout copy-in captures a stale snapshot.
Option A: Change to ref int err_count. All tasks write to the same location. Increments are live. Note: err_count++ is still a read-modify-write that can race within a single delta cycle — acceptable for most testbench counters.
Option B: Make errors a module-level variable (not a task argument at all). Each task writes to it directly. Module-level state is not subject to the copy-in/copy-out mechanism. This is the clearest approach for shared scoreboard counters.
ref on Static Task — Compile Error That Confuses
SEVERITY: LOWSimulator throws: "ref argument not allowed in static task/function" or similar. The engineer added ref to improve performance but forgot the module is not declared automatic and the task has no automatic keyword.
task my_task(ref logic[7:0] buf[]) — the task is static (default in a module without automatic). A static task cannot hold a reference because the reference would outlive the stack frame (there is no stack frame for a static task).
Add automatic:
task automatic my_task(ref logic[7:0] buf[]);
// ...
endtaskOr declare module automatic tb_top; to make the entire module automatic. This is also the right fix for any other task that uses ref.
inout Not Seeing Live Updates — Monitoring Bug
SEVERITY: MEDIUMA watchdog task reads an inout int progress argument in a parallel fork to detect stalls. The watchdog always sees the initial value of progress and incorrectly fires a timeout even though the driver is clearly advancing.
The driver passes its counter as inout to the watchdog task. The watchdog receives a copy of the counter value at call time. The driver increments its own local copy, but the watchdog's copy never updates — the watchdog is watching a snapshot, not the live variable.
Use ref int progress. The watchdog now reads the driver's actual counter — every increment is visible in real time. Alternatively, use a module-level variable so both driver and watchdog access the same storage without any argument direction needed.
Rule: Any time you need a parallel process to monitor a value while another process writes to it, use either ref (for task argument) or a module-level variable. inout is the wrong tool for monitoring — it was designed for sequential read-modify-write, not concurrent visibility.
Interview Q&A — Argument Passing Questions
Both are pass-by-value (they copy data). input copies data into the task at call time; the callee can read it but any writes only modify the local copy — the caller never sees them. inout copies in at call time AND copies the modified value back to the caller's variable when the task returns. Use input when the task only reads, inout when it reads and writes back a modified result.