The Arithmetic Logic Unit
The arithmetic logic unit (ALU) is the combinational block that turns two 32-bit operands and a small operation selector into one 32-bit result. For RV32I it needs to do exactly ten things — ADD, SUB, AND, OR, XOR, SLL, SRL, SRA, SLT, SLTU — and that count is not a simplification or a starting subset. It is the complete list of integer computational operations the ratified base ISA defines (Unprivileged ISA, version 20240411, §Integer Computational Instructions). Three design facts make the block more interesting than its op list suggests: SUB is ADD with an inverted operand and a carry-in, so one 32-bit adder serves both and both comparisons as well; the comparisons write a 0 or a 1 into a register rather than setting flags, because RISC-V has no condition-code register at all — a deliberate and consequential ISA decision; and the shifter is a barrel shifter, which is by a wide margin the most expensive thing in the block.
The ALU is also the canonical critical path. In a single-cycle core, the clock period is bounded below by the time to read a register, propagate a carry across 32 bits, and write the result back — so the ALU’s structure is directly your Fmax. This note builds the module, cross-checks it against a C reference model over 202,260 vectors under Verilator 5.046, shows the output, and demonstrates in simulation the one-bit decoder mistake that almost everyone makes on their first
addi. Context: definitely-not-esp32 MOC Stage 2, alongside The Register File.
Mental Model
An ALU is not one machine; it is several machines running in parallel, all the time, with a multiplexer at the end. Every cycle, the adder adds, the shifter shifts, the AND gates AND, and the comparators compare — on whatever operands happen to be present — and then a single mux driven by the operation selector picks one result and discards the rest. Nothing is “skipped” for an operation you did not ask for. This is the fundamental difference between hardware and software: in software an unused branch costs nothing; in hardware it costs full area and full power, and it is always running.
That fact drives every design decision below. Sharing the adder between ADD, SUB, SLT and SLTU is not a stylistic economy, it is the difference between one 32-bit carry chain and four. Building the shifter as a barrel shifter rather than a loop is what makes it expensive, and it is expensive whether or not the program contains a single shift instruction.
flowchart LR A["a (rs1)"] B["b (rs2 or imm)"] SEL["sel = {instr[30], funct3}<br/>4 bits from the decoder"] subgraph UNITS["always computing, every cycle"] direction TB ADD["33-bit adder<br/>a + (b XOR sub) + sub"] SHL["left barrel shifter<br/>a << b[4:0]"] SHR["right barrel shifter<br/>33-bit arithmetic"] LOG["bitwise AND / OR / XOR<br/>32 independent gates"] end A --> ADD & SHL & SHR & LOG B --> ADD & SHL & SHR & LOG ADD -->|"sum[31:0]"| MUX{{"result mux<br/>driven by sel"}} ADD -->|"cout, sum[31]"| CMP["comparator logic<br/>lt_u = ~cout<br/>lt_s = sign-aware"] CMP -->|"zero-extended to<br/>32 bits: 0 or 1"| MUX SHL --> MUX SHR --> MUX LOG --> MUX SEL --> MUX MUX --> Y["y (32 bits) -> rd"] Y --> Z["zero = (y == 0)<br/>free, used by BEQ/BNE"]
The RV32I ALU as parallel function units feeding one mux. What it shows: four functional blocks, all evaluating simultaneously on the same operands, with the operation selector acting only at the output. Note that the comparator hangs off the adder’s carry-out and sign bit rather than being its own unit. The insight to take: the delay of the ALU is max(delay of each unit) + mux delay, not the sum. So the slowest unit sets your clock — and the two candidates for slowest are the 32-bit carry chain and the five-level barrel shifter. Everything else (AND, OR, XOR) is a single gate delay and is effectively free.
The Ten Operations RV32I Requires
Ten. Not eight, not twelve. The ratified spec’s own encoding table settles it — here are the sixteen rows of the RV32I base instruction set that use opcode 0110011 (OP, register-register) and opcode 0010011 (OP-IMM, register-immediate), transcribed from the manual’s instruction listing (Unprivileged ISA 20240411, §RV32/RV64 Instruction Set Listings):
| Operation | funct7 (instr[31:25]) | funct3 (instr[14:12]) | opcode | I-type form | ALU behaviour |
|---|---|---|---|---|---|
| ADD | 0000000 | 000 | 0110011 | ADDI | a + b, overflow ignored |
| SUB | 0100000 | 000 | 0110011 | (none) | a - b, overflow ignored |
| SLL | 0000000 | 001 | 0110011 | SLLI | a << b[4:0], zeros in |
| SLT | 0000000 | 010 | 0110011 | SLTI | a <ₛ b ? 1 : 0 |
| SLTU | 0000000 | 011 | 0110011 | SLTIU | a <ᵤ b ? 1 : 0 |
| XOR | 0000000 | 100 | 0110011 | XORI | a ^ b |
| SRL | 0000000 | 101 | 0110011 | SRLI | a >> b[4:0], zeros in |
| SRA | 0100000 | 101 | 0110011 | SRAI | a >> b[4:0], sign in |
| OR | 0000000 | 110 | 0110011 | ORI | a | b |
| AND | 0000000 | 111 | 0110011 | ANDI | a & b |
The complete RV32I ALU operation table, with the exact encodings. What it shows: ten operations, and a strikingly regular encoding — funct3 alone distinguishes eight of them, and funct7 distinguishes the remaining two pairs (ADD/SUB and SRL/SRA) using a single bit, instr[30]. The insight to take: funct3 is the ALU opcode. You do not need a decode table that maps instructions to ALU operations; you need three wires and one extra bit. Note also the empty cell: there is no SUBI, because addi rd, rs1, -imm covers it — the immediate is signed, so subtraction of a constant is free.
Everything not in that table is worth naming explicitly, because “surely the ALU also does X” is a common source of scope creep:
LUIandAUIPCare not ALU operations in the usual sense.LUIplaces a 20-bit immediate in the top ofrdwith zeros below — that is immediate generation and a bypass, not arithmetic.AUIPCadds the same shifted immediate to the program counter, which some designs route through the ALU (feedingpcin as operanda) and others give a separate adder. Either is fine; routing it through the ALU costs a 2:1 mux on operanda, which you already need for branch-target calculation.- No overflow detection. The spec is unambiguous: “No integer computational instructions cause arithmetic exceptions”, and “Overflows are ignored and the low XLEN bits of results are written to the destination
rd”. There is no overflow flag, no trap, nothing. The spec’s rationale explains that overflow checks are cheap in software: for general signed addition, three extra instructions (slti,slt,bne) suffice, and for unsigned addition one branch does it —add t0, t1, t2; bltu t0, t1, overflow. - No carry-in, no carry-out, no add-with-carry. Multi-word arithmetic is synthesised: add the low words, use
SLTUto derive the carry (sltu carry, sum_lo, a_lo), and add that into the high word. TheSLTUyou built for comparisons doubles as the carry generator, which is a neat and non-obvious payoff for the shared-adder design. - No rotate. Rotates arrive with the ratified B (bit-manipulation) extension, not the base. Do not build them into your RV32I ALU.
- No multiply or divide. Those are the M extension, and they belong in a separate multi-cycle unit — see The M Extension Is Not in the ALU.
The shift instructions carry one encoding subtlety worth internalising now. The immediate shift forms (SLLI, SRLI, SRAI) are, in the spec’s words, “encoded as a specialization of the I-type format. The operand to be shifted is in rs1, and the shift amount is encoded in the lower 5 bits of the I-immediate field. The right shift type is encoded in bit 30.” So a shift-immediate is not a general I-type: bits [31:25] are a funct7 field, not immediate bits, and only [24:20] carry the shift amount. That is why the naive decoder in the next section but one goes wrong on ordinary ADDI and right on SRAI.
One Adder for ADD and SUB
Two’s complement negation is “invert every bit and add one”. So:
a − b = a + (−b) = a + (¬b + 1) = a + ¬b + 1
Walk the symbols: ¬b is the bitwise complement of b (every 0 becomes 1 and vice versa); adding 1 to that complement produces −b in two’s complement; and adding that to a is subtraction. The crucial structural observation is the position of the + 1: an adder already has a carry-in input, so the + 1 is free — you do not need a second adder to add it, you simply assert carry-in.
That gives the classic adder/subtractor: one adder, one XOR gate per bit on operand b, and one control wire that drives both the XOR gates and the carry-in.
flowchart LR SUBW["sub<br/>(1 = subtract)"] B["b[31:0]"] A["a[31:0]"] subgraph INV["32 XOR gates — the conditional inverter"] X["b_eff[i] = b[i] XOR sub<br/>sub=0 -> b unchanged<br/>sub=1 -> b complemented"] end B --> X SUBW --> X SUBW -->|"same wire,<br/>as carry-in"| ADD A --> ADD["33-bit adder<br/>{1'b0,a} + {1'b0,b_eff} + sub"] X --> ADD ADD -->|"sum33[31:0]"| S["sum -> ADD / SUB result"] ADD -->|"sum33[32] = cout"| C["carry-out"] C -->|"~cout"| LTU["lt_u : a <ᵤ b"] ADD -->|"sum[31] (sign of difference)"| LTS["lt_s : a <ₛ b<br/>(with sign-disagreement fix-up)"] style SUBW fill:#ffe9c9,stroke:#c80
The shared adder/subtractor, and how both comparators fall out of it. What it shows: a single control signal (sub) drives 32 XOR gates on one operand and the adder’s carry-in; the same adder’s carry-out and sign bit then answer both comparison questions. The insight to take: four of the ten RV32I operations — ADD, SUB, SLT, SLTU — are one adder plus about 35 gates of decoration. A naive implementation with a separate adder, subtractor, signed comparator and unsigned comparator would burn four 32-bit carry chains where one does. On a small FPGA where the carry chain is a dedicated, fast, but finite resource, that is a real difference.
Why the carry-out is the unsigned comparison
This is the part that looks like a coincidence and is not. When sub = 1, the adder computes a + ¬b + 1 in 33 bits. Consider the two cases:
- If
a ≥ b(unsigned), the true differencea − bis non-negative and fits in 32 bits, and the 33-bit sum ends up with bit 32 set — the carry propagated out. - If
a < b(unsigned), the difference is negative, represented as2³² + (a − b), and there is no carry out.
So cout = 1 ⟺ a ≥ b unsigned, and therefore SLTU is simply ~cout. One inverter. No comparator.
Why the signed comparison needs a fix-up
The signed case cannot just read sum[31], because a − b can overflow when a and b have different signs. Take a = 0x7FFFFFFF (INT_MAX, positive) and b = 0x80000000 (INT_MIN, negative): the true difference is 2³¹ − 1 − (−2³¹) = 2³² − 1, which does not fit, and the 32-bit result has its sign bit set — falsely suggesting a < b.
The standard fix uses the observation that overflow is only possible when the operands’ signs differ, and in exactly that case the answer is trivially known:
wire lt_s = (a[31] ^ b[31]) ? a[31] : sum[31];Reading it: if the sign bits disagree (a[31] ^ b[31] is 1), then whichever operand is negative is the smaller one, so a < b precisely when a is the negative one — that is a[31]. If the signs agree, the difference cannot overflow, so its sign bit sum[31] is the answer directly. Two gates on top of the adder.
This is genuinely all of it. The simulation below exercises the exact boundary cases (0x80000000 vs 0x7FFFFFFF, 0xFFFFFFFF vs 1, equality) plus 200,000 random vectors against a C reference model, and finds zero mismatches.
Comparison Without Flags
On x86, ARM, SPARC and PowerPC, a comparison sets bits in a dedicated condition-code register (the flags register: zero, carry, negative, overflow), and a subsequent conditional branch reads those bits. On RISC-V there is no such register. SLT and SLTU write a plain 0 or 1 into a general-purpose register, exactly like any other arithmetic result, and conditional branches do their own comparison inline.
This is the single most consequential ISA decision reflected in the ALU, and the spec argues for it at length (Unprivileged ISA 20240411, §Conditional Branches):
The conditional branches were designed to include arithmetic comparison operations between two registers (as also done in PA-RISC, Xtensa, and MIPS R6), rather than use condition codes (x86, ARM, SPARC, PowerPC)… This design was motivated by the observation that a combined compare-and-branch instruction fits into a regular pipeline, avoids additional condition code state or use of a temporary register, and reduces static code size and dynamic instruction fetch traffic. Another point is that comparisons against zero require non-trivial circuit delay (especially after the move to static logic in advanced processes) and so are almost as expensive as arithmetic magnitude compares. Another advantage of a fused compare-and-branch instruction is that branches are observed earlier in the front-end instruction stream, and so can be predicted earlier.
Four distinct arguments there, and each has a hardware consequence you will feel:
- No extra architectural state. Condition codes are a register that every instruction implicitly writes and every branch implicitly reads. In a pipeline that is a hazard on a resource that does not appear in the instruction encoding — which is exactly the kind of hazard that is easy to get wrong. In an out-of-order machine it must be renamed like any other register, adding a rename port to every arithmetic instruction. RISC-V simply does not have the problem.
- A branch is self-contained.
beq rs1, rs2, offsetnames both operands and the target. Nothing earlier in the instruction stream has to have set anything up. The branch predictor and the branch resolution logic need only the instruction word and two register reads. - Comparison is not cheaper than subtraction anyway. The spec’s point about “non-trivial circuit delay” is the structural one: a 32-bit magnitude compare requires information to propagate across all 32 bits, which is the same problem a carry chain solves. Since you already have an adder, you get the compare for free — which is precisely the sharing exploited in the previous section.
- Code size. No
cmpinstruction preceding every branch.
What this means for your datapath
The branches reuse the ALU’s comparator, and the mapping is direct. Branch funct3 values, from the same encoding table:
| Branch | funct3 | Condition | Reuses |
|---|---|---|---|
BEQ | 000 | rs1 == rs2 | zero output of a − b |
BNE | 001 | rs1 != rs2 | ~zero |
BLT | 100 | rs1 <ₛ rs2 | lt_s — the same wire SLT uses |
BGE | 101 | rs1 ≥ₛ rs2 | ~lt_s |
BLTU | 110 | rs1 <ᵤ rs2 | lt_u — the same wire SLTU uses |
BGEU | 111 | rs1 ≥ᵤ rs2 | ~lt_u |
Branch conditions mapped onto ALU comparator outputs. What it shows: all six conditional branches are covered by the two comparator wires you already built, plus one equality test, plus inversion. The insight to take: the branch unit is not a separate comparator — it is a three-input mux selecting between zero, lt_s and lt_u, with funct3[0] acting as the invert bit (note the pattern: 000/001, 100/101, 110/111 — the low bit always flips the sense). If you find yourself instantiating a second subtractor for the branch unit, stop; you have the answer already.
Note also what is absent from that table: BGT, BGTU, BLE, BLEU. The spec explains they “can be synthesized by reversing the operands to BLT, BLTU, BGE, and BGEU, respectively” — the assembler swaps rs1 and rs2 and emits the mirrored opcode. Four fewer encodings, zero extra hardware. The same economy that gave x0 its idiom family (see The Register File).
One genuinely useful consequence for software, which the spec flags as a tip: “Signed array bounds may be checked with a single BLTU instruction, since any negative index will compare greater than any nonnegative bound.” A negative index, reinterpreted as unsigned, is an enormous number, so one unsigned compare catches both the < 0 and the ≥ length cases. Your lt_u wire is doing bounds checking for the whole language runtime.
Finally, the point the section title makes: SLT produces a value, not a flag. The simulation output below shows SLT of (7, 9) = 00000001 — a full 32-bit word, zero-extended, ready to be written to rd by the register file’s write port like any sum. That is why there is no special path from the ALU to a flags register: there is no flags register, and the comparison result travels the ordinary result path.
The Barrel Shifter
Shifting is the one RV32I operation with no cheap combinational implementation. Adding is cheap because each bit’s result depends on only three inputs plus a carry that propagates linearly. Shifting is expensive because every output bit can come from any input bit, so in principle each of the 32 output bits needs a 32:1 multiplexer — 32 muxes of 32 inputs, which is a lot of silicon.
The barrel shifter is the standard trick that turns that into something affordable: instead of one 32:1 mux per bit, use five stages of 2:1 muxes, each stage shifting by a fixed power of two, gated by one bit of the shift amount.
flowchart TB IN["a[31:0]"] --> S0 subgraph BS["barrel shifter — 5 stages, 32 muxes each"] direction TB S0["stage 0: shamt[0]<br/>pass through, or shift by 1"] S1["stage 1: shamt[1]<br/>pass through, or shift by 2"] S2["stage 2: shamt[2]<br/>pass through, or shift by 4"] S3["stage 3: shamt[3]<br/>pass through, or shift by 8"] S4["stage 4: shamt[4]<br/>pass through, or shift by 16"] S0 --> S1 --> S2 --> S3 --> S4 end SH["shamt = b[4:0]<br/>only 5 bits, always"] SH -.->|"bit 0"| S0 SH -.->|"bit 1"| S1 SH -.->|"bit 2"| S2 SH -.->|"bit 3"| S3 SH -.->|"bit 4"| S4 S4 --> OUT["result[31:0]"] FILL["fill bit:<br/>0 for SLL and SRL<br/>a[31] for SRA"] -.->|"shifted in at<br/>every stage"| BS
Barrel shifter structure. What it shows: five stages of conditional shift-by-2ⁿ, controlled by the five bits of the shift amount, with a fill bit determining what enters the vacated positions. Any shift from 0 to 31 is the binary decomposition of the shift amount across these stages — a shift by 21 (10101₂) enables stages 0, 2 and 4. The insight to take: the cost is 5 stages × 32 bits = 160 two-input multiplexers per direction, and the delay is 5 mux levels. Compare that with the adder’s 32-position carry chain, which on an FPGA runs on dedicated fast carry logic. The shifter is bigger in area; whether it is slower depends entirely on whether your target has a hardened carry chain. On FPGAs it usually does, so the shifter often wins the “who is the critical path” contest.
The mux count is where the “expensive” reputation comes from. On the Tang Nano 20K’s LUT4 architecture a 2:1 mux is one LUT4 (a LUT4 can implement any function of four inputs; a mux needs three). So one direction of barrel shifter is on the order of 160 LUT4s, against roughly 32 LUT4s plus carry logic for a 32-bit adder. That is a five-to-one ratio between the two units, for the two operations you use least and most respectively. This arithmetic is a structural derivation, not a synthesis measurement — see the uncertainty callout in The ALU as the Critical Path.
Three shifts from (nearly) one shifter
RV32I needs three shifts: SLL (left, zeros in), SRL (right, zeros in), SRA (right, sign in). The two right shifts merge trivially — they differ only in the fill bit — and the standard idiom is to widen to 33 bits so that Verilog’s arithmetic right shift does the work:
wire arith = (sel == SRA);
wire [32:0] shr_in = {arith & a[31], a}; // sign bit prepended, or 0
wire [32:0] shr33 = $signed(shr_in) >>> shamt; // ONE arithmetic right shift
wire [31:0] shr = shr33[31:0]; // bit 32 is scaffoldingLine by line: arith is 1 only for SRA. shr_in is a 33-bit value whose top bit is the sign of a for SRA and 0 for SRL. $signed(...) >>> shamt is Verilog’s arithmetic right shift, which replicates the top bit — so for SRA it replicates the sign, and for SRL it replicates the zero you supplied. One shifter, both semantics, selected by one bit of the input rather than by a mux on the output.
Left shifts genuinely need a second shifter, or a bit-reversal trick: reverse the input, right-shift, reverse the output. Bit reversal is free in hardware (it is just wiring — no gates at all), so the reversal approach costs one shifter plus two 32-bit muxes instead of two shifters. Whether that is a win depends on the target; the code below uses two shifters for clarity, and the synthesis tool on a LUT architecture will often share logic between them anyway. If you are area-constrained, the reversal trick is the first optimisation to try.
The five-bit rule, and why it is a mask not a clamp
The spec says shifts take their amount from “the lower 5 bits of register rs2” (register-register form) or “the lower 5 bits of the I-immediate field” (immediate form). So b[4:0], and the upper 27 bits of rs2 are ignored entirely. This is a mask, not a saturating clamp, and the difference is observable:
== 5. shift amount is masked to 5 bits, not clamped ==
SLL 1 by 32 = 00000001 (shamt = 32 & 31 = 0, so this is a no-op)
SLL 1 by 33 = 00000002 (shamt = 1)
SRA 80000000 by 31 = ffffffff SRA 80000000 by 32 = 80000000Shifting by 32 is a no-op, not a zeroing. C programmers will recognise this as the hardware reason x << 32 is undefined behaviour on a 32-bit int — the language refuses to promise a result because different architectures mask, clamp, or produce garbage. RISC-V masks, and the spec says so, so on RV32 it is well-defined even where C is not. Get this wrong (by using the full rs2 as the shift amount, which in Verilog will produce zero for any value ≥ 32) and you will pass every hand-written test and fail riscv-tests, which exercises exactly this boundary.
How the Decoder Selects an Operation
The elegant part of RV32I is that the ALU’s operation selector is almost literally a slice of the instruction word. funct3 (instr[14:12]) distinguishes eight operations; instr[30] — the top bit of funct7 — splits ADD from SUB and SRL from SRA. Four bits, {instr[30], funct3}, name all ten. That is the sel input of the module below, and it needs no lookup table.
But there is a trap, and it is a one-bit trap that costs people an evening.
Look at the same bit position in the two formats that share it:
packet-beta title R-type (opcode 0110011) — instr[30] is funct7 bit 5 0-6: "opcode" 7-11: "rd" 12-14: "funct3" 15-19: "rs1" 20-24: "rs2" 25-29: "funct7[4:0]" 30-30: "ALT" 31-31: "f7[6]"
packet-beta title I-type (opcode 0010011) — instr[30] is imm[10], a DATA bit 0-6: "opcode" 7-11: "rd" 12-14: "funct3" 15-19: "rs1" 20-29: "imm[9:0]" 30-30: "imm[10]" 31-31: "imm[11] sign"
The same bit, instr[30], in the two formats that the ALU decoder must distinguish. What it shows: in R-type it is the funct7 bit that separates ADD from SUB and SRL from SRA; in I-type the whole of instr[31:20] is the twelve-bit immediate, so bit 30 is imm[10] — an ordinary data bit. The insight to take: because imm[11] at bit 31 is the sign bit and the immediate is sign-extended, imm[10] is set for every negative immediate from −1 to −1024. That is not an exotic corner; it is the ordinary case. The shift-immediate forms (SLLI/SRLI/SRAI) are the exception that makes the wrong rule look right: for those, instr[31:25] really is a funct7 field, because the shift amount only needs instr[24:20].
flowchart TB START(["instruction word"]) --> OP{"opcode<br/>instr[6:0]"} OP -->|"0110011<br/>OP (R-type)"| RT["instr[31:25] IS funct7.<br/>instr[30] is a real<br/>operation-select bit."] OP -->|"0010011<br/>OP-IMM (I-type)"| IT{"funct3<br/>instr[14:12]"} OP -->|"anything else"| OTHER["ALU still runs —<br/>force sel = ADD for<br/>loads, stores, JALR,<br/>AUIPC, branch targets"] IT -->|"001 SLLI<br/>101 SRLI/SRAI"| SHIFT["shift-immediate:<br/>instr[31:25] IS funct7.<br/>instr[30] is real."] IT -->|"000 ADDI · 010 SLTI<br/>011 SLTIU · 100 XORI<br/>110 ORI · 111 ANDI"| IMM["ordinary I-type:<br/>instr[31:20] IS the immediate.<br/>instr[30] is imm[10] —<br/>A DATA BIT. IGNORE IT."] RT --> GOOD["alt = instr[30]"] SHIFT --> GOOD IMM --> ZERO["alt = 0"] OTHER --> ZERO GOOD --> SEL["sel = {alt, funct3}"] ZERO --> SEL style IMM fill:#ffd9d9,stroke:#c33 style SHIFT fill:#fff3cd,stroke:#c90
How the decoder derives ALUOp from the instruction word. What it shows: instr[30] is a genuine operation-select bit for R-type instructions and for the shift forms of OP-IMM, and is an ordinary immediate data bit (imm[10]) for every other OP-IMM instruction. The insight to take: the red box is the bug. The rule alt = instr[30] is right two-thirds of the time and catastrophically wrong the rest, which means it will pass your first ten tests. The correct rule is one AND gate wider: alt = instr[30] & (is_OP | (is_OP_IMM & is_shift)).
The bug, demonstrated
Rather than assert this, here it is running. The testbench instantiates two decoders — a correct one and the naive alt = instr[30] — feeds both into identical ALU instances, and drives them with real instruction words disassembled from riscv64-linux-gnu-gcc 16.1.1 output, not hand-invented encodings:
// Correct: instr[30] is a real funct7 bit only for R-type, or for the
// *shift* forms of OP-IMM. For every other OP-IMM instruction instr[30]
// is imm[10] -- a data bit that has nothing to do with the operation.
assign sel_good = {bit30 & (is_op | (is_op_imm & is_shift)), funct3};
// Naive: trust instr[30] unconditionally.
assign sel_naive = {bit30, funct3};$ iverilog -g2012 -o tb_decode.vvp rtl/alu.v rtl/decode_alusel.v rtl/tb_decode.v
$ ./tb_decode.vvpReal encodings, straight out of riscv64-linux-gnu-objdump:
addi x1, x0, 1024 40000093 bit30=1 sel_good=0000 sel_naive=1000 y_good=00000400 (1024) y_naive=fffffc00 (-1024) <== DIVERGES
addi x1, x0, 42 02a00093 bit30=0 sel_good=0000 sel_naive=0000 y_good=0000002a (42) y_naive=0000002a (42)
addi sp, sp, -16 ff010113 bit30=1 sel_good=0000 sel_naive=1000 y_good=00001ff0 (8176) y_naive=00002010 (8208) <== DIVERGES
addi sp, sp, -1024 c0010113 bit30=1 sel_good=0000 sel_naive=1000 y_good=00001c00 (7168) y_naive=00002400 (9216) <== DIVERGES
addi sp, sp, -2048 80010113 bit30=0 sel_good=0000 sel_naive=0000 y_good=00001800 (6144) y_naive=00001800 (6144)
andi x1, x2, 1024 40017093 bit30=1 sel_good=0111 sel_naive=1111 y_good=00000000 (0) y_naive=f0f0f4f0 (-252644112) <== DIVERGES
ori x1, x2, 1024 40016093 bit30=1 sel_good=0110 sel_naive=1110 y_good=f0f0f4f0 (-252644112) y_naive=f0f0f4f0 (-252644112)
slti x1, x2, 1024 40012093 bit30=1 sel_good=0010 sel_naive=1010 y_good=00000001 (1) y_naive=f0f0ecf0 (-252646160) <== DIVERGES
xori x1, x2, 1024 40014093 bit30=1 sel_good=0100 sel_naive=1100 y_good=f0f0f4f0 (-252644112) y_naive=f0f0f4f0 (-252644112)
srli x1, x1, 4 0040d093 bit30=0 sel_good=0101 sel_naive=0101 y_good=0ffff800 (268433408) y_naive=0ffff800 (268433408)
srai x1, x1, 4 4040d093 bit30=1 sel_good=1101 sel_naive=1101 y_good=fffff800 (-2048) y_naive=fffff800 (-2048)
add x1, x2, x3 003100b3 bit30=0 sel_good=0000 sel_naive=0000 y_good=0000000d (13) y_naive=0000000d (13)
sub x1, x2, x3 403100b3 bit30=1 sel_good=1000 sel_naive=1000 y_good=00000005 (5) y_naive=00000005 (5)Read the first line carefully, because it is the whole point: addi x1, x0, 1024 executes as x1 = −1024 on the naive decoder. The immediate 1024 is 0b010000000000; imm[10] is set; imm[10] occupies instr[30]; the naive decoder reads that as “subtract” and the ALU computes 0 − 1024.
Now read the lines that do not diverge, because they are why this bug survives:
addi x1, x0, 42is fine, because 42 hasimm[10] = 0. Every non-negative immediate below 1024 is safe — and those are exactly the constants a hand-written first test uses.oriandxoriat these operands agree by accident. The naiveselof1110/1100is not a legal encoding, so the ALU’sdefaultbranch returns the sum — and fora = 0xF0F0F0F0,b = 1024, the bit-10 position ofahappens to be clear, soa + 1024anda | 1024are the same number. Change either operand and it breaks.- The shift forms
srli/sraiare correctly handled by the naive decoder, because for shift-immediatesinstr[30]really is a funct7 bit. That is exactly the case that makes the wrong rule look right.
So the bug is data-dependent and silent on the obvious tests — but the three stack-pointer lines show it is nothing like rare. addi sp, sp, -16 is the most common instruction in any function prologue in existence, and its encoding is 0xff010113: the immediate −16 is 0b111111110000 after sign extension into imm[11:0], so imm[10] = 1, so instr[30] = 1, so the naive decoder subtracts. The stack pointer moves up by 16 instead of down. Every local variable then aliases the caller’s frame.
Generalise that: imm[10] is a sign-extension bit for every negative immediate in the range −1024 to −1, which is where essentially all real negative immediates live. So the naive decoder is wrong for almost every negative addi a compiler emits. Note the one that survives: addi sp, sp, -2048 (0x80010113) has imm[10] = 0, because −2048 is exactly 0b100000000000 and the bit-10 position is where the sign extension stops. A test suite containing only −2048-sized frames would pass.
The upshot: hand-written assembly with small positive constants — which is what Stage 2’s first programs are — will not find this. Compiled code finds it immediately. This is precisely the class of failure Stage 4’s The riscv-tests Suite exists to catch, and precisely why the MOC insists that your own tests encode your own misunderstandings.
One more decoder detail that matters for the datapath: the ALU runs for instructions that are not ALU instructions. A load lw rd, imm(rs1) needs rs1 + imm for its address; a store needs the same; jalr needs rs1 + imm for its target; a conditional branch needs pc + imm for its target. All of those go through the ALU with sel forced to ADD, and with the operand muxes selecting immediate or pc as appropriate. That is the OTHER branch in the flowchart above, and it is a large part of what Datapath and Control is about.
Writing It in Verilog
The complete module — rtl/alu.v, purely combinational, no clock, linted clean under verilator --lint-only -Wall (Verilator 5.046):
// alu.v -- the complete RV32I ALU. Ten operations, one adder, one barrel
// shifter, no condition-code register.
//
// The select is {alt, funct3} taken straight out of the instruction:
// alt = instr[30], funct3 = instr[14:12].
// That is not a coincidence -- RV32I's funct3 field *is* the ALU opcode, and
// instr[30] is the one bit that distinguishes ADD/SUB and SRL/SRA.
module alu (
input wire [3:0] sel, // {alt, funct3}
input wire [31:0] a, // rs1
input wire [31:0] b, // rs2 or the sign-extended immediate
output reg [31:0] y,
output wire zero // y == 0, free for BEQ/BNE
);
localparam ADD = 4'b0_000, SUB = 4'b1_000,
SLL = 4'b0_001,
SLT = 4'b0_010, SLTU = 4'b0_011,
XOR = 4'b0_100,
SRL = 4'b0_101, SRA = 4'b1_101,
OR = 4'b0_110, AND = 4'b0_111;
// ---- ONE adder serves ADD, SUB, SLT and SLTU -------------------------
// subtract = a + ~b + 1. Invert b and feed the inversion in as carry-in.
wire do_sub = sel[3] & (sel[2:0] == 3'b000) // SUB
| (sel[2:0] == 3'b010) // SLT needs a-b
| (sel[2:0] == 3'b011); // SLTU needs a-b
wire [31:0] b_eff = do_sub ? ~b : b;
wire [32:0] sum33 = {1'b0, a} + {1'b0, b_eff} + {32'b0, do_sub};
wire [31:0] sum = sum33[31:0];
wire cout = sum33[32]; // 1 iff a >= b, unsigned, when do_sub
// Comparators fall out of the same adder -- no extra subtractor.
wire lt_u = ~cout; // a <u b
wire lt_s = (a[31] ^ b[31]) ? a[31] : sum[31]; // a <s b
// ^ if the signs differ the negative one is smaller; if they agree the
// difference cannot overflow, so its sign bit is the answer.
// ---- ONE barrel shifter serves SLL, SRL and SRA -----------------------
// Only the low 5 bits of the shift amount are used (spec: RV32I shifts
// take shamt from the low 5 bits of rs2 / of the I-immediate).
wire [4:0] shamt = b[4:0];
wire arith = (sel == SRA);
wire [32:0] shr_in = {arith & a[31], a}; // 33-bit, sign in
/* verilator lint_off UNUSEDSIGNAL */
wire [32:0] shr33 = $signed(shr_in) >>> shamt; // one right shifter
/* verilator lint_on UNUSEDSIGNAL */
wire [31:0] shr = shr33[31:0]; // bit 32 is scaffolding
wire [31:0] shl = a << shamt;
always @* begin
case (sel)
ADD, SUB : y = sum;
SLT : y = {31'b0, lt_s}; // 0 or 1 into rd
SLTU : y = {31'b0, lt_u}; // 0 or 1 into rd
XOR : y = a ^ b;
OR : y = a | b;
AND : y = a & b;
SLL : y = shl;
SRL, SRA : y = shr;
default : y = sum; // unreachable in RV32I
endcase
end
assign zero = (y == 32'b0);
endmoduleThe commentary that matters:
input [3:0] selwithlocalparam ADD = 4'b0_000etc. The underscore is a Verilog numeric separator and is purely cosmetic, but it makes the{alt, funct3}structure visible at a glance. Defining the ten selectors as named constants rather than raw literals is what lets thecasestatement read as the spec’s operation table.always @*with acaseand adefault. The@*(or@(*)) sensitivity list is inferred, so you cannot forget a signal — a classic pre-2001 Verilog bug. Thedefaultbranch is mandatory: without it, the six illegalselvalues (1001,1010,1011,1100,1110,1111) would leaveyunassigned, and Verilog’s rule for an incompletely assignedregin a combinational block is to infer a latch. A latch in your ALU is a catastrophic and hard-to-find bug;verilator -Wallcatches it, but writing thedefaultmeans it never arises.wire do_sub = ... | (sel[2:0] == 3'b010) | (sel[2:0] == 3'b011);— note that SLT and SLTU force subtraction regardless ofsel[3]. This is the sharing that makes four operations one adder.wire [32:0] sum33 = {1'b0, a} + {1'b0, b_eff} + {32'b0, do_sub};— everything is widened to 33 bits explicitly so the carry-out is a real bit you can read, rather than being silently discarded. The explicit{32'b0, do_sub}rather than baredo_subis what keeps Verilator’s width checker quiet; Verilog’s implicit width extension rules are a genuine source of bugs and it is worth being explicit everywhere.assign zero = (y == 32'b0);— computed from the result, not from the adder specifically. That meanszerois meaningful for every operation, and forSUBit is exactly theBEQcondition. A 32-input NOR is a few gate levels and does not extend the critical path, because it happens after the result mux in parallel with the result being registered.- The
lint_off UNUSEDSIGNALpragma aroundshr33. Bit 32 of the 33-bit shift result is deliberately discarded, and Verilator correctly notices. Silencing a warning you have understood, in the narrowest possible scope, with the reason in a comment, is good practice; silencing warnings globally is not. Note that Verilator’s first complaint here was a genuineWIDTHTRUNC— assigning a 33-bit expression to a 32-bit wire — which is exactly the category of error that produces a design that simulates one way and synthesises another.
What the module deliberately does not contain: no clock, no reset, no state. An RV32I ALU is pure combinational logic, and keeping it that way means it can be tested exhaustively without any notion of time — which is what makes the 202,260-vector cross-check below cheap enough to run on every build.
Simulating It
The ALU is combinational, so it is tested by a reference model cross-check: write the ten operations a second time in C, straight from the prose of the spec, and confirm the two agree on a large number of operand pairs. This is a genuinely different implementation, not a restatement — the C model uses int32_t comparison operators and the language’s own shift semantics, while the RTL uses a shared adder and a barrel shifter — so agreement is evidence rather than tautology.
// The reference model. Every line is a sentence from the spec.
static uint32_t ref(uint8_t sel, uint32_t a, uint32_t b) {
int32_t sa = (int32_t)a, sb = (int32_t)b;
uint32_t shamt = b & 31u; // "the lower 5 bits"
switch (sel) {
case 0x0: return a + b; // ADD (overflow ignored)
case 0x8: return a - b; // SUB (overflow ignored)
case 0x1: return a << shamt; // SLL
case 0x2: return (sa < sb) ? 1u : 0u; // SLT -> 1 or 0 in rd
case 0x3: return (a < b ) ? 1u : 0u; // SLTU -> 1 or 0 in rd
case 0x4: return a ^ b; // XOR
case 0x5: return a >> shamt; // SRL (zeros shifted in)
case 0xD: return (uint32_t)(sa >> shamt); // SRA (sign replicated)
case 0x6: return a | b; // OR
case 0x7: return a & b; // AND
}
return 0;
}Build and run under Verilator, which compiles the RTL to C++ and links it against this harness:
$ verilator --cc --exe --build -Wall -o alu_sim rtl/alu.v rtl/tb_alu.cpp --top-module alu
$ ./obj_dir/alu_sim== 1. the ten operations, one worked example each ==
ADD a=0000002a b=00000000 -> 0000002a
SUB a=00000005 b=00000007 -> fffffffe
SLL a=00000001 b=0000001f -> 80000000
SLT a=ffffffff b=00000001 -> 00000001
SLTU a=ffffffff b=00000001 -> 00000000
XOR a=f0f0f0f0 b=0f0f0f0f -> ffffffff
SRL a=80000000 b=00000004 -> 08000000
SRA a=80000000 b=00000004 -> f8000000
OR a=f0f0f0f0 b=0f0f0f0f -> ffffffff
AND a=f0f0f0f0 b=ff00ff00 -> f000f000
== 2. SLT vs SLTU on the same bits -- the whole point of two ops ==
a=ffffffff b=00000001 SLT=1 SLTU=0 (-1 <s 1, but 4294967295 >u 1)
a=80000000 b=7fffffff SLT=1 SLTU=0 (INT_MIN <s INT_MAX, but >u)
a=00000000 b=00000000 SLT=0 SLTU=0 (equal is not less-than)
== 3. the result is a 0/1 VALUE in rd, not a flag ==
SLT of (7, 9) = 00000001 -- a full 32-bit word, writable by the RF
== 4. SUB is ADD with an inverted operand and carry-in ==
00000009 - 00000004 = 00000005 (== 00000009 + ~00000004 + 1 = 00000005)
00000004 - 00000009 = fffffffb (== 00000004 + ~00000009 + 1 = fffffffb)
00000000 - 00000001 = ffffffff (== 00000000 + ~00000001 + 1 = ffffffff)
80000000 - 00000001 = 7fffffff (== 80000000 + ~00000001 + 1 = 7fffffff)
== 5. shift amount is masked to 5 bits, not clamped ==
SLL 1 by 32 = 00000001 (shamt = 32 & 31 = 0, so this is a no-op)
SLL 1 by 33 = 00000002 (shamt = 1)
SRA 80000000 by 31 = ffffffff SRA 80000000 by 32 = 80000000
== 6. SRA replicates the sign; SRL never does ==
a=ffff8000 >> 0 : SRL=ffff8000 SRA=ffff8000
a=ffff8000 >> 4 : SRL=0ffff800 SRA=fffff800
a=ffff8000 >> 8 : SRL=00ffff80 SRA=ffffff80
== 7. the zero flag is free, and is what BEQ/BNE use ==
SUB 1234-1234 : y=00000000 zero=1 -> BEQ taken
SUB 1234-1235 : y=ffffffff zero=0 -> BEQ not taken
== 8. directed corner cases ==
2260 corner vectors, 0 mismatches
== 9. randomized cross-check against the C reference model ==
200000 random vectors across all 10 ops, 0 mismatches
PASS -- 202260 checks, 0 failuresWhat each block is evidence for:
- Test 1 exercises each of the ten operations once, with an argument chosen to be diagnostic.
SLL 1 by 31producing80000000confirms the shifter reaches the top bit;SRLandSRAof80000000by 4 producing08000000andf8000000confirms the fill-bit logic in both directions. - Test 2 is the reason
SLTandSLTUare two instructions rather than one. The same 64 bits of input produce opposite answers depending on which interpretation you ask for, and the80000000vs7fffffffrow is precisely the case where a naivesum[31]signed comparison overflows and gets it wrong. That row passing is the evidence that the sign-disagreement fix-up works. - Test 3 makes the “value not flag” point concrete:
00000001is a 32-bit word on the ordinary result path. - Test 4 verifies the algebraic identity underlying the shared adder, including the
80000000 − 1 = 7fffffffcase where the subtraction wraps through the sign boundary. - Test 5 verifies the five-bit mask.
- Test 6 shows
SRLandSRAdiverging exactly as the fill bit dictates, and agreeing at shift-by-zero. - Test 7 is the
zerooutput feedingBEQ/BNE. - Test 8 is 2,260 directed corner vectors: the cross product of ten operations with a fifteen-value corner set (
0,1,2,INT_MAX,INT_MIN,−1, half-word masks, alternating bit patterns, and the shift-boundary values 31/32/33) in both operand positions. - Test 9 is 200,000 pseudorandom vectors from an xorshift64 generator, distributed round-robin across all ten operations.
202,260 checks, 0 failures. That is not proof of correctness — an exhaustive check of a 32×32×4-bit input space is 2⁶⁸ vectors and is not happening — but combined with the directed corner set it is a strong signal, and it takes under a second to run. Wire it into your build. The value of a fast exhaustive-ish check on a combinational block is that when your core later produces a wrong result, you can eliminate the ALU from suspicion in one command rather than staring at a waveform.
A note on why this is a C++ harness rather than a Verilog testbench: Verilator compiles RTL into a C++ class, so the reference model can be written in ordinary C with real int32_t semantics, and 200,000 vectors run at native speed. The same test written in Verilog under Icarus would take orders of magnitude longer and the reference model would have to be written in Verilog, where it would be far more likely to share a bug with the design. Use Icarus for sequential testbenches with clocks and $dumpvars (as The Register File does); use Verilator with a C++ harness for high-volume checking of combinational blocks. See Testbenches and RTL Verification.
The ALU as the Critical Path
“Critical path” means the longest combinational delay between any two registers in the design. It is the thing that sets your maximum clock frequency, because the clock period must be at least that delay plus setup time plus clock skew. In a single-cycle RV32I core, the path is almost always this one:
flowchart LR PC[("PC register")] --> IMEM["instruction<br/>memory read"] IMEM --> DEC["decode:<br/>opcode, funct3,<br/>instr[30]"] IMEM --> RFR["register file<br/>2 x async read"] IMEM --> IMM["immediate<br/>generation"] DEC --> AMUX["operand mux<br/>rs2 or imm"] RFR --> AMUX IMM --> AMUX AMUX --> ALU["**ALU**<br/>carry chain OR<br/>5-level barrel shift"] DEC --> ALU ALU --> WBMUX["writeback mux<br/>ALU / load / pc+4"] WBMUX --> RFW[("register file<br/>write port")] ALU -.->|"branch condition"| PCMUX["PC mux"] PCMUX --> PC style ALU fill:#ffd9d9,stroke:#c33,stroke-width:3px
The single-cycle critical path, with the ALU highlighted. What it shows: everything between the PC register and the register-file write port happens in one clock period, and the ALU sits in the middle of it with two other multiplexers stacked on either side. The insight to take: the ALU is not the only thing on this path, but it is the only thing on it whose delay grows with data width. Instruction memory read, decode, and the muxes are roughly constant; the 32-bit carry chain and the five-level barrel shifter are not. That is why “the ALU is the critical path” is the default assumption — and why the second path in this diagram, the dotted one from the ALU’s comparator back to the PC mux, is often the actual worst case in a pipelined design, because it must also resolve before the next fetch.
Two structural facts govern how bad it is.
The adder. A naive ripple-carry adder propagates the carry through 32 full-adder stages in series, so its delay is roughly linear in width — the worst structure available. Real designs use carry-lookahead, carry-select, or carry-skip topologies to get logarithmic delay at higher area cost. On an FPGA you generally get this for free: every modern family has a dedicated hardened carry chain running vertically through the logic cells, far faster than the general routing fabric, and the synthesis tool maps a + b onto it automatically. This is why on an FPGA the adder is often not the critical path even though on an ASIC it would be. Writing assign sum = a + b; and letting the tool infer the carry chain will beat any hand-built adder you write; do not hand-build one.
The shifter. Five levels of 2:1 mux, each level on general routing. There is no dedicated hardware to accelerate it. This is why the shifter frequently becomes the FPGA critical path even though the adder is “the classic” one — the adder got hardware help and the shifter did not.
PicoRV32’s design decisions are evidence for exactly this. Its default is not a barrel shifter: BARREL_SHIFTER defaults to 0, and instead shifts are “performed by successively shifting by a small amount”. The default TWO_STAGE_SHIFT performs shifts “in two stages: first shifts in units of 4 bits and then shifts in units of 1 bit”, which the README says “speeds up shift operations, but adds additional hardware” (PicoRV32 README). In other words, a widely used small RV32 core deliberately spends cycles on shifts rather than area and delay, and only offers the barrel shifter as an option — with the note that “when BARREL_SHIFTER is activated, a shift operation takes as long as any other ALU operation”. PicoRV32 also offers TWO_CYCLE_COMPARE, which “relaxes the longest data path a bit by adding an additional FF stage at the cost of adding an additional clock cycle delay to the conditional branch”. The existence of that option tells you where the designer measured the problem to be: the comparator-to-branch path.
The measured payoff of all that discipline: PicoRV32 closes timing at 2.2–2.4 ns (416–454 MHz) on Kintex-7 and Virtex-7 parts, rising to 1.3–1.4 ns (714–769 MHz) on UltraScale+, with 750–2,000 LUTs depending on configuration.
Uncertain
Verify: the actual critical path and Fmax of the
alu.vin this note on a Gowin GW2AR-18, and the LUT4 cost of its barrel shifter versus its adder. Reason: no synthesis tool is installed on this machine —yosys,nextpnr-himbaecheland Gowin EDA are all absent, so nothing here is a timing measurement. The160 LUT4s per shifter directionfigure earlier in this note is arithmetic from the structure (5 stages × 32 bits × 1 LUT4 per 2:1 mux), not a synthesis report, and real tools will do better through logic sharing between the SLL and SRL/SRA paths. The PicoRV32 numbers quoted above are measured, but by their authors on Xilinx parts with Vivado, not on this design or this FPGA family. To resolve:yosys -p 'synth_gowin -family gw2a -json alu.json' alu.vfor area, thennextpnr-himbaechel --device GW2AR-LV18QN88C8/I7for a timing estimate; or build the design in Gowin EDA and read the Timing Analysis Report’s critical path. Until then, treat “the ALU is the critical path” as the standard structural expectation to test, not as a measured fact about this module. See Timing Closure and Fmax.
The practical advice for Stage 2, which does not depend on the unresolved numbers: build the straightforward version first, synthesise it, and read the report. The MOC’s Stage 9 guidance says the same thing about branch prediction — “read the synthesis report’s critical path and find out what is really limiting your Fmax — it is rarely what you assumed.” An ALU optimisation made before you have a timing report is a guess, and the pattern of guesses being wrong here is strong: people optimise the adder that the FPGA already accelerated and leave the shifter that it did not.
The M Extension Is Not in the ALU
RV32IMC includes the M extension (version 2.0, ratified), which adds eight instructions: MUL, MULH, MULHSU, MULHU, DIV, DIVU, REM, REMU. They share opcode 0110011 with the R-type ALU operations and are distinguished by funct7 = 0000001 (Unprivileged ISA 20240411, §RV32M):
| Instruction | funct7 | funct3 | Result |
|---|---|---|---|
MUL | 0000001 | 000 | low XLEN bits of rs1 × rs2 |
MULH | 0000001 | 001 | high XLEN bits, signed × signed |
MULHSU | 0000001 | 010 | high XLEN bits, signed × unsigned |
MULHU | 0000001 | 011 | high XLEN bits, unsigned × unsigned |
DIV | 0000001 | 100 | signed quotient, rounding toward zero |
DIVU | 0000001 | 101 | unsigned quotient |
REM | 0000001 | 110 | signed remainder; sign follows the dividend |
REMU | 0000001 | 111 | unsigned remainder |
The M extension’s eight instructions and their encodings. What it shows: they occupy the same funct3 space as the base ALU operations, discriminated purely by funct7 = 0000001. The insight to take: this is why your decoder must check funct7 and not only instr[30] once you add M — 0000001 has instr[30] = 0 and instr[25] = 1, so a decoder that only looks at bit 30 will happily decode MUL as ADD. Adding M is a decoder change before it is a datapath change.
These do not belong in the combinational ALU. The reason is the critical path argument from the previous section, taken to its conclusion: a combinational 32×32 multiplier is roughly the area of several adders and has a delay several times a single carry chain, and a combinational restoring divider is worse — divide is fundamentally sequential, requiring on the order of one subtract-and-compare per result bit. Putting either inline would set the clock period for every instruction, including the addi that makes up most of your instruction stream. So the standard structure is a separate multi-cycle functional unit with a start/busy/done handshake: the pipeline issues the operation, stalls the dependent instruction, and collects the result some cycles later.
The spec’s own rationale endorses the separation: “We separate integer multiply and divide out from the base to simplify low-end implementations, or for applications where integer multiply and divide operations are either infrequent or better handled in attached accelerators.”
There is also a Zmmul extension (version 1.0, ratified) that provides the multiply subset without the divide, and the spec’s note on it is directly aimed at this project’s situation: “For many microcontroller applications, division operations are too infrequent to justify the cost of divider hardware… Simple FPGA soft cores particularly benefit from eliminating division but retaining multiplication, since many FPGAs provide hardwired multipliers but require dividers be implemented in soft logic.” The Tang Nano 20K’s GW2AR-18 carries 48 hardened 18×18 DSP multipliers; a 32×32 multiply maps onto a handful of them, essentially free. A divider maps onto nothing and must be built out of LUTs. If area gets tight, Zmmul is the principled place to cut.
Divide by zero does not trap — and that is unusual
This is the M extension’s most surprising rule and the one most likely to bite you when you write the divider. RISC-V does not raise an exception on divide by zero, or on signed division overflow. It returns a defined value. From the spec’s table, where L is the operation width in bits (32 for RV32):
| Condition | Dividend | Divisor | DIVU | REMU | DIV | REM |
|---|---|---|---|---|---|---|
| Division by zero | x | 0 | 2ᴸ − 1 (all ones) | x (the dividend) | −1 (all ones) | x (the dividend) |
| Signed overflow | −2ᴸ⁻¹ | −1 | — | — | −2ᴸ⁻¹ (the dividend) | 0 |
Divide-by-zero and overflow semantics, transcribed from the spec’s divby0 table (Unprivileged ISA 20240411, §Division Operations). What it shows: four fully defined results where most architectures trap. Quotient of division by zero is all-ones in both signed and unsigned interpretations; remainder of division by zero is the dividend unchanged; signed overflow (INT_MIN / −1) returns the dividend with remainder zero. The insight to take: your divider must produce these exact values, and the “all ones” choice is not arbitrary — the spec says it was picked because “the value of all 1s is both the natural value to return for unsigned divide, representing the largest unsigned number, and also the natural result for simple unsigned divider implementations”, and because “signed division is often implemented using an unsigned division circuit and specifying the same overflow result simplifies the hardware.” The spec chose the values your circuit was going to produce anyway.
The design rationale for not trapping is worth reading in full, because it explains a systems-level decision, not a circuit one:
We considered raising exceptions on integer divide by zero, with these exceptions causing a trap in most execution environments. However, this would be the only arithmetic trap in the standard ISA (floating-point exceptions set flags and write default values, but do not cause traps) and would require language implementors to interact with the execution environment’s trap handlers for this case. Further, where language standards mandate that a divide-by-zero exception must cause an immediate control flow change, only a single branch instruction needs to be added to each divide operation, and this branch instruction can be inserted after the divide and should normally be very predictably not taken, adding little runtime overhead.
Two consequences for the project. First, your trap handler will never see a divide-by-zero, so do not write a mcause case for one — there is no such mcause value. Second, this interacts with the microkernel in Stage 8: on x86 or ARM, dividing by zero in a user task is a fault the kernel must handle and turn into a signal or a task kill. On RISC-V it is not. A buggy user task that divides by zero gets 0xFFFFFFFF and keeps running. If you want the x86 behaviour, the compiler must emit the branch, which is what the spec is describing.
Two more implementation notes the spec supplies for free:
- Fusion pairs. If both the high and low halves of a product are needed, the recommended sequence is
MULH[[S]U] rdh, rs1, rs2immediately followed byMUL rdl, rs1, rs2with the source specifiers in the same order andrdhnot aliasing a source. “Microarchitectures can then fuse these into a single multiply operation instead of performing two separate multiplies.” The same holds forDIV/REM. You do not have to implement fusion, but if you emit a divider that computes both quotient and remainder internally — which most do — recognising the pair is nearly free and halves the cost ofa/bplusa%b. REMsign rule. “For REM, the sign of a nonzero result equals the sign of the dividend.” So−7 % 3is−1, not2. This matches C99 and differs from Python. Get it wrong andriscv-tests’rv32umsuite will tell you.
None of this belongs in alu.v. Build the ten operations, pass rv32ui, then add the M unit as a separate module with its own handshake and its own testbench. That ordering is Stage 4 of the ladder for a reason.
Failure Modes and Gotchas
Ordered by how likely each is to cost you an evening.
1. alt = instr[30] unconditionally. Demonstrated in simulation above. addi sp, sp, -16 becomes addi sp, sp, +16. The fix is one AND gate. This is the single highest-probability bug in this note.
2. A missing default in the case, inferring a latch. An incompletely assigned reg in an always @* block synthesises to a latch, which turns your combinational ALU into a state element with no clock. Symptom: results that depend on the previous instruction’s operands in a way that makes no sense; timing analysis reporting paths through a latch. Verilator’s -Wall flags it (LATCH). Always write the default.
3. Using all 32 bits of rs2 as the shift amount. a << b in Verilog, with b a 32-bit value, does what Verilog says, not what RISC-V says. For b ≥ 32 you get zero; RISC-V requires b & 31. Symptom: passes every hand test, fails rv32ui-p-sll, -srl, -sra. The fix is b[4:0], and note the same masking applies to the immediate shift forms.
4. Signed comparison implemented as sum[31]. Works for every operand pair with matching signs, fails when the signs differ and the subtraction overflows. Symptom: slt gives the wrong answer for INT_MAX vs INT_MIN, and nothing else looks wrong. This is the case Test 2 in the simulation exists to pin.
5. Signed comparison implemented as $signed(a) < $signed(b) and the unsigned one as a < b. This is correct, and it is also how you end up with two extra 32-bit comparators that the synthesis tool may or may not merge with your adder. Not a correctness bug — a silent area and timing cost. Prefer deriving both from the shared adder’s cout and sum[31], and let the test suite prove the derivation.
6. Sign-extending the immediate in the wrong place. The ALU’s operand b for an I-type instruction is the sign-extended 12-bit immediate. If your immediate generator zero-extends, addi x1, x0, -1 gives x1 = 4095 instead of −1. This is not an ALU bug but it presents as one, and it is worth checking first when arithmetic on negative constants goes wrong. This belongs to Instruction Decode and RISC-V Instruction Formats.
7. Forgetting that SLTIU sign-extends the immediate before comparing it as unsigned. The spec is explicit: “SLTIU is similar but compares the values as unsigned numbers (i.e., the immediate is first sign-extended to XLEN bits then treated as an unsigned number).” So sltiu rd, rs1, -1 compares against 0xFFFFFFFF, not against some small number. This double-conversion is easy to get backwards and is exactly how the seqz pseudoinstruction (sltiu rd, rs, 1) works.
8. Width truncation on the shift result. Verilator’s WIDTHTRUNC fired on the first draft of the module in this note: $signed(shr_in) >>> shamt on a 33-bit input produces 33 bits, assigned to a 32-bit wire. Verilog silently truncates and in this case the truncation is correct — but relying on implicit truncation is how you get a bug the day the widths change. Make it explicit and lint clean.
9. Building a separate comparator for the branch unit. Not wrong, just wasteful: a second 32-bit magnitude compare on the critical path. The six branch conditions are zero, lt_s, lt_u and their inversions, all of which the ALU already produces.
10. Assuming zero is only meaningful after a SUB. It is computed from y, so it is valid for every operation. Useful, but also a trap if your control logic assumes otherwise: and x0, x1, x2 sets zero too.
11. Putting MUL in the combinational ALU because “the FPGA has DSP blocks anyway”. The DSP block is fast, but it is not free on the critical path, and DIV cannot use it at all. Keep M as a separate multi-cycle unit with a handshake even if the multiplier itself turns out to be single-cycle — the interface is what lets you change your mind later.
Alternatives and When to Choose Them
The ALU has real design freedom, unlike The Register File. The choices cluster into three independent axes: adder topology, shifter strategy, and how much you share.
| Choice | What it is | Cost | When to choose it |
|---|---|---|---|
Inferred a + b | let the tool map to the FPGA carry chain | smallest and fastest on FPGA | Always, on an FPGA. You cannot beat hardened carry logic. |
| Hand-built ripple-carry | 32 full adders in series | linear delay | Never on FPGA. Only for teaching, or on an ASIC flow with no adder library. |
| Carry-lookahead / carry-select | logarithmic-delay adder topologies | more area, less delay | ASIC, or an FPGA where the tool refused to infer the carry chain. |
| Barrel shifter | 5 mux stages, 1 cycle | ~160 LUT4s per direction | Single-cycle cores; any core where a shift must not stall. |
| Iterative shifter | shift by 1 per cycle, up to 31 cycles | tiny | Multi-cycle cores where shifts are rare. PicoRV32’s default. |
| Two-stage shifter | shift by 4, then by 1 (≤ 11 cycles) | middling | PicoRV32’s TWO_STAGE_SHIFT, the pragmatic middle. |
| Bit-reversal for SLL | one right shifter + free wiring | halves shifter area | Area-constrained designs. Reversal is pure routing, zero gates. |
| Shared adder for ADD/SUB/SLT/SLTU | one carry chain, XOR + carry-in | ~35 extra gates | Always. Four operations for the price of one. |
| Separate comparators | $signed(a) < $signed(b) etc. | 2 extra 32-bit compares | Only if the synthesis report says the shared version is slower, which it will not. |
| Bit-serial ALU | 1 bit wide, 32+ cycles per op | SERV: 125–239 LUTs for the whole core | Extreme area constraints. |
ALU implementation choices. What it shows: three orthogonal decisions, each trading area and delay against cycles. The insight to take: the top row and the “shared adder” row are not really choices — they are strictly dominant on an FPGA and you should just take them. The genuine decision is the shifter, and it is genuine because a barrel shifter costs roughly five times an adder in LUTs for the least frequently executed operations in the ISA.
Two of these deserve more than a table row.
Iterative versus barrel shifter is the one real trade in a small core. A barrel shifter makes every shift take one cycle at the cost of ~160 LUT4s per direction and five levels of mux delay. An iterative shifter is a 32-entry state machine costing almost nothing in area, at the price of up to 31 extra cycles per shift instruction. Which wins depends entirely on your workload’s shift density and on whether you are already multi-cycle. For this project, the barrel shifter is the right call, because Stage 2’s whole point is a single-cycle reference model against which the pipelined version is compared. An iterative shifter forces multi-cycle behaviour into a design that is supposed to be the simple baseline. Build the barrel shifter, and revisit only if the Stage 9 timing report indicts it.
Bit-serial is instructive as the limit case. SERV processes one bit per cycle throughout — the register file, the ALU, and the datapath are all one bit wide — and fits an entire RV32I core in 125 LUTs on an AMD Artix-7, 198 on a Lattice iCE40, 239 on an Intel Cyclone 10LP, with 164 flip-flops (SERV README, fetched 2026-09-04). Compare that to the 750–2,000 LUTs of PicoRV32 and it is clear what the width buys and costs. SERV’s existence is also a useful corrective to the instinct that a wider ALU is obviously better: for a design whose job is to poll a sensor, thirty-two cycles per instruction on a core that costs 125 LUTs may be exactly right.
Against the sibling blocks: The Register File has almost no design freedom (the ISA fixes it), Instruction Decode has moderate freedom (how much to decode when), and the ALU has the most. That is why the ALU is where you should expect to spend your Stage 9 optimisation effort, and why it is also where a premature optimisation costs you the most.
Production Notes
What real cores actually build. PicoRV32 is the useful reference point precisely because it is not maximal: no barrel shifter by default, an optional second register-file read port, an optional two-cycle compare to shorten the critical path. It reaches 416–454 MHz on 7-series Xilinx parts in 750–2,000 LUTs, with an average CPI around 4 and Dhrystone at 0.516 DMIPS/MHz with fast multiply, divide and the barrel shifter enabled (PicoRV32 README). That CPI is high because it is a multi-cycle design, and the README says so up front: “This core is optimized for size and fmax, not performance.” A pipelined single-issue core targets CPI closer to 1.2 with far more logic. Knowing both ends of that range is what makes the Stage 10 honest-benchmarking exercise meaningful — see Cycles Per Instruction.
The two numbers to hold onto for this project. SERV at 125 LUTs (bit-serial, tens of cycles per instruction) and PicoRV32 at 750–2,000 LUTs (multi-cycle, CPI ≈ 4). A pipelined RV32IMC with a barrel shifter, an M unit, CSRs and PMP will land well above the PicoRV32 figure. On the Tang Nano 20K’s 20,736 LUT4s there is comfortable room, but the margin is not infinite once the UART, Wishbone fabric, CLINT and PLIC are added. Budget the ALU’s shifter as a real line item.
Test the ALU exhaustively before you test the core. The full 202,260-vector cross-check in this note runs in well under a second. Make it part of the build. The reason is diagnostic economy: when your core executes riscv-tests and fails rv32ui-p-sra, you want to be able to say “the ALU is correct” without opening a waveform. A combinational block with a reference model is the cheapest possible thing to prove correct, and proving it removes an entire suspect from every future debugging session.
Where the reference model earns its keep twice. The C model written here is the seed of an instruction-set simulator. Extend it with a register file array, a program counter and a memory, and you have a golden model you can co-simulate against the RTL cycle by cycle — run both on the same program, compare x1–x31 after every instruction, and stop at the first divergence. That technique turns “my core is wrong somewhere in a 10,000-instruction program” into “instruction 4,127 wrote the wrong value to x14”, which is the difference between an afternoon and five minutes. Building the ALU’s reference model now is the first step toward it.
The instruction you will debug most. Not a shift, not a compare — addi. It is the most frequent instruction in compiled RV32 code (it is mv, it is li, it is nop, it is every stack adjustment and every structure-field offset), it exercises the immediate path, the operand mux, the shared adder and the instr[30] decode rule all at once, and its failure mode from the naive decoder is a sign flip that looks like a memory corruption. If you add exactly one assertion to your core, make it a check that addi rd, x0, imm writes imm.
See Also
The rest of Stage 2 — build these alongside this module:
- The Register File — supplies both ALU operands and receives the result; the other half of execute
- Instruction Decode — where
sel, the operand muxes and the immediate come from - RISC-V Instruction Formats — R, I, S, B, U, J, and why
funct3lands where it does - Datapath and Control — the split this module sits inside
- The Single-Cycle Processor — the reference model these blocks assemble into
Where the ALU’s structure comes due:
- Classic Five-Stage Pipeline — the ALU is the EX stage
- Operand Forwarding — feeding the ALU’s own output back into its inputs
- Pipeline Hazards · Load-Use Hazard — what the EX stage has to wait for
- Branch Prediction · Two-Bit Saturating Counter — hiding the cost of the comparator-to-PC path
- Timing Closure and Fmax — where the “is the ALU really the critical path?” question is answered with a report
- Cycles Per Instruction — the number a barrel shifter versus an iterative shifter actually moves
ISA and toolchain:
- RV32IMC — the exact target; M adds the multi-cycle unit, C adds decoder complexity
- RISC-V Instruction Set Architecture — the base and its design choices
- Instruction Set Architecture — why condition codes versus compare-and-branch is an ISA-level decision
- The RISC-V Cross-Compilation Toolchain — how the encodings in this note were produced
- The riscv-tests Suite —
rv32uifor the ten operations,rv32umfor the M unit
Tooling:
- Verilator — the C++ harness that ran 202,260 vectors
- Testbenches and RTL Verification · Waveform Debugging · Register-Transfer Level
- Field-Programmable Gate Array · Tang Nano 20K — carry chains, LUT4s, and 48 hardened DSP multipliers
Maps of Content:
- definitely-not-esp32 MOC — the build ladder; this note is Stage 2
- Computer Architecture MOC — the concept view