Skip to content

What the decompiler recovers

Level: 201 · for anyone who can build a C program and has just opened one they did not write

One line: Ghidra's decompiler gives back C that computes what the machine code computes — (a * 3 + b) / 2 reassembled from a lea, an add and a four-instruction signed shift — but not the C that was written: the file never held the parameter names, the local names or the struct, so p->y comes back as param_1[1] until you tell Ghidra about point, and a loop the optimizer replaced with a formula comes back as the formula.

The program

Four functions, each there to show one thing, and a main that calls them — demo/shapes.c:

/* Four small functions and a main that calls them -- the program this page
   builds, runs, and then hands to Ghidra's decompiler. */
#include <stdio.h>
#include <string.h>

struct point {
    int x;
    int y;
};

/* An expression the optimizer takes apart: a multiply, an add, and a signed
   division by two that becomes a shift with a sign fix-up. */
int weighted(int a, int b)
{
    return (a * 3 + b) / 2;
}

/* A loop with two locals. */
int sum_to(int n)
{
    int total = 0;
    for (int i = 1; i <= n; i++) {
        total += i;
    }
    return total;
}

/* A struct reached through a pointer: the file keeps offsets, not names. */
int norm2(const struct point *p)
{
    return p->x * p->x + p->y * p->y;
}

/* A string the library functions know the type of. */
void shout(const char *word)
{
    size_t n = strlen(word);
    printf("%s has %zu letters\n", word, n);
}

int main(void)
{
    struct point p = { 3, 4 };
    printf("weighted(4, 6) = %d\n", weighted(4, 6));
    printf("sum_to(10) = %d\n", sum_to(10));
    printf("norm2({3, 4}) = %d\n", norm2(&p));
    shout("decompiler");
    return 0;
}
Function What it is there to show
weighted an expression the compiler splits into six instructions, none of them a division
sum_to a loop with two locals, which an optimizer is free not to keep
norm2 a struct reached through a pointer: the file keeps offsets, the source kept names
shout a parameter whose type the library functions give away

What the file still knows

Build it as this page decompiles it — -O2, no -g — run it, and ask the executable what it kept. The script's names lists the exported function names with nm, minus the addresses and the leading underscore that make Linux and macOS disagree:

Verified output of decompiler_shapes_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cc -std=c17 -Wall -Wextra -O2 -o shapes shapes.c
$ ./shapes
weighted(4, 6) = 9
sum_to(10) = 55
norm2({3, 4}) = 25
decompiler has 10 letters
$ names shapes
main norm2 shout sum_to weighted 
$ grep -c -a 'has %zu letters' shapes
1
$ grep -c -a -w -E 'point|word|total' shapes
0
$ strip shapes
$ names shapes

$ ./shapes
weighted(4, 6) = 9
sum_to(10) = 55
norm2({3, 4}) = 25
decompiler has 10 letters

The five function names are in the file, and so is the format string: the program needs one at run time, and the others are there because nothing removed them. point, word and total are not in the file at all — they were the compiler's, and the compiler is finished. After strip, the function names are gone too and the program runs exactly as before. That is the edge of what any decompiler can do. It reads the file, and a name that is not in the file has to come from somewhere else: a debug-info file, a library whose prototypes Ghidra ships, or you.

What Ghidra gives back

demo/decompile.sh builds the program and hands it to Ghidra's headless analyzer with two scripts: DumpDecompiled.java writes the decompiled C of the functions it is named, and ApplyPointType.java is the next section's. The analyzer runs the same auto-analysis the Code Browser runs on import, and -deleteProject throws the project away afterwards:

#!/usr/bin/env bash
# Build shapes.c, hand the program to Ghidra's headless analyzer, and print what
# the decompiler gives back -- first as the file arrived, then after
# ApplyPointType.java tells Ghidra about struct point.
#
# Needs a Ghidra install. Homebrew's is found through `brew --prefix ghidra`;
# any other one through GHIDRA_INSTALL_DIR, the folder holding support/.
set -eu
here=$(cd "$(dirname "$0")" && pwd)
ghidra=${GHIDRA_INSTALL_DIR:-$(brew --prefix ghidra)/libexec}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT

cc -std=c17 -Wall -Wextra -O2 -o "$work/shapes" "$here/shapes.c"

"$ghidra/support/analyzeHeadless" "$work" shapes -import "$work/shapes" \
    -scriptPath "$here" \
    -postScript DumpDecompiled.java "$work/before.txt" weighted sum_to norm2 shout \
    -postScript ApplyPointType.java "$work/after.txt" \
    -deleteProject > "$work/ghidra.log" 2>&1

cat "$work/before.txt" "$work/after.txt"

Everything Ghidra printed on this page was produced on 2026-09-13 by Ghidra 12.1.3 from Homebrew on OpenJDK 25, on an x86-64 Mac running macOS 26 with Apple clang 21. CI does not run it — the runners have no Ghidra — so each block is a Real output fence that says so, and the program the fences describe is the one the verified block above built and ran.

Real output — Ghidra 12.1.3 headless on the clang -O2 build, x86-64 macOS; the first script's file, from demo/decompile.sh
// _weighted
int _weighted(int param_1,int param_2)

{
  return (param_1 * 3 + param_2) / 2;
}

// _sum_to
int _sum_to(int param_1)

{
  if (0 < param_1) {
    return (int)((ulong)(param_1 - 2) * (ulong)(param_1 - 1) >> 1) + param_1 * 2 + -1;
  }
  return 0;
}

// _norm2
int _norm2(int *param_1)

{
  return param_1[1] * param_1[1] + *param_1 * *param_1;
}

// _shout
void _shout(char *param_1)

{
  size_t sVar1;
  
  sVar1 = _strlen(param_1);
  _printf("%s has %zu letters\n",param_1,sVar1);
  return;
}

The leading underscores are Mach-O's: a C function weighted is the symbol _weighted on macOS and plain weighted on Linux, and Ghidra shows the symbol — the GCC build further down has none. Read against the list of capabilities in Ghidra's own introduction to the decompiler ↗:

Ghidra says it recovers Where it shows above
Expressions — operations the compiler split up and interleaved with others, put back into one line (param_1 * 3 + param_2) / 2: the machine code below has no division in it
Function parameters — from the calling convention two ints in weighted, one pointer each in norm2 and shout: the registers the code reads before it writes them
Data types, by propagation — from anything that already has one shout's parameter is char * and its local is size_t because Ghidra knows strlen's prototype, and the type flowed backwards from the call to the parameter
Names and signatures you applied none yet — every parameter is param_N and the struct is an int *; the next section applies one
Scoped variables — one source variable followed through stack and registers sVar1 in shout; the two locals of sum_to are missing, and The loop that is not there says why
Structure definitions param_1[1]: the offset is recovered, the field name cannot be

weighted is the one to look at closely. This is what the decompiler started from:

Real output — objdump from Apple LLVM 21 on the same build, _weighted only; the alignment padding after retq cut
0000000100000470 <_weighted>:
100000470:     	pushq	%rbp
100000471:     	movq	%rsp, %rbp
100000474:     	leal	(%rdi,%rdi,2), %ecx
100000477:     	addl	%esi, %ecx
100000479:     	movl	%ecx, %eax
10000047b:     	shrl	$0x1f, %eax
10000047e:     	addl	%ecx, %eax
100000480:     	sarl	%eax
100000482:     	popq	%rbp
100000483:     	retq

leal (%rdi,%rdi,2) is a * 3 — an address-calculation instruction used as a multiplier — addl %esi is + b, and the four after it are a signed division by two: copy the value, pull its sign bit out with shrl $0x1f, add that back, then shift right arithmetically, so that -3 / 2 is -1 as C requires and not -2. The decompiler's data-flow analysis sees one value threaded through all of them and writes it as one expression. Nothing in the file says the author wrote a division; nothing else is what those instructions compute.

Tell it what the file never held

norm2 came back as int *param_1 with param_1[1] for p->y, because the file holds an offset of four and nothing about a struct. ApplyPointType.java does through the API what the Decompiler window does by hand — a point structure in the Data Type Manager with int x at 0 and int y at 4, then Retype Variable on the parameter to point * and Rename Variable to p — and decompiles the function again:

Real output — the second script's file, same run
// _norm2, told about struct point
int _norm2(point *p)

{
  return p->y * p->y + p->x * p->x;
}

That is the loop Ghidra's introduction describes: the window redecompiles on every annotation, and a type applied to one variable reaches every expression it touches. Look at the order, though: p->y first. The source said p->x * p->x + p->y * p->y; the compiler loaded y second and added in the order it liked, and the decompiler writes what the code does, not what the text said. Nothing computes differently, and nothing will tell you which order the author typed. Program annotations affecting the decompiler ↗ lists what else can be applied — function prototypes, types on globals, comments — and each of them redecompiles the function it touches.

The loop that is not there

sum_to is a loop over two locals in the source, and the decompiler gave back an if and a formula: (n - 2)(n - 1) / 2 + 2n - 1, which for n ≥ 1 is n(n + 1) / 2 — LLVM replaced the loop with its closed form, so there is no loop, no i and no total in the file for anyone to recover. The same source built without optimization keeps them:

Real output — Ghidra 12.1.3 on the same source built with clang -O0, x86-64 macOS
// _sum_to
int _sum_to(int param_1)

{
  undefined4 local_14;
  undefined4 local_10;
  
  local_10 = 0;
  for (local_14 = 1; local_14 <= param_1; local_14 = local_14 + 1) {
    local_10 = local_14 + local_10;
  }
  return local_10;
}

