This lesson builds on Verilog's always @* and case; RTL Foundations covers them if they are new.
A plain always @(...) block carries no declared intent. The same construct models flops, latches, and combinational logic, so a forgotten signal or a missing else silently mismatches synthesis.
The specialized blocks declare intent that tools verify. always_comb infers sensitivity from every variable read in the body, including reads inside called functions, and it executes once at time zero. always_ff takes one edge-sensitive event-control statement, which may list two edges such as posedge clk or negedge rst_n. always_latch marks an intentional latch so reviewers know it is deliberate.
Block | Models | Rule (and lint guideline) |
|---|---|---|
| Combinational logic | Full sensitivity including functions, runs once at time zero; blocking assignments (guideline, lint-checked) |
| Flip-flops | One edge-sensitive event control; nonblocking assignments (guideline, lint-checked) |
| Intentional latches | Declares the latch is wanted; blocking assignments (guideline, lint-checked) |
Combinational logic must assign its outputs on every path through the block. A missed branch forces the output to hold its previous value, which is unclocked storage: a latch.
// snippet: missing branch holds state, which is a latch
always @* begin
case (state)
2'b00: grant = req[0];
2'b01: grant = req[1];
2'b10: grant = req[2];
endcase
endAssigning a default value first closes every path. A default arm inside the case works equally well.
// snippet: default first, so every path assigns grant
always_comb begin
grant = 1'b0;
case (state)
2'b00: grant = req[0];
2'b01: grant = req[1];
2'b10: grant = req[2];
endcase
endSome checks are mandatory and some are lint quality. Required by the LRM: variables written in always_comb, always_latch or always_ff may not be written by any other process, and always_ff allows exactly one edge-sensitive event control. Not every simulator enforces the first rule, so lint for it too.
Latch detection inside always_comb is commonly flagged but not guaranteed everywhere. always_comb does not prevent latches; it makes them detectable, and the default-assignment habit is what prevents them.
case inside always_comb covers three of four state values with no default and no prior assignment. What does synthesis build for the missing value?A latch. The output must hold its old value on that path, which is unclocked storage.
always @* block calls a function that reads a signal. What can go wrong in simulation?The signal is missing from the inferred sensitivity list. always_comb includes function-internal reads; always @* does not.
req is 3'b101 and state steps 0, 1, 2, 3. At state 3, what do grant_bad and grant_good show?grant_bad still shows 1, the value it took at state 2 (req[2]): no branch assigns it, so it holds, which is a latch. grant_good shows 0, its default.
A latch-prone decoder next to its default-first fix, checked for all four state values.
module latch_demo (
input logic [1:0] state,
input logic [2:0] req,
output logic grant_bad,
output logic grant_good
);
always @* begin
case (state)
2'b00: grant_bad = req[0];
2'b01: grant_bad = req[1];
2'b10: grant_bad = req[2];
endcase
end
always_comb begin
grant_good = 1'b0;
case (state)
2'b00: grant_good = req[0];
2'b01: grant_good = req[1];
2'b10: grant_good = req[2];
endcase
end
endmodule
module tb_latch_demo;
timeunit 1ns; timeprecision 1ps;
logic [1:0] state;
logic [2:0] req;
logic grant_bad, grant_good;
latch_demo dut (.*);
int errors = 0;
initial begin
req = 3'b101;
for (int s = 0; s < 4; s++) begin
state = s[1:0]; #1;
if (s < 3) begin
if (grant_good !== req[s]) errors++;
end else begin
if (grant_good !== 1'b0) errors++;
end
end
if (grant_bad !== req[2]) errors++;
if (errors != 0) $fatal(1, "FAIL: grant outputs look wrong for some state");
$display("PASS: default-first grant is right for all 4 states and the bare case holds its old value");
$finish;
end
endmodulePASS: default-first grant is right for all 4 states and the bare case holds its old valueRun with Verilator 5.048 (--binary --timing) on 2026-10-18; every bench printed the PASS line shown, with no warnings. Icarus 13 (-g2012) runs them too, with the same output.
Not every simulator rejects a second process writing an always_ff variable: Verilator 5.048 accepts an initial block doing it.