Skip to content

Tasks & Functions

Every driver, monitor, and scoreboard method you'll ever write is a task or a function. The distinction between them is small in syntax but load-bearing in meaning, and getting automatic wrong is a real, hard-to-diagnose bug class in exactly the kind of parallel testbench code UVM is built from.

Functions vs. tasks — the one real difference

function int add(int a, int b);
  return a + b;
endfunction

task drive_byte(input bit [7:0] data);
  @(posedge clk);
  dut_data <= data;
  @(posedge clk);
endtask

A function executes in zero simulation time and cannot contain any blocking timing control (#, @, wait) — it must return before the current time step advances. A task can consume time: it can wait on clock edges, delays, or events, and the calling process blocks until it returns. This is the entire distinction. Everything else — return values, arguments, being called from other code — is nearly identical.

Rule of thumb: if it needs to touch a clock edge or wait for anything, it's a task. Pure computation (parity, checksum, converting a status enum to a string) is a function.

automatic vs. static — this one actually bites

task automatic drive_transaction(txn t);
  // ... uses local variables, waits on clock edges ...
endtask

Verilog tasks/functions default to static storage: all local variables live in one shared memory location for the entire simulation, not a fresh copy per call. That's fine for a function that runs to completion in zero time with no re-entrancy. It's a real bug for a task that blocks on a clock edge, because if the same task is called again (or called concurrently from a different process via fork) before the first call finishes, both calls share the same local variables and silently corrupt each other's state.

SystemVerilog's automatic keyword gives each call its own stack frame, like a normal function in software — every local variable gets a fresh copy per invocation, safe to call concurrently or recursively.

Default lifetime depends on where the task lives

A task/function declared inside a class (which is essentially everything in a UVM-based testbench — drivers, monitors, sequences) is automatic by default, because class methods need to support exactly this kind of concurrent/re-entrant calling. Module-scope tasks/functions default to static unless you say automatic explicitly. This asymmetry means the classic "shared local variable corruption" bug mostly shows up in older, module-based (non-class) testbench code — but it's worth knowing the rule rather than assuming, since mixing styles (a module-level helper task called from multiple concurrent class-based drivers) is exactly where it resurfaces.

Void functions

function void report_error(string msg);
  $error("[SB] %s", msg);
endfunction

A void function returns nothing and can be called as a standalone statement (unlike a value-returning function, which — outside of SystemVerilog's relaxed rules — technically wants its return value used somewhere). In practice, void functions are the default choice for anything that's really a procedure: logging, printing, updating a counter, raising an error.

Arguments: direction and passing style

function void scale(input int in_val, output int out_val, ref int call_count);
  out_val = in_val * 2;
  call_count++;
endfunction
  • input (the default if unspecified) — passed by value, changes inside the function don't propagate back.
  • output — passed by reference for the return direction only; the function can set it, the caller sees the new value after the call, but the function doesn't see whatever value the caller had before calling.
  • inout — value flows both ways, like output but the function also sees the caller's starting value.
  • ref — a true reference (like C++'s &) — any read or write inside the function directly touches the caller's variable in real time, not just at entry/exit. This is also the only way to pass large data structures (a full array or a class-heavy struct) without a full copy — the standard choice for passing a transaction object's fields around in bulk without copy overhead.
function void set_defaults(int base = 0, int increment = 1);
  ...
endfunction

set_defaults();           // base=0, increment=1
set_defaults(10);         // base=10, increment=1
set_defaults(.increment(5)); // named argument — base keeps its default, only increment changes

Default argument values plus named-argument calling (.argname(value)) together give you most of what argument overloading would in another language: one function signature that can be called tersely in the common case and precisely when you need to override just one non-default parameter deep in the list.

return inside a task is legal, disable is a different thing

A task can use a plain return; (or return value; if you gave it a return type via task ret_type name(...), though that's unusual — tasks are conventionally void-shaped) to exit early, exactly like a function. disable task_name / disable fork is the separate mechanism for another process to forcibly terminate a still-running task or a fork block from the outside — used, for example, to kill an in-flight driver task when a test hits a timeout, which return can't do since return only ever ends the caller's own execution.