Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 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 no declared Java parameters 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). Immediate-offset addressing's base register can be SP or any general register (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) - the pattern real -O0 output constantly produces once a function has a frame pointer. Register-offset addressing and STP/LDP still require an SP base for now (documented as a scoped, not accidental, gap in translate/loadstore.c).
  • 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). The top-level entry point getTrampoline resolves must still take no declared Java parameters (parameter passing is Stage 5); functions it calls internally aren't subject to that restriction, since they receive arguments the same way real AArch64 code does (X0-X7).

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). Everything else (parameter passing beyond the implicit JNIEnv*/jclass, PC-relative data addressing, 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages