Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nitai

Nitai is an ARM64 to x86_64 binary translation layer for Android, built as an Android native bridge shared object: Android's runtime dlopen()s it and calls into it whenever an app ships only arm64-v8a native code on a non-arm64 device.

Current status: work in progress. libnitai.so builds, loads, and registers itself with the platform as the native bridge for arm64, and it now does real translation: it JIT-compiles a target library's arm64 machine code into equivalent x86-64 code the first time a native method is resolved, rather than leaving every callback a no-op. It's still narrow - see "What's supported" below - but it's no longer boilerplate.

Project scope and philosophy: development proceeds in two passes, not one. The first pass, for each subsystem, is to get it working end to end against real compiler output and real on-device execution - not a minimal slice of an instruction class, but its whole architectural shape (every addressing mode, every operand width, every ABI-mandated case), since a narrower slice reliably turns into a later stage's blocker rather than actually saving work (see the load/store addressing-mode note under Stage 4 in the roadmap for a concrete example). The second pass, applied as gaps are found rather than spec-read up front, is going back to fix places where Nitai's translation appears to work but actually diverges from what a real arm64 device would do - subtle flag-polarity mismatches, ABI edge cases, memory-ordering shortcuts (MFENCE for DMB/DSB/ISB is deliberately over-strong, for instance) - once a concrete case exposes the difference, rather than trying to anticipate every such gap speculatively before anything real has run. "Fail loudly instead of miscompiling" is what makes this order safe: unimplemented cases are a hard translation error, never a silently wrong answer, so shipping pass one ahead of pass two never hides a bug behind a plausible-looking result.

What's supported

The translator handles whole functions with real control flow - loops, if/else with divergent RET sites, compiler-generated branch soup - as long as they take integer-class Java parameters (up to the JVM's own 255-parameter descriptor limit) and return an integer-class value. A function's extent is discovered by following its own control flow (fall-through plus every branch edge), so no symbol sizes are needed. It currently handles:

  • Data processing: MOVZ/MOVN/MOVK, ADD/SUB (immediate, shifted register, and extended register incl. the SP-relative forms), AND/ORR/EOR (immediate and shifted register, plus BIC/ORN/EON/MVN), MUL/MADD/MSUB, SDIV/UDIV, LSL/LSR/ASR (immediate and register-amount via LSLV/LSRV/ASRV/RORV), SXTB/SXTH/SXTW, CLZ/CLS/RBIT/REV/REV16/REV32 - each at both the 64-bit (Xd) and 32-bit (Wd) operand width, with correct wraparound and zero-extend-on-write semantics for the 32-bit forms.
  • Load/store: STR/LDR (unsigned-offset, unscaled, pre/post-indexed, and register-offset addressing incl. the extended/scaled UXTW/SXTW/LSL index forms) at byte/half/word/doubleword widths, sign-extending LDRSB/LDRSH/LDRSW, and STP/LDP/LDPSW (64-bit and 32-bit register pairs) - every one of these forms accepts SP or any general register as its base (X10-X30-range bases resolve through the same spill mechanism as any other cold-register use, with pre/post-index writeback committed back through it too), covering both the frame-pointer-relative addressing real -O0 output constantly produces and register-offset/STP-LDP against an arbitrary pointer.
  • Conditionals: CMP/CMN/TST, CCMP/CCMN, and the CSEL/CSINC/CSINV/CSNEG family (including the CSET/CSETM/MVN aliases), backed by real x86 EFLAGS, at both operand widths.
  • Control flow: B, B.cond (every condition, including the AL/NV always-forms), CBZ/CBNZ and TBZ/TBNZ (both register widths, any bit number), forward and backward, with multiple RET sites - assembled in two passes with rel32 backpatching. Flag-consuming instructions after a branch-target boundary are rejected loudly rather than trusting whichever path's flags happen to arrive.
  • The quiet stuff real binaries are full of: the entire hint space (NOP, PACIASP/AUTIASP, BTI, YIELD/WFE/...) as no-ops, DMB/DSB/ISB as MFENCE, PRFM as a no-op, and BRK as a real trap (UD2).
  • Function calls: BL, BLR, and BR. Every callee gets its own translated "guest entry" (prologue + body, no JNI-specific overhead), cached process-lifetime per (library, vaddr) so a function called from several places - or recursively - is only ever translated once; the cache's slot-indirection design means a direct or mutually recursive BL resolves correctly even when its target is still mid-translation higher up the same call stack. BL bakes a direct call through the cache at translate time; BLR/BR (whose target is a runtime register value) resolve through a small always-linked C helper instead, saving and restoring every caller-saved guest register around it so the register file comes out the other side exactly as if no such call had happened. BR is a real tail call (frame torn down before the jump, so the callee's own RET returns straight to the original caller).
  • Parameter passing: getTrampoline-resolved native methods may take any number of integer-class Java parameters (Z/B/C/S/I/J/L
    • every declared type except F/D, up to the JVM's own 255-parameter descriptor limit), in addition to the implicit JNIEnv*/jclass. A small JNI adapter shuffles ART's incoming arguments - some in SysV registers, some already on ART's own incoming stack - into wherever the register allocator says the corresponding guest X2-X7 lives (via a memory staging area rather than register-to-register moves, since several of the AAPCS64/SysV register assignments genuinely cycle - X1's host is X3's source and vice versa), and, for the 7th Java parameter onward (AAPCS64's 8 integer argument registers exhausted), constructs a real incoming-stack-arguments area for the guest function's own SP-relative reads to find - the trampoline's prologue reserves that region below the X10-X30 spill block rather than overlapping it. Functions called internally via BL/BLR were never subject to the old zero-parameter restriction in the first place, since they receive arguments the same way real AArch64 code does (X0-X7, plus their own incoming stack) regardless of what the public entry point's own shorty allows.
  • Global/static data: ADR, ADRP, and the PC-relative literal-pool forms LDR/LDRSW. All four have a fully compile-time-known target - unlike BLR/BR's runtime register value, the instruction's own address and encoded displacement are both known during translation, so the resulting host pointer (library load bias + target vaddr, bounds- checked against the whole loaded image) is baked straight into the trampoline via a single MOVABS, no runtime resolver needed. The ELF loader's per-PT_LOAD image (built when the library is opened, one contiguous RW mapping with .bss's tail genuinely kernel-zeroed) is what makes this a real, persistent read or write: a static global a translated function mutates stays mutated for every later call that reads it, exactly like on real hardware.

