Combinational logic in SystemVerilog

FPGA design · Building blocks · Jul 2026

Combinational logic is any circuit whose outputs depend only on its current inputs, with no memory and no clock. Change an input and the outputs settle to their new values after a short delay set by the depth of lookup tables the signal passes through. This page builds the handful of combinational blocks that show up in almost every design, writes each one in synthesizable SystemVerilog, and keeps pointing back at how it lands on the fabric from the previous page. It also covers the one mistake that turns combinational code into something that accidentally remembers, which is worth understanding early because the tools warn about it rather than stop.

Two ways to describe combinational logic

SystemVerilog gives you two constructs for combinational output, and they mean the same hardware. The first is the continuous assignment, written with assign, which states an expression that holds at all times. The second is the always_comb block, a procedural region that the tools re-evaluate whenever any input changes. Use assign for a single expression and always_comb when a decision reads better as a case or a short sequence of if statements. The word logic is the modern data type for both, and it replaces the old wire and reg distinction that confused a generation of newcomers.

mux2.sv// a two to one multiplexer, the single most common combinational block.
// when sel is 0 the output is a, when sel is 1 the output is b.
module mux2 #(
    parameter int WIDTH = 8
)(
    input  logic             sel,
    input  logic [WIDTH-1:0] a,
    input  logic [WIDTH-1:0] b,
    output logic [WIDTH-1:0] y
);
    assign y = sel ? b : a;
endmodule

The #(parameter int WIDTH = 8) makes the module generic over bus width, so one description serves an eight-bit datapath and a thirty-two-bit one. A one-bit multiplexer is a single lookup table. An eight-bit one is eight copies of that table, one per bit, all selected by the same control signal, and they run in parallel because they are separate hardware.

The accidental latch, and how to avoid it

Here is the mistake. A combinational block must assign every output for every possible path through it. If some path leaves an output unassigned, the language says the output must keep its old value, which means it now remembers, and the tools build a latch to hold it. A latch in code that was meant to be combinational is almost always a bug, and it creates timing problems that are painful to chase. The cause is usually a case or an if that forgets a branch.

latch_trap.sv// WRONG. when sel is 2 or 3, y is never assigned, so a latch appears.
always_comb begin
    case (sel)
        2'd0: y = a;
        2'd1: y = b;
    endcase
end

// RIGHT. a default value assigned first means every path defines y,
// so the block is purely combinational and no latch is built.
always_comb begin
    y = a;                 // default
    case (sel)
        2'd1: y = b;
        2'd2: y = c;
        2'd3: y = d;
        default: ;         // y keeps the default, which is fine because it exists
    endcase
end

The habit that prevents this entirely is to assign a default to every output at the top of an always_comb block before any case or if. After that, no branch can leave an output undefined, and the tool has nothing to latch. Using always_comb rather than the older always @(*) helps too, because it asks the tool to flag a block that could infer a latch instead of silently building one.

Decoders and encoders

A decoder turns a binary number into a one-hot signal, a bus with exactly one bit set, the bit chosen by the input. It is how an address selects one of many things. The reverse direction, turning a one-hot or a set of requests back into a binary index, is an encoder, and the useful version is the priority encoder, which returns the index of the highest-priority active request and so must decide what to do when several are active at once.

decode_encode.sv// three to eight decoder. one_hot has exactly one bit set, at position sel.
module decoder3to8 (
    input  logic [2:0] sel,
    output logic [7:0] one_hot
);
    assign one_hot = 8'b1 << sel;
endmodule

// eight input priority encoder. idx is the index of the highest set bit of
// req, and valid says whether any bit was set at all. the for loop is
// unrolled by synthesis into a tree of lookup tables, it is not a runtime loop.
module priority_encoder8 (
    input  logic [7:0] req,
    output logic [2:0] idx,
    output logic       valid
);
    always_comb begin
        idx   = '0;
        valid = 1'b0;
        for (int i = 7; i >= 0; i--) begin
            if (req[i]) begin
                idx   = i[2:0];
                valid = 1'b1;
            end
        end
    end
endmodule

The loop counting down from seven means a lower index overwrites a higher one, so bit zero wins ties, which is a priority order. A for loop in SystemVerilog is not a thing that runs over time. Synthesis unrolls it completely and builds the flattened logic, here a tree of lookup tables that resolves in one pass. This is worth internalizing, because loops describe repeated structure in space, not steps in time.

Comparators

A comparator comes almost for free because the language has the operators and synthesis knows the efficient circuits behind them. An equality test is a wide exclusive-or followed by a reduction, and a magnitude test reuses the same carry chain that the adder uses, since asking whether a is less than b is the same as subtracting and looking at the borrow.

comparator.svmodule comparator #(
    parameter int WIDTH = 8
)(
    input  logic [WIDTH-1:0] a,
    input  logic [WIDTH-1:0] b,
    output logic             eq,   // a equals b
    output logic             lt,   // a is less than b, unsigned
    output logic             gt    // a is greater than b, unsigned
);
    assign eq = (a == b);
    assign lt = (a <  b);
    assign gt = (a >  b);
endmodule

The adder, and why width does not slow it much

Addition is the block that makes the fabric's carry chain earn its place. Written the obvious way, an adder is a row of full adders where each carry feeds the next, which sounds like it should get slower in proportion to the number of bits. On an FPGA it does not, or not by much, because that carry does not crawl through general lookup tables. It rides the dedicated carry chain described on the previous page, which is built to pass a carry up a column of slices quickly. You do not instantiate any of this. You write the plus sign and let synthesis recognize it.

adder.sv// a parameterized unsigned adder with carry in and carry out. the extra bit on
// the sum captures the carry out, and the concatenation of a carry-in gives the
// tools the exact pattern they map onto the hardened carry chain.
module adder #(
    parameter int WIDTH = 16
)(
    input  logic [WIDTH-1:0] a,
    input  logic [WIDTH-1:0] b,
    input  logic             cin,
    output logic [WIDTH-1:0] sum,
    output logic             cout
);
    assign {cout, sum} = a + b + cin;
endmodule

The trick in that one line is the concatenation {cout, sum} on the left. The right-hand side is a WIDTH+1 bit result, the low bits are the sum and the top bit is the carry out, and assigning the whole thing to a concatenation splits it cleanly. This is the idiomatic way to catch a carry, and it maps directly onto the fabric because it is exactly the shape the carry logic expects.

What carries into the next page

These blocks share one property. Every output is a pure function of the inputs at this instant, so there is no clock and nothing is stored. Real designs cannot stay purely combinational, because they need to count, to wait, to hold a partial result across cycles. The moment a circuit remembers something on purpose, it needs the flip-flop, and it needs a clock to tell it when to remember. That is sequential logic, and it is the next page.

Next, sequential logic, registers, counters, and reset.