Skip to content

Decoding RV32I Instructions from Binary

Why is Decoding necessary?

When a program is compiled, its source code is translated into machine code, a sequence of raw bytes that a CPU can execute directly.

Consider the following raw bytes:

0xfea42a23

These bytes do not carry any labels, hints, or structure that a human reader or analysis tool can immediately reason about. To a decompiler, a stream of raw bytes is essentially meaningless until it knows how to interpret them. This is where instruction decoding comes in.

Decoding is the process of taking these raw bytes and mapping them back to structured instructions, identifying the operation being performed, the registers involved, and any immediate values encoded in the bits. It transforms an opaque blob of binary data into something a decompiler can actually analyze:

sw x10, -12(x8)

Without a correct, structured interpretation of each instruction, the later stages of decompilation would not have anything meaningful to work with and we would not be able to recover any useful information about the original program.

For our decompiler, we will be decoding RV32I binaries.

Why RV32I?

During our decompilation journey, we will be using RV32I, the base 32-bit integer instruction set for the RISC-VAn open, royalty-free instruction-set architecture with a small, modular base — which is what makes it a friendly decompiler target. More → architecture, as our target machine code. RV32I is the ideal ISA for several reasons:

  1. Fixed-width instructions: every instruction is exactly 32 bits, making decoding straightforward and predictable compared to variable-length ISAs like x86.
  2. Small instruction count: with only 40 instructions, the full instruction set is manageable to implement and reason about.
  3. Clean encoding: fields like opcode, rs1, rs2, and rd always appear at the same bit positions across formats, simplifying decoder logic.

While simpler than x86-64 or ARM64, RV32I still contains all the essential concepts needed to understand how to build a decompiler. This simplicity is precisely what makes it a great starting point for our decompilation journey.

Instruction Types

As mentioned previously, every RV32I instruction is a 32-bit binary word. The 32-bit layout of the word tells the hardware everything it needs to know: what operation to perform, which registers to use, and what immediate value to apply if any.

RV32I is organized into four core instruction formats:

  • R-type: Register operations (e.g. ADD, SUB, AND).
  • I-type: Immediate operations and loads (e.g. ADDI, LW).
  • S-type: Store operations (e.g. SW, SB).
  • U-type: Upper immediate operations (e.g. LUI, AUIPC).

Additionally, there are two variant formats derived from S and U types:

  • B-type: Branch operations (e.g. BEQ, BLT). Derived from S-type, B-type instructions encode a branch offset across non-contiguous bits.
  • J-type: Jump operations (e.g. JAL). Derived from U-type, J-type instructions encode a larger jump offset.

The bit layout for each instruction format is as follows: r-type i-type s-type u-type b-type j-type

Source: RISC-V ISA Reference1

The key fields you will encounter across these formats are:

  • opcode [6:0]: identifies the instruction format and general operation class
  • rd [11:7]: destination register
  • funct3 [14:12]: a 3-bit field that further distinguishes instructions within the same opcode group
  • rs1 [19:15]: first source register
  • rs2 [24:20]: second source register (R and S-type only)
  • funct7 [31:25]: a 7-bit field used to distinguish instructions like ADD vs SUB (R-type only)
  • imm: an immediate value whose bit positions vary by instruction type

We can see that some of the key fields occupy the same bits across the different instruction types.

For example, the opcode field always occupies bits [6:0] and the funct3 field always occupies bits [14:12] across all formats that use them. By reading the bits [6:0], we can quickly tell what instruction type we are dealing with and, depending on the instruction type, read the bits [14:12] to identify the specific instruction.

In R-type instructions, the funct7 field at bits [31:25] further helps us determine the specific instruction.

Decoding Instructions

To decode an instruction, we extract each field from the 32-bit binary word and use them together to determine what operation to perform.

Let's walk through a concrete example. Consider the following 32-bit instruction in hex:

0x00A58533

Converting to binary:

0000 0000 1010 0101 1000 0101 0011 0011

The first thing we do is read the opcode field at bits [6:0]:

0110011

Looking up the opcode 0110011 in the RV32I Instruction Table2, we can see that this opcode corresponds to instructions such as ADD, SUB, OR, and AND, all of which are R-type instructions.

Now that we know we are dealing with an R-type instruction, we can extract the remaining fields:

Field Bits Value
funct7 [31:25] 0000000
rs2 [24:20] 01010
rs1 [19:15] 01011
funct3 [14:12] 000
rd [11:7] 01010
opcode [6:0] 0110011

Although we know that this opcode value refers to R-type instructions, we need to use the funct3 and funct7 fields to determine the specific instruction. The opcode, funct3, and funct7 values together tell us that we are dealing with the ADD instruction.

Bit Precision in Decoding

The same opcode can map to very different instructions depending on funct3 and funct7. In this example, flipping one bit in funct7 from 0000000 to 0100000 would give us the instruction SUB instead of ADD, and changing funct3 from 000 to 100 would give us XOR. Getting every bit right is crucial to correctly decoding an instruction.

Putting it all together, this instruction decodes to:

ADD x10, x11, x10
which adds the values of register x10 and x11 and stores the sum back into register x10.

Content Review Quiz

Content Review Quiz

What is the size of every RV32I instruction?

Which of the following is NOT a core RV32I instruction format?

What field should you read first when decoding and why?

Which of the following fields does NOT occupy the same bit positions across all instruction formats?

Explore the Code

Let's take a closer look at the tiny-dec decoder code to understand how the raw bytes are being translated into RV32I instructions.

Code Structure

The core functionality of the decoder is located in decoder.py, which defines the data class RV32IInstruction representing the decoded instruction and the function decode_rv32i to decode a single 32-bit word into a specific RV32I instruction.

RV32IInstruction is the primary output of the decoding stage, capturing everything a decoded RV32I instruction needs to carry forward into later stages. Some of the key fields include:

  • mnemonic: the name of the instruction derived from the opcode (e.g. ADDI, SW)
  • format: the instruction format (e.g. I-type, S-type)
  • registers: the source and destination registers involved in the operation (rs1, rs2, rd)
  • immediate value (imm): the sign-extended immediate encoded in the instruction
  • target: the resolved jump or branch destination address, computed as address + imm for J-type and B-type instructions

The function decode_rv32i is the entry point for decoding a single instruction. It takes a raw 32-bit word and its address, extracts all the shared bit fields (opcode, rd, funct3, rs1, rs2, funct7) and pre-computes the immediate values for all possible instruction formats. This information is then handed off to a helper function _decode_rv32i_dispatch, which dispatches on the opcode field (as well as funct3 and funct7 when necessary) to identify the specific instruction and assemble the final RV32IInstruction.

Code Walkthrough

Let's run tiny-dec on our fixture_basic binary and see what the decoder produces. We can target a specific function using the --func flag and stop at a specific stage using --stage.

$ poetry run tiny-dec decompile ./tests/fixtures/bin/fixture_basic_O0_nopie.elf --stage decode --func main
tiny_dec decompile
binary: ./tests/fixtures/bin/fixture_basic_O0_nopie.elf
arch: riscv32 (32-bit, little-endian)
entrypoint: 0x110b4
target_function: main
target_address: 0x110b4
stage: decode
decode:
  0x000110b4: 0xff010113  addi x2, x2, -16
  0x000110b8: 0x00112623  sw x1, 12(x2)
  0x000110bc: 0x00812423  sw x8, 8(x2)
  0x000110c0: 0x01010413  addi x8, x2, 16
  0x000110c4: 0x00700513  addi x10, x0, 7
  0x000110c8: 0xfea42a23  sw x10, -12(x8)
  0x000110cc: 0xff442503  lw x10, -12(x8)
  0x000110d0: 0x00000097  auipc x1, 0

Each line shows the instruction address, its raw hex encoding, and the decoded instruction. Converting the raw bytes into RV32I instructions makes the program’s behavior far easier to interpret and provides the decompiler with a structured representation it can analyze in later stages.

Let's trace the first instruction through the tiny-dec decoding stage, following how 0xff010113 becomes addi x2, x2, -16.

Step 1: Extract the shared fields
Recall that while not all fields are present in every instruction format, whichever fields a format does use are always found in the same bit positions. The tiny-dec decoder extracts these fields in decode_rv32i:

opcode = word & 0x7F            # bits [6:0]   → 0x13
rd = (word >> 7) & 0x1F         # bits [11:7]  → 2  (x2)
funct3 = (word >> 12) & 0x7     # bits [14:12] → 0
rs1 = (word >> 15) & 0x1F       # bits [19:15] → 2  (x2)
rs2 = (word >> 20) & 0x1F       # bits [24:20] → 2
funct7 = (word >> 25) & 0x7F    # bits [31:25] → 0x7F

Which fields actually matter will be determined in the later steps when we identify the specific instruction.

Step 2: Pre-compute the immediate fields
Unlike the other fields, the immediate field varies in position depending on the instruction format. Rather than waiting to identify the type of instruction to extract this field, we pre-compute the immediate field for each instruction type in decode_rv32i and later pick the correct value based on the opcode.

# R-type instructions do not have an immediate field
imm_i = _sign_extend((word >> 20) & 0xFFF, 12)
imm_s = _sign_extend((((word >> 25) & 0x7F) << 5) | ((word >> 7) & 0x1F), 12)
imm_b = _sign_extend(...)
imm_u = _sign_extend((word & 0xFFFFF000) >> 12, 20) << 12
imm_j = _sign_extend(...)

_sign_extend and two's complement

_sign_extend is used to treat the raw bit pattern as a two's complement integer. This is necessary because RV32I architecture treats immediate values as signed integers. For our instruction 0xff010113, the raw I-type immediate extracted would be 0xFF0. Without this sign extension, this would read as 4080 when the correct interpretation is -16.

Now that we have all the possible fields, we have to choose the correct fields and assemble the complete instruction.

Step 3: Assemble the Instruction
With the extracted fields, _decode_rv32i_dispatch dispatches on the opcode to identify the instruction. Using the opcode value 0x13, we land here in the code:

if opcode == 0x13:
    if funct3 == 0x0:
        return _rv32i(mnemonic=Mnemonic.ADDI, fmt=InstructionFormat.I,
                      rd=rd, rs1=rs1, imm=imm_i, ...)

Note that even after we check the opcode value, we need to perform an additional check on the funct3 field. The opcode value 0x13 is shared among multiple I-type instructions such as addi, ori, andi, and many others. The funct3 field helps us distinguish which of these instructions the bytes refer to. For R-type instructions, this check can go one step further, comparing the funct7 field to further distinguish between instructions that share the same opcode and funct3 values.

With the complete instruction assembled, we can output the RV32IInstruction giving us the result we saw in the beginning:

0x000110b4: 0xff010113  addi x2, x2, -16

What's Next?

Now that we understand how RV32I instructions are structured and how to decode them from binary, the next post will walk through lifting these instructions into p-code, Ghidra's intermediate representation (IR) for architecture-neutral analysis.