Skip to content

decomp: Add ARM system intrinsic emission and robust userop handling#281

Merged
kumarak merged 10 commits into
mainfrom
kumarak/userop_classification
Jun 8, 2026
Merged

decomp: Add ARM system intrinsic emission and robust userop handling#281
kumarak merged 10 commits into
mainfrom
kumarak/userop_classification

Conversation

@kumarak

@kumarak kumarak commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

This pull request adds architecture-aware handling for system-level userops, with initial support for ARM CMSIS-Core and ACLE intrinsics. Ghidra-classified system userops are now mapped to compiler intrinsics during code generation, improving the accuracy and portability of emitted C code for ARM firmware.

Key changes include:

  • Adding a new architecture-specific intrinsic emission pipeline for system userops.
  • Mapping ARM system operations and coprocessor instructions (MCR/MRC/CDP/LDC/STC) to canonical CMSIS-Core and ACLE built-ins, including operand reordering and pointer coercion where required.
  • Extending Ghidra serialization/deserialization to propagate intrinsic metadata such as intrinsic class, mapped status, and system register information.
  • Updating intrinsic call construction to support explicit pointer casting for intrinsics requiring const void * operands.
  • Improving robustness for unknown userops by emitting opaque placeholders instead of aborting, while preserving diagnostic visibility.

kumarak and others added 9 commits June 6, 2026 20:23
Two-layer handling of CALLOTHER userops that encode privileged firmware
operations (interrupt masking, MSR/MRS special registers, CP15 coprocessor,
barriers, WFI/WFE). Previously these emitted under their raw SLEIGH names
(disableIRQinterrupts(...), coprocessor_load(...)): preserved but unreadable
and not recompilable, with no visibility into coverage gaps.

Java (classify + inventory):
- UseropClassifier.java maps each userop name to an arch-neutral taxonomy
  (irq_mask_*, sysreg_*, coproc_*, barrier_*, hint_*, trap, mode_switch,
  query, unknown) plus a decoded system_register; named CP15 coproc_*_<X>
  resolve the register from the name suffix.
- PcodeSerializer tags recognized CALLOTHER targets with
  intrinsic_class/system_register/mapped, and emits a top-level
  intrinsic_manifest via layered discovery: a low-pcode instruction walk
  (authoritative presence, catches decompiler-elided userops) unioned with
  high/synthetic userops that reach serialization, each marked with origin
  and mapped so unmapped_count surfaces coverage gaps loudly.
- An unknown userop index no longer drops the whole function; it becomes an
  opaque __patchestry_unknown_0x<hex> op, flagged in the manifest.

C++ (spelling):
- OperationTarget carries the new optional fields (parsed in JsonDeserialize).
- emit_arm_system_intrinsic maps the class to a compiler builtin (ACLE) /
  CMSIS-Core name, dispatched first in create_callother: CPS -> __disable_irq/
  __enable_irq, named M sysreg -> __get_<R>/__set_<R>, hints -> __wfi/__wfe/
  __sev/__yield/__nop, coproc LDC/STC -> __arm_ldc/__arm_stc. Barriers stay on
  the C11-fence path; generic coproc MCR/MRC (ACLE arg reorder) and non-PRIMASK
  CPS deliberately fall through rather than mis-spell.

Inline-asm implementation + register save/restore is deferred; the ACLE/CMSIS
names are the substrate it would later back.

Validated: LIT 105/105 (new callother_arm_system_intrinsics.json), gradle build
+ UseropClassifierTest, headless controller.elf (2789 fns; manifest 18 userops/
6 unmapped; setISAMode origin:low x3069 proves the layered walk), and a clang
cortex-m4 round-trip regenerating wfi/dmb/dsb/isb/mrs/msr + cpsid/cpsie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generic single-register coprocessor moves were classified (coproc_read/write/
cdp) but left on the raw registered-call path because ACLE's operand order
differs from Ghidra's SLEIGH order (opc2 is in the middle in SLEIGH, last in
ACLE). Add emit_arm_coproc, which reorders the serialized inputs and emits the
ACLE intrinsic, reusing create_intrinsic_call for decl synthesis + output
assignment:
  coprocessor_moveto(cpn,op1,op2,Rt,CRn,CRm)    -> __arm_mcr(cpn,op1,Rt,CRn,CRm,op2)
  coprocessor_movefromRt(cpn,op1,op2,CRn,CRm)   -> __arm_mrc(cpn,op1,CRn,CRm,op2)  (returns)
  coprocessor_function(cpn,op1,op2,CRd,CRn,CRm) -> __arm_cdp(cpn,op1,CRd,CRn,CRm,op2)

Arity-guarded: only the exact single-register shapes are reordered; the 3-arg
movefromRt variant falls through. The *2 forms (MCRR/MRRC/CDP2) now get distinct
classes (coproc_write2/read2/cdp2) in UseropClassifier so they cannot hit the
single-register path; they stay on the registered-call path until handled.

