Combinational Logic in SystemVerilog

Combinational logic is hardware with no memory: the outputs depend only on the inputs right now, the way a pocket calculator's display depends only on what is currently typed. Adders, decoders, multiplexers, your Karnaugh-map simplifications — all combinational. You already know how to design such circuits with truth tables and gates; this lesson is about how pleasant they are to write. In SystemVerilog a K-map's worth of gate-juggling collapses into a few readable lines — and the tool derives the gates.

Two ways to say "wire this up"

Simple functions suit the assign you have already met:

assign y = (sel) ? d1 : d0; // a 2:1 multiplexer, in one ternary assign parity = ^data; // XOR-reduce a whole vector: 1 if odd number of 1s

For anything with structure — priorities, cases, multi-step selections — SystemVerilog offers the always_comb block: a region where you describe the output using familiar if/else and case, and the synthesizer builds the equivalent gate network:

always_comb begin if (sel == 2'b00) y = d0; // 2'b00: a 2-bit binary literal else if (sel == 2'b01) y = d1; else if (sel == 2'b10) y = d2; else y = d3; end

Do not be fooled by the program-like look: this does not "execute". It is a specification of a truth table — for every value of the inputs, which value the output wire carries — and it becomes a 4:1 multiplexer, pure gates. The if/else if ladder does carry one hardware meaning worth knowing: it implies priority. The conditions are checked in order, so the synthesized network gives the first true branch the shortest logical path. A case statement on a full decode, by contrast, implies a flat, parallel selector with no favouritism — often smaller and faster. Choose the shape that matches your intent.

What the tool builds

A synthesizer typically realises that 4:1 selection as a small tree of 2:1 muxes — the hardware version of a knockout tournament:

Worked example: a seven-segment decoder

The classic showpiece of case: turn a 4-bit number into the seven on/off signals that light a digit display (the display on every microwave and petrol pump). One segment pattern per digit — a truth table with sixteen rows, written exactly as you would say it:

always_comb begin case (digit) // segments: abcdefg 4'd0: seg = 7'b1111110; 4'd1: seg = 7'b0110000; 4'd2: seg = 7'b1101101; 4'd3: seg = 7'b1111001; 4'd4: seg = 7'b0110011; 4'd5: seg = 7'b1011011; 4'd6: seg = 7'b1011111; 4'd7: seg = 7'b1110000; 4'd8: seg = 7'b1111111; 4'd9: seg = 7'b1111011; default: seg = 7'b0000000; // blank for 10-15: EVERY case needs a default endcase end

Run the same decoder below and watch the digits appear — the TypeScript mirrors the case table line for line, then "wires" each segment bit to a stroke of the drawing:

// The case table: segment bits abcdefg for each digit 0-9. const table = [ "1111110", "0110000", "1101101", "1111001", "0110011", "1011011", "1011111", "1110000", "1111111", "1111011", ]; // Render one digit: segments a(top) b(top-right) c(bottom-right) d(bottom) e(bottom-left) f(top-left) g(middle). function render(digit: number): string[] { const s = table[digit]; const on = (i: number) => s[i] === "1"; return [ on(0) ? " __ " : " ", (on(5) ? "|" : " ") + (on(6) ? "__" : " ") + (on(1) ? "|" : " "), (on(4) ? "|" : " ") + (on(3) ? "__" : " ") + (on(2) ? "|" : " "), ]; } // Show 0-9 side by side, one row of the drawing at a time. for (let row = 0; row < 3; row++) { let line = ""; for (let d = 0; d <= 9; d++) line += render(d)[row] + " "; console.log(line); }

The rule that keeps it combinational

Combinational hardware has no memory — so your description must give the output a value for every possible input. Break that rule and something eerie happens:

always_comb begin if (enable) y = data; // …and if enable is 0? y must REMEMBER its old value! end

When enable is 0, the description says nothing about y — so the synthesizer, dutifully, builds hardware in which y keeps its previous value. Keeping a previous value means memory: the tool infers a latch, a storage element you did not ask for, with ugly timing behaviour that haunts chips. Declaring the block always_comb (rather than old-style always @(*)) makes the tool warn you about exactly this. The cures are mechanical: a default arm in every case, an else for every trailing if, or assigning a safe value at the top of the block.

You may notice something bittersweet: nobody asked for a Karnaugh map. The synthesizer minimises two-level logic automatically (its algorithms — Quine–McCluskey's descendants, Espresso — handle hundreds of variables where K-maps manage six), so the days of hand-simplifying a decoder are gone. Why did you learn K-maps then? For the same reason pilots learn to fly without autopilot: the map is how you see what the tool is doing — why a default arm shrinks a circuit, why one extra product term appears when you split a case, why "don't care" inputs are a gift. Engineers who can read the minimisation reason about area and timing; engineers who can't just watch numbers move. The tool replaced the labour, not the understanding.

Inside always_comb the ifs and cases look so procedural that the lesson-one mindset slips. Hold the line: the block describes one gate network, recomputed continuously from its inputs — all branches physically exist at once, and "which branch ran" just means "which path through the muxes is currently selected". You cannot put a delay in the middle, wait for something, or count events: there is no time inside combinational logic, only inputs and the outputs they force. Every trick that needs memory of the past — counting, pausing, remembering — needs a clock and a flip-flop, which is exactly where this module goes next.