SystemVerilog · Module 5
Loops
for, while, do-while, repeat, forever, foreach — synthesisable vs simulation-only.
Module 5 · Page 5.4
for — The Workhorse Loop
The for loop is identical in form to C. It is the most common loop in SystemVerilog RTL because synthesis tools can unroll it: when the bounds are known at compile time, the tool simply replicates the loop body N times in hardware — no actual loop exists in the synthesised netlist.
// ── Basic for loop ──────────────────────────────────────────────
always_comb begin
parity = 1'b0;
for (int i = 0; i < 8; i++) // unrolls to 8 XOR gates
parity ^= data[i];
end
// ── for with parameter (synthesis-friendly) ─────────────────────
parameter WIDTH = 16;
always_comb begin
result = '0;
for (int i = 0; i < WIDTH; i++)
if (mask[i]) result[i] = data[i];
end
// ── Nested for (2D array init) ───────────────────────────────────
initial begin
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++)
mem[r][c] = r * 4 + c;
end
// ── Declare loop variable inside for (SV style) ─────────────────
always_comb begin
for (int unsigned k = 0; k < 32; k++)
popcount += data[k]; // k is local to this for block
end🏗 Synthesis Insight: What Loop Unrolling Actually Produces
When synthesis unrolls for(int i=0; i<8; i++) parity ^= data[i];, it does not generate 8 sequential XOR operations — it generates 8 parallel XOR gates wired together as a tree. The synthesized netlist is identical to writing parity = data[0]^data[1]^data[2]^data[3]^data[4]^data[5]^data[6]^data[7]; directly. Critical path through the XOR tree: 3 gate levels (log₂8) ≈ 300ps in 28nm. The loop variable i does not exist in the netlist — it is purely a code-generation tool that the synthesizer uses once and discards. This is why for loops in RTL are powerful: they let you describe N-way parallel hardware concisely without manually writing N statements.
🧠 How the Simulator Executes for in always_comb
In simulation, a for loop inside always_comb executes sequentially — iteration 0 completes before iteration 1 starts. But since the entire always_comb block runs in the Active region (zero simulation time), all 8 iterations finish within a single delta cycle. From the waveform viewer's perspective, the output parity changes atomically when any input changes — it appears combinational. This is simulation-accurate: sequential execution within zero time is equivalent to parallel evaluation.
while — Condition-First Loop
A while loop evaluates its condition before executing the body. If the condition is false from the start, the body never runs. In simulation it runs as long as the condition remains true. In synthesis it is only valid if the tool can prove the iteration count is bounded and constant.
// ── Simulation: while loop with dynamic condition ───────────────
initial begin
count = 0;
while (count < 100) begin // runs 100 times
@(posedge clk);
count++;
end
end
// ── RTL: while synthesises only with statically bounded count ───
always_comb begin
int tmp = data;
lz = 0;
while (tmp[7] == 0 && lz < 8) begin // bound = 8 → unrollable
tmp = tmp << 1;
lz++;
end
end
// ── Testbench: wait for handshake ───────────────────────────────
initial begin
while (!dut.ready) @(posedge clk); // spin until DUT asserts ready
$display("DUT ready at t=%0t", $time);
enddo-while — Body Executes at Least Once
The do-while loop evaluates its condition after the body. This guarantees the body runs at least once, regardless of the initial condition value. It is less common in RTL but useful in testbenches where you need at least one cycle of stimulus.
// ── Body runs at least once even if condition is false initially ─
initial begin
do begin
@(posedge clk); // always samples at least one edge
data_in = $random;
end while (data_in != 8'hFF); // keep driving until 0xFF
end
// ── Compare: while vs do-while ──────────────────────────────────
initial begin
count = 10;
// while — condition false immediately, body never runs
while (count < 5) begin count++; end // body skipped
count = 10;
// do-while — body runs once, then condition checked
do begin count++; end while (count < 5); // body runs once, count=11
endrepeat — Fixed Iteration Count
repeat(N) executes its body exactly N times. N can be any expression — it is evaluated once at the start and then held fixed. Unlike for, there is no loop variable. It is frequently used in testbenches to drive a fixed number of clock cycles or transactions.
// ── Drive 10 clock cycles ───────────────────────────────────────
initial begin
repeat(10) @(posedge clk);
$display("10 cycles done at t=%0t", $time);
end
// ── Send N transactions (N from parameter) ──────────────────────
initial begin
repeat(NUM_PKTS) begin
@(posedge clk);
valid = 1;
data = $random;
@(posedge clk);
valid = 0;
end
end
// ── Synthesis: repeat with constant N synthesises ───────────────
always_comb begin
shifted = data;
repeat(4) shifted = {shifted[6:0], 1'b0}; // left-shift 4 times = <<4
endforever — Runs Without End
forever has no condition and no counter — it runs indefinitely. It is simulation-only: synthesis tools will reject it because they cannot unroll an infinite loop into hardware. It is the correct way to write clock generators, bus monitors, and any testbench process that must run for the duration of simulation.
// ── Clock generator ─────────────────────────────────────────────
initial begin
clk = 0;
forever #5 clk = ~clk; // 10-ns period
end
// ── Bus monitor ─────────────────────────────────────────────────
initial begin
forever begin
@(posedge clk);
if (valid && ready)
$display("[%0t] Transfer: data=%0h", $time, data);
end
end
// ── Watchdog (ends simulation if timeout) ───────────────────────
initial begin
forever begin
@(posedge clk);
if (cycle_count > 10_000) begin
$error("TIMEOUT: simulation exceeded 10000 cycles");
$finish;
end
end
end🔍 Debugging Insight: How to Detect a Zero-Time Forever Loop
Symptom: simulation binary starts, prints nothing, consumes 100% CPU, and never terminates. No VCD output is created. The simulator is stuck in the Active region executing your forever body millions of times per second in zero simulation time. Diagnostic steps: (1) Check all forever blocks for missing @, #, or wait. (2) Search for always begin (without sensitivity) — same zero-time loop risk. (3) VCS will print "Iteration limit reached" before aborting if you add +nbaopt — use this flag to get a stack trace pointing to the offending loop. (4) In Questa, add -iterationlimit 10000 to get an error with file/line number instead of a hang.
💡 Senior Verification Engineer Tip: forever vs always for Clock Generation
Both always #5 clk = ~clk; and initial begin clk=0; forever #5 clk=~clk; end generate a 10ns clock. The difference: the always form starts with an undefined clock value (X) at T=0 until the first toggle at T=5. The initial + forever form starts with clk=0 at T=0 and toggles at T=5, T=10, etc. In strict setup-hold checking, the initial X state from the always form can cause spurious timing violations in the first cycle. Production testbenches always use the initial + forever pattern with an explicit initial clock value.
foreach — Iterate Over Arrays Automatically
foreach is SystemVerilog's array-aware loop — the counterpart of the manual indexing described in Fixed-Size Arrays. It automatically generates the correct loop variable and bounds for any unpacked array dimension — you never have to manually write the size. It handles multi-dimensional arrays elegantly.
// ── 1D array ────────────────────────────────────────────────────
int arr[8];
initial begin
foreach (arr[i]) // i automatically declared, range 0..7
arr[i] = i * 2;
end
// ── 2D array ────────────────────────────────────────────────────
int matrix[4][4];
initial begin
foreach (matrix[r, c]) // r iterates rows, c iterates columns
matrix[r][c] = r + c;
end
// ── Dynamic array ───────────────────────────────────────────────
int dyn[];
dyn = new[16];
initial begin
foreach (dyn[k])
dyn[k] = $random;
end
// ── Queue ───────────────────────────────────────────────────────
string q[$] = {"alpha", "beta", "gamma"};
initial begin
foreach (q[j])
$display("q[%0d] = %s", j, q[j]);
end
// ── Partial dimension (outer loop only) ─────────────────────────
int cube[4][4][4];
initial begin
foreach (cube[x]) // iterate only the first dimension
cube[x][0][0] = x;
end🚀 RTL Design Insight: foreach in Synthesis vs Simulation
foreach on a static unpacked array (fixed size, declared with logic arr[8]) is synthesizable — the tool knows the size at compile time and unrolls identically to a for loop. foreach on a dynamic array (logic arr[]) or queue (logic arr[$]) is simulation-only — dynamic size means the iteration count cannot be determined at synthesis time. This is the most common mistake engineers make when porting verification code to RTL: a foreach that worked in the testbench silently errors in synthesis because the array was dynamic.
Synthesis vs. Simulation — Which Loops Are Synthesisable?
The rule is about the trip count, not about the keyword. A loop synthesises if and only if the tool can determine the number of iterations at elaboration time; if it cannot, it errors out.
It is worth stating what that rule does not say, because the shorthand version — "for is synthesizable, while is not" — is wrong in both directions. A for loop whose bound is a signal is just as unsynthesizable as a dynamic while (Debug Lab 2). A while whose bound the tool can prove is static may unroll perfectly well. The keyword is not the criterion; the determinability of the trip count is.
The practical caveat is that "the tool can prove it" is tool-dependent and version-dependent. A while that unrolls today can fail after a tool upgrade, with no change to your code. In RTL, prefer a constant-bounded for, where the trip count is visible to the reader as well as to the tool. In a testbench, use whichever reads better — none of this applies.
| Loop type | Synthesisable? | Condition | Typical use |
|---|---|---|---|
for | Yes | Bounds must be compile-time constants | Bit manipulation, parallel operations, RTL |
repeat(N) | Yes | N must be a constant expression | Fixed replication, RTL and testbench |
foreach | Yes | Only over unpacked dimensions of a statically-sized array; illegal on packed dimensions, unsynthesizable on dynamic arrays and queues | Array initialisation and transformation |
while | Tool-dependent | Only if the tool can prove a static bound — do not rely on it in RTL | Simulation freely; avoid in RTL |
do-while | Tool-dependent | Same caveat as while | Testbenches, one-or-more semantics |
forever | No | Infinite — cannot unroll | Clock generators, monitors, testbench processes |
🧭 Three Different Things Called "A Loop"
Before the RTL patterns, the distinction that resolves most confusion about loops in hardware description. The same keyword for denotes three different mechanisms depending on where it appears, and they run at different times, produce different things, and fail in different ways.
| Procedural loop | Synthesis unrolling | Generate loop | |
|---|---|---|---|
| Where it appears | inside initial / always / a task or function | inside always_comb / always_ff in synthesizable RTL | at module scope, for (genvar …) |
| When it runs | at run time, during simulation | at elaboration, inside the synthesis tool | at elaboration, in both simulator and synthesizer |
| What it produces | repeated execution of statements | N copies of the body as concurrent logic | N copies of declarations — instances, nets, always blocks |
| Loop variable | a normal variable, exists at run time | a compile-time integer, gone from the netlist | a genvar, exists only during elaboration |
| Can replicate module instances? | no | no | yes — this is the only one that can |
| Bound may be a signal? | yes | no | no |
The row that surprises people is the last-but-one. A procedural for loop cannot instantiate a module. If you need eight copies of a submodule, no amount of always_comb will produce them — you need generate.
// 1. PROCEDURAL - runs at simulation run time. Nothing is built.
initial begin
for (int i = 0; i < 8; i++)
$display("packet %0d", i); // 8 executions, in time order
end
// 2. UNROLLED - elaborated into concurrent logic. One always_comb block,
// eight XOR gates inside it, all evaluating simultaneously.
always_comb begin
parity = 1'b0;
for (int i = 0; i < 8; i++)
parity ^= data[i];
end
// 3. GENERATE - elaborated into eight separate hardware objects. This is
// the only form that can replicate instances, and note that each
// iteration creates its own always_ff block and its own named scope.
genvar g;
generate
for (g = 0; g < 8; g++) begin : g_lane
logic [7:0] stage_q; // 8 distinct signals
always_ff @(posedge clk) // 8 distinct always blocks
stage_q <= data_in[g];
lane_fifo u_fifo ( // 8 module INSTANCES
.clk (clk),
.din (stage_q),
.dout(lane_out[g])
);
end
endgenerate
// Hierarchical names: g_lane[0].u_fifo, g_lane[1].u_fifo, ...
// The label after the colon is not decoration - without it the generated
// scopes are unnamed, and nothing in the waveform viewer, the constraints,
// or a hierarchical assertion can refer to them.Two practical consequences follow from the table, and both cause real bugs.
A procedural loop does not advance time unless you make it. for (int i = 0; i < 8; i++) data <= x[i]; inside an always_ff does not produce eight clock cycles of activity — it produces eight assignments in the same cycle, of which only the last survives. Time in a procedural loop advances only through an explicit @, #, or wait. This is the subject of Debug Lab 9 below.
A generate loop cannot use a signal as its bound either. genvar bounds must be elaboration-time constants, the same restriction as unrolling. What generate buys you is not dynamic sizing — it is the ability to replicate declarations, which unrolling cannot do at all.
A for loop inside always_ff was expected to shift over eight clock cycles
PROCEDURAL-LOOP-IS-NOT-TIME// ❌ BUG: written expecting one shift per clock cycle for eight cycles.
// Simulation shows the output changing in a SINGLE cycle, and
// synthesis reports a combinational path far longer than expected.
module bad_serializer (
input logic clk,
input logic rst_n,
input logic start,
input logic [7:0] data,
output logic sout
);
logic [7:0] sr;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) sr <= 8'h00;
else if (start) sr <= data;
else begin
for (int i = 0; i < 8; i++) begin
sout <= sr[7]; // "one bit per cycle" <-- it is not
sr <= {sr[6:0], 1'b0}; // "shift once per cycle" <-- it is not
end
end
end
endmodule
// The loop body contains no timing control, so all 8 iterations execute in
// the SAME clock cycle. Eight nonblocking assignments to the same target in
// one cycle: the LAST one wins. The design shifts once per clock, not eight
// times, and sout only ever shows one value.
// ✅ FIX: the iteration IS the clock. One shift per activation of the block,
// with a counter carrying the loop state across cycles.
module good_serializer (
input logic clk,
input logic rst_n,
input logic start,
input logic [7:0] data,
output logic sout,
output logic busy
);
logic [7:0] sr;
logic [3:0] cnt;
assign busy = (cnt != 4'd0);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sr <= 8'h00; cnt <= 4'd0; sout <= 1'b0;
end else if (start) begin
sr <= data; cnt <= 4'd8; sout <= 1'b0;
end else if (cnt != 4'd0) begin
sout <= sr[7]; // one bit, this cycle
sr <= {sr[6:0], 1'b0}; // one shift, this cycle
cnt <= cnt - 4'd1; // the loop counter lives in a register
end
end
endmoduleA procedural loop iterates within a single evaluation of the block. It does not iterate over time. The always_ff block is evaluated once per clock edge, and everything inside that evaluation happens at that one edge. Eight iterations of the loop body are eight statements executed at the same instant — not eight clock cycles.
The nonblocking semantics then hide what happened. All eight sr <= … assignments are scheduled in the NBA region for the same timestep, and the last one scheduled wins. So the design does not error, does not warn, and does not obviously misbehave — it just shifts once per clock while the author believes it shifts eight times, and the mismatch surfaces as a serializer that emits the wrong number of bits.
The general principle, and the reason this is worth its own lab: in hardware, iteration over time is a state machine, not a loop. A loop can iterate over space — eight bits, eight lanes, eight taps — and unrolling turns that into eight pieces of concurrent logic. Iterating over time requires state that persists between clock edges, which means a register: a counter, a shift register, or an FSM. The loop keyword offers no mechanism to advance a clock, and there is nowhere for it to store its progress between edges.
The clue in synthesis is worth recognising too. If the loop body had computed rather than assigned, unrolling would have produced an eight-deep chain of combinational logic between two registers, and the timing report would show one very long path where the author expected eight short ones. "My critical path is eight times longer than I designed for" is frequently this same misunderstanding, seen from the other end.
The corrected module above makes the activation of the block the iteration. Each clock edge performs one step; cnt carries the loop state across edges; busy exposes the state to the rest of the design. That is the hardware form of a loop over time.
Two checks make the distinction concrete before it reaches silicon.
// The property the buggy version silently violated: while busy, exactly one
// bit is emitted per clock. A design that "loops" eight times in one cycle
// cannot satisfy this, because sout does not change on seven of the edges.
a_one_bit_per_clock: assert property (@(posedge clk) disable iff (!rst_n)
(busy && !start) |=> $changed(sout) || (sr == '0))
else $error("serializer did not advance exactly one bit this cycle");
// And the structural signature, checkable at review time rather than in
// simulation: an always_ff whose body contains a loop that assigns the same
// nonblocking target more than once is almost always this bug. Most linters
// have a rule for it; it is worth enabling as an error rather than a warning.The reading habit that catches it fastest: when you see a loop in an always_ff, ask what the loop is iterating over. If the answer is "bits" or "lanes" or "taps", it is fine — that is spatial, and unrolling does the right thing. If the answer is "cycles", it is wrong, and what you need is a counter.
🏗 RTL Loop Patterns — What Real Hardware Engineers Write
Every production RTL design contains loops. They appear in every data-path block — ALUs, encoders, decoders, shifters, CRC generators, memory controllers. For exiting one early, see break, continue, return & disable. Here are the canonical patterns that show up repeatedly in code reviews.
// ── Pattern 1: Parameterized parity generator ─────────────────────
module parity_gen #(parameter int WIDTH = 32) (
input logic [WIDTH-1:0] data,
output logic parity_even, parity_odd
);
always_comb begin
parity_even = 1'b0;
for (int i = 0; i < WIDTH; i++)
parity_even ^= data[i]; // XOR tree: log₂(WIDTH) gate levels
parity_odd = ~parity_even;
end
endmodule
// Synthesis: 32-input XOR tree → 5 gate levels → ~250ps critical path (28nm)
// ── Pattern 2: Population count (popcount / Hamming weight) ──────
module popcount #(parameter int W = 32) (
input logic [W-1:0] data,
output logic [$clog2(W):0] count
);
always_comb begin
count = '0;
for (int i = 0; i < W; i++)
count += data[i]; // W single-bit adders → adder tree
end
endmodule
// Synthesis: W=32 → 5-level carry-save adder tree
// Used in: network packet header processing, LDPC decoders, fault injection
// ── Pattern 3: Parameterized priority encoder ─────────────────────
module priority_enc #(parameter int N = 8) (
input logic [N-1:0] req,
output logic [$clog2(N)-1:0] grant_id,
output logic valid
);
always_comb begin
grant_id = '0;
valid = 1'b0;
for (int i = N-1; i >= 0; i--) begin // scan high→low: lowest index wins
if (req[i]) begin
grant_id = i[$clog2(N)-1:0];
valid = 1'b1;
end
end
end
endmodule
// for loop scans MSB to LSB → last overwrite wins = lowest-index priority
// ── Pattern 4: 8-bit CRC-8 (serial) using for in always_ff ───────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
crc <= 8'hFF;
end else if (valid) begin
automatic logic [7:0] tmp = crc;
for (int i = 0; i < 8; i++) begin
if (tmp[7] ^ data_in[7-i])
tmp = (tmp << 1) ^ 8'h07; // CRC-8/SMBUS polynomial
else
tmp = tmp << 1;
end
crc <= tmp;
end
end
// for loop inside always_ff: synthesizes to 8-stage combinational chain
// The 8 iterations are all computed in parallel before registering the result
// ── Pattern 5: Find-first-set-bit using for ───────────────────────
always_comb begin
first_set = '0;
found = 1'b0;
for (int i = 0; i < 32; i++) begin
if (!found && data[i]) begin
first_set = i[4:0];
found = 1'b1;
end
end
end
// The 'found' flag gates later iterations — synthesis: priority encoder🔬 Verification Loop Patterns — Drivers, Monitors, and Checkers
Every UVM component and every directed testbench is built on loops. The pattern you choose determines simulation performance, debuggability, and correctness. Here are the canonical patterns used in production verification environments.
module tb_full_verif_pattern;
logic clk = 0;
logic rst_n;
logic [7:0] data_in, data_out;
logic valid_in, valid_out;
int pass_cnt = 0, fail_cnt = 0;
// ── ① Clock generator: forever in initial ────────────────────
initial forever #5 clk = ~clk; // clk starts 0, toggles at 5,10,15...
// ── ② Reset generator: repeat for fixed-cycle hold ───────────
initial begin
rst_n = 0;
repeat(5) @(posedge clk); // hold reset for 5 cycles
rst_n = 1;
end
// ── ③ Directed driver: for loop drives N test vectors ─────────
initial begin
@(posedge rst_n); // wait for reset deassertion
for (int i = 0; i < 256; i++) begin
@(posedge clk);
data_in = i[7:0]; // sweep all 8-bit values
valid_in = 1'b1;
@(posedge clk);
valid_in = 1'b0;
end
$display("Directed test done. Pass:%0d Fail:%0d", pass_cnt, fail_cnt);
$finish;
end
// ── ④ Random stimulus driver: while loop with coverage goal ──
initial begin
@(posedge rst_n);
while (pass_cnt < 1000) begin // run until 1000 passing transactions
@(posedge clk);
data_in = $urandom_range(0, 255);
valid_in = $urandom_range(0, 1);
end
end
// ── ⑤ Output monitor: forever sampling on posedge ────────────
initial forever begin
@(posedge clk);
if (valid_out) begin
automatic logic [7:0] expected = ~data_in; // reference model
if (data_out === expected) pass_cnt++;
else begin
$error("FAIL: got %h exp %h", data_out, expected);
fail_cnt++;
end
end
end
// ── ⑥ Watchdog: forever with cycle counter ────────────────────
initial begin
forever begin
@(posedge clk);
if ($time > 1_000_000) begin
$fatal(1, "WATCHDOG: simulation timeout");
end
end
end
// ── ⑦ Memory scoreboard: foreach iterates result array ────────
logic [7:0] results[256];
initial begin
@(posedge rst_n); #1000;
foreach (results[i]) begin // check all collected results
if (results[i] !== ~i[7:0])
$error("results[%0d]=%h expected %h", i, results[i], ~i[7:0]);
end
end
endmodule| TB Component | Loop Used | Why That Loop |
|---|---|---|
| Clock generator | initial + forever | Starts with known clk=0, runs for entire simulation |
| Reset sequencer | repeat(N) | Exactly N cycles of reset — readable, no counter variable needed |
| Directed driver | for | Known count, index used for data value — natural fit |
| Random driver | while | Runs until coverage/count goal met — dynamic termination |
| Monitor | forever | Must run for entire simulation, no termination condition |
| Watchdog | forever | Continuous check, ends simulation on timeout |
| Array checker | foreach | Iterates collected results array — clean, automatic bounds |
⚙ Loop Unrolling — What the Synthesis Tool Actually Does
Understanding how synthesis unrolls a loop is the key to writing RTL that produces efficient hardware. The unrolling decision is binary: either the tool can determine the exact iteration count at elaboration time, or it cannot. There is no middle ground. What you write:
logic [7:0] data;
logic parity;
always_comb begin
parity = 1'b0;
for (int i = 0; i < 8; i++)
parity ^= data[i];
endWhat synthesis elaborates it to — the loop is executed once, at elaboration
time, and each iteration is replaced by its body with i substituted:
parity = 1'b0; <- initial value
parity ^= data[0]; <- i = 0
parity ^= data[1]; <- i = 1
parity ^= data[2]; <- i = 2
parity ^= data[3]; <- i = 3
parity ^= data[4]; <- i = 4
parity ^= data[5]; <- i = 5
parity ^= data[6]; <- i = 6
parity ^= data[7]; <- i = 7
which is exactly the expression
parity = data[0]^data[1]^data[2]^data[3]^data[4]^data[5]^data[6]^data[7];There is no loop and no variable i in the result. i is a compile-time
integer the tool substitutes once and discards.
What the technology mapper then builds. The elaborated expression is an 8-input XOR reduction, which maps to a balanced tree three levels deep. Written with XOR2 cells:
Level 1: xor2(data[0], data[1]) -> t01
xor2(data[2], data[3]) -> t23
xor2(data[4], data[5]) -> t45
xor2(data[6], data[7]) -> t67
Level 2: xor2(t01, t23) -> t0123
xor2(t45, t67) -> t4567
Level 3: xor2(t0123, t4567) -> parity
Depth 3 = ceil(log2(8)). All 8 inputs are evaluated in parallel; the
critical path is 3 cell delays, not 8.Where unrolling stops working. The tool must be able to evaluate the trip count at elaboration time. A bound that is a signal cannot be evaluated:
// Variable bound - synthesis cannot unroll this.
module bad_shift (
input logic [7:0] data,
input logic [2:0] shift_amt, // a runtime value
output logic [7:0] out
);
always_comb begin
out = data;
for (int i = 0; i < shift_amt; i++) // trip count unknown at elaboration
out = {out[6:0], 1'b0};
end
endmodule
// Synthesis error: cannot determine loop bound.
// The standard fix: unroll the MAXIMUM, and gate each stage.
module barrel_shift (
input logic [7:0] data,
input logic [2:0] shift_amt,
output logic [7:0] out
);
always_comb begin
out = data;
for (int i = 0; i < 8; i++) // constant 8 - always unrollable
if (shift_amt > i) // the runtime value becomes a MUX
out = {out[6:0], 1'b0};
end
endmodule
// 8 stages always exist in hardware; shift_amt selects how many are active.The transformation is worth naming, because it is the general answer to "my loop bound is a signal": move the runtime value from the loop bound into the loop body. The bound becomes a constant the tool can unroll, and the signal becomes select logic on hardware that is always present. You pay for the maximum case in area and get a static structure in return.
🏗 Synthesis Concern: Large Loops Create Large Netlists
A for loop with 1024 iterations and a complex body creates 1024 copies of that logic in the netlist. A 32-bit adder inside a 1024-iteration loop produces 1024 32-bit adders — ~32,768 full-adder cells. This is intentional for parallel hardware, but if you accidentally put a large loop in RTL, synthesis will run for a very long time and produce an enormous (and probably wrong) netlist. Always review loop bounds in RTL: for(int i=0; i<32; i++) with a simple body (XOR, AND) is fine. for(int i=0; i<1024; i++) with a 64-bit multiplier is probably a mistake.
⚠ Infinite Loop Debugging — Finding Simulation Hangs
A zero-time infinite loop is one of the most disorienting simulation failures. The process hangs, no output appears, and the VCD file is empty. Here is a systematic approach to finding and fixing it.
- Confirm the symptom — check CPU usage. If one simulation core is pegged at 100% with no VCD output and no console messages, a zero-time loop is almost certain.
- Make the simulator abort — add
+nbaopt(VCS) or-iterationlimit(Questa) to get the simulator to abort with a stack trace when the delta-cycle limit is exceeded. The stack trace points directly to the offending line. - Narrow by inspection — search for loops without timing controls. Find every
forever,while,always, andforloop in the file. Check each one for at least one@(event),#delay, orwait(condition)inside the body. - Rule out feedback — check for delta-cycle oscillation. Even with timing controls, an always_comb chain that feeds back onto itself can cause unlimited delta cycles. Look for combinational feedback loops.
- Instrument — add a
$displayat the loop entry as a quick diagnostic. If you see millions of prints in microseconds, the loop is the culprit.
// ── Zero-time loop pattern 1: forever without timing ─────────────
// ❌ BUG: simulation hangs at T=0
initial forever begin
data = $random; // no @, no #, no wait → infinite loop in Active region
end
// ✅ FIX: add timing control
initial forever begin
@(posedge clk); // yields time — simulation advances every iteration
data = $random;
end
// ── Zero-time loop pattern 2: while with condition never false ────
// ❌ BUG: count never changes because no timing control
initial begin
count = 0;
while (count < 10) begin
$display("count=%0d", count); // no timing → prints count=0 infinitely
end // count never increments!
end
// ✅ FIX: either add timing OR increment inside loop
initial begin
count = 0;
while (count < 10) begin
@(posedge clk);
$display("count=%0d", count);
count++; // ✅ count changes → loop eventually terminates
end
end
// ── Delta cycle oscillation: two always_comb blocks feed each other
// ❌ BUG: combinational feedback loop → unlimited deltas
always_comb a = b ^ input1; // a depends on b
always_comb b = a & input2; // b depends on a → loop!
// Simulator: b changes → a re-evaluates → b changes → a re-evaluates → ...
// VCS: "Delta limit exceeded" error after 1M iterations
// ✅ FIX: break the loop with a register
always_comb a = b_reg ^ input1; // reads registered b
always_ff @(posedge clk) b_reg <= a & input2; // register breaks the loop⚙ Advanced Code Examples — Real Project Patterns
Example A — Parametrized Shift Register with Tap Output (for in RTL)
// ── Parameterized N-stage shift register ─────────────────────────
// Used in: SDR/DDR clock domain buffering, pipeline delay lines,
// spread-spectrum clock generators, digital delay chains
module shift_reg #(
parameter int DEPTH = 8,
parameter int WIDTH = 8,
parameter int TAP = 4 // tap output at stage N
) (
input logic clk, rst_n,
input logic [WIDTH-1:0] d,
output logic [WIDTH-1:0] q, // last stage output
output logic [WIDTH-1:0] q_tap // tap at stage TAP
);
logic [WIDTH-1:0] pipe [DEPTH]; // array of flip-flops
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < DEPTH; i++)
pipe[i] <= '0;
end else begin
pipe[0] <= d;
for (int i = 1; i < DEPTH; i++)
pipe[i] <= pipe[i-1]; // each stage captures previous stage
end
end
assign q = pipe[DEPTH-1];
assign q_tap = pipe[TAP-1];
// Synthesis: DEPTH flip-flop stages, no combinational logic between stages
// For DEPTH=8,WIDTH=8: 64 flip-flops, 8-cycle pipeline delay
endmoduleExample B — AXI Burst Write Driver (while + repeat in testbench)
// ── AXI4-Lite write burst task ────────────────────────────────────
task automatic axi_write_burst(
input logic [31:0] base_addr,
input logic [7:0] burst_len,
input logic [31:0] data[]
);
// Phase 1: Write address channel handshake
awaddr = base_addr;
awvalid = 1'b1;
awlen = burst_len - 1;
while (!awready) @(posedge clk); // wait for slave ready
@(posedge clk);
awvalid = 1'b0;
// Phase 2: Write data channel — drive all beats
for (int beat = 0; beat < burst_len; beat++) begin
wdata = data[beat];
wstrb = 4'hF;
wvalid = 1'b1;
wlast = (beat == burst_len - 1); // assert LAST on final beat
while (!wready) @(posedge clk); // wait for slave to accept
@(posedge clk);
end
wvalid = 1'b0; wlast = 1'b0;
// Phase 3: Write response — wait for BRESP
bready = 1'b1;
while (!bvalid) @(posedge clk); // wait for response
@(posedge clk);
bready = 1'b0;
assert (bresp == 2'b00) else $error("AXI write error resp: %b", bresp);
endtaskExample C — Coverage-Driven Loop (foreach on covergroup bins)
// ── Coverage-driven test: run until all opcodes covered ───────────
typedef enum logic [2:0] {ADD,SUB,AND,OR,XOR,NOT,SHL,SHR} op_t;
logic [7:0] a, b;
op_t op;
bit opcode_hit[8]; // track which opcodes have been tested
initial begin
forever begin
@(posedge clk);
a = $urandom;
b = $urandom;
op = op_t'($urandom_range(0, 7));
opcode_hit[op] = 1'b1; // mark this opcode as hit
// Check if all opcodes have been exercised
begin
automatic bit all_hit = 1;
foreach (opcode_hit[i])
if (!opcode_hit[i]) all_hit = 0;
if (all_hit) begin
$display("All opcodes covered — stopping test");
break; // exit forever loop (see 5.5 for break/continue)
end
end
end
end🔬 Debugging Academy — 8 Real Loop Bugs from the Field
Each of these is a loop that behaved differently from how it reads. Four fail in simulation, three fail in synthesis, and one — the worst — fails by passing.
forever without a timing control hangs the simulator at time zero
ZERO-TIME-LOOP// ❌ BUG: stimulus driver with no timing control
initial begin
forever begin
data = $random; // no @, no #, no wait
valid = 1; // simulator loops here infinitely at T=0
end
end
// Symptom: simulation runs, CPU=100%, no output, no VCD, no $finish
// VCS: hangs indefinitely (default: no delta limit)
// Questa: hangs until timeout (default delta limit = 1 million)
// ✅ FIX: add clock-edge timing control
initial begin
forever begin
@(posedge clk); // ✅ yields time — simulation advances
data = $urandom_range(0, 255);
valid = 1'b1;
end
endThe forever body executes in the Active region of the simulation scheduler. Time only advances once the scheduler can drain every region and find no more events pending at the current timestep. A loop body with no @, #, or wait never yields, so the Active region never empties and the timestep never ends. The simulation is not slow — it is stopped, at T=0, running as fast as the machine allows.
The symptom set is distinctive once you have seen it: 100% CPU on one core, no console output, no VCD, and no $finish. Nothing is printed because nothing that would print has been reached; the VCD is empty because no time has passed to record.
Simulator behaviour differs, which matters when you are trying to get a stack trace rather than a hang:
- VCS hangs indefinitely by default.
+vcs+loopdetect(or+nbaopton some flows) makes it warn instead. - Questa applies a default iteration limit of one million, then aborts with
Maximum iteration limit exceededand the file and line — usually the fastest route to the culprit. - Xcelium behaves like Questa, with the limit configurable on the command line.
Learn your simulator's limit flag before you need it. Debugging this with a hang and no message is far harder than debugging it with a line number.
A for loop bound that is a signal cannot be unrolled
RUNTIME-BOUND// ❌ BUG: n is an input port — not a compile-time constant
module bad_rotate(
input logic [7:0] data,
input logic [2:0] n, // rotation amount — runtime signal
output logic [7:0] rotated
);
always_comb begin
rotated = data;
for(int i = 0; i < n; i++) // ❌ n is a signal — cannot unroll!
rotated = {rotated[6:0], rotated[7]};
end
endmodule
// Simulation: works fine — runs n iterations at runtime
// Synthesis: ERROR "Cannot evaluate loop bound 'n' at compile time"
// ✅ FIX: always unroll max, select with mux
always_comb begin
logic [7:0] stages[8];
stages[0] = data;
for(int i = 1; i <= 8; i++) // constant bound = 8 ✅
stages[i] = {stages[i-1][6:0], stages[i-1][7]};
rotated = stages[n]; // mux selects correct rotation
endSynthesis unrolls by evaluating the loop at elaboration time, which requires the trip count to be a constant then — a literal, a parameter, or a localparam. n is an input port, so its value exists only during simulation. There is no number for the tool to unroll to.
What makes this bug expensive is the asymmetry in when it is discovered. Simulation is perfectly happy: a procedural loop with a runtime bound is legal SystemVerilog and runs n iterations. The design passes every functional test, and the failure appears later, at synthesis, in someone else's queue.
The fix is the general transformation for this whole class: move the runtime value out of the bound and into the body. Unroll the maximum — eight stages, always present — and let n select among them. The hardware cost is the maximum case; the benefit is a static structure the tool can build. There is no way to build a circuit whose number of gates depends on a signal, and that is really what the original code was asking for.
A module-scope loop variable shared by nested loops silently truncates the outer loop
SCOPE-COLLISION// ❌ BUG: outer loop uses i, inner loop also uses i
integer i; // declared at module scope — shared variable!
initial begin
for (i = 0; i < 4; i++) begin // outer loop: row 0..3
for (i = 0; i < 4; i++) // ❌ inner loop reuses i → clobbers outer!
mem[i][i] = i; // only diagonal set (both i are same)
// After inner loop: i=4. Outer loop increments to 5 → loop ends!
// Only ONE outer iteration actually runs
end
end
// ✅ FIX: declare loop variables inside for (SV feature)
initial begin
for (int r = 0; r < 4; r++) begin // ✅ r is local to this for block
for (int c = 0; c < 4; c++) // ✅ c is local — no interference
mem[r][c] = r * 4 + c;
end
end
// Rule: ALWAYS declare loop variables inside the for() declaration in SV.
// Never use module-scope integer variables as loop counters.Both loops assign the same variable. The inner loop runs to completion and leaves i at 4; the outer loop's increment then takes it to 5, its condition i < 4 is false, and the outer loop exits after a single iteration. Sixteen assignments were intended; four occurred, all on the diagonal, because mem[i][i] uses the same index twice.
This is inherited from Verilog-2001, where loop variables had to be declared at module scope and reuse was easy. SystemVerilog fixed it by allowing the declaration inside the for header, which gives the variable automatic lifetime scoped to that loop. Two nested loops declared this way cannot collide, because they are different variables.
The reason this one survives review is that it produces no error and no warning — the code is legal, and the result is a plausible-looking partially-populated array. It usually surfaces as a checker mismatch on data nobody wrote, hours downstream of the loop that failed to write it. Declaring loop variables in the for header makes the bug unwritable, which is a better guarantee than remembering to avoid it.
repeat(0) runs no stimulus and the test reports PASS
VACUOUS-PASS// ❌ BUG: NUM_PKTS parameter accidentally set to 0
parameter NUM_PKTS = 0; // ← should be 100, but typo or misconfiguration
initial begin
@(posedge rst_n);
repeat(NUM_PKTS) begin // repeat(0) → body NEVER runs
send_packet();
end
$display("Test complete: sent %0d packets", NUM_PKTS);
$finish;
end
// Output: "Test complete: sent 0 packets"
// Simulation completes instantly. All checks pass (vacuously).
// Coverage: 0%. Regression: "PASS" — but no test was actually run!
// ✅ FIX: add assertion that loop count is non-zero
initial begin
assert (NUM_PKTS > 0) else $fatal(1, "NUM_PKTS must be > 0");
@(posedge rst_n);
repeat(NUM_PKTS) send_packet();
$finish;
endrepeat(0) is legal and executes its body zero times. Every subsequent check passes because nothing happened to violate it. The test finishes instantly, prints a success message, and the regression turns green.
This is the most dangerous shape a bug can take, because the failure signature is indistinguishable from success on every axis the regression reports. Exit status is zero. No assertion fired. The log even says what it did — "sent 0 packets" — but nobody reads a passing log.
The one signal that is available is coverage: a test that drove nothing covers nothing. Zero functional coverage alongside a passing test is not a coverage problem; it is a stimulus problem wearing a coverage problem's clothes. Any regression that reports pass/fail without also reporting coverage movement can hide this indefinitely.
The realistic path in is a parameter override — a plusarg or a config file setting NUM_PKTS to 0 for every directed test in a suite, while the suite continues to report success. The guard is cheap and belongs at the top of every stimulus loop: assert the count is non-zero before entering it, and treat a zero-iteration stimulus loop as a fatal configuration error rather than a quiet no-op.
An unsigned loop variable wraps instead of terminating
UNSIGNED-WRAP// ❌ BUG: bit-width loop variable wraps instead of terminating
initial begin
for (logic [3:0] i = 15; i >= 0; i--) begin
// ❌ PROBLEM: i is 4-bit unsigned
// When i=0 and we decrement: i wraps to 15 (0-1 = 4'b1111)
// Condition i >= 0 is ALWAYS true for unsigned!
// Loop runs forever: 15,14,13...0,15,14,13...0,15...
$display("%0d", i);
end
end
// ✅ FIX: use int (signed) for count-down loops
initial begin
for (int i = 15; i >= 0; i--) begin // ✅ int is signed — i goes to -1
$display("%0d", i); // -1 < 0 → loop terminates
end
end
// Rule: Always use signed int for loop variables. Use int, not logic, not bit.
// logic and bit are unsigned — count-down termination never works.logic [3:0] i is a 4-bit unsigned variable, so it can never hold a negative value. The intended terminating condition — i going below zero — is unreachable. Decrementing from 0 wraps to 4'b1111, and i >= 0 is true for every value an unsigned 4-bit variable can hold. The loop is infinite by construction.
The general rule is stronger than "use a wide enough type": any count-down loop whose termination depends on going below zero requires a signed loop variable. Widening logic [3:0] to logic [31:0] does not fix it, because the problem is signedness, not width. int is signed 32-bit and terminates correctly: i reaches −1, the condition is false, the loop exits.
This is also the strongest argument for int as the default loop-variable type. integer is the 4-state Verilog legacy equivalent — it works, but carries X-propagation cost in simulation for no benefit in a counter. bit and logic are unsigned and reintroduce exactly this bug. In synthesizable RTL the signedness of int changes nothing about the unrolled hardware; it only determines whether the elaboration-time termination check works.
foreach cannot iterate a packed dimension
PACKED-VS-UNPACKED// ❌ BUG: foreach only works on UNPACKED dimensions
logic [7:0] packed_byte; // packed array — 8-bit vector
logic [7:0] unpacked[4]; // unpacked array — 4 elements of 8-bit
// foreach on packed: ILLEGAL — compile error
foreach (packed_byte[i]) // ❌ ERROR: packed dimensions not allowed
$display("%b", packed_byte[i]);
// foreach on unpacked: LEGAL — iterates 4 elements
foreach (unpacked[i]) // ✅ iterates i=0,1,2,3
$display("%h", unpacked[i]);
// ✅ For packed bits: use for loop instead
for (int i = 0; i < 8; i++)
$display("%b", packed_byte[i]);
// ✅ For unpacked elements accessing packed contents:
foreach (unpacked[i]) // foreach for the unpacked dimension
for (int b = 0; b < 8; b++) // for loop for packed bits
parity ^= unpacked[i][b];foreach derives its loop variables and bounds from an array's unpacked dimensions. A packed dimension is not an array of elements — it is a contiguous vector of bits, addressed by part-select. There is nothing for foreach to enumerate, and the language rejects it at compile time.
The distinction is the same one that governs everything else about these two kinds of dimension: unpacked dimensions are a collection of objects, packed dimensions are a single integral value that happens to be indexable. A for loop with an explicit bound iterates bit positions of a vector; foreach iterates elements of a collection.
Mixed arrays are where this becomes practical rather than pedantic. logic [7:0] unpacked[4] has one unpacked dimension and one packed dimension, and the two need different loops: foreach for the four elements, for for the eight bits inside each. Writing foreach (unpacked[i][b]) does not reach the bits.
The compile error here is a mercy. The same confusion applied to a slice of a packed array fails silently at run time instead, returning X rather than erroring — see Fixed-Size Arrays.
A while loop with a data-dependent condition cannot be synthesized
DYNAMIC-TERMINATION// ❌ BUG: while condition depends on input signal — synthesis cannot unroll
always_comb begin
int tmp = data_in;
result = '0;
while (tmp != 0) begin // ❌ termination depends on data_in (runtime)
result = result + tmp[0]; // synthesis: cannot determine iterations
tmp = tmp >> 1;
end
end
// Synthesis ERROR: "Cannot evaluate loop bound at elaboration time"
// Note: simulation works perfectly — for testbench use this is fine!
// ✅ FIX: convert to for loop with max bound
always_comb begin
int tmp = data_in;
result = '0;
for(int i = 0; i < 32; i++) begin // ✅ constant 32 — always unrollable
result += tmp[0];
tmp = tmp >> 1;
end
endThe loop terminates when tmp reaches zero, and tmp is derived from an input. The number of iterations therefore depends on a runtime value, so there is no fixed number of body copies for the tool to emit.
Note carefully what is not being claimed. The rule is not "for is synthesizable and while is not" — that shorthand is wrong in both directions. A for loop with a signal bound is just as unsynthesizable (Bug 2), and a while loop whose bound a tool can prove is static may well unroll. What synthesis requires is a statically determinable trip count, whichever keyword produces it.
That said, "may well unroll" is a poor foundation for RTL. Whether a tool can prove a given while bound is tool-dependent and version-dependent, so a construct that synthesizes today can fail after a tool upgrade. In RTL, prefer a for loop with an explicit constant bound, where the trip count is visible to the reader as well as to the tool.
In a testbench, none of this applies: the while form is clearer and the runtime bound is exactly what you want.
A combinational block that reads and writes the same signal oscillates
DELTA-OSCILLATION// ❌ BUG: for loop writes 'accum' which is read by another always_comb
logic [7:0] accum;
always_comb begin // Block A: computes partial sums
accum = '0;
for (int i = 0; i < 8; i++)
accum += data[i];
end
always_comb begin // Block B: uses accum
result = accum * scale;
end
// This is actually CORRECT — accum changes once per Block A evaluation,
// triggers Block B once (delta 1), Block B output settles. Fine.
// ❌ REAL BUG: if Block A reads AND writes accum (combinational feedback)
always_comb begin
for (int i = 0; i < 8; i++) begin
if (data[i]) accum += data[i]; // reads accum in condition AND writes it
// accum changing triggers re-eval of this same block → delta oscillation
end
end
// ✅ FIX: use a local automatic variable inside always_comb
always_comb begin
automatic logic [7:0] tmp = '0; // local — no re-trigger
for (int i = 0; i < 8; i++)
if (data[i]) tmp += data[i];
accum = tmp; // write once to the actual signal
endThe important part of this example is the contrast between its two halves, because only one of them is a bug.
Block A writing accum and Block B reading it is correct. accum settles once per evaluation of Block A, triggers Block B once, and the pair converges in a single delta cycle. This is ordinary combinational fan-out and needs no fixing.
The bug is the second form, where one always_comb block both reads and writes accum. Each write schedules a re-evaluation of the block, whose next write schedules another, and the block never reaches a fixed point. The simulator burns delta cycles at the current timestep until it hits its limit or hangs — the same end state as Bug 1, reached by a different route.
The fix is to keep the accumulator out of the block's sensitivity. A variable declared automatic inside the block is not a signal; writing it schedules nothing. The loop accumulates into tmp, and accum is written exactly once, at the end. This is the general pattern for any iterative computation inside always_comb: accumulate locally, assign once.
Worth knowing for diagnosis: a delta-cycle oscillation looks identical to a zero-time loop from the outside — pegged CPU, no output, no time advance. The way to tell them apart is the iteration-limit message, which names the offending block.
💡 Senior Verification Engineer Tip: Always Declare Loop Variables with int, Not integer or bit
In SystemVerilog, int is a signed 32-bit 2-state variable — it is the correct type for loop counters. integer is the Verilog legacy 32-bit 4-state (can hold X/Z) — functionally equivalent but slower in simulation due to X-propagation overhead. bit and logic are unsigned and cause wrap-around bugs in count-down loops. The modern rule: declare loop variables inside for declarations as int — they are automatically scoped to the loop, cannot pollute outer scope, and perform optimally.
🎯 Interview Q&A — Loops in RTL and Verification
📚 Related Pages & References
Where loops meet the rest of the language. The distinction between packed and unpacked dimensions decides what foreach can iterate and what a part-select returns — Fixed-Size Arrays covers it, including a slicing trap that returns X rather than erroring. For the containers foreach handles but synthesis cannot, see Dynamic Arrays, Queues and Associative Arrays; Array Methods covers the reduction and locator methods that often replace a loop entirely.
For exiting a loop early rather than running it to completion, see break, continue, return & disable. For the scheduler behaviour behind zero-time loops and delta oscillation, see Procedural Blocks and Blocking vs Non-blocking Assignments — the latter explains why eight nonblocking assignments in one cycle leave only the last, which is the mechanism behind Debug Lab 9. For loops that spawn concurrent processes rather than iterating in sequence, see fork-join. For why int rather than integer or logic is the right loop-variable type, see Integer Types and 2-state vs 4-state Types.
References.
- IEEE 1800 (SystemVerilog) — the loop statements (
for,while,do-while,repeat,forever,foreach) are defined in the procedural statements clause;foreachis specified over an array's unpacked dimensions, which is why the packed form in Debug Lab 6 is a compile error rather than a run-time surprise. Generate constructs,genvar, and the elaboration-time semantics of generate loops are defined in the generate-constructs clause. The scheduling semantics behind zero-time loops and delta oscillation are in the scheduling-semantics clause. - IEEE 1364.1 (Verilog RTL Synthesis) — the historical statement of the synthesizable subset, including the requirement that loop bounds be determinable at elaboration.
Two things on this page are industry practice rather than language requirement, and are labelled as such where they appear: which loops a given synthesis tool will accept beyond the clearly-static cases, and the simulator-specific iteration-limit flags. IEEE 1800 defines the language; it does not define what any particular tool will synthesize. When a construct's synthesizability is tool-dependent, that is a reason to avoid it in RTL rather than a detail to memorise per vendor.
Part of SystemVerilog Fundamentals·Procedural Statements·Lesson 32 of 53
View program