Register allocation covers the full integer file, X0-X30 (or their W0-W30 32-bit views): X0-X9 map directly to host registers, X10-X30 are spilled to a per-call stack block instead (there aren't enough x86-64 GPRs to map all of them directly), plus three dedicated scratch registers (enough for any AArch64 instruction shape observed so far, including STP/LDP and register-offset addressing with three simultaneously-cold operands). Everything else (floating point/SIMD, 32-bit stack-pointer arithmetic) is out of scope for now and fails translation loudly rather than miscompiling. See the doc comments in include/nitai/decode/arm64.h and src/translate/ for the exact boundaries.

Source layout

  • src/decode/ - the arm64 instruction decoder, split by instruction category, dispatched from decode.c.
  • src/emit/ - the x86-64 machine code emitter.
  • src/translate/ - the actual translator, mapping decoded arm64 instructions to emitted x86-64 code. translate/cache.c holds the process-lifetime (library, vaddr) -> translated-guest-entry cache BL/BLR/BR calls go through, plus the BLR/BR run-time call-target resolver.
  • src/native_bridge.c - the NativeBridgeCallbacks entry point the platform actually calls into.
  • src/elf_loader.c - a narrow but real ELF64 loader: builds an in-memory image of every PT_LOAD segment (with .bss zero-fill and eagerly-applied dynamic relocations) from a plain file or an uncompressed APK zip entry, and resolves symbols/vaddrs against it.

Building

Requires the Android NDK and CMake/Ninja. Point ANDROID_NDK_HOME (or ANDROID_NDK_ROOT) at your NDK install, then:

./scripts/build.sh

This produces build/libnitai.so, cross-compiled for x86_64 (the container's native ISA - see comment in the script for why).

Installing on Waydroid

./scripts/install-waydroid.sh

Builds if needed, drops libnitai.so into Waydroid's system overlay, and registers the required ro.dalvik.vm.isa.arm64 / ro.dalvik.vm.native.bridge properties. Requires sudo and a Waydroid container restart to take effect.

About

A from-scratch Android native bridge that JIT-translates ARM64 shared libraries to x86-64 at load time.

Topics

Resources

Contributing

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages