Running apps built for one kind of processor on a completely different one — translated instruction by instruction, while the app runs, without the app ever knowing.
00 What we'll cover
Machine code, registers, ISAs — everything the rest assumes. Skippable if you know it.
Why ARM64-only apps fail on x86_64 emulators.
Houdini, android-x86, NativeBridge, Berberis — and their cousins.
Registers, flags, and encodings: what actually differs.
Interpreter, JIT, regions, the cache, three gears.
NativeBridge, app startup, the guest world.
Registers, decoding, code generation, faults.
Proxy libraries, syscalls, standalone binaries.
A Vulkan triangle traced end to end.
Speed, detection, limits, debugging — asked and answered.
Written for readers new to CPUs and assembly — Part 0 starts from nothing. Amber = ARM64 (guest); steel = x86_64 (host) — used consistently throughout.
Nothing assumed. What a program actually is by the time a processor runs it, and the handful of words the rest of this deck leans on.
Already fluent in registers, ABIs and JITs? Skip to Part I — nothing here is needed again except the vocabulary slide at the end.
0 Foundations · What a program becomes
You write text. A compiler turns that text into machine code — numbers a specific processor recognises as commands. By the time an app ships, the readable part is gone.
Those eight bytes are the product. The app store ships bytes like these; no phone ever sees your source code — and the mnemonics above are a convenience for readers, not something the chip stores.
0 Foundations · What a processor does
A processor has no idea what an app is. It repeats three steps, billions of times a second, and never looks further ahead than the next command.
Everything in this deck is a consequence of step 2: the bits only mean something to a chip that was built to read them that way.
0 Foundations · Registers
A processor cannot do arithmetic on memory the way you might imagine. It works on a tiny set of named slots built into the silicon — registers — each holding one number.
Registers are the desk: a few sheets of paper, instantly to hand. Memory is the filing cabinet across the room — vastly bigger, far slower to reach.
To add two numbers from memory a CPU must fetch both onto the desk, add, and file the result back. The desk is small, so the traffic is constant.
Remember the desk metaphor: how many sheets each architecture gives you is the single biggest headache in this whole project.
0 Foundations · Instruction sets
The list of commands a processor understands, and the exact bit patterns that mean each one, is its instruction set architecture — its ISA. Two ISAs matter here.
Neither is "better" here. They are simply different languages — and a program is written in exactly one of them.
0 Foundations · The same idea, twice
"Add the numbers in two slots and put the answer in a third." Both chips can do it. Neither can read the other's version of it.
Same intent, different grammar — and one ARM64 instruction already needed two x86_64 ones. Hold on to that; it is the shape of the whole problem.
0 Foundations · Flags
Programs branch: if this is bigger, then do that. A processor splits that into two steps, and keeps a scrap of memory between them called the flags.
Subtract one value from the other and throw the answer away — keeping only a few bits describing it.
Was the result zero? Negative? Did it overflow? Four bits on ARM64, named N, Z, C, V.
A conditional jump reads those bits and decides whether to take the branch.
Both architectures have flags. They do not agree on what the bits mean — subtraction sets the carry bit oppositely. That mismatch gets a slide of its own later.
0 Foundations · What an app ships
Most Android code is not machine code. It ships as portable bytecode that the phone finishes compiling on arrival — so it runs anywhere. The exception is the part written in C or C++.
Ships as bytecode. The Android runtime compiles it for whatever CPU it finds. Never the problem.
.so)Already machine code when the app is built. Game engines, video codecs, camera pipelines, and anything using Vulkan live here.
An app declares which chips its native code supports by which folders it packs — lib/arm64-v8a/, lib/x86_64/. That naming is called an ABI.
0 Foundations · The mismatch
The Android emulator is not a simulation of a phone's processor — it runs at full speed precisely because it uses the real CPU of the machine it is on. Which is almost never an ARM chip.
So an ARM64-only app meets an x86_64 emulator and simply stops. Making that meeting work is the entire project.
0 Foundations · The options
Only one of them is available to somebody who has an APK and no source code.
Recompile the source for x86_64. Perfect results — and impossible: you do not have the source of somebody else's app.
Write a program that imitates an ARM processor in software. Always works; historically 10–100× slower, because every instruction costs many.
Read the ARM64 instructions and write out equivalent x86_64 ones, then run those on the real CPU at full speed. This is binary translation.
Option 3 is what Digitalis does. The rest of the deck is how.
0 Foundations · Interpret vs. translate
Two ways to handle a foreign language, and the same trade-off a human would face.
Real systems use both: translate what runs often, interpret the rare and awkward. Digitalis has three such gears — Part IV.
0 Foundations · At runtime
Nothing is converted ahead of time. The app is installed exactly as its author shipped it, and translation happens in the fraction of a second before each piece of code first runs — then never again for that piece.
This is why the app cannot tell. There is no patched APK, no repackaging, no second copy — the original bytes are what got installed.
0 Foundations · Where the hard part hides
If a program only did arithmetic, this deck would be short. Real code constantly asks the world around it for things — and that world is now the wrong shape.
ARM64 code expects 31 registers. The host has 16, some of them already spoken for. Something has to give.
The app calls the graphics driver, the C library, the audio stack — all of which exist on this machine only as x86_64 code.
When translated code faults, the app must see an ARM64-shaped crash, at an ARM64 address — not the truth.
Parts V through VII are almost entirely about these three, not about instructions.
0 Foundations · Asking the operating system
A program cannot touch a file, a screen or a network by itself. It must ask the operating system, through a system call — and the asking is itself a machine instruction.
Different instruction, different numbering, sometimes differently-shaped arguments. Every one of these has to be caught and rewritten — Part VII.
0 Foundations · Vocabulary
arm64-v8a0 Foundations · How to read this deck
The problem, where the idea came from, and the questions everyone asks. No assembly required.
How the two architectures differ, how translation works, and how Android lets a translator plug in at all.
Register allocation, code generation, faults, proxy libraries. Written for people who will read the source afterwards.
The colours are used consistently from here on: if something is amber it belongs to the app's world, if it is steel it belongs to the machine.
An app compiled for one processor, asked to run on another — and the wall that puts up.
I The Problem
Android emulators — on laptops, cloud CI, and dev machines — run on x86_64. Many apps are built only for ARM64. Those two facts collide.
Games, camera apps, and anything using Vulkan graphics frequently include no x86_64 build — real phones are ARM, so why bother?
On an x86_64 emulator the app crashes at launch — or refuses to install at all.
Developers and CI pipelines can't test a huge slice of the app ecosystem on the hardware they actually have.
Digitalis makes that app run anyway — by translating its ARM64 code to x86_64 at runtime. The app doesn't know the difference.
I The Problem · Anatomy of an app
Portable. The Android runtime (ART) turns it into whatever the local CPU speaks — no problem on any device.
.so)Compiled straight to ARM64 machine code and packed under lib/arm64-v8a/. Only an ARM chip can run these bytes.
A well-behaved app ships both folders. The problem apps ship only arm64-v8a/ — and then there is simply no native code an x86_64 CPU can execute.
I The Problem · What failure looks like
The package manager compares the APK's native-library folders against the device's supported ABIs, finds no overlap, and rejects the install.
A multi-ABI app installs, but a plugin or downloaded module is ARM64-only — the process dies the moment that library loads.
e_machine: 183 is 0xB7 — the ELF header's code for AArch64. The loader is saying: "this file is for a CPU I am not."
I The Core Idea
ARM64 and x86_64 are two languages processors speak. Both can add, load, branch, and call — they just spell every operation differently. A binary translator reads one and produces the equivalent in the other, on the fly.
Take the ARM64 machine code the app shipped, exactly as-is.
Produce equivalent x86_64 code that does the same thing.
Execute that on the real host CPU, at close to native speed.
Like a simultaneous interpreter at a conference — not for spoken languages, but for CPU instruction sets.
I The Vocabulary That Runs Through Everything
Like a guest staying in someone's home. These words appear in nearly every part of the system — keep them straight and the rest follows.
"ARM on x86" is older than Digitalis. Here's the lineage it inherits — and the cousins solving the same problem elsewhere.
II History · Part 1
Houdini worked — but it was closed-source and vendor-controlled. You couldn't read it, fix it, or extend it. That constraint shaped everything that came next.
II History · Part 2
With Android 5.0, Google built NativeBridge into the runtime (ART): a pluggable interface that any binary translator can attach to. AOSP defines the socket; a vendor supplies the translator behind it.
Each x86 device integrated its translator its own way — fragile, device-specific, invisible to AOSP.
ART asks the bridge: "load this foreign library", "wrap this native method", "handle this crash." Any translator answering those calls just works.
Houdini became one plug-in among possible many — and an open-source plug-in became feasible.
The turning point: "run foreign CPU code" becomes a defined extension point in Android, not a per-device hack.
II History · The socket matured
The interface started minimal and absorbed a decade of hard lessons. Each version records a real problem someone hit:
| Version | What it added — and why |
|---|---|
| v1 | The basics: initialize, load a library, wrap a native method. |
| v2 | Signal handling — so a crash in translated code can reach the app's own crash handler. |
| v3 | Linker namespaces — which libraries an app is allowed to see. Critical for isolation. |
| v4–v5 | Vendor & exported namespaces (Project Treble's vendor/system split). |
| v6 | Pre-fork hook for app zygotes. |
| v7 | JNI call-type info for smarter trampolines. |
| v8 | Function-pointer detection — the version Digitalis implements (compatible back to v2). |
We'll meet the actual callbacks — initialize, loadLibraryExt, getTrampoline…, getSignalHandler — in Part V.
II History · Part 3
Same job Houdini once did — opposite philosophy: open, inspectable, extensible.
II The Lineage at a Glance
Amber nodes are ARM-focused milestones; steel nodes are the open AOSP framework and its backends.
II The Cousins
Binary translation quietly powers several famous migrations. Digitalis sits in a well-established family:
| System | Direction | Approach |
|---|---|---|
| Apple Rosetta 2 | x86_64 → ARM64 | Translates Mac apps for Apple Silicon: ahead-of-time at install, JIT for code generated at runtime. |
| Windows on ARM | x86/x86_64 → ARM64 | Emulation layer with caching, so Intel-era apps run on ARM laptops. |
| FEX-Emu | x86/x86_64 → ARM64 | Open-source Linux JIT, best known for running Windows games through Wine on ARM desktops. Its thunks hand OpenGL and Vulkan calls straight to the host's native drivers — the same trick as this project's proxy libraries. |
| QEMU (user mode) | many → many | General-purpose dynamic translation (TCG). Very broad, slower — built for breadth, not one polished pair. |
| Houdini | ARM → x86 | Closed-source Android plug-in; the direct ancestor of this niche. |
| Digitalis | ARM64 → x86_64 | Open-source JIT + interpreter, one architecture pair, deeply integrated with Android via NativeBridge. |
Note the directions: Rosetta and Windows translate toward ARM (new hardware, old apps); Android emulation translates away from ARM (ARM apps, x86 hardware). Same machinery, mirrored motive.
Why translation is real work, not a relabel. What actually differs between the two architectures.
III Two Worlds · Overview
Underneath every app, a CPU runs a stream of simple operations: arithmetic, loads and stores, comparisons, branches, system calls. Both architectures do all of them — with opposite temperaments.
Same capabilities, opposite temperaments — each row is one difference the translator must reconcile; the rest of this part walks through them.
III Two Worlds · Registers
Registers are labeled boxes inside the CPU, each holding one number. Reading one takes under a nanosecond; reaching main memory takes ~100× longer. Every arithmetic operation works on registers.
Each register also has narrower views of itself — and the two architectures disagree on a subtle rule about them:
Writing W0 (the 32-bit half) always zeroes the upper 32 bits of X0.
Writing EAX zero-extends; writing AX or AL does not.
The translator must reproduce ARM64's zero-extension rule exactly — get it wrong and later 64-bit math sees leftover garbage in the top half.
III Two Worlds · The register mismatch
| Feature | ARM64 (guest) | x86_64 (host) | Challenge |
|---|---|---|---|
| General registers | 31 (X0–X30) | 16 (RAX–R15) | Map 31 into 13 usable slots (Part VI) |
| SIMD registers | 32 × 128-bit (V0–V31) | 16 × 128-bit (XMM0–15) | Map 32 into 16 |
| Condition flags | NZCV — 4 bits | RFLAGS — 6+ bits | Different layout & carry sense |
| Zero register | X31 = ZR (reads 0) | none | Handle "reads as 0, writes discarded" specially |
| Stack pointer | SP (separate) | RSP (one of the 16) | X31 means SP or ZR depending on the instruction |
| Program counter | PC | RIP | Guest PC tracked explicitly by the translator |
The count mismatch — 31 vs 16 — is one of the biggest challenges in translation, and drives the register allocator we'll meet in Part VI.
III Two Worlds · Condition flags
After arithmetic, a CPU sets condition flags as a side effect — was the result zero? negative? did it overflow? A following conditional branch tests them. Both sides have flags; they disagree on layout and one crucial meaning.
Negative · Zero · Carry · oVerflow — four named bits, packed together in one small register.
SF, ZF, CF, OF and more — same ideas, scattered across a different register with a different layout.
ARM64's Carry after subtract is the inverse of x86_64's borrow. Every translated compare must flip that bit, or "branch if carry" takes the wrong path — Part VI shows the exact fix.
III Two Worlds · Instruction encoding
Good news for the translator: the input (ARM64) is the easy one to read, and the tricky output (x86_64) only has to be generated — never decoded. One dedicated assembler component handles all the x86_64 encoding complexity.
Two lucky breaks worth naming: both architectures are little-endian (bytes stored least-significant first), so data needs no conversion — and one ARM64 instruction becomes only 1–5 x86_64 instructions, not dozens.
III Two Worlds · One instruction, bit by bit
ADD X1, X2, X3This one instruction — "X1 = X2 + X3" — is encoded as the 32-bit word 0x8B030041. Every field lives at a fixed bit position:
sf, op, S, and the class bits. Decoders read fields, not opcodes.41 00 03 8B — least significant first.III Two Worlds · The same add, translated
ADD dest, src1, src2Names three registers. Both sources survive; the result lands in a separate destination.
add dest, srcOnly two operands. The destination is also a source — the operation overwrites it. Hence the extra mov to save X2's value.
Small detail, huge cumulative effect: this three-operand vs two-operand mismatch is why translated code sprouts extra movs throughout.
III Two Worlds · The phrasebook
| Operation | ARM64 | x86_64 | Note |
|---|---|---|---|
| Add registers | ADD X1, X2, X3 | mov rcx,rdx · add rcx,rsi | copy first (destructive ops) |
| Add immediate | ADD X1, X2, #42 | lea rcx, [rdx+42] | LEA adds without touching flags |
| Load | LDR X1, [X2] | mov rcx, [rdx] | same idea, different encoding |
| Store | STR X1, [X2] | mov [rdx], rcx | operand order reverses |
| Compare | CMP X1, X2 | cmp rcx, rdx | flag layouts differ (carry!) |
| Branch if equal | B.EQ label | je label | different flag checks |
| Call | BL func | call func | ARM64 saves return addr in X30; x86_64 pushes it on the stack |
| Return | RET | ret | jump to X30 vs pop from stack |
| System call | SVC #0 · nr in X8 | syscall · nr in RAX | different registers AND different numbers (Part VII) |
III Two Worlds · The vector units
Both CPUs can apply the same arithmetic to several values in a single instruction — SIMD, "Single Instruction, Multiple Data". Graphics, audio, video and machine-learning code is built almost entirely out of it.
A vector register is one wide slot holding several numbers packed end to end. ARM64 calls its vector unit NEON; x86_64 calls its equivalent SSE.
This is where the performance of a game or a video codec actually lives — which is why a translator that handled only ordinary arithmetic would be useless in practice.
III Two Worlds · A lucky match, an awkward mismatch
Because the widths agree exactly, the arithmetic maps one-to-one: FADD→addps, a vector load→movdqu, zeroing→pxor. No lane has to be split or emulated.
Half the guest's vector registers simply have nowhere permanent to live on the host. They stay in memory, in the guest CPU record, and are fetched per operation.
Next slide: what that fetching actually costs.
III Two Worlds · Borrowing a vector register
A guest vector operation becomes a small ritual: load the two operands out of the guest CPU record into scratch host registers, do the one real instruction, store the answer back.
III The guest's vocabulary · the shape of it
A table of a dozen instruction families is coming. It is much easier to read once you notice that every one of them serves one of six intentions — and that this list would be the same for any processor ever built.
Add, subtract, multiply, divide, shift, mask. The actual work.
Load a value from memory into a register, or store one back out.
Compare two things, then continue somewhere else depending on the answer.
Apply the same arithmetic to four or eight numbers in one instruction — SIMD.
Agree with other CPU cores about who touches a value first: atomics and barriers.
Call the kernel, read a special register, flush a cache. Anything a program cannot do alone.
The translator must handle all six, and it handles them very differently — the fourth and sixth are where nearly all the difficulty lives.
III The guest's vocabulary · in full
The same six intentions, now as the architecture actually groups them — with the path each family takes through Digitalis. Skim this; do not memorise it. The one column that matters is the last.
| Family | Representative instructions | Path in Digitalis |
|---|---|---|
| Arithmetic | ADD · SUB · ADC · SBC (+ flag-setting ADDS/SUBS) | JIT — the bread and butter |
| Logical & shifts | AND · ORR · EOR · BIC · LSLV · LSRV · ASRV · RORV | JIT |
| Divide & multiply | UDIV · SDIV · MUL · UMULH · SMULH | JIT — with divide-by-zero guards (ARM returns 0; x86 would trap) |
| Memory | LDR · STR · LDP · STP — imm/register offset, pre/post-index | JIT — every access paired with fault-recovery code |
| Control flow | B · BL · B.cond · RET · CBZ/CBNZ · TBZ/TBNZ | JIT — these define where regions end |
| Conditional select | CSEL · CSINC · CSINV · CCMP | JIT |
| Atomics | CAS · SWP · LDADD (LSE) · LDXR/STXR exclusives | JIT — via x86 lock-prefixed instructions |
| SIMD / FP (NEON) | FADD · FMUL · vector int ops · permute · across-lanes | Common shapes JIT; exotic shapes interpreted (next slides) |
| CRC32 | CRC32B/H/W/X + CRC32C variants | Both JIT: CRC32C via the host’s hardware CRC; IEEE flavor via carry-less multiply |
| Crypto | AES · SHA1/256/512 · SM3/SM4 | AES (AES-NI), SHA-1/SHA-256 and PMULL JIT; only SHA-512 · SM3/SM4 interpreted |
| System | SVC · MRS/MSR · DMB/DSB/ISB · IC IVAU | SVC inlined; barriers cost nothing; IC IVAU invalidates the cache |
| Newer extensions | I8MM dot/matmul · FRINT32/64 · MTE tags · RNDR | Decoded and handled — Digitalis additions beyond upstream Berberis |
The pattern to notice: ~98% of what real apps execute lands in the JIT rows; the interpreted rows are rare by construction.
III The host's toolbox · the strategy
Reading and writing are not symmetrical. Digitalis must understand every ARM64 instruction an app might contain — but it gets to choose which x86_64 instructions it emits, and it chooses conservatively.
Plain integer instructions and SSE2 — part of the x86_64 architecture from the start, so present on every 64-bit x86 CPU ever made. Output using only these needs no feature check and always runs.
A few later instructions replace whole sequences when the host has them — hardware CRC32, byte-shuffle table lookups. Checked once at startup.
AVX and AVX-512 are detected and then ignored: ARM64's vectors are 128 bits wide, so 256-bit registers would sit half empty.
"Use the oldest instruction that does the job" sounds unambitious. It is what makes translated code run identically on a 2010 laptop and a 2025 server.
III The host's toolbox · in full
The same strategy as a table — every family the host offers, and what Digitalis does with it. Again: skim.
| Family | Representative instructions | Role in Digitalis |
|---|---|---|
| General purpose | mov · add · sub · lea · and · or · shl · imul · div | The JIT's main output — nearly every guest integer op lands here |
| Control flow | jmp · jcc (je, jne, jc…) · call · ret | Branches, region exits, dispatch jumps |
| Flag access | lahf · seto · setcc · cmovcc | The NZCV packing epilogue lives on lahf + seto |
| Atomics | lock cmpxchg · lock xadd · lock xchg | Back ARM64's CAS, LDADD, and SWP one-to-one |
| Bit tricks | bswap · bsr · popcnt | Single-instruction gifts: REV→bswap, CLZ→bsr+xor |
| SSE / SSE2 (baseline) | movdqu · movq · pxor · addps/addsd · ucomisd | All NEON and FP output — guaranteed on every x86_64 CPU |
| SSE3 … SSE4.2 | pshufb · crc32 · pclmulqdq | Runtime-detected extras: table-lookup TBL→pshufb, CRC32C→crc32, PMULL→pclmulqdq |
| AVX / AVX2 (256-bit) | vmovdqu · vaddps on YMM | Detected but unused — nothing 128-bit NEON needs it for |
| AVX-512 (512-bit) | ZMM operations | Not supported at all — the one op that wants it falls back to the interpreter |
A deliberate asymmetry: Digitalis must read every ARM64 family but only writes the host families it chooses — so it leans on the boring, universally-available ones and treats everything past SSE2 as optional acceleration.
III SIMD · lanes
A SIMD register is a 128-bit box that both architectures slice into lanes — and one instruction operates on every lane at once. ARM64 writes the slicing right into the operand name:
Where lanes stay in place — vector add/multiply, loads/stores, zeroing, compares — one NEON op maps to one SSE op (FADD .4S → addps).
Where lanes move or change width — pairwise adds, across-lanes reductions ("sum all four lanes"), widening/narrowing, permutes — x86 has no clean twin, so the JIT emits short shuffle-and-combine sequences; only the rarest shapes stay interpreted.
That JIT/interpreter split is the practical rule of thumb: lane-parallel = fast path, lane-crossing = fallback. This is where most of the "~2% interpreted" instructions come from.
Two strategies, one cache, and three gears that get chosen automatically.
IV Strategy 1
The simplest way to run foreign code is a loop that mimics each instruction, one at a time, against a software copy of the guest CPU.
It handles any instruction — but every guest instruction costs many host instructions of overhead. Typical slowdown: 10–50×.
Like cooking from a foreign-language recipe by translating each line in your head as you go. Correct, works for any recipe — but slow.
IV Strategy 2
A Just-In-Time compiler doesn't mimic instructions — it generates real x86_64 machine code and runs that directly on the host CPU.
Analyze and translate the code. Slower than interpreting it once.
Run the cached native code — no translation overhead, near-native speed. A hot loop runs its cached code thousands of times.
Like translating the whole recipe onto a card once, then cooking straight from the card every time after. Digitalis's JIT is called the Lite Translator.
IV The unit of work
The JIT doesn't translate one instruction at a time — it takes a region: a run of instructions with no branch into the middle, compiled into one block of x86_64 and cached as a unit.
IV The heartbeat
The cache maps each guest address to a host code pointer. The dispatch loop — ExecuteGuest() — runs forever: read the guest PC, look it up, jump to whatever's there.
It's an indirect jump, not a giant switch — the cache stores raw code pointers, and the loop's only explicit check is "should I stop?" Everything else routes through the pointer itself.
IV An address's life
Digitalis translates every region on first encounter — the translation threshold is zero. The bet: JIT cost is always low enough to pay off, so skip the usual "is this hot yet?" profiling for the first pass.
While an address has no real code, the cache stores a signpost instead: "not translated — compile me", "route to interpreter", "another thread is compiling — retry", or "stop, thread exiting."
IV Three gears
Single-pass translation, fast to emit, local register allocation. Handles ~98% of the instructions a typical app executes.
Re-translates hot regions with global register allocation and loop optimization. Up to ~2× faster on register-heavy code.
Per-instruction simulation for the rare, exotic instructions the JIT doesn’t compile.
Safety net: any tier that meets an instruction it can't handle drops to a slower tier that can — correctness never depends on the fast path.
IV Gearing up · a worked scenario
Every lite region carries an invocation counter. Take a 34-instruction loop body inside a real app:
IV The map so far
All three tiers share the same front door: the decoder reads ARM64 bits; a thin bridge (the "semantics player") hands each decoded instruction to whichever tier is running.
This shape has a project rule attached: when a common instruction is added or fixed, it must be covered in every tier where it's reachable — a gap in one tier degrades that tier silently.
How Android hands a foreign-architecture app to Digitalis, and how the guest world gets built inside an x86_64 process.
V NativeBridge
NativeBridge is a plugin system inside Android's runtime (ART). When it meets an app whose native libraries are the "wrong" architecture, it loads the bridge library and hands it the hard parts.
At boot, ART reads the system property ro.dalvik.vm.native.bridge, dlopens the named library — here libberberis_arm64.so — and looks up one exported symbol: NativeBridgeItf, a struct of function pointers.
When Java calls a native method, the bridge builds a trampoline — generated glue that converts the call and enters translation.
Signals, library namespaces, and faults all route through the bridge so the guest behaves like a real ARM process.
The same socket Houdini once filled — Digitalis is simply a different implementation behind it. Even installs work: if an APK has no matching ABI but a bridge is present, the package manager selects a bridge-compatible ABI instead of rejecting it.
V NativeBridge · Lifecycle
The odd-looking middle step matters: pre-initialization runs with elevated privileges — after Android's template process (Zygote) forks the app but before it drops to the app's restricted permissions — so the bridge can create its code-cache directory while it still can.
V NativeBridge · The interface
Everything ART asks of the bridge goes through the NativeBridgeItf function table. The important entries, and what Digitalis does behind each:
| Callback | What Digitalis does |
|---|---|
| initialize() | Once per app process: create the guest loader, spawn the guest thread, load the ARM64 linker, libc, and vDSO. |
| loadLibraryExt() | App loads a native library: try the guest (ARM64) loader first; fall back to host dlopen(). |
| getTrampolineWithJNICallType() | Java calls a native method: build an x86_64 wrapper that marshals arguments and enters guest execution. |
| createNamespace() / linkNamespaces() | Create paired guest + host linker namespaces and link them; whitelist the vDSO across the boundary. |
| getSignalHandler() | Hand ART Digitalis's fault handler, so a host SIGSEGV can be routed to the guest's own handler (Part VI). |
Sequence at boot: init starts Zygote → Zygote loads the bridge (property → dlopen → dlsym) → every app process forked from Zygote inherits it, already opened.
V Startup · Building the guest world
The host kernel refuses to load an ARM64 binary — wrong architecture. So Digitalis loads it itself, doing in user space what the kernel normally does. Its minimal ELF loader, TinyLoader, bootstraps exactly three files:
app_processAndroid's process entry point — the ARM64 build of it.
A tiny ARM64 library standing in for the kernel's fast-call page (next slides).
linker64Android's real ARM64 dynamic linker — which then takes over.
.bss — it deliberately skips relocations (the address patches a library needs once its load address is known). That's the guest linker's job, and TinyLoader loads that linker first.linker64 then runs under translation and loads libc and everything else via normal dynamic linking — it doesn't know it's being translated. Digitalis drives it through its exported __loader_* functions (dlopen, dlsym, create_namespace)./system/lib64/arm64/ — where proxy libraries stand in for the host's real ones (Part VII).V Startup · The address space
There is no separate "guest memory." Everything shares one x86_64 process address space (addresses illustrative — randomized every launch):
Amber regions hold ARM64 bytes (data to the host CPU); steel regions hold x86_64 the CPU actually executes. When TinyLoader maps an ARM64 segment it gets a real host address, and the guest runs "at" that address — no address translation, ever.
V Startup · Rules of the shared space
Digitalis strips the execute permission from every guest mapping (PROT_EXEC → PROT_READ). The host CPU can never stumble into ARM64 bytes — only translated x86_64 in the JIT cache is executable.
A one-bit-per-page bitmap remembers which guest pages are supposed to be executable — bookkeeping the host page tables no longer carry. The JIT consults it; guest mprotect() calls update it.
A guest address is a host address — GuestAddr is just an integer, and the conversion helpers are type-safety wrappers, not arithmetic. A buffer handed to a syscall or a proxy never needs relocating.
V Startup · Crossing from Java
When Java calls a native method, ART knows the x86_64 calling convention — but the target function is ARM64. The bridge generates a trampoline: a small x86_64 wrapper that catches the call, converts it, and enters the guest.
One pointer needs special care: JNIEnv* is itself a table of function pointers — a guest env holds guest-callable pointers, so it can't cross to host code unchanged. JNI trampolines translate it both ways.
V Startup · The fast-call page
The kernel maps a tiny library — the vDSO — into every process, so hot calls like gettimeofday() skip the kernel entirely:
AT_SYSINFO_EHDR) — exactly the way the kernel would.clock_gettime / gettimeofday inline without entering the kernel — keeping the guest's cheap calls cheap under translation too.Register allocation, decoding, byte-level code generation, and what happens when translated code crashes.
VI The three data structures
The complete guest CPU as a struct in memory: X0–X30 + SP, V0–V31, the packed NZCV flags, the program counter, thread-local storage, and a pending-signal flag. Every instruction ultimately reads and writes it.
Guest address → host code pointer. Lock-free to read (one atomic load), locked only to install new translations. Every dispatch cycle starts here.
Just an integer. Guest and host addresses are numerically identical; the conversion wrappers exist to keep the two kinds of pointer from being mixed up by accident.
Even a debugger obeys this contract: on a crash, Android's debuggerd reads the guest registers out of ThreadState (tagged with the magic string "BERBERIS") and prints an ARM64-shaped crash dump.
VI Register allocation
Three host registers are reserved (RAX, RBP, RSP — previous slide). The remaining 13 form the mapping pool, handed out in a deliberate order:
Spilling first — rather than cutting the region — keeps translated blocks long, and long blocks are what make the JIT fast.
VI Reference · the complete mapping
| ARM64 (guest) | Lives on x86_64 as | Detail |
|---|---|---|
| X0–X30 | pool: RBX RCX RSI RDI R8–R15 RDX | First 13 registers a region touches get permanent slots; the rest spill to ThreadState memory per use |
| SP | ThreadState memory | The guest stack pointer always lives in the guest CPU record, never in a host register |
| PC | RAX (reserved) | The current guest program counter — so any exit can report "where was I" |
| NZCV flags | 16-bit word in ThreadState | Packed as N=bit 15, Z=14, C=8, V=0 — refreshed by the LAHF/SETO epilogue |
| V0–V31 (NEON) | ThreadState v[ ] + scratch XMM0–15 | No persistent mapping: load into a scratch XMM, operate, store back |
| XZR / WZR | — (synthesized) | x86_64 has no zero register; reads become the constant 0, writes are discarded |
| TPIDR_EL0 (TLS) | field in ThreadState | The guest thread-pointer system register, read via MRS |
| — (the guest CPU itself) | RBP (reserved) | Always points at ThreadState — every spill/fill is one dereference away |
| — (host bookkeeping) | RSP (reserved) | The host stack, untouched by guest logic |
Three host registers are reserved outright (RAX, RBP, RSP), thirteen are pooled for guest integers, XMMs are borrowed per vector operation — and every guest register that misses out is answered from ThreadState memory.
VI Reference · crossing the boundary
The mapping above covers guest code talking to itself. The moment translated code calls a real host function — the C library, the graphics driver — a second mapping applies: the calling convention, which says where a function looks for its arguments.
VI Register allocation · worked example
| Guest register | Role | Host register | Pool slot |
|---|---|---|---|
| X0 | array pointer | RBX | 0 |
| X3 | loaded value | RCX | 1 |
| X2 | running sum | RSI | 2 |
| X1 | counter | RDI | 3 |
Four guest registers, four host slots, 9 of 13 left unused — the common case. Every iteration runs entirely out of host registers; ThreadState is touched only at region entry and exit.
The practical moral: values kept in the first few registers a function touches win the permanent slots — late arrivals pay the memory round-trip. Compiler-generated code naturally behaves this way.
VI Decoding
Before anything can be translated, the decoder reads the 32 bits and extracts operation and operands. Its first question — bits [28:25] — splits every ARM64 instruction into five families:
| Bits [28:25] | Instruction family | Examples |
|---|---|---|
| 100x | Data processing — immediate | ADD X1, X2, #42 |
| 101x | Branches, exceptions, system | B.EQ · BL · SVC · RET |
| x1x0 | Loads and stores | LDR · STR · LDP · STP |
| x101 | Data processing — register | ADD X1, X2, X3 |
| x111 | SIMD and floating point | FADD · FMUL · vector ops |
VI One instruction, whole pipeline
STR X1, [X0, #16] all the way throughA store from the app's real init code — state->device = device; in C — traced through every stage:
x1x0 → loads/stores family → STR with immediate offset. Fields: source X1, base X0, offset 16, size 64-bit.Store() with those decoded fields.mov [rsi+16], rdi — bytes 48 89 7E 10 — plus a paired recovery stub in case the address faults.mov [rsi+16], rdi.This is the entire business model of the JIT in one instruction: pay the pipeline once, keep the four bytes of output forever.
VI Making bytes
The JIT never writes hex by hand. It calls methods on an assembler object, which knows every x86_64 encoding rule — prefixes, register codes, immediate sizes:
REV → one BSWAP; count-leading-zeros CLZ → BSR + XOR 63. The translator takes every gift the host offers.VI Making bytes runnable
Modern systems enforce W^X: a memory page may be writable or executable, never both — it blocks the classic write-then-run attack, but equally blocks a well-behaved JIT. Digitalis threads the needle with one block of memory mapped twice:
Neither view ever violates W^X — no page is writable and executable — yet freshly generated code is runnable immediately. A pre-allocated code pool parcels out these regions, avoiding a system call per translation.
VI Reconciling the flags · worked trace
Every flag-setting guest instruction ends with a fixed little epilogue. Here it is for SUBS X0, X5, X6 computing 10 − 10:
Seven host instructions, of which one does the arithmetic and six exist purely so the guest's four flag bits end up correct. Read the next slide for why the sixth is the interesting one.
VI Reconciling the flags · the disagreement
Flags are the one place where the same event produces the opposite answer on each side — so copying the bits across would be silently, catastrophically wrong.
xorAfter a subtract or compare, flip that single bit. One instruction, no branch.
Additions agree on carry. Applying the flip everywhere would break them just as badly.
No crash. Every unsigned comparison in the app quietly takes the wrong branch — the hardest possible class of bug to find.
This is the deck's recurring theme in miniature: the instructions are the easy part, and the conventions around them are where correctness is won or lost.
VI Reconciling the flags · two traps
sub rsp, 8 would destroy the very flags being captured.0xC101 mask, and the final 0x4100 = "Zero set, Carry set."Both are the kind of detail that only shows up as a mysterious wrong answer thousands of instructions later.
VI When code faults · worked example
Suppose guest code runs LDR X1, [X0] with X0 = 0xDEAD — an unmapped address. The fault happens in translated code, so the host kernel raises a host SIGSEGV. But many apps install their own crash handlers; the signal must reach them, shaped like ARM64.
mov rcx, [rsi] faults; the host kernel delivers SIGSEGV to Digitalis's handler.LDR: it writes that PC into ThreadState, queues the signal, and exits generated code cleanly.si_addr = 0xDEAD.The interpreter needs the same care: its memory accesses go through special faulting load/store stubs — a raw memcpy would crash the host process with no route back to the guest's handler.
VI The cache must stay honest
JavaScript engines, ART itself, regex engines — many apps contain their own JITs, generating fresh ARM64 at runtime. That collides with a translator's central assumption: that translated code stays valid.
IC IVAU "invalidate instruction cache by address" — ARM64 has no flush-icache syscall; this instruction IS the protocolTreating IC IVAU as a harmless no-op — tempting, since x86 needs no such flush — means silently running stale translations of deleted code. A real Qt app crashed exactly this way (its regex engine regenerates code) until the invalidation was wired up.
Don't translate what you can borrow — how guest calls reach real system libraries and the real kernel.
VII Proxy libraries
Every system library the app calls — Vulkan, libc, audio — exists on the device as native x86_64. Translating those would be wasted work. Instead, a proxy library stands in for each one and reshapes the call:
Like a travel plug adapter: the electricity (your data) is the same on both sides — only the shape of the plug (which register holds each argument) differs between countries. The full set: libc, libm, libvulkan, libEGL, libGLESv1_CM/v2/v3, libaaudio, libOpenSLES, libOpenMAXAL, libamidi, libcamera2ndk, libmediandk, libandroid, libandroid_runtime, libnativewindow, libnativehelper, libjnigraphics, libbinder_ndk, libneuralnetworks, libwebviewchromium_plat_support.
VII Why not call host code directly?
ARM64's first argument rides in X0; x86_64's in RDI. A direct call would find malloc's size parameter in the wrong register — garbage in, garbage allocated.
Both want 16-byte alignment but disagree on what a call pushes: ARM64 stores its return address in register X30; x86_64 pushes it onto the stack.
Some structs — the file-info stat is the classic — order and pad fields differently per architecture. The proxy repacks them field by field, both directions.
The payoff is the whole performance story: the GPU never runs translated code. A Vulkan call crosses the proxy once, then the host graphics stack (GFXStream) and the real GPU do the heavy work at full native speed.
.so logic. Rendering, allocation, codecs, the kernel — the expensive machinery — all run native behind proxies. That's the core trick: translate the code you must, borrow everything you can.VII Inside a proxy call
"Marshalling" = repackaging data as it crosses the boundary. Real Vulkan calls show all four cases:
VkInstanceCreateInfo*The proxy reads the struct out of guest memory and passes it on — for most Vulkan structs the layout is already identical.
&priorityA pointer into the guest's ARM64 stack. Still just an address in the shared space — passed through, valid as-is.
ANativeWindow*Host-side objects the guest only holds a token to. Passed through untouched — the guest never dereferences them.
Byte blobs (compiled GPU shader programs) — handed to the host driver, which only reads them.
VII The honest edges
Proxy call tables are largely auto-generated from library headers. Some signatures defeat automation — and the design principle is to fail loudly and precisely, never mysteriously:
JNIEnv* keep getting special-cased?"VII The honest edges · vtables
Android's audio APIs (OpenSL ES, OpenMAX AL) hand the app a struct of function pointers — a vtable — and every call goes through it. There is no symbol to put in a call table; the proxy can only wrap each method lazily, by name, the moment the app first obtains the interface:
VII Syscall emulation · worked example
write(1, buf, 3), end to endLibraries have proxies; the kernel gets a translation layer. Nothing lines up — different number register, different number value, different argument registers:
SVC as a direct call into the syscall translator — system calls never detour through the interpreter.stat) are repacked field by field on the way out, just as proxies do.VII Syscalls · the long tail
Number remapping is the easy 95%. The last 5% is a collection of hard-won, very specific fixes — each one traced from a real app failing:
uname() says "aarch64"The guest asks what machine it's on. Answering truthfully ("x86_64") trips anti-emulator checks in real apps — so the syscall layer reports the machine the guest believes in.
Fast userspace mutexes compare a whole memory word before sleeping — but Android's libc uses 16-bit lock words that can sit in memory with uninitialized neighbors. The emulation compensates, or threads sleep forever.
Android's crash daemon sizes a pipe (F_SETPIPE_SZ) while streaming crash reports. When the emulation rejected that one command, every guest crash silently produced no report at all.
Guest and host share one file-descriptor table, and Android's fd-ownership checker (fdsan) aborts on wrong-owner closes. The close-family syscalls consult ownership tags before acting.
A theme worth noticing: none of these are "translate the instruction" problems — they're "be a convincing ARM64 Linux" problems. That's most of what shipping a translator actually is.
VII Beyond apps
Not everything arrives through an app. A shell command, a test binary, a subprocess spawned by execve() can also be ARM64. Linux's binfmt_misc lets the kernel recognize those and hand them to Digitalis automatically:
NativeBridge intercepts an APK's native libraries — every ARM64-only app takes this route.
adb shell ./my-arm64-tool just works: the kernel matches the magic bytes and launches the program runner, which enters the same translator.
Complementary, not alternative — both paths share the same engine, cache, and proxies. Path B is what makes ARM64 test binaries and CI tools runnable on the emulator too.
VII binfmt_misc · under the hood
execve() to the dispatch loopadb shell ./my-arm64-tool — the shell calls execve() on an ARM64 ELF./proc/sys/fs/binfmt_misc/.\x7fELF … machine 0xb7 = AArch64) — so the kernel silently rewrites the exec: it launches the registered interpreter instead, passing the original binary's path along. The P flag preserves argv[0], so the tool still sees its own name.berberis_program_runner_binfmt_misc_arm64 — a small x86_64 program that initializes the translator and asks the guest loader to start the ARM64 executable.| Magic file | Matches | Why two |
|---|---|---|
| arm64_exe | ELF + ET_EXEC + AArch64 | Classic fixed-address executables |
| arm64_dyn | ELF + ET_DYN + AArch64 | Position-independent executables — the modern default for everything the NDK builds |
Both trigger properties are set by the Digitalis product config, so registration needs no opt-in. A second runner binary exists for manual use — same engine, CLI-friendly arguments — which is how ARM64 gtest suites get run on the emulator during development.
Every piece cooperating — to draw one triangle. hello-vulkan: ~550 lines of C++, ARM64-only, rendering three colored vertices at 60fps on an x86_64 emulator.
VIII The main loop, dissected
Comparisons, branches, pointer loads — translated once to native x86_64, cached, near-native forever after.
ALooper_pollOnce bottoms out in epoll_wait — number and arguments remapped inline to the host kernel.
Every vk* call crosses the Vulkan proxy to the host graphics stack and the real GPU.
The launch itself is Part V verbatim: ART sees only arm64-v8a/, loads the bridge, the guest world is built, and a JNI trampoline enters android_main().
VIII Vulkan init · proxies in action
When the window appears, vulkan_init() fires a burst of proxy calls — each one marshalled guest → host, each returning a handle in X0:
From the host Vulkan driver's point of view, nothing unusual happened — it served a perfectly ordinary client. It cannot tell the client was ARM64.
VIII The render loop · every ~16ms
| Code in the frame | Path | Cost |
|---|---|---|
| Struct writes, if/else, loops | JIT | near-native |
| vk* API calls | proxy | small marshalling overhead |
| Vertex + fragment shading | host GPU | full native speed |
Memory barriers (DMB) | JIT emits nothing | free — x86's stronger memory ordering already guarantees it |
| Syscalls | inline remap | only during event polling — never mid-frame |
VIII The expansion
Berberis supplied the architecture-neutral engine — cache, dispatch, assembler, exec regions, NativeBridge glue. Digitalis is the ARM64-shaped half that slots in beside it:
Every 4-byte ARM64 encoding — including CRC32, AES/SHA/SM3/SM4 crypto, and newer extensions Berberis never decoded.
The full ARM64→x86_64 code generator, tuned for 31 registers.
The second-gear recompiler for hot regions.
Complete ARM64 semantics as the always-correct fallback.
Number remapping plus the long tail of real-app fixes.
ARM64 marshalling for 21 libraries + gap trampolines + compatibility fixes.
ARM64 namespaces, vDSO plumbing, linker configuration.
The emulator product config, binfmt_misc registration, and program runners.
The distribution is concrete: ~74 files — one translator library, 21 proxies, ~43 guest ARM64 system libraries, two program runners, configs and magic files.
VIII How we know it works
The samples span Vulkan, OpenGL ES, JNI, audio, camera, sensors, NEON/SIMD, CPU-extension probes, and real third-party engines — Qt, React Native, media and ML libraries — plus real prebuilt commercial apps as regression targets.
The five questions every audience raises — answered with the machinery you now know.
IX Question 1
There's no single number — the honest answer is a profile. Speed depends on which path each piece of work takes:
| Workload | Path | Cost under translation |
|---|---|---|
| App logic (hot loops) | JIT, cached | Near-native — ~1–5 host instructions per guest instruction, no re-translation |
| Hot + register-heavy code | heavy tier | Up to ~2× faster than the lite tier's version of the same region |
| GPU work (the frame itself) | native | Zero translation cost — the GPU never sees guest code |
| System API calls | proxy | A small fixed marshalling cost per call |
| Rare instructions (exotic tail) | interpreter | 10–50× — but by design confined to a ~2% sliver of execution |
| First visit to any region | translate | One-time JIT cost, then cached for the process lifetime |
The design keeps the multiplier where the time isn't: games spend their frames in the GPU and in hot cached loops — the two cheapest rows of this table. Measured anchors: GPU-bound 3DMark Wild Life Extreme scores 90% of the same machine's native run; pure-CPU Geekbench lands near one-fifth of native — and real apps live much closer to the first number than the second, because their frames are the first two rows.
IX Question 2
Digitalis works hard to be unobservable — and some apps work hard to observe. Both sides of that arms race, honestly:
When one of these breaks, the fix lands in the translator — never as a per-app hack — so each hardened app that starts working makes the next one likelier to work out of the box.
IX Question 3
A translated crash has two locations — the host instruction that faulted and the guest instruction it stands for. The tooling keeps both visible:
.so at that offset, and you're looking at the exact ARM64 instruction that misbehaved.IX Question 4
Digitalis translates ARM64 user-space code on Android. It is not a device emulator: the kernel, drivers, and GPU are the host's own. 32-bit ARM apps are outside its scope — the modern Android ecosystem is 64-bit.
Crypto instruction groups (AES/SHA/SM3/SM4), one CRC32 flavor whose polynomial the host lacks, and a handful of exotic SIMD shapes run interpreted. Correct, slower, rare.
A few callback shapes (variadic, unknown function-pointer returns) can't be safely auto-bridged; they fail loudly with the symbol's name rather than corrupting a call.
Every real app that trips something new turns into a decoder entry, a JIT lowering, a proxy trampoline, or a syscall fix — plus a regression test. The suite ratchets; coverage doesn't slide back.
A fair summary: the hard 98% — running real apps' real code fast — is done and continuously verified; the remaining work is a curated list of known, bounded edges.
✦ In one breath
.so code is CPU-specific; many apps ship ARM64 only.★ Where to learn the rest
This deck explains each idea only as far as it needs to. These go the rest of the way — ordered so each one assumes the one before it. Covers link to the book by ISBN.
If you only read two: Bryant & O’Hallaron for what a machine actually does, and Smith & Nair for what binary translation is. Between them they cover most of what this deck assumes. For the two instruction sets themselves, pair Hyde (x86-64, the language the translator writes) with Markstedter (ARM64, the language it reads).