Combinational Logic in HDL
Continuous assignment, always @(*) and VHDL processes, if and case, vectors and arithmetic, and the accidental latch — the classic beginner bug.
1. Combinational logic: outputs that depend only on present inputs
A combinational circuit has no memory: its outputs are a function of its inputs right now, like the gates, multiplexers, decoders and adders of Chapter 2. On an FPGA every such function, up to six inputs, fits into one LUT; larger ones become a few LUTs plus, for arithmetic, the carry chain (Chapter 5). This chapter shows the HDL constructs that describe combinational logic, and the one classic way of accidentally describing memory instead.
Both languages offer two styles. Concurrent statements sit directly in the module or architecture body — one statement per output, like the assign and <= of Chapter 7. Procedural blocks (always in Verilog, process in VHDL) group several statements that are read top to bottom, which is convenient for if and case. Both describe hardware; the procedural style is not “more software”.
// mux4: choose one of four 4-bit inputs. Concurrent style.
module mux4 (
input wire [3:0] d0, d1, d2, d3,
input wire [1:0] sel,
output wire [3:0] y
);
// Conditional operator cond ? if_true : if_false, nested
assign y = (sel == 2'd0) ? d0 :
(sel == 2'd1) ? d1 :
(sel == 2'd2) ? d2 :
d3;
endmoduleVerilog's cond ? x : y (borrowed from C) is literally a 2-to-1 multiplexer; nesting three of them describes the 4-to-1 mux. VHDL has two concurrent forms: the conditional assignment y <= x when cond else z; and the selected assignment with sel select y <= … when …; used here. The when others choice is required because std_logic has nine values, so "00"…"11" do not cover every possible value of sel — others covers the remaining ones (including "11").
2. Procedural blocks: always @(*) and process
A procedural block is a container for statements read in order. For combinational logic:
- Verilog:
always @(*). The@(*)means “re-evaluate whenever any signal read inside changes”. Signals assigned in analwaysblock must be declaredreg— a historical name; here theregbecomes plain wires and gates, not a register. Use the blocking assignment=in combinational blocks (Chapter 9 explains why). - VHDL:
process (list). The sensitivity list names the signals whose change wakes the process up. VHDL-2008 allowsprocess (all), but Vivado treats.vhdfiles as VHDL-93 by default, so this course writes the list out in full: every signal read in the process must appear in it.
// prio_enc: index of the highest-numbered active request.
// Procedural style: always @(*) with if / else if.
module prio_enc (
input wire [3:0] req,
output reg [1:0] idx,
output reg valid
);
always @(*) begin
valid = 1'b1; // default ...
if (req[3]) idx = 2'd3; // highest priority, tested first
else if (req[2]) idx = 2'd2;
else if (req[1]) idx = 2'd1;
else if (req[0]) idx = 2'd0;
else begin
idx = 2'd0; // every branch assigns idx
valid = 1'b0; // ... overridden here
end
end
endmoduleTwo rules make the procedural style predictable. First, the last assignment wins: the block sets valid to 1 at the top and overrides it in the final else. That is not “assign 1 then change it” in time — the synthesiser works out, for each combination of inputs, which assignment is the last one executed, and builds logic that produces that value. Second, in VHDL a signal assigned in a process does not change until the process suspends, so reading valid right after writing it would still give the old value; Chapter 9 makes use of this.
b but its list says only (sel, a), the simulator does not wake the process when b changes, so the output holds its old value — a latch-like behaviour. The synthesiser ignores the list and builds the combinational logic you wrote. Simulation and hardware now disagree. Vivado warns about this; take the warning seriously. Verilog's @(*) avoids the problem, which is why you should never write a hand-made list like @(a or b).3. if versus case — priority versus parallel
An if / else if chain describes priority: the first true condition wins, so req[3] above masks every lower request. Synthesis builds it as a chain of 2-to-1 muxes (or the equivalent LUT logic). A case statement compares one expression with a list of constant values that cannot overlap, which describes a parallel selection — a wide multiplexer or a look-up table. When the conditions are mutually exclusive, use case: it says what you mean, and for small functions the result is the same LUTs anyway.
A 7-segment display shows a digit with seven bar-shaped LEDs labelled a to g. The decoder below turns a 4-bit value into the seven segment enables, with hexadecimal letters A–F. It is the archetypal case: sixteen constants, one per input value. Seven outputs of four inputs each means seven LUTs (Vivado can pair some of them into dual-output LUTs).
// seg7: hexadecimal digit to 7-segment pattern.
// seg = {g, f, e, d, c, b, a}, 1 = segment lit (common-cathode display).
// a
// f b
// g
// e c
// d
module seg7 (
input wire [3:0] hex,
output reg [6:0] seg
);
always @(*) begin
case (hex)
4'h0: seg = 7'b0111111;
4'h1: seg = 7'b0000110;
4'h2: seg = 7'b1011011;
4'h3: seg = 7'b1001111;
4'h4: seg = 7'b1100110;
4'h5: seg = 7'b1101101;
4'h6: seg = 7'b1111101;
4'h7: seg = 7'b0000111;
4'h8: seg = 7'b1111111;
4'h9: seg = 7'b1101111;
4'hA: seg = 7'b1110111;
4'hB: seg = 7'b1111100; // lower-case b
4'hC: seg = 7'b0111001;
4'hD: seg = 7'b1011110; // lower-case d
4'hE: seg = 7'b1111001;
default: seg = 7'b1110001; // F
endcase
end
endmoduleThe default (Verilog) or when others (VHDL) branch is good practice even when every value is listed: in VHDL it is mandatory for the reason given in Section 1, and in both languages it guarantees that the output is assigned whatever the input. The testbench drives all sixteen values, checks that each pattern lights the right number of segments, and draws the result:
`timescale 1ns/1ps
// Drives all 16 digits, checks the number of lit segments of each,
// and draws the patterns in ASCII art.
module seg7_tb;
reg [3:0] hex;
wire [6:0] seg;
reg [6:0] pat [0:15];
reg [63:0] nlit = 64'h4554_5667_3654_5526; // lit segments, digit F..0
integer i, k, lit, errors = 0;
seg7 dut (.hex(hex), .seg(seg));
initial begin
for (i = 0; i < 16; i = i + 1) begin
hex = i;
#10;
pat[i] = seg;
lit = 0;
for (k = 0; k < 7; k = k + 1) lit = lit + seg[k];
if (lit != nlit[4*i +: 4]) errors = errors + 1;
end
// seg = {g, f, e, d, c, b, a}: three text rows per digit
for (i = 0; i < 16; i = i + 1)
$write(" %s ", pat[i][0] ? "_" : " ");
$write("\n");
for (i = 0; i < 16; i = i + 1)
$write("%s%s%s ", pat[i][5] ? "|" : " ", pat[i][6] ? "_" : " ", pat[i][1] ? "|" : " ");
$write("\n");
for (i = 0; i < 16; i = i + 1)
$write("%s%s%s ", pat[i][4] ? "|" : " ", pat[i][3] ? "_" : " ", pat[i][2] ? "|" : " ");
$write("\n");
if (errors == 0) $display("PASS: 16 digits, segment counts correct");
else $display("FAIL: %0d digits wrong", errors);
$finish;
end
endmodule_ _ _ _ _ _ _ _ _ _ _ _ | | | _| _| |_| |_ |_ | |_| |_| |_| |_ | _| |_ |_ |_| | |_ _| | _| |_| | |_| _| | | |_| |_ |_| |_ | PASS: 16 digits, segment counts correct
The PYNQ-Z2 has no 7-segment display on board; you can connect a PMOD display, or reuse seg7 in later chapters to drive one through a PMOD connector.
4. Vectors: slicing, concatenation, replication
Buses are manipulated as a whole or in parts. None of these operations creates a single gate: slicing and concatenation are just wiring, renaming which physical wire is which bit. They are free in hardware.
| Operation | Verilog | VHDL | Hardware |
|---|---|---|---|
| Declare 8-bit bus | wire [7:0] d | signal d : std_logic_vector(7 downto 0) | eight wires |
| One bit | d[7] | d(7) | one wire |
| Slice | d[7:4] | d(7 downto 4) | four wires |
| Concatenate | {a, b} | a & b | wires side by side |
| Replicate | {4{x}} | (3 downto 0 => x) | one wire fanned out |
| All zeros | 8'd0 or 0 | (others => '0') | tied to ground |
| Swap nibbles | {d[3:0], d[7:4]} | d(3 downto 0) & d(7 downto 4) | crossed wires |
| Shift left by 1 | {d[6:0], 1'b0} | d(6 downto 0) & '0' | wires, no gates |
| Reduction XOR (parity) | ^d | xor d (VHDL-2008 only) | XOR tree |
Shifting by a constant is wiring; shifting by a variable amount (d << n, shift_left(d, n)) is a barrel shifter, a layer of multiplexers per bit of n. The ALU in Section 7 contains one.
5. Arithmetic: numeric_std, widths, carry and sign
A vector is just bits; whether 1011 means 11 or \(-5\) is an interpretation you choose (Chapter 1). Verilog vectors are unsigned unless declared signed or cast with $signed(). VHDL is stricter: std_logic_vector has no arithmetic at all. The standard package ieee.numeric_std provides two types with the same bits but different meaning, unsigned and signed, and you convert explicitly:
| Conversion | VHDL (numeric_std) | Verilog |
|---|---|---|
| bits → unsigned / signed | unsigned(v) signed(v) | v / $signed(v) |
| number → bits | std_logic_vector(x) | (automatic) |
| integer → n-bit unsigned | to_unsigned(i, n) | n'd i |
| n-bit → integer | to_integer(x) | (automatic) |
| change width | resize(x, n) | {zeros or sign copies, x} |
The width of a result matters, because hardware has a fixed number of wires. Adding two \(n\)-bit numbers can produce an \((n+1)\)-bit result; if you store it in \(n\) bits, the top bit — the carry out — is silently dropped and the result wraps around modulo \(2^n\). To keep it, widen an operand first. How you widen depends on the interpretation:
// widths: the same two 4-bit codes added as unsigned and as signed numbers.
module widths (
input wire [3:0] a,
input wire [3:0] b,
output wire [3:0] wrap, // 4-bit result: overflows silently
output wire [4:0] usum, // 5 bits: carry out kept (zero extension)
output wire [4:0] ssum, // 5 bits: signed, sign extension
output wire [7:0] rep // replication: a copied twice
);
assign wrap = a + b; // truncated to 4 bits
assign usum = {1'b0, a} + {1'b0, b}; // extend with 0
assign ssum = {a[3], a} + {b[3], b}; // extend with sign bit
assign rep = {2{a}}; // = {a, a}
endmoduleIn VHDL, numeric_std's + returns the width of its wider operand, so resize(unsigned(a), 5) + unsigned(b) is a 5-bit addition, and resize on a signed value copies the sign bit. Verilog is more permissive: it extends both operands to the width of the largest expression in the statement, including the left-hand side. Writing the extension explicitly, as here, makes the intent obvious in both languages. With a = 1011, b = 0111 both simulators print:
`timescale 1ns/1ps
// a = 1011, b = 0111: as unsigned 11 + 7 = 18, as signed -5 + 7 = 2.
module widths_tb;
reg [3:0] a = 4'b1011, b = 4'b0111;
wire [3:0] wrap;
wire [4:0] usum, ssum;
wire [7:0] rep;
widths dut (.a(a), .b(b), .wrap(wrap), .usum(usum), .ssum(ssum), .rep(rep));
initial begin
#1;
$display("wrap = %b (%0d)", wrap, wrap);
$display("usum = %b (%0d)", usum, usum);
$display("ssum = %b (%0d)", ssum, $signed(ssum));
$display("rep = %b", rep);
if (wrap == 4'd2 && usum == 5'd18 && $signed(ssum) == 2 && rep == 8'b10111011)
$display("PASS");
else
$display("FAIL");
$finish;
end
endmodulewrap = 0010 (2) 11 + 7 = 18 does not fit in 4 bits: 18 mod 16 = 2 usum = 10010 (18) zero-extended: the carry is kept ssum = 00010 (2) sign-extended: -5 + 7 = 2 rep = 10111011 PASS
$signed(a) < b with an unsigned b compares as unsigned, and the sign of a is ignored. VHDL would refuse to compile the mix; Verilog silently does the wrong thing. Cast every operand, or declare the signals signed.6. The inferred latch
Here is the classic beginner bug. The intent is “y follows d while en is high”; nothing is said about what happens when en is low.
// fragment: BUGGY - y is not assigned when en = 0, so a latch is inferred
always @(*) begin
if (en)
y = d;
endA combinational block must give every output a value for every combination of inputs. This one does not: when en = 0 the code assigns nothing, and the language rules say a reg or signal that is not assigned keeps its previous value. Keeping a value requires memory. Since there is no clock, the synthesiser builds a level-sensitive latch: transparent while en = 1, holding while en = 0. Vivado reports it as a warning (“inferring latch for variable y”); the open-source GHDL synthesiser refuses outright with latch infered for net "y".
// gate_fixed: y follows d when en = 1, and is 0 otherwise.
// A default assignment at the top makes y defined on every path.
module gate_fixed (
input wire en,
input wire [3:0] d,
output reg [3:0] y
);
always @(*) begin
y = 4'd0; // default: no path leaves y unassigned
if (en)
y = d;
end
endmoduleWhy are latches bad on an FPGA? The fabric is built around edge-triggered flip-flops; a latch uses the same storage element in a different mode, but its enable is now an ordinary logic signal that can glitch, and a transparent latch creates a timing path that passes straight through it, which the timing analyser handles poorly. Intentional latches are almost never needed in FPGA design. Every latch in your synthesis report should be treated as a bug until proven otherwise.
if an else, and every case a default / when others. (3) Make sure each output is assigned in every branch, not just some. Rule (1) alone is enough and is the easiest to check by eye.7. Complete example: an 8-bit ALU
An arithmetic and logic unit is the calculating heart of a processor: a 3-bit opcode selects one of eight operations on two 8-bit operands, and four status flags describe the result. It combines everything in this chapter — a case on the opcode, defaults against latches, widened arithmetic for the carry, signed and unsigned interpretation, and a variable shift.
| op | Operation | carry | overflow |
|---|---|---|---|
| 000 | y = a + b | carry out of bit 7 | signed result out of −128…127 |
| 001 | y = a − b | 1 if a ≥ b (unsigned): no borrow | signed result out of range |
| 010 | y = a AND b | 0 | 0 |
| 011 | y = a OR b | 0 | 0 |
| 100 | y = a XOR b | 0 | 0 |
| 101 | y = 1 if a < b (signed) | 0 | 0 |
| 110 | y = 1 if a < b (unsigned) | 0 | 0 |
| 111 | y = a shifted right arithmetically by b[2:0] | 0 | 0 |
zero is 1 when y is all zeros and neg is bit 7 of y. The two kinds of “too big” are different: carry is about the unsigned reading (\(200 + 100 = 300 > 255\)), overflow about the signed one (\(100 + 100 = 200 > 127\)). For addition, signed overflow happens exactly when both operands have the same sign and the result has the other:
// alu: 8-bit arithmetic and logic unit with status flags.
// op operation carry overflow
// 000 y = a + b carry out signed overflow
// 001 y = a - b 1 if a >= b (no borrow, unsigned) signed overflow
// 010 y = a & b 0 0
// 011 y = a | b 0 0
// 100 y = a ^ b 0 0
// 101 y = (a < b) signed 0 0
// 110 y = (a < b) unsigned 0 0
// 111 y = a >>> b[2:0] 0 0 (arithmetic shift right)
module alu (
input wire [7:0] a,
input wire [7:0] b,
input wire [2:0] op,
output reg [7:0] y,
output reg carry,
output reg overflow,
output wire zero,
output wire neg
);
// 9-bit results keep the carry / borrow in bit 8
wire [8:0] sum9 = {1'b0, a} + {1'b0, b};
wire [8:0] dif9 = {1'b0, a} - {1'b0, b};
always @(*) begin
carry = 1'b0; // defaults: no latches
overflow = 1'b0;
case (op)
3'b000: begin
y = sum9[7:0];
carry = sum9[8];
overflow = (a[7] == b[7]) && (sum9[7] != a[7]);
end
3'b001: begin
y = dif9[7:0];
carry = ~dif9[8]; // bit 8 is the borrow
overflow = (a[7] != b[7]) && (dif9[7] != a[7]);
end
3'b010: y = a & b;
3'b011: y = a | b;
3'b100: y = a ^ b;
3'b101: y = {7'd0, $signed(a) < $signed(b)};
3'b110: y = {7'd0, a < b};
default: y = $signed(a) >>> b[2:0];
endcase
end
assign zero = (y == 8'd0);
assign neg = y[7];
endmoduleNotes on the two versions. The sums are computed once, outside the case, in 9 bits so that bit 8 holds the carry (for subtraction, bit 8 is set when a borrow occurred, so carry is its inverse — the ARM convention). Verilog's >>> on a $signed value is an arithmetic shift (copies the sign bit in); VHDL uses shift_right on a signed. The VHDL version computes the result into an internal signal r because zero and neg need to read it, and VHDL-93 forbids reading an out port. The process sensitivity list contains every signal it reads: a, b, op, sum9, dif9.
A 3-input function of 8-bit operands has only \(8 \times 256 \times 256 = 524\,288\) input combinations, so the testbench simply tries them all. It computes the expected value with ordinary integer arithmetic — a completely different method from the design — and also folds every output into a 32-bit signature so that the two simulations, and the Python model below, can be compared with one number.
`timescale 1ns/1ps
// Exhaustive ALU test: all 8 ops x 256 x 256 operands = 524 288 vectors.
// The expected results are computed with plain integer arithmetic.
module alu_tb;
reg [7:0] a, b;
reg [2:0] op;
wire [7:0] y;
wire carry, overflow, zero, neg;
integer ia, ib, iop, sa, sb, r, errors = 0;
reg [7:0] ey;
reg ec, ev;
reg [31:0] sig = 0; // signature of all outputs, compared with Python
alu dut (.a(a), .b(b), .op(op), .y(y), .carry(carry),
.overflow(overflow), .zero(zero), .neg(neg));
initial begin
for (iop = 0; iop < 8; iop = iop + 1)
for (ia = 0; ia < 256; ia = ia + 1)
for (ib = 0; ib < 256; ib = ib + 1) begin
op = iop; a = ia; b = ib;
#1;
sa = (ia > 127) ? ia - 256 : ia; // two's-complement value
sb = (ib > 127) ? ib - 256 : ib;
ec = 0; ev = 0;
case (iop)
0: begin r = ia + ib; ec = r > 255;
ev = (sa + sb > 127) || (sa + sb < -128); end
1: begin r = ia - ib; ec = ia >= ib;
ev = (sa - sb > 127) || (sa - sb < -128); end
2: r = ia & ib;
3: r = ia | ib;
4: r = ia ^ ib;
5: r = sa < sb;
6: r = ia < ib;
default: r = sa >>> (ib % 8);
endcase
ey = r; // keep the low 8 bits
if (y !== ey || carry !== ec || overflow !== ev ||
zero !== (ey == 0) || neg !== ey[7]) begin
if (errors < 5)
$display("op=%0d a=%0d b=%0d: y=%0d expected %0d", iop, ia, ib, y, ey);
errors = errors + 1;
end
sig = sig * 31 + {y, carry, overflow, zero, neg};
end
if (errors == 0) $display("PASS: 524288 vectors, signature %h", sig);
else $display("FAIL: %0d mismatches", errors);
$finish;
end
endmoduleIcarus: PASS: 524288 vectors, signature 4624fe68 (about 3 s) GHDL: PASS: 524288 vectors, signature 4624FE68 (about 45 s)
(GHDL also prints one harmless metavalue detected warning at time 0, when the inputs are still 'U' and the shifter converts b to an integer.)
8. Python: a bit-accurate model and two readings of one byte
A bit-accurate model reproduces the hardware's results bit for bit, using the same operations the hardware uses — masking to 8 bits, a 9-bit sum, sign-bit comparisons — rather than high-level arithmetic. Here it is checked against a reference written with Python's unbounded integers, over all 524 288 cases at once with NumPy, and it reproduces the testbench signature. The plots show the same 8-bit code read as unsigned and as signed, and which operand pairs make \(a + b\) set the carry flag, the overflow flag, or both.
Click Run to execute the Python code
Code will be executed with Python 3 on the server
The right-hand plot makes the difference between the flags visible. Carry (sky and amber) is set above the anti-diagonal \(a + b = 256\). Overflow occupies two triangles: both operands positive (codes below 0x80) with a sum above 127 (orange), or both negative with a sum below \(-128\) (amber, where the carry is also set). Mixed signs can never overflow. Exactly a quarter of all pairs overflow — the 16 384 printed by the program.
9. Summary
| You want | Verilog | VHDL (VHDL-93 compatible) |
|---|---|---|
| One output, simple expression | assign y = ...; | y <= ...; |
| Two-way choice, concurrent | assign y = c ? a : b; | y <= a when c = '1' else b; |
| Choice on a value, concurrent | (nested ?: or always + case) | with s select y <= ... when ...; |
| Several outputs, if / case | always @(*) begin ... end | process (every input) begin ... end process; |
| Arithmetic | on wire/reg, $signed() for signed | unsigned()/signed() from numeric_std |
| Avoid latches | default assignment at the top | default assignment at the top |
Everything in this chapter is memoryless. Chapter 9 adds the clock: flip-flops, registers, counters and shift registers, and the second kind of assignment that goes with them.
Practice Problems
wire [7:0] d = 8'hB6; (VHDL: d = x"B6"), what are d[7:4], {d[3:0], d[7:4]} and {4{d[0]}}? Write the VHDL equivalents of the last two expressions.always @(sel or a) if (sel) y = a; else y = b;. It simulates wrongly but synthesises to a correct multiplexer. Explain both halves of that sentence, then give two fixes (one Verilog, one for the VHDL-93 equivalent).widths module. Give wrap, usum and ssum in binary and in decimal, and say whether the signed 4-bit addition overflowed.a[7] == b[7] and y[7] != a[7]”, is equivalent to carry into bit 7 XOR carry out of bit 7. Then explain why the subtraction rule compares a[7] != b[7].