Part 1 · Language Foundations · Intermediate

Tasks vs Functions

Time-consumption rules, automatic vs static lifetime and the classic reentrancy bug, argument directions including ref, and void functions.

The dividing line is time

A function must execute in zero simulation time: no # delays, no @ event controls, no wait, and it cannot call a task. That guarantee is what lets functions appear inside expressions — the simulator can evaluate y = f(a) + g(b) atomically without the value changing mid-expression. A task may consume time, block on events, and call anything — which is why bus-driving routines, monitors, and anything that says “wait for the clock” must be tasks. A void function returns nothing but keeps the zero-time guarantee; it is the right tool for pure side-effect work like printing, updating counters, or sampling covergroups, and it is callable from places (like other functions) where a task is illegal.

systemverilog
// FUNCTION: zero-time, usable in expressions
function automatic logic [7:0] crc8(input logic [7:0] d, c);
  return d ^ {c[6:0], c[7] ^ c[3]};   // pure computation, no time
endfunction

// VOID FUNCTION: side effects, still zero-time
function automatic void report_txn(input int unsigned addr);
  txn_count++;
  $display("[%0t] txn #%0d addr=%0h", $time, txn_count, addr);
endfunction

// TASK: consumes time — drives a bus over multiple cycles
task automatic drive_write(input logic [31:0] addr, data);
  @(posedge clk);
  vif.psel    <= 1'b1;  vif.paddr <= addr;
  vif.pwrite  <= 1'b1;  vif.pwdata <= data;
  @(posedge clk);
  vif.penable <= 1'b1;
  wait (vif.pready);          // blocks: legal ONLY in a task
  @(posedge clk);
  vif.psel <= 1'b0; vif.penable <= 1'b0;
endtask

Automatic vs static lifetime — the classic reentrancy bug

In modules and interfaces, tasks and functions default to static lifetime : one shared copy of every argument and local variable, allocated once for the whole simulation, exactly like 1995 Verilog. If two processes call the same static task concurrently — two fork branches driving two ports, or two always blocks sharing a utility task — the second call overwrites the first call's arguments and locals in place . Both invocations then operate on corrupted state. Declaring the routine automatic gives every call its own stack frame, like every mainstream language. Class methods are always automatic; module/interface routines are the danger zone, and this bug is a beloved interview question because it produces wrong data with no warning of any kind.

systemverilog
// BUG: static task called from two parallel processes
task send_pkt(input int id, input int len);   // static by default!
  int beats;                                  // ONE shared copy
  beats = len;
  repeat (beats) begin
    @(posedge clk);
    $display("pkt %0d beat (beats=%0d)", id, beats);
  end
endtask

initial fork
  send_pkt(0, 10);   // starts counting 10 beats...
  send_pkt(1, 2);    // ...overwrites id, len, beats for BOTH calls
join

// FIX: one keyword — each call gets private storage
task automatic send_pkt(input int id, input int len);
  int beats;
  // ... identical body, now reentrant ...
endtask
diagram
STATIC vs AUTOMATIC — CONCURRENT CALLS

  STATIC (default in modules)         AUTOMATIC
  ┌───────────────────────┐          ┌───────────────────┐
  │  ONE shared frame     │          │ call A: frame A   │
  │  id / len / beats     │          │ id=0 len=10       │
  └───────────┬───────────┘          ├───────────────────┤
       ▲      │      ▲              │ call B: frame B   │
       │      ▼      │              │ id=1 len=2        │
   call A  corrupt  call B          └───────────────────┘
   writes   state   writes           independent frames —
   id=0,len=10  id=1,len=2           both calls correct
    call A suddenly sees len=2

Argument directions, including ref

Arguments default to input (copied in at call time). output copies out at return; inout copies both ways — note this is still copy-in/copy-out , not a live connection: the caller sees nothing until the routine returns. ref passes a true reference: the routine reads and writes the caller's variable directly, mid-execution changes are visible both ways, and large arrays avoid an expensive copy. ref requires an automatic routine, and const ref gives you the no-copy efficiency while forbidding modification — the idiomatic way to pass big transaction objects or arrays for read-only use.

systemverilog
// const ref: no copy of a large array, read-only contract
function automatic int unsigned checksum(const ref byte data[]);
  checksum = 0;
  foreach (data[i]) checksum += data[i];
endfunction

// ref in a time-consuming task: caller sees updates LIVE
task automatic count_pulses(ref int count, input int cycles);
  repeat (cycles) begin
    @(posedge pulse);
    count++;            // visible to the caller immediately
  end
endtask

Key takeaways

  • Functions are zero-time and expression-safe; tasks may block — anything waiting on clocks is a task.

  • Module/interface routines are static by default — declare 'automatic' or concurrent calls corrupt each other.

  • output/inout are copy-out-at-return; ref is a live alias and requires an automatic routine.

  • const ref passes large arrays/objects without copying while guaranteeing read-only access.

Common pitfalls

  • Forgetting 'automatic' on a task called from forked processes — silent data corruption, no warning.

  • Expecting an output argument to update the caller while the task is still running — it copies at return.

  • Putting an event control in a function — compile error, or worse, a refactor that turns it into a task callers can't use in expressions.

  • Passing a megabyte array by value (input) in a hot loop — copy overhead that const ref eliminates.