SystemVerilog · Module 6
Automatic vs. Static Lifetime
What `lifetime` means, default rules, recursion's need for `automatic`, the static-task concurrent-call bug, and decision rules.
Module 6 · Page 6.4
One missing keyword — automatic — can corrupt the results of every concurrent task call in your testbench. This page explains exactly why, shows you the memory model behind it, and gives you a permanent rule for every situation.
What "Lifetime" Means for a Task or Function
Lifetime controls how long the local variables inside a task or function exist, and — critically — whether multiple concurrent calls share the same storage or each get their own private copy.
| Lifetime | Storage model | Concurrent-safe? | Default in | Typical use |
|---|---|---|---|---|
automatic | Private stack frame per call | Yes | class, program | All testbench tasks/functions, recursion |
static | One shared storage cell across every call | No | module, interface, package | Persistent counters, singleton state |
automatic— Private Stack Frame. Each call gets its own fresh copy of every local variable. Variables are created when the call starts and destroyed when it ends. Safe for concurrent calls. Required for recursive functions. Default inside classes and programs.static— Single Shared Storage. ONE copy of each variable shared across ALL calls. The variable persists between calls and retains its last value. Concurrent calls step on each other's data. Default inside modules, interfaces, and packages. Rarely correct in testbenches.
The Classic Bug — Static Task Called Concurrently
This is the most dangerous bug that static lifetime causes. It happens silently — no compiler error, no simulator warning — and the results look plausible enough that you might spend days debugging before tracing it back to a missing automatic.
// ════ BUGGY: static task — shared variable 'n' ════════════════════
task wait_cycles_bad (input int n); // ❌ static: ONE shared 'n'
repeat(n) @(posedge clk);
endtask
initial begin
fork
wait_cycles_bad(10); // Thread A: wants to wait 10 cycles
wait_cycles_bad(5); // Thread B: starts, writes n=5 over A's n!
join
// Both threads share the same 'n' — A ends after 5 cycles, not 10
end
// ════ FIXED: automatic task — each call gets private 'n' ══════════
task automatic wait_cycles_good (input int n); // ✅ private 'n' per call
repeat(n) @(posedge clk);
endtask
initial begin
fork
wait_cycles_good(10); // Thread A: has its own n=10
wait_cycles_good(5); // Thread B: has its own n=5
join
// A waits 10 cycles, B waits 5 — completely independent ✅
end
// ════ More dangerous example: APB driver shared intermediate var ═══
task apb_write_bad (input logic [31:0] addr, data);
logic [31:0] local_addr; // ❌ static: shared across all concurrent calls
local_addr = addr;
@(posedge clk); // ← another call can change local_addr here!
paddr = local_addr; // drives the corrupted value
endtask
// ✅ FIX: 'automatic' protects every local variable
task automatic apb_write_good (input logic [31:0] addr, data);
logic [31:0] local_addr = addr; // ✅ private — no other call can touch it
@(posedge clk);
paddr = local_addr; // always the right value
endtaskDefault Lifetimes — What You Get Without Specifying
The default lifetime of a task or function depends on where it is declared. Modules default to static; classes and programs default to automatic. This asymmetry is a common source of confusion.
| Where declared | Default lifetime | Reason | Action |
|---|---|---|---|
module | static | Modules are hardware — static matches RTL semantics | Add automatic if calling concurrently |
interface | static | Same as module | Add automatic for testbench use |
package | static | Utility functions — one copy per package | Add automatic for recursive/concurrent use |
class | automatic | OOP: each object instance needs its own scope | Already safe — no action needed |
program | automatic | Programs are testbench constructs — automatic is safer | Already safe — no action needed |
// ── Inside a MODULE — default is STATIC ──────────────────────────
module tb;
task drive_data (input int n); // ⚠️ static by default in module
repeat(n) @(posedge clk);
endtask
task automatic safe_drive (input int n); // ✅ explicit automatic
repeat(n) @(posedge clk);
endtask
endmodule
// ── Inside a PROGRAM — default is AUTOMATIC ───────────────────────
program tb_program;
task safe_by_default (input int n); // ✅ automatic by default in program
repeat(n) @(posedge clk);
endtask
endprogram
// ── Inside a CLASS — default is AUTOMATIC ────────────────────────
class ApbDriver;
task write (input logic [31:0] a, d); // ✅ automatic by default in class
@(posedge clk); psel = 1;
endtask
endclass
// ── MODULE-level: make ALL tasks automatic with one declaration ───
module automatic tb_auto; // ← 'automatic' on the module declaration
// ALL tasks and functions inside are now automatic by default
task drive_data (input int n); // ✅ automatic (inherited from module)
repeat(n) @(posedge clk);
endtask
endmoduleWhen static Is Intentional — Persistent State
Static is not always a bug. Sometimes you want a variable to persist between calls — for example, a call counter that increments every time a function runs, or a flag that records whether initialisation has happened. For these patterns, static is exactly correct.
// ── Pattern 1: Call counter — intentionally persists ─────────────
task automatic apb_write_counted (
input logic [31:0] addr, data,
output logic err
);
// 'static int' inside an automatic task — persists across ALL calls
// but the rest of the task is still automatic (safe for concurrency)
static int call_count = 0;
call_count++;
$display("APB write #%0d to addr 0x%h", call_count, addr);
@(posedge clk); psel = 1; paddr = addr; pwdata = data;
@(posedge clk); penable = 1;
@(posedge clk); err = pslverr; psel = 0; penable = 0;
endtask
// call_count increments with each call: 1, 2, 3, ...
// The task itself is automatic (concurrent-safe)
// Only the counter is static (intentionally shared)
// ── Pattern 2: Once-only initialisation ──────────────────────────
function automatic void init_once ();
static bit done = 0; // persists across all calls
if (done) return; // skip if already done
done = 1;
$display("[INIT] Initialising testbench state once");
// ... perform one-time setup
endfunction
// Calling init_once() 10 times only runs the body once
// ── Pattern 3: Running transaction ID generator ───────────────────
function automatic int next_txn_id ();
static int txn_id = 0;
return ++txn_id; // always unique: 1, 2, 3, ...
endfunction
// Each APB write gets a unique ID
$display("TXN ID: %0d", next_txn_id()); // 1
$display("TXN ID: %0d", next_txn_id()); // 2
$display("TXN ID: %0d", next_txn_id()); // 3Recursive Functions Require automatic
Recursion depends on each call having its own private copy of every local variable and argument. With static lifetime, all recursive calls share one set of variables — the function cannot track its own call stack and produces completely wrong results.
// ════ BROKEN: static recursive function ═══════════════════════════
function int factorial_bad (input int n);
// ❌ static: ONE shared 'n' for all recursive calls
// When factorial_bad(3) calls factorial_bad(2), the shared 'n'
// gets overwritten with 2 — factorial_bad(3)'s context is lost
if (n <= 1) return 1;
return n * factorial_bad(n - 1); // produces garbage
endfunction
// ════ FIXED: automatic recursive function ═════════════════════════
function automatic int factorial_good (input int n);
// ✅ Each call gets its own 'n' on the call stack:
// factorial_good(3) has n=3
// factorial_good(2) has n=2 ← independent
// factorial_good(1) has n=1 ← independent
if (n <= 1) return 1;
return n * factorial_good(n - 1); // correct: 3*2*1 = 6
endfunction
$display("3! = %0d", factorial_good(3)); // prints: 3! = 6Lifetime Decision Rules — One Clear Table
| Situation | Use | Why |
|---|---|---|
| Testbench task (any task that may be called concurrently) | automatic | Concurrent calls need private variable copies |
| Testbench function (called from multiple threads) | automatic | Same — each call needs independent local state |
| Recursive function | automatic | Each recursion level needs its own copy of arguments |
| Task or function inside a class | Already automatic | Classes are automatic by default — nothing to add |
| RTL function (purely combinational, no concurrency) | Either (static fine) | Not called concurrently; static is safe and is the default |
| Persistent call counter / once-flag | static int inside automatic task | Variable must survive across calls; task itself stays automatic |
| Unique ID generator | static int inside automatic function | Counter persists; function arguments remain private |
| Whole testbench module | module automatic tb; | One keyword makes all tasks/functions inside automatic |
Common Mistakes
// ════ MISTAKE 1: Forgetting automatic on forked tasks ════════════
task count_down (input int n); // ❌ static — shared 'n'
while (n > 0) begin @(posedge clk); n--; end
endtask
fork
count_down(10); // ← these two share n — both end unpredictably
count_down(5);
join
// ✅ FIX: add automatic
task automatic count_down (input int n); // private n per call
while (n > 0) begin @(posedge clk); n--; end
endtask
// ════ MISTAKE 2: Using static counter in static task ═════════════
task log_write (input string msg);
// ❌ This task is static AND has a static counter
// Concurrent calls corrupt both the counter AND the task state
int cnt = 0; // ← static by default inside static task
cnt++;
$display("[%0d] %s", cnt, msg);
endtask
// ✅ FIX: automatic task + intentional static counter
task automatic log_write (input string msg);
static int cnt = 0; // intentionally shared — message number
cnt++;
$display("[%0d] %s", cnt, msg);
endtask
// ════ MISTAKE 3: Expecting a local var to reset each call ════════
task compute_bad ();
int result = 0; // ❌ static: 'result' is NOT reset each call!
result++; // accumulates across calls: 1, 2, 3 — not 1,1,1
endtask
// ✅ FIX: use automatic
task automatic compute_good ();
int result = 0; // ✅ fresh 0 every call
result++; // always 1
endtaskQuick Reference — Lifetime Cheat Sheet
// ── automatic: private per call ──────────────────────────────
task automatic my_task (...); // explicit on task
function automatic int my_fn (...); // explicit on function
module automatic tb; // whole module — all inside are automatic
// ── static: shared across all calls ─────────────────────────
task static my_task (...); // explicit static
function static int my_fn (...); // explicit static
task my_task (...); // implicit static inside module
// ── Mix: automatic task + static variable ────────────────────
task automatic counted_task (...);
static int call_n = 0; // persists; rest of task is automatic
call_n++;
endtask
// ── Default rules ─────────────────────────────────────────────
// module / interface / package → static (must add automatic)
// class / program → automatic (already safe)
// ── The golden rule ───────────────────────────────────────────
// Any task or function that CAN be called concurrently MUST
// be automatic. When in doubt: always use automatic.Simulation Memory Model — What the Tool Actually Does
Understanding how the simulator physically manages task storage makes the static/automatic distinction intuitive rather than a rule to memorise.
// task static wait_n(input int n) — ONE shared slot for 'n'
//
// fork
// wait_n(10); ← Thread A
// wait_n(5); ← Thread B
// join
//
// Simulator memory map (single global allocation):
//
// Address Variable Value at T=0ns Value at T=5ns (B starts)
// ──────────────────────────────────────────────────────────────
// 0x1000 n 10 (A wrote) 5 ← B overwrote !
//
// Both threads read from address 0x1000.
// After B starts, A's loop also terminates in 5 cycles — not 10.// task automatic wait_n(input int n) — fresh stack frame per call
//
// fork
// wait_n(10); ← Thread A gets its own stack frame
// wait_n(5); ← Thread B gets its own stack frame
// join
//
// Simulator memory map (stack-based, two frames allocated):
//
// Frame Address Variable Value
// ──────────────────────────────────────────────────
// A frame 0x2000 n 10 ← untouched by B
// B frame 0x2010 n 5 ← untouched by A
//
// A terminates after 10 cycles. B terminates after 5. Correct.
// Both frames are freed when their respective tasks complete.The Mix Pattern — static Variable Inside automatic Task
The most powerful and commonly misunderstood pattern: an automatic task (private stack per call) that contains one or more static variables (intentionally shared across calls). This gives you concurrency safety and persistent state in a single declaration.
task apb_write (
input logic [31:0] addr, data
);
int txn_num; // shared — WRONG
static int cnt=0; // also shared — intent?
txn_num = ++cnt;
@(posedge clk);
paddr = addr; // corrupted if concurrent
@(posedge clk);
pwdata = data;
endtasktask automatic apb_write (
input logic [31:0] addr, data
);
static int cnt=0; // intentional: shared counter
int txn_num; // private per call — correct
txn_num = ++cnt; // ++ is atomic in simulation
@(posedge clk);
paddr = addr; // private — no corruption
@(posedge clk);
pwdata = data;
endtask// ── Pattern A: Call counter + unique-ID tracker ────────────────────
task automatic apb_write_tracked (
input logic [31:0] addr, data,
output logic err
);
static int global_cnt = 0; // persists: total transaction count
int my_id; // private: this call's ID
my_id = ++global_cnt;
$display("[TXN %0d] write 0x%h → 0x%h", my_id, addr, data);
@(posedge clk); psel=1; paddr=addr; pwdata=data; penable=0;
@(posedge clk); penable=1;
@(posedge clk); err=pslverr; psel=0; penable=0;
endtask
// ── Pattern B: Once-flag (init runs exactly once regardless of callers) ─
function automatic void ensure_init ();
static bit initialised = 0;
if (initialised) return;
initialised = 1;
$display("[INIT] First call — initialising shared state");
// setup code here
endfunction
// ── Pattern C: Accumulated error counter ──────────────────────────
task automatic check_resp (input logic expected, actual, input string tag);
static int errors = 0;
if (expected !== actual) begin
errors++;
$display("[FAIL #%0d] %s: exp=%0b got=%0b", errors, tag, expected, actual);
end
endtask
// ── Pattern D: Testbench module — make everything automatic at once ──
module automatic tb_top; // ← one keyword, whole module is automatic
// All tasks and functions below are automatic by inheritance
task run_test (); // automatic — no explicit keyword needed
@(posedge clk);
endtask
function int rand_addr (); // automatic — safe for concurrent calls
return $urandom_range(32'hFF, 0);
endfunction
endmoduleDebugging Academy — Lifetime Bugs You Will Encounter
Five real-world lifetime bugs, each with a clinical analysis of symptom, root cause, and fix. Recognising these patterns will cut hours off your next debug session.
Static Task — Concurrent Stimulus Corruption
SEVERITY: HIGHTwo concurrent APB write transactions write to wrong addresses. Protocol checker flags unexpected PADDR values. Happens only when two writes overlap in time. Sequential tests pass.
task apb_write(input logic[31:0] addr, data) — no automatic. The argument addr and any local variable are in a single shared slot. When Thread B calls the task and writes its address over Thread A's address before A drives PADDR, A drives B's address.
Directed tests call writes sequentially — no overlap, no bug. Only random/constrained tests that generate overlapping transactions expose it. The protocol-checker error says nothing about lifetime.
task automatic apb_write(input logic[31:0] addr, data) — one keyword, private frame per call. Or declare module automatic tb_top; to protect all tasks at once.
Add $display("[%0t] apb_write addr=%0h entry", $time, addr); at task entry. If two threads print the same address, you found the corruption point.
Recursive Function Returns Wrong Value
SEVERITY: HIGHA recursive function produces wrong results for inputs > 1. Results are inconsistent and appear simulator-dependent. Waveform shows correct logic toggles but data bus carries garbage values.
function int compute(input int n) — static by default in a module. All recursive calls share one n. Deep calls overwrite the shared variable; shallow levels then read the overwritten value.
function automatic int compute(input int n) — recursive functions always require automatic. This is not optional. Any recursion without automatic is undefined behaviour in simulation.
Test the function with n=1 and n=2 before n=10. If n=1 is correct but n=2 is wrong, static lifetime is the cause. The bug always fails at the second recursion level.
Local Variable Doesn't Reset Between Calls
SEVERITY: MEDIUMA task's internal counter or accumulator starts from a non-zero value on the second call, even though the code initialises it to 0. Cumulative totals keep growing across test phases.
int result = 0; inside a static task. The = 0 initialiser runs only at elaboration time, not at each call. Every subsequent call sees the value left by the previous call.
task automatic my_task(); — automatic tasks allocate fresh storage and re-execute the initialiser on every call. The = 0 now runs each time.
Grep for task without automatic in the module file. Any task that has a local variable with an initialiser and is called more than once is a candidate for this bug.
Unintentional Singleton — Static Variable in Automatic Task
SEVERITY: MEDIUMAn automatic task produces different results depending on how many times it was called previously — even though no arguments changed. Behaviour changes between simulation runs if the seed alters call order.
static int mode = 0; inside an automatic task — intentional static, but the engineer forgot that it persists across all callers. A previous test phase changed mode and this phase inherits the changed value.
Either reset the static variable explicitly at test start, or add a reset argument to the task. Alternatively, lift the persistent state out to a module-level variable for clarity — hidden static state is a design smell.
Search for static inside automatic tasks. Each one is a persistent singleton — document its lifecycle explicitly or move it to module scope where it is visible.
Package Function — Static Corruption in Multi-Instance Test
SEVERITY: HIGHA utility function in a package works correctly when called from one testbench but produces wrong results when called from two different testbench modules simultaneously (multi-DUT or multi-instance tests).
function int compute_crc(input byte data[]) in a package — static by default. All callers across all instantiated testbenches share the same internal state. One DUT's call corrupts the other's.
function automatic int compute_crc(input byte data[]) — package functions used in multi-concurrent contexts must be automatic. Make this the standard for all utility package functions.
Package functions are easy to overlook because they look like software utilities. Audit all package functions: any that have local variables and are called from concurrent contexts need automatic.
Advanced Patterns — Lifetime in Real Verification Architectures
// ════ Full testbench module with all lifetime decisions correct ════
module automatic tb_top; // ← automatic on the module: blanket protection
// ── Infrastructure (all automatic by module inheritance) ─────────
task gen_clock (); // automatic — no concurrent issue (single call)
forever begin
clk = 0; #5;
clk = 1; #5;
end
endtask
task apply_reset (); // automatic — called once
rst_n = 0; @(posedge clk); @(posedge clk);
rst_n = 1; @(posedge clk);
endtask
// ── Bus driver (automatic — called concurrently from fork) ───────
task apb_write (
input logic [31:0] addr, data,
output logic err
);
static int txn_cnt = 0; // intentional: global transaction count
int my_txn; // private: this call's transaction number
my_txn = ++txn_cnt;
@(posedge clk);
psel=1; paddr=addr; pwdata=data; penable=0;
@(posedge clk); penable=1;
@(posedge clk); err=pslverr; psel=0; penable=0;
$display("[TXN %0d] apb_write 0x%h = 0x%h err=%0b", my_txn, addr, data, err);
endtask
// ── Self-checking write+verify ───────────────────────────────────
task write_and_verify (
input logic [31:0] addr, data
);
static int pass=0, fail=0; // shared score counters
logic [31:0] rd; // private readback — automatic
logic err;
apb_write(addr, data, err);
@(posedge clk); @(posedge clk);
// apb_read(addr, rd, err); // assumed defined
if (rd === data) begin pass++; $display("PASS[%0d] addr=0x%h", pass, addr); end
else begin fail++; $display("FAIL[%0d] addr=0x%h", fail, addr); end
endtask
// ── Stimulus: concurrent transactions via fork ───────────────────
initial begin
fork
gen_clock();
begin
apply_reset();
fork
write_and_verify(32'h100, 32'hA5A5_A5A5);
write_and_verify(32'h104, 32'h5A5A_5A5A);
write_and_verify(32'h108, 32'hFF00_FF00);
join
$finish;
end
join_any
end
endmoduleInterview Q&A — Lifetime Questions That Engineers Ask
Static. Any task or function declared inside a module, interface, or package is static by default. This means all calls share the same storage for local variables. To make them automatic, add the automatic keyword to the task/function header, or to the module declaration itself (module automatic tb;).