Validated: callother_arm_system_intrinsics.json extended with MCR/MRC ops
(opc1=2/opc2=4 make the swap visible) -> @__arm_mcr(15,2,v,1,3,4) /
@__arm_mrc(15,2,1,3,4); LIT 105/105; clang -march=armv7-a round-trip regenerates
mcr/mrc; gradle build SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coproc_load/store were spelled as __arm_ldc/__arm_stc, but their ACLE prototype
takes the address as `const void *`. patchir emits the address operand with its
varnode type (often an integer), so the emitted C failed to recompile against
<arm_acle.h> with a hard "incompatible integer to pointer conversion" error --
a silent recompile break (42 such calls in the controller binary).

Add an optional pointer_arg_index to create_intrinsic_call that forces an
explicit `(const void *)` cast on that argument, and route LDC/STC through it
with the address at index 2. The emitted call becomes
__arm_ldc(11U, 0U, (const void *)addr), which recompiles cleanly. MCR/MRC are
unaffected: their immediate fields emit as integer literals (satisfying the
constant-argument requirement) and the value/return args are integers, which
__arm_mcr/__arm_mrc accept.

Validated: the actual emitted C recompiles via clang -mcpu=cortex-m4 against
real <arm_acle.h> + CMSIS, regenerating cpsid/cpsie/ldc/mcr/mrc/mrs/msr/wfi;
LIT 105/105 (fixture now asserts the pointer-typed LDC address).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The userop classification (Java) and spelling (C++) were hard-coded to ARM32
even though the taxonomy between them is architecture-neutral. Refactor both
layers behind a registry keyed on the program's processor string so other
architectures can be added without touching the serializer or the CALLOTHER
handler. ARM32 behavior is unchanged.

Java (classify): rename UseropClassifier -> IntrinsicClassifier, now a registry
+ taxonomy holder. The ARM vocabulary moves to Arm32IntrinsicClassifier behind a
new ArchIntrinsicClassifier interface; AArch64IntrinsicClassifier is a stub
(returns unknown) wired so dispatch is exercised. IntrinsicClassifier.classify
takes the processor string (case-insensitive); PcodeSerializer threads
this.architecture into the four classify call sites.

C++ (spell): introduce an ArchSpeller registry {arch, SpellFn, primary_header}
matching the existing data-table idiom. The ARM logic becomes
arm_emit_system_intrinsic; emit_arm_system_intrinsic -> emit_system_intrinsic
dispatches on program_arch() (the arch seam that already flowed but was unused).
aarch64 speller is a fall-through stub. primary_header is reserved for the
header-emission follow-up (not consumed yet).

Validated: LIT 105/105 (ARM fixture through the new dispatch); gradle build
SUCCESSFUL (real, srcDir-redirected) + in-image build SUCCESSFUL; headless
controller.elf manifest byte-identical to pre-refactor (18 distinct, 6 unmapped,
2789 fns) -- ARM path transparent through the registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two robustness fixes around the Ghidra decompiler builtin id range
(0x10000000+), informed by checking userop.cc upstream (NationalSecurityAgency/
ghidra): Ghidra mints exactly 6 builtins, 0x10000000..0x10000005, contiguous and
unchanged through HEAD, so the next free slot is 0x10000006.

1. Collision: patchestry's synthetic exception-return ids previously sat at
   0x10000006/0x10000007 -- the next free Ghidra builtin slots. Move them to a
   dedicated PATCHESTRY_BUILTIN_BASE = 0x20000000 so a future Ghidra builtin
   cannot collide. The ids are referenced only symbolically (resolveUseropName
   switch + buildExceptionReturnOp injection), and remain >= BUILTIN_STRINGDATA
   so they still route through the generic intrinsic path; emitted names
   (exception_return[_cpsr]) are unchanged. Add GHIDRA_BUILTIN_MAX for clarity.

2. Manifest mis-flag: finalizeManifestOrigins force-marked any index
   >= 0x10000000 as mapped:true (origin synthetic), which would hide an
   unrecognized future Ghidra builtin from unmapped_count. Now only a builtin
   whose name actually resolves (resolveUseropName != null) is marked
   handled; an unresolved index in the synthetic range stays mapped:false so the
   manifest surfaces it.

Validated: gradle build SUCCESSFUL; headless controller.elf manifest unchanged
(18 distinct, 6 unmapped); makair.elf ISR tagging intact (Default_Handler/
SysTick_Handler still is_interrupt) after the id move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The serialized JSON is the AST-generation input; it should carry only what
create_callother consumes. The intrinsic_manifest block (the missing-intrinsic
inventory) is a diagnostic that no AST generator reads -- and its low-pcode-walk
entries describe userops not even used in any function. Remove it and its
machinery: discoverIntrinsicManifest (the per-instruction low-pcode walk),
recordManifestLow, finalizeManifestOrigins, serializeManifest, the ManifestEntry
type, and the intrinsicManifest field.

Kept (these ARE AST inputs): the per-op intrinsic_class/system_register/mapped
tagging on used CALLOTHER targets, the is_intrinsic declarations for used
userops, and the unknown-index stop-the-drop (__patchestry_unknown_0x<hex>, no
whole-function drop).

