Skip to content

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.

LifetimeStorage modelConcurrent-safe?Default inTypical use
automaticPrivate stack frame per callYesclass, programAll testbench tasks/functions, recursion
staticOne shared storage cell across every callNomodule, interface, packagePersistent 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.

SystemVerilog — The concurrent bug (static) vs the fix (automatic)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ════ 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
endtask

Default 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 declaredDefault lifetimeReasonAction
modulestaticModules are hardware — static matches RTL semanticsAdd automatic if calling concurrently
interfacestaticSame as moduleAdd automatic for testbench use
packagestaticUtility functions — one copy per packageAdd automatic for recursive/concurrent use
classautomaticOOP: each object instance needs its own scopeAlready safe — no action needed
programautomaticPrograms are testbench constructs — automatic is saferAlready safe — no action needed
SystemVerilog — Default lifetime in different scopes
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
endmodule

When 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.

SystemVerilog — Intentional static: persistent counter and once-flag
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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());  // 3

Recursive 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.

SystemVerilog — Recursion requires automatic (broken vs fixed)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ════ 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! = 6

Lifetime Decision Rules — One Clear Table

SituationUseWhy
Testbench task (any task that may be called concurrently)automaticConcurrent calls need private variable copies
Testbench function (called from multiple threads)automaticSame — each call needs independent local state
Recursive functionautomaticEach recursion level needs its own copy of arguments
Task or function inside a classAlready automaticClasses 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-flagstatic int inside automatic taskVariable must survive across calls; task itself stays automatic
Unique ID generatorstatic int inside automatic functionCounter persists; function arguments remain private
Whole testbench modulemodule automatic tb;One keyword makes all tasks/functions inside automatic

Common Mistakes

SystemVerilog — Lifetime mistakes and fixes
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ════ 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
endtask

Quick Reference — Lifetime Cheat Sheet

SystemVerilog — Automatic vs Static Quick Reference
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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.

SystemVerilog — Static task memory layout during concurrent fork
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.
SystemVerilog — Automatic task memory layout during concurrent fork
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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.

Static task — concurrent corruption
Shared everything — broken under concurrency
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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;
endtask
Automatic task + static counter — correct mix
Private frame + one shared counter
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
task 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
SystemVerilog — Complete mix-pattern reference
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
endmodule

Debugging 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.

1

Static Task — Concurrent Stimulus Corruption

SEVERITY: HIGH
Symptom

Two 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.

Root Cause

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.

Why Hard

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.

Fix

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.

Debug Tip

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.

2

Recursive Function Returns Wrong Value

SEVERITY: HIGH
Symptom

A 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.

Root Cause

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.

Fix

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.

Debug Tip

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.

3

Local Variable Doesn't Reset Between Calls

SEVERITY: MEDIUM
Symptom

A 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.

Root Cause

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.

Fix

task automatic my_task(); — automatic tasks allocate fresh storage and re-execute the initialiser on every call. The = 0 now runs each time.

Debug Tip

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.

4

Unintentional Singleton — Static Variable in Automatic Task

SEVERITY: MEDIUM
Symptom

An 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.

Root Cause

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.

Fix

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.

Debug Tip

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.

5

Package Function — Static Corruption in Multi-Instance Test

SEVERITY: HIGH
Symptom

A 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).

Root Cause

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.

Fix

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.

Debug Tip

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

SystemVerilog — Production-quality testbench with correct lifetimes
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ════ 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
 
endmodule

Interview 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;).