Skip to content

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 / inoutPass by Reference — ref
A copy of the data is made at the point of call or returnNo copy — callee gets a direct alias to the caller's variable
input: copy-in at call, caller's variable unchangedCaller sees changes immediately, not just at return
output: copy-out at return, undefined inside until writtenCannot pass constants or expressions — must be a variable
inout: copy-in AND copy-outRequired for large arrays (avoid copying megabytes of data)
Caller does NOT see changes until the task returnsTask/function must be automatic
Works with constants and expressions as argumentsconst ref: alias, but callee cannot write
Safe: callee cannot accidentally corrupt caller's data (for input)

All Four Argument Directions — Compared

DirectionMechanismCopy made?Callee can write?Caller sees change when?Accepts expression?
inputPass by value (in)Yes — at callYes (private copy only)Never — input is isolatedYes — fn(a+b)
outputPass by value (out)Yes — at returnYes — must writeAfter task returnsNo — must be a variable
inoutPass by value (both)Yes — at call and returnYes — read and writeAfter task returnsNo — must be a variable
refPass by referenceNo — direct aliasYes — read and writeImmediately — any write is liveNo — must be a variable
const refPass by referenceNo — direct aliasNo — read onlyImmediately (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.

SystemVerilog — ref argument: syntax and immediate-visibility demo
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
endtask

inout 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.

SystemVerilog — ref and const ref with arrays
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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]);
endtask

const 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.

SystemVerilog — const ref vs input vs ref
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
endfunction

Decision Guide — Which to Use

DirectionUse when
inputPassing 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.
outputThe 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.
inoutThe callee needs to both read the current value AND write a new value back. Example: increment a counter that already has a starting value.
refPassing 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 refPassing large arrays/structs for reading only. Zero copy + compiler enforces no accidental writes. Best practice for large read-only data.

Common Mistakes

SystemVerilog — Pass by ref mistakes and fixes
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ════ 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++;
endtask

Quick Reference — Argument Passing Cheat Sheet

SystemVerilog — Pass by Value / Reference Quick Reference
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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.

SystemVerilog — argument passing event timeline
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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_var

Real 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.

SystemVerilog — Verification patterns using ref and const ref
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ═══════════════════════════════════════════════════════════════════
// 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 overhead

Performance Impact — When the Copy Cost Is Real

Data TypeTypical Sizeinput/inout Costconst ref CostRecommendation
int, bit, logic1–64 bitsnegligiblenegligibleUse input — cleaner semantics
Small struct (<16 bytes)8–128 bitsnegligiblenegligibleUse input — no benefit from ref
Medium struct / packed array64–512 bytesnoticeable at scalezeroUse const ref if called >10K times
Packet buffer (256–1K bytes)256B–1KBmeasurablezeroAlways use const ref
Large array / scoreboard queue1KB–1MBseverezeroAlways use const ref or ref
Dynamic queue [$]variablesevere for large queueszeroAlways use const ref
Associative array [string]variablevery slow to copyzeroAlways use const ref
Slow: input with large queue
Copies entire queue every call
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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
Fast: const ref with large queue
Zero-copy alias
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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: normal

Debugging 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.

1

output Argument Silently Skipped — disable Trap

SEVERITY: HIGH
Symptom

A 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.

Root Cause

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 executeserr retains its pre-call value of 0.

Symptom

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.

Fix

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.

2

Accidental Write Through ref — Data Corruption

SEVERITY: HIGH
Symptom

A 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.

Root Cause

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.

Fix

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.

3

Concurrent inout Corruption — Race on Shared Output

SEVERITY: HIGH
Symptom

A 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.

Root Cause

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.

Fix

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.

4

ref on Static Task — Compile Error That Confuses

SEVERITY: LOW
Symptom

Simulator 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.

Root Cause

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).

Fix

Add automatic:

Make the task automatic
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
task automatic my_task(ref logic[7:0] buf[]);
    // ...
endtask

Or declare module automatic tb_top; to make the entire module automatic. This is also the right fix for any other task that uses ref.

5

inout Not Seeing Live Updates — Monitoring Bug

SEVERITY: MEDIUM
Symptom

A 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.

Root Cause

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.

Fix

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.