SystemVerilog · Module 5
Procedural Blocks
initial, always, always_comb, always_ff, always_latch — what hardware each one infers.
Module 5 · Page 5.1
Procedural blocks are where RTL behaviour is written, and where most simulation/synthesis mismatches are born. The assignments used inside them are blocking vs non-blocking; the decision statements are case/casex/casez and if-else with unique/priority.
What Is a Procedural Block?
A procedural block is a container for sequential behavioral code. Everything inside a procedural block executes line by line, from top to bottom, like a programming language. Outside a procedural block — in the module body — every statement executes concurrently in parallel hardware.
SystemVerilog has five procedural block types. Each one has a different execution model and infers different hardware (or no hardware at all). Picking the right one is the most important RTL decision you make every time you write a block.
initial
Runs once at simulation time 0. Not synthesisable. Used in testbenches to drive stimulus and initialise memory.
always
Verilog legacy. Runs forever with a manual sensitivity list. Avoid in new SystemVerilog code — too ambiguous.
always_comb
SystemVerilog. Combinational logic. Auto-sensitivity list. Tool guarantees no latches. Use for all combinational RTL.
always_ff
SystemVerilog. Registered (flip-flop) logic. Requires a clock edge. Tool guarantees register inference. Use for all sequential RTL.
always_latch
SystemVerilog. Latch inference. Level-sensitive. Tool guarantees latch inference. Rarely needed — avoid unless interface demands it.
Figure 1 — When Each Block Executes During Simulation
Figure 1 — Execution model for each block. initial fires once; always_comb fires on any input change; always_ff fires only at clock edges; always_latch fires when the enable level is high.
initial — Runs Once at Time 0
An initial block begins executing at simulation time 0 and runs until it reaches the end — or a $finish. It never repeats. It is the primary tool of testbenches: apply reset, drive stimulus, check results, finish.
initial is not synthesisable. If you accidentally write it in RTL, synthesis tools either error or silently ignore it. Keep it in files that never go through synthesis.
// ── Testbench: clock gen + reset + stimulus ─────────────────────
module tb;
logic clk = 0;
logic rst_n;
logic [7:0] data;
// ① Clock generator — runs forever via always
always #5 clk = ~clk; // 10-unit period
// ② Reset + stimulus — runs once from T=0
initial begin
rst_n = 0; data = 8'h00;
@(posedge clk); @(posedge clk); // hold reset 2 cycles
rst_n = 1;
@(posedge clk); data = 8'hAA;
@(posedge clk); data = 8'h55;
@(posedge clk);
$display("Simulation complete");
$finish;
end
// ③ Memory pre-load — another initial block
initial begin
$readmemh("program.hex", u_dut.mem); // backdoor load
end
// ④ Multiple initial blocks run CONCURRENTLY starting at T=0
initial begin
$dumpfile("sim.vcd");
$dumpvars(0, tb); // VCD waveform dump
end
endmodule🧠 How the Simulator Actually Handles initial at T=0
All initial blocks are added to the event queue at simulation time 0. The simulator processes them in the Active region — but the order between multiple initial blocks is non-deterministic by the IEEE spec. In practice, most simulators (VCS, Questa, Xcelium) process them top-to-bottom in file order, but you must never depend on this. If two initial blocks write to the same signal at T=0, which value wins is tool-dependent. Design your testbench so initial blocks are independent — each owns distinct signals.
Waveform — initial block execution at T=0 with 2-cycle resetTime0 5 10 15 20 25 30clk0________1_______0________1_______0________1_______0rst_n0__________________________1 (released after 2 posedges)dataX______________________________________AA_____55phase│ initial fires │ @posedge×2 │ rst_n=1 │ stim │ stim │ finish
⚠ Common Industry Mistake: Using initial to Reset RTL Flip-Flops
Junior engineers sometimes write initial q = 0; inside RTL modules to avoid wiring up a reset. This works only in simulation — synthesis ignores it entirely. The flip-flop powers on to an unknown state in silicon. Always model reset in always_ff. This mistake appears constantly in code reviews and is a guaranteed flag during RTL sign-off.
always — Legacy Verilog (Avoid in New Code)
The plain always block is a general-purpose procedural loop that runs forever. It requires you to manually write a sensitivity list using @(*) or @(signal_list). The tool does not check what you intended — it will equally happily infer combinational logic, a flip-flop, or a latch from the same always block.
The core problem: an always block gives the tool zero information about your intent. If you forget a signal in the sensitivity list, you get a simulation–synthesis mismatch with no warning. SystemVerilog replaced it with three specialised blocks — always_comb, always_ff, always_latch — each of which tells the tool exactly what you mean.
// ❌ Legacy always — tool doesn't know your intent
always @(*) begin // @(*) = manual auto-sensitivity
y = a & b; // intended: combinational — but tool can't verify
end
always @(posedge clk) begin // intended: flip-flop — but tool can't verify
q <= d;
end
// ✅ Modern SystemVerilog — intent is explicit and verified by the tool
always_comb begin
y = a & b; // tool GUARANTEES combinational, auto-sensitivity
end
always_ff @(posedge clk) begin // tool GUARANTEES flip-flop inference
q <= d;
end
// ── The sensitivity list bug that always hides ──────────────────
always @(a) begin // ❌ b missing from list!
y = a & b; // simulation: y doesn't update when b changes
end // synthesis: works (uses all inputs)
// → sim/synth mismatch — impossible to debug
// always_comb would catch this immediately — no sensitivity list to get wrong🚀 RTL Design Insight: Why always Still Exists in SystemVerilog
always is in SystemVerilog purely for Verilog backward compatibility. Every major synthesis and lint tool (Spyglass, Synopsys Lint, Cadence JasperGold) flags unqualified always @(*) in new RTL as a lint warning. Most modern project lint rules enforce "No always @(*) in RTL — use always_comb" and "No always @(posedge clk) — use always_ff". If your project does not have these lint rules, add them. The migration cost is a one-time find-and-replace; the quality improvement is permanent.
🧠 How the Simulator Sees always @(*) vs always_comb
always @(*) builds its sensitivity list at elaboration time from the signals read during the first execution of the block. If a signal is only read on a conditional path that wasn't taken in the first evaluation, it may be absent from the sensitivity list — a subtle and hard-to-reproduce bug. always_comb builds its sensitivity list from static analysis of all possible read paths through the block, including all branches. This is the deeper reason always_comb is safer, even beyond the missing-signal problem.
always_comb — Combinational Logic
always_comb is the correct block for any logic that has no memory — a mux, a decoder, an adder, an ALU, a priority encoder. The tool automatically infers the sensitivity list from every signal read inside the block. You never need to write @(*) — and you never get the sensitivity list wrong.
The tool enforces two guarantees: (1) every output must be assigned on every possible path through the block — no latches can be inferred. If you forget a default, the tool errors. (2) no timing controls (#, @, wait) are allowed inside.
Figure 2 — always_comb Infers Pure Combinational Gates (No Memory)
Figure 2 — always_comb with a 4-to-1 mux infers pure combinational gates. No clock, no flip-flop, no memory element. Output changes immediately when any input changes.
// ── Rule 1: No sensitivity list — it's automatic ─────────────────
always_comb begin
y = a & b; // auto-senses {a, b} — no @(*) needed or allowed
end
// ── Rule 2: Every output must be assigned on ALL paths ────────────
always_comb begin
out = 4'h0; // ← default assignment: prevents latch
if (enable)
out = data_in; // overrides default when enable=1
end
// ── Rule 3: Functions are fine inside always_comb ─────────────────
function automatic logic [2:0] priority_enc(input logic [7:0] req);
priority_enc = 3'b0;
for(int i=7; i>=0; i--) if(req[i]) priority_enc = i[2:0];
endfunction
always_comb begin
grant = 8'h0;
grant_id = priority_enc(request); // function call in always_comb ✅
if (|request)
grant[grant_id] = 1;
end
// ── What always_comb CANNOT contain ──────────────────────────────
always_comb begin
// @(posedge clk); ← ILLEGAL — timing control in always_comb
// #10; ← ILLEGAL — delay in always_comb
// wait(valid); ← ILLEGAL — wait in always_comb
y = a ^ b; // ✅ pure combinational
endalways_comb Pitfalls Engineers Hit in Real Projects
These are not textbook edge cases — they appear in peer code reviews every week on real RTL projects. ❌ Pitfall 1 — Task call with timing// always_comb cannot call tasks // that contain timing controls task capture(output logic v); @(posedge clk); // ← timing! v = bus_data; endtask always_comb begin capture(result); // COMPILE ERROR end✅ Fix — Use functions only// Functions: no timing controls // allowed — safe in always_comb function automatic logic [7:0] decode(input logic [3:0] op); case (op) 4'h0: decode = 8'hA5; default: decode = 8'h00; endcase endfunction always_comb out = decode(op);❌ Pitfall 2 — Writing to same var in two always_comb// Two blocks both drive 'grant' always_comb begin grant = req_a ? 2'b01 : 2'b00; end always_comb begin grant = req_b ? 2'b10 : 2'b00; end // → Multi-driver: grant becomes X // → Tool ERROR or undefined result✅ Fix — Single always_comb with priority// One block owns one output always_comb begin grant = 2'b00; // default if (req_a) grant = 2'b01; else if (req_b) grant = 2'b10; end // One driver, clear priority. // No ambiguity in sim or synth.❌ Pitfall 3 — Combinational feedback loop// Output feeds back into input always_comb begin a = b & c; b = a | d; // b depends on a! end // → Simulator: X oscillation // → Synthesis: LOOP error // → Real chip: metastability✅ Fix — Register to break loop// Separate with register stage always_comb begin a = b_reg & c; // uses registered b next_b = a | d; end always_ff @(posedge clk) begin b_reg <= next_b; // pipeline break end
🏗 Synthesis Concern: always_comb Fires at T=0 (IEEE Spec)
Unlike always @(*), the always_comb block is guaranteed by the IEEE 1800 spec to evaluate once at simulation time 0, even before any events have been triggered. This ensures combinational outputs are valid from the very first simulation instant — outputs won't show as X if inputs are driven. This is one of the subtle reasons always_comb is superior to always @(*) even when they appear equivalent.
always_ff — Registered (Flip-Flop) Logic
always_ff is the correct block for all sequential (clocked) logic — shift registers, counters, state machines, pipeline stages, anything with a flip-flop. It requires at least one clock edge in the sensitivity list (posedge clk or negedge clk). The tool guarantees that only registers are inferred — no latches.
Figure 3 — always_ff Infers Flip-Flops (Registers with Memory)
Figure 3 — always_ff infers a flip-flop (D-type register). The output q changes only on the rising clock edge. The async reset sets q=0 immediately regardless of clock.
// ── Pattern 1: Asynchronous active-low reset (most common in ASIC) ─
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= 8'h00; // async reset: fires WITHOUT clock
else q <= d;
end
// ── Pattern 2: Synchronous reset (common in FPGA) ─────────────────
always_ff @(posedge clk) begin // only posedge in sensitivity list
if (!rst_n) q <= 8'h00; // sync reset: fires WITH clock
else q <= d;
end
// ── Pattern 3: 8-bit counter with enable ────────────────────────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) count <= 8'h00;
else if (en) count <= count + 8'h01;
end
// ── Pattern 4: 3-state FSM ───────────────────────────────────────
typedef enum logic [1:0] {IDLE, BUSY, DONE} state_t;
state_t state, next_state;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) state <= IDLE;
else state <= next_state; // registers the NEXT state
end
always_comb begin // next-state logic is COMBINATIONAL
next_state = state;
case (state)
IDLE: if (start) next_state = BUSY;
BUSY: if (done) next_state = DONE;
DONE: next_state = IDLE;
endcase
end
// ── Rule: always use <= (non-blocking) inside always_ff ──────────
always_ff @(posedge clk) begin
q1 <= d; // ✅ non-blocking: models a real register correctly
// q1 = d; ← ❌ blocking in always_ff: timing hazard (covered in 5.6)
endAsync vs Synchronous Reset — The Real Engineering Trade-off
This is one of the most discussed topics in ASIC interviews and code reviews. The choice is not about preference — it has real timing and DFT implications.
| Aspect | Asynchronous Reset | Synchronous Reset |
|---|---|---|
| Sensitivity list | @(posedge clk or negedge rst_n) | @(posedge clk) |
| Reset timing | Fires immediately — no clock needed | Fires only at next posedge clk |
| ASIC suitability | Preferred — can reset even with clock stopped | Acceptable but needs clock to be running |
| FPGA suitability | Supported but uses dedicated async reset path | Preferred — maps to synchronous reset in fabric |
| Timing closure | Harder — async reset path needs special STA rules | Easier — reset treated as data path |
| Reset glitch risk | Higher — a glitch on rst_n can accidentally reset | Lower — glitches filtered by clock edge |
| Safe deassertion | Requires reset synchronizer (2-FF synchronizer) | Built-in — deassertion is synchronous by nature |
// ── The Reset Synchronizer — industry standard for async reset ──
// Problem: Async reset deassertion can violate setup/hold on FF.
// Solution: Synchronize the deassertion to the clock domain.
module reset_sync #(parameter int STAGES = 2) (
input logic clk,
input logic rst_n_async, // async from power-on or pin
output logic rst_n_sync // synchronized, safe to use
);
logic [STAGES-1:0] sync_chain;
always_ff @(posedge clk or negedge rst_n_async) begin
if (!rst_n_async)
sync_chain <= '0; // async assert propagates immediately
else
sync_chain <= {sync_chain[STAGES-2:0], 1'b1};
end
assign rst_n_sync = sync_chain[STAGES-1];
// Deassertion travels through N flip-flops — synchronized to clk.
// All downstream FFs see clean synchronous deassertion.
endmodule
// ── 2-stage Pipeline using always_ff — waveform-correct model ───
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stage1 <= '0;
stage2 <= '0;
end else begin
stage1 <= data_in; // RHS captured at clock edge (old value)
stage2 <= stage1; // RHS = old stage1 — correct 2-cycle latency
end // LHS updates in NBA region after both evaluate
endWaveform — Non-blocking correctly models 2-stage pipelineclk_‾‾‾‾‾_data_in A B C D Estage1 X →A → →B → →C → →D ← 1 cycle latencystage2 X X →A → →B → →C ← 2 cycle latency └── both update simultaneously in NBA region (non-blocking) ──┘
🔍 Debugging Insight: Blocking = in always_ff Is the #1 RTL Bug
When you use blocking (=) in always_ff, the RHS is evaluated AND written immediately in the Active region. So stage2 = stage1 reads the already-updated stage1 value — both registers end up holding the same value. This is not a 2-stage pipeline, it's a 1-stage pipeline with an alias. The waveform looks wrong, bugs appear non-deterministically, and the behavior differs between simulators. Always use <= in always_ff. No exceptions.
always_latch — Level-Sensitive Latch
always_latch explicitly declares that you intend to infer a latch — a level-sensitive storage element that holds its value when the enable is low and is transparent when the enable is high.
Latches are almost never the right choice in synchronous digital design. They cause timing closure problems in ASIC flows, are harder to test, and often appear accidentally from an incomplete if or case statement in an always_comb block. The only legitimate use is an intentional latch demanded by a specific interface protocol (e.g., some bus standards).
Figure 4 — Latch (Level-Sensitive) vs Flip-Flop (Edge-Triggered)
Figure 4 — A latch (left) is transparent when enable=1: output follows input in real time. A flip-flop (right) captures the input only at the clock edge and holds it until the next edge.
// ── Intentional latch: always_latch makes intent clear ─────────
always_latch begin
if (enable)
q = d; // transparent when enable=1
// holds q when enable=0 (latch behaviour)
end
// ── Accidental latch from always_comb (common mistake) ──────────
always_comb begin
if (enable)
out = data; // ❌ 'out' not assigned when enable=0
end // → tool ERRORS: "always_comb infers a latch"
// Fix: add default assignment at top
always_comb begin
out = '0; // ✅ default: no latch possible
if (enable)
out = data;
end
// ── When you might legitimately use always_latch ────────────────
// Specific bus protocols (some older standards) require transparent latches
// for address/data hold. Outside of that: always prefer always_comb or always_ff.Diagnosing Accidental Latch Inference
When you see a latch warning from the synthesis tool, here is the exact diagnostic process used in real RTL sign-off reviews:
- **** — Identify the signal name from the warning message:
"Latch inferred for signal 'result' in module 'alu'" - **** — Find every code path through the always_comb block. Draw them out if needed. Ask: "Is
resultassigned on ALL paths?" - **** — Find the missing path — it is always an
ifbranch without anelse, or acasewithout adefault, or a case item that misses one signal. - **** — Add a default assignment at the very top of the block:
result = '0;. This covers all paths. The latch warning disappears instantly. - **** — Verify in simulation: confirm the output now drives 0 for the previously uncovered path, not the old held value. This is a functional change — re-run regressions.
// ── Root Cause 1: if without else ────────────────────────────────
always_comb begin
if (valid) data_out = fifo_data; // ❌ what when valid=0? → latch
end
always_comb begin
data_out = '0; // ✅ default → no latch
if (valid) data_out = fifo_data;
end
// ── Root Cause 2: case missing default, one signal unassigned ────
always_comb begin
case (opcode)
2'b00: begin result = a + b; carry = 1'b0; end
2'b01: begin result = a - b; end // ❌ carry missing here → latch on carry
default: begin result = '0; carry = 1'b0; end
endcase
end
// ✅ Fix: assign carry at top, OR assign in every case arm
always_comb begin
{carry, result} = '0; // ✅ default: both covered
case (opcode)
2'b00: {carry, result} = a + b;
2'b01: result = a - b; // carry stays 0 from default
endcase
end
// ── Root Cause 3: Nested if — inner branch misses assignment ─────
always_comb begin
out = '0; // outer default: not enough!
if (mode) begin
if (enable) out = data; // ✅ covered
// mode=1, enable=0 → out not assigned here
// BUT outer default covers it! Wait — does it?
// YES — out='0 at top covers ALL paths including this one.
// Lesson: one default at the TOP covers ALL nested paths.
end
end🚀 RTL Design Insight: When a Latch IS the Right Answer
In specific protocol interfaces — some legacy bus standards, certain clock-gating cell structures, and address latching in microprocessor designs — a transparent latch is required. In these cases, use always_latch explicitly and add a comment explaining the protocol requirement. The tool will correctly infer and constrain the latch path. Your STA engineer will thank you for the clear intent signal instead of discovering an accidental latch during timing sign-off.
Proving the latch — the failure always_comb was designed to catch
The section above says an incomplete assignment infers a latch. That is checkable, and the check is worth running once because the symptom is memory in something you believed was combinational — which reads on a waveform as a signal that refuses to change.
// ─────────────────────────────────────────────────────────────────────────────
// Three decoders, same intent, three outcomes:
// A: always_comb with an unassigned path -> LATCH (holds the old value)
// B: always_comb with a default assigned -> pure combinational
// C: always_latch -> a latch, but a DECLARED one
//
// vcs -sverilog latch_proof.sv && ./simv | xrun -sv latch_proof.sv
// ─────────────────────────────────────────────────────────────────────────────
module latch_proof;
timeunit 1ns; timeprecision 1ps;
logic [1:0] sel;
logic [3:0] out_latch, out_comb, out_declared;
logic en;
int errors = 0;
// ── A: sel==3 assigns nothing, so out_latch must HOLD. That retention is
// the inferred latch - storage nobody asked for.
always_comb begin
case (sel)
2'd0: out_latch = 4'h1;
2'd1: out_latch = 4'h2;
2'd2: out_latch = 4'h4;
// 2'd3: no assignment -> holds previous value
endcase
end
// ── B: the fix. Assign a default FIRST, then let the case override it.
// Every path now writes out_comb, so nothing is retained.
always_comb begin
out_comb = 4'h0; // default - this line is the whole fix
case (sel)
2'd0: out_comb = 4'h1;
2'd1: out_comb = 4'h2;
2'd2: out_comb = 4'h4;
default: ; // documented: default already applied
endcase
end
// ── C: a latch on purpose. Same storage as A, but declared - so a reviewer
// and the linter both see a decision rather than an accident.
always_latch
if (en) out_declared <= {2'b00, sel};
task automatic check(string label, logic [3:0] got, logic [3:0] exp);
if (got !== exp) begin
errors++;
$display(" ** FAIL %-22s got 0x%0h, expected 0x%0h", label, got, exp);
end else
$display(" ok %-22s 0x%0h", label, got);
endtask
initial begin
en = 1'b0;
sel = 2'd2; #1;
check("sel=2 latch version", out_latch, 4'h4);
check("sel=2 comb version", out_comb, 4'h4);
// The moment of truth: move to the unassigned path.
sel = 2'd3; #1;
// A retains 0x4 - it is storing. B goes to its default - it is logic.
check("sel=3 latch RETAINED", out_latch, 4'h4); // proves the latch
check("sel=3 comb defaulted", out_comb, 4'h0); // proves no storage
if (errors == 0)
$display("\n [PASS] the unassigned path retained its value - that retention IS the latch");
else
$display("\n [FAIL] %0d check(s) failed", errors);
$finish;
end
endmodule ok sel=2 latch version 0x4
ok sel=2 comb version 0x4
ok sel=3 latch RETAINED 0x4
ok sel=3 comb defaulted 0x0
[PASS] the unassigned path retained its value - that retention IS the latchRead the third line as the definition. out_latch is 0x4 at sel==3 not because anything computed 0x4, but because nothing computed anything and the old value survived. Surviving across an input change is precisely what storage means — the latch is not a tool's interpretation of your code, it is the only hardware that reproduces that waveform.
Two things worth doing to this file. Comment out the out_comb = 4'h0; default and out_comb behaves exactly like out_latch — one line separates logic from memory. And run it through synthesis or a linter: version A produces a latch-inference warning, version C produces none, because always_latch says you meant it.
Complete Comparison — All Five Blocks
| Block | Fires When | Hardware Inferred | Synthesisable? | Assignment Type | Use For |
|---|---|---|---|---|---|
initial | Once at T=0 | None | No — TB only | = (blocking) | Testbench stimulus, memory init, VCD setup, watchdog |
always | On sensitivity list events (loops forever) | Ambiguous — depends on code | Yes (avoid in new code) | = or <= depending on use | Legacy Verilog; clock generator in testbench only |
always_comb | Any input change + once at T=0 | Combinational gates — no memory | Yes | = (blocking only) | All combinational RTL: muxes, decoders, ALU, next-state logic |
always_ff | Clock edge (posedge or negedge only) | Flip-flops (D-type registers) | Yes | <= (non-blocking — mandatory) | All sequential RTL: state registers, counters, pipelines, FSM state |
always_latch | When enable is HIGH (level-sensitive) | Transparent latch | Yes (use sparingly) | = (blocking) | Only when specific protocol mandates a latch — rare |
🏗 Synthesis Concern: What Each Block Tells the Synthesis Tool
Synthesis tools use procedural block types as intent declarations. always_ff tells the tool: "infer exactly one register per LHS signal — if you can't, error." always_comb tells the tool: "infer zero registers — if any path causes latch inference, error." always_latch tells the tool: "infer a latch — this is intentional." These constraints allow synthesis to verify your intent, not just blindly translate code. Tools like Synopsys Design Compiler and Cadence Genus use these annotations to generate better QoR (Quality of Results) because they know exactly what type of hardware was intended.
Common Mistakes
// ════ MISTAKE 1: Putting combinational logic in always_ff ════════
always_ff @(posedge clk) begin
y = a & b; // ❌ combinational inside always_ff — adds unwanted register
end
// ✅ FIX: use always_comb for combinational logic
always_comb begin
y = a & b; // ✅ pure combinational — no register inferred
end
// ════ MISTAKE 2: Missing default in always_comb → latch ══════════
always_comb begin
if (sel) out = a; // ❌ what is 'out' when sel=0? → latch inferred → tool errors
end
// ✅ FIX: default assignment at the top
always_comb begin
out = '0; // ✅ default covers sel=0 path
if (sel) out = a;
end
// ════ MISTAKE 3: Writing initial inside RTL (not testbench) ══════
module bad_rtl (...);
initial q = 0; // ❌ initial in RTL — synthesis ignores or errors
// ✅ FIX: use reset in always_ff instead
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= 0; // ✅ synthesisable reset
else q <= d;
end
endmodule
// ════ MISTAKE 4: Blocking assignment (=) inside always_ff ════════
always_ff @(posedge clk) begin
q1 = d; // ❌ blocking in always_ff — race condition (covered in 5.6)
q2 = q1; // q2 gets new q1 — behaviour differs from <= version!
end
// ✅ FIX: always use non-blocking (<=) in always_ff
always_ff @(posedge clk) begin
q1 <= d; // ✅ evaluates RHS at clock edge, updates after
q2 <= q1; // uses OLD q1 — models a 2-stage pipeline correctly
end⚠ Common Industry Mistake: Mixing = and <= in the Same always_ff
Some engineers use blocking for intermediate calculations inside always_ff, thinking it's harmless: tmp = a + b; result <= tmp; — this looks correct but has non-deterministic behavior. The value of tmp depends on when result <= evaluates relative to other blocks that also read tmp. Different simulators will give different answers. Use a local variable declared with automatic, or restructure using functions. Never mix blocking and non-blocking in the same always_ff block.
Quick Reference — Procedural Blocks Cheat Sheet
// ── initial: runs once, testbench only ────────────────────────
initial begin
rst_n = 0; @(posedge clk); rst_n = 1;
$finish;
end
// ── always: legacy — only use for clock gen ────────────────────
always #5 clk = ~clk; // testbench clock generator
// ── always_comb: combinational RTL ────────────────────────────
always_comb begin
out = '0; // default first — prevents latches
if (en) out = data; // auto-sensitivity, no @(*) needed
end
// ── always_ff: sequential RTL ─────────────────────────────────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= '0; // async reset
else q <= d; // use <= (non-blocking)
end
// ── always_latch: intentional latch (rarely needed) ────────────
always_latch begin
if (enable) q = d; // transparent when enable=1
end
// ── Key rules ─────────────────────────────────────────────────
// always_comb: no @, no #, always add a default assignment
// always_ff: always use <= (non-blocking), include reset
// initial: testbench only — never in RTL intended for synthesis
// always: avoid — use always_comb or always_ff instead
// always_latch: avoid — almost always a design error🧠 How the Simulator Schedules Events — Delta Cycles Explained
Every SystemVerilog simulator is an event-driven engine. It does not execute statements continuously — it processes a list of events at each simulation time step. Understanding this model is what separates engineers who can debug race conditions from those who can't.
The Simulation Time Wheel and Event Regions
At each simulation time step, the simulator processes events in a defined order of regions. The IEEE 1800 standard defines these regions. For RTL engineers, the critical ones are:
| Region | What Happens | Relevant To |
|---|---|---|
| Active | Blocking assignments (=) evaluate and update. Continuous assignments. Current-time input port changes. $display executes. | All blocking logic, always_comb, input changes |
| Inactive | #0 delay events. Rarely used — avoid #0 in RTL. | Legacy workarounds |
| NBA | Non-blocking assignment (<=) updates apply. The RHS was evaluated in Active; now the LHS is updated. | always_ff, all non-blocking assignments |
| Observed | SVA concurrent assertions sample values. | Assertions (assert property) |
| Reactive | Program blocks, clocking blocks evaluate. Testbench sampling. | UVM drivers/monitors in program blocks |
| Postponed | $strobe and $monitor display. Final stable values for the time step. | Debug displays showing post-NBA values |
Delta Cycles — Zero-Time Iterations
A delta cycle is an iteration of the Active→NBA→Active loop that occurs at the same simulation time. When a non-blocking assignment updates a signal (NBA region), that update may trigger an always_comb block to re-evaluate (Active region), which may cause another update, triggering another iteration — all at the same timestamp. This is a delta cycle.
module delta_demo;
logic clk = 0;
logic [7:0] d, q, doubled;
always #5 clk = ~clk;
// Sequential: captures d on posedge clk
always_ff @(posedge clk) q <= d;
// Combinational: computes doubled from q
always_comb doubled = q << 1;
// ── What happens at posedge clk? ──────────────────────────────
// T=5ns, Delta 0 (Active): always_ff evaluates: RHS d is read (old d)
// T=5ns, Delta 0 (NBA): q gets new value (= old d)
// T=5ns, Delta 1 (Active): always_comb sees q changed → re-evaluates
// doubled = q << 1 (uses new q)
// T=5ns, Delta 1 (Postponed):$strobe shows final stable doubled value
// ─────────────────────────────────────────────────────────────
// $display at posedge might show OLD doubled (Delta 0).
// $strobe at posedge shows NEW doubled (Postponed — final stable).
// This is why verification engineers use $strobe not $display for FF outputs!
initial begin
d = 8'h0A;
@(posedge clk);
$display("$display: q=%0h doubled=%0h", q, doubled); // may show stale
$strobe ("$strobe: q=%0h doubled=%0h", q, doubled); // shows final
$finish;
end
endmodule
// ── Expected Output ───────────────────────────────────────────────
// $display: q=0a doubled=00 ← caught between Delta 0 and Delta 1
// $strobe: q=0a doubled=14 ← after Delta 1 settles (0x0A<<1=0x14)Delta Cycle Timeline at T=5ns (posedge clk)T=5nsRegionActive Δ0 │ NBA Δ0 │ Active Δ1 │ PostponedEventalways_ff reads d │ q ← old d │ comb sees q↑ │ $strobe firesq= old_q (unchanged)│= new_q(=d) │ new_q (visible) │ stabledoubled= old_q<<1 │ unchanged │= new_q<<1 │ stable$displayfires here ──────┘ ← may show stale doubled!$strobe fires here ─┘
💡 Senior Verification Engineer Tip: Use $strobe for Post-NBA Sampling
In testbench code, $display executes in the Active region — it may capture values before non-blocking updates have committed. $strobe executes in the Postponed region — after all NBA updates and combinational re-evaluations have settled. For monitoring flip-flop outputs, always use $strobe. For UVM monitors in program blocks, the clocking block automatically samples in the Observed region — which is why clocking blocks exist.
🔬 Procedural Blocks in Real Verification Environments
Every component of a UVM or directed-test environment is built on procedural blocks. Understanding which block is right for which verification task is what makes the difference between a testbench that's easy to debug and one that has subtle ordering bugs.
| TB Component | Block Used | Why That Block | Pattern |
|---|---|---|---|
| Clock generator | always | Needs to loop forever, toggle every half-period. No sensitivity list needed. | always #5 clk = ~clk; |
| Reset driver | initial | Runs once, holds reset for N cycles then releases. | initial begin rst=1; repeat(5) @(posedge clk); rst=0; end |
| Stimulus driver | initial or task called from initial | Drives sequences of values, waits for handshakes, checks responses. | Sequence of @(posedge clk) and signal drives |
| Signal monitor | always_ff-style sampling | Needs to capture output at every clock edge — edge-triggered behavior. | always @(posedge clk) if (valid_out) capture(dout); |
| Combinational checker | always_comb | Checks combinational outputs continuously — fires whenever output changes. | always_comb assert(parity == ^data); |
| Protocol monitor | always @(posedge clk) | Samples interface signals at clock edges to detect protocol violations. | State machine checking handshake rules |
| Coverage sampler | always @(posedge clk) | Samples covergroup at clock edge for accurate cycle-accurate coverage. | always @(posedge clk) cg.sample(); |
| Watchdog | initial with timeout | Fires once, waits for maximum simulation time, then calls $fatal. | initial begin #MAX_TIME; $fatal("Watchdog!"); |
module tb_fifo;
// ── Signals ───────────────────────────────────────────────────
logic clk, rst_n;
logic wr_en, rd_en;
logic [7:0] wr_data, rd_data;
logic full, empty;
int error_count = 0;
// ── DUT instantiation ────────────────────────────────────────
fifo_8x16 u_dut (.clk,.rst_n,.wr_en,.rd_en,.wr_data,.rd_data,.full,.empty);
// ── ① Clock generator: always (only legitimate use) ─────────
always #5 clk = ~clk;
// ── ② Reset generator: initial (runs once at T=0) ────────────
initial begin
clk = 0; rst_n = 0; wr_en = 0; rd_en = 0;
repeat(4) @(posedge clk);
rst_n = 1;
end
// ── ③ Stimulus driver: initial with task calls ───────────────
initial begin
@(posedge rst_n); // wait for reset to deassert
repeat(2) @(posedge clk);
write_word(8'hA5);
write_word(8'h3C);
read_word();
read_word();
repeat(2) @(posedge clk);
$display("Errors: %0d", error_count);
$finish;
end
// ── ④ Monitor + scoreboard: always (edge-triggered sampling) ─
logic [7:0] ref_queue[$];
always @(posedge clk) begin
if (wr_en && !full) ref_queue.push_back(wr_data);
if (rd_en && !empty) begin
automatic logic [7:0] exp = ref_queue.pop_front();
if (rd_data !== exp) begin
$error("MISMATCH: got %0h exp %0h", rd_data, exp);
error_count++;
end
end
end
// ── ⑤ Combinational assertion: always_comb ───────────────────
always_comb begin
assert (!(full && empty)) // FIFO can't be both full AND empty
else $fatal(1, "FIFO state machine error!");
end
// ── ⑥ Watchdog: initial (fires once, kills runaway sim) ──────
initial begin
#100_000;
$fatal(1, "Watchdog timeout — simulation stuck!");
end
// ── Tasks ─────────────────────────────────────────────────────
task automatic write_word(input logic [7:0] data);
@(posedge clk); wr_en = 1; wr_data = data;
@(posedge clk); wr_en = 0;
endtask
task automatic read_word();
@(posedge clk); rd_en = 1;
@(posedge clk); rd_en = 0;
endtask
endmodule⚡ Race Condition Analysis — Why They Happen and How to Find Them
A race condition in SystemVerilog simulation occurs when the outcome of a computation depends on the non-deterministic scheduling order of concurrent processes. The IEEE simulator standard allows tools to process events in any order within the Active region — so two always blocks at the same posedge clock have no guaranteed execution order relative to each other.
Race Type 1: Blocking Assignment Across always_ff Blocks
❌ Race Condition — Non-deterministic Resultlogic [7:0] tmp, a, b, c; // Block 1 — runs at posedge clk always @(posedge clk) begin tmp = a; // blocking: writes tmp b = tmp; // reads tmp immediately end // Block 2 — SAME posedge clk always @(posedge clk) begin tmp = c; // blocking: also writes tmp! end // Result: b = a or c depending on // which block the simulator runs first // VCS may give different answer than // Questa. This is a real project bug.✅ Fix — Non-blocking Eliminates the Racelogic [7:0] tmp, a, b, c; // Block 1 — posedge clk always_ff @(posedge clk) begin tmp <= a; // NBA: evaluated now b <= tmp; // uses OLD tmp value end // both update in NBA region // Block 2 — posedge clk always_ff @(posedge clk) begin tmp <= c; // DIFFERENT register! end // ← actually this is still // a multi-driver issue — // better to merge into one FF
Race Type 2: Reading a Signal That Another always_comb Is Writing
// ── This is NOT a race — this is correct combinational chaining ──
always_comb mid = a & b; // Block 1
always_comb out = mid | c; // Block 2 reads mid
// When a changes:
// Delta 0: Block 1 fires, mid updates
// Delta 1: Block 2 sees mid changed, fires, out updates
// Delta 2: Nothing changes → converges. Correct behavior.
// always_comb chains settle through delta cycles automatically.
// ── THIS is a race / oscillation ─────────────────────────────────
always_comb a = b ^ c; // a depends on b
always_comb b = a & d; // b depends on a — FEEDBACK LOOP!
// When b changes → a changes → b changes → a changes → ... forever
// Simulator detects delta cycle limit exceeded → error or X
// VCS: "Combinational loop detected"
// Questa: "Iteration limit exceeded"
// This produces X in simulation, oscillation in real silicon
// ── Identifying race conditions in waveform ───────────────────────
// Symptom 1: Signal toggles X within same timestamp
// Symptom 2: $display shows different value than waveform
// Symptom 3: Simulation gives different result on rerun with different seed
// Symptom 4: Two simulators give different RTL results on same testbench🔍 Debugging Insight: Race Conditions Show Differently in Each Simulator
If your RTL passes in VCS but fails in Questa (or vice versa), the first thing to check is blocking assignment usage in always_ff and multi-driver nets. The simulator that "passes" is not correct — it's just happening to schedule events in an order that produces the expected result. The other simulator reveals the true undefined behavior. Resolution: eliminate all blocking assignments from always_ff, ensure each net has exactly one driver.
⚙ Synthesis vs Simulation Mismatch — The Silent Killer
A simulation-synthesis mismatch means your RTL simulates correctly but the actual synthesized netlist behaves differently. This can lead to silicon bugs that only appear after tape-out — the most expensive bugs in semiconductor design. All five causes below stem from misusing procedural blocks.
| Root Cause | Simulation Behavior | Synthesis Behavior | Prevention |
|---|---|---|---|
Incomplete sensitivity list in always @(a,b) with c also used | Output doesn't update when c changes (simulation misses the event) | Output correctly updates when c changes (synthesis uses all inputs) | Use always_comb — eliminates the bug entirely |
Blocking = in always_ff | Race condition — result depends on simulator scheduling | Synthesis may create correct register chain or may not — tool-dependent | Use <= exclusively in always_ff |
| Latch inferred accidentally (missing default) | Output holds old value when condition is false | Latch is synthesized — timing analysis fails or latch behaves differently under scan | Use always_comb — tool forces you to add default |
initial used to initialize RTL registers | Register starts at 0 in simulation | Register powers on to X — no reset exists in netlist | Use reset in always_ff |
| Async reset sensitivity missing from list | Always block only fires on clock — reset has no effect in simulation | Synthesis may or may not infer async reset correctly | Always include or negedge rst_n for async reset |
// ── The Bug: incomplete sensitivity list in legacy always ─────────
module adder_bad (
input logic [7:0] a, b, c,
output logic [8:0] sum
);
always @(a, b) begin // ❌ c missing from sensitivity list!
sum = a + b + c; // sim: sum only updates when a or b changes
end // synth: sum correctly updates when c changes
endmodule
// ── Symptoms in simulation ────────────────────────────────────────
// At T=10: a=3, b=5, c=2 → sum=10 (a+b+c, a changed) ← correct
// At T=20: c=7 (only c changes) → sum stays 10! ← WRONG in sim
// At T=30: a=4 → sum=4+5+7=16 ← correct again (but c=7 now used)
// Synthesis netlist: sum always = a+b+c → different behavior!
// ── The Fix: always_comb — problem impossible ─────────────────────
module adder_good (
input logic [7:0] a, b, c,
output logic [8:0] sum
);
always_comb
sum = a + b + c; // auto-sensitivity: {a,b,c} — always correct
endmodule🚀 RTL Design Insight: always_comb, always_ff, always_latch Eliminate the Entire Bug Class
The three SystemVerilog-specific blocks were designed precisely to eliminate simulation-synthesis mismatches. always_comb auto-sensitivity eliminates sensitivity list bugs. always_ff intent declaration eliminates ambiguous register inference. always_latch intent declaration eliminates accidental latch inference. A codebase written entirely with these three blocks and zero always @() or always @(*) has an entire category of mismatch bugs eliminated by construction — not by code review.
⏱ Reset Modeling Strategies — ASIC vs FPGA vs Simulation
Reset is the most safety-critical signal in a synchronous design. A wrong reset model means the chip starts up in an undefined state. Here are the patterns used in production RTL, with the reasoning behind each choice.
// ── Pattern 1: Async Active-Low Reset (ASIC standard) ────────────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= '0; // fires immediately when rst_n goes low
else q <= d; // normal operation
end
// Synthesis: infers DFF with asynchronous active-low clear
// Timing: rst_n path constrained by async check (recovery, removal)
// ── Pattern 2: Sync Active-Low Reset (FPGA standard) ─────────────
always_ff @(posedge clk) begin
if (!rst_n) q <= '0; // fires at next posedge clk after rst_n low
else q <= d;
end
// Synthesis: infers DFF with synchronous reset — clean STA
// Concern: if clk stops, sync reset cannot clear the register
// ── Pattern 3: Async Assert, Sync Deassert (best of both) ────────
// Use a reset synchronizer (see always_ff section above) to produce
// rst_n_sync, then use it as async reset input — assertion is still
// immediate, but deassertion is synchronous to clock. This is the
// gold standard in ASIC methodology.
// ── Pattern 4: Reset with Set-able Default (not all 0) ───────────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) state <= IDLE; // reset to IDLE, not 0
else state <= next_state;
end
// Synthesis: infers DFF with preset/clear depending on tool
// Concern: some cells don't support non-zero reset — check lib
// ── Pattern 5: Conditional Reset (only some FFs reset) ───────────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
ctrl_reg <= '0; // control registers reset
// data_pipe <= '0; ← purposely NOT reset (saves area/power)
// DFT tool: data_pipe path marked "no reset" in DFT constraints
end else begin
ctrl_reg <= ctrl_next;
data_pipe <= data_in; // unreset FF — valid after 1st reset cycle
end
end
// Area/power saving: not every FF needs reset in datapath
// Constraint: initialize in SW, or gate output with a valid signalWaveform — Async vs Sync Reset Timing Differenceclk_‾‾‾‾‾_‾rst_n‾‾‾‾‾‾__‾‾‾‾‾‾‾‾‾‾‾‾‾↑ asserted here (between clock edges)q_asyncFF FF 00 00 00 00 FF FF← clears IMMEDIATELY (no clock needed)q_syncFF FF FF 00 00 00 FF FF← clears at NEXT posedge (1 cycle later)↑ first posedge after rst_n asserted
🔬 Debugging Academy — 10 Real RTL Bugs, Step by Step
These are not invented examples. Every bug below has appeared in real RTL projects and verification environments. Each one has a waveform signature, a root cause, and a fix. Study these until you can recognize them in five seconds of waveform inspection.
Bug 1 — Missing sensitivity: output frozen in simulation, correct after synthesis
A combinational output ignores one of its inputs in simulation, but the synthesised netlist responds to it correctly
SENSITIVITY-LIST-SIM-SYNTH-MISMATCHAn RTL test fails: a combinational output does not update when one particular input changes. The same test passes at gate level after synthesis. A junior engineer "fixes" the RTL failure by adding logic that forces the update — and the netlist then produces the wrong answer, because the netlist never had the bug.
// As written - legacy sensitivity list
always @(a or b) // c is READ in the body but MISSING here
out = (a & b) | c;Expected: out is the combinational function of a, b and c.
Actual in simulation: the block only re-executes when a or b change. A lone c transition leaves out holding a stale value.
Actual in synthesis: the sensitivity list is ignored entirely. The tool builds the function of everything the body reads, so the netlist does respond to c.
The RTL and the netlist are genuinely different designs, and the simulation is the incorrect one.
The direction of the mismatch is the diagnosis:
- Which side is wrong? If RTL fails and gate level passes, suspect the sensitivity list before suspecting synthesis. The reverse direction points at something else entirely.
- Compare the list against the body. Every signal read on a right-hand side must appear. Signals read inside a called function count too, and are the ones most often missed because they are not visible in the block.
- Confirm on the waveform. The stale output changes on
a/btransitions and holds flat throughctransitions — a signal that responds to some of its inputs and not others is conclusive.
A legacy always block re-evaluates only on the events listed. Synthesis has no equivalent concept — it extracts the combinational function from the statements — so an incomplete list changes the simulation and not the hardware.
The damage compounds because the RTL failure invites a fix. Any logic added to make the RTL match expectation is compensating for a simulation artefact, so it becomes a real defect in the netlist that already behaved correctly.
always_comb // sensitivity inferred, and inferred correctly
out = (a & b) | c;always_comb builds the sensitivity list from everything the body reads — including variables read by functions the block calls, which always @(*) does not guarantee. It also executes once at time zero, so out is defined before any input moves, and it makes writing out from a second block an error rather than a race.
Then remove any compensating logic added while chasing the RTL failure.
Ban manual sensitivity lists in new RTL. always_comb for combinational, always_ff for sequential, always_latch where a latch is intended — each is checkable by the tool, which a hand-written list is not.
Then close the detection gap: this class of bug is invisible to any test that changes a, b and c together, because the block re-evaluates on the a/b part and produces the right answer for the wrong reason. Toggling one input at a time is what exposes it, which is why single-input-transition stimulus belongs in combinational block tests and why constrained-random finds these where directed tests do not.
The original buggy code and trace follow.
Category: simulation/synthesis mismatch. Buggy code:
// ❌ BUG: c is not in the sensitivity list
always @(a or b) begin // c is missing
out = a & b & c;
end
// Simulation: 'out' only re-evaluates when a or b change.
// If c changes alone, out stays at its old value. Looks like a hold violation.
// Synthesis: synthesis reads ALL signals — out = a&b&c always correct.
// Chip behavior: correct. Simulation: wrong. Bug is invisible at tape-out.
// ✅ FIX: Replace always @(a or b) with always_comb
always_comb out = a & b & c; // auto-sense: {a,b,c} — bug impossibleWaveform symptom / root cause / fix. Waveform symptom: out does not change when only c transitions. In the waveform, c shows a clear rising edge at T=50, but out stays flat. Engineers mistake this for propagation delay or a hold violation.Root CauseThe simulator's event scheduler only re-evaluates the always @(a or b) block when a or b change. A change on c alone does not trigger an event for this block. The simulator is doing exactly what the code says — it's the code that is wrong.Debugging Process1. See out frozen in waveform when expected to change. 2. Check if DUT uses always @() instead of always_comb. 3. Search for every signal that drives out — list them: {a, b, c}. 4. Compare against the sensitivity list — c is missing. 5. Fix by switching to always_comb.PreventionUse always_comb for all combinational RTL. This bug is impossible with always_comb. Never use always @(*) or always @(signal_list) in SystemVerilog RTL.2Blocking Assignment in always_ff — Pipeline Collapses to Single StageFunctional BugBuggy Code
// ❌ INTENDED: 2-stage pipeline (data_in → stage1 → stage2)
always_ff @(posedge clk) begin
stage1 = data_in; // blocking: stage1 updates IMMEDIATELY
stage2 = stage1; // blocking: reads NEW stage1, not old stage1
end
// Result: stage2 = data_in at same clock edge → 1-stage pipeline, not 2
// ✅ FIX: Non-blocking — both RHS evaluated with OLD values first
always_ff @(posedge clk) begin
stage1 <= data_in; // RHS captured: data_in (old)
stage2 <= stage1; // RHS captured: old stage1
end // both update in NBA region simultaneously
// Result: stage2 = old stage1 = data_in from previous cycle → correct 2-stageWaveform symptom / root cause / fix. Waveform symptom: stage1 and stage2 have identical values at every clock edge. The expected 1-cycle difference between stage1 and stage2 never appears. Output latency appears to be 1 cycle (correct for stage1) but stage2 tracks stage1 in lock-step.Root CauseBlocking = evaluates AND writes the LHS immediately. By the time stage2 = stage1 executes, stage1 already contains the new value from the line above. Both registers capture data_in in the same clock cycle.Real Project ImpactThis bug caused a reported silicon issue in a DSP pipeline: the expected 2-cycle filter latency appeared as 1 cycle, causing off-by-one errors in the output frame. The synthesis netlist was actually correct (most synthesis tools recognize the pattern), but the simulation model was wrong, so the regressions passed and the bug was missed until integration-level testing.3Infinite Loop — Simulation Hangs at Time 0, No WaveformSimulation HangBuggy Code
// ❌ BUG: always block with no timing control — infinite loop at T=0
always begin
if (enable)
counter = counter + 1; // ← runs forever in zero time
end // ← simulator never advances time
// Symptom: simulation binary starts, prints nothing, hangs forever.
// CPU usage: 100% on one core. No VCD output.
// $finish is never reached. Kill with Ctrl+C.
// ❌ Also wrong: forever loop with no timing control
initial forever begin
data = $random; // infinite Active region loop
end
// ✅ FIX 1: Add timing control — yield simulation time
always @(posedge clk) begin
if (enable) counter <= counter + 1;
end
// ✅ FIX 2: Add delay — yields time between iterations
initial forever begin
@(posedge clk); data = $urandom_range(0, 255);
endDiagnosis and fix. Diagnosis: when simulation hangs at T=0 with no output, search immediately for any always block or forever loop that has no @(), no #delay, and no wait statement. In large codebases, search for always begin without a following @ or #. The offending block is consuming all CPU in the Active region without advancing time.Simulator BehaviorVCS and Questa both have a delta-cycle limit (typically configurable, default ~1 million iterations). If the loop is truly infinite (no delta cycle convergence), the simulator hits this limit and aborts with "Iteration limit reached" or simply hangs. The simulator is not broken — the code is.4Latch from Incomplete Case — Holds Stale Value, Passes Directed Test but Fails RandomLatch InferenceBuggy Code
// ❌ BUG: case covers only opcodes 0,1,2 — what about 3?
always_comb begin
case (opcode[1:0])
2'b00: alu_out = a + b;
2'b01: alu_out = a - b;
2'b10: alu_out = a & b;
// 2'b11: NOT HANDLED → alu_out holds last value → LATCH
endcase
end
// always_comb tool ERROR: "Latch inferred on alu_out"
// Directed test: only tests 0,1,2 → passes
// Random test: eventually generates opcode=3 → stale alu_out
// ✅ FIX: Always add default assignment at top
always_comb begin
alu_out = '0; // ← default: covers ALL unhandled opcodes
case (opcode[1:0])
2'b00: alu_out = a + b;
2'b01: alu_out = a - b;
2'b10: alu_out = a & b;
2'b11: alu_out = a | b; // ← or: default handles it
endcase
endWhy directed tests pass but random tests catch this. Insight: this is exactly why constrained-random verification finds bugs that directed tests miss. A directed test that only checks the documented operations (00, 01, 10) will pass. A random test with full-range opcode randomization will eventually hit 11, and either see the wrong (stale) output or detect the latch as X in post-synthesis simulation. This is one of the strongest arguments for randomized testing over directed-only approaches.5Multiple Drivers on Same Net — Signal Becomes XMulti-Driver / X BugBuggy Code
// ❌ BUG: Two always_ff blocks both drive 'q'
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= '0;
else q <= channel_a_data;
end
always_ff @(posedge clk) begin
if (override_en) q <= override_val; // ❌ second driver of q!
end
// Symptom: q = X whenever both blocks try to write different values.
// Tool Warning: "Multiple drivers on net q"
// Synthesis: CRITICAL ERROR — cannot resolve multi-driven net
// ✅ FIX: Merge into single always_ff with explicit priority
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= '0;
else if (override_en) q <= override_val; // override takes priority
else q <= channel_a_data; // normal operation
endBug 6 — Wrong reset polarity: the module never resets, outputs stay X
Category: reset bug. Buggy code:
// ❌ BUG: rst is active-HIGH (testbench drives rst=1 to reset)
// But the module checks !rst — this is always false when rst=1!
always_ff @(posedge clk or posedge rst) begin
if (!rst) q <= '0; // ❌ !rst = !1 = 0 → reset condition is NEVER true
else q <= d; // rst=1 → else branch → q gets d during reset!
end
// Simulation: q never initialises to 0. Stays X throughout.
// Waveform: rst pulse visible, q stays X, no response to reset.
// ✅ FIX: Match polarity — active-HIGH rst → if(rst), not if(!rst)
always_ff @(posedge clk or posedge rst) begin
if (rst) q <= '0; // ✅ active-high: fires when rst=1
else q <= d;
end
// ─── Naming convention prevents this bug ─────────────────────────
// rst → active high (fires when HIGH) → check: if(rst)
// rst_n → active low (fires when LOW) → check: if(!rst_n)
// The _n suffix is an industry convention for active-low signals.
// Sensitivity: posedge rst (active-high) vs negedge rst_n (active-low)Bug 7 — Task with timing control inside always_comb: compile error or deadlock
Category: compile error. Buggy code:
// ❌ BUG: A task with timing control called from always_comb
task automatic sample_bus(output logic [7:0] val);
@(posedge clk); // ← timing control inside task
val = data_bus;
endtask
always_comb begin
sample_bus(captured); // ❌ ERROR: always_comb cannot block on @
end
// Compiler: "Timing controls not allowed in always_comb"
// ✅ FIX: Use a function (functions cannot contain timing controls)
function automatic logic [7:0] process_bus(input logic [7:0] raw);
return raw ^ 8'hFF; // ✅ pure combinational — no timing
endfunction
always_comb begin
processed = process_bus(data_bus); // ✅ function call: legal
end
// For the original sampling intent: use always_ff
always_ff @(posedge clk) captured <= data_bus; // correctBug 8 — Glitch on a combinational output: visible in the waveform, passes timing analysis
Category: glitch / hazard. Buggy code:
// ── Scenario: Gray-code counter output feeds combinational decode
// Gray-code: only 1 bit changes at a time — but NOT in simulation!
// In simulation, multiple bits appear to change "simultaneously"
// but they are actually staggered by gate delays in real silicon.
always_comb begin
case (gray_count) // gray_count = [2:0] changes 010→011
3'b000: sector = 3'd0; // In sim: appears atomic (same delta)
3'b001: sector = 3'd1; // In silicon: bit[1] and bit[0] may
3'b011: sector = 3'd2; // switch at slightly different times
3'b010: sector = 3'd3; // → illegal code 3'b010 momentarily
default: sector = 3'd0; // → sector glitches to wrong value
endcase
end
// Simulation: looks clean (atomic change)
// Silicon: glitches on sector during counter transitions
// Symptom in silicon: incorrect sector decode for 1-2 gate delays
// ✅ FIX: Register the output to filter glitches
always_ff @(posedge clk) sector_reg <= sector; // register filters glitchBug 9 — Uninitialised always_comb output: X propagation at startup
Category: X-propagation. Buggy code:
// ❌ BUG: sel starts as X in simulation (undriven at T=0)
always_comb begin
case (sel)
2'b00: mux_out = in_a;
2'b01: mux_out = in_b;
2'b10: mux_out = in_c;
2'b11: mux_out = in_d;
// No default: if sel=2'bXX → mux_out = X → propagates everywhere
endcase
end
// At T=0: sel=XX (not yet driven) → mux_out=X
// X propagates into downstream always_ff → q=X after first clock
// Even after reset, q may stay X if reset doesn't reach this path
// ✅ FIX 1: Add default case (defensive X handling)
always_comb begin
mux_out = in_a; // ← default: covers X input
case (sel)
2'b00: mux_out = in_a;
2'b01: mux_out = in_b;
2'b10: mux_out = in_c;
2'b11: mux_out = in_d;
endcase
end
// ✅ FIX 2: Drive sel before simulation begins
initial sel = 2'b00; // testbench: initialize sel at T=010Mixed Blocking/Non-Blocking in always_ff — Tool-Dependent, Non-ReproducibleNon-DeterministicBuggy Code
// ❌ BUG: mixing blocking and non-blocking in same always_ff
always_ff @(posedge clk) begin
tmp = a + b; // blocking: tmp updates immediately (Active)
result <= tmp; // non-blocking: RHS is current tmp (post-block?)
status <= (tmp > 8'hFF); // which tmp? pre- or post-blocking update?
end
// IEEE spec: This is non-deterministic. The value of tmp seen by <=
// depends on scheduling order within the Active region.
// VCS may give: result = new tmp (post-blocking update)
// Questa may give: result = old tmp (NBA evaluated with pre-Active values)
// Both are "correct" per IEEE — your code is ambiguous.
// ✅ FIX: Use only non-blocking. For intermediate calc, use automatic var.
always_ff @(posedge clk) begin
automatic logic [8:0] tmp_local = a + b; // local: not a net
result <= tmp_local[7:0]; // ✅ clear and deterministic
status <= tmp_local[8]; // ✅ overflow bit
end
// Or: compute in always_comb, register the result in always_ff
always_comb tmp_comb = a + b;
always_ff @(posedge clk) begin
result <= tmp_comb[7:0];
status <= tmp_comb[8];
end💡 Senior Verification Engineer Tip: Run Your RTL on Two Simulators
The single most effective way to catch blocking/non-blocking races and sensitivity list bugs is to simulate the same testbench on two different simulators (e.g., VCS and Questa or Xcelium). Any result that differs between the two is non-deterministic behavior — a bug by definition. Set this up as part of your CI flow. A 30-minute effort to add the second simulator run will catch bugs that months of single-simulator testing will miss.
Interview Q&A — From Fresher to Senior Engineer
initial runs once from time 0 and then terminates; always re-arms and runs again every time its sensitivity condition is met. initial is testbench-only — it has no hardware meaning, because a chip has no "run this once at power-on" construct that synthesis can build.
The distinction that matters in review is not the definition but the consequence: an always block with no timing control (always out = a & b; with no @) never suspends, so the simulator re-enters it forever within a single time step and simulation hangs at time 0 with no output. That is the most common way this construct is misused.
Where This Is Specified
Procedural blocks are defined in IEEE Std 1800 (SystemVerilog), clause 9 — Processes: initial and always in §9.2, and the intent-carrying variants always_comb, always_ff, and always_latch in §9.2.2. The delta-cycle behaviour behind the scheduling section above is clause 4, Scheduling semantics. The IEEE Standards Association listing is the primary source.
The part of clause 9.2.2 worth reading directly is what the specialised blocks actually promise, because it is stronger than "a shorthand for always @(*)". The standard requires the inferred sensitivity to include variables read by called functions, requires the block to execute once at time zero, and makes a variable written by an always_comb illegal to write from anywhere else. Those three properties are the reason the legacy form is discouraged — not style, but checkability.
Related lessons. The assignments used inside these blocks are blocking vs non-blocking; the scheduling regions in depth are IPC and the scheduling regions, with the Verilog-track treatment in race conditions and determinism. For decision statements inside these blocks see case, casex, casez and if-else with unique and priority. Where an X from an unreset register ends up is where X comes from.
Part of SystemVerilog Fundamentals·Procedural Statements·Lesson 29 of 53
View program