SystemVerilog · Module 6
Task Declarations & Time-Consuming Calls
Task anatomy, timing controls (#, @, wait), arguments, disable, fork-join, class tasks, and APB driver patterns.
Module 6 · Page 6.3
Tasks are the workhorses of every testbench — every bus driver, every stimulus generator, every protocol transaction is a task. This page covers the full task syntax, every timing control form, output arguments, how to run tasks in parallel, the simulator mechanics that make tasks behave the way they do, the static-task bug that haunts every verification engineer, advanced production patterns, five real debugging labs, and ten interview-ready questions.
Anatomy of a Task — Every Part Labelled
A task looks similar to a function but has key structural differences: no return type, all three argument directions are allowed, and timing controls can appear anywhere in the body.
// ① 'task' + 'automatic' — always use for testbench tasks
// ② input args — copy-in at call, read-only inside task
// ③ output arg — written by task, copy-out on return
// ④ Timing controls (@, #, wait) — suspend the process
// ⑤ Output written here — caller reads it after return
task automatic apb_write (
input logic [31:0] addr,
input logic [31:0] wdata,
output logic slverr
);
@(posedge clk);
psel = 1; paddr = addr; pwrite = 1;
@(posedge clk);
penable = 1; pwdata = wdata;
@(posedge clk);
slverr = pslverr; // write to output arg
psel = 0; penable = 0;
endtaskThe Three Timing Controls Inside Tasks
Tasks are the only place in SystemVerilog where you can write timing controls that suspend execution and let simulation time advance. There are three forms, each waiting for a different condition.
// ── ① #delay — wait a fixed number of time units ─────────────────
task automatic apply_reset (input int reset_cycles);
rst_n = 1'b0;
#(reset_cycles * CLK_PERIOD); // wait N clock periods in time units
rst_n = 1'b1;
#(2 * CLK_PERIOD); // 2-cycle settling time
endtask
// ── ② @event — wait for a signal change or edge ─────────────────
task automatic wait_cycles (input int n);
repeat(n) @(posedge clk); // wait exactly N rising clock edges
endtask
task automatic wait_negedge ();
@(negedge clk); // wait for falling edge
endtask
task automatic wait_signal_change (input logic sig);
@(sig); // wait for ANY change on sig (posedge or negedge)
endtask
task automatic wait_rising (input logic sig);
@(posedge sig); // wait for 0→1 transition on sig
endtask
// ── ③ wait() — level-sensitive wait ─────────────────────────────
task automatic wait_for_ready ();
wait(ready == 1'b1); // suspend until ready is HIGH
@(posedge clk); // then align to clock
endtask
task automatic wait_for_idle ();
wait(state == IDLE); // wait until FSM is idle
endtask
// ── Mixing all three in one task ─────────────────────────────────
task automatic uart_send_byte (input logic [7:0] data);
wait(tx_ready); // wait until transmitter is ready
@(posedge clk); tx_data = data; tx_valid = 1;
@(posedge clk); tx_valid = 0; // deassert after one cycle
#100; // guard time before next byte
endtaskTask Arguments — All Three Directions
Unlike functions (which only use input), tasks support all three argument directions: input, output, and inout. This is how a task communicates results back to the caller without using a return value.
// ── Task with all three argument directions ───────────────────────
task automatic apb_read (
input logic [31:0] addr, // ① input: address to read (caller → task)
output logic [31:0] rdata, // ② output: data read back (task → caller)
output logic slverr // ② output: error flag (task → caller)
);
@(posedge clk);
psel = 1; paddr = addr; pwrite = 0;
@(posedge clk);
penable = 1;
@(posedge clk);
// Write results to output ports before returning
rdata = prdata; // captured from DUT
slverr = pslverr;
psel = 0; penable = 0;
endtask
// ── Caller reads the output ports after task returns ─────────────
logic [31:0] read_val;
logic err;
apb_read(32'h1000, read_val, err); // read_val and err updated on return
$display("Read: %h err=%b", read_val, err);
// ── inout: a counter that increments across multiple calls ────────
task automatic send_and_count (
input logic [7:0] data,
inout int tx_count // read current count, increment, write back
);
@(posedge clk); tx_reg = data;
tx_count = tx_count + 1; // inout: read AND write
endtask
int total_sent = 0;
send_and_count(8'hAA, total_sent); // total_sent = 1 after call
send_and_count(8'hBB, total_sent); // total_sent = 2 after call| Direction | Data Flow | Inside Task Can... | Use When |
|---|---|---|---|
input | Caller → Task (copy-in) | Read only — cannot assign | Passing stimulus, addresses, data to send |
output | Task → Caller (copy-out) | Write only — undefined until written | Returning read data, status, error flags |
inout | Both directions | Read AND write | Incrementing a shared counter, modifying a state variable |
ref | Shared alias — no copy | Read AND write (immediate) | Large arrays, when caller must see updates as they happen (covered in 6.5) |
Complete Protocol Driver — APB Bus
Here is a production-quality APB (Advanced Peripheral Bus) driver with separate write and read tasks, proper handshaking, error checking, and a watchdog timeout — exactly as you would write it in a real project.
// ── APB interface signals (module-level) ─────────────────────────
logic pclk, presetn;
logic psel, penable, pwrite, pready, pslverr;
logic [31:0] paddr, pwdata, prdata;
localparam int TIMEOUT_CYCLES = 1000;
// ── APB Write task ────────────────────────────────────────────────
task automatic apb_write (
input logic [31:0] addr,
input logic [31:0] data,
output logic err
);
int timeout_cnt = 0;
// ① SETUP phase — drive address and control
@(posedge pclk);
psel = 1; paddr = addr; pwdata = data; pwrite = 1; penable = 0;
// ② ACCESS phase — assert penable
@(posedge pclk);
penable = 1;
// ③ Wait for PREADY with timeout protection
@(posedge pclk);
while (!pready && timeout_cnt < TIMEOUT_CYCLES) begin
@(posedge pclk);
timeout_cnt++;
end
if (timeout_cnt == TIMEOUT_CYCLES)
$fatal(1, "APB write timeout at addr=0x%h", addr);
// ④ Capture result, deassert bus
err = pslverr;
psel = 0; penable = 0; pwrite = 0;
if (err) $error("APB SLVERR on write to 0x%h", addr);
endtask
// ── APB Read task ─────────────────────────────────────────────────
task automatic apb_read (
input logic [31:0] addr,
output logic [31:0] rdata,
output logic err
);
int timeout_cnt = 0;
@(posedge pclk);
psel = 1; paddr = addr; pwrite = 0; penable = 0;
@(posedge pclk);
penable = 1;
@(posedge pclk);
while (!pready && timeout_cnt < TIMEOUT_CYCLES) begin
@(posedge pclk); timeout_cnt++;
end
if (timeout_cnt == TIMEOUT_CYCLES) $fatal(1, "APB read timeout");
rdata = prdata;
err = pslverr;
psel = 0; penable = 0;
endtask
// ── Test sequence using the tasks ─────────────────────────────────
initial begin
logic [31:0] rd_data;
logic wr_err, rd_err;
wait_cycles(5); // wait 5 clocks after reset
apb_write(32'h0000_0010, 32'hCAFE_0001, wr_err);
apb_read (32'h0000_0010, rd_data, rd_err);
$display("Readback: %h (err=%b)", rd_data, rd_err);
$finish;
endDisabling a Task — Stopping Mid-Execution
Sometimes you need to abort a running task before it finishes — for example, when a timeout fires, when an error is detected, or when a fork-join branch completes and you no longer need the other branch. SystemVerilog provides disable task_name; to immediately terminate a named task.
// ── Named task that can be disabled ──────────────────────────────
task automatic long_transaction;
begin : txn_body // ← named block inside task
@(posedge clk); drive_addr();
@(posedge clk); drive_data();
repeat(10) @(posedge clk); // long wait
capture_response();
end
endtask
// Disable from outside — stops the task immediately
initial begin
fork
long_transaction(); // runs until disabled
begin
#500;
disable long_transaction; // abort after 500 time units
end
join
end
// ── disable fork — stop ALL threads spawned by fork ──────────────
initial begin
fork
drive_stimulus();
monitor_bus();
begin
#10000;
$display("Simulation timeout!");
disable fork; // kills all threads in this fork
end
join_any // exit when any one thread finishes
disable fork; // clean up remaining threads
endFork-Join with Tasks — Parallel Execution
Tasks can be launched in parallel using fork...join, fork...join_any, and fork...join_none. Each thread in the fork runs its own task concurrently.
// ── fork...join: wait for ALL tasks ─────────────────────────────
fork
drive_master_port(); // both tasks start at the same time
drive_slave_port();
join // resume only when BOTH finish
$display("Both ports done");
// ── fork...join_any: race to finish + cleanup ────────────────────
fork
wait_for_ack(); // waits until ack received
begin
#5000;
$display("TIMEOUT waiting for ack!");
end
join_any // fires when FIRST branch completes
disable fork; // kill the other branch
$display("Ack received OR timeout");
// ── fork...join_none: fire and forget ───────────────────────────
fork
background_monitor(); // runs in background — never blocks caller
join_none // caller continues immediately
$display("Monitor started in background");
// ── Practical: run multiple transactions in parallel ─────────────
initial begin
logic e0, e1, e2;
fork
apb_write(32'h0, 32'h1, e0); // 3 writes in parallel
apb_write(32'h4, 32'h2, e1);
apb_write(32'h8, 32'h3, e2);
join // all three must finish
$display("All writes done: errs=%b%b%b", e0, e1, e2);
endTasks Inside Classes — UVM Foundation
Tasks are not limited to modules — they can be defined inside class bodies. This is the foundation of UVM: every driver, every monitor, and every sequence is a class with tasks as its primary interface. Class tasks declared in a class are always automatic by default.
// ── Class-based driver: tasks as primary interface ────────────────
class ApbDriver;
// Interface handle (virtual interface — not covered yet)
virtual apb_if vif;
// Tasks are always automatic inside a class
task write (input logic [31:0] addr, data,
output logic err);
@(posedge vif.pclk);
vif.psel = 1; vif.paddr = addr;
vif.pwdata = data; vif.pwrite = 1;
@(posedge vif.pclk);
vif.penable = 1;
@(posedge vif.pclk);
err = vif.pslverr;
vif.psel = 0; vif.penable = 0;
endtask
task run(); // called by the testbench to start the driver
logic [31:0] stimulus[$]; // stimulus queue
logic wr_err;
foreach (stimulus[i])
write(32'h1000 + i, stimulus[i], wr_err);
endtask
endclass
// Usage in testbench:
ApbDriver drv = new();
drv.vif = vif_handle;
drv.run(); // task call on a class objectCommon Mistakes
// ════ MISTAKE 1: Reading an output port inside the task ══════════
task automatic bad1 (input logic a, output logic b);
if (b == 1) ...; // ❌ reading 'b' before writing it — undefined
endtask
// ✅ FIX: write to output first, or use inout if you need both
task automatic good1 (input logic a, inout logic b);
if (b == 1) ...; // ✅ inout: read current value, then write new value
b = a;
endtask
// ════ MISTAKE 2: Missing automatic — shared static variables ══════
task bad2 (input int n); // ❌ static: concurrent calls share 'n'
repeat(n) @(posedge clk);
endtask
// ✅ FIX: always use automatic for testbench tasks
task automatic good2 (input int n); // ✅ each call gets its own copy of 'n'
repeat(n) @(posedge clk);
endtask
// ════ MISTAKE 3: Using task in always_comb ════════════════════════
always_comb begin
apb_write(32'h0, data, err); // ❌ task in always_comb — timing violation
end
// ✅ FIX: tasks belong in initial/always blocks, not always_comb
initial begin
apb_write(32'h0, data, err); // ✅ correct context
end
// ════ MISTAKE 4: Calling fork in join_any without disable fork ════
fork
wait_ack();
#5000;
join_any
// ❌ the losing branch still runs in the background!
// ✅ FIX: always disable fork after join_any
fork
wait_ack();
#5000;
join_any
disable fork; // ✅ kills the background thread cleanlyQuick Reference — Task Cheat Sheet
// ── Task declaration ─────────────────────────────────────────
task automatic task_name (
input <type> in_arg, // caller → task (read-only inside)
output <type> out_arg, // task → caller (write-only inside)
inout <type> io_arg // both ways
);
// timing controls, logic, other task/function calls
endtask [: task_name]
// ── Timing controls ──────────────────────────────────────────
#10; // wait 10 time units
@(posedge clk); // wait for rising clock edge
@(negedge clk); // wait for falling clock edge
@(valid); // wait for any change on 'valid'
wait(ready == 1); // level-sensitive wait
repeat(n) @(posedge clk); // wait N clock cycles
// ── Calling a task ───────────────────────────────────────────
task_name(arg1, arg2); // standalone statement — NOT in expression
// ── Parallel execution ───────────────────────────────────────
fork
task_a(); task_b();
join // wait for ALL
join_any // wait for FIRST; then disable fork;
join_none // return immediately; threads background
// ── Stopping tasks ───────────────────────────────────────────
disable task_name; // kill a named task
disable fork; // kill all forked threads
// ── Key rules ────────────────────────────────────────────────
// • Always use 'automatic' for testbench tasks
// • Tasks have no return value — use output/inout/ref ports
// • Tasks cannot be used inside expressions
// • Tasks are not synthesisable (if they contain timing)
// • Always 'disable fork' after 'join_any'Simulation Mechanics — How the Simulator Executes a Task
Understanding what the simulator actually does when it encounters a task call separates engineers who merely write tasks from engineers who can debug them under pressure. A task is not a subroutine in the C sense — it is a suspendable process that participates in the event-driven simulation schedule alongside every other process in the design.
The APB Write Timing Walk-Through
Let us trace exactly what happens during a three-clock APB write transaction. Each @(posedge clk) suspends the task process and releases the simulator to advance time and evaluate everything else. The walk-through below shows the actual signal activity that results.
// clk ____/‾‾\____/‾‾\____/‾‾\____/‾‾\____
// psel ________/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\________
// penable ________________/‾‾‾‾‾‾‾‾\________
// pready ________________________/‾‾‾‾\____
// Process RUN▸SUSP@1 RUN▸SUSP@2 RUN▸SUSP@3 RUN→RET
// │ │ │ │
// T=0 T=10 T=20 T=30 (ns)Delta Cycles: Zero-Time Ripple Evaluation
Within a single simulation timestamp, combinational logic re-evaluates in delta cycles — zero-duration steps that settle propagation ripple. If you drive a signal and immediately read a combinational output that depends on it, you may read the old value. This is the subtlest task-related bug in testbench writing.
// DUT has a combinational path: data_out = f(data_in)
logic [7:0] data_in, data_out;
assign data_out = data_in ^ 8'hFF; // combinational — updates in delta cycle
task automatic drive_and_check_wrong();
data_in = 8'h0A; // drive at time T, delta 0
// ❌ data_out still reflects OLD value here — assign hasn't fired yet
$display("[WRONG] T=%0t: data_out=%h", $time, data_out);
// data_out updates at T, delta 1 — we read it at T, delta 0
endtask
task automatic drive_and_check_correct();
data_in = 8'h0A;
#0; // ✅ advance 1 delta cycle (zero real time)
// Now assign has fired — data_out = 0x0A ^ 0xFF = 0xF5
$display("[CORRECT] T=%0t: data_out=%h", $time, data_out);
endtask
task automatic drive_and_check_best();
@(posedge clk);
data_in = 8'h0A;
@(posedge clk); // ✅ best: wait full cycle — combinational fully settled
$display("[BEST] stable: data_out=%h", data_out);
endtask
// Expected output:
// [WRONG] T=5: data_out=xx (or old value — tool-dependent)
// [CORRECT] T=5: data_out=f5
// [BEST] T=15: data_out=f5The Static Task Bug — A Real Silicon Project Debugging Story
This is the bug that haunts every verification engineer who forgets the automatic keyword. It does not crash your simulation — it silently produces wrong results. It appears intermittently. It disappears when you add $display statements to debug it. It took three days to find on a real SoC project. Here is exactly how it works.
task apb_write (
// No 'automatic' keyword!
input logic [31:0] addr,
input logic [31:0] data
);
// local_addr is STATIC — one copy
// shared by ALL concurrent calls
logic [31:0] local_addr;
local_addr = addr; // ← Thread A writes 0x100
@(posedge clk); // ← SUSPEND (Thread B runs)
// Thread B writes local_addr = 0x200
// Thread A resumes — local_addr is NOW 0x200!
paddr = local_addr; // ← WRONG: sends 0x200 not 0x100
@(posedge clk);
endtask
fork
apb_write(32'h100, 32'hAABB); // Thread A
apb_write(32'h200, 32'hCCDD); // Thread B
join
// Both writes go to 0x200!task automatic apb_write (
input logic [31:0] addr,
input logic [31:0] data
);
// local_addr is AUTOMATIC — each call
// gets its own private copy on the stack
logic [31:0] local_addr;
local_addr = addr; // Thread A stores 0x100
@(posedge clk); // SUSPEND
// Thread B has ITS OWN local_addr = 0x200
// Thread A resumes — its local_addr is still 0x100
paddr = local_addr; // ← CORRECT: 0x100
@(posedge clk);
endtask
fork
apb_write(32'h100, 32'hAABB); // Thread A
apb_write(32'h200, 32'hCCDD); // Thread B
join
// Each goes to its correct address.Real Verification Architecture — How Tasks Compose a Testbench
In production verification environments, tasks are the building blocks of the entire testbench infrastructure. Every testbench component — clock generator, reset sequencer, bus driver, bus monitor, scoreboard checker — is ultimately a set of tasks calling each other. Understanding how they fit together is the foundation for reading and writing UVM environments.
| Testbench Component | Primary Task | Timing Profile | Purpose |
|---|---|---|---|
| Clock Generator | gen_clock() | Infinite loop with #delay | Drives the design clock forever |
| Reset Sequencer | apply_reset() | N clock cycles | Assert/deassert reset at startup |
| Bus Driver | write(), read() | 3–N clock cycles per transaction | Drives protocol-correct bus transactions |
| Bus Monitor | monitor_bus() | Infinite loop, samples each cycle | Captures all bus activity non-intrusively |
| Scoreboard | check_result() | Zero-time (no delays) | Compares expected vs actual — zero-time because it only reads signals |
| Timeout Watchdog | inline fork branch | #MAX_TIME then $fatal | Kills simulation if it hangs |
// ── Complete testbench skeleton: clock, reset, driver, monitor ────
module tb_top;
localparam CLK_PERIOD = 10; // ns
localparam RESET_CYCLES = 8;
localparam MAX_SIM_TIME = 100_000;
logic clk, rst_n;
logic psel, penable, pwrite, pready, pslverr;
logic [31:0] paddr, pwdata, prdata;
// ── ① Clock generator ──────────────────────────────────────────
task automatic gen_clock;
clk = 0;
forever #(CLK_PERIOD/2) clk = ~clk;
endtask
// ── ② Reset sequencer ──────────────────────────────────────────
task automatic apply_reset;
rst_n = 1'b0;
repeat(RESET_CYCLES) @(posedge clk);
@(negedge clk); // deassert on falling edge — avoids setup/hold issues
rst_n = 1'b1;
repeat(2) @(posedge clk); // 2-cycle post-reset settling
endtask
// ── ③ APB Bus driver ───────────────────────────────────────────
task automatic apb_write(
input logic [31:0] addr, data,
output logic err
);
int timeout = 0;
@(negedge clk); // drive on negedge to avoid setup violations
psel = 1; paddr = addr; pwdata = data; pwrite = 1; penable = 0;
@(negedge clk); penable = 1;
@(posedge clk);
while (!pready && ++timeout < 100) @(posedge clk);
if (timeout == 100) $fatal(1, "APB write timeout @ 0x%h", addr);
err = pslverr;
@(negedge clk); psel = 0; penable = 0; pwrite = 0;
endtask
// ── ④ Bus monitor (runs forever in background) ─────────────────
task automatic bus_monitor;
forever begin
@(posedge clk);
if (psel && penable && pready) begin
if (pwrite)
$display("%0t [MON] WRITE addr=%h data=%h err=%b",
$time, paddr, pwdata, pslverr);
else
$display("%0t [MON] READ addr=%h data=%h err=%b",
$time, paddr, prdata, pslverr);
end
end
endtask
// ── ⑤ Timeout watchdog ─────────────────────────────────────────
task automatic watchdog;
#MAX_SIM_TIME;
$fatal(1, "Simulation timeout at %0t ns", $time);
endtask
// ── ⑥ Top-level test ───────────────────────────────────────────
initial begin
logic [31:0] rd_val;
logic wr_err, rd_err;
fork
gen_clock(); // background — runs forever
bus_monitor(); // background — runs forever
watchdog(); // background — kills if test hangs
join_none // launch all three, don't wait
apply_reset(); // blocks until reset complete
apb_write(32'h0000_0100, 32'hCAFE_F00D, wr_err);
apb_write(32'h0000_0104, 32'h1234_5678, wr_err);
$display("[TEST] All writes done. Simulation PASS.");
$finish;
end
endmoduleAdvanced Task Patterns Used in Real Projects
Beyond the basics, experienced verification engineers use a set of well-established task patterns that appear repeatedly across projects. Recognising these patterns and knowing when to apply them is a hallmark of a senior DV engineer.
Pattern 1 — Timeout-Protected Transaction Wrapper
// ── Reusable timeout wrapper: run any task with a deadline ────────
task automatic run_with_timeout (
input int timeout_cycles,
input string label
);
int elapsed;
fork
begin : work
apb_write(32'h1000, 32'hABCD, err);
// add more work here — whatever the transaction is
end
begin : watchdog
elapsed = 0;
while (elapsed++ < timeout_cycles) @(posedge clk);
$error("%s timed out after %0d cycles", label, timeout_cycles);
end
join_any // whichever finishes first
disable fork; // kill the other branch cleanly
endtaskPattern 2 — Self-Checking Task
task automatic write_and_verify (
input logic [31:0] addr,
input logic [31:0] exp_data
);
logic [31:0] rd_data;
logic wr_err, rd_err;
apb_write(addr, exp_data, wr_err); // write
apb_read (addr, rd_data, rd_err); // read back
if (wr_err || rd_err)
$error("Bus error: wr=%b rd=%b @ 0x%h", wr_err, rd_err, addr);
else if (rd_data !== exp_data)
$error("MISMATCH @ 0x%h: wrote 0x%h got 0x%h", addr, exp_data, rd_data);
else
$display("PASS @ 0x%h: 0x%h", addr, rd_data);
endtaskPattern 3 — Mailbox-Driven Driver (UVM Preview)
// A mailbox decouples transaction generation from bus driving.
// The generator puts transactions in; the driver task pulls and sends.
mailbox #(logic [31:0]) tx_mbx = new(0); // unbounded mailbox
task automatic mailbox_driver;
logic [31:0] data;
logic err;
int idx = 0;
forever begin
tx_mbx.get(data); // blocks until item available
apb_write(32'h2000 + idx * 4, data, err);
if (err) $error("Write error on txn %0d", idx);
idx++;
end
endtask
task automatic stimulus_generator;
foreach (logic [31:0] payload[] = '{32'h1, 32'h2, 32'h3, 32'h4}) begin
tx_mbx.put(payload); // non-blocking put into mailbox
end
endtaskPattern 4 — Retry with Back-off
task automatic apb_write_retry (
input logic [31:0] addr, data,
input int max_retries = 3,
output logic final_err
);
logic err;
int attempt = 0;
do begin
apb_write(addr, data, err);
if (err && attempt < max_retries) begin
$warning("SLVERR on attempt %0d — retrying", attempt);
repeat(4 * (attempt + 1)) @(posedge clk); // exponential back-off
end
end while (err && ++attempt <= max_retries);
final_err = err;
endtaskDebugging Academy — 5 Real Task Bugs Dissected
Every bug below has been observed in real verification projects. Each one is preceded by a symptom description that mirrors what you would actually see in simulation before you know the root cause. Work through each one as a diagnostic exercise before reading the explanation.
Static Variable Corruption in Parallel Fork Threads
SEVERITY: HIGHScoreboard reports register mismatches for 3 of 10 parallel writes. The failing addresses change across regression runs. Adding a $display after the address assignment makes the failures disappear. Waveform shows paddr toggling correctly but the scoreboard sees transactions going to the wrong addresses.
task apb_write(input logic [31:0] addr, data); — missing the automatic keyword. All local variables including the addr copy-in register are stored in a single static location shared by all concurrent calls.
Static tasks have a single storage location for all local variables. When two concurrent calls both execute local_addr = addr but are then interleaved at the @(posedge clk) suspension point, the second call overwrites the value that the first call stored. On resumption, the first call reads the corrupted value.
task automatic apb_write(...) — each call now gets its own private stack frame. Arguments and locals are independent across all concurrent invocations.
Ghost Thread Scoreboard Errors After Test Completes
SEVERITY: MEDIUMTest appears to pass — "$finish reached, test PASS" is printed. But 200 ns later, the simulation suddenly prints "$error: TIMEOUT waiting for ack!" and terminates abnormally. CI marks the test as FAIL.
The join_any fires when wait_for_ack() finishes but the timeout branch is still running in the background. It fires 500 ns after the test finishes.
fork
wait_for_ack(); // completes at T=500
begin
#1000; // ← timeout branch, never killed!
$error("TIMEOUT waiting for ack!");
end
join_any
// ❌ Missing: disable fork;
$display("Test PASS"); $finish;join_any only exits the join construct when the first thread finishes — it does not kill the other threads. They continue running as background processes. Even after $finish, some simulators will still execute pending background events before terminating.
Add disable fork; immediately after every join_any. This is an unconditional rule: join_any is always followed by disable fork.
Simulation Deadlock — wait() on a Signal Never Driven
SEVERITY: HIGHSimulation runs forever. Memory usage grows. No output appears after "apply_reset() complete". Ctrl-C in Questa shows the active process is suspended at wait(dut.status_valid == 1). The simulation time has stopped advancing.
dut.status_valid is driven by a register block that is only enabled when a specific mode bit is set. The test forgot to write the mode register before calling this task.
task automatic wait_for_status;
wait(dut.status_valid == 1); // ← blocks forever
@(posedge clk);
endtaskwait() on a condition that is never satisfied causes the process to suspend permanently. Unlike an event wait (@signal), wait() does not time out — it waits indefinitely. With no other active processes to advance time, the simulation stalls.
Two-part fix: (1) Write the mode register before calling the task. (2) Add a timeout wrapper around every wait():
fork
begin wait(dut.status_valid); end
begin #MAX_WAIT; $fatal(1, "status_valid never asserted"); end
join_any
disable fork;Race Condition: Output Argument Captured One Cycle Too Early
SEVERITY: MEDIUMAn APB read task occasionally returns stale data. The waveform shows prdata is correct one cycle after the task captures it — as if the task is sampling one clock cycle too early. The bug disappears when simulation runs with a slower clock or with +delay options enabled.
The task samples pready and prdata on the same clock edge it asserted penable. The DUT's response requires one pipeline stage — it asserts pready on the following posedge.
task automatic apb_read_buggy(...);
@(posedge pclk);
penable = 1;
@(posedge pclk);
// ❌ Sampling pready and prdata on the SAME posedge
// that drives penable. DUT needs one cycle to respond.
if (pready) rdata = prdata;
endtaskSimulation-time race: both the testbench and the DUT's always_ff respond to the same posedge in the same delta-cycle step. The testbench reads prdata before the DUT's flip-flops have updated it. This is the "testbench reads before RTL updates" race class.
Sample on the next posedge:
@(posedge pclk); penable = 1;
@(posedge pclk); // wait for DUT response
rdata = prdata;Alternatively, drive on negedge and sample on posedge for maximum setup margin. This is the standard APB protocol timing.
output Argument Holds X When Task Is disable-d Mid-Execution
SEVERITY: LOWAfter a fork...join_any with a timeout branch, the caller reads the task's output argument and sees X. The checker flags a spurious failure. The task never reached the line where it wrote to the output port.
The caller's rdata variable is never written because the task was killed by disable fork before reaching the assignment.
task automatic apb_read(output logic [31:0] rdata, output logic err);
@(posedge clk); psel = 1; ...
@(posedge clk); penable = 1;
@(posedge clk);
// timeout fires HERE — task killed before this line:
rdata = prdata; // ← never executed
err = pslverr; // ← never executed
endtaskoutput arguments are only copied out when the task returns normally. If a task is killed by disable, the copy-out never happens. The caller's variables retain whatever value they had before the call (which may be X for uninitialized logic).
Initialise output arguments at declaration time:
task automatic apb_read(
output logic [31:0] rdata = '0,
output logic err = 1'b1,
output logic completed = 1'b0
);
// ... transaction body ...
completed = 1'b1; // ← set at the very end
endtaskThen even if the task is killed, the caller sees a deterministic default rather than X. The extra completed flag tells the caller whether the task finished normally.
Interview Q&A — Tasks & Timing Controls
The critical difference is time consumption. A function is zero-time — it evaluates combinationally and returns in the same simulation step it was called. A task can consume simulation time by containing timing controls (@, #, wait). Structurally: functions have a return value and only accept input arguments; tasks have no return value and accept input, output, and inout. Tasks can call functions; functions cannot call tasks (because tasks can advance time, breaking the zero-time constraint of functions).