Blogs / Binary lifting
Ghidra P-code, Explained: Varnodes, State, and the Decompiler
Follow one x86 instruction through nine P-code operations. Change the inputs, step through its effects, and see what each varnode names.
An instruction such as test %rsi, %rdi looks small in assembly. Underneath, it computes a bitwise result, sets several flags, and gives a later branch a condition to read. A decompiler eventually turns the sequence into something like (a & b) != 0. P-code is the representation Ghidra uses between those two views. It gives the effects of an instruction as a sequence of simpler operations over registers, memory, constants, and temporary values.
This article is a field guide to reading that representation. We will unpack varnodes, address spaces, widths, memory operations, branches, calls, and the distinction between raw and high P-code. A real x86-64 function gives us an example with actual addresses and operations. Near the end, we will see why these details matter when another tool, including HydIR, consumes Ghidra's output.
The best reference while reading is Ghidra's own SLEIGH manual, P-code operation reference, and additional operation reference. The manual defines the model; the operation pages settle questions that a printed line of P-code often leaves ambiguous.
Why Ghidra has P-code#
Ghidra needs to analyze many instruction sets. The bytes for an x86-64 TEST, an ARM comparison, and a MIPS branch differ, but the analyses behind decompilation repeatedly ask similar questions: Which bytes of state were read? Which were written? What value reaches this comparison? Where can control flow go?
SLEIGH is Ghidra's processor-description language. A processor specification describes instruction decoding, assembly display, and the translation of instructions into P-code. P-code is a register-transfer language with a comparatively small operation vocabulary. It can say “copy these bytes,” “add two values of this width,” “load from this address space,” or “branch if this one-byte condition is nonzero.” An architecture supplies its register layout and instruction translations. The analyses can then work on P-code instead of learning every machine opcode separately.
That does not make P-code “portable assembly” in the sense of a complete standalone program. It describes operations over state that has to exist somewhere. A P-code emulator still needs memory bytes, register values, address-space definitions, and behavior for operations whose effects are external or deliberately abstracted. We will return to those boundaries.
The split in that diagram explains a common misunderstanding. People say “Ghidra's P-code” while referring to either the operations attached directly to instructions or the later, optimized operations inside a decompiled function. They are related, but they answer different questions.
The three ingredients: spaces, varnodes, operations#
Ghidra's model begins with an address space: a named, indexed collection of storage units. A typical language specification has ram for main memory and register for processor registers. The SLEIGH compiler also supplies a unique space for temporary values and a const space for literal operands. A processor may define more spaces. A space has an address size and, for some architectures, an addressable unit larger than one byte. These details come from the selected language, not from the spelling of the P-code opcode. Ghidra's SLEIGH introduction lays out this model.
A varnode is a region within a space: (space, offset, size). The size is in bytes. For example:
(register, 0x38, 8) eight bytes beginning at register offset 0x38
(unique, 0xd5700, 8) eight temporary bytes
(const, 0xff, 8) the eight-byte literal value 255
(ram, 0x2013d6, 8) an eight-byte region of RAM
The const case is special: its offset supplies the value of the literal. (const, 0xff, 8) does not ask an emulator to read eight bytes from address 0xff. Conversely, a ram varnode names storage at a RAM address; it does not automatically mean “the constant numeric value of this address.” Confusing an address with the bytes stored there is one of the fastest ways to misread a P-code listing.
Varnodes are typeless storage. They do not inherently say “signed integer,” “pointer,” or “C struct.” The operation using the bytes supplies an interpretation. INT_LESS compares unsigned values; INT_SLESS compares signed values. Both may read the same two varnodes. Ghidra's SLEIGH manual also allows varnodes to overlap. In a typical x86-64 Ghidra register layout, the eight-byte RAX, four-byte EAX, and one-byte AL views all begin at register offset zero. A write to the low byte changes one of the bytes seen through the wider view. If a machine instruction also clears the upper half of RAX when writing EAX, its P-code must express that behavior; overlap alone does not invent the clearing. These are views of storage, not three independent source variables.
RAX = 0x1122334455667788. Select a write to see which bytes it changes.
A filled byte was written by the selected view. An outlined byte was cleared by the x86-64 32-bit register-write rule.
Try to predict the AH case before selecting it: byte +1 changes, but AL at +0 does not. The EAX case makes a different point. Writing a 32-bit x86-64 general-purpose register also clears its upper 32 bits. The four low bytes and the four cleared bytes have different reasons for changing. A varnode overlap check explains the first part; the instruction semantics explain the second. AMD's architecture manual, section 3.4.5, specifies the extension rule for 32-bit results and the preserved high bits for 8- and 16-bit results.
Finally, a P-code operation has an opcode, zero or more input varnodes, and sometimes one output varnode. It can read and write those values, or change control flow. The PcodeOp API represents that shape directly. One machine instruction can generate many operations because its behavior may include intermediate calculations, flags, memory effects, and a transfer of control.
| Space | How to read a varnode there | Common trap |
|---|---|---|
const |
The offset encodes a literal value | Treating it as memory to dereference |
register |
Bytes in Ghidra's processor register layout | Assuming two overlapping register views are separate |
unique |
Temporary storage used while expressing semantics | Assuming a numeric unique offset is a process address |
ram |
Bytes in the named memory space | Treating its address as its contents |
In raw instruction translation, unique values are useful scratch space. A SLEIGH author can write a local temporary, and the compiler can allocate its storage in the unique space. Spinsel's small processor-module walkthrough shows this in a compact setting: a SLEIGH local turns into a unique-space varnode, with COPY and INT_ADD operations updating it. This is easier to see in a tiny instruction set before approaching x86 flags.
Give the triple a precise meaning
Let a varnode be : space , offset , and size bytes. For an ordinary storage space, reading it means taking bytes through from that space's state. Two varnodes overlap when they name the same space and their byte ranges intersect:
That formula explains why RAX, EAX, and AL cannot be modeled as unrelated variables. It also tells us what a partial write changes: exactly the bytes selected by its output varnode, plus any other writes explicitly present in the translated instruction. Endianness determines how a multi-byte value is assembled from those bytes. For example, four little-endian bytes 78 56 34 12 represent the integer 0x12345678; the same bytes interpreted big-endian represent 0x78563412. The space's endianness, not the spelling of INT_ADD, chooses the encoding. Ghidra's P-code reference defines that relationship.
const needs its own rule. Its offset is the literal, so (const, 0xff, 1) yields 0xff. It does not read . This distinction matters again for branch destinations, where a constant-space varnode has a special instruction-local meaning.
Why a SLEIGH local becomes a unique varnode
Take the semantic body of a toy swap instruction:
local tmp:4 = r1;
r1 = r2;
r2 = tmp;
This follows the temporary-variable pattern in Ghidra's SLEIGH constructor documentation; it is a semantic fragment, not a complete processor specification. The temporary preserves the old value of r1 before r1 is overwritten. A simplified raw translation is:
unique[tmp:4] = COPY register[r1:4]
register[r1:4] = COPY register[r2:4]
register[r2:4] = COPY unique[tmp:4]
Predict the result if the first operation were deleted. The last COPY would have no saved original r1 to read. The unique space exists because the description of one machine instruction sometimes needs scratch values that are neither architectural registers nor process RAM. It is best understood as translation-time temporary storage, not as an address you can find in the executable's memory map.
Widths are part of the meaning#
P-code's byte sizes are semantic, not decoration. An INT_ADD with two four-byte inputs and a four-byte output computes modulo 2^32. The corresponding eight-byte operation computes modulo 2^64. Neither operation silently keeps a ninth byte or sets a processor flag. The operation reference defines separate operations such as INT_CARRY and INT_SCARRY for unsigned carry and signed overflow.
Here is a simplified illustration:
out:1 = INT_ADD const[0xff:1], const[0x01:1] ; out becomes 0x00
cf:1 = INT_CARRY const[0xff:1], const[0x01:1] ; cf becomes true
The notation above is explanatory, rather than a byte-for-byte dump from Ghidra. The point is that the one-byte result wraps, while carry is a distinct value. When you see an INT_ZEXT or INT_SEXT, it is also a real semantic step: one fills new high bits with zero; the other repeats the sign bit. SUBPIECE drops a specified number of least-significant bytes, and PIECE concatenates high and low parts. These operations explain how a language can express register slices and multiword results without inventing new opcodes for each architecture.
The same discipline applies to shifts and comparisons. INT_RIGHT is logical right shift; INT_SRIGHT is arithmetic right shift. INT_LESS and INT_SLESS disagree whenever the signed bit changes the ordering. An analyzer that translates every right shift to the same host-language operator, or every comparison to a signed comparison, will produce plausible-looking but incorrect results. The exact width and opcode matter more than the name an analyst assigns to the variable.
Floating-point operations form their own family, including FLOAT_ADD, FLOAT_EQUAL, conversions, and NaN tests. Their presence does not let a consumer assume that ordinary integer operations are floating-point because a type hint says double. Raw P-code works at the operation level; later type recovery is another layer.
Here is a small executable model for the integer rules. Python's integers do not overflow, so the mask is the step that gives a P-code operation its selected width. Run this fragment with python pcode_widths.py:
def mask(nbytes):
return (1 << (8 * nbytes)) - 1
def int_add(a, b, nbytes):
return (a + b) & mask(nbytes)
def signed(bits, nbytes):
width = 8 * nbytes
bits &= mask(nbytes)
sign = 1 << (width - 1)
return bits - (1 << width) if bits & sign else bits
def int_less(a, b, nbytes):
return int((a & mask(nbytes)) < (b & mask(nbytes)))
def int_sless(a, b, nbytes):
return int(signed(a, nbytes) < signed(b, nbytes))
assert int_add(0xff, 1, 1) == 0x00
assert int_add(0xff, 1, 2) == 0x0100
assert int_less(0xff, 1, 1) == 0
assert int_sless(0xff, 1, 1) == 1
The final two assertions use identical input bits. Under unsigned comparison, 0xff is ; under signed eight-bit comparison, it is . The code is a teaching model for these opcodes, not a complete emulator. It deliberately leaves out memory, float formats, exceptional cases, and the architecture's instruction sequencing. The test makes one point hard to miss: the varnode's bytes do not carry a permanent signedness tag. The operation decides how to read them.
Here is another small decoding exercise. SUBPIECE takes its second input as a byte count to discard from the low end; PIECE takes a high part followed by a low part. These two lines select and then rebuild bytes, with no C type involved:
hi:2 = SUBPIECE const[0x12345678:4], const[2:4] ; hi = 0x1234
whole:4 = PIECE hi:2, const[0xabcd:2] ; whole = 0x1234abcd
The tempting mistake is to read SUBPIECE(..., 2) as “take two bytes starting from the left.” It actually drops two least-significant bytes. This matters when a processor splits a wide register into smaller operations: the byte slice you choose can reverse the meaning of a comparison or memory write. The operation reference defines the operand order and width constraints.
One x86 TEST becomes nine operations#
Consider this real five-instruction function from a checked-in x86-64 ELF fixture:
hydir_stage_bit_gate:
mov $0, %rax
test %rsi, %rdi
je .Lbit_gate_done
mov $1, %rax
.Lbit_gate_done:
ret
Its result is 1 when RDI & RSI is nonzero and 0 otherwise. That short C-like statement hides the flags. The saved Ghidra snapshot records five machine instructions and fifteen raw P-code operations. The TEST at 0x2013d6 alone contributes nine operations.
The following is a readable transcription of those nine operations. It substitutes flag names for the fixture's one-byte register offsets and short names for unique-space offsets; the operation order and calculations come from the saved raw export.
0x2013d6 test %rsi, %rdi
CF = COPY 0
OF = COPY 0
t0:8 = INT_AND RDI:8, RSI:8
SF = INT_SLESS t0:8, 0:8
ZF = INT_EQUAL t0:8, 0:8
t1:8 = INT_AND t0:8, 0xff:8
t2:1 = POPCOUNT t1:8
t3:1 = INT_AND t2:1, 1:1
PF = INT_EQUAL t3:1, 0:1
0x2013d9 je 0x2013e2
CBRANCH 0x2013e2, ZF
TEST computes the AND but does not save that result into either input register. t0 is temporary storage. The raw translation clears carry and overflow, derives the sign and zero flags, and calculates parity from the low byte. The next instruction reads ZF. This is why a hand-written rule saying “test is just AND” misses observable state, even if it happens to predict this function's return value.
The flag names in this transcription are for the reader. The actual JSON contains varnodes such as (register, 0x206, 1) for the zero flag and (unique, 0xd5700, 8) for t0, along with each operation's source instruction address and sequence position. The offsets belong to the Ghidra language used for this binary. They are not universal register numbers.
Try two inputs: RDI = 0x48, RSI = 0x08 gives a nonzero AND, so ZF = 0 and JE falls through to the mov $1. Changing RDI to 0x40 makes the AND zero, so ZF = 1 and JE goes directly to ret. The operations make the condition traceable one value at a time:
RDI and RSI -> INT_AND -> t0 -> INT_EQUAL(t0, 0) -> ZF -> CBRANCH
That chain is also a useful recipe for reading unfamiliar P-code. Start at the branch condition. Find the operation that writes its varnode. Follow the inputs backward until you reach registers, constants, or memory. Only then summarize the condition in C-like language.
Let's calculate every flag the nine operations set. The parity flag tests whether the low byte has an even number of set bits. Zero has zero set bits, so its parity is even.
| RDI | RSI | t0 = RDI & RSI |
SF | ZF | PF | JE |
|---|---|---|---|---|---|---|
0x48 |
0x08 |
0x08 |
0 | 0 | 0 | Fall through |
0x40 |
0x08 |
0x00 |
0 | 1 | 1 | Take branch |
0x8000000000000000 |
same | same | 1 | 0 | 1 | Fall through |
In all three rows CF = OF = 0. The third row is instructive: the sign bit is set, but the low byte is zero, so SF = 1 and PF = 1 at the same time. Flags answer different questions about the same result. Here is a compact Python implementation of the fixture's TEST flag behavior:
MASK64 = (1 << 64) - 1
def test_flags(rdi, rsi):
result = (rdi & rsi) & MASK64
low_byte = result & 0xff
return {
"result": result,
"CF": 0,
"OF": 0,
"SF": (result >> 63) & 1,
"ZF": int(result == 0),
"PF": int(low_byte.bit_count() % 2 == 0),
}
assert test_flags(0x48, 0x08)["ZF"] == 0
assert test_flags(0x40, 0x08)["ZF"] == 1
assert test_flags(1 << 63, 1 << 63)["SF"] == 1
Try the P-code chain
Enter two 64-bit hexadecimal values. The output follows the INT_AND, flag writes, and CBRANCH in the real TEST example.
Final state for these inputs
Temporary t0 =
- CF
- OF
- SF
- ZF
- PF
At 0x2013d9, .
Step through the raw operations
The first nine entries share source address 0x2013d6; the last is the next machine instruction at 0x2013d9. The list uses one-based teaching numbers; the exported operation sequence times start at zero.
CF = COPY 0OF = COPY 0t0 = INT_AND RDI, RSISF = INT_SLESS t0, 0ZF = INT_EQUAL t0, 0t1 = INT_AND t0, 0xfft2 = POPCOUNT t1t3 = INT_AND t2, 1PF = INT_EQUAL t3, 0CBRANCH 0x2013e2, ZF
Before operation 1, these flags have not yet been written by this instruction.
This little experiment is intentionally narrower than running an ELF. It computes the raw TEST effect on supplied register values. It does not claim anything about where a larger program obtained those values.
Memory has two addresses in the picture#
The printed shape of a LOAD can be misleading. It has a special first input naming the space being accessed, a second input holding a pointer offset, and an output whose size determines how many bytes to read. STORE similarly has a destination space, pointer offset, and value whose size determines the write width. The operation reference calls out this distinction explicitly.
For illustration, imagine an eight-byte pointer in a register, and a four-byte read from RAM:
ptr:8 = INT_ADD base:8, const[0x10:8]
value:4 = LOAD spaceid(ram), ptr:8
ptr is stored in some varnode, perhaps in register or unique. Its numeric contents give an offset into the RAM space named by spaceid(ram). The four-byte output sets the read width. The address space containing the ptr varnode can differ from the address space being loaded. spaceid(ram) is explanatory notation here; a raw dump encodes that space ID in a special constant input.
On common x86-64 languages an addressable RAM unit is a byte. Ghidra's definition also allows a space whose addressable unit is larger. For those spaces, LOAD and STORE scale the pointer offset by the space's word size to obtain a byte offset. An emulator that assumes every space is byte-addressed can read the wrong location while still producing a perfectly well-formed P-code trace.
The raw RET in our fixture makes the two roles of const unusually clear. Here are its three operations, with the exact exported offsets. This fixture's address-space table maps ID 0x1b1 to ram.
0x2013e2:
(register,0x288,8) = LOAD (const,0x1b1,8), (register,0x20,8)
(register,0x20,8) = INT_ADD (register,0x20,8), (const,0x8,8)
RETURN (register,0x288,8)
Read the first line slowly. (register,0x20,8) holds the stack pointer value. (const,0x1b1,8) is the special space ID operand to LOAD; it selects RAM. (register,0x288,8) receives the eight bytes loaded from that stack address. In the next line, (const,0x8,8) is an ordinary numeric eight, and the stack pointer advances. The final operation transfers control to the value just loaded. The same const space appears in both lines, but the opcode and operand position determine whether its offset is a space selector or a literal arithmetic input.
LOAD. This is a teaching seed with RSP = 0x700000 and supplied little-endian stack bytes. It is not a claim that these bytes occur in the fixture ELF.(const, 0x1b1, 8)RAM(register, 0x20, 8)0x700000LOAD reads eight bytes from ram[0x700000] ↓
The loaded value is 0x00000000deadbeef. A separate mapping check would be needed before treating it as an executable return target.
Remove byte +3 in the diagram. Seven known bytes cannot determine the eight-byte value: the unknown byte occupies bits 24 through 31. Filling it with zero would turn a missing observation into a made-up return address. The LOAD operation defines the read, but the initial state defines whether that read has a known result.
For a word-addressed space, the pointer conversion is:
If wordsize = 2 and the pointer offset is 0x10, LOAD starts at byte offset 0x20. This scaling is specific to dereferencing through LOAD or STORE; it is not a license to multiply every P-code address by two. Ghidra's reference manual makes that exception explicit.
There is a second memory question that P-code alone cannot answer: what bytes are there? A LOAD specifies where to read and how much to read. It does not promise that a file has initialized those bytes, that a pointer is mapped, or that a previous call has a known effect. A decompiler can reason symbolically about the load. A concrete emulator needs an initial state or an explicit unknown result.
For the x86-64 fixture's little-endian RAM, a minimal read can be written this way:
def load_le(known_bytes, address, size):
missing = [address + i for i in range(size)
if address + i not in known_bytes]
if missing:
raise ValueError(f"unknown memory byte at {missing[0]:#x}")
return sum(known_bytes[address + i] << (8 * i)
for i in range(size))
stack = {
0x700000 + i: byte
for i, byte in enumerate((0xef, 0xbe, 0xad, 0xde, 0, 0, 0, 0))
}
assert load_le(stack, 0x700000, 8) == 0xdeadbeef
The check for missing bytes is the important line. Replacing it with known_bytes.get(address + i, 0) would quietly turn unknown memory into a zero-filled stack. That would let an emulator “succeed” on a state nobody supplied. Real process modeling also needs mapped segments, permissions, and writes, but the small example already shows why a P-code LOAD cannot manufacture its own input.
Branches, calls, and returns have precise quirks#
BRANCH uses its destination varnode as an address descriptor, not as a normal value to read. CBRANCH adds a one-byte condition: nonzero means take the branch. If the destination is in the constant space, it has a special meaning: a relative jump among the P-code operations generated for the current machine instruction. It is not a jump to a constant machine address. Ghidra's branch definitions give the example of operation 5 moving to operation 8 with a relative offset of 3.
Here is that example drawn as an operation list:
machine instruction at 0x401000
op 5: CBRANCH (const,+3), condition
op 6: ... # condition was false
op 7: ...
op 8: ... # condition was true
When condition is true, op 5 + 3 selects op 8 inside the translation of the same machine instruction. It does not execute machine address 0x3 or 0x401003. Why would an instruction need this? A SLEIGH specification may describe a conditional choice or loop inside one architectural instruction. Its constructor documentation shows labels and conditional jumps inside a semantic section; the compiler encodes those label jumps as P-code-relative indices. A tool that only records instruction-to-instruction edges can miss this smaller control-flow layer.
BRANCHIND gets the target offset from a varnode at runtime. That is the form to watch around jump tables and computed dispatch. The current address space supplies the target space. Without the runtime value or a sound finite target set, there may be more destinations than a static listing currently shows.
CALL, CALLIND, and RETURN sound more high level than their raw behavior. The reference describes raw CALL as a branch marked as call-like for analysis; it does not push a return address onto a hidden P-code call stack. Raw RETURN is likewise a return-like indirect branch. The machine instruction's other P-code operations carry effects such as saving or loading a return address. In our x86 fixture, ret has a LOAD from the stack, an INT_ADD that advances RSP by eight, and RETURN. Reading only the last operation would drop two parts of the instruction.
These details matter when emulating or lifting. The presence of a CALL marker can guide function recovery, while actual calling conventions, stack state, argument locations, and imported effects require additional information. It also matters for security analysis: a single observed indirect-call target tells you where one run went, not everywhere the program could go.
Raw P-code versus high P-code#
Raw P-code is obtained from instruction translation. It keeps the small operations that express the instruction's immediate effects, including flags and temporary values. Ghidra exposes it through the Instruction.getPcode API. A PcodeOp also carries a sequence number tied to the source instruction address and a position among that instruction's operations. That lets a reader point to the machine instruction behind an operation rather than merely saying “somewhere in the function.”
High P-code is produced after the decompiler has analyzed control and data flow. Values can acquire SSA-style identity, variable names, and type information. Operations may be simplified, rewritten, or removed when they do not contribute to the decompiler's chosen output. Ghidra exposes the resulting operations through a HighFunction. The same five-instruction fixture has fifteen raw operations but only four operations in its optional high-P-code export. That difference is expected: the high view has turned the flag-and-branch sequence into a compact value expression and no longer shows every stack effect in the raw ret translation.
Some opcodes appear only after analysis. Ghidra's additional operations page lists several especially helpful ones:
| High-P-code operation | What it tells you |
|---|---|
MULTIEQUAL |
An SSA phi: choose the incoming value from the predecessor actually taken |
INDIRECT |
A value may have changed through an effect that analysis cannot directly express, such as an alias |
PTRADD |
Pointer arithmetic recognized as an array index and element stride |
PTRSUB |
Pointer arithmetic recognized as an offset into a structure |
CAST |
The bits are copied, but their inferred type interpretation changes |
Let's build a phi node by hand. Suppose a function's control flow is:
if (condition) {
x = 4;
} else {
x = 9;
}
return x;
At the merge block, raw P-code contains the branch and the concrete writes that implement each path. The decompiler can give each reaching value a distinct identity and insert a high-P-code merge:
true predecessor: x1 = COPY 4
false predecessor: x2 = COPY 9
merge block: x3 = MULTIEQUAL x1, x2
RETURN x3
The operation means “select the input associated with the predecessor actually taken.” It does not mean add 4 + 9, pick either value at random, or execute a new instruction at the merge. The incoming edge determines the selection. In SSA notation, we write . That explicit definition is why a backward slice can follow x3 to both possible sources. This example is schematic; the exact printed high P-code for a compiled function depends on Ghidra's analysis and simplification style.
x3. The phi belongs to the analyzed dataflow graph; it is not another machine instruction.Arrived from the true block: x3 = 4.
Now consider a different high-level clue: PTRADD(base, i, 12). Its numeric calculation is base + i * 12, but its opcode says more: analysis currently treats base as an array pointer with twelve-byte elements. PTRSUB(element, 8) computes element + 8 and expresses a proposed field offset inside a structured value. A decompiler might combine them into array[i].field. The raw P-code may show only multiplies, additions, and a LOAD. Those richer pointer operations help explain recovered C, while the numeric offsets remain worth checking against the original accesses. Ghidra's additional-opcode reference spells out both calculations.
This is why the two views serve different tasks. If you want to emulate exact instruction effects, raw P-code is the natural starting point. If you want to trace a value through a function or find a type-aware pattern, high P-code often saves a great deal of work. A gentle introduction to static analysis with P-code uses the latter for taint-style traversal; twevs' high-P-code case study uses it to identify PlayStation graphics patterns that would be awkward to recover from isolated machine instructions.
High P-code is not one immutable snapshot either. Ghidra's DecompInterface.setSimplificationStyle API exposes styles such as firstpass, normalize, and decompile. firstpass gives a largely unmodified dataflow syntax tree, normalize omits type recovery and some final cleanup, and the default decompile performs the work aimed at C output. An analysis script that needs an early stack copy may obtain a different operation set from one that asks for final C-oriented high P-code. Record the style when you compare results; otherwise two correct screenshots can appear to contradict each other.
There is a price for that convenience. NCC Group's Ghostrings investigation found that a decompiler simplification stage could remove stack-copy operations its Go-string recovery wanted to inspect. PracticalPCode shows a successful use of high-level dataflow to recover dynamically resolved Win32 API names. Both examples reward asking which stage of P-code you are reading and what transformations happened before your script received it.
Why P-code cannot simply be renamed LLVM IR#
This question comes up whenever someone wants to lift a binary into a compiler. Both P-code and LLVM IR have operations called “add” and “load.” Their surrounding contracts differ. Raw P-code can write overlapping architectural register bytes, uses named machine address spaces, and carries instruction-local control flow. LLVM IR gives computations SSA values and its own pointer, memory, and undefined-behavior rules. Translation needs a model for those differences.
Consider an eight-bit P-code INT_ADD. Its result is the low eight bits of the sum. A corresponding LLVM fragment can use an ordinary add i8:
%sum = add i8 %a, %b
%carry = icmp ult i8 %sum, %a
The second line calculates unsigned carry for this addition. In an LLVM lift, the carry must be represented because a later machine branch may read it. Adding LLVM's nsw flag to the first line would assert that signed overflow cannot occur; if it does, LLVM produces a poison value. P-code's plain INT_ADD makes no such promise. The LLVM language reference defines these flags and poison behavior. A lift that adds nsw just because the machine instruction looked like ordinary C arithmetic has changed the program.
The pointer story is similar. High P-code PTRADD can encode an inferred array stride, while LLVM's getelementptr computes addresses under LLVM's pointer rules. Qualifiers such as inbounds introduce additional conditions whose violation can produce poison; the LLVM GEP guide explains why this is more than decorative syntax. Machine pointer arithmetic may wrap or target bytes that do not belong to a recovered C object. A lifter has to justify each stronger LLVM claim, or keep a more explicit machine-state representation.
This contrast also explains why high P-code is attractive for understanding C and raw P-code is attractive for checking instruction effects. Each has removed a different amount of machine detail. Neither turns an unfamiliar binary into a well-typed, exception-free LLVM program by simple opcode substitution.
How to inspect it yourself#
You can enable a P-code field in Ghidra's Listing. Spinsel's walkthrough shows the field setup, including the “Display Raw Pcode” option. The listing is ideal for answering “what does this instruction do?” For repeatable inspection, save the following as a Ghidra Java script named ExplainOneInstruction.java. Put the cursor on an instruction and run it:
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.pcode.PcodeOp;
public class ExplainOneInstruction extends GhidraScript {
@Override
protected void run() throws Exception {
Instruction ins =
currentProgram.getListing().getInstructionAt(currentAddress);
if (ins == null) {
println("Put the cursor on a decoded instruction.");
return;
}
println(ins.getAddress() + " " + ins.getMnemonicString());
for (PcodeOp op : ins.getPcode(true)) {
println(" #" + op.getSeqnum().getTime()
+ " " + op.getMnemonic()
+ " output=" + op.getOutput());
for (int i = 0; i < op.getNumInputs(); i++) {
println(" input[" + i + "]=" + op.getInput(i));
}
}
}
}
The true argument requests the version that includes Ghidra's flow overrides, as documented by the Instruction API. The script prints the opcode and each input separately because operand position can change meaning, as it did for LOAD's special space ID. getSeqnum().getTime() distinguishes operations at the same machine instruction address. If you are building an analyzer, inspect these structured fields; parsing op.toString() is fragile.
To see the analyzed view, use a different API. Save this as ExplainHighPcode.java and place the cursor inside a function:
import java.util.Iterator;
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.pcode.HighFunction;
import ghidra.program.model.pcode.PcodeOpAST;
import ghidra.program.model.pcode.Varnode;
public class ExplainHighPcode extends GhidraScript {
@Override
protected void run() throws Exception {
Function fn = currentProgram.getFunctionManager()
.getFunctionContaining(currentAddress);
if (fn == null) {
println("Put the cursor inside a function.");
return;
}
DecompInterface decompiler = new DecompInterface();
try {
decompiler.setSimplificationStyle("decompile");
if (!decompiler.openProgram(currentProgram)) {
println("Could not open the program in the decompiler.");
return;
}
DecompileResults results =
decompiler.decompileFunction(fn, 30, monitor);
if (!results.decompileCompleted()) {
println(results.getErrorMessage());
return;
}
HighFunction high = results.getHighFunction();
if (high == null) {
println("Decompilation produced no HighFunction.");
return;
}
Iterator<PcodeOpAST> ops = high.getPcodeOps();
while (ops.hasNext()) {
PcodeOpAST op = ops.next();
println(op.getSeqnum().getTarget() + " " + op);
for (int i = 0; i < op.getNumInputs(); i++) {
Varnode input = op.getInput(i);
println(" input[" + i + "] " + input
+ " <- " + input.getDef());
}
}
} finally {
decompiler.dispose();
}
}
}
This script prints high P-code in Ghidra's decompile style. The DecompInterface API documents opening the program, checking completion, and obtaining HighFunction. Each input.getDef() points to the high-P-code operation that defined that input, when one is available. A constant or live-in value may have no local definition. Try changing the simplification style to normalize and compare the operation set. A high varnode's definition and uses can be followed through Varnode.getDef() and getDescendants(). The operation iterator alone is not an execution trace; branches and MULTIEQUAL inputs still require control-flow context.
A useful inspection order is: locate the source instruction; read the raw operations and their widths; map register varnodes through the selected language; follow the high-P-code value chain if you need cross-block context; then compare the resulting C-like expression with the original effects. This order catches errors caused by trusting a nice-looking expression before checking how it was derived.
Where P-code's model stops#
P-code has a defined vocabulary, but some effects are intentionally represented as placeholders. CALLOTHER identifies a language-defined user operation. Its name and inputs do not automatically specify every side effect. Ghidra's pseudo-operation reference says such an operation may need architecture-specific handling or conservative black-box treatment. That is a meaningful warning for unusual instructions, hardware interactions, and virtualized or obfuscated code.
Likewise, INT_DIV defines an unsigned quotient but leaves division-by-zero behavior to surrounding modeling; the operation reference does not make it an automatic processor trap. A direct call marker does not tell you what an imported library routine changes. A LOAD does not populate missing memory. High P-code can introduce type and alias assumptions that make a function readable while losing an effect a particular analysis wanted to observe.
Researchers have studied these edges formally. Naus, Verbeek, Walker, and Ravindran's P-code semantics work examines high P-code and the information needed to give it executable meaning. Its scope matters: findings about a decompiler-transformed representation should not be casually applied to every raw operation. The practical lesson is to name the P-code stage, the selected language, and the state assumptions behind any claimed execution result.
John Toterhi's Ghidra P-code emulation walkthrough makes that state requirement concrete by setting up registers, memory, a starting address, and steps in Ghidra's emulator. It is a useful bridge between reading a listing and asking “what would these operations do for this input?” An emulator answers that question for a supplied state and path. Static analysis asks a broader question about many possible states.
The experiment has a boundary
Imagine you seed RDI = 0x48 and RSI = 0x08 at our function's entry. The nine TEST operations determine the flags. The branch determines the next instruction. But the later RET reads eight bytes from RSP. Unless a caller or test harness supplied those stack bytes, the return destination remains unknown. We can state the experiment as a small transition system:
Here is register and temporary state, is memory, and identifies the current P-code operation or machine instruction. The function is only defined for the inputs the operation actually has. For our RET, that includes eight readable stack bytes. We can calculate ZF from two registers without knowing the stack; we cannot calculate the return destination from those registers alone. This distinction prevents a local result from being mistaken for a whole-program result.
Ghidra's own debugger emulation guide offers a useful warning: emulating a program image only approximates the operating system's loader, external library linkage needs additional modeling, and some uninitialized reads can appear as stale zero values in that workflow. The same guide notes that some language specifications were optimized for decompilation and can contain user operations an emulator cannot execute without handlers. VoidStar's password-cracking walkthrough is a practical example of the power of seeded emulation: when the state and scope are chosen carefully, P-code can evaluate a difficult routine without running the entire target program.
When reading an emulation result, ask four questions in order:
- Which instruction and P-code operation did it start from? An address alone can hide multiple operations and instruction-local branches.
- Which register and memory bytes were supplied? A register name is not a value, and a mapped page is not necessarily initialized data.
- Which external effects were modeled? Imports, system calls, and
CALLOTHERdo not acquire behavior merely because execution reached them. - What was actually observed? A successful local path shows behavior for that seed. It does not prove that every path has been explored.
A brief note on HydIR#
HydIR uses Ghidra automatically as a frontend and imports raw P-code with source bytes, addresses, varnodes, operation order, and register-space information. The raw operations feed its Rust semantics and bounded lift; Ghidra's optional high P-code is kept as separate decompiler evidence. This follows the division above: a concise high-level value is useful for explanation, while the ordered raw effects are what a concrete executor must account for.
For the TEST example, the useful HydIR question is specific: given RDI = 0x48 and RSI = 0x08, does the modeled path set ZF to zero and fall through at 0x2013d9? With RDI = 0x40, does it take the branch? Both paths were run against the saved fixture. If a necessary memory byte, user operation, or target is unknown, the result reports that source site instead of inventing a continuation. Readers who want the implementation can inspect the exporter and P-code model.
P-code is valuable because it makes small effects visible. Once you learn to read a varnode's space and width, follow an output into the next operation, and distinguish instruction translation from decompiler analysis, a mysterious line of C becomes a chain you can inspect. That skill transfers to emulator scripts, slicing, decompiler debugging, and any framework built on Ghidra's frontend.
Reading list#
- Ghidra's SLEIGH manual, operation reference, pseudo operations, and analysis-only operations: the definitive definitions used throughout this article.
- Emulating Ghidra's PCode: Why/How: a worked introduction to seeded P-code emulation.
- Ghidra's debugger emulation guide and VoidStar's scripting walkthrough: practical state, environment, and user-operation examples.
- Implementing a brainfuck CPU in Ghidra, part 2: a small SLEIGH example that shows where unique varnodes come from and how to view raw P-code.
- A Gentle Introduction to Static Analysis with Ghidra Pcode and using high P-code to detect patterns: practical high-P-code dataflow examples.
- NCC Group's earlyremoval investigation and PracticalPCode: examples of what high-P-code transformation can hide and what it can make tractable.
- A Formal Semantics for P-Code: a research treatment of high-P-code semantics.
- LLVM Language Reference and GetElementPtr FAQ: the precise contracts behind the P-code-to-LLVM comparison.
The concrete TEST sequence and operation counts above come from HydIR's checked-in Ghidra snapshot, examined at repository revision 34dbb5b. The flag-name transcription is editorial; the snapshot retains the exact register and unique-space offsets.
