Verilog Crash Course Lesson 3 / 8
Lesson 3 · Describing Logic

Combinational Logic

Learning objective Write combinational logic (outputs that depend only on current inputs, with no memory) two ways — assign and always @(*) — and recognize the single most common bug in combinational Verilog: accidentally describing a latch.

Number literals, quickly

Before writing expressions you need Verilog's literal syntax: <size>'<radix><value>.

4'b1010   // 4 bits, binary:      1010
8'hFF     // 8 bits, hex:         11111111
3'd5      // 3 bits, decimal:     101
1'b1      // 1 bit:               1

Size is in bits, not digits. Radix is b (binary), h (hex), d (decimal), or o (octal). Omitting the size (just 'h1F) is legal but leaves the width up to context — always size your literals explicitly once you're inside real logic; it avoids a class of subtle width-mismatch bugs.

assign: a standing relationship, not a one-time computation

The clearest analogy for a CS background: assign is like a spreadsheet formula, not an assignment statement. =B2+B3 in a spreadsheet cell doesn't compute once — it silently recomputes forever, every time B2 or B3 changes. assign works identically:

assign sum  = a ^ b ^ cin;
assign cout = (a & b) | (cin & (a ^ b));

These two lines describe two pieces of gate-level hardware (an adder's sum and carry logic) that are permanently, continuously live. Change a at any moment, in simulation or in real silicon, and sum and cout update immediately (in a real circuit, after gate delay; in simulation, in the same time step). There's no "running" assign — it never finishes and never re-executes, because it never stopped.

Operators you'll actually use

CategoryOperatorsNotes
Bitwise& | ^ ~ ^~Per-bit, on vectors of any width.
Logical&& || !Treat the whole operand as one boolean; result is always 1 bit. Use in conditions (if), not to combine bit-vectors.
Reduction&v |v ^vUnary prefix — collapses every bit of vector v into one bit. &v is "AND of all bits" (useful for "are all bits 1?").
Relational== != < > <= >=Result is 1 bit (or X if either side has an unknown bit).
Shift<< >>Logical shift; fills with 0.
Concatenation{a, b}Glues signals into a wider vector, MSB-first.
Replication{4{1'b0}}Repeats a pattern — here, four zero bits (0000).
Ternarycond ? a : bA multiplexer, spelled as an expression.
Bitwise vs. logical, concretely With a = 4'b0110 and b = 4'b0011: a & b is 4'b0010 (per-bit AND). a && b is 1'b1 (both operands are "truthy" — nonzero — so the logical AND is true). Mixing these up is a common source of quietly-wrong width-1 results where you meant a full bitwise operation.

The ternary operator is a mux

From Lesson 2's mux2: assign y = sel ? b : a; is exactly a 2-to-1 multiplexer — "if sel, drive y continuously from b; otherwise from a." No branch is "taken" in the software sense; both possibilities are wired in, and sel continuously steers which one reaches the output.

See it as one file: code/mux2to1.v (source also in code/mux2to1.v).

always @(*): the same hardware, with if/case

assign gets awkward once logic needs more branches than a ternary comfortably expresses. always @(*) describes the exact same category of hardware — purely combinational, no memory — but lets you use familiar control-flow keywords as a way of describing a circuit, not as a runtime sequence:

module mux4 (
    input  wire [1:0] sel,
    input  wire [7:0] in0, in1, in2, in3,
    output reg  [7:0] y
);
    always @(*) begin
        case (sel)
            2'd0: y = in0;
            2'd1: y = in1;
            2'd2: y = in2;
            2'd3: y = in3;
        endcase
    end
endmodule

Three things to notice, because each one is a rule, not a style choice:

The latch bug: Verilog's most common combinational mistake

A reg retains its last value until told otherwise — that's the whole point of the keyword. Inside a clocked block (Lesson 4) that's exactly what you want. Inside a combinational always @(*) block, it's a trap: if there's some input combination for which your if or case doesn't assign the output at all, the tool has only one honest way to satisfy "hold the last value" — insert a real latch (unwanted memory) to remember it. You've accidentally described sequential hardware while trying to describe combinational hardware.

Infers a latch

always @(*) begin
    if (enable)
        y = data;
    // no else —
    // what is y when
    // enable is 0?
end

Purely combinational

always @(*) begin
    if (enable)
        y = data;
    else
        y = 8'b0;
end

The same rule applies to case: every branch of the case must assign the output, or you need a default that does. The fix is always the same shape — every signal assigned in the block must be assigned on every possible path through it.

Why this matters more than it sounds like An accidental latch isn't just "extra hardware." Latches are level-sensitive (not edge-triggered), don't play well with static timing analysis the way flip-flops do, and are usually not what your course's grading/synthesis flow expects. A synthesis tool will typically print an explicit inferred-latch warning — never ignore that warning.
Q: In an always @(*) block, why does an incomplete if (no matching else) on a reg output cause a latch to be inferred?
Because the reg must hold its previous value on the un-covered condition, and holding a value across time is exactly what a latch does. Because reg always synthesizes to memory regardless of context. Because @(*) only re-evaluates on clock edges, so the value must be latched between edges.
Try it Finish the full_adder module you started in Lesson 2 using two assign statements: sum = a ^ b ^ cin and cout = (a & b) | (cin & (a ^ b)). Then rewrite it a second way using always @(*) with plain Boolean expressions assigned to reg outputs, and convince yourself both versions describe identical hardware — the assign/always@(*) choice is style, not substance, as long as every output is assigned on every path.

New terms — combinational logic, latch, sensitivity list, literal / radix — are in the glossary.

Previous← Modules, Ports & Signal Types Contents⌂ All lessons