SystemVerilog tasks and functions are the building blocks of every testbench. Understanding the difference between them — especially time-consuming tasks vs zero-time functions, automatic vs static lifetime, and ref vs const ref arguments — separates engineers who write correct re-entrant testbenches from those who spend days debugging mysterious simulation races.
The single most important rule: tasks can consume simulation time; functions cannot. A function must execute and return in the same simulation time step it was called — it cannot contain @(posedge clk), #delay, wait(), or any other time-consuming statement. A task has no such restriction and is the correct construct for driving bus transactions, waiting for acknowledgements, and implementing protocol state machines.
| Feature | Task | Function |
|---|---|---|
| Consumes time | ✅ Yes — can contain @, #, wait() | ❌ No — zero simulation time |
| Return value | ❌ No return value (uses output args) | ✅ Returns a single value (or void) |
| Can call tasks | ✅ Yes | ❌ No (would violate zero-time rule) |
| Can call functions | ✅ Yes | ✅ Yes |
| Argument directions | input, output, inout, ref, const ref | input, ref, const ref (no output in SV2012+: use ref) |
| Default lifetime (module) | static | static |
| Default lifetime (class) | automatic | automatic |
| Typical use | Drive UART/AXI/SPI transactions, wait for response, watchdog | CRC calculation, data packing, scoreboard checks |
When a task or function has static lifetime (the default in modules), all its local variables live in a single shared storage location. If two simulation threads call the same task simultaneously, they share those variables and corrupt each other. This is the classic re-entrancy bug: you fork two calls to the same task, they run in parallel, and both overwrite the same loop counter or return variable.
Automatic lifetime creates a fresh stack frame for each call, just like a C function. Local variables are private to each invocation. In module-based testbenches, always declare tasks and functions as automatic if they will be called from forked threads.
If you have task drive_bus(input [7:0] data) in a module (static by default), and your testbench forks 4 threads all calling drive_bus simultaneously, the local variable data is shared. All 4 threads will read/write the same memory location, producing completely wrong results with no error message. Fix: task automatic drive_bus(...).
The default. A copy of the caller's value is passed in. The task/function cannot modify the caller's original variable.
The argument is written inside the task and the value is copied back to the caller's variable when the task finishes. Not the same as a return value — it is copied back at exit, not continuously.
The value is copied in on call, can be read and modified inside, and is copied back on exit. Use sparingly — ref is usually clearer and more efficient.
ref passes the actual variable, not a copy. The task/function operates directly on the caller's variable. Critical for large arrays — passing a 10,000-element array by input copies every element; passing by ref copies a pointer. Also allows the task to modify the caller's variable continuously (not just at exit).
const ref is like ref but the function cannot modify the variable. This is the right choice for large arrays passed to a read-only analysis function: efficient (no copy) and safe (cannot accidentally modify).
input (default, safe)output argsconst ref (efficient, safe)refrefA void function (function void check_result(...)) performs an action but returns no value. It is preferred over a task for operations that must consume zero simulation time and do not return a value — like writing to a log, incrementing a counter, or performing an assertion check. The compiler enforces that a void function cannot contain time-consuming statements, catching errors early.
Recursive functions require automatic lifetime — each recursive call needs its own stack frame. A static function would have one shared set of local variables, making recursion impossible. Declare function automatic int factorial(int n) and the simulator correctly creates a new stack frame per call. Never attempt recursion with a static function — the results will be wrong or the simulator may crash.
Tasks can contain fork...join, fork...join_any, and fork...join_none to spawn parallel threads. This is the foundation of testbench parallelism:
fork...join — wait for all spawned threads to completefork...join_any — wait for the first thread to complete, then continue (others keep running)fork...join_none — spawn threads and continue immediately without waitingdisable fork kills all threads spawned in the current scope. The classic watchdog timer pattern uses fork...join_any with two threads: one doing the actual work, one counting timeout cycles. Whichever finishes first wins. Then disable fork kills the other.
// ============================================================
// tasks_demo.sv — Tasks, Functions, automatic, ref, recursive
// EcrioniX · SV Verification Course · Day 4
// ============================================================
// --- Standalone automatic task: drive a UART byte ---
// 'automatic' is critical if called from multiple fork threads
task automatic drive_uart(
input logic clk,
ref logic tx,
input logic [7:0] data,
input int baud_cycles // cycles per UART bit
);
// Start bit (low)
tx = 1'b0;
repeat(baud_cycles) @(posedge clk);
// 8 data bits, LSB first
for (int i = 0; i < 8; i++) begin
tx = data[i];
repeat(baud_cycles) @(posedge clk);
end
// Stop bit (high)
tx = 1'b1;
repeat(baud_cycles) @(posedge clk);
endtask
// --- Void function: check expected vs actual ----
function automatic void check_response(
input logic [7:0] actual,
input logic [7:0] expected,
input string test_name
);
if ($isunknown(actual)) begin
$error("[%s] DUT output has X: %0b", test_name, actual);
return;
end
if (actual !== expected)
$error("[%s] FAIL: got 8'h%0h, expected 8'h%0h", test_name, actual, expected);
else
$display("[%s] PASS: 8'h%0h", test_name, actual);
endfunction
// --- Pure calculation function: CRC-8 ---
function automatic logic [7:0] calc_crc8(
const ref logic [7:0] data_arr[], // large array passed by const ref
input int len
);
logic [7:0] crc = 8'hFF;
for (int i = 0; i < len; i++) begin
crc = crc ^ data_arr[i];
for (int b = 0; b < 8; b++) begin
if (crc[7]) crc = (crc << 1) ^ 8'h07;
else crc = crc << 1;
end
end
return crc;
endfunction
// --- Recursive function: requires automatic! ---
function automatic longint factorial(input int n);
if (n <= 1) return 1;
else return n * factorial(n - 1);
endfunction
// --- Watchdog timer task ---
// Fork two threads: work thread + timeout thread
// join_any stops when first finishes; disable fork kills the other
task automatic with_timeout(
input int timeout_cycles,
input logic clk
);
bit timed_out = 0;
fork
begin // -- thread 1: the work (caller fills this in externally) --
// Placeholder: wait for DUT done signal
@(posedge clk); // replace with actual done condition
end
begin // -- thread 2: timeout counter --
repeat(timeout_cycles) @(posedge clk);
timed_out = 1;
$error("WATCHDOG: operation timed out after %0d cycles", timeout_cycles);
end
join_any
disable fork; // kill whichever thread is still running
if (!timed_out) $display("Operation completed within timeout.");
endtask
// ============================================================
module tasks_demo;
logic clk = 0;
always #5 clk = ~clk; // 100 MHz clock
logic tx;
initial begin
// --- Factorial: recursive automatic function ---
$display("5! = %0d", factorial(5)); // 120
$display("10! = %0d", factorial(10)); // 3628800
// --- CRC-8 on a data buffer ---
logic [7:0] buf[] = new[4];
buf = '{8'hDE, 8'hAD, 8'hBE, 8'hEF};
$display("CRC-8 = 8'h%0h", calc_crc8(buf, 4));
// --- Check function ---
check_response(8'hA5, 8'hA5, "read_test_1"); // PASS
check_response(8'hFF, 8'h00, "read_test_2"); // FAIL
// --- Drive UART byte --- (tx driven by ref)
tx = 1'b1; // idle high
@(posedge clk);
drive_uart(clk, tx, 8'h55, 10); // 10 cycles per bit
$display("UART transmission complete");
// --- Two parallel UART drives (automatic ensures no corruption) ---
fork
drive_uart(clk, tx, 8'hAA, 10);
// In real TB: second UART instance would drive its own tx2
join
$finish;
end
endmoduleinput (copy in), output (copy out on exit), ref (by reference, read/write), const ref (by reference, read-only).const ref for read-only use — efficient, no copy, compiler-enforced immutability.automatic — each call needs its own stack frame.fork ... join_any + disable fork — the first of (work / timeout) to finish wins.Tasks can consume simulation time (contain @, #delay, wait()) and use output arguments to return multiple values. Functions execute in zero simulation time and return exactly one value (or void). If you need to drive a bus transaction or wait for a response, use a task. If you need to compute a value (CRC, parity, address decode), use a function.
Automatic lifetime means each call gets its own private stack frame with independent local variables. Static lifetime (module default) shares one set of variables across all concurrent calls — breaking re-entrant testbenches. Always use task automatic and function automatic in module-based testbenches with forked threads. Class methods are automatic by default.
Use ref for large arrays to avoid the cost of copying all elements. Use const ref for large arrays that are read-only. Use ref when you need the task to update the caller's variable continuously (not just on exit). Avoid ref for simple scalar inputs — input is cleaner and safer.
Use fork...join_any with two threads — one doing the real work, one counting timeout cycles with repeat(N) @(posedge clk). After join_any returns (whichever thread finished first), call disable fork to kill the remaining thread. Check a timed_out flag to know which one won.