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.
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
-O0output 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 implicitJNIEnv*/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 guestX2-X7lives (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.
- every declared type except
- 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: astaticglobal 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.
src/decode/- the arm64 instruction decoder, split by instruction category, dispatched fromdecode.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.cholds 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- theNativeBridgeCallbacksentry 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.bsszero-fill and eagerly-applied dynamic relocations) from a plain file or an uncompressed APK zip entry, and resolves symbols/vaddrs against it.
Requires the Android NDK and CMake/Ninja. Point ANDROID_NDK_HOME (or
ANDROID_NDK_ROOT) at your NDK install, then:
./scripts/build.shThis produces build/libnitai.so, cross-compiled for x86_64 (the
container's native ISA - see comment in the script for why).
./scripts/install-waydroid.shBuilds 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.