Skip to main content
Be Bitwise

What Ghidra's decompiler is doing when it shows you C

Ghidra's decompiler turns assembly into something that looks like C. The pipeline runs through a curious IR called P-code, and what it can't recover matters as much as what it can.

#ghidra#decompilation#p-code#reverse-engineering

Open a binary in Ghidra, double click a function, and the decompiler window fills with C. The output is good enough that you can forget the binary started life as 14,000 instructions of stripped, optimised x86. That convenience is also a trap. The C you're reading is a reconstruction with three or four layers of inference between it and the bytes on disk, and treating it as ground truth will burn you.

Here's what's actually happening between the disassembly and the pseudocode.

P-code is the thing in the middle

Ghidra doesn't decompile assembly directly. It first translates each instruction into a register-transfer language called P-code: small, side-effect-free operations on a uniform register/memory model. A single x86 instruction usually expands into several P-code ops. MOV EAX, [RBX+8] becomes a LOAD from a computed address into a temporary, then a COPY into EAX. ADD EAX, EBX becomes an INT_ADD and a series of flag updates: INT_SCARRY, INT_CARRY, an INT_EQUAL against zero, and so on, each writing its own boolean.

The point of P-code is that every architecture Ghidra supports (x86, ARM, MIPS, PowerPC, RISC-V, weirder ones) gets translated into the same IR. The decompiler proper only ever sees P-code. Add a new architecture and you write a Sleigh specification (Ghidra's processor description language); you don't touch the decompiler.

You can see raw P-code on any instruction. In the listing window, right-click an instruction, choose "Instruction Info", and the bottom pane shows the P-code expansion. It's verbose and unflattering. The flag updates alone are usually 80% of the volume. Most of the decompiler's job is getting rid of the noise that P-code introduces, before doing anything substantive on top.

The pipeline

After the P-code expansion, the decompiler builds a single static assignment form, where every variable is written exactly once. SSA makes dataflow tractable: if you want to know "where did this value come from?", there's one answer instead of an arbitrary set.

Then it runs simplification passes. Dead code (those flag computations whose results nobody reads) gets pruned. Constant subexpressions fold. Algebraic identities apply: x ^ x becomes 0, (x << 4) | (x >> 28) becomes a 32-bit rotate, sequences of small MOVs into adjacent stack slots collapse into a single struct copy. Compiler idioms get pattern-matched back to their high-level form. A multiply-by-constant trick becomes a multiplication; a cmov-based ternary becomes cond ? a : b.

Type propagation is its own pass. The decompiler starts with whatever it knows (function signatures from headers, calling-convention slots, the return type of malloc and friends) and pushes those types backward and forward through the SSA graph until it hits a contradiction or runs out of things to say. This is where int * becomes MyStruct *, where a stack variable acquires a type, where a void * from malloc gets a structure imposed on it the moment the result hits a known field access.

Control flow recovery is the final big pass. The CFG you started with is just basic blocks and branches. The decompiler has to identify loops, separate if/else chains from switch statements (and handle jump tables, indirect jumps, tail calls, exception edges), and produce structured C. There's a body of academic work behind this, with words like structuring, gotoless, Hammock graphs, and Ghidra implements a competent version of it. But not every CFG is structurable. When the decompiler gives up, it falls back to goto. If you see goto in Ghidra output, the underlying control flow is genuinely ugly, not arbitrary.

What the decompiler can't recover

Three categories of information aren't in the binary, and no decompiler can hallucinate them honestly.

Variable names. Local variables in stripped code have no names. Ghidra makes them up: iVar1, pcVar3, local_28. The number is a counter, the prefix encodes Ghidra's best guess at the type. They mean nothing about what the variable was originally called. Your job, as you read, is to rename them.

Source-level structure. The decompiler sees only what survived compilation. Inlined functions are gone — their bodies are part of the caller. Macro expansions are gone. Templates and generics in C++ are monomorphised, so vector<int> and vector<string> become two unrelated functions. Const qualifications and reference vs pointer distinctions in C++ disappear at the ABI layer. Whether a for loop was originally a for or a while is unrecoverable; the decompiler picks one based on heuristics.

Optimised-away code. A debug check that the optimiser proved unreachable is no longer there. A comment is no longer there (obviously). A function that got fully inlined into all its callers might leave no trace under its original name.

If you want a single rule: the decompiler's output is a hypothesis about source code that, when compiled with the same compiler at the same optimisation level, would produce a binary equivalent to this one. There are infinitely many such hypotheses. Ghidra picks a plausible one.

Reading the artefacts

The shape of decompiled C tells you what the decompiler was unsure about.

*(undefined4 *)(param_1 + 0x18) is the decompiler's way of saying "I know there's a 4-byte read at offset 0x18 of param_1, but I don't know what type that field is, and I don't know what param_1 is either." If you give it a struct definition and apply the type to param_1, this collapses to param_1->field_name. Until then, the casts are scaffolding.

(int)(char)x and similar visible-cast chains are the decompiler being precise about a width-narrowing operation that the C programmer probably wrote implicitly. They're noise from the fact that C's integer promotion rules make some narrowing implicit and the IR doesn't.

Ghost variables (an iVar1 that gets assigned and used once, then never appears again) are usually the SSA pass's intermediate values that the simplification pass couldn't eliminate cleanly. Often a sign that the surrounding code is doing something the simplifier didn't recognise — a custom calling convention, an inlined assembly fragment, an SSE intrinsic.

unaff_RBX and similar unaff_ prefixed names mean "this register was used by the function but the calling convention doesn't say it should have been preserved, so I'm flagging that something unusual happened." Custom calling conventions, hand-written assembly, or, sometimes, a Ghidra bug.

A function that takes seven arguments where you expected three is usually a calling-convention mismatch. Ghidra defaults to __stdcall or __cdecl based on the binary's platform; if the function is actually __fastcall or __thiscall or something custom, you'll see ghost arguments. Right-click the function, "Edit Function Signature", set the convention. The decompilation usually snaps into shape.

A working posture

Trust the control flow. Trust which functions call which. Be wary of types until you've imposed them. Treat variable names as placeholders. If something looks bizarre, look at the listing — the disassembly is what the binary actually does, and the decompiler is occasionally wrong in interesting ways.

The pleasant surprise is how often you can iterate. Rename a few variables, define a struct, fix a function signature, and the decompiler reruns — different output, often dramatically clearer. The tool gets better as you teach it. That feedback loop is the actual reason Ghidra is good.

Where this fits

The internals of decompilation (IRs, SSA, structuring algorithms, the difference between a sound transformation and a heuristic) are covered in Module 67: How Decompilers Work. Once you've seen the underlying algorithms, Ghidra's output stops being magic, and the artefacts start telling you exactly which pass left them behind.