Two stack locals, a for with the source's shape, and names Ghidra made from the slots' offsets in the frame, because that is all a stack slot has. undefined4 is Ghidra's four bytes, type not established: nothing in the function forces them to be int rather than unsigned, and the decompiler does not guess where it has no evidence.

Build the same source with GCC and the loop is a third shape:

Real output — Ghidra 12.1.3 on the Mac, decompiling the ELF that GCC 14.4.0 built from the same source at -O2 in Docker's gcc:14 image, x86-64
// sum_to
int sum_to(uint param_1)

{
  int iVar1;
  int iVar2;
  
  if ((int)param_1 < 1) {
    return 0;
  }
  iVar2 = 0;
  iVar1 = 1;
  if ((param_1 & 1) != 0) {
    iVar1 = 2;
    iVar2 = 1;
    if (param_1 + 1 == 2) {
      return 1;
    }
  }
  do {
    iVar2 = iVar2 + 1 + iVar1 * 2;
    iVar1 = iVar1 + 2;
  } while (iVar1 != param_1 + 1);
  return iVar2;
}

GCC unrolled the loop by two — iVar2 + 1 + iVar1 * 2 adds i and i + 1 in one step — and peeled the first iteration off when n is odd. Three compilations, three functions, and the same output from each. CI checks that for the builds it can make — the same source at -O0 and -O2, with GCC on Linux and clang on macOS — as two files that differ and print the same lines:

Verified output of decompiler_levels_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cc -std=c17 -Wall -Wextra -O0 -o shapes0 shapes.c
$ cc -std=c17 -Wall -Wextra -O2 -o shapes2 shapes.c
$ cmp -s shapes0 shapes2
exit 1
$ ./shapes0
weighted(4, 6) = 9
sum_to(10) = 55
norm2({3, 4}) = 25
decompiler has 10 letters
$ ./shapes2
weighted(4, 6) = 9
sum_to(10) = 55
norm2({3, 4}) = 25
decompiler has 10 letters

The decompiler recovered each compiler's program, not the author's. The Rust library's What the optimizer does ↗ shows the same LLVM turning a ten-element sum into movl $55, %eax.

Stripped

The function names came from the symbol table. After strip, Ghidra has nothing to name a function with but its address:

Abridged — real output, Ghidra 12.1.3 on the stripped clang -O2 build, every function; three cut, identical to the text above under FUN_ names
// FUN_100000470
int FUN_100000470(int param_1,int param_2)

{
  return (param_1 * 3 + param_2) / 2;
}

// entry
undefined8 entry(void)

{
  _printf("weighted(4, 6) = %d\n",9);
  _printf("sum_to(10) = %d\n",0x37);
  _printf("norm2({3, 4}) = %d\n",0x19);
  _printf("%s has %zu letters\n","decompiler",10);
  return 0;
}

FUN_100000470 is the function's address in the file; entry is where the load command says execution starts, since the _main symbol is gone; and _printf kept its name because an import has to be named for the dynamic linker to find it. The bodies are the same text as before — the decompiler never needed the names. And main is the optimizer's work once more: every call had constant arguments, so all four were computed at compile time, and the function that reached the file prints 9, 0x37 and 0x19. That is the FUN_00401040 and param_1 at the top of Ghidra's introduction: not a failure of analysis, but a file with nothing better in it.

If you are coming from another language

Rust. The same LLVM behind rustc, so a summing loop folds the same way — the Rust library's What the optimizer does ↗ watches a ten-element sum become a constant. Its assembly listing also shows what a Rust binary keeps that a C binary does not: a mangled name that encodes the module path and the generic arguments, so an unstripped Rust function arrives in Ghidra with more of its name. Past the name, it is the same problem.

Python. There is nothing to recover, because nothing was thrown away: a function's parameter and local names ride along in its code object, and the bytecode refers to them by name.

Verified output of decompiler_names_kept_py.py — regenerated by tools/run_examples.py, never hand-typed.

sum_to(10)  = 55
co_varnames = ('n', 'total', 'i')
co_names    = ('range',)

ABAP. (Not machine-checked — CI cannot run ABAP.) Nothing to decompile either, for the opposite reason: the source is the object of record, stored in the repository beside the generated load, and the kernel regenerates the load from the source whenever it is stale. The decompiler's problem — machine code and no source — is a state an ABAP system is not supposed to reach.

See also

  • Ghidra's help at the installed tag: the introduction ↗, concepts ↗ and annotations ↗ — the pages this lesson checks against; the Help menu of a running Ghidra shows them rendered
  • DecompInterface — the class both scripts use — and the headless analyzer's README ↗, which ships as support/analyzeHeadlessREADME.html inside the install, for the -import, -scriptPath and -postScript options
  • ELF ↗ and Mach-O ↗ in the encodings library — the two containers the builds on this page came in, and where each keeps the symbol table that strip empties
  • Makefiles — where -O2 comes from in a real build, and what CFLAGS reaches