Skip to content

Lifting RV32I to P-code

Now that each 32-bit word has been decoded into an instruction, the next step is to capture what that instruction means.

Why is lifting necessary?

Lifting transforms decoded assembly instructions into an intermediate representation (IR) that captures program semantics in a machine-independent way.

Raw assembly is difficult to analyze directly because the same operation is expressed differently across architectures:

# RISC-V
add x5, x6, x7

# x86
add eax, ebx

# ARM
ADD r0, r1, r2

Although all three instructions perform the same operation, addition, they differ in register naming, operand formats, and architectural conventions. These differences force any analysis written directly on assembly to constantly adapt to each instruction set.

Lifting removes this dependency by normalizing instructions into a shared semantic representation. Once in this form, later analysis stages can operate without caring about the original architecture at all.

For our decompiler, we will be using p-code as the intermediate representation (IR).

What is P-code?

P-code is GhidraA free, open-source software reverse-engineering suite (originally from the NSA); its p-code IR is what we lift instructions into. More →'s intermediate representation1. It normalizes machine instructions into a small set of simple, architecture-independent operations that explicitly describe computation, memory access, and control flow.

Every p-code program is built from three core concepts:

  • address spaces, which define where data lives
  • varnodes, which represent the data itself
  • p-code operations, which describe what is done to that data.

These three building blocks together capture the full semantics of any instruction from any ISA, making p-code a powerful foundation for architecture-neutral analysis.

P-code Operations

A p-code operation is a single, atomic step in a computation. Each operation takes one or more input varnodes and writes the result to an output varnode.

The full set of p-code operations is small but expressive, covering three main categories:

  • Computation: INT_ADD, INT_SUB, INT_MULT, and others for arithmetic and logical operations
  • Memory access: LOAD and STORE for reading and writing memory
  • Control flow: BRANCH, CBRANCH, and CALL for jumps, conditional branches, and function calls

Address Spaces

An address space defines a named region where data can live. P-code defines several address spaces to model the different kinds of storage a program uses.

The main address spaces are:

  • register: represents CPU registers. Each architectural register (e.g., x5 in RV32I) is mapped into this space so it can be referenced uniformly in p-code.
  • ram: represents main memory. Any load or store operation ultimately reads from or writes to this space.
  • unique: represents temporary values created during lifting. These do not exist in the original machine code but are introduced to hold intermediate results (similar to compiler temporaries or SSA variables).
  • const: represents immediate values embedded directly in instructions.

The const space is special because it does not correspond to physical storage like registers or memory. Instead, it is used to model literal values such as immediates in arithmetic or address calculations, making them first-class operands in p-code operations.

Varnodes

A varnode is the fundamental unit of data in p-code. Every value that a p-code operation reads or writes is represented as a varnode.

A varnode is described by three properties:

  • Space: the address space it lives in (e.g. register, ram, unique, const)
  • Offset: its location within that space
  • Size: the number of bytes it represents

For example, the RV32I register x10 would be a varnode in the register space at its corresponding offset with a size of 4 bytes, while a temporary value computed mid-instruction would live in the unique space.

Lifting to P-code

Let's walk through a concrete example of lifting a real RV32I instruction into p-code.

Consider the following sw instruction that stores the value in register x8 into memory at address x2 + 24:

sw x8, 24(x2)

From the decoder we already know:

  • opcode: sw
  • base register: x2
  • source register: x8
  • immediate: 24

Semantically, this instruction can be represented as:

address = x2 + 24
MEM[address] = x8

This is exactly how Ghidra’s p-code works. When an instruction involves multiple semantic steps, such as address computation and a memory access, it is broken into separate explicit operations so each part can be analyzed independently.

To convert this into p-code, we first translate the architectural components into varnodes: - x2register[0x2:4] - x8register[0x8:4] - 24const[0x18:4]

We also introduce a temporary to hold the computed address: tmp0unique[0x0:4]

The first operation computes the effective memory address:

INT_ADD unique[0x0:4] <- register[0x2:4], const[0x18:4]

This adds the base register (x2) and the immediate offset (24) to produce the final memory address, which is stored in the temporary address space represented by unique.

The second operation performs the actual memory write:

STORE unique[0x0:4], register[0x8:4]

This writes the value in x8 into memory at the computed address.

Putting the two together, the full lifted p-code sequence for sw instruction above is:

INT_ADD unique[0x0:4] <- register[0x2:4], const[0x18:4]
STORE unique[0x0:4], register[0x8:4]

This is the representation our decompiler produces for each instruction during lifting, forming a uniform semantic layer that all later analysis stages operate on.

Content Review Quiz

Content Review Quiz

What is the primary purpose of p-code in Ghidra?

In p-code, what is a varnode defined as?

Why does p-code introduce the unique address space?

Which of the following is NOT true about lifting?

Explore the Code

Let's take a look at the tiny-dec lifting stage to understand how each decoded instruction is lifted into its respective p-code.

Code Structure

The core functionality of the lifting stage is located in lift_rv32i.py, which defines the data class _LiftContext using the decoded RV32IInstruction from the previous stage to lift each instruction into p-code in the function lift_instruction.

_LiftContext is the intermediary data class that carries the decoded instruction and provides helper methods to calculate the varnodes and temporaries needed to lift the decoded instruction:

  • read_reg / write_reg: maps an architectural register to a varnode in the register address space.
  • imm32: constructs a const varnode from the instruction's immediate value.
  • tmp: allocates a new temporary varnode in the unique address space to hold intermediate values during lifting.

The lift_instruction function takes a decoded RV32IInstruction, dispatching on the mnemonic to branch to the correct lift routine, and returns a list of PcodeOp objects representing the lifted p-code operations for that instruction.

Code Walkthrough

Let's run tiny-dec on our fixture_basic binary and see what the lifting stage produces.

$ poetry run tiny-dec decompile ./tests/fixtures/bin/fixture_basic_O0_nopie.elf --stage pcode --func main
...
pcode:
  0x000110b4: 0xff010113  addi x2, x2, -16
      INT_ADD register[0x2:4] <- register[0x2:4], const[0xfffffff0:4]
  0x000110b8: 0x00112623  sw x1, 12(x2)
      INT_ADD unique[0x0:4] <- register[0x2:4], const[0xc:4]
      STORE unique[0x0:4], register[0x1:4]
  0x000110bc: 0x00812423  sw x8, 8(x2)
      INT_ADD unique[0x0:4] <- register[0x2:4], const[0x8:4]
      STORE unique[0x0:4], register[0x8:4]
  0x000110c0: 0x01010413  addi x8, x2, 16
      INT_ADD register[0x8:4] <- register[0x2:4], const[0x10:4]
  0x000110c4: 0x00700513  addi x10, x0, 7
      INT_ADD register[0xa:4] <- const[0x0:4], const[0x7:4]
  0x000110c8: 0xfea42a23  sw x10, -12(x8)
      INT_ADD unique[0x0:4] <- register[0x8:4], const[0xfffffff4:4]
      STORE unique[0x0:4], register[0xa:4]
  0x000110cc: 0xff442503  lw x10, -12(x8)
      INT_ADD unique[0x0:4] <- register[0x8:4], const[0xfffffff4:4]
      LOAD unique[0x4:4] <- unique[0x0:4]
      COPY register[0xa:4] <- unique[0x4:4]
  0x000110d0: 0x00000097  auipc x1, 0
      INT_ADD register[0x1:4] <- const[0x110d0:4], const[0x0:4]

Whereas the decoding stage produced just readable instructions, the lifting stage further exposes the semantics of each instruction, decomposing it into atomic operations that explicitly represent its behavior.

Let's trace the first instruction through the tiny-dec lifting stage, following how addi x2, x2, -16 becomes INT_ADD register[0x2:4] <- register[0x2:4], const[0xfffffff0:4].

Step 1: Dispatch on the mnemonic.
lift_instruction receives the RV32IInstruction produced by the decoder and dispatches on the mnemonic to find the correct lift routine. Since our instruction is addi, execution lands in the _lift_op_imm branch:

elif mnemonic in {
    Mnemonic.ADDI.value,
    Mnemonic.SLTI.value,
    ...
}:
    _lift_op_imm(ctx, mnemonic)

In this way, lift_instruction handles every RV32I mnemonic, routing different instructions to their own dedicated routine.

Step 2: Build the varnodes.
Inside the _lift_op_imm function, the first thing we do is construct the varnodes for each operand using the _LiftContext ctx:

rd  = ctx.write_reg(ctx.insn.rd)   # register[0x2:4]  (x2)
...
lhs = ctx.read_reg(ctx.insn.rs1)   # register[0x2:4]  (x2)
rhs = ctx.imm32()                  # const[0xfffffff0:4]  (-16)

read_reg and write_reg functions map the architectural register to a varnode in the register address space.

Step 3: Emit the p-code operation.
With the varnodes constructed, _lift_op_imm looks up the correct p-code opcode for addi and emits the operation:

bin_map = {
    Mnemonic.ADDI.value: PcodeOpcode.INT_ADD,
    ...
}
...
    ctx.emit(bin_map[mnemonic], lhs, rhs, output=rd)

ctx.emit appends a PcodeOp to the context's operation list, giving us the final result we saw in the output above:

INT_ADD register[0x2:4] <- register[0x2:4], const[0xfffffff0:4]

The lifter processes each decoded instruction independently, the same way the decoder processed each raw word. Each instruction produces one or more p-code operations depending on its complexity. In our example, the addi instruction mapped to a single INT_ADD, but a store instruction like sw would require two operations, one for address computation, and another to write the value to the computed address.

What's Next?

Now that we've lifted each RV32I instruction into p-code, the next post will walk through recursive descent disassembly where these p-code operations will be grouped into basic blocksA straight-line run of instructions with a single entry and a single exit — no branches in or out except at the ends. to trace the control flow and recover the CFGA graph whose nodes are basic blocks and whose edges are the jumps between them. More →.