SystemVerilog · Module 5
break, continue, return, disable
Loop control — synthesis vs simulation behavior.
Module 5 · Page 5.5
break — Exit a Loop Early
break immediately exits the innermost loop containing it. Execution resumes at the statement after the end of that loop. It works inside for, while, do-while, repeat, and forever.
In synthesisable RTL, break is valid as long as the enclosing loop can still be statically unrolled — the tool unrolls the loop and inserts logic equivalent to stopping iteration at the right point.
// ── Find first set bit (priority encoder) ───────────────────────
always_comb begin
first_set = 4'hF; // default: no bit found
for (int i = 0; i < 8; i++) begin
if (data[i]) begin
first_set = i[3:0];
break; // stop as soon as first '1' found
end
end
end
// ── Testbench: stop listening once error seen ───────────────────
initial begin
for (int pkt = 0; pkt < 1000; pkt++) begin
@(posedge clk);
if (error_flag) begin
$error("Error at packet %0d", pkt);
break; // no point sending more after an error
end
drive_packet(pkt);
end
end
// ── break in nested loops: only exits the innermost loop ────────
initial begin
for (int r = 0; r < 4; r++) begin // outer loop continues
for (int c = 0; c < 4; c++) begin
if (c == 2) break; // exits inner loop at c=2
$display("[%0d][%0d]", r, c); // prints [r][0] and [r][1] only
end
end
end🏗 Synthesis Insight: What break Produces in Hardware
When synthesis unrolls a for loop containing a break, it does not generate hardware that stops executing — there is no "halt" concept in gates. Instead, it generates a found flag that propagates through the unrolled iterations, gating all subsequent assignments. The hardware is equivalent to: iterate all N positions, but only commit the result of the first match. For a priority encoder with break, synthesis produces a priority tree — identical to writing it with cascaded if-else if. The unrolling itself is covered in Loops. The loop with break is just a more readable way to express the same hardware intent.
🧠 How the Simulator Handles break in always_comb
In simulation, break inside always_comb causes the for loop to terminate at that iteration. Subsequent iterations do not execute. Since the entire always_comb block runs in zero simulation time (see Procedural Blocks), this early exit saves simulation CPU cycles — a loop with break is faster to simulate than one that runs all iterations. The output is computed correctly because the default assignment before the loop covers all positions that were not reached.
continue — Skip to the Next Iteration
continue skips the rest of the current loop body and jumps directly to the next iteration check. The loop itself does not exit — only the current iteration is cut short. Think of it as "skip this one, try the next."
// ── Sum only even-indexed elements ──────────────────────────────
always_comb begin
even_sum = '0;
for (int i = 0; i < 8; i++) begin
if (i[0]) continue; // skip odd indices (i[0]=1 when odd)
even_sum += data[i];
end
end
// ── Testbench: log only valid transactions ───────────────────────
initial begin
for (int i = 0; i < 100; i++) begin
@(posedge clk);
if (!valid) continue; // skip idle cycles
$display("[%0t] data=%0h", $time, data);
end
end
// ── continue vs break side-by-side ──────────────────────────────
initial begin
for (int i = 0; i < 5; i++) begin
if (i == 3) continue; // prints 0,1,2,4 (skips 3)
$display("i = %0d", i);
end
for (int i = 0; i < 5; i++) begin
if (i == 3) break; // prints 0,1,2 (stops at 3)
$display("i = %0d", i);
end
end🚀 RTL Design Insight: continue Is a Readable Way to Express Selective Logic
In synthesis, continue inside a for loop generates an if condition that gates the body of that iteration. if(i[0]) continue; sum += data[i]; is identical to if(!i[0]) sum += data[i]; in hardware. The continue version reads as "skip odd indices" — the intent is clearer. (What unrolling does to the surrounding loop is covered in Loops.) Both produce identical netlists. The choice between them is purely a code-readability decision. Use whichever makes the filtering condition most obvious to the next engineer reading the code.
return — Exit a Function or Task
return exits the current function or task immediately. In a function, you can optionally provide a return value: return expr;. In a task, return takes no value — it just exits the task.
// ── return with value in a function ─────────────────────────────
function automatic int clz(input logic [7:0] data);
for (int i = 7; i >= 0; i--)
if (data[i]) return 7 - i; // count leading zeros from MSB
return 8; // all zeros → 8
endfunction
// ── return in a task (no value) ─────────────────────────────────
task automatic send_packet(input int size);
if (size <= 0) begin
$warning("send_packet: invalid size %0d, aborting", size);
return; // early exit, no further execution
end
for (int b = 0; b < size; b++) begin
@(posedge clk);
data = $random;
valid = 1;
end
valid = 0;
endtask
// ── Multiple return paths (guard clauses pattern) ───────────────
function automatic logic [7:0] saturate_add(
input logic [7:0] a, b);
logic [8:0] sum = a + b;
if (sum[8]) return 8'hFF; // overflow → saturate at max
return sum[7:0]; // normal result
endfunction💡 Senior Verification Engineer Tip: return as a Guard Clause Pattern
The guard clause pattern — using early return to reject invalid inputs at the top of a function — dramatically improves readability compared to deeply-nested if blocks. Instead of if(valid) { if(size>0) { ... } }, write if(!valid) return; if(size<=0) return; followed by the main logic. This pattern is widely used in production UVM driver code (see UVM Sequence Items for the transactions such a driver consumes): each guard clause handles one error condition, the main logic at the bottom only runs with valid inputs, and each return exit point can have its own $error message for clear debugging.
disable — Kill a Named Block or Task
disable has two forms:
disable label;— immediately terminates the labelled block or named task.disable fork;— terminates all active descendant processes of the calling process. Not just the immediate children, and not just the threads of theforkblock it is written in. The scope is the calling process's whole subtree, which is broader than most code that uses it assumes — see the callout below.
disable is primarily a simulation-only construct. It is not synthesisable. Use it in testbenches and verification environments.
// ── disable label: exit a named block ──────────────────────────
initial begin
// label a named block with: begin : label_name
begin : search_block
for (int i = 0; i < 16; i++) begin
if (mem[i] == target) begin
found_idx = i;
disable search_block; // jump out of the named block
end
end
found_idx = -1; // not reached if disable fires
end
end
// ── disable fork: kill spawned threads ──────────────────────────
initial begin
fork
begin : timeout_proc
#10_000; // watchdog: 10 000 time units
$fatal("TIMEOUT");
end
begin : main_proc
run_test(); // actual test sequence
end
join_any
disable fork; // kill whichever thread didn't finish
end
// ── disable task (kills an executing named task) ────────────────
task automatic long_driver();
for (int i = 0; i < 1000; i++) begin
@(posedge clk);
data = i;
end
endtask
initial begin
fork
long_driver();
join_none
#200;
disable long_driver; // abort task after 200 time units
end🔍 Debugging Insight: disable fork Is Stateful — It Kills ALL Current Children
disable fork terminates all active descendant processes of the calling process — IEEE 1800 defines it over descendants, not over children, and not over the fork block that textually encloses it. Both of those distinctions matter in practice.
Descendants, not children. If this process called a task, and that task spawned threads of its own, those threads are grandchildren of this process and disable fork kills them too. The code being terminated may be in a different file, written by someone else, and entirely unaware that a caller several frames up is about to stop it.
All outstanding descendants, not just this fork block's. A thread left running by an earlier fork ... join_none in the same process is still a descendant, so it dies here as well — even though it has nothing to do with the fork ... join_any the disable fork was written to clean up.
Neither of those is a defect in the language; it is what "terminate my subtree" means, and at end-of-test it is exactly the behaviour you want. It becomes a bug when disable fork is used mid-test to stop one thread, because it is not a mechanism for stopping one thread.
When you mean one thread, name it. Wrap it as fork begin : my_thread ... end join_none and use disable my_thread. That terminates precisely that block, and it does not reach anything else. Reserve disable fork for the cases where killing everything outstanding is the actual intent — cleanup after a join_any, or teardown at the end of a test. Debug Lab 5 is the mid-test misuse, and it cost two weeks of misattributed coverage.
The process tree — what "descendant" actually covers
The scope of disable fork is easier to trust once you have watched it. This is short enough to run, and the output settles the question.
// disable_fork_scope.sv - what does "descendant" include?
//
// Structure built below:
//
// main process
// |-- A (fork...join_none, earlier in the process)
// |-- B (child, from the fork...join_any)
// `-- C (child, from the fork...join_any)
// `-- D (GRANDCHILD - spawned inside a task C called)
//
// disable fork is executed by the MAIN process. Which of A, B, C, D die?
module disable_fork_scope;
task automatic spawns_a_helper(string who);
fork
begin : helper // D - a grandchild of main
forever begin #10; $display("[%0t] %s helper alive", $time, who); end
end
join_none
endtask
initial begin
// A: left running by an EARLIER fork...join_none in this same process.
fork
begin : early_thread
forever begin #10; $display("[%0t] A (early join_none) alive", $time); end
end
join_none
#5;
fork
// B: an ordinary child that never finishes on its own.
begin : b_thread
forever begin #10; $display("[%0t] B alive", $time); end
end
// C: a child that spawns D and then finishes quickly. Note that C
// itself COMPLETES - but D keeps running after C is gone.
begin : c_thread
spawns_a_helper("D (grandchild)");
#20;
$display("[%0t] C finished normally", $time);
end
join_any // unblocks when C completes
$display("[%0t] --- main calls disable fork ---", $time);
disable fork;
#40;
$display("[%0t] survivors above this line are the ones NOT killed", $time);
$finish;
end
endmoduleOutput (times abbreviated):
15 A (early join_none) alive
15 B alive
15 D (grandchild) helper alive
25 A (early join_none) alive
25 B alive
25 D (grandchild) helper alive
25 C finished normally
25 --- main calls disable fork ---
65 survivors above this line are the ones NOT killed
A, B and D all stop at T=25.Read the result carefully, because two of the three are the ones people get wrong.
B dies — expected. It is an immediate child from the fork block enclosing the disable fork.
D dies — and D is a grandchild, spawned inside a task that C called. Nothing in the initial block mentions it. It is terminated because it is a descendant of main, which is what the language specifies.
A dies — and A came from a different fork ... join_none, earlier in the process, with no relationship to the fork ... join_any the disable fork was written to clean up. It is terminated for the same reason: it is still an active descendant.
That is the whole hazard in one output. disable fork is a statement about the calling process's subtree, not about the nearest fork block, and the code it kills may be somewhere you would never think to look.
Choosing among the process-control constructs
| Construct | Blocks the caller? | Effect on the spawned threads | Typical use |
|---|---|---|---|
join | until all threads finish | none — all run to completion | parallel work that must all complete |
join_any | until one finishes | the rest keep running | race two alternatives; usually followed by cleanup |
join_none | not at all | all continue in the background | launch monitors and daemons |
wait fork; | until all descendants finish | none — waits, does not terminate | drain outstanding work before ending a phase |
disable fork; | no | terminates all descendants | cleanup after join_any; teardown at end of test |
disable label; | no | terminates that named block only | stop one specific thread |
Two pairings account for most correct usage. join_any then disable fork is the timeout-watchdog idiom: race the work against a timer, take whichever finishes, kill the loser. join_none then wait fork is the drain idiom: launch background work, carry on, then block until it has all finished before tearing anything down.
The mistake to avoid is reaching for disable fork when you mean disable label. Both compile; only one is a statement about a single thread. wait fork and disable fork share the same descendant scope as each other, so the reasoning above about grandchildren and earlier join_none threads applies equally to what wait fork will wait for. For the full treatment of these two, see disable & wait fork; for the join variants themselves, see fork-join.
Quick Reference
| Keyword | Applies to | Effect | Synthesisable? |
|---|---|---|---|
break | Loops (for, while, do-while, repeat, forever) | Exits innermost loop immediately | Yes, if loop unrolls |
continue | Loops | Skips rest of current iteration, jumps to next | Yes, if loop unrolls |
return | Functions and tasks | Exits function/task; function can return a value | Yes |
disable label; | Named blocks, named tasks | Terminates the named scope and all statements inside it | No — simulation only |
disable fork; | All active descendants of the calling process | Terminates the calling process's entire subtree — grandchildren included, and any thread left running by an earlier join_none | No — simulation only |
🏗 Synthesis Behavior — What the Tool Generates
Understanding what synthesis produces from each control keyword prevents surprises during gate-level simulation and timing analysis. The hardware generated is often less obvious than the simulation behavior.
| Keyword | Synthesizable? | Condition | Hardware Generated | Sim/Synth Match? |
|---|---|---|---|---|
break | Yes | Inside a loop with constant unrollable bound | "Found" flag gates remaining iterations — priority logic | Yes — identical priority behavior |
continue | Yes | Inside a loop with constant unrollable bound | Condition gate on that iteration's body — equivalent to if(!cond) | Yes — identical selective evaluation |
return (function) | Yes | Always synthesizable in functions called from always_comb | Multiplexer selecting which return path's value drives output | Yes |
return (task) | Conditional | Only if task has no timing controls and loop bounds are static | Early-exit condition on subsequent logic | Usually yes, verify per tool |
disable label | No | Never — requires process model | N/A — simulation only | N/A |
disable fork | No | Never — requires forked thread model | N/A — simulation only | N/A |
// ── break in for loop — synthesis perspective ─────────────────────
always_comb begin
first = 4'hF; // default
for (int i = 0; i < 8; i++) begin
if (data[i]) begin
first = i[3:0];
break; // stop at first '1'
end
end
end
// ── Synthesis unrolls to equivalent hardware (NO break in netlist):
always_comb begin
logic found;
first = 4'hF; found = 0;
if (!found && data[0]) begin first = 0; found = 1; end // i=0
if (!found && data[1]) begin first = 1; found = 1; end // i=1
if (!found && data[2]) begin first = 2; found = 1; end // i=2
// ... and so on for all 8 iterations
// Hardware: cascade of AND gates checking !found — priority encoder
end
// ── continue in for loop — synthesis perspective ──────────────────
always_comb begin
even_sum = '0;
for (int i = 0; i < 8; i++) begin
if (i[0]) continue; // skip odd
even_sum += data[i];
end
end
// ── Synthesis unrolls to (hardware identical):
always_comb begin
even_sum = '0;
if (!1'b0) even_sum += data[0]; // i=0: i[0]=0, not skipped
// if (!1'b1) → constant false → data[1] never added (optimized away)
if (!1'b0) even_sum += data[2]; // i=2: i[0]=0, not skipped
// ... pattern continues
// Hardware: adder with 4 inputs (data[0,2,4,6]). No gates for odd indices.
end🔬 RTL vs Verification — Which Keyword Belongs Where
Each of these keywords has a dominant use context. Knowing which tool to reach for prevents both synthesis errors and verification logic bugs.
| Keyword | Primary RTL Use | Primary Verification Use | Never Use Here |
|---|---|---|---|
break | Priority encoder: stop scanning at first match. Find-first-set. Early-exit search in always_comb for loops. | Stop test on first error. Exit coverage-driven loop when goal met. Abort stimulus after fault injection. | Inside forever in simulation — exits the forever, ending the process unexpectedly |
continue | Selective accumulation: skip specific indices. Filtered parallel operations (sum only valid bits). | Skip idle/invalid cycles in monitor loops. Filter transactions in scoreboard loops. | Inside forever without a timing control — causes zero-time loop hang |
return | Guard clauses in synthesizable functions. Early return from function on saturate/overflow condition. | Guard clauses in driver tasks. Early exit from task on invalid parameter. Multi-path function results. | Returning from always_comb block body (return is for functions/tasks only) |
disable | Not synthesizable — never use in RTL intended for synthesis | Timeout watchdog (disable fork after join_any). Killing a concurrent stimulus thread. Aborting a running named task. | In synthesizable RTL of any kind |
⚙ RTL Patterns — Real Hardware Using break and continue
// ── Pattern 1: Priority encoder with break ────────────────────────
module prio_enc #(parameter int N = 8) (
input logic [N-1:0] req,
output logic [$clog2(N):0] grant_id,
output logic valid
);
always_comb begin
grant_id = '0;
valid = 1'b0;
for (int i = 0; i < N; i++) begin
if (req[i]) begin
grant_id = i[$clog2(N):0];
valid = 1'b1;
break; // req[0] is highest priority — stop here
end
end
end
endmodule
// break here: synthesis generates a standard priority encoder tree
// Equivalent to priority if chain — break makes the code read naturally
// ── Pattern 2: Checksum over valid bytes (continue skips invalid) ─
module valid_checksum (
input logic [7:0] data [16],
input logic valid [16], // per-byte valid mask
output logic [7:0] checksum
);
always_comb begin
checksum = 8'h00;
for (int i = 0; i < 16; i++) begin
if (!valid[i]) continue; // skip invalid bytes
checksum ^= data[i]; // XOR only valid bytes
end
end
endmodule
// continue: synthesis generates 16 AND gates (valid[i] & data[i])
// feeding an XOR tree. Invalid bytes produce 0 contribution → correct
// ── Pattern 3: Memory search with break (find match in TCAM style) ─
module cam_lookup #(parameter int DEPTH=16, WIDTH=8) (
input logic [WIDTH-1:0] key,
input logic [WIDTH-1:0] mem [DEPTH],
output logic [$clog2(DEPTH):0] match_addr,
output logic hit
);
always_comb begin
match_addr = '0;
hit = 1'b0;
for (int i = 0; i < DEPTH; i++) begin
if (mem[i] == key) begin
match_addr = i[$clog2(DEPTH):0];
hit = 1'b1;
break; // return lowest-address match
end
end
end
endmodule🛡 Return Guard Clauses — Cleaner, Safer Functions and Tasks
The guard clause pattern uses early return statements to handle edge cases and invalid inputs at the top of a function or task, before the main logic runs. It makes code flatter, more readable, and easier to test.
Deep nesting — the work is buried:
task automatic send_burst(input int len, input logic [31:0] addr);
if (len > 0) begin
if (addr != 0) begin
if (!bus_busy) begin
for (int i = 0; i < len; i++) begin // the actual work,
@(posedge clk); // three levels deep
drive_data(i);
end
end else $error("Bus busy");
end else $error("Bad addr");
end else $error("Bad len");
endtaskGuard clauses — flat, and each rejection reads on one line:
task automatic send_burst(input int len, input logic [31:0] addr);
// Reject bad inputs first. Note that nothing has been DRIVEN yet, which
// is what makes these returns safe - see Debug Lab 3 for what happens
// when an early return skips a deassertion.
if (len <= 0) begin $error("Bad len"); return; end
if (addr == 0) begin $error("Bad addr"); return; end
if (bus_busy) begin $error("Busy"); return; end
// Main logic - reached only when every guard has passed.
for (int i = 0; i < len; i++) begin
@(posedge clk);
drive_data(i);
end
endtaskBoth versions reject the same three conditions. The second puts each rejection and its message on one line, and leaves the main logic at the outermost level where it can be read without tracking which branch you are in. The ordering rule that keeps it safe: all guard clauses go above the first statement that drives anything.
// ── Saturating arithmetic with multiple return paths ──────────────
function automatic logic [7:0] sat_add8(
input logic [7:0] a, b);
logic [8:0] wide = a + b;
if (wide[8]) return 8'hFF; // overflow → saturate max
return wide[7:0]; // normal
endfunction
// Synthesis: 9-bit adder → 2:1 mux on bit[8] → saturate or pass
// ── Priority encode with guard and early return ──────────────────
function automatic logic [2:0] penc3(
input logic [7:0] req);
if (req == 8'h00) return 3'd7; // no request → sentinel value
for (int i = 0; i < 8; i++)
if (req[i]) return i[2:0]; // first set bit → priority encode
return 3'd7; // unreachable but satisfies tool
endfunction
// Guard clause (req==0 check) is synthesized as a separate 8-input NOR
// feeding a 2:1 mux that selects between sentinel and the encoder output
// ── AXI response handler with error guard ────────────────────────
function automatic logic [7:0] decode_axi_resp(
input logic [1:0] resp,
input logic valid);
if (!valid) return 8'hFF; // guard: not valid
if (resp == 2'b10) return 8'hFE; // SLVERR
if (resp == 2'b11) return 8'hFD; // DECERR
return 8'h00; // OKAY/EXOKAY
endfunction⚡ Concurrent Process Control — disable in Real Verification
disable is the verification engineer's tool for managing concurrent simulation threads. Every production testbench uses it — typically in the timeout watchdog and test cleanup patterns.
// ── Pattern 1: Timeout watchdog using fork-join_any + disable fork
module tb_with_watchdog;
initial begin
fork
begin : test_body
run_directed_test(); // main stimulus
run_random_test();
end
begin : watchdog
#1_000_000; // timeout: 1M time units
$fatal(1, "WATCHDOG: test timed out");
end
join_any
disable fork; // kill whichever thread is still running
// Control resumes here after EITHER test_body OR watchdog finishes
$display("Test complete at t=%0t", $time);
$finish;
end
endmodule
// ── Pattern 2: Coverage-driven test with disable label ────────────
initial begin
begin : coverage_test
int max_cycles = 100_000;
for (int cycle = 0; cycle < max_cycles; cycle++) begin
@(posedge clk);
drive_random_stimulus();
if (coverage_goal_met()) begin
$display("Coverage goal met at cycle %0d", cycle);
disable coverage_test; // exit the named block early
end
end
$display("Coverage NOT met after %0d cycles", max_cycles);
end
end
// ── Pattern 3: Protocol handshake with configurable timeout ───────
task automatic wait_for_handshake(
input int timeout_cycles,
output bit timed_out
);
timed_out = 0;
fork
begin : hs_wait
@(posedge clk iff (valid && ready)); // wait for handshake
end
begin : hs_timeout
repeat(timeout_cycles) @(posedge clk);
timed_out = 1;
end
join_any
disable fork; // clean up whichever thread is still waiting
if (timed_out) $error("Handshake timeout after %0d cycles", timeout_cycles);
endtaskfork-join_any + disable fork - which thread finishes first?
Scenario A: the test completes before the watchdog fires (normal exit)
time -> 0 100 200 300 400 500
test_body [===================DONE]
watchdog [==============================.....] killed at 300
join_any unblocks at T=300 when test_body finishes.
disable fork then terminates the still-running watchdog.
"Test complete" is printed. Simulation ends normally.
Scenario B: the test hangs and the watchdog fires (timeout exit)
time -> 0 100 200 300 400 500
test_body [===============================HUNG] killed at 500
watchdog [==============================TIMEOUT]
join_any unblocks at T=500 when the watchdog expires.
disable fork then terminates the hung test_body.
$fatal reports the timeout. Simulation aborts with a diagnosable failure
instead of running to the regression's wall-clock limit with no message.
The pattern's value is entirely in Scenario B: without it, a hang produces a
job killed by the grid scheduler hours later, with no indication of where.Note the scope of the disable fork in both scenarios: it terminates every outstanding descendant of this process, not only the two threads shown. If this task had earlier spawned anything with fork ... join_none, it dies here too. That breadth is what makes the pattern safe at end-of-test and dangerous mid-test — Debug Lab 5 is the mid-test case.
🔬 Advanced Verification Patterns
module tb_complete;
logic clk = 0, rst_n;
logic [7:0] data_in, data_out;
logic valid, ready, error_flag;
int pass_cnt=0, fail_cnt=0, tx_cnt=0;
// ── Clock ─────────────────────────────────────────────────────
initial forever #5 clk = ~clk;
// ── Reset ─────────────────────────────────────────────────────
initial begin
rst_n = 0;
repeat(4) @(posedge clk);
rst_n = 1;
end
// ── Stimulus driver using break and continue ──────────────────
task automatic drive_stimulus();
@(posedge rst_n);
for (int i = 0; i < 1000; i++) begin
@(posedge clk);
if (error_flag) begin
$error("Error at i=%0d — stopping stimulus", i);
break; // stop on first error
end
if (!ready) continue; // skip when DUT not ready
data_in = $urandom;
valid = 1'b1;
tx_cnt++;
end
valid = 1'b0;
endtask
// ── AXI response handler using return guard ───────────────────
task automatic check_output();
@(posedge clk);
if (!valid) return; // guard: not a valid transaction
if (error_flag) return; // guard: DUT in error state
if (data_out === ~data_in) pass_cnt++;
else begin
$error("got %h exp %h", data_out, ~data_in);
fail_cnt++;
end
endtask
// ── Main test with timeout watchdog using disable fork ────────
initial begin
fork
begin : test_main
drive_stimulus();
$display("Done: tx=%0d pass=%0d fail=%0d",tx_cnt,pass_cnt,fail_cnt);
end
begin : watchdog
#500_000;
$fatal(1, "WATCHDOG timeout");
end
join_any
disable fork;
$finish;
end
// ── Monitor (uses continue to skip non-valid cycles) ─────────
initial forever begin
@(posedge clk);
if (!valid) continue; // wait for valid — skip idle
check_output();
end
endmodule🔬 Debugging Academy — 8 Real Bugs from the Field
Every one of these compiles cleanly. Three end a process that should still be running, two produce a wrong-but-plausible answer, one hangs, one leaves the DUT stuck, and one returns X.
break inside forever exits the loop and kills the monitor process
SILENT-PROCESS-DEATH// ❌ BUG: engineer intended to stop logging on error, but break exits forever
initial begin
forever begin
@(posedge clk);
if (valid) $display("[%0t] data=%h", $time, data_out);
if (error_flag) begin
$error("Error detected");
break; // ❌ exits the forever loop entirely!
// Monitor STOPS. Error reported at T=X,
// but subsequent transactions NEVER checked.
// Test appears to pass — missing error detections.
end
end
end
// ✅ FIX 1: Don't break from monitor — just flag the error and continue
initial begin
forever begin
@(posedge clk);
if (valid) $display("[%0t] data=%h", $time, data_out);
if (error_flag) $error("Error detected"); // continue monitoring
end
end
// ✅ FIX 2: If you want to stop the test, use disable or $finish
initial begin
forever begin
@(posedge clk);
if (error_flag) begin $fatal(1, "Fatal error"); end // ends sim
end
endbreak exits the innermost enclosing loop. When that loop is forever, exiting it ends the loop permanently — and since the forever was all that remained in the initial block, the process finishes and the monitor is gone.
Nothing announces this. The process does not error, it completes, which is the normal way for a process to end. From the simulator's point of view everything is fine.
The consequence is a regression that silently under-checks. An error is reported at time X and no error is ever reported again, because there is nothing left checking. Pass and fail counts include only the transactions up to the first error; coverage stops accumulating at the same instant. The result looks almost right — a plausible number of checks, a plausible coverage figure — which is exactly what makes it survive review.
The tell is available if you look for it: a monitor's transaction count that stops advancing part-way through a test, or coverage that plateaus at a suspiciously round point in the run. In a monitor you want continue (skip this transaction, keep watching) or nothing at all — not break.
continue inside forever with no timing control produces a zero-time hang
ZERO-TIME-LOOP// ❌ BUG: continue re-enters forever body immediately (no time advance)
initial begin
forever begin
if (!valid) continue; // ❌ skips to next forever iteration
// but there's no timing before this check!
// valid never changes → zero-time loop
$display("%h", data);
end
end
// Simulation: hangs at time 0. CPU=100%. No output. Same as forgetting @.
// ✅ FIX: ALWAYS have a timing control before continue in forever
initial begin
forever begin
@(posedge clk); // ✅ timing BEFORE the continue check
if (!valid) continue; // ✅ safe: re-enters AFTER next posedge
$display("%h", data);
end
endcontinue jumps to the next iteration of the loop. In a forever loop the next iteration begins immediately, so if the path taken by continue skips the loop's only timing control, the loop re-enters at the same simulation timestep and never yields.
This is the same failure as a forever with no timing control at all, but harder to spot, because the timing control is present in the source — it is simply on the path continue skips over. Reading the loop body top to bottom, you see the @(posedge clk); what you have to notice is that it sits below the continue.
The rule that makes it unwritable: in a forever loop, put the timing control first, before any conditional logic that might continue. Then no path through the body can skip it. If the timing control has to be later for some reason, every continue above it needs its own.
Diagnosis is the same as any zero-time loop — pegged CPU, no output, no VCD, no time advance — and the simulator's iteration-limit flag is again the fastest route to a line number.
return from a driver task leaves the DUT holding stale control signals
INCOMPLETE-CLEANUP// ❌ BUG: task returns early but leaves valid=1 asserted permanently
task automatic drive_packet(input int size, input logic [7:0] payload[]);
if (size <= 0) return; // ❌ returns but valid/ready may be asserted!
valid = 1'b1;
for (int i = 0; i < size; i++) begin
data = payload[i];
@(posedge clk);
if (bus_error) return; // ❌ returns mid-burst, valid still=1!
// DUT sees endless burst after task exits
end
valid = 1'b0; // this is only reached if loop completes normally
endtask
// ✅ FIX: always clean up signals before return (or use defer-style cleanup)
task automatic drive_packet(input int size, input logic [7:0] payload[]);
if (size <= 0) return; // safe — valid not yet asserted
valid = 1'b1;
for (int i = 0; i < size; i++) begin
data = payload[i];
@(posedge clk);
if (bus_error) begin
valid = 1'b0; // ✅ clean up BEFORE return
$error("Bus error at beat %0d", i);
return;
end
end
valid = 1'b0;
endtaskreturn exits the task at that point. Every statement below it is skipped — including the deassertions that would have released the interface.
This is the cost of the guard-clause pattern when it is applied without care. Early return genuinely does improve readability, but in a task that has already driven signals, an early exit leaves those signals where it found them. The DUT sees a request that is never withdrawn, and hangs waiting for a handshake the testbench has stopped participating in.
The distinction worth internalising: a guard clause is safe when it runs before any side effect. Validating arguments at the top of a task, before anything is driven, is the right use. Returning from the middle of a sequence that has already asserted valid is a different act, and needs the interface returned to a known state on the way out.
Two robust patterns. Put all guard clauses above the first drive, so no early return can ever skip a deassertion. Or, where an early exit genuinely is needed mid-sequence, drive the idle state explicitly before each return — or restructure so there is a single exit point that does it once.
disable with a mistyped label silently does nothing
LABEL-TYPO// ❌ BUG: label name typo — 'seach_blk' instead of 'search_blk'
initial begin
begin : search_blk // ← correct label here
for (int i = 0; i < 16; i++) begin
if (mem[i] == target) begin
found_idx = i;
disable seach_blk; // ❌ TYPO: 'seach' not 'search'
// In some simulators: compile error
// In others: runtime — no effect!
// Loop runs all 16 iterations
// found_idx = last match (not first)
end
end
found_idx = -1; // this runs even after match!
end
end
// ✅ FIX: use break for simple in-loop exit (no label needed)
initial begin
found_idx = -1;
for (int i = 0; i < 16; i++) begin
if (mem[i] == target) begin
found_idx = i;
break; // ✅ no label required, no typo risk
end
end
enddisable takes an identifier, and a name that does not resolve to a block in scope may be accepted with nothing more than a warning — or, if it happens to match a different block that exists elsewhere, disable that one instead. Either way, the block you meant to stop keeps running.
The failure mode is the one that makes label-based control fragile in general: the connection between the disable and its target is a string, checked loosely, and not visible at the point of use. A typo in a signal name is caught at compile time. A typo in a label may not be.
This is the core of the argument for preferring break for in-loop exit. break names nothing, so it cannot be mistyped, and it always refers to the loop it is written in — which is also what makes it survive a later refactor that renames or re-nests the block.
Reserve disable label for what only it can do: exiting a named block that is not a loop, or terminating a block from a different process. When you do use it, keep the disable and the begin : label close enough to see together, and turn on the lint rule that checks label resolution — most tools have one.
disable fork killed a coverage sampler along with the thread it was meant to stop
COLLATERAL-PROCESS-KILL// ❌ BUG: disable fork kills the coverage sampler along with the stimulus
initial begin
fork
stimulus_driver(); // drives 1000 transactions
coverage_sampler(); // needs to run AFTER stimulus finishes
watchdog();
join_any
disable fork; // ❌ kills ALL — coverage sampler killed too!
// Coverage never completes — coverage reports are incomplete
end
// ✅ FIX: use named labels to kill only specific threads
initial begin
fork
begin : stim_proc stimulus_driver(); end
begin : cov_proc coverage_sampler(); end
begin : wdog_proc watchdog(); end
join_any
disable stim_proc; // ✅ kill only stimulus
disable wdog_proc; // ✅ kill only watchdog
// coverage sampler (cov_proc) continues running
enddisable fork terminates all active descendant processes of the calling process — not only the threads of the fork block it appears in, and not only immediate children.
That scope is broader than most people expect, in two directions at once. It reaches every descendant, so a process spawned by a task that this process called is killed too, even though it is a grandchild and even though the code that spawned it is in a different file. And it reaches every outstanding descendant, including threads left running by an earlier fork ... join_none elsewhere in the same process — not just the ones from the fork block textually enclosing the disable.
Here the process had three children: the stimulus driver, the coverage sampler, and the protocol monitor. disable fork was written to stop the driver, and it stopped all three. The test kept running, so nothing failed; coverage simply stopped being sampled from that point on, and the missing coverage was attributed to weak stimulus for two weeks.
Use a named block and disable that name when you want to stop a specific thread: wrap it in begin : label ... end and disable label. Reserve disable fork for the case where killing everything outstanding is genuinely what you want — most legitimately, cleaning up at the end of a test or after a join_any where the survivors have no further purpose.
Related: wait fork waits for all descendants to complete rather than terminating them, and has the same descendant scope. See disable & wait fork for the full comparison.
break in an inner loop does not exit the outer loop
INNERMOST-ONLY// ❌ BUG: engineer expected break to exit both loops — it only exits inner
initial begin
for (int r = 0; r < 8; r++) begin // outer: row 0..7
for (int c = 0; c < 8; c++) begin // inner: col 0..7
if (mem[r][c] == target) begin
found_r = r; found_c = c;
break; // ❌ exits INNER loop only!
// Outer loop continues from r=found_r+1
// More searches happen → found_r/found_c overwritten
end
end
end
end
// ✅ FIX: use a 'found' flag to gate the outer loop
initial begin
bit found = 0;
found_r = -1; found_c = -1;
for (int r = 0; r < 8 && !found; r++) begin
for (int c = 0; c < 8; c++) begin
if (mem[r][c] == target) begin
found_r = r; found_c = c;
found = 1;
break; // ✅ exits inner, outer checks !found
end
end
end
endbreak exits the innermost enclosing loop only. There is no labelled-break form in SystemVerilog, so a break in a nested loop returns control to the outer loop's next iteration — which then continues searching, usually overwriting the result that was just found.
Two correct ways out, with different applicability:
A found flag in the outer condition. Declare a flag, set it in the inner loop before the break, and add && !found to the outer loop's condition: for (int r = 0; r < N && !found; r++). After the inner break, the outer loop re-tests, sees the flag, and terminates. This is synthesizable, and it is the right choice in RTL — the flag becomes exactly the gating signal that unrolling would have generated anyway.
A named block and disable. Wrap both loops in begin : outer_search ... end and use disable outer_search in place of the inner break. This exits both loops at once, and reads more directly. It is simulation-only — not synthesizable — so it belongs in testbench code, where it is often the clearer of the two.
The failure this produces is quiet: the search finds the correct match and then keeps going, so the reported result is the last match rather than the first. On data where the first and last match coincide, it passes.
A function with no return on one path returns an unknown value
MISSING-RETURN-PATH// ❌ BUG: function has no return on the else path
function automatic logic [7:0] compute(input logic [7:0] a, b);
if (a > b) begin
return a - b; // path A: explicit return ✅
end
// path B: no return statement!
// SystemVerilog: function implicitly returns function_name variable
// which was never assigned → returns 0 (or X in 4-state)
// Simulation: returns 0 when a <= b
// This matches "b - a = 0 when a==b" accidentally → test passes
// But when a=3, b=5: returns 0 instead of 5-3=2 — WRONG!
endfunction
// ✅ FIX: always have an explicit return on every path
function automatic logic [7:0] compute(input logic [7:0] a, b);
if (a > b) return a - b; // ✅ path A
return b - a; // ✅ path B — abs(a-b)
endfunction
// Lint tools catch "function may have no return on all paths" — treat as ERRORA SystemVerilog function returns the value of its implicit return variable — the one named after the function. If a code path never assigns it and never executes a return, the function still returns: it returns whatever that variable held, which for a 4-state type is X.
There is no error and often no warning. The function is legal; it simply has a path that produces an undefined result.
The consequence differs on each side of the flow, and both are bad in their own way. In simulation, X propagates from the function's result into whatever consumed it, and the failure surfaces some distance from its cause. In synthesis, the tool must build something for that path, and what it builds — a latch, or a don't-care the optimizer resolves however it likes — depends on the context and on the tool.
The analogy that makes the severity clear: a function with a missing return path is the same class of defect as a case statement with no default. Both produce a value on a path nobody tested, and both are caught by exactly the same discipline — assign the result unconditionally at the top of the function, then let later paths override it. That way there is no path that can escape without a defined value.
Every serious lint tool has a rule for this. It is worth setting to error rather than warning at RTL sign-off.
continue where break was intended finds the last match instead of the first
WRONG-CONTROL-FLOW// ❌ BUG: continue skips remaining body but keeps looping
// Engineer wanted to stop at first match — used continue instead of break
always_comb begin
first_set = 4'hF;
for (int i = 0; i < 8; i++) begin
if (!data[i]) continue; // skip iterations where data[i]=0
first_set = i[3:0]; // ← BUT this runs for EVERY set bit!
// Result: first_set = LAST set bit (not first)
// RTL synthesis: not a priority encoder — last-wins mux chain
end
end
// Example: data=8'b0001_0100 (bits 2 and 4 set)
// With continue: first_set = 4 (last set bit) ❌
// With break: first_set = 2 (first set bit) ✅
// ✅ FIX: use break to stop at first match
always_comb begin
first_set = 4'hF;
for (int i = 0; i < 8; i++) begin
if (data[i]) begin
first_set = i[3:0];
break; // ✅ stops at first match — priority encoder behavior
end
end
endcontinue and break answer two different questions, and the code compiles either way.
continue means "skip this one, keep going." The loop runs to completion. It is the right keyword for a filter — process every element that qualifies, skip the ones that do not.
break means "found it, stop looking." The loop exits. It is the right keyword for a search — find the first match and stop.
The bug is using continue while thinking "skip the cases I don't want", and forgetting that the loop still runs every remaining iteration. Each qualifying element overwrites the output, so the answer that survives is the last match, not the first.
What makes it durable is that the two agree whenever there is exactly one match. A priority encoder tested with one-hot inputs behaves identically under both keywords, and the difference appears only when two requesters assert at once — which, in a priority encoder, is precisely the case the design exists to handle. The test that distinguishes them is the one with multiple simultaneous matches, and it is worth writing deliberately rather than hoping random stimulus produces it.
In RTL, the unrolled forms differ correspondingly: break produces a found-flag priority cascade, while continue produces logic where each match overwrites the previous one — a last-match encoder, which is legal hardware and almost never what was wanted.
💡 Senior Verification Engineer Tip: Run Lint on Control Flow Keywords
Most RTL lint tools (Spyglass, Synopsys Lint, Cadence JasperGold) have rules specifically for these control flow keywords: detecting break/continue in non-synthesizable loops, flagging missing return paths in functions, and checking disable label matches. Enable these rules and set them to ERROR severity during RTL sign-off. A function with a missing return path is as dangerous as a latch from a missing default in a case statement — both produce undefined values on untested code paths.
🎯 Interview Q&A — Control Flow in RTL and Verification
📚 Related Pages & References
Where these keywords meet the rest of the language. The loops that break and continue control — and the difference between a procedural loop, synthesis unrolling, and a generate loop — are covered in Loops, which also has the counterpart Debug Lab for a for inside always_ff that was expected to iterate over clock cycles. For the process model behind disable fork and wait fork, see fork-join and disable & wait fork — this page deliberately does not restate them, and links out rather than duplicating.
For the scheduler regions that make a continue past a timing control hang the simulator, see Procedural Blocks. For the function-return semantics behind the missing-return-path bug, see Function Declarations & Return Values and Task Declarations and, for the X that a missing return produces, 2-state vs 4-state Types.
References.
- IEEE 1800 (SystemVerilog) —
breakandcontinueare defined in the jump-statements clause, andbreakis specified to exit the innermost enclosing loop, which is why there is no labelled break and why Debug Lab 6 needs a found flag or a named block.disableanddisable forkare defined in the processes clause:disable forkterminates all active descendant processes of the calling process, which is the scope demonstrated by the runnable example above and the source of the correction on this page — it is not limited to immediate children, and not limited to the enclosingforkblock.wait forkis defined over the same descendant set. Function return semantics, including the implicit return variable, are in the subroutines clause. - IEEE 1364.1 (Verilog RTL Synthesis) — the synthesizable subset, in which
disabledoes not appear;break,continueandreturnare synthesizable inside statically-bounded loops and functions.
One thing on this page is project practice rather than language rule, and is labelled where it appears: the recommendation to prefer break over disable label for in-loop exit, and to prefer a named disable label over disable fork when stopping a single thread. Both are legal; the preference is about which failures the code makes possible, not about which constructs are permitted. Lint-rule names and severities are likewise tool-specific.
Part of SystemVerilog Fundamentals·Procedural Statements·Lesson 33 of 53
View programContinue learning
Related tutorials
Standards & specifications
- Governing standard
- IEEE Std 1800 (SystemVerilog)(opens IEEE in a new tab)
Defines SystemVerilog language semantics — syntax, data types, scheduling and the behaviour a conforming simulator must produce. It does not define tool-specific synthesis support or vendor methodology.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the SystemVerilog curriculum.