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.
// 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;
endtaskAutomatic 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.
// 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 ...
endtaskSTATIC 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=2Argument 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.
// 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
endtaskKey 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.