gradle build SUCCESSFUL; C++ LIT path (105/105) unaffected -- it never consumed
the manifest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clearer naming that mirrors the Java side: ArchIntrinsicClassifier (classify
what a userop is) <-> ArchIntrinsicEmitter (emit how the compiler sees it).
Renames the C++ per-architecture registry: ArchSpeller -> ArchIntrinsicEmitter,
SpellFn -> IntrinsicEmitFn, arch_spellers -> arch_intrinsic_emitters,
get_speller -> get_intrinsic_emitter.

Also drop the reserved primary_header field: the header-emission route is
abandoned. Intrinsics are emitted as extern declarations (create_intrinsic_call
-> get_or_create_intrinsic_decl, SC_Extern); the emitted C carries its own
extern prototypes rather than #include-ing arch headers.

No behavior change. LIT 105/105.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the flat is_known_cmsis_sysreg whitelist + "__get_"/"__set_"
name concatenation with an explicit per-register CmsisSysreg table so the
emitted accessor always matches a real CMSIS-Core builtin:

  - BASEPRI_MAX is write-only (no __get_BASEPRI_MAX)
  - IPSR/APSR/xPSR are read-only (no setters)
  - the combined PSR getter is __get_xPSR, not __get_XPSR

An absent getter/setter for the requested direction now falls through to
name-based dispatch instead of emitting a builtin that does not exist.
Latent today (the Arm32 classifier only emits BASEPRI/MSP/PSP/MSPLIM/
PSPLIM/IPSR) but guards against wrong-name emission as the classifier
grows.

Also debloat the verbose comments added with the ARM system-intrinsic
emitter across the touched files; no other behavior change.

LIT callother_arm_system_intrinsics unchanged (__set_BASEPRI, __get_IPSR,
coproc reorders, __arm_ldc pointer arg all preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ource of truth

Two design cleanups to the ARM system-intrinsic pipeline:

(#3) Remove the redundant `mapped` JSON field. The presence of
`intrinsic_class` is already the "is classified" signal, the serializer
only ever wrote `mapped:true` (and only when classified), and one of the
two serializer sites never emitted it. Drop it from the serialized JSON,
the OperationTarget struct, and the deserializer. The Java
IntrinsicClassifier.Result.mapped boolean stays -- it is real control
flow (the serialize gate), not the dead wire field.

(#4) Make cmsis_sysregs the single source of truth for register ->
accessor. The Java classifier stays structural (sysreg_read/write + raw
register); the CMSIS spelling and read-only/write-only validity live only
in the C++ table. New cmsis_sysreg_accessor() helper distinguishes
intentional fall-through (no accessor for this direction) from drift (a
register the table does not cover at all) and logs the latter loudly so a
missing row surfaces instead of silently degrading to a placeholder. The
duplicated sysreg_read/sysreg_write branches collapse into one helper
call; the Java side carries a cross-reference to the C++ table.

LIT callother_arm_system_intrinsics unchanged (all known registers spell
correctly); injecting an unknown register now warns and falls through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kumarak

kumarak commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

@claude

@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

PR Review in Progress

  • Read patchir-review skill definition
  • Read AGENTS.md for repo context
  • Gather PR diff and changed files
  • Review C/C++ changes for correctness, bugs, and security issues
  • Review Java/script changes (scope-skip per skill rules)
  • Post inline findings via GitHub inline comment tool
  • Post final verdict

View job run

Comment thread lib/patchestry/AST/IntrinsicHandlers.cpp
Comment thread lib/patchestry/AST/IntrinsicHandlers.cpp
Two fixes from the claude[bot] PR #281 review:

- LDC/STC (coproc_load/loadl/store/storel) now guards op.inputs.size() == 3
  before emitting, mirroring emit_arm_coproc's arity checks. A wrong-arity
  op falls through to the placeholder path instead of emitting a malformed
  __arm_ldc/stc call (the pointer_arg_index=2 cast would otherwise target
  the wrong input or never apply).

- cmsis_sysreg_accessor asserts klass is sysreg_read/sysreg_write before the
  getter/setter ternary, so a future caller passing an unexpected class fails
  loudly in debug instead of silently returning the setter.

Verified: existing callother_arm_system_intrinsics fixture still passes; a
2-input LDC now emits __patchestry_missing_coprocessor_load instead of a
bad-shape __arm_ldc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kumarak

kumarak commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

@claude

@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

PR Review Complete

  • Read patchir-review skill definition
  • Read AGENTS.md for repo context
  • Gather PR diff and changed files (6 in-scope C/C++ files; Java/JSON out of scope)
  • Review C/C++ changes — no compile database, clang-tidy skipped
  • Post inline findings via GitHub inline comment tool
  • Post final verdict

No new blocking issues found. The two issues flagged in the prior round — missing LDC/STC arity guard and missing sysreg-class assert — were both addressed in commits 88da519 and 48c9df9. The current head is clean. PR approved.

View job run

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found. The two previously flagged issues (missing LDC/STC arity guard, missing sysreg-class assert) were addressed in 88da519 and 48c9df9. The remaining C++ changes are correct.

@kumarak
kumarak merged commit 4acddb4 into main Jun 8, 2026
14 checks passed
@kumarak
kumarak deleted the kumarak/userop_classification branch June 8, 2026 00:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant