SystemVerilog · Module 5
Blocking vs Non-Blocking Assignments
= vs <=, the NBA scheduler, the golden rule, race conditions.
Module 5 · Page 5.6
This page is the SystemVerilog treatment; the Verilog-track lesson on the same operators is blocking and non-blocking assignments, and the multi-process consequences live in race conditions and determinism. The blocks these assignments appear in are procedural blocks.
The Two Assignment Operators
= Blocking Assignment
Executes in order, immediately. The next statement in the same block sees the updated value. Think of it like a regular C assignment.
<= Non-Blocking Assignment (NBA)
Evaluates the RHS now but schedules the LHS update for the end of the current time step. All NBA RHS expressions in a time step are sampled before any LHS is written.
// ── Blocking (=): sequential execution ──────────────────────────
always_comb begin
a = b + 1; // a is immediately b+1
c = a * 2; // c uses the NEW value of a → c = (b+1)*2
end
// ── Non-blocking (<=): all RHS captured first ───────────────────
always_ff @(posedge clk) begin
a <= b + 1; // schedules a = b+1 for end of time step
c <= a * 2; // uses OLD value of a (before posedge) → c = a_old*2
end
// Both a and c update simultaneously at end of time step.
// This is exactly how flip-flops work in real hardware.
// ── The classic swap: only non-blocking works without a temp ────
always_ff @(posedge clk) begin
a <= b; // schedule: a ← old b
b <= a; // schedule: b ← old a
end
// Result: a and b are swapped. No temp variable needed.
// ── With blocking = the swap fails ──────────────────────────────
always_ff @(posedge clk) begin
a = b; // a is immediately b — old a is LOST
b = a; // b = b (the same value!) — swap broken
end🧠 The Most Important Mental Model: = Sees New Values, <= Sees Old Values
When reading code that uses <=, ask yourself: "What did this signal look like before this time step started?" That is what the RHS captures. With =, ask: "What did the previous statement in this block just set this to?" That is what you see. This distinction drives everything: why pipelines need <=, why combinational chains need =, and why mixing them in the same block is non-deterministic.
The NBA Scheduler — How It Works
SystemVerilog's event scheduler divides each time step into two phases. Understanding this is the key to understanding why <= behaves the way it does.
Figure 1 — The Two Phases of a Simulator Time Step
Figure 1 — Each simulator time step has two phases. Blocking assignments happen in Phase 1. Non-blocking LHS updates all happen together in Phase 2, after all RHS values have been sampled.
🚀 RTL Design Insight: Why NBA Makes Multi-Block Designs Deterministic
Without the NBA scheduler, two always_ff blocks at the same posedge clk would race to read and write the same signals — the result would depend on which block the simulator processes first (non-deterministic). The NBA scheduler eliminates this by separating READ and WRITE: all RHS expressions are evaluated in the Active region (everyone reads the pre-clock-edge values simultaneously), then all LHS assignments happen in the NBA region (everyone writes simultaneously). Order of always_ff block processing becomes irrelevant — the result is always the same. This is why <= is mandatory in always_ff.
The Golden Rule
always_comb → use =
Combinational logic has no memory. Statements execute in order and you want each result to be available immediately to the next statement. Use =.
always_ff → use <=
Flip-flops sample their input and hold the value until the next clock edge. Non-blocking exactly models this behaviour — all outputs update simultaneously. Use <=.
// ── CORRECT: = in always_comb ───────────────────────────────────
always_comb begin
sum = a + b; // purely combinational
carry = sum[8]; // uses updated sum immediately
result = sum[7:0];
end
// ── CORRECT: <= in always_ff ─────────────────────────────────────
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
q <= '0;
count <= '0;
end else begin
q <= d;
count <= count + 1;
end
end
// ── WRONG: = in always_ff ────────────────────────────────────────
always_ff @(posedge clk) begin
q = d; // ⚠ blocking in clocked block — race condition risk
end
// Simulates correctly when only one always_ff drives q.
// Breaks silently if two always_ff blocks both assign q.
// ── WRONG: <= in always_comb ─────────────────────────────────────
always_comb begin
out <= a | b; // ⚠ NBA in comb: out updates at end of delta, not now
// subsequent uses of out see the OLD value
end🏗 Synthesis Concern: = in always_ff Creates Tool-Dependent RTL
When synthesis sees = inside always_ff, different tools behave differently. Synopsys DC infers a flip-flop (ignoring the blocking semantics and treating it as <=). Cadence Genus may generate the same. But now your simulation model (which uses blocking semantics — immediate update) disagrees with your synthesized netlist (which always uses flip-flop capture semantics). This is the simulation-synthesis mismatch — the netlist does the right thing, but your verification environment validated the wrong behavior. The discrepancy is only revealed during gate-level simulation or integration testing.
Race Conditions — When = in always_ff Goes Wrong
A race condition between two always_ff blocks occurs when both blocks assign the same signal using blocking assignment. Which block executes first is determined by the simulator's event scheduler — not by the code order, not by any rule you can rely on. The result is simulator-dependent and will not match synthesised hardware.
// ── Classic race: shift register with blocking ──────────────────
// WRONG — outcome depends on which always_ff the simulator runs first
always_ff @(posedge clk) a = d_in; // ⚠ blocking
always_ff @(posedge clk) b = a; // ⚠ which 'a'? old or new?
// If 'a' block runs first: b captures the NEW a (= d_in) — acts like wire
// If 'b' block runs first: b captures the OLD a — acts like flip-flop
// Synthesis always infers a flip-flop. Simulation may not match.
// ── CORRECT: non-blocking eliminates the race ───────────────────
always_ff @(posedge clk) a <= d_in; // schedules NBA
always_ff @(posedge clk) b <= a; // RHS sampled before NBA region
// Both RHS values captured in Phase 1 (a_old, b_old).
// Both LHS updated in Phase 2: a = d_in, b = a_old.
// Deterministic regardless of block evaluation order. Matches hardware.🔍 Debugging Insight: The Two-Simulator Test for Blocking Races
The definitive way to find blocking assignment races in always_ff is to simulate on two different tools — VCS and Questa (or Xcelium). If the results differ between tools, you have a race condition. VCS and Questa have different event scheduling implementations — they process always_ff blocks in different orders within the same time step. Non-blocking (<=) code produces identical results regardless of order. Blocking (=) code may produce different results depending on which block the tool processes first. This is a build-your-CI-pipeline-now insight: always run regressions on two simulators.
Proving It — the Race, Run Twice
The page's central claim is that blocking assignment in always_ff makes the result depend on which block the simulator happens to evaluate first. That is a claim about scheduling, and scheduling claims deserve evidence rather than assertion.
The module below builds the same two-flop shift register three ways and lets one simulation report all three. The only difference between the first two is the textual order of two concurrent always_ff blocks — an edit with no meaning in the language, since concurrent blocks have no source-order semantics.
// ─────────────────────────────────────────────────────────────────────────────
// Three shift registers, identical intent:
// A: blocking, "producer" block written first
// B: blocking, "consumer" block written first <- same logic, swapped text
// C: non-blocking (correct)
//
// A and B differ only in the order two concurrent always_ff blocks appear in
// the file. If they disagree, the result was never in the logic.
//
// vcs -sverilog nba_race_proof.sv && ./simv
// xrun -sv nba_race_proof.sv | iverilog -g2012 -o r nba_race_proof.sv && ./r
// ─────────────────────────────────────────────────────────────────────────────
module nba_race_proof;
timeunit 1ns; timeprecision 1ps;
logic clk = 1'b0, d = 1'b0;
logic a1, a2, b1, b2, c1, c2;
int errors = 0;
always #5 clk = ~clk;
// ── A: blocking, producer first ───────────────────────────────────────
always_ff @(posedge clk) a1 = d; // producer
always_ff @(posedge clk) a2 = a1; // consumer - which a1?
// ── B: blocking, consumer first. Same two blocks, swapped in the file. ─
always_ff @(posedge clk) b2 = b1; // consumer
always_ff @(posedge clk) b1 = d; // producer
// ── C: non-blocking. Order cannot matter: all RHS sample before any LHS
// updates, so both blocks read pre-edge values whatever the order.
always_ff @(posedge clk) c1 <= d;
always_ff @(posedge clk) c2 <= c1;
initial begin
// Drive a single 1 through the shift register and watch stage 2.
@(negedge clk) d = 1'b1;
@(negedge clk) d = 1'b0; // one-cycle pulse
@(posedge clk); // edge 2: a true 2-stage sees it now
#1;
$display(" after two edges: a2=%0b b2=%0b c2=%0b", a2, b2, c2);
$display("");
// C is the only version with a defined answer, so C is the only one
// checked. Asserting a specific result for A or B would encode one
// simulator's arbitrary scheduling choice as if it were the specification.
if (c2 !== 1'b1) begin
errors++;
$display(" ** FAIL: non-blocking stage 2 did not see the pulse");
end
if (a2 !== b2)
$display(" ** A and B DISAGREE. Identical logic, opposite answers - the\n"
" only difference is which block was typed first.");
else
$display(" ** A and B agreed on THIS simulator. That is luck, not a\n"
" guarantee: nothing in IEEE 1800 orders these two blocks.");
$display(" ** C is order-independent by construction.");
if (errors == 0) $display("\n [PASS] non-blocking behaved as a 2-stage pipeline");
else $display("\n [FAIL] %0d check(s) failed", errors);
$finish;
end
endmoduleRepresentative output.
after two edges: a2=1 b2=0 c2=1
** A and B DISAGREE. Identical logic, opposite answers - the
only difference is which block was typed first.
** C is order-independent by construction.
[PASS] non-blocking behaved as a 2-stage pipelineNote what is and is not checked. Only version C is asserted. A self-check that demanded a2 == 1 would freeze one tool's arbitrary evaluation order and present it as correct behaviour — the precise mistake this page warns against. When a design has no defined answer, the honest test reports what happened and refuses to grade it.
Two things to try on this file. Your simulator may print the same value for a2 and b2; that is not a refutation — it means this tool happens to resolve the order consistently, and tells you nothing about the next tool or the next version. And swapping the two c blocks changes nothing at all, which is the whole argument for <= in one experiment: the correct version is immune to an edit that has no meaning.
Common Mistakes at a Glance
// ── Mistake 1: mixing = and <= in the same always_ff ────────────
always_ff @(posedge clk) begin
temp = a + b; // ⚠ blocking — tool-dependent, lint error
out <= temp; // ⚠ uses immediately-updated temp — breaks NBA model
end
// FIX: split into comb + ff, or use NBA throughout
always_comb temp = a + b; // combinational intermediate
always_ff @(posedge clk) out <= temp;
// ── Mistake 2: NBA in always_comb for intermediate ──────────────
always_comb begin
mid <= a ^ b; // ⚠ NBA in comb: mid hasn't updated when next line runs
out <= mid | c; // ⚠ reads OLD mid
end
// FIX: use blocking in always_comb
always_comb begin
mid = a ^ b; // mid updates immediately
out = mid | c; // uses NEW mid
end
// ── Mistake 3: using = for reset in always_ff ───────────────────
always_ff @(posedge clk) begin
if (!rst_n) q = '0; // ⚠ blocking reset — inconsistent with NBA data path
else q <= d;
end
// FIX: use <= for reset too
always_ff @(posedge clk) begin
if (!rst_n) q <= '0; // consistent NBA throughout
else q <= d;
endQuick Reference
| Operator | Name | When LHS updates | Use in | Models |
|---|---|---|---|---|
= | Blocking | Immediately, in statement order | always_comb, functions, tasks | Combinational logic, sequential calculations |
<= | Non-blocking (NBA) | End of current time step (NBA region) | always_ff, always_latch | Flip-flops, registers, state elements |
🧠 Delta Cycle Deep Dive — The Full Event Scheduler
The simplified "Phase 1 / Phase 2" model is useful but incomplete. The IEEE 1800 simulator has nine distinct event regions within each simulation time step. Understanding all of them explains why $display sometimes shows wrong values, why assertions sample differently from RTL, and why testbench code in program blocks sees different values than RTL.
| Region | What Executes | Assignment Type | Relevant To |
|---|---|---|---|
| Active | Blocking (=) assignments, continuous assigns, $display, non-blocking RHS evaluation | = executes; <= RHS captured | RTL (always_comb, always_ff), combinational logic |
| Inactive | #0 delay events. Avoid in RTL — used for special ordering tricks. | Rare | Legacy workarounds |
| NBA | Non-blocking LHS updates. All <= assignments commit simultaneously. | <= LHS written | always_ff, all flip-flop outputs |
| Observed | SVA concurrent assertions sample stable values after NBA. | Read-only | assert property(...) |
| Reactive | Program blocks, clocking block driven outputs. | Testbench = or <= | UVM drivers via clocking block |
| Postponed | $strobe and $monitor. Always shows final settled values for the time step. | Read-only | $strobe — correct post-NBA display |
// ── Why $display shows wrong value after posedge ─────────────────
always_ff @(posedge clk) q <= d; // NBA: q updates in NBA region
initial begin
forever begin
@(posedge clk);
// $display fires in ACTIVE region — BEFORE NBA updates q
$display("$display: q=%h (may be old value)", q); // ← sees q_old
// $strobe fires in POSTPONED region — AFTER NBA updates q
$strobe(" $strobe: q=%h (final correct value)", q); // ← sees q_new
end
end
// ── Expected Output (d=0x55, q was 0x00) ─────────────────────────
// $display: q=00 ← Active region: NBA not yet applied
// $strobe: q=55 ← Postponed region: q updated by NBA
// ── Rule: Always use $strobe when printing flip-flop outputs.
// Use $display only for combinational values or testbench signals
// that are driven with blocking (=) assignments.Delta Cycle Timeline — posedge clk at T=10nsT=10nsRegionActive Δ0 │ NBA Δ0 │ Active Δ1 │ PostponedEventalways_ff evals RHS │ q ← d_old │ comb sees q↑ │ $strobeq= q_old (unchanged) │= d_old (new) │ visible (d_old) │ finalcomb_out= f(q_old) │ unchanged │= f(d_old) │ final$displayfires here (sees q_old) ─┘ ← may be WRONG$strobe fires here ✅────────────────────────────────────────────────────────────────────── If comb_out changes in Δ1, it triggers another Active cycle (Δ2). This process repeats until no new events — simulation time then advances.
📊 Waveform Analysis — Reading NBA Behavior
When you understand what the NBA scheduler does, waveforms become instantly readable. The signature of correct non-blocking behaviour is that every flip-flop output changes on the same clock edge using pre-edge values. Any deviation from that pattern is a defect, and the two waveforms below are the same two-stage pipeline written both ways.
Non-blocking (correct) — two stages, 1-cycle offset
7 cyclesBlocking (wrong) — the two stages collapse into one
7 cycles// Figure 2 — correct: both RHS sampled before either LHS updates
always_ff @(posedge clk) begin
stage1 <= d;
stage2 <= stage1; // reads the PRE-EDGE stage1
end
// Figure 3 — wrong: stage1 updates immediately, stage2 reads the new value
always_ff @(posedge clk) begin
stage1 = d;
stage2 = stage1; // reads the value assigned one line above
end🔍 How to Spot Blocking Race in a Waveform
In the waveform viewer, look for two registered signals that should have a 1-cycle latency between them but appear to track each other identically on every clock edge. If stage2 and stage1 change together at every posedge instead of stage2 being one cycle behind stage1, you have a blocking assignment collapse. The fix: replace = with <= inside always_ff. The waveform will immediately show the correct 1-cycle offset.
⚙ Pipeline Modeling — Why <= Is Mandatory for Registers
Pipeline modeling is where the difference between = and <= has the most profound practical impact. Every N-stage pipeline in a design depends on non-blocking assignments to correctly model the 1-cycle latency between stages.
// ── CORRECT: 4-stage pipeline using non-blocking ─────────────────
module pipeline4_correct (
input logic clk, rst_n,
input logic [7:0] d,
output logic [7:0] q4
);
logic [7:0] s1, s2, s3;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
s1 <= '0; s2 <= '0; s3 <= '0; q4 <= '0;
end else begin
s1 <= d; // RHS: d_old
s2 <= s1; // RHS: s1_old (= d from 1 cycle ago)
s3 <= s2; // RHS: s2_old (= d from 2 cycles ago)
q4 <= s3; // RHS: s3_old (= d from 3 cycles ago)
end // All LHS update simultaneously in NBA → 4-cycle latency
end
endmodule
// ── WRONG: 4-stage pipeline using blocking — all collapse to 1 stage
module pipeline4_broken (
input logic clk, rst_n,
input logic [7:0] d,
output logic [7:0] q4
);
logic [7:0] s1, s2, s3;
always_ff @(posedge clk) begin
s1 = d; // s1 immediately = d (new value)
s2 = s1; // s2 immediately = d (new s1) — 1 cycle latency GONE
s3 = s2; // s3 immediately = d (new s2) — 2 cycle latency GONE
q4 = s3; // q4 immediately = d (new s3) — 3 cycle latency GONE
end // Synthesis: q4 = d with 1 clock latency only (synthesis is right)
// Simulation: q4 = d with 1 clock latency (looks the same!)
// But: the INTERMEDIATE stages s1, s2, s3 are wrong in simulation
endmodule
// ── Test: verify the 4-cycle latency ─────────────────────────────
module tb;
logic clk=0, rst_n;
logic [7:0] d, q4_correct, q4_broken;
always #5 clk = ~clk;
pipeline4_correct u_c(.clk,.rst_n,.d,.q4(q4_correct));
pipeline4_broken u_b(.clk,.rst_n,.d,.q4(q4_broken));
initial begin
rst_n = 0; d = 8'h00;
repeat(3) @(posedge clk); rst_n = 1;
@(posedge clk); d = 8'hAA; // push 0xAA into pipeline
repeat(6) @(posedge clk);
$strobe("correct: %h broken: %h", q4_correct, q4_broken);
// correct: appears at q4 after 4 cycles
// broken: appears at q4 after 1 cycle
$finish;
end
endmodule🏗 Synthesis Impact — What Each Form Generates in Hardware
| Code Form | Simulation Behavior | Synthesis Result | Match? | Risk Level |
|---|---|---|---|---|
= in always_comb | Sequential combinational evaluation: next line sees new value | Combinational gates — correct | ✅ Match | None — this is correct |
<= in always_ff | All RHS captured pre-clock, LHS updated in NBA | D flip-flops — correct | ✅ Match | None — this is correct |
= in always_ff | Sequential: next statement sees new value (race-prone) | Most tools infer D flip-flop (correct hardware) | ❌ Mismatch risk | HIGH — sim/synth mismatch possible |
<= in always_comb | Intermediate value NOT updated until next delta — stale reads | Tool warning; comb logic with delayed update semantics | ❌ Mismatch | HIGH — always wrong, tool warns |
Mixed = and <= in same always_ff | Non-deterministic — IEEE says this is undefined behavior | Tool-dependent — may or may not warn | ❌ Non-deterministic | CRITICAL — different simulators give different answers |
⚠ Common Industry Mistake: "It Works in Simulation, Must Be Fine"
The most dangerous class of blocking/non-blocking bugs is the one where simulation appears correct. This happens because the simulator happens to process the always_ff blocks in a favorable order — the order that produces the expected result. Change simulator, change version, add more logic, and the ordering changes — now different results. Silicon always infers flip-flops (captures pre-edge values) regardless of whether you used = or <=. The only guaranteed-correct code is: always use <= in always_ff, no exceptions.
🔬 Testbench Usage — Which Assignment to Use Where
The blocking vs non-blocking choice in testbenches is just as important as in RTL — but the rules are different. Incorrect assignment types in testbenches produce checks that fire with stale values or miss functional bugs entirely.
// ── Rule 1: Use = in initial blocks for immediate signal driving ──
initial begin
rst_n = 0; // ✅ drives rst_n=0 IMMEDIATELY at T=0
data = 8'hAA;
@(posedge clk);
rst_n = 1; // ✅ drives rst_n=1 immediately after posedge
end
// ── Wrong: <= in initial block — signal changes in NBA region ────
initial begin
data <= 8'hAA; // ❌ NBA: data doesn't change until NBA region
@(posedge clk); // @(posedge clk) might fire BEFORE data updates!
check(data); // might check old data value
end
// ── Rule 2: Use $strobe to print flip-flop outputs ───────────────
initial begin
forever begin
@(posedge clk);
$display("q=%h", q); // ❌ Active region — q still old value!
$strobe( "q=%h", q); // ✅ Postponed region — q has new value
end
end
// ── Rule 3: For clocking-block driven signals — use <= via cb ────
clocking drv_cb @(posedge clk);
output #1 valid, data; // output skew: drives 1ns after posedge
endclocking
initial begin
@(drv_cb);
drv_cb.data <= 8'hBB; // ✅ clocking block assignment: use <=
drv_cb.valid <= 1'b1; // drives 1ns after next posedge
end
// ── Rule 4: Checking outputs — sample in Reactive region ─────────
clocking mon_cb @(posedge clk);
input #-1 data_out, valid_out; // input skew: samples 1ns before posedge
endclocking
// Clocking block input samples are BEFORE the clock edge — setup time
// This is why UVM monitors use clocking blocks for accurate timing💡 Senior Verification Engineer Tip: Use Clocking Blocks for All DUT Interface Interactions
The cleanest way to avoid blocking/non-blocking confusion in testbenches is to use clocking blocks for all DUT interface interactions. Clocking blocks automatically handle the timing: <= for driven outputs (via output skew), and input sampling before the clock edge (via input skew). The driver writes cb.signal <= value and the monitor reads cb.signal — the tool handles the scheduling. This is why UVM's clocking_block-based drivers never have blocking/non-blocking issues.
📋 RTL Coding Style Guide — The Complete Rule Set
These are not preferences — they are the rules enforced by RTL sign-off lint checks at every major semiconductor company. Breaking any of these rules is a blocker that prevents RTL from proceeding to synthesis.
| Context | Use | Never Use | Reason | Lint Rule |
|---|---|---|---|---|
always_comb | = (blocking) | <= (NBA) | Combinational logic needs immediate value propagation | STARC-2.1.4.1 |
always_ff | <= (NBA) | = (blocking) | Registers model simultaneous capture; blocking is race-prone | STARC-2.1.4.2 |
always_latch | = (blocking) | <= (NBA) | Latch is combinational-style: immediate update when transparent | Style guide |
| Functions | = (blocking) | <= (NBA) | Functions cannot have NBA semantics — blocking only | Language rule |
| Tasks (synthesis) | = for local; output via calling block | Mixed freely | Same rules as the calling block apply to synthesizable tasks | Tool-specific |
Testbench initial | = for immediate; <= via clocking block | <= for direct signal driving | Direct NBA in initial block delays signal until NBA region | Methodology |
⚡ Advanced Race Condition Analysis
// ── Race Type 1: Two always_ff blocks writing same signal with = ──
always_ff @(posedge clk) shared = a; // block 1
always_ff @(posedge clk) result = shared; // block 2 — sees new or old shared?
// VCS runs block 1 first: result = a (looks correct)
// Questa runs block 2 first: result = shared_old (different answer!)
// Synthesis: two flip-flops — result always gets shared_old (correct HW)
// ✅ Fix: use <= in both blocks
// ── Race Type 2: Read-after-Write in same always_ff block with = ─
always_ff @(posedge clk) begin
stage1 = d; // writes stage1 = d (new value)
stage2 = stage1; // reads NEW stage1 → stage2 = d (wrong — should be old)
end
// This is NOT a race between processes — it's sequential in one block.
// It's deterministic but wrong: both get d in same cycle.
// ✅ Fix: use <= for both
// ── Race Type 3: = and <= mixed in same block (non-deterministic) ─
always_ff @(posedge clk) begin
tmp = a + b; // blocking: tmp = a+b immediately (Active)
result <= tmp; // NBA: which tmp? pre-blocking (Active) or post?
end
// IEEE says: the value of tmp seen by <= is IMPLEMENTATION-DEFINED.
// VCS may give tmp_new, Questa may give tmp_old. Both are "correct" per spec.
// ✅ Fix: use automatic local variable for intermediate, then <=
always_ff @(posedge clk) begin
automatic logic [8:0] tmp = a + b; // local var: no net/reg — no NBA
result <= tmp[7:0]; // ✅ result gets tmp from this cycle
end
// ── Verification: detect blocking races with assertions ───────────
always_ff @(posedge clk) begin
// XMR check: assert that result matches EXPECTED pre-clock value
assert (result === $past(shared)) else
$error("result=%h expected=%h — blocking race!", result, $past(shared));
end🔬 Debugging Academy — 8 Blocking/Non-Blocking Bugs from the Field
Bug 1 — Blocking in always_ff: a 2-stage pipeline collapses to 1 stage
A two-stage pipeline reports one cycle of latency instead of two, and only on one simulator
NBA-PIPELINE-COLLAPSEA datapath specified for two cycles of latency measures one in RTL simulation. Downstream logic that compensates for the missing cycle is added, the block passes its tests, and gate-level simulation later shows the compensation is now off by one in the other direction.
// As written
always_ff @(posedge clk) begin
stage1 = d; // blocking
stage2 = stage1; // reads the value assigned on the line above
endExpected: stage2 holds what stage1 held before the edge — one cycle behind it.
Actual: stage1 is updated to d first, so stage2 receives this cycle's d. Both registers end the edge holding the same value, and the two waveform traces are identical (Figure 3).
Three checks, in the order that costs least:
- Grep before you probe.
=insidealways_ffon a signal read elsewhere is the bug, and finding it is a text search rather than a waveform session. - Compare the two traces. Two registered signals that should differ by one cycle but change together on every edge is the collapse signature, and it is visible at a glance.
- Run the second simulator. Blocking races give tool-dependent results; correct non-blocking code does not. A disagreement between tools is conclusive, and an agreement proves nothing — see the §"Proving It" experiment.
The detail that identifies this bug rather than a generic latency error: the measured latency is short by exactly the number of collapsed stages, and it is short in RTL while the netlist has the full pipeline.
Blocking assignment executes in the Active region and takes effect immediately, so the second statement reads a value that, in hardware, does not exist until after the edge. The RTL models a wire where the design has a register.
The compensation logic added downstream is the expensive part. It corrects a simulation artefact, so once the assignment is fixed the pipeline lengthens by one and the compensation becomes an error of its own — which is why the mismatch surfaced at gate level rather than at the original fix.
always_ff @(posedge clk) begin
stage1 <= d;
stage2 <= stage1; // samples the PRE-EDGE stage1
endBoth right-hand sides are sampled before either update is applied, so stage2 receives the value stage1 held entering the edge. Then remove the downstream compensation — it was built against the broken behaviour, and leaving it converts a one-cycle shortfall into a one-cycle excess.
Make = inside always_ff a lint rule, with a documented exception for block-local temporaries that are never read outside the block. That single rule removes this bug and the cross-block races in the same sweep, and it is mechanically checkable in a way that review is not.
Then verify latency rather than assuming it: a self-checking test that drives a marker through the pipeline and asserts it emerges on the expected cycle catches a collapse the moment it is introduced, instead of after downstream logic has been built around it.
The original buggy code and waveform trace follow.
Category: pipeline collapse. Buggy code:
// ❌ BUG: intended 2-stage pipeline with 2-cycle latency
always_ff @(posedge clk) begin
stage1 = data_in; // stage1 immediately = data_in (new)
stage2 = stage1; // stage2 immediately = data_in (new stage1!)
end
// Result: stage2 = data_in with 1-cycle latency, NOT 2-cycle
// Simulation: both stages have identical value every cycle
// Synthesis: correctly infers 2 FFs, data arrives at stage2 1 cycle late
// Impact: filter/DSP algorithm sees wrong delayed data → incorrect output
// ✅ FIX: use non-blocking — both stages capture pre-edge values
always_ff @(posedge clk) begin
stage1 <= data_in; // RHS: data_in_old (pre-edge)
stage2 <= stage1; // RHS: stage1_old (pre-edge) → 2-cycle latency ✅
endRoot cause / waveform / fix. Waveform symptom: stage1 and stage2 show identical waveforms — they change simultaneously on every posedge. The expected 1-cycle offset between them is missing. This is the definitive visual signature of a blocking-in-pipeline bug.Root CauseBlocking assignment (=) updates stage1 immediately. The next line then reads the NEW stage1, not the pre-clock-edge value. Both assignments complete within the same Active region, eliminating the intended 1-cycle delay between stages.Real ImpactThis bug caused a reported issue in a DSP filter implementation where the FIR tap coefficients were applied at the wrong time step, producing a completely incorrect frequency response in silicon. The RTL simulation passed (because the blocking collapse still produced output, just wrong output), but the gate-level simulation revealed the timing discrepancy.2Non-Blocking in always_comb — Intermediate Value Is StaleStale Comb ValueBuggy Code
// ❌ BUG: NBA in always_comb — mid is not updated when 'out' is assigned
always_comb begin
mid <= a ^ b; // schedules mid update for NBA region
out <= mid | c; // ❌ uses OLD mid (before this always_comb ran)
// out = (mid_old | c) — wrong!
end
// Simulation: out is 1 delta cycle behind its correct value
// The always_comb re-triggers (NBA updates mid) → out eventually correct
// But in between there's a glitch where out has wrong value
// Tool warning: "non-blocking assignment in always_comb" — treat as error
// ✅ FIX: use blocking = for all intermediate values in always_comb
always_comb begin
mid = a ^ b; // mid updated immediately
out = mid | c; // uses NEW mid — correct single-cycle behavior
endBug 3 — Mixing = and <= in one always_ff: non-deterministic result
Category: non-determinism. Buggy code:
// ❌ BUG: mixing = and <= in same always_ff
always_ff @(posedge clk) begin
tmp = a + b; // blocking: tmp updates immediately (Active region)
result <= tmp; // NBA: which tmp? IEEE says IMPLEMENTATION-DEFINED
status <= (tmp[8]); // IEEE says IMPLEMENTATION-DEFINED
end
// VCS interpretation: result = new tmp (a+b from this cycle)
// Questa interpretation: result = old tmp (0 on first cycle)
// Both are "correct" per IEEE 1800. Your code is ambiguous.
// Will cause regressions to differ between tool vendors.
// ✅ FIX: Use local automatic variable for intermediate, then <=
always_ff @(posedge clk) begin
automatic logic [8:0] tmp_local = a + b; // local — no NBA semantics
result <= tmp_local[7:0]; // ✅ deterministic
status <= tmp_local[8]; // ✅ deterministic
endBug 4 — Blocking shift register acts as a wire: both flops hold the same value
Category: race / wire bug. Buggy code:
// ❌ BUG: shift register split across two always_ff blocks
always_ff @(posedge clk) a = d_in; // block 1: a = d_in (blocking)
always_ff @(posedge clk) b = a; // block 2: b = a (but which a?)
// Scenario A: Simulator runs block 1 first
// a gets d_in (new). Then block 2: b = a = d_in (new). Result: b = d_in
// This makes b act like a wire to d_in, not a 1-cycle delayed register.
// Scenario B: Simulator runs block 2 first
// b = a (old). Then block 1: a = d_in. Result: b = a_old (correct FF behavior)
// Synthesis: always scenario B (b = a_old — two FFs in series)
// Simulation: depends on simulator. This is the classic sim/synth mismatch.
// ✅ FIX: use non-blocking — deterministic, matches synthesis
always_ff @(posedge clk) a <= d_in; // RHS: d_in_old captured
always_ff @(posedge clk) b <= a; // RHS: a_old captured. NBA updates: a=d_in, b=a_oldReal impact — the classic "passes in VCS, fails in Questa" bug. This bug passes regression on the primary simulator (whichever happened to run block 2 first) but is immediately caught when the second simulator (which runs block 1 first) is added to the regression. This is the exact signature the two-simulator test is designed to catch. Running two simulators in CI is the definitive detector for this class of bug.5Mixed Reset (=) and Data (<=) in Same always_ff — Tool WarningInconsistent BlockBuggy Code
// ❌ BUG: = for reset path, <= for data path — inconsistent
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q = '0; // ❌ blocking reset
else q <= d; // NBA data
end
// Lint: "Mixed blocking and non-blocking assignments in always_ff"
// IEEE: behavior when = and <= are mixed is implementation-defined
// VCS may simulate correctly; Questa may show glitches during reset
// ✅ FIX: use <= consistently for BOTH reset and data paths
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) q <= '0; // ✅ consistent NBA
else q <= d; // ✅ consistent NBA
end
// Rule: every assignment in an always_ff block must use <=. Always.6$display Shows Stale FF Value — Wrong Debug PrintDebug Print BugBuggy Code
// ❌ BUG: $display fires in Active region — before NBA updates FF output
always_ff @(posedge clk) q <= d;
initial begin
d = 8'hAA; #10; // d=0xAA, posedge at T=5
@(posedge clk);
$display("q = %h", q); // ❌ prints 0x00 (old value!) — Active region
$strobe( "q = %h", q); // ✅ prints 0xAA (new value) — Postponed region
end
// ── Common engineer mistake that leads to wrong conclusions ───────
// Engineer sees $display showing 0x00 instead of 0xAA
// Thinks: "FF not working, d not being captured"
// Spends hours looking for a sensitivity list bug
// Reality: FF works fine, $display just fires before NBA
// ✅ RULE: Use $strobe for all debug prints involving FF outputs
// Use $display only for combinational signals driven with =7NBA in Testbench Initial Block — Signal Not Ready When CheckedTestbench TimingBuggy Code
// ❌ BUG: <= in initial block — data not ready when @(posedge clk) fires
initial begin
data <= 8'hAA; // NBA: scheduled for NBA region
valid <= 1'b1; // NBA: scheduled for NBA region
@(posedge clk); // fires in Active region
// ← But NBAs haven't committed yet!
// DUT samples data and valid BEFORE they update
// DUT sees old values: data=0x00, valid=0
// Transaction appears to not be driven
end
// ✅ FIX: use = in initial blocks for immediate signal driving
initial begin
data = 8'hAA; // ✅ immediate — data=0xAA right now
valid = 1'b1; // ✅ immediate
@(posedge clk); // DUT samples correct values
endBug 8 — Swap fails with blocking: the classic temp-variable requirement
Category: swap bug. Buggy code:
// ❌ BUG: swap with blocking — classic value-destruction bug
always_ff @(posedge clk) begin
if (swap_en) begin
a = b; // a = b (new). Old a is LOST forever.
b = a; // b = a = b (already overwritten!)
// Both a and b now hold b_old. Swap failed.
end
end
// ✅ FIX 1: non-blocking — RHS captured before any LHS updates
always_ff @(posedge clk) begin
if (swap_en) begin
a <= b; // RHS: b_old captured
b <= a; // RHS: a_old captured
// NBA: a ← b_old, b ← a_old simultaneously. Swap works. ✅
end
end
// ✅ FIX 2: if you must use blocking, need temp variable
always_ff @(posedge clk) begin
if (swap_en) begin
automatic logic [7:0] tmp = a; // save a before overwriting
a = b;
b = tmp; // restore old a from tmp. But still risky — use <=
end
end
// KEY INSIGHT: This is THE canonical example of why <= exists.
// Hardware registers naturally swap — each FF captures its D input
// at the clock edge simultaneously. <= models this perfectly.💡 Senior Verification Engineer Tip: Enforce with Lint, Not Code Review
Blocking/non-blocking violations are too subtle and too consequential to rely on code review to catch. Configure your lint tool (Synopsys Spyglass, Cadence JasperGold, Aldec ALINT-PRO) to enforce: (1) STARC-2.1.4.1: no NBA in always_comb, (2) STARC-2.1.4.2: no blocking in always_ff, (3) W_MIXED_BA_NBAS: no mixing in same block. Set all three to ERROR severity, not WARNING. These three rules eliminate an entire class of RTL bugs automatically, without requiring engineers to remember the rule on every code write.
Interview Q&A — Blocking vs Non-Blocking
= updates its target immediately, so the next statement in the same block reads the new value. <= samples its right-hand side immediately but defers the update, so every statement in the block reads the values that existed before the edge.
The consequence that matters is not about one block but about all of them. Because <= separates reading from writing, every always_ff in the design reads pre-edge values and writes post-edge values, so the order the simulator happens to evaluate those blocks in cannot affect the result. That is exactly what real flip-flops do, and it is why <= is the rule rather than a preference.
Where This Is Specified
The two assignment forms are defined in IEEE Std 1800 (SystemVerilog), clause 10 — Assignment statements — blocking in §10.4.1 and non-blocking in §10.4.2. The region model that makes <= deterministic is clause 4, Scheduling semantics, which defines the Active and NBA regions and, critically, states that the simulator may process events within a region in any order. The IEEE Standards Association listing is the primary source.
That last point is the one worth reading in the standard rather than taking on trust. The non-determinism this page describes is not a tool defect or a quality-of-implementation gap — it is explicitly permitted behaviour, which is why "it works on my simulator" carries no weight and why the proof above refuses to assert a result for the blocking versions.
Related lessons. The blocks these assignments live in are procedural blocks; the scheduling regions in depth are IPC and the scheduling regions. The Verilog-track treatment of the same operators is blocking and non-blocking assignments, and the multi-process consequences — including why non-blocking does not fix a write-write race — are in race conditions and determinism. For where the RTL/netlist mismatch surfaces, see RTL vs netlist behaviour.
Part of SystemVerilog Fundamentals·Procedural Statements·Lesson 34 of 53
View programContinue learning
Related tutorials
- Recommended next
Function Declarations & Return Values
Return types, return statement vs named variable, early return, arguments, RTL usage, recursion, packages, synthesis deep dive, 7 debugging labs, and interview Q&A.
- Recommended next
Loops
for, while, do-while, repeat, forever, foreach — synthesisable vs simulation-only.