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;
endmoduleThe #(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
endThe 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
endmoduleThe 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);
endmoduleThe 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;
endmoduleThe 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.