16.4 Combinational RTL: Continuous Assignment and always_comb

Write combinational logic in Verilog/SystemVerilog using both the continuous-assignment style and the procedural always_comb/case style, and recognize the incomplete-assignment mistake that causes unintended latch inference.

Combinational logic — output depends only on the current inputs, with no memory of the past — can be described in Verilog two different ways: the continuous-assignment style already used throughout this module (assign y = ...;), and a procedural style using an always_comb block containing statements like if/else or case. Both styles describe the exact same kind of hardware and, when written correctly, synthesize to the same kind of combinational gate network; the procedural style simply becomes necessary once the logic is naturally expressed as a multi-way decision (a case statement selecting among several possibilities) rather than a single expression. This lesson covers both styles side by side, using a 2:1 multiplexer as the running example, and covers the single most important pitfall in combinational RTL: forgetting to assign an output on every possible path, which causes a simulator or synthesis tool to infer unwanted memory (a latch) where none was intended.

Style 1 — Continuous Assignment with the Ternary Operator

For simple two-way decisions, the conditional (ternary) operator ?: inside a continuous assignment is the most compact way to express a multiplexer: assign y = sel ? b : a; reads directly as 'y is b when sel is true, otherwise y is a.' This is exactly the module's own combinational RTL example, and it demonstrates that assign is not limited to simple bitwise expressions — it can carry arbitrarily complex combinational expressions, including nested ternary operators for multi-way selection.

The ternary form is popular for small multiplexers and simple conditional logic specifically because it stays a single, self-contained expression — there's no possibility of forgetting a branch the way there is with a multi-branch procedural statement, because the syntax itself forces exactly one 'true' branch and exactly one 'false' branch to be written.

Style 2 — Procedural Combinational Logic with always_comb and case

When a decision has more than two outcomes, or when logic is more naturally expressed as a sequence of conditions, SystemVerilog's always_comb block lets you write combinational logic procedurally, using constructs familiar from software (if/else, case) while still describing pure combinational hardware. always_comb automatically re-evaluates the entire block any time any signal read inside it changes — the tool infers the correct sensitivity automatically, removing an entire historical class of bugs from the older Verilog always @(*) syntax where a forgotten signal in a manual sensitivity list could cause a simulation mismatch against the synthesized hardware.

Inside the block, a case statement selects among branches based on the value of a controlling expression (here, sel), and each branch assigns a value to y using blocking assignment (=) — covered in full detail in 16.6, but the short version for now is that blocking assignment is the correct choice inside always_comb.

Step by Step: Building the 2:1 Mux Both Ways

Walking through the procedural version: always_comb begin ... end opens the block. case (sel) ... endcase begins a case selection on sel. 1'b0: y = a; says 'when sel equals the 1-bit binary literal 0, assign y the value of a.' 1'b1: y = b; covers the other possible value of a 1-bit signal. Because sel is exactly 1 bit wide, these two branches (1'b0 and 1'b1) are the only two values it can ever take, so this case statement covers every possibility — which is exactly the property the next section explains is mandatory.

Both the ternary version and the case version above produce the identical multiplexer truth table (y=a when sel=0, y=b when sel=1) and, when correctly written, synthesize to the same kind of 2:1 mux hardware. Which style to use is mostly a matter of which decision structure most naturally matches the logic being described — a 2-way choice favors the ternary; a 3-or-more-way choice (as in an ALU operation selector) favors case.

The Critical Rule: Every Combinational Output Needs an Assignment on Every Path

The module's own tricky problem states the rule directly: every output must receive an assignment for every relevant path in combinational logic, otherwise synthesis may infer a latch. This happens because a synthesis tool interprets 'no assignment happened on this path' as 'this signal must hold its previous value' — and the only hardware element that can hold a previous value is a memory element (a latch), not a plain combinational gate network. The tool will therefore build an unintended latch to preserve that 'do nothing' behavior, even though the designer never wrote a clock, a reset, or any storage-related construct anywhere in the code.

