Part 1 · Language Foundations · Intermediate

always_comb, always_ff, always_latch

Intent-checking semantics vs plain always, sensitivity inference, what tools enforce, and a latch-inference example.

Why plain always was a problem

Verilog's plain always @(...) block carries no declared intent: the same construct models flip-flops, latches, and combinational logic, and the tool has no way to know which one you meant . Forget a signal in the sensitivity list and simulation quietly disagrees with synthesis (synthesis ignores the list; simulation obeys it). Forget an else branch and synthesis infers a latch you never wanted — silently. SystemVerilog's specialized blocks turn both mistakes into checkable contracts : you declare the hardware you intend, and the compiler/synthesizer errors or warns when the body doesn't match.

always_comb infers its sensitivity automatically from every variable read in the body (including inside called functions — an improvement over always @*, which misses signals read only inside functions). It also executes once at time zero so outputs are correct before the first input change, forbids any other process from writing its outputs, and lets tools flag incomplete assignments that would create latches. always_ff requires an explicit edge-sensitive list and tells synthesis “flip-flops only”. always_latch exists for the rare intentional latch, so reviewers know it isn't an accident.

systemverilog
// Combinational: no sensitivity list to forget
always_comb begin
  unique case (sel)
    2'b00: y = a;
    2'b01: y = b;
    2'b10: y = c;
    default: y = '0;     // complete assignment: no latch possible
  endcase
end

// Sequential: edge list required, flops intended
always_ff @(posedge clk or negedge rst_n) begin
  if (!rst_n) count <= '0;
  else if (en) count <= count + 1'b1;
end

// Intentional latch: documented by the keyword itself
always_latch begin
  if (gate_en) q_lat <= d;   // no else: holds value — by design
end

The latch-inference trap, before and after

Combinational logic must assign its outputs on every path through the block. Miss a branch and the output must “remember” its previous value on that path — and memory in unclocked logic is a level-sensitive latch. With plain always @* this slips through to synthesis as an unintended latch with all its timing-analysis pain; with always_comb the tool is entitled (and linting flows are configured) to flag the incomplete assignment immediately.

systemverilog
// BUG: grant unassigned when state==2'b11 → latch inferred
always @* begin
  case (state)
    2'b00: grant = req[0];
    2'b01: grant = req[1];
    2'b10: grant = req[2];
  endcase                      // no default, no error from plain always
end

// FIX 1: default value first — every path now assigns grant
always_comb begin
  grant = 1'b0;                // safety assignment
  case (state)
    2'b00: grant = req[0];
    2'b01: grant = req[1];
    2'b10: grant = req[2];
  endcase
end
// FIX 2: a default: arm inside the case works equally well
diagram
WHY A MISSING BRANCH BECOMES A LATCH

  always @* / always_comb            state==2'b11 path:
       │                             grant not assigned
       ▼                                   │
  ┌──────────────────────┐                 ▼
  │ case (state)         │      "keep previous value"
  │  00: grant = req[0]  │                 │
  │  01: grant = req[1]  │                 ▼
  │  10: grant = req[2]  │      unclocked storage element
  │  (11: ???)           │                 │
  └──────────────────────┘                 ▼
                                ┌─────────────────────┐
   plain always : silent        │  level-sensitive    │
   always_comb  : tool flags    │  LATCH  en=(state   │
   incomplete assignment        │  !=11)  d=req[...]  │
                                └─────────────────────┘

What tools actually enforce

The LRM makes some checks mandatory and leaves others as quality-of-implementation. Reliably enforced: always_comb and always_latch variables may not be written by any other process (compile error); always_ff requires exactly one edge-sensitive event control and no other timing controls in the body. Commonly flagged by synthesis and lint, but not guaranteed at compile time everywhere: latch inference inside always_comb, and non-flop behavior inside always_ff. Interviewers probe exactly this boundary — “does always_comb prevent latches?” No: it makes them detectable and reportable; the default-assignment-first habit is what prevents them.

Quick contrast

  • always @* — sensitivity from body but misses function-internal reads; no write-exclusivity; no time-zero execution guarantee.

  • always_comb — full sensitivity (incl. functions), runs once at time 0, exclusive writes, latch checks.

  • always_ff — edge list mandatory, body must look like flops, exclusive writes; use nonblocking assigns.

  • always_latch — same checks as always_comb but declares the latch is intentional.

Key takeaways

  • The specialized always blocks are contracts: tools verify the body matches declared hardware intent.

  • always_comb beats always @* — function-read sensitivity plus mandatory time-zero execution.

  • Latch = unassigned path in combinational code; default-assign first, or always provide default/else.

  • always_comb doesn't prevent latches — it makes them detectable; the coding habit prevents them.

Common pitfalls

  • Writing an always_comb output from a second process — compile error that surprises plain-Verilog habits.

  • Putting #delays or extra event controls inside always_ff — the single-edge-list contract forbids it.

  • Relying on always @* when functions read signals — those reads are missing from the inferred list.

  • Treating synthesis latch warnings as noise — an unintended latch is a functional and timing bug.