Instruction Decode
Instruction decode is a pure function from 32 bits of instruction to a bundle of control wires, and in a single-cycle or classically pipelined RISC-V core it is entirely combinational — no state, no clock, no sequencing. Feed it
0x02a00513and it must assertreg_write, select the immediate as ALU operand B, chooseADD, route the ALU result to the register file, and namex10as the destination — all before the clock edge that commits the result. RISC-V makes this cheap on purpose: the encoding puts the opcode at a fixed offset, keepsfunct3andfunct7at fixed offsets, and never moves a register specifier, so the decoder is a shallow case statement over three slices rather than a search. This note owns the hardware: the decision tree fromopcode/funct3/funct7to control signals, a real control table for a working RV32I subset, the standard trick of expanding compressed instructions into their 32-bit equivalents before the main decoder ever sees them, illegal-instruction detection and the trap it raises, and the honest cost in area and critical path. Its sibling RISC-V Instruction Formats owns the encoding — which bit means what, and why the immediate bits are scrambled. Where that note says “inst[31:25]isfunct7”, this note says “funct7[5]picksSUBoverADD.”
What was measured, and with what
The decoder and compressed expander shown below were written for this note, linted with Verilator 5.046 (
--lint-only -Wall, clean), built into a C++ testbench, and run against real instruction words produced by clang 22.1.8 (--target=riscv32 -march=rv32imc -mabi=ilp32) and GNUasfrom the riscv64-linux-gnu binutils accompanying gcc 16.1.1. Disassembly cross-checks usedllvm-objdump 22.1.8andriscv64-linux-gnu-objdump. Specification quotations are from “The RISC-V Instruction Set Manual Volume I: Unprivileged Architecture”, revision20250508(ratified; published 2025-05-12) and, for trap behaviour, the corresponding Volume II: Privileged Architecture at the same tag. Production RTL quotations are from lowRISC Ibex at commit34b0705(master, 2026-08-28) and PicoRV32 at commita473fc8(main, 2026-07-31).
Mental Model — A Truth Table You Are Allowed to Write in Verilog
The decoder has no memory. It is a combinational block whose output at any instant is a function of its input at that instant, and the honest way to think about it is as a truth table with 2³² rows, almost all of which are “illegal instruction”. The design job is to compress that table into a nest of case statements small enough to fit in a few hundred gates while still getting every row right.
Three things make this tractable in RISC-V:
- The selector is at a fixed offset.
inst[6:0]is the opcode in every 32-bit instruction, so the outer case is a 7-bit switch with eleven live arms for RV32I. No prefix bytes, no length ambiguity, no mode bits. - The sub-selectors are also at fixed offsets.
funct3 = inst[14:12]andfunct7 = inst[31:25]. The tree is at most three levels deep and its depth does not depend on the instruction. - The operand extraction does not depend on the decision.
rs1,rs2,rdand all five immediates are constant slices of the instruction word (see RISC-V Instruction Formats). The decoder decides which immediate to use and whether to writerd; it never has to compute where they are.
Point 3 is the one that separates the decoder from the operand extractor, and it is worth keeping them separate in your RTL too. Operand extraction is wiring. Decode is logic. Confusing them produces a decoder that looks much more expensive than it is.
flowchart LR IW["instruction word<br/>32 bits"] subgraph WIRING["Operand extraction - zero gates"] RS1["rs1 = inst[19:15]"] RS2["rs2 = inst[24:20]"] RDF["rd = inst[11:7]"] IMMS["imm_i imm_s imm_b imm_u imm_j<br/>all five, built in parallel"] end subgraph LOGIC["Decode - the actual gates"] OPC["case inst[6:0]"] F3["case inst[14:12]"] F7["case inst[31:25]"] OPC --> F3 --> F7 end IW --> WIRING IW --> LOGIC LOGIC --> CTL["control bundle<br/>reg_write · alu_src · mem_read · mem_write<br/>branch · jump · wb_sel · alu_op · illegal"] CTL -->|"selects one of"| IMMS RS1 --> RF[("register file")] RS2 --> RF CTL -->|"write enable + address"| RF CTL --> ALU["ALU"] CTL --> LSU["load/store unit"] CTL --> PCU["next-PC logic"] CTL --> TRAP["trap unit<br/>mcause = 2"]
The decode stage split into its two halves. What it shows: the wiring half consumes the instruction word and produces operands with no logic at all; the logic half consumes the same word and produces a control bundle; the two meet at a mux. The insight to take: these two paths run in parallel, not in series. The register file’s address inputs and all five immediates are stable as soon as the instruction word is stable, so the decoder’s propagation delay is hidden behind the register-file read rather than added to it. A design that computes register addresses inside the decode case throws that away and lengthens the critical path by the full depth of the decode tree.
The Decoder Is Pure Combinational Logic
In Verilog this is an always @* block (or always_comb in SystemVerilog) with every output assigned a default at the top. The defaults are not stylistic: an output that is not assigned on some path through a combinational always block infers a latch, and a latch in the decode path is a synthesis bug that will show up as a timing violation or a functional failure on hardware but often simulates fine.
Both production cores cited here follow the same shape. Ibex’s ibex_decoder.sv opens its always_comb with roughly thirty default assignments before unique case (opcode). Its module header comment states the property plainly: “This module is fully combinatorial, clock and reset are used for assertions only.”
The decision tree for RV32I looks like this. Note that only three opcodes need to look at funct7 at all, and one of those (OP-IMM) needs it only for the shift instructions:
flowchart TB START["inst[31:0]"] LEN{"inst[1:0]"} START --> LEN LEN -->|"00 / 01 / 10"| CMP["compressed parcel<br/>go to the expander"] LEN -->|"11"| OPC{"inst[6:0]<br/>opcode"} OPC -->|"0110111 LUI"| LUI["reg_write · imm_u · ALU passes B"] OPC -->|"0010111 AUIPC"| AUI["reg_write · imm_u · operand A = PC"] OPC -->|"1101111 JAL"| JAL["reg_write · jump · imm_j<br/>writeback = PC+4"] OPC -->|"1100111 JALR"| JALR{"funct3<br/>== 000 ?"} JALR -->|"no"| ILL JALR -->|"yes"| JALRK["reg_write · jump · imm_i<br/>writeback = PC+4"] OPC -->|"1100011 BRANCH"| BR{"funct3"} BR -->|"010 or 011"| ILL BR -->|"000 001 100 101 110 111"| BRK["branch · imm_b<br/>no register write"] OPC -->|"0000011 LOAD"| LD{"funct3"} LD -->|"011 110 111"| ILL LD -->|"000 001 010 100 101"| LDK["reg_write · mem_read · imm_i<br/>writeback = memory"] OPC -->|"0100011 STORE"| ST{"funct3"} ST -->|"> 010"| ILL ST -->|"000 001 010"| STK["mem_write · imm_s<br/>no register write"] OPC -->|"0010011 OP-IMM"| OI{"funct3"} OI -->|"001 or 101"| SH{"inst[31:25]<br/>== 0000000 or 0100000 ?"} SH -->|"no"| ILL SH -->|"yes"| OIK OI -->|"000 010 011 100 110 111"| OIK["reg_write · imm_i<br/>alu_op from funct3"] OPC -->|"0110011 OP"| OP{"inst[31:25]"} OP -->|"0000000"| OPK["reg_write · operand B = rs2<br/>alu_op from funct3"] OP -->|"0100000 with funct3 000 or 101"| OPK OP -->|"0000001"| MEXT["RV32M - MUL family<br/>illegal in an RV32I-only decoder"] OP -->|"anything else"| ILL OPC -->|"0001111 MISC-MEM"| FEN["fence - architecturally a no-op<br/>on a single-hart in-order core"] OPC -->|"1110011 SYSTEM"| SYS{"funct3 == 000 and<br/>inst[31:20] in {0, 1} ?"} SYS -->|"no"| ILL SYS -->|"yes"| SYSK["ecall / ebreak<br/>raise a trap"] OPC -->|"anything else"| ILL["illegal = 1<br/>trap with mcause = 2"]
The complete RV32I decode decision tree. What it shows: eleven opcode arms, of which six need a funct3 check and two need a funct7 check; every unlisted combination falls to illegal. The insight to take: the tree is wide and shallow — never more than three levels — which is why decode is not the critical path in a simple core even though it touches every instruction. It also shows where the extensions plug in: the whole M extension is the single inst[31:25] == 0000001 arm under OP, and the whole Zicsr extension is a widening of the SYSTEM arm. Notice that illegal is reached from eleven distinct places; a decoder that only checks the opcode and forgets the funct3/funct7 guards will silently execute reserved encodings as though they were something else.
The Control Signal Table
Here is a control signal set sufficient for a working RV32I single-cycle core — the Stage 2 target of definitely-not-esp32 MOC. Each signal is named for what it does to a specific mux or enable in the datapath:
| signal | width | meaning |
|---|---|---|
reg_write | 1 | write-enable for the register file’s write port |
alu_src_a_pc | 1 | ALU operand A: 0 = rs1 value, 1 = PC (for AUIPC, JAL, branch targets) |
alu_src_b_imm | 1 | ALU operand B: 0 = rs2 value, 1 = the selected immediate |
mem_read | 1 | load: request a data-memory read |
mem_write | 1 | store: request a data-memory write |
branch | 1 | this is a conditional branch; next-PC depends on the comparison result |
jump | 1 | this is JAL or JALR; next-PC is taken unconditionally |
wb_sel | 2 | write-back mux: 0 = ALU result, 1 = memory data, 2 = PC + 4 |
alu_op | 4 | ALU operation (ADD, SUB, SLL, SLT, SLTU, XOR, SRL, SRA, OR, AND, PASSB) |
illegal | 1 | nothing matched; raise an illegal-instruction exception |
And the table itself, by opcode:
| opcode | reg_write | AsrcA_pc | BsrcI | mem_read | mem_write | branch | jump | wb_sel | alu_op | immediate |
|---|---|---|---|---|---|---|---|---|---|---|
LUI 0110111 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | ALU | PASSB | U |
AUIPC 0010111 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | ALU | ADD | U |
JAL 1101111 | 1 | 1 | 1 | 0 | 0 | 0 | 1 | PC+4 | ADD | J |
JALR 1100111 | 1 | 0 | 1 | 0 | 0 | 0 | 1 | PC+4 | ADD | I |
BRANCH 1100011 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | – | comparison | B |
LOAD 0000011 | 1 | 0 | 1 | 1 | 0 | 0 | 0 | MEM | ADD | I |
STORE 0100011 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | – | ADD | S |
OP-IMM 0010011 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | ALU | from funct3 (+inst[30] for shifts) | I |
OP 0110011 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | ALU | from funct3 + funct7[5] | – |
MISC-MEM 0001111 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | – | – | – |
SYSTEM 1110011 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | – | – | – (trap) |
The RV32I control table. What it shows: only two rows set reg_write to zero (branch and store — the two instruction classes with no destination), only two rows use the PC as an ALU input, and only three distinct write-back sources exist. The insight to take: the table is mostly zeros, which is the shape of a cheap decoder — most signals are asserted by one or two opcodes, so most control bits synthesise to a small OR of opcode decodes rather than to a wide mux. It also exposes the design’s one genuinely arbitrary choice: JAL/JALR use the ALU to compute PC + 4 for the link value while a separate adder computes the target, or vice versa. Either works; pick one and be consistent, because getting it half-right is the classic reason jal writes the wrong return address.
Two rows deserve a note. LUI needs an ALU operation that ignores operand A and passes operand B through — hence PASSB. Many designs instead force operand A to zero and use ADD, which is equivalent and one ALU opcode cheaper; I kept PASSB explicit so the table reads unambiguously. And BRANCH uses the ALU for a comparison rather than a result: funct3 selects BEQ/BNE/BLT/BGE/BLTU/BGEU, the ALU (or a dedicated comparator) produces a taken/not-taken bit, and the branch-target adder runs in parallel on PC + imm_b. Ibex’s documentation confirms this is how real cores do it: its ALU “computes branch targets with a PC + Imm calculation” and “implements… the comparison operations required for the Control Transfer Instructions.”
The decoder, as actual Verilog
This is the body of the module I linted and simulated. Reading it alongside the table above is the fastest route from “I understand the encoding” to “I can write this in Verilog.”
wire [6:0] opcode = instr[6:0];
wire [2:0] funct3 = instr[14:12];
wire [6:0] funct7 = instr[31:25];
// register specifiers: fixed slices in EVERY format — no decode needed
assign rs1 = instr[19:15];
assign rs2 = instr[24:20];
assign rd = instr[11:7];
// all five immediates, built unconditionally as pure wiring
wire [31:0] imm_i = {{20{instr[31]}}, instr[31:20]};
wire [31:0] imm_s = {{20{instr[31]}}, instr[31:25], instr[11:7]};
wire [31:0] imm_b = {{19{instr[31]}}, instr[31], instr[7], instr[30:25], instr[11:8], 1'b0};
wire [31:0] imm_u = {instr[31:12], 12'b0};
wire [31:0] imm_j = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
always @* begin
// DEFAULTS FIRST — every output, every time, or you infer latches
reg_write = 1'b0; alu_src_a_pc = 1'b0; alu_src_b_imm = 1'b0;
mem_read = 1'b0; mem_write = 1'b0; branch = 1'b0;
jump = 1'b0; wb_sel = 2'd0; alu_op = ALU_ADD;
imm = imm_i; illegal = 1'b0;
case (opcode)
OP_LUI: begin reg_write=1; alu_src_b_imm=1; imm=imm_u; alu_op=ALU_PASSB; end
OP_AUIPC: begin reg_write=1; alu_src_a_pc=1; alu_src_b_imm=1; imm=imm_u; end
OP_JAL: begin reg_write=1; jump=1; wb_sel=2'd2;
alu_src_a_pc=1; alu_src_b_imm=1; imm=imm_j; end
OP_JALR: begin if (funct3 != 3'b000) illegal = 1'b1;
reg_write=1; jump=1; wb_sel=2'd2; alu_src_b_imm=1; end
OP_BRANCH:begin branch=1; imm=imm_b;
if (funct3==3'b010 || funct3==3'b011) illegal = 1'b1; end
OP_LOAD: begin reg_write=1; mem_read=1; wb_sel=2'd1; alu_src_b_imm=1;
if (funct3==3'b011 || funct3==3'b110 || funct3==3'b111)
illegal = 1'b1; end
OP_STORE: begin mem_write=1; alu_src_b_imm=1; imm=imm_s;
if (funct3 > 3'b010) illegal = 1'b1; end
OP_OPIMM: begin reg_write=1; alu_src_b_imm=1; imm=imm_i;
case (funct3)
3'b000: alu_op = ALU_ADD; 3'b010: alu_op = ALU_SLT;
3'b011: alu_op = ALU_SLTU; 3'b100: alu_op = ALU_XOR;
3'b110: alu_op = ALU_OR; 3'b111: alu_op = ALU_AND;
3'b001: begin alu_op = ALU_SLL;
if (funct7 != 7'b0000000) illegal = 1'b1; end
3'b101: begin alu_op = funct7[5] ? ALU_SRA : ALU_SRL;
if (funct7 != 7'b0000000 && funct7 != 7'b0100000)
illegal = 1'b1; end
endcase end
OP_OP: begin reg_write=1;
if (funct7 == 7'b0000000 ||
(funct7 == 7'b0100000 && (funct3==3'b000 || funct3==3'b101)))
case (funct3)
3'b000: alu_op = funct7[5] ? ALU_SUB : ALU_ADD;
3'b001: alu_op = ALU_SLL; 3'b010: alu_op = ALU_SLT;
3'b011: alu_op = ALU_SLTU; 3'b100: alu_op = ALU_XOR;
3'b101: alu_op = funct7[5] ? ALU_SRA : ALU_SRL;
3'b110: alu_op = ALU_OR; 3'b111: alu_op = ALU_AND;
endcase
else begin illegal = 1'b1; reg_write = 1'b0; end end
OP_MISCM: begin /* fence: no-op on a single-hart in-order core */ end
OP_SYSTEM:begin if (!(funct3 == 3'b000 &&
(instr[31:20]==12'h000 || instr[31:20]==12'h001)))
illegal = 1'b1; end
default: illegal = 1'b1;
endcase
if (instr[1:0] != 2'b11) illegal = 1'b1; // not a 32-bit instruction at all
endLine-by-line, the things that matter:
- The three
wiredeclarations at the top are the whole “field extraction” story for control.funct7is declared even though only two opcodes read it; synthesis will not instantiate anything for an unused net. assign rs1/rs2/rdsit outside thealwaysblock deliberately. They must not depend on the decode result — see The Register File.- The five immediates are computed unconditionally, exactly as Ibex does.
imminside thealwaysblock is a selection, not a computation. - The default block assigns all eleven outputs. Removing any one line makes that output latched on the paths that do not write it.
OP_JALRsetsillegaland the normal control signals whenfunct3 ≠ 000. That is fine: the trap logic downstream suppresses the write. Cleaner designs clearreg_writetoo; both are correct as long asillegalgenuinely gates commit.OP_OP’s guard rejects everyfunct7except0000000and0100000(and the latter only forADD/SUBandSRL/SRA). This is what makes an RV32MMUL(funct7 = 0000001) trap on an RV32I-only core, which is the correct behaviour and a useful thing to observe deliberately.- The final
ifcatches the case where a 16-bit parcel reached the 32-bit decoder unexpanded. In a core without the C extension this is the mandated illegal-instruction path; in a core with it, this line should never fire, and an assertion here is a cheap way to catch a broken expander.
What it actually does, on real instructions
Feeding the built simulation the exact hex words produced by clang and GNU as earlier gives this (columns abbreviated; AsrcA is alu_src_a_pc, BsrcI is alu_src_b_imm):
instr rd rs1 rs2 imm RegW AsrcA BsrcI MemR MemW Br Jmp WBsel ALUop Illegal
00a58533 x10 x11 x10 10 1 0 0 0 0 0 0 0 0 0 # add a0,a1,a0
02a00513 x10 x0 x10 42 1 0 1 0 0 0 0 0 0 0 # addi a0,x0,42
00c52503 x10 x10 x12 12 1 0 1 1 0 0 0 1 0 0 # lw a0,12(a0)
00b52623 x12 x10 x11 12 0 0 1 0 1 0 0 0 0 0 # sw a1,12(a0)
00b54463 x8 x10 x11 8 0 0 0 0 0 1 0 0 0 0 # blt a0,a1,+8
feb51de3 x27 x10 x11 -6 0 0 0 0 0 1 0 0 0 0 # bne a0,a1,-6
12345537 x10 x8 x3 305418240 1 0 1 0 0 0 0 0 10 0 # lui a0,0x12345
00000097 x1 x0 x0 0 1 1 1 0 0 0 0 0 0 0 # auipc ra,0
00c000ef x1 x0 x12 12 1 1 1 0 0 0 1 2 0 0 # jal ra,+12
000080e7 x1 x1 x0 0 1 0 1 0 0 0 1 2 0 0 # jalr ra,0(ra)
40355513 x10 x10 x3 1027 1 0 1 0 0 0 0 0 7 0 # srai a0,a0,3
02b50533 x10 x10 x11 43 0 0 0 0 0 0 0 0 0 1 # mul -> ILLEGAL (RV32I only)
00100073 x0 x0 x1 1 0 0 0 0 0 0 0 0 0 0 # ebreak
00000073 x0 x0 x0 0 0 0 0 0 0 0 0 0 0 0 # ecall
00000000 x0 x0 x0 0 0 0 0 0 0 0 0 0 0 1 # all zeros -> ILLEGAL
ffffffff x31 x31 x31 -1 0 0 0 0 0 0 0 0 0 1 # all ones -> ILLEGAL
0000106f x0 x0 x0 4096 1 1 1 0 0 0 1 2 0 0 # jal x0,+4096
02a0051b x10 x0 x10 42 0 0 0 0 0 0 0 0 0 1 # addiw -> ILLEGAL (RV64)A real decode trace. What it shows: correct control for every RV32I class, illegal correctly asserted for an RV32M MUL on an I-only decoder, for the two architecturally-illegal all-zero and all-ones words, and for the RV64-only ADDIW. The insight to take: look at the rd/rs1/rs2 columns for feb51de3 (a branch) — the decoder reports rd = x27, which is not a register the instruction uses at all; those bits are immediate. The register fields are extracted unconditionally, and the control signals are what make the garbage harmless. Any hazard-detection or forwarding logic that compares register numbers without also checking reg_write / register-read-enable will manufacture dependencies out of immediate bits. Note also srai’s imm = 1027 (0x403): the decoder emits the full I-immediate and the ALU takes only imm[4:0] as the shift amount.
Decoding Compressed Instructions — Expand First, Then Decode
The C extension exists to shrink code, and the ISA was designed so that supporting it costs almost nothing in the decoder. Per §16.1 of the ratified manual, RVC “was designed under the constraint that each RVC instruction expands into a single 32-bit instruction,” and the first benefit the spec claims for that constraint is exactly the implementation trick: “Hardware designs can simply expand RVC instructions during decode, simplifying verification and minimizing modifications to existing microarchitectures.”
So the standard structure is a two-stage front end: a small combinational expander that turns a 16-bit parcel into its 32-bit equivalent, followed by the unmodified 32-bit decoder. lowRISC’s Ibex documents this placement precisely: “Compressed instructions are expanded by the IF stage so the decoder can always deal with uncompressed instructions (the ID stage still receives the compressed instruction for placing into mtval on an illegal instruction exception).”
flowchart LR FETCH["fetch<br/>16-bit aligned"] SEL{"parcel[1:0]<br/>== 11 ?"} EXP["RVC expander<br/>pure rewiring"] MUX{"mux"} DEC["32-bit decoder<br/>unchanged by C"] CTL["control signals"] TVAL["mtval on trap<br/>the ORIGINAL 16 bits"] ILLC["illegal_c"] FETCH --> SEL SEL -->|"no - 16-bit"| EXP SEL -->|"yes - 32-bit"| MUX EXP --> MUX EXP --> ILLC MUX --> DEC DEC --> CTL ILLC -->|"OR"| CTL FETCH -.->|"carried alongside,<br/>never expanded"| TVAL
The expand-then-decode front end. What it shows: the expander sits entirely ahead of the main decoder, contributes its own illegal_c signal, and the original parcel is carried forward separately for mtval. The insight to take: the main decoder is genuinely unmodified — this is what “adding C” means structurally. The two things the expander cannot delegate are its own illegality (reserved compressed encodings that have no 32-bit equivalent) and the trap-value path, because expanding 0x952e into 0x00b50533 and then reporting 0x00b50533 in mtval would lie to the trap handler about what the program actually contained.
The expander is rewiring, not arithmetic
This is the point that surprises people the first time they read production RTL. Here is Ibex’s complete c.lw expansion, one line, from ibex_compressed_decoder.sv:
// c.lw -> lw rd', imm(rs1')
instr_o = {5'b0, instr_i[5], instr_i[12:10], instr_i[6],
2'b00, 2'b01, instr_i[9:7], 3'b010, 2'b01, instr_i[4:2], {OPCODE_LOAD}};Read it as a picture of the 32-bit word being assembled from left (bit 31) to right (bit 0):
| piece | contributes to | why |
|---|---|---|
5'b0 | imm[11:7] | the C.LW offset is 7 bits, zero-extended |
instr_i[5] | imm[6] | CL format puts uimm[6] at c[5] |
instr_i[12:10] | imm[5:3] | CL format puts uimm[5:3] at c[12:10] |
instr_i[6] | imm[2] | CL format puts uimm[2] at c[6] |
2'b00 | imm[1:0] | the ×4 word scaling, as tied-low wires |
2'b01, instr_i[9:7] | rs1[4:0] | the x8–x15 window: constant 01 prefix |
3'b010 | funct3 | LW |
2'b01, instr_i[4:2] | rd[4:0] | same window trick for rd′ |
OPCODE_LOAD | inst[6:0] | constant 0000011 |
There is not a single gate in that line — it is a permutation of c plus eleven constant bits. That is true of essentially every expansion in the file. The compressed decoder’s logic is entirely in the case-selection tree (c[1:0], then c[15:13], then sometimes c[12] or c[11:10]); the datapath is wires.
Two more expansions worth having in your head, because they are the ones that differ from naïve expectation:
// c.mv -> add rd/rs1, x0, rs2 (NOT addi rd, rs2, 0)
instr_o = {7'b0, instr_i[6:2], 5'b0, 3'b0, instr_i[11:7], {OPCODE_OP}};
// c.add -> add rd, rd, rs2
instr_o = {7'b0, instr_i[6:2], instr_i[11:7], 3'b0, instr_i[11:7], {OPCODE_OP}};The two differ only in whether instr_i[11:7] or 5'b0 lands in the rs1 position — and the selector between them is the single bit c[12], exactly as RISC-V Instruction Formats describes for 0x852e versus 0x952e.
A complete, verified expander
The expander below is the one I built and tested. It covers the RV32IC integer subset — every compressed instruction a -march=rv32imc compiler will emit for integer code. The structure is the three-quadrant case the RVC opcode map implies:
module rvc_expand (input wire [15:0] c, output reg [31:0] instr, output reg illegal);
wire [2:0] rdp = c[4:2]; // rd' / rs2' (x8 + rdp)
wire [2:0] rs1p = c[9:7]; // rs1' / rd' (x8 + rs1p)
wire [4:0] rdw = c[11:7]; // full-width rd / rs1
wire [4:0] rs2w = c[6:2]; // full-width rs2
always @* begin
instr = 32'h0; illegal = 1'b0;
case (c[1:0])
2'b00: case (c[15:13]) // ---- quadrant 0
3'b000: begin // c.addi4spn -> addi rd',x2,imm
instr = {2'b0, c[10:7], c[12:11], c[5], c[6], 2'b00,
5'd2, 3'b000, 2'b01, rdp, OP_OPIMM};
if (c[12:5] == 8'b0) illegal = 1'b1; // uimm=0 is reserved
end
3'b010: instr = {5'b0, c[5], c[12:10], c[6], 2'b00, // c.lw
2'b01, rs1p, 3'b010, 2'b01, rdp, OP_LOAD};
3'b110: instr = {5'b0, c[5], c[12], 2'b01, rdp, 2'b01, // c.sw
rs1p, 3'b010, c[11:10], c[6], 2'b00, OP_STORE};
default: illegal = 1'b1;
endcase
2'b01: case (c[15:13]) // ---- quadrant 1
3'b000: instr = {{6{c[12]}}, c[12], c[6:2], rdw, // c.nop / c.addi
3'b000, rdw, OP_OPIMM};
3'b001: instr = {c[12], c[8], c[10:9], c[6], c[7], // c.jal (RV32 only)
c[2], c[11], c[5:3], c[12], {8{c[12]}}, 5'd1, OP_JAL};
3'b010: instr = {{6{c[12]}}, c[12], c[6:2], 5'd0, // c.li
3'b000, rdw, OP_OPIMM};
3'b011: begin
if (rdw == 5'd2) // c.addi16sp
instr = {{3{c[12]}}, c[4:3], c[5], c[2], c[6], 4'b0,
5'd2, 3'b000, 5'd2, OP_OPIMM};
else // c.lui
instr = {{15{c[12]}}, c[6:2], rdw, OP_LUI};
if ({c[12], c[6:2]} == 6'b0) illegal = 1'b1; // imm=0 is reserved
end
3'b100: case (c[11:10]) // MISC-ALU
2'b00, 2'b01: begin // c.srli / c.srai
instr = {1'b0, c[10], 5'b0, c[6:2], 2'b01, rs1p,
3'b101, 2'b01, rs1p, OP_OPIMM};
if (c[12]) illegal = 1'b1; // RV32: shamt[5] must be 0
end
2'b10: instr = {{6{c[12]}}, c[12], c[6:2], 2'b01, // c.andi
rs1p, 3'b111, 2'b01, rs1p, OP_OPIMM};
2'b11: begin // c.sub/xor/or/and
if (c[12]) illegal = 1'b1; // RV64-only ADDW/SUBW
else case (c[6:5])
2'b00: instr = {7'b0100000, 2'b01, rdp, 2'b01, rs1p, 3'b000, 2'b01, rs1p, OP_OP};
2'b01: instr = {7'b0000000, 2'b01, rdp, 2'b01, rs1p, 3'b100, 2'b01, rs1p, OP_OP};
2'b10: instr = {7'b0000000, 2'b01, rdp, 2'b01, rs1p, 3'b110, 2'b01, rs1p, OP_OP};
2'b11: instr = {7'b0000000, 2'b01, rdp, 2'b01, rs1p, 3'b111, 2'b01, rs1p, OP_OP};
endcase
end
endcase
3'b101: instr = {c[12], c[8], c[10:9], c[6], c[7], // c.j
c[2], c[11], c[5:3], c[12], {8{c[12]}}, 5'd0, OP_JAL};
3'b110, 3'b111: instr = {{4{c[12]}}, c[6:5], c[2], // c.beqz / c.bnez
5'd0, 2'b01, rs1p, {2'b00, c[13]}, c[11:10], c[4:3], c[12], OP_BR};
default: illegal = 1'b1;
endcase
2'b10: case (c[15:13]) // ---- quadrant 2
3'b000: begin instr = {7'b0, c[6:2], rdw, 3'b001, rdw, OP_OPIMM}; // c.slli
if (c[12]) illegal = 1'b1; end
3'b010: begin instr = {4'b0, c[3:2], c[12], c[6:4], 2'b00, // c.lwsp
5'd2, 3'b010, rdw, OP_LOAD};
if (rdw == 5'd0) illegal = 1'b1; end // rd=x0 reserved
3'b100: begin
if (!c[12]) begin
if (rs2w != 5'd0) instr = {7'b0, rs2w, 5'd0, 3'b000, rdw, OP_OP}; // c.mv
else begin instr = {12'b0, rdw, 3'b000, 5'd0, OP_JALR}; // c.jr
if (rdw == 5'd0) illegal = 1'b1; end
end else begin
if (rs2w != 5'd0) instr = {7'b0, rs2w, rdw, 3'b000, rdw, OP_OP}; // c.add
else if (rdw == 5'd0) instr = 32'h0010_0073; // c.ebreak
else instr = {12'b0, rdw, 3'b000, 5'd1, OP_JALR}; // c.jalr
end
end
3'b110: instr = {4'b0, c[8:7], c[12], rs2w, 5'd2, // c.swsp
3'b010, c[11:9], 2'b00, OP_STORE};
default: illegal = 1'b1;
endcase
2'b11: illegal = 1'b1; // not a compressed parcel
endcase
if (c == 16'h0000) illegal = 1'b1; // the architecturally-defined illegal instruction
end
endmoduleVerified against real toolchain output, 2026-09-04
Built with
verilator --cc --exe --build -Wall(Verilator 5.046, clean at-Wall) and driven by a C++ testbench over 44 distinct compressed words: 27 lifted verbatim fromclang 22.1.8 -march=rv32imc -Osoutput for real C functions, plus 17 assembled with GNUasto cover the formats clang did not emit. Each 32-bit result was written into an object file with.wordand disassembled independently byriscv64-linux-gnu-objdump -D -b binary -m riscv:rv32 -M no-aliases, then compared against the disassembly of the original compressed parcels. All 44 matched, including the PC-relative offsets ofc.beqz(+16),c.bnez(+14),c.jal(+12) andc.j(+10). Sample rows:
compressed disassembles as expander output disassembles as 952ec.add a0,a100b50533add a0,a0,a18082c.jr ra00008067jalr zero,0(ra)4548c.lw a0,12(a0)00c52503lw a0,12(a0)c54cc.sw a1,12(a0)00b52623sw a1,12(a0)852ec.mv a0,a100b00533add a0,zero,a11141c.addi sp,-16ff010113addi sp,sp,-16c606c.swsp ra,12(sp)00112623sw ra,12(sp)850dc.srai a0,0x340355513srai a0,a0,0x3c901c.beqz a0,+1600050863beq a0,zero,+169002c.ebreak00100073ebreakIndependently, the uncompressed (
-march=rv32i) build of the same C source contains00c52503,00b52623,ff010113,00112623,00150513,00c12083,01010113and40355513at the corresponding positions — so the expander’s output is byte-identical to what the compiler emits when compression is switched off.
The other design: decode compressed directly
Expanding to 32 bits is the standard trick, not the only one. PicoRV32 decodes compressed instructions straight into its internal decoded fields, skipping the 32-bit intermediate entirely:
3'b010: begin // C.LW
is_lb_lh_lw_lbu_lhu <= 1;
decoded_rs1 <= 8 + mem_rdata_latched[9:7];
decoded_rd <= 8 + mem_rdata_latched[4:2];
endThis is legitimate and slightly cheaper — it avoids building a 32-bit word only to take it apart again. It works for PicoRV32 because that core is a multi-cycle state machine whose “decoded instruction” is already a set of registered one-hot flags rather than an instruction word, so there is no 32-bit decoder to feed.
| expand to 32 bits (Ibex, most cores) | decode compressed directly (PicoRV32) | |
|---|---|---|
| main decoder | completely unchanged | must grow a second set of cases |
| verification | one decoder to verify; expander checked separately | every instruction has two decode paths to test |
mtval on illegal | original parcel must be routed alongside | original parcel is already what you have |
| area | one extra 32-bit mux + the expander | no intermediate word |
| where it fits | any core with a real 32-bit decoder | multi-cycle cores with registered one-hot decode |
The two ways to support C. What it shows: the expand-first approach trades a little area for a large reduction in verification surface. The insight to take: for a from-scratch core, the verification argument dominates. You will run The riscv-tests Suite rv32ui-p-* against the uncompressed decoder first and get it passing; expanding-then-decoding means those tests still cover the entire decode path once you add C, and the only new surface is the expander, which can be tested exhaustively against a disassembler in an afternoon. Decoding C directly doubles the paths the rv32uc tests need to cover.
Illegal Instruction Detection and Its Trap
An instruction is illegal if nothing in the decoder matches it. The privileged specification assigns this exception code 2 in mcause — the causes in order are 0 instruction address misaligned, 1 instruction access fault, 2 illegal instruction, 3 breakpoint, and so on. The hardware sequence is the standard trap entry documented in RISC-V Trap Handling: mepc ← the address of the offending instruction, mcause ← 2, mstatus.MPP/MPIE capture the previous mode and interrupt-enable, and the PC jumps to mtvec.
Where the decoder is concerned, four things must be right.
1. The default arm must trap, not fall through. default: illegal = 1'b1; on the opcode case, and a default on each nested funct3/funct7 case that does not enumerate all values. The manual is explicit that the architectural behaviour of a reserved encoding is unspecified — “The behavior upon decoding a reserved instruction is UNSPECIFIED,” with a note that “Some platforms may require that opcodes reserved for standard use raise an illegal-instruction exception. Other platforms may permit reserved opcode space be used for non-conforming extensions.” For a core that intends to run standard software and pass the compliance tests, trapping is the right choice, and it is the choice that catches your own bugs.
2. The two architecturally-mandated illegal encodings must be detected. All-zeros (inst[15:0] == 0) and all-ones (inst[ILEN-1:0] all set). These are not merely unmatched — they are defined illegal, precisely so that a jump into erased flash or a disconnected bus traps immediately rather than executing garbage. My decoder catches all-zeros via the inst[1:0] != 11 check and all-ones via the opcode default (1111111 is not a valid opcode); the expander catches c == 16'h0000 explicitly.
3. The compressed expander’s illegal must be OR-ed in. Several compressed encodings are reserved within a valid quadrant/funct3 combination: c.addi4spn with uimm = 0, c.lui with imm = 0, c.lwsp with rd = x0, c.jr with rs1 = x0, and (on RV32) any c.slli/c.srli/c.srai with shamt[5] set. Those cannot be detected downstream, because the expander will have produced a syntactically valid 32-bit instruction. Ibex handles this with a dedicated illegal_c_insn_i input to the main decoder.
4. mtval must receive the original instruction bits. The privileged spec: mtval “can optionally also be used to return the faulting instruction bits on an illegal-instruction exception,” and when it does, it contains “the shortest of: the actual faulting instruction / the first ILEN bits of the faulting instruction / the first MXLEN bits of the faulting instruction,” right-justified with upper bits zeroed. For a faulting compressed instruction that is the 16-bit parcel, not the expansion. This is why Ibex’s ID stage “still receives the compressed instruction for placing into mtval.” Getting this wrong produces a trap handler that sees a plausible 32-bit instruction that was never in memory — a debugging nightmare, and it breaks any software that emulates unimplemented instructions by reading mtval.
stateDiagram-v2 [*] --> Fetching Fetching --> Decoding : instruction word valid Decoding --> Executing : illegal = 0 Decoding --> TrapEntry : illegal = 1 TrapEntry --> Handler : mepc = faulting PC<br/>mcause = 2<br/>mtval = original bits<br/>mstatus.MPIE = MIE, MIE = 0<br/>PC = mtvec Handler --> Emulate : handler decodes mtval Handler --> Fatal : unknown encoding Emulate --> Returning : advance mepc past the instruction Returning --> Fetching : mret Fatal --> [*]
The illegal-instruction path from decode to mret. What it shows: illegal is the single decoder output that diverts the whole machine, and the trap unit’s job is to snapshot four pieces of state before redirecting the PC. The insight to take: the Emulate arm is why mtval fidelity matters — a handler that emulates, say, an unimplemented MUL reads the faulting bits out of mtval, decodes them in software, performs the operation, and advances mepc by the instruction’s length. With C present, “the instruction’s length” is itself something the handler must determine from mtval[1:0], which only works if mtval holds the original parcel. This is the concrete reason the un-expanded word is routed forward. See Control and Status Registers.
The Honest Cost — Area and Critical Path
Area. The decoder is small, but “small” needs a denominator. lowRISC publishes whole-core synthesis figures for Ibex: a RV32IMC “small” configuration (3-cycle multiplier) is 26.60 kGE by their Yosys flow with a latch-based register file, against 16.85 kGE for a minimal RV32EC “micro” and 32.48 kGE for a maximum-performance RV32IMC. Those are gate-equivalent counts for the entire core — decoder, register file, ALU, multiplier, LSU, CSRs and control.
Uncertain
Verify: the fraction of a small RV32IMC core’s area that the decoder and the compressed expander actually occupy. Reason: lowRISC publishes only whole-core kGE figures; I could not find a per-module breakdown in the Ibex repository, and no synthesis tool is installed on this machine (
yosysis absent; only Verilator 5.046 and Icarus are available), so I could not measure it. Proxies I can state honestly: at Ibex commit34b0705,ibex_decoder.svis ~59 KB andibex_compressed_decoder.sv~41 KB of SystemVerilog, but both files are inflated by CHERIoT and Zcmp support that a plain RV32IMC core does not build. To resolve: run the Ibexsyn/Yosys flow with-noflattenand read per-module cell counts, or synthesise the two modules above standalone against a standard cell library and report gate counts with the library named. Until then, treat “the decoder is a few percent of the core” as folklore, not a measurement.#uncertain
What is measurable without a synthesiser is the shape of the logic, and the shape says the decoder should be cheap. The control table above is mostly zeros; each control bit reduces to an OR of a handful of opcode comparisons. The five immediates cost zero cells. The compressed expander’s datapath costs zero cells. The real cells are in the case-selection tree and the final mux that picks one 32-bit instruction word (expanded or not) and one immediate out of five.
Critical path. Decode is rarely the critical path in a single-cycle core — that honour usually goes to the memory or the ALU’s carry chain — but it contributes in two ways that are worth understanding before Timing Closure and Fmax bites.
The first is serial depth: opcode → funct3 → funct7 → alu_op → the ALU’s operation mux is a real chain, and in a single-cycle machine it is followed by the entire ALU and then the register-file write setup. This is precisely why the ALU control is often pre-decoded — computing alu_op from funct3 and inst[30] directly, in parallel with the opcode case, rather than after it.
The second is fan-out, and Ibex is unusually candid about it. Its decoder carries this comment:
To help timing the flops containing the current instruction are replicated to reduce fan-out.
instr_aluis used to determine the ALU control logic and associated operand/imm select signals as the ALU is often on the more critical timing paths.instris used for everything else.
That is a production core paying real flip-flop area to duplicate the instruction register, purely because one 32-bit net driving both the decoder and the immediate generator and the register-file addresses is too heavily loaded to meet timing. inst[31] alone drives 20 bits of sign extension in imm_i, 20 in imm_s, 19 in imm_b, 12 in imm_j, plus the funct7 comparison — roughly 72 loads from one flop output. On an FPGA the router will insert buffers and you will see it as a slow path in the timing report; on an ASIC the synthesiser will buffer or replicate.
The compressed expander adds a third consideration: it sits in front of the decoder, so its delay is additive on the fetch-to-decode path. Ibex mitigates this by putting the expander in the IF stage rather than ID, so the expansion happens in the cycle the instruction is fetched and the pipeline register between IF and ID holds an already-expanded 32-bit word. That is the placement to copy.
flowchart LR IR["instruction register<br/>32 flops"] subgraph FAN["fan-out from inst[31] alone"] FI["imm_i sign fill<br/>20 loads"] FS["imm_s sign fill<br/>20 loads"] FB["imm_b sign fill<br/>19 loads"] FJ["imm_j sign fill<br/>12 loads"] FF["funct7 compare<br/>1 load"] end IR --> FAN IR --> D1["opcode case<br/>inst[6:0]"] D1 --> D2["funct3 case<br/>inst[14:12]"] D2 --> D3["funct7 case<br/>inst[31:25]"] D3 --> AOP["alu_op"] AOP --> AMUX["ALU operation mux"] FAN --> IMUX["immediate mux<br/>5 to 1"] D1 --> IMUX IMUX --> AMUX AMUX --> ALU["ALU carry chain"] ALU --> WB["write-back mux"] WB --> RFW["register file<br/>write setup"] IR -.->|"Ibex duplicates the IR here<br/>purely to cut this load"| D1
Where decode delay actually comes from. What it shows: two contributions running in parallel — a serial chain of three case levels feeding the ALU operation mux, and a heavy capacitive load on inst[31] from four independent sign-extension fan-outs. The insight to take: the serial chain is short (three levels) and can be shortened further by deriving alu_op from funct3 and inst[30] directly rather than after the opcode case. The fan-out is the part you cannot design away, only buffer — which is exactly what Ibex’s duplicated instruction register is for. In a single-cycle core the whole picture, left to right, is one clock period, which is why single-cycle designs run slowly and why Stage 3 of the build ladder exists.
Failure Modes and Gotchas
Inferred latches from an incomplete always @*. The single most common decoder bug. Symptom: simulation passes, synthesis warns about a latch, hardware behaves as though a control signal is “sticky” from the previous instruction — typically manifesting as an instruction that mysteriously writes a register it should not, but only when preceded by a specific other instruction. Cure: assign every output a default at the top of the block, unconditionally, and treat any latch warning as an error.
Opcode-only decoding. Matching 0110011 and going straight to the funct3 mux without checking funct7 means MUL (funct7 = 0000001) silently executes as ADD. The instruction does not trap and the result is wrong. This class of bug survives your own test suite because your own tests only contain instructions you meant to write; it is exactly what The riscv-tests Suite’s rv32ui-p-* programs exist to catch.
Forgetting x0 is hardwired to zero. The decoder happily asserts reg_write with rd = x0 for, say, addi x0, x0, 0 (the canonical NOP). The register file must discard that write; the decoder should not special-case it. Conversely, a decoder that does suppress reg_write when rd == x0 is also fine and saves a little write-port power — but it must not also suppress the side effects of JAL/JALR, since jal x0, offset is the canonical unconditional jump and still has to jump.
Treating fence as illegal. MISC-MEM (0001111) is a legal opcode. On a single-hart in-order core with no caches, FENCE is architecturally a no-op, but it must not trap — compilers emit it, and fence.i (the Zifencei extension, funct3 = 001) appears in any code that writes instruction memory. Decoding it as illegal makes ordinary compiled code fault.
Getting the jal link value from the wrong source. JAL must write PC + 4 to rd while jumping to PC + imm_j. If your ALU computes the target and your write-back mux also selects the ALU, you have written the target into the link register and every function returns to itself. The wb_sel = PC+4 row in the control table is there because this is a real and very confusing bug. Note that with the C extension the link value is PC + 2 for a compressed c.jal/c.jalr — but if you expand first, the expanded jal looks like a 4-byte instruction to the decoder, so the next-PC logic must use the original instruction’s length, not the expanded one’s. This is the second reason (after mtval) that “is this instruction compressed?” has to travel down the pipeline alongside the expanded word.
Expanding and then forgetting the compressed word. Covered above for mtval, but it bites in a third place too: a debugger or trace unit that reports the executing instruction will report the expansion, which does not match the disassembly the user is looking at.
Assuming the expander is a no-op for 32-bit instructions. It must pass through inst[31:0] unchanged when inst[1:0] == 11. Ibex does this with a default assignment instr_o = instr_i; at the top of its always_comb. A design that only assigns instr_o inside the compressed cases will pass an undefined (or latched) word to the decoder for every uncompressed instruction.
Not testing the reserved compressed encodings. c.addi4spn with uimm = 0 is bit pattern 0x0000-adjacent and easy to mis-handle; c.lwsp with rd = x0 is a reserved code point that expands into a perfectly valid lw x0, off(x2) if you forget the guard. Neither will appear in compiler output, so only a deliberate test or rv32uc will find them.
Alternatives and When to Choose Them
Random logic (case statements) — what everything above assumes. Fastest, smallest, and the right default for a RISC-V core, precisely because the encoding was designed to make it cheap. Choose this unless you have a specific reason not to.
Microcode / a decode ROM. Store the control bundle in a ROM addressed by {opcode, funct3, funct7} and look it up. This was the classical approach for CISC ISAs with irregular encodings, and it is still how x86 handles complex instructions. For RV32I it is a bad trade: the address space is 7 + 3 + 7 = 17 bits, so a naïve ROM is 128 Ki entries wide enough to hold the whole control bundle — vastly larger than the few hundred gates the case statement synthesises to. A compressed decode ROM addressed by a pre-decoded index can work, but you have then just moved the random logic in front of the ROM. Microcode earns its keep when instructions decompose into multi-step sequences; RISC-V’s do not.
One-hot pre-decode. Instead of a case, compute an explicit boolean per instruction (instr_add, instr_lw, …) and OR them into the control signals. PicoRV32 does exactly this: instr_lui <= mem_rdata_latched[6:0] == 7'b0110111; and so on, then is_lui_auipc_jal <= |{instr_lui, instr_auipc, instr_jal};. The advantage is that the flags are registered, taking decode off the critical path of the following cycle; the cost is a cycle of latency and a wider set of flops. This is the natural fit for a multi-cycle core where a decode cycle is free anyway. PicoRV32’s published figures — 750–2000 LUTs and 250–450 MHz on Xilinx 7-series, with an average CPI of about 4 — show the trade: very high clock, very low IPC.
Decoded-instruction caches / µop caches. Cache the output of decode rather than re-running it. Only worth it when decode is genuinely expensive (x86) or when the same instructions are re-decoded very frequently in a deeply pipelined machine. For an in-order RV32 core, the decoder is cheaper than the cache would be.
Deferring illegal-instruction detection to a later stage. Some designs let the decoder produce garbage for unmatched encodings and detect illegality separately. This saves nothing (the comparison logic exists either way) and makes the trap’s timing harder to reason about. Detect in decode.
Production Notes
Put the expander in IF, not ID. Ibex’s documentation states the placement and the reason: expanding in the instruction-fetch stage means “the decoder can always deal with uncompressed instructions.” Structurally this means the IF/ID pipeline register carries a 32-bit expanded word plus two extra bits — is_compressed (for next-PC arithmetic) and illegal_c (for the trap) — plus the original 16 bits if you want faithful mtval. That is four extra flops’ worth of plumbing for a very large simplification.
Verify the expander against a disassembler, exhaustively if you can. The expander’s input space is only 2¹⁶ words, of which three quarters are compressed parcels. That is small enough to enumerate completely: expand every value of c[15:0] whose low bits are not 11, emit the results, and diff both sides against objdump. I checked 44 words by hand-selecting real compiler output plus assembler-generated coverage of the missing formats; a full sweep is an afternoon’s work and would be strictly better. This is the single highest-value test in the whole front end because the expander is pure combinational logic with no state — exhaustive testing is actually achievable, which is almost never true of a hardware block.
Use -mno-relax when generating test vectors. By default the RISC-V assembler leaves local branch and call offsets as relocations for the linker to relax, so an unlinked .o shows branch immediates of zero. llvm-objdump -dr will show you the relocations; -mno-relax makes the assembler resolve them itself and gives you real immediates to decode. Every branch example in this note and in RISC-V Instruction Formats was produced that way.
Adding M is one funct7 value. Once the RV32I decoder passes rv32ui-p-*, the entire M extension is the arm funct7 == 7'b0000001 under OP, with funct3 selecting among MUL, MULH, MULHSU, MULHU, DIV, DIVU, REM, REMU. The decoder change is a dozen lines; the hard part is the multiplier and divider themselves and the multi-cycle stalling they require. Confirming this ordering: the run above shows 02b50533 (mul a0,a0,a1, straight out of clang -march=rv32imc) trapping as illegal on the RV32I-only decoder — exactly the failure you want to see before you add M, and exactly the failure rv32um-p-* will report.
Assert what you believe. Two assertions cost nothing in synthesis and catch real bugs in simulation: that the 32-bit decoder never sees inst[1:0] != 11 once the expander is in place, and that illegal and reg_write are never both high at commit. Ibex’s compressed decoder is full of such assertions; its module comment notes that “clock and reset are used for assertions only,” which is a good description of what a combinational block’s clock is for.
See Also
- RISC-V Instruction Formats — the encoding side: what every bit means, and why the immediates are scrambled
- Datapath and Control — the split this note’s control bundle drives
- The Single-Cycle Processor — the Stage 2 core where this decoder first executes an instruction end to end
- The Register File — the read ports driven directly by
inst[19:15]andinst[24:20], before decode finishes - The Arithmetic Logic Unit — the consumer of
alu_op, and the usual owner of the real critical path - Classic Five-Stage Pipeline — where decode becomes a named stage and the IF/ID register appears
- Pipeline Hazards — why unconditional register-field extraction manufactures false dependencies
- Operand Forwarding — the mechanism that needs decode’s register-read-enable signals to be honest
- RISC-V Trap Handling — what happens after
illegalgoes high - Control and Status Registers —
mcause,mepc, and themtvalfidelity requirement - RV32IMC — the target profile: why C is required and why it comes last
- The riscv-tests Suite — the official programs that find the
funct7guard you forgot - Verilator — the simulator used to build and check everything above
- Testbenches and RTL Verification — how to drive a combinational module like this one
- Timing Closure and Fmax — where decoder fan-out and depth eventually show up
- Computer Architecture MOC — the concept hub
- definitely-not-esp32 MOC — the project this note is Stage 2 reading for