This is one of the most common and most dangerous synthesis-mismatch bugs in RTL design, because it can be functionally invisible in a quick simulation (the simulator may happen to hold the last value correctly too, matching the inferred latch's behavior) while creating real problems in the manufactured hardware: unintended latches are notoriously hard to time correctly, are usually flagged as errors by timing-closure tools, and often indicate an actual logic bug (a missing case, a missing else) rather than an intentional design choice.

• A case statement missing one or more possible values of its selector (and with no default branch) leaves those paths with no assignment — the classic latch-inference trigger. • An if with no matching else has exactly the same problem for the same reason. • The fix is either to enumerate every possible case value explicitly, or to add a default: branch (or a final else) that assigns a safe fallback value to every output. • This rule applies per-output: even in a block that assigns several outputs, each individual output must be covered on every path, not just the block as a whole.

Two Equivalent Combinational Styles

always_comb automatically infers its own sensitivity list — no manual signal list (and no risk of forgetting one) is needed, unlike the older always @(*) form. Inside always_comb, use blocking assignment (=), never nonblocking (<=) — see 16.6. A case statement must cover every possible value of its selector, or include a default: branch, to avoid unintended latch inference.

// Style 1: continuous assignment with ternary operator
assign y = sel ? b : a;

// Style 2: procedural always_comb with case
always_comb begin
    case (sel)
        1'b0: y = a;
        1'b1: y = b;
    endcase
end

Code Examples

This module intentionally builds the same 2:1 mux twice, once per style, to show they are functionally identical. Note that y_case is declared logic rather than wire — any signal assigned inside a procedural block (always_comb, always_ff, etc.) must be a variable type (logic in SystemVerilog, reg in plain Verilog), never a plain wire, because a wire can only be driven by a continuous assignment or a gate/module output, not by a procedural statement. Expected result: y_assign and y_case are always equal for any combination of a, b, sel — both implement the same 2:1 mux truth table.

sel is 2 bits wide, so it can take four values: 00, 01, 10, and 11. Only the first three are handled — when sel == 2'b11, the block executes with no assignment to y at all, which the synthesis tool reads as 'hold the previous value of y,' and it will insert a latch to implement that holding behavior. This module simulates 'correctly' in many quick tests (because a simulator will also just hold the last driven value), which is exactly what makes this bug dangerous — it hides until timing analysis or a careful code review catches the missing branch.

// 2'b11 case is missing!

Common mistake: Missing case branch with no default: fallback — the classic latch-inference bug.

module mux2to1 (
    input  wire a,
    input  wire b,
    input  wire sel,
    output wire y_assign,
    output logic y_case
);

// Style 1: continuous assignment
assign y_assign = sel ? b : a;

// Style 2: procedural, using always_comb
always_comb begin
    case (sel)
        1'b0: y_case = a;
        1'b1: y_case = b;
    endcase
end

endmodule
module mux4to1_buggy (
    input  wire [1:0] sel,
    input  wire [3:0] d,
    output logic y
);

always_comb begin
    case (sel)
        2'b00: y = d[0];
        2'b01: y = d[1];
        2'b10: y = d[2];
        // 2'b11 case is missing!
    endcase
end

endmodule

2:1 multiplexer block symbol

sel selects which data input reaches y — y=a when sel=0, y=b when sel=1, for both the ternary and case-based descriptions.

┌─────────┐
  a ───►│0        │
        │   MUX   ├───► y
  b ───►│1        │
        └────┬────┘
             │
            sel

Important Rules

assign with ?: and procedural always_comb with case/if both describe combinational hardware and can be functionally equivalent.

A signal assigned inside any procedural block must be declared as a variable type (logic/reg), never wire.

always_comb infers its own sensitivity automatically — never write a manual (and possibly incomplete) sensitivity list for combinational logic.

Every output must be assigned on every possible path through a combinational block, or synthesis may infer an unintended latch.

A missing case branch (with no default:) or a missing else on an if are the two classic triggers for accidental latch inference.

Why Timing-Closure Tools Flag Latches

In a real ASIC or FPGA flow, static timing analysis tools specifically flag inferred latches because level-sensitive storage elements are much harder to time correctly than edge-triggered flip-flops, and because an inferred latch is very often unintentional — a synthesis report listing an unexpected latch is one of the most common first clues that a case or if statement is missing a branch somewhere in the RTL, and experienced engineers treat any unexpected latch warning as a bug to investigate immediately, not a style choice to accept.

always_comb begin case (sel) 2'b00: y = d[0]; 2'b01: y = d[1]; 2'b10: y = d[2]; endcase end always_comb begin case (sel) 2'b00: y = d[0]; 2'b01: y = d[1]; 2'b10: y = d[2]; default: y = d[3]; endcase end

Adding a default: branch (or explicitly writing out 2'b11: y = d[3];) guarantees y is assigned on every possible value of sel, eliminating the path that would otherwise leave y unassigned and trigger latch inference in synthesis.

[tricky-problem] A 2-bit sel signal drives a case statement inside always_comb with only three of its four possible values (00, 01, 10) explicitly handled and no default: branch. What does synthesis do with the unhandled sel == 11 path? A. It infers a latch to hold the output's previous value, because the tool interprets 'no assignment on this path' as 'preserve state' B. It automatically fills in the missing branch with the same logic as the 00 case C. It raises a compile-time error and refuses to synthesize the design D. It treats the missing branch as equivalent to assigning the output to 0

A. It infers a latch to hold the output's previous value, because the tool interprets 'no assignment on this path' as 'preserve state' — This is the module's central tricky problem: combinational logic must assign every output on every reachable path. When a path leaves an output unassigned, the only way to give that output a well-defined value is to remember whatever it was driven to last — which requires a storage element. Synthesis tools implement that by inserting a latch, even though nothing about a case/always_comb block looks like it should create memory.

[interview] Why is always_comb generally preferred over the older always @(*) syntax for writing combinational logic? A. always_comb infers its sensitivity list automatically and performs additional compile-time checks for common combinational-logic mistakes, removing a class of sensitivity-list bugs B. always_comb executes faster in simulation because it skips the synthesis step C. always @(*) cannot be used with case statements, only always_comb can D. always_comb is only valid inside testbenches, not in synthesizable RTL

A. always_comb infers its sensitivity list automatically and performs additional compile-time checks for common combinational-logic mistakes, removing a class of sensitivity-list bugs — always @(*) also auto-infers sensitivity, but always_comb (introduced in SystemVerilog) goes further: it performs additional static checks (for example, flagging variables that are read before being assigned inside the block) and clearly signals design intent as purely combinational, making it the modern preferred choice for combinational procedural logic.

Why must y_case in a mux module be declared logic instead of wire when it's assigned inside always_comb? A. Because only a variable type (logic/reg) can be the target of a procedural (inside-a-block) assignment; a wire can only be driven by a continuous assignment or a gate/module output B. Because logic signals are automatically faster in hardware than wire signals C. Because wire cannot be used as a module output under any circumstances D. Because always_comb blocks are not allowed to drive module outputs at all

A. Because only a variable type (logic/reg) can be the target of a procedural (inside-a-block) assignment; a wire can only be driven by a continuous assignment or a gate/module output — In SystemVerilog, logic is a general-purpose variable type usable as either a wire-like combinational signal or a clocked register, and it is required as the assignment target inside any procedural block. wire remains reserved for signals driven by continuous assignment (assign) or directly by a submodule/gate output port.

This is the spec's own design challenge: write synthesizable RTL for a 2:1 multiplexer with inputs d0, d1, sel and output y, using a single continuous assignment (no procedural block).

module mux2to1_assign ( input wire d0, input wire d1, input wire sel, output wire y ); assign y = sel ? d1 : d0; endmodule

The ternary operator sel ? d1 : d0 is exactly a 2:1 mux expression — you only need one line inside the module.