An unintentional latch is one of the most dangerous bugs in RTL design — it silently passes simulation but breaks timing analysis and can fail in silicon. This guide shows exactly when and why synthesis infers a latch, how to detect it, how to eliminate it, and the complete set of RTL coding guidelines every ASIC designer must follow.
At the top of every always @(*) block, assign every output to its safe default value before any if/case logic. This guarantees full coverage with zero latches.
Even if the synthesizer never reaches it, always add a default: out = '0; branch to every case statement to prevent latch inference on uncovered encodings.
Each reg/wire must be driven by exactly one always block or assign statement. Two drivers produce X in simulation and a short circuit in synthesis — both are silent killers.
Use blocking = only in combinational blocks; non-blocking <= only in sequential blocks. Mixing them in one always block causes sim/synth mismatches that are nearly impossible to debug.
A latch is a level-sensitive storage element: its output is transparent (follows input) when the enable is active, and holds its value when the enable is inactive. Unlike a flip-flop, it has no clock — it changes output whenever the enable and data change, creating a window that STA tools cannot fully time.
An unintentionally inferred latch will often pass RTL simulation because the test vectors happen to trigger the output on the intended path. But in silicon, the latch enable glitches due to combinational hazards, or the transparent window causes hold violations that STA missed. Most ASIC projects require zero unintentional latches — enforced by lint tools like Spyglass or Mentor's HDL Designer.
Every latch inference comes down to one root cause: the synthesizer cannot find a combinational assignment for an output in every possible code path, so it inserts a storage element to hold the previous value.
always @(*) begin if (load) data_out = data_in; // No else: when load=0, // data_out must HOLD → LATCH end
always @(*) begin data_out = 8'h00; // default if (load) data_out = data_in; // load=0 → data_out = 8'h00 // No latch. Pure combinational. end
always @(*) begin case (state) IDLE: out = 2'b00; READ: out = 2'b01; WRITE: out = 2'b10; // DONE state → out holds → LATCH endcase end
always @(*) begin out = 2'b00; // default — prevents latch case (state) IDLE: out = 2'b00; READ: out = 2'b01; WRITE: out = 2'b10; default: out = 2'b00; // covers DONE endcase end
always @(*) begin if (sel) begin y = a; z = b; // z assigned here end else begin y = c; // z NOT assigned when sel=0 → LATCH on z! end end
always @(*) begin y = 1'b0; z = 1'b0; // defaults if (sel) begin y = a; z = b; end else begin y = c; z = 1'b0; // explicit, or rely on default end end
These guidelines prevent the majority of synthesis bugs, sim/synth mismatches, and DFT issues encountered in production ASIC flows. Follow all of them on every RTL block.
The first statement(s) inside always @(*) should assign every output to its safe inactive value. This guarantees zero latches regardless of how complex the if/case logic becomes, and makes code easier to review.
Manual sensitivity lists (always @(a or b or sel)) become stale when signals are added. A missed signal means the simulator does not re-evaluate the block when that input changes — a silent sim/synth divergence. Always use @(*) or always_comb.
Blocking assignments in a clocked block cause ordering-dependent behavior in simulation that synthesis ignores. Non-blocking assignments in a combinational block cause zero-delay feedback loops. Keep the rule absolute: = in combinational, <= in sequential — never mixed.
Each reg must be driven by exactly one always block. Two always blocks both driving the same variable produce X in simulation (last-executed wins non-deterministically) and are a synthesis error. Use a single always block with internal mux logic if multiple conditions must update the same signal.
Even if the full encoding is theoretically covered by named cases, always add default: out = '0;. This documents intent, prevents lint warnings, covers X-propagation in simulation, and prevents latches on any encoding the tool considers "reachable."
A flip-flop without reset comes up in an unknown state after power-on. Always include a reset condition in every sequential always block. Prefer synchronous reset (if (!rst_n) q <= '0;) for ASIC flows; use asynchronous reset only when the design specifically requires it.
If a signal feeds itself through combinational logic (assign a = a & b;), the simulator will oscillate or produce X, and synthesis may produce a ring oscillator or error. Every combinational path must have a registered cut point (flip-flop) to break feedback.
Verilog expressions evaluate at the width of the widest operand, but assignments truncate. Always explicitly size intermediate wires: wire [8:0] sum = {1'b0, a} + {1'b0, b}; to capture carry. Implicit truncation discards the MSB silently.
always_comb causes a compile error if a latch would be inferred. always_ff restricts the block to flip-flop inference and catches incorrect usage. These typed always blocks make synthesis intent explicit and eliminate entire categories of bugs at compile time rather than silicon time.
Tools like Spyglass, Mentor Questa Lint, or Synopsys Leda catch latch inference, multi-drivers, incomplete sensitivity lists, and sim/synth mismatches before a single gate is mapped. Treat lint warnings as errors. A design that lint-clean synthesizes predictably.
Latches are not universally wrong — they appear intentionally in clock-gating cells and specific low-power design patterns. The key is that the intent must be explicit.
// ── Integrated Clock Gating (ICG) latch ───────────────── // Standard cell in ASIC libs — latch holds the enable. // ICG cell is instantiated, not inferred, in production RTL. module icg_cell ( input clk, enable, test_en, output gated_clk ); reg en_latch; // Latch: transparent on LOW phase of clk always @(*) begin if (!clk) en_latch = enable | test_en; end assign gated_clk = clk & en_latch; endmodule // ── SystemVerilog: always_latch (explicit intent) ──────── // Use always_latch when a latch is truly intended. // Compiler will error if your block infers a FF instead. always_latch begin if (en) q_latch <= d; // transparent when en=1 end // For all other RTL: use always_comb (errors on latch inference) always_comb begin out = '0; // default if (sel) out = a; // no latch — default covers sel=0 end
| Rule | Combinational Block | Sequential Block | Consequence if Broken |
|---|---|---|---|
| Assignment type | Blocking = |
Non-blocking <= |
Sim/synth mismatch, race condition |
| Sensitivity list | @(*) or always_comb | @(posedge clk [or negedge rst_n]) | Sim divergence from missed signals |
| All outputs assigned | Yes — default first | N/A (holds value by FF nature) | Latch inferred → timing failure |
| case completeness | default branch required | default branch required | Latch on outputs not in all branches |
| Reset | N/A | Always include reset | Unknown state after power-on |
| Drivers per signal | Exactly one | Exactly one | X in sim / short circuit in silicon |
| SV preferred form | always_comb | always_ff | Missing compile-time latch check |
always @(*) block does not assign an output variable in every possible code path. The synthesizer must insert a storage element to hold the previous value when the unassigned path is taken. The three patterns that cause this: (1) an if without an else, (2) a case without a default branch, (3) an output that is only assigned in some branches of an if/case but not all. The fix for all three is the same: add a default assignment at the top of the block that covers every output.always @(posedge clk)), never unintentional latches.always_latch to declare the intent explicitly. Any latch that appears without intentional use of always_latch is an unintentional latch — a bug.always_comb in SystemVerilog causes the elaborator (and most simulators) to issue a compile-time error if the block would infer a latch. This turns what was a silent synthesis issue — discovered only during gate-level netlist review or STA — into an error that stops compilation immediately. Additionally, always_comb executes once at time zero before simulation begins, ensuring outputs are not left in an unknown state at startup. Use always_comb in any flow that supports SystemVerilog; it is strictly superior to always @(*).always @(*) begin out = sel ? a : b; end). Lint tools catch multi-drivers at elaboration. If you are seeing X propagation through a net, a multi-driver is a likely cause.= inside posedge clk. (5) Combinational loop — a net feeds itself without a register. (6) Width mismatch — assigning a wider expression to a narrower net (truncation). (7) Unconnected output port — a module output left floating. These seven categories catch the majority of RTL bugs before synthesis runs.