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 0x02a00513 and it must assert reg_write, select the immediate as ALU operand B, choose ADD, route the ALU result to the register file, and name x10 as 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, keeps funct3 and funct7 at 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 from opcode/funct3/funct7 to 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] is funct7”, this note says “funct7[5] picks SUB over ADD.”

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 GNU as from the riscv64-linux-gnu binutils accompanying gcc 16.1.1. Disassembly cross-checks used llvm-objdump 22.1.8 and riscv64-linux-gnu-objdump. Specification quotations are from “The RISC-V Instruction Set Manual Volume I: Unprivileged Architecture”, revision 20250508 (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 commit 34b0705 (master, 2026-08-28) and PicoRV32 at commit a473fc8 (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:

  1. 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.
  2. The sub-selectors are also at fixed offsets. funct3 = inst[14:12] and funct7 = inst[31:25]. The tree is at most three levels deep and its depth does not depend on the instruction.
  3. The operand extraction does not depend on the decision. rs1, rs2, rd and 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 write rd; 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 -->|"&gt; 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:

signalwidthmeaning
reg_write1write-enable for the register file’s write port
alu_src_a_pc1ALU operand A: 0 = rs1 value, 1 = PC (for AUIPC, JAL, branch targets)
alu_src_b_imm1ALU operand B: 0 = rs2 value, 1 = the selected immediate
mem_read1load: request a data-memory read
mem_write1store: request a data-memory write
branch1this is a conditional branch; next-PC depends on the comparison result
jump1this is JAL or JALR; next-PC is taken unconditionally
wb_sel2write-back mux: 0 = ALU result, 1 = memory data, 2 = PC + 4
alu_op4ALU operation (ADD, SUB, SLL, SLT, SLTU, XOR, SRL, SRA, OR, AND, PASSB)
illegal1nothing matched; raise an illegal-instruction exception

And the table itself, by opcode:

opcodereg_writeAsrcA_pcBsrcImem_readmem_writebranchjumpwb_selalu_opimmediate
LUI 01101111010000ALUPASSBU
AUIPC 00101111110000ALUADDU
JAL 11011111110001PC+4ADDJ
JALR 11001111010001PC+4ADDI
BRANCH 11000110000010comparisonB
LOAD 00000111011000MEMADDI
STORE 01000110010100ADDS
OP-IMM 00100111010000ALUfrom funct3 (+inst[30] for shifts)I
OP 01100111000000ALUfrom funct3 + funct7[5]
MISC-MEM 00011110000000
SYSTEM 11100110000000– (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
  end

Line-by-line, the things that matter:

  • The three wire declarations at the top are the whole “field extraction” story for control. funct7 is declared even though only two opcodes read it; synthesis will not instantiate anything for an unused net.
  • assign rs1/rs2/rd sit outside the always block deliberately. They must not depend on the decode result — see The Register File.
  • The five immediates are computed unconditionally, exactly as Ibex does. imm inside the always block 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_JALR sets illegal and the normal control signals when funct3 ≠ 000. That is fine: the trap logic downstream suppresses the write. Cleaner designs clear reg_write too; both are correct as long as illegal genuinely gates commit.
  • OP_OP’s guard rejects every funct7 except 0000000 and 0100000 (and the latter only for ADD/SUB and SRL/SRA). This is what makes an RV32M MUL (funct7 = 0000001) trap on an RV32I-only core, which is the correct behaviour and a useful thing to observe deliberately.
  • The final if catches 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):

piececontributes towhy
5'b0imm[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'b00imm[1:0]the ×4 word scaling, as tied-low wires
2'b01, instr_i[9:7]rs1[4:0]the x8x15 window: constant 01 prefix
3'b010funct3LW
2'b01, instr_i[4:2]rd[4:0]same window trick for rd′
OPCODE_LOADinst[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
endmodule

Verified 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 from clang 22.1.8 -march=rv32imc -Os output for real C functions, plus 17 assembled with GNU as to cover the formats clang did not emit. Each 32-bit result was written into an object file with .word and disassembled independently by riscv64-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 of c.beqz (+16), c.bnez (+14), c.jal (+12) and c.j (+10). Sample rows:

compresseddisassembles asexpander outputdisassembles as
952ec.add a0,a100b50533add a0,a0,a1
8082c.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,a1
1141c.addi sp,-16ff010113addi sp,sp,-16
c606c.swsp ra,12(sp)00112623sw ra,12(sp)
850dc.srai a0,0x340355513srai a0,a0,0x3
c901c.beqz a0,+1600050863beq a0,zero,+16
9002c.ebreak00100073ebreak

Independently, the uncompressed (-march=rv32i) build of the same C source contains 00c52503, 00b52623, ff010113, 00112623, 00150513, 00c12083, 01010113 and 40355513 at 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];
end

This 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 decodercompletely unchangedmust grow a second set of cases
verificationone decoder to verify; expander checked separatelyevery instruction has two decode paths to test
mtval on illegaloriginal parcel must be routed alongsideoriginal parcel is already what you have
areaone extra 32-bit mux + the expanderno intermediate word
where it fitsany core with a real 32-bit decodermulti-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 (yosys is absent; only Verilator 5.046 and Icarus are available), so I could not measure it. Proxies I can state honestly: at Ibex commit 34b0705, ibex_decoder.sv is ~59 KB and ibex_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 Ibex syn/ Yosys flow with -noflatten and 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 → funct3funct7alu_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_alu is used to determine the ALU control logic and associated operand/imm select signals as the ALU is often on the more critical timing paths. instr is 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