← / → to navigate · C for contents
Binary Translation for Android

ARM64 x86_64
How Digitalis Works

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.

Guest · ARM64 Host · x86_64 Built on Berberis · AOSP

00 What we'll cover

Ten parts, one journey

0

Foundations

Machine code, registers, ISAs — everything the rest assumes. Skippable if you know it.

I

The Problem

Why ARM64-only apps fail on x86_64 emulators.

II

History

Houdini, android-x86, NativeBridge, Berberis — and their cousins.

III

Two Worlds

Registers, flags, and encodings: what actually differs.

IV

Translation

Interpreter, JIT, regions, the cache, three gears.

V

The Framework

NativeBridge, app startup, the guest world.

VI

Inside the Engine

Registers, decoding, code generation, faults.

VII

Talking to the Host

Proxy libraries, syscalls, standalone binaries.

VIII

All Together

A Vulkan triangle traced end to end.

IX

Questions

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.

Part 0

Foundations

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.

  • Q
    What is a computer actually doing when it "runs" an app?
  • Q
    Why can't the same app run on any chip?
  • Q
    What would it even mean to translate a program that is already finished?

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

The code you write is not the code that runs

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.

// what a person writes int add(int a, int b) { return a + b; } // what the compiler emits for an ARM64 chip — two 32-bit numbers, and that is the entire function 0x0B010000 0xD65F03C0 // the same two numbers, spelled the way humans read them ADD W0, W0, W1 ; put the sum of the two arguments in the answer slot RET ; go back to whoever called

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 CPU is a very fast, very literal loop

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.

1 · Fetch
Read the next instruction from memory, at the address held in the program counter
2 · Decode
Work out what those bits mean: which operation, on which registers
3 · Execute
Do it — add, load, store, jump — then advance the program counter and repeat

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

Registers: the desk the CPU works on

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.

the analogy

Desk and filing cabinet

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.

the consequence

Everything passes through the desk

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.

<1ns
to read a register
~1ns
L1 cache
~80ns
main memory

Remember the desk metaphor: how many sheets each architecture gives you is the single biggest headache in this whole project.

0 Foundations · Instruction sets

An instruction set is a vocabulary — and chips speak different ones

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.

phones, tablets, Apple silicon

ARM64 — also called AArch64

laptops, desktops, servers, cloud CI

x86_64 — also called AMD64

Every instruction is exactly 4 bytes wide
Instructions run from 1 to 15 bytes
31 general-purpose registers to work with
16 general-purpose registers
Designed late, kept deliberately regular
Grown since 1978, layered and irregular

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

One task, two spellings

"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.

// ARM64 — names three registers, so both inputs survive ADD X1, X2, X3 ; X1 = X2 + X3 stored as the number 0x8B030041 // x86_64 — only two operands, so the destination is also an input mov rcx, rdx ; copy the first number into place… add rcx, rsi ; …then add the second onto 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

How a CPU remembers the last comparison

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.

step 1

Compare

Subtract one value from the other and throw the answer away — keeping only a few bits describing it.

the scrap

Flags

Was the result zero? Negative? Did it overflow? Four bits on ARM64, named N, Z, C, V.

step 2

Branch

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

Which part of an app is tied to a chip?

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++.

portable

Java / Kotlin

Ships as bytecode. The Android runtime compiles it for whatever CPU it finds. Never the problem.

chip-specific

Native libraries (.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

Phones are ARM. Development machines are not.

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.

what apps are built for

The phone

what developers actually own

The emulator host

An ARM64 processor, near-universally
An x86_64 laptop, workstation or cloud CI runner
Runs the app's native libraries directly
Cannot read a single byte of ARM64 machine code

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

Three ways to run code built for another chip

Only one of them is available to somebody who has an APK and no source code.

option 1

Rebuild it

Recompile the source for x86_64. Perfect results — and impossible: you do not have the source of somebody else's app.

option 2

Simulate the chip

Write a program that imitates an ARM processor in software. Always works; historically 10–100× slower, because every instruction costs many.

option 3

Translate the code

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

The interpreter and the translator

Two ways to handle a foreign language, and the same trade-off a human would face.

simple, universal, slow

Interpreting

more work up front, then fast

Translating

Take one sentence, understand it, act on it. Repeat.
Translate the whole page once, then read the translation.
Every instruction re-examined every single time it runs
Examined once; the result is kept and reused
A loop running a million times costs a million examinations
A loop running a million times is translated once

Real systems use both: translate what runs often, interpret the rare and awkward. Digitalis has three such gears — Part IV.

0 Foundations · At runtime

"At runtime" means there is no build step

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.

The app asks to run some ARM64 code
a function it has never reached before
Digitalis translates that stretch, right then
a few microseconds of work, once
The x86_64 result is stored and jumped into
every later visit reuses it at full native speed

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

Translating the instructions is the easy half

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.

the desk problem

31 slots into 16

ARM64 code expects 31 registers. The host has 16, some of them already spoken for. Something has to give.

the library problem

Calling into the system

The app calls the graphics driver, the C library, the audio stack — all of which exist on this machine only as x86_64 code.

the honesty problem

Crashes and signals

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

System calls: the one door out of a program

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.

// ARM64 asks the kernel to write 3 bytes to output MOV X8, #64 ; 64 = "write", on ARM64 Linux SVC #0 ; the door: hand control to the kernel // x86_64 asks for the very same thing, differently mov eax, 1 ; 1 = "write", on x86_64 Linux syscall

Different instruction, different numbering, sometimes differently-shaped arguments. Every one of these has to be caught and rewritten — Part VII.

0 Foundations · Vocabulary

Ten words the rest of the deck assumes

  • guest
    the ARM64 world being emulated — the app's code, registers and memory
  • host
    the real x86_64 machine and its operating system, doing the actual work
  • register
    one numbered slot inside the CPU holding a single value
  • PC
    program counter — the register holding the address of the next instruction
  • ISA
    instruction set architecture: the vocabulary of one chip family, e.g. ARM64
  • ABI
    the naming and calling conventions binaries must agree on, e.g. arm64-v8a
  • JIT
    just-in-time: generating machine code while the program runs, not before
  • region
    a straight run of guest instructions translated together as one unit
  • syscall
    a program's request to the operating system to do something for it
  • SIMD
    one instruction operating on several numbers at once — NEON on ARM64, SSE on x86_64

0 Foundations · How to read this deck

Three depths — take the one you want

the story

Parts I–II, and IX

The problem, where the idea came from, and the questions everyone asks. No assembly required.

the machinery

Parts III–V

How the two architectures differ, how translation works, and how Android lets a translator plug in at all.

the engine room

Parts VI–VIII

Register allocation, code generation, faults, proxy libraries. Written for people who will read the source afterwards.

amber = guest, ARM64 steel = host, x86_64 ← → arrow keys to move

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.

Part I

The Problem

An app compiled for one processor, asked to run on another — and the wall that puts up.

  • Q
    Why do some apps refuse to even install on an emulator?
  • Q
    What exactly inside an APK is CPU-specific?
  • Q
    What does "translating a binary" even mean?

I The Problem

An ARM64 app, an x86_64 machine, a wall between them

Android emulators — on laptops, cloud CI, and dev machines — run on x86_64. Many apps are built only for ARM64. Those two facts collide.

Who ships ARM64-only

Games, camera apps, and anything using Vulkan graphics frequently include no x86_64 build — real phones are ARM, so why bother?

What happens

On an x86_64 emulator the app crashes at launch — or refuses to install at all.

Why it matters

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

Two kinds of code live inside every app

Runs anywhere

Java / Kotlin bytecode

Portable. The Android runtime (ART) turns it into whatever the local CPU speaks — no problem on any device.

CPU-specific

Native libraries (.so)

Compiled straight to ARM64 machine code and packed under lib/arm64-v8a/. Only an ARM chip can run these bytes.

my_app.apk ├── classes.dex Java/Kotlin bytecode — runs everywhere ├── lib/ │ ├── arm64-v8a/ ARM64 native libraries │ │ └── libgame.so │ └── x86_64/ x86_64 native libraries — often MISSING └── res/ resources

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

Two ways the wall shows itself

at install time

The polite failure

The package manager compares the APK's native-library folders against the device's supported ABIs, finds no overlap, and rejects the install.

at run time

The rude failure

A multi-ABI app installs, but a plugin or downloaded module is ARM64-only — the process dies the moment that library loads.

# Installing an ARM64-only APK on a stock x86_64 emulator: $ adb install game.apk Failure [INSTALL_FAILED_NO_MATCHING_ABIS: Failed to extract native libraries] # Or, if it gets further — the runtime crash: java.lang.UnsatisfiedLinkError: dlopen failed: "libgame.so" has unexpected e_machine: 183

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

Binary translation: a live interpreter for CPUs

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.

Read

Take the ARM64 machine code the app shipped, exactly as-is.

Translate

Produce equivalent x86_64 code that does the same thing.

Run

Execute that on the real host CPU, at close to native speed.

Q
"Is this the same as an emulator like QEMU?"
Same family, different point on the spectrum. Full-system emulators simulate a whole machine — CPU, devices, memory map. Digitalis translates only the app's code and borrows everything else (kernel, libraries, GPU) from the real host. Less work per instruction, much closer 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

Guest and Host

The visitor

Guest — the world being emulated

The environment

Host — the machine doing the work

The ARM64 app: its code, registers, and memory
The real x86_64 CPU and operating system
Believes it's running on an ARM chip
Provides the environment; runs the translated code
"Guest loader" · "guest address space" · "guest registers"
"Host libraries" · "host kernel" · "host registers"
  • register
    a tiny, ultra-fast storage slot built into the CPU that holds one number
  • PC
    the program counter — the register holding the address of the instruction about to run
  • syscall
    how a program asks the OS kernel for things it can't do itself (files, network, time)
  • ABI
    the contract for how arguments and return values are passed between functions

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.

Part II

A Short History

"ARM on x86" is older than Digitalis. Here's the lineage it inherits — and the cousins solving the same problem elsewhere.

  • Q
    Who did this first, and why was it closed-source?
  • Q
    What is NativeBridge, and why did Android build a standard socket for translators?
  • Q
    How does this compare to Apple's Rosetta 2?

II History · Part 1

Where "ARM on x86" began

  • ~2012
    Intel Atom Android phones arrive. Devices like the Motorola RAZR i ran Android on x86 processors. Java apps were fine — but thousands of apps carried ARM-only native libraries an x86 CPU couldn't run. Intel had a store-compatibility crisis.
  • Houdini
    Intel's answer: a proprietary translator. Intel shipped libhoudini, a closed-source ARM→x86 binary translator. Slip it under an ARM library and the calls quietly became x86. The first widely-used ARM-on-x86 layer for Android.
  • android-x86
    The community needed it too. The android-x86 project ported Android to ordinary PCs and laptops. For ARM-only apps it leaned on Houdini-style translation — the same trick, now on desktop hardware, but still dependent on a binary blob nobody could read or fix.

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

2014: Android grows a standard socket

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.

before

Bespoke hacks

Each x86 device integrated its translator its own way — fragile, device-specific, invisible to AOSP.

after

One defined seam

ART asks the bridge: "load this foreign library", "wrap this native method", "handle this crash." Any translator answering those calls just works.

consequence

Translators became swappable

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

NativeBridge grew for a decade — eight versions

The interface started minimal and absorbed a decade of hard lessons. Each version records a real problem someone hit:

VersionWhat it added — and why
v1The basics: initialize, load a library, wrap a native method.
v2Signal handling — so a crash in translated code can reach the app's own crash handler.
v3Linker namespaces — which libraries an app is allowed to see. Critical for isolation.
v4–v5Vendor & exported namespaces (Project Treble's vendor/system split).
v6Pre-fork hook for app zygotes.
v7JNI call-type info for smarter trampolines.
v8Function-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

Berberis opens the box — Digitalis widens it

  • Berberis
    Google's open-source translator in AOSP. Unlike Houdini, you can read and modify every line. It was built first for RISC-V → x86_64 and plugs into NativeBridge like any other bridge.
  • Digitalis
    The ARM64 backend for Berberis. Berberis had the engine — cache, dispatch, assembler, proxy machinery — but no ARM64 support. Digitalis adds the entire ARM64 → x86_64 half: decoder, JIT, interpreter, syscall layer, proxies.
Q
"Why start from Berberis instead of writing a translator from scratch?"
Because most of a translator is architecture-neutral plumbing: the translation cache, the dispatch loop, executable-memory management, signal routing, the NativeBridge glue, 21 proxy libraries' worth of marshalling machinery. Berberis had all of it, tested, in AOSP. Digitalis only had to supply the ARM64-shaped parts.

Same job Houdini once did — opposite philosophy: open, inspectable, extensible.

II The Lineage at a Glance

From vendor black box to open backend

~2012
Intel Houdini
Proprietary ARM→x86 translation on Atom phones.
2010s
android-x86
Android on PCs, using ARM translation for app compat.
2014
NativeBridge
Android 5.0 adds a pluggable translator interface (v1 → v8 over a decade).
2020s
Berberis
Open-source, RISC-V→x86_64 first, in AOSP.
Now
Digitalis
Adds the complete ARM64→x86_64 backend to Berberis.

Amber nodes are ARM-focused milestones; steel nodes are the open AOSP framework and its backends.

II The Cousins

The same idea, elsewhere in the industry

Binary translation quietly powers several famous migrations. Digitalis sits in a well-established family:

SystemDirectionApproach
Apple Rosetta 2x86_64 → ARM64Translates Mac apps for Apple Silicon: ahead-of-time at install, JIT for code generated at runtime.
Windows on ARMx86/x86_64 → ARM64Emulation layer with caching, so Intel-era apps run on ARM laptops.
FEX-Emux86/x86_64 → ARM64Open-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 → manyGeneral-purpose dynamic translation (TCG). Very broad, slower — built for breadth, not one polished pair.
HoudiniARM → x86Closed-source Android plug-in; the direct ancestor of this niche.
DigitalisARM64 → x86_64Open-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.

Part III

Two Different Worlds

Why translation is real work, not a relabel. What actually differs between the two architectures.

  • Q
    What does a CPU actually do all day?
  • Q
    If both CPUs can add and load, why is translating between them hard?
  • Q
    What does one real instruction look like, bit by bit?

III Two Worlds · Overview

Tidy vs. dense

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.

Guest · ARM64

Regular by design

Host · x86_64

Compact but irregular

Fixed 4-byte instructions — every one the same width
Variable-length instructions — 1 to 15 bytes each
31 general registers — lots of scratch space
16 general registers — far fewer to hand out
Load / store architecture — math happens only on registers
Memory operands — many instructions touch RAM directly
RISC — few, simple instruction shapes
CISC — many complex instruction forms

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: the CPU's scratch paper

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.

<1ns
to read a register
~100ns
to read main memory
~100×
why keeping values in registers matters so much

Each register also has narrower views of itself — and the two architectures disagree on a subtle rule about them:

ARM64
X0
W0

Writing W0 (the 32-bit half) always zeroes the upper 32 bits of X0.

x86_64
RAX
EAX
AX
AL

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

31 boxes on one side, 16 on the other

FeatureARM64 (guest)x86_64 (host)Challenge
General registers31 (X0–X30)16 (RAX–R15)Map 31 into 13 usable slots (Part VI)
SIMD registers32 × 128-bit (V0–V31)16 × 128-bit (XMM0–15)Map 32 into 16
Condition flagsNZCV — 4 bitsRFLAGS — 6+ bitsDifferent layout & carry sense
Zero registerX31 = ZR (reads 0)noneHandle "reads as 0, writes discarded" specially
Stack pointerSP (separate)RSP (one of the 16)X31 means SP or ZR depending on the instruction
Program counterPCRIPGuest 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

The four bits branches depend on

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.

ARM64

NZCV

Negative · Zero · Carry · oVerflow — four named bits, packed together in one small register.

x86_64

RFLAGS

SF, ZF, CF, OF and more — same ideas, scattered across a different register with a different layout.

The trap: what "Carry" means after a subtraction 10 - 10 on ARM64: C = 1 "no borrow happened" 10 - 10 on x86_64: CF = 0 "no borrow happened" same event — OPPOSITE bit value

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

Fixed width vs. variable width

ARM64 instruction stream — every instruction exactly 4 bytes: ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │ insn @ 0 │ insn @ 4 │ insn @ 8 │ insn @ C │ insn @10 │ └──────────┴──────────┴──────────┴──────────┴──────────┘ next instruction = current address + 4, always x86_64 instruction stream — 1 to 15 bytes each: ┌──────┬─────────────┬────┬─────────┬─────────────────┐ │ 2 B │ 5 bytes │ 1B │ 3 bytes │ 7 bytes │ │ push │ mov reg,imm │nop │ add r,r │ mov [rdi+8],rax │ └──────┴─────────────┴────┴─────────┴─────────────────┘ next instruction: must fully decode the current one first

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

Anatomy of ADD X1, X2, X3

This one instruction — "X1 = X2 + X3" — is encoded as the 32-bit word 0x8B030041. Every field lives at a fixed bit position:

31
1
sf=64-bit
30
0
op=ADD
29
0
S: no flags
28 … 24
01011
class: add/sub reg
23 22
00
shift
21
0
20 … 16
00011
Rm = X3
15 … 10
000000
imm6 = 0
9 … 5
00010
Rn = X2
4 … 0
00001
Rd = X1
  • no opcode field
    There's no single "this is ADD" number — the operation is spread across sf, op, S, and the class bits. Decoders read fields, not opcodes.
  • bits [28:25]
    The decoder's first question: these four bits route every ARM64 instruction into one of five big families (Part VI).
  • little-endian
    In the file, 0x8B030041 is stored as the bytes 41 00 03 8B — least significant first.

III Two Worlds · The same add, translated

"Add two numbers," both ways

// ARM64 — one fixed 32-bit instruction (4 bytes) ADD X1, X2, X3 ; X1 = X2 + X3 encoding: 0x8B030041 // x86_64 — the translator's equivalent (6 bytes) mov rcx, rdx ; copy X2's value into X1's slot first… add rcx, rsi ; …then add X3's. bytes: 48 89 F1 48 01 F1
ARM64 · non-destructive

ADD dest, src1, src2

Names three registers. Both sources survive; the result lands in a separate destination.

x86_64 · destructive

add dest, src

Only 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

Same intent, different spelling — a mini dictionary

OperationARM64x86_64Note
Add registersADD X1, X2, X3mov rcx,rdx · add rcx,rsicopy first (destructive ops)
Add immediateADD X1, X2, #42lea rcx, [rdx+42]LEA adds without touching flags
LoadLDR X1, [X2]mov rcx, [rdx]same idea, different encoding
StoreSTR X1, [X2]mov [rdx], rcxoperand order reverses
CompareCMP X1, X2cmp rcx, rdxflag layouts differ (carry!)
Branch if equalB.EQ labelje labeldifferent flag checks
CallBL funccall funcARM64 saves return addr in X30; x86_64 pushes it on the stack
ReturnRETretjump to X30 vs pop from stack
System callSVC #0 · nr in X8syscall · nr in RAXdifferent registers AND different numbers (Part VII)

III Two Worlds · The vector units

One instruction, four numbers at once

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.

; four separate additions, the ordinary way — four instructions FADD S0, S1, S2 FADD S3, S4, S5 ; …and so on, one number at a time ; the same four additions as ONE SIMD instruction FADD V0.4S, V1.4S, V2.4S ; ".4S" = four 32-bit floats side by side

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

The widths line up. The counts do not.

ARM64 · NEON

32 registers × 128 bits

x86_64 · SSE2

16 registers × 128 bits

V0–V31 — each holds e.g. four 32-bit floats at once
XMM0–XMM15 — exactly the same 128-bit width
Shared between SIMD and ordinary floating point
Guaranteed on every x86_64 CPU — no feature check needed
the luck

128 bits, both sides

Because the widths agree exactly, the arithmetic maps one-to-one: FADDaddps, a vector load→movdqu, zeroing→pxor. No lane has to be split or emulated.

the mismatch

32 will not fit in 16

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

What "no permanent home" costs

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.

; guest: V0 = V1 + V2, four floats at a time FADD V0.4S, V1.4S, V2.4S ; host: one useful instruction, wrapped in three bookkeeping ones movdqu xmm0, [rbp + V1_off] ; fetch guest V1 (128 bits) movdqu xmm1, [rbp + V2_off] ; fetch guest V2 addps xmm0, xmm1 ; ← the actual work: four float adds movdqu [rbp + V0_off], xmm0 ; put the answer back
  • why it is fine
    Simple, and always correct. The loads hit the innermost cache, so the cost is real but small — and Part VI's optimiser removes the redundant ones inside hot loops.
  • rbp
    A host register permanently pointing at the guest CPU record, so any guest register is one memory access away. Part VI explains the arrangement.
  • AVX unused
    Wider 256- and 512-bit host vectors exist, but 128-bit NEON semantics gain nothing from them — Digitalis deliberately stays on plain SSE.

III The guest's vocabulary · the shape of it

Six things any program ever needs to say

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.

1

Do arithmetic

Add, subtract, multiply, divide, shift, mask. The actual work.

2

Move data

Load a value from memory into a register, or store one back out.

3

Decide and jump

Compare two things, then continue somewhere else depending on the answer.

4

Do many at once

Apply the same arithmetic to four or eight numbers in one instruction — SIMD.

5

Coordinate

Agree with other CPU cores about who touches a value first: atomics and barriers.

6

Ask the system

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

ARM64's instruction families

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.

FamilyRepresentative instructionsPath in Digitalis
ArithmeticADD · SUB · ADC · SBC (+ flag-setting ADDS/SUBS)JIT — the bread and butter
Logical & shiftsAND · ORR · EOR · BIC · LSLV · LSRV · ASRV · RORVJIT
Divide & multiplyUDIV · SDIV · MUL · UMULH · SMULHJIT — with divide-by-zero guards (ARM returns 0; x86 would trap)
MemoryLDR · STR · LDP · STP — imm/register offset, pre/post-indexJIT — every access paired with fault-recovery code
Control flowB · BL · B.cond · RET · CBZ/CBNZ · TBZ/TBNZJIT — these define where regions end
Conditional selectCSEL · CSINC · CSINV · CCMPJIT
AtomicsCAS · SWP · LDADD (LSE) · LDXR/STXR exclusivesJIT — via x86 lock-prefixed instructions
SIMD / FP (NEON)FADD · FMUL · vector int ops · permute · across-lanesCommon shapes JIT; exotic shapes interpreted (next slides)
CRC32CRC32B/H/W/X + CRC32C variantsBoth JIT: CRC32C via the host’s hardware CRC; IEEE flavor via carry-less multiply
CryptoAES · SHA1/256/512 · SM3/SM4AES (AES-NI), SHA-1/SHA-256 and PMULL JIT; only SHA-512 · SM3/SM4 interpreted
SystemSVC · MRS/MSR · DMB/DSB/ISB · IC IVAUSVC inlined; barriers cost nothing; IC IVAU invalidates the cache
Newer extensionsI8MM dot/matmul · FRINT32/64 · MTE tags · RNDRDecoded 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

The translator writes only boring instructions

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.

always used

The guaranteed baseline

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.

used if present

Detected extras

A few later instructions replace whole sequences when the host has them — hardware CRC32, byte-shuffle table lookups. Checked once at startup.

deliberately unused

The wide vectors

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

x86_64's instruction families

The same strategy as a table — every family the host offers, and what Digitalis does with it. Again: skim.

FamilyRepresentative instructionsRole in Digitalis
General purposemov · add · sub · lea · and · or · shl · imul · divThe JIT's main output — nearly every guest integer op lands here
Control flowjmp · jcc (je, jne, jc…) · call · retBranches, region exits, dispatch jumps
Flag accesslahf · seto · setcc · cmovccThe NZCV packing epilogue lives on lahf + seto
Atomicslock cmpxchg · lock xadd · lock xchgBack ARM64's CAS, LDADD, and SWP one-to-one
Bit tricksbswap · bsr · popcntSingle-instruction gifts: REV→bswap, CLZ→bsr+xor
SSE / SSE2 (baseline)movdqu · movq · pxor · addps/addsd · ucomisdAll NEON and FP output — guaranteed on every x86_64 CPU
SSE3 … SSE4.2pshufb · crc32 · pclmulqdqRuntime-detected extras: table-lookup TBL→pshufb, CRC32C→crc32, PMULL→pclmulqdq
AVX / AVX2 (256-bit)vmovdqu · vaddps on YMMDetected but unused — nothing 128-bit NEON needs it for
AVX-512 (512-bit)ZMM operationsNot 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

One register, many 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:

V0.2D — two 64-bit lanes
double
 
double
V0.4S — four 32-bit lanes
float
 
float
 
float
 
float
V0.16B — sixteen 8-bit lanes
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
 
b
JIT-compiled SIMD

Lane-parallel math

Where lanes stay in place — vector add/multiply, loads/stores, zeroing, compares — one NEON op maps to one SSE op (FADD .4Saddps).

sequenced SIMD

Lane-crossing shapes

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.

Part IV

How Translation Works

Two strategies, one cache, and three gears that get chosen automatically.

  • Q
    Simulate each instruction, or compile them — and why not both?
  • Q
    How does translated code get found again the next time around?
  • Q
    Why not just translate the whole APK once, up front?

IV Strategy 1

The interpreter: simple, universal, slow

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.

1
Fetchnext ARM64 instruction
2
Decodewhat does it mean?
3
Executeupdate guest registers
4
AdvancePC += 4, repeat

It handles any instruction — but every guest instruction costs many host instructions of overhead. Typical slowdown: 10–50×.

Q
"If it's 10–50× slower, why keep an interpreter at all?"
Coverage and correctness. Rare instructions (exotic SIMD, residual crypto like SHA-512/SM3/SM4) aren't worth compiling; the interpreter runs everything, so the fast path never has to be complete to be safe. Digitalis also batches interpreter runs — reusing the decode machinery across up to 500 consecutive instructions makes the interpreter itself ~3× faster, since ~60% of its cost is setup, not execution.

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

The JIT: translate once, run native

A Just-In-Time compiler doesn't mimic instructions — it generates real x86_64 machine code and runs that directly on the host CPU.

First visit

Pay once

Analyze and translate the code. Slower than interpreting it once.

Every visit after

Reap forever

Run the cached native code — no translation overhead, near-native speed. A hot loop runs its cached code thousands of times.

Q
"Why not translate the whole APK ahead of time, at install?"
Three reasons. You can't find all the code statically — data and code interleave, and jump targets are computed at runtime. Apps generate code at runtime — JavaScript engines, ART itself, regex JITs all write fresh ARM64 that no install-time pass could have seen. And JIT cost is tiny — a region is translated once, cached, and reused; amortized over a session the up-front translation is noise. (Rosetta 2 does AOT plus a JIT for exactly this reason.)

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

Regions: straight-line runs of code

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.

; a loop body forms one region — boundaries marked ▶ ▶ loop: ◀ branch target = region START LDR X3, [X0], #8 ADD X2, X2, X3 SUBS X1, X1, #1 B.NE loop ◀ backward branch = region END ▶ MOV X0, X2 ◀ next region begins here
  • ends on
    Unconditional branch or call — the target may not be translated yet. (Conditional forward branches don't end it: the JIT emits a taken-path exit and keeps compiling.)
  • ends on
    Backward branch — loop edge; ending here guarantees pending signals get checked between iterations.
  • ends on
    Register pressure — the allocator is running low on host registers.
  • survives
    Partial success — if instruction 8 of 12 can't be compiled, the JIT still installs the working prefix (1–7); the straggler routes to the interpreter.

IV The heartbeat

The translation cache & dispatch loop

The cache maps each guest address to a host code pointer. The dispatch loopExecuteGuest() — runs forever: read the guest PC, look it up, jump to whatever's there.

Read guest PC from ThreadState Pending signals? deliver first — the handler may change the PC Cache lookup one atomic load — no lock on the fast path Jump to it indirect call into whatever code is there repeat forever — until the guest thread exits

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

How a guest address moves through the cache

Not translated first time seen Translating one thread wins; others wait Lite-translated native x86_64 · first gear Heavy-optimized recompiled faster · second gear Interpreted if the JIT can't compile it success runs hot JIT fails

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

The cheapest path that works, then promote

First gear · default

Lite JIT

Single-pass translation, fast to emit, local register allocation. Handles ~98% of the instructions a typical app executes.

Second gear · hot code

Heavy optimizer

Re-translates hot regions with global register allocation and loop optimization. Up to ~2× faster on register-heavy code.

Fallback

Interpreter

Per-instruction simulation for the rare, exotic instructions the JIT doesn’t compile.

Q
"Why not just always use the heavy optimizer?"
Because optimizing costs time up front, and most code runs once or twice. Compiling everything heavily would make app startup crawl to speed up code that never runs again. Lite-first gets the app running immediately; only regions that prove they're hot earn the expensive recompile.

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

How a hot loop earns the second gear

Every lite region carries an invocation counter. Take a 34-instruction loop body inside a real app:

runs 1 … 999 lite version executes; counter++ each time run 1000 counter crosses the gear-switch threshold (1000) → is the region ≥ 20 instructions? no → stay lite forever (too small to pay off) yes → re-translate with the heavy optimizer runs 1001 … every later run uses the optimized version cache entry: Lite-translated → Heavy-optimized
  • ≥ 20 insns
    The size gate: a 12-instruction micro-loop stays lite forever — recompiling it would cost more than it could ever save. Tiny loops are never regressed.
  • what heavy does
    Lowers the whole region into an SSA intermediate form ("every value written exactly once"), then does global register allocation, hoists loop-invariant work, and eliminates redundant guest-state loads/stores — things a one-pass translator can't see.
  • safe bail
    If heavy meets an instruction it doesn't support, the region simply settles back at lite — a missed optimization, never a crash.

IV The map so far

One decoder, three consumers

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.

ARM64 bytes 4 bytes at the guest PC Decoder bit-field parsing + semantics bridge Lite translator emits x86_64 · first gear Heavy optimizer emits SSA IR · second gear Interpreter updates guest state directly Host CPU runs the result

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.

Part V

The Framework & Startup

How Android hands a foreign-architecture app to Digitalis, and how the guest world gets built inside an x86_64 process.

  • Q
    How does Android even know to call a translator?
  • Q
    Who loads an ARM64 binary when the kernel refuses to?
  • Q
    Where does guest memory live — is there a separate "guest RAM"?

V NativeBridge

Android's plug for translators

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.

Discover & load

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.

Bridge each call

When Java calls a native method, the bridge builds a trampoline — generated glue that converts the call and enters translation.

Handle the mess

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

Four states from cold to ready

kNotSetup no bridge loaded yet kOpened library loaded, symbol found, version verified kPreInitialized code-cache dir created — still privileged kInitialized guest environment ready — translation begins kClosed load pre-init init error / unload

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

One symbol, a table of callbacks

Everything ART asks of the bridge goes through the NativeBridgeItf function table. The important entries, and what Digitalis does behind each:

CallbackWhat 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

An ARM64 environment inside an x86_64 process

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:

1

app_process

Android's process entry point — the ARM64 build of it.

2

guest vDSO

A tiny ARM64 library standing in for the kernel's fast-call page (next slides).

3

linker64

Android's real ARM64 dynamic linker — which then takes over.

  • division of labor
    TinyLoader only maps ELF segments into memory and zeroes .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.
  • the trick
    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).
  • the swap
    When the linker asks for a system library, the namespace search path leads to /system/lib64/arm64/ — where proxy libraries stand in for the host's real ones (Part VII).

V Startup · The address space

One process, two worlds — a map

There is no separate "guest memory." Everything shares one x86_64 process address space (addresses illustrative — randomized every launch):

high addresses 0x7ffd_0000 Guest ARM64 stack guest SP points here 0x7fa0_0000 Guest vDSO (ARM64) handed to guest linker 0x7f40_0000 JIT cache — R+X view CPU executes from here 0x7f30_0000 JIT cache — R+W alias JIT writes here (same memory!) 0x7f00_0000 Proxy libs (x86_64) run natively, never translated 0x7e80_0000 libberberis_arm64.so + host libc the translator itself 0x7a00_0000 Guest app .so (ARM64) execute bit STRIPPED 0x7900_0000 Guest ARM64 system libs libc, libm, libvulkan … 0x7880_0000 Guest linker64 (ARM64) resolves relocations 0x5500_0000 App heap one heap, shared by both worlds low addresses

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

Three consequences of one address space

safety

Guest code can't run raw

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.

bookkeeping

GuestMapShadow

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.

simplicity

Pointers pass through

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.

Q
"What if the app tries to read its own code?"
It works fine — the pages are still readable, and reading is all most apps do (checksums, unwinding tables). Only executing them is impossible, and the app never notices because execution is exactly what the translator intercepts.

V Startup · Crossing from Java

Trampolines: the Java → ARM64 doorway

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.

java
ART calls native methodargs in RDI, RSI, RDX…
trampoline
Marshal argumentsx86_64 ABI → ARM64 ABI (X0–X7)
guest
Enter dispatch looprun the ARM64 function
return
Result backX0 → RAX
How does the trampoline know the argument types? A "shorty" string — Dalvik's compact signature notation: "VJI" = Void fn( J = long, I = int ) → marshal one long + one int, expect no return value

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

vDSO: the syscall that isn't

The kernel maps a tiny library — the vDSO — into every process, so hot calls like gettimeofday() skip the kernel entirely:

~100ns
a real syscall's mode-switch overhead
~5ns
the same call answered in user space via vDSO
20×
why every process gets one for free
  • problem
    The host kernel maps an x86_64 vDSO — guest ARM64 code can't call it. But the guest linker expects an ARM64 vDSO; without one it may load a rogue copy with no translator hooks.
  • solution
    TinyLoader pre-loads a Berberis-provided ARM64 vDSO and hands its address to the guest linker through the auxiliary vector (AT_SYSINFO_EHDR) — exactly the way the kernel would.
  • bonus
    The syscall layer answers clock_gettime / gettimeofday inline without entering the kernel — keeping the guest's cheap calls cheap under translation too.
Part VI

Inside the Engine

Register allocation, decoding, byte-level code generation, and what happens when translated code crashes.

  • Q
    How do 31 guest registers fit into 13 host slots?
  • Q
    What does one instruction's journey through the whole pipeline look like?
  • Q
    How does a crash in generated code reach the app's own crash handler?

VI The three data structures

What the whole system passes around

the guest CPU

ThreadState

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.

the routing table

TranslationCache

Guest address → host code pointer. Lock-free to read (one atomic load), locked only to install new translations. Every dispatch cycle starts here.

type safety

GuestAddr

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.

The register contract inside any JIT region: RBP → always points at ThreadState the guest CPU is one deref away RAX → holds the current guest PC so exits can report "where was I" RSP → the host stack untouched by guest logic

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

Fitting 31 guest registers into 13 host slots

Three host registers are reserved (RAX, RBP, RSP — previous slide). The remaining 13 form the mapping pool, handed out in a deliberate order:

pool order: RBX RCX RSI RDI R8 R9 R10 R11 R12 R13 R14 R15 RDX ↑ RCX early — it earns a stable mapping despite needing a save/restore around variable shifts (x86 wants counts in CL) ↑ RDX last — usually a temporary, since MUL/DIV clobber RDX:RAX
  • first touch wins
    The first 13 distinct guest registers a region touches get permanent host slots for the whole region.
  • the 14th register
    Doesn't end the region — it's spilled: each use borrows a scratch register, loads the value from ThreadState memory, and stores it back. Slower, always correct.
  • desk analogy
    A desk with room for 13 sheets of paper: need a 14th, file one back in the drawer (memory), work on the new sheet, pull the filed one out when needed again.

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 → x86_64: where everything lives

ARM64 (guest)Lives on x86_64 asDetail
X0–X30pool: RBX RCX RSI RDI R8–R15 RDXFirst 13 registers a region touches get permanent slots; the rest spill to ThreadState memory per use
SPThreadState memoryThe guest stack pointer always lives in the guest CPU record, never in a host register
PCRAX (reserved)The current guest program counter — so any exit can report "where was I"
NZCV flags16-bit word in ThreadStatePacked as N=bit 15, Z=14, C=8, V=0 — refreshed by the LAHF/SETO epilogue
V0–V31 (NEON)ThreadState v[ ] + scratch XMM0–15No 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 ThreadStateThe 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

And when a call leaves the guest world

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.

And when a CALL crosses the boundary — the ABI mapping: function args X0 … X7RDI RSI RDX RCX R8 R9 + stack (8 register args vs only 6) return value X0RAX return address X30 (register)pushed on the stack syscall number X8RAX (and the value changes!) syscall args X0 … X5RDI RSI RDX R10 R8 R9
  • 8 into 6
    ARM64 passes up to eight arguments in registers; x86_64 only six. Arguments seven and eight have to be moved onto the stack — a real conversion, not a rename.
  • the return address
    ARM64 keeps it in a register (X30); x86_64 pushes it onto the stack. Every call and every return has to translate between the two habits.
  • the syscall trap
    Both use a register for the syscall number — but the numbers themselves differ. "Write" is 64 on ARM64 Linux and 1 on x86_64. Part VII is largely about that table.

VI Register allocation · worked example

A summation loop needs no spill at all

; int64_t sum(const int64_t* p, int64_t n) X0=p X1=n MOV X2, #0 ; running sum loop: LDR X3, [X0], #8 ; X3 = *p, then p += 8 ADD X2, X2, X3 ; sum += X3 SUBS X1, X1, #1 ; n--, set flags B.NE loop ; backward branch ends the region
Guest registerRoleHost registerPool slot
X0array pointerRBX0
X3loaded valueRCX1
X2running sumRSI2
X1counterRDI3

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

Figuring out what an instruction is

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 familyExamples
100xData processing — immediateADD X1, X2, #42
101xBranches, exceptions, systemB.EQ · BL · SVC · RET
x1x0Loads and storesLDR · STR · LDP · STP
x101Data processing — registerADD X1, X2, X3
x111SIMD and floating pointFADD · FMUL · vector ops
Q
"What's the worst bug class here?"
Silent mis-routing. Families share encoding prefixes; miss one distinguishing bit and instruction A decodes as instruction B — real cases include a compare decoding as a max operation. Nothing crashes; the code just computes wrong values until, much later, corrupted data hits a memory boundary. The defense is checking every field against the ARM Architecture Reference Manual, plus one quirk: register number 31 means the stack pointer in some instructions and the always-zero register in others — per-instruction, by specification.

VI One instruction, whole pipeline

Following STR X1, [X0, #16] all the way through

A store from the app's real init code — state->device = device; in C — traced through every stage:

  • 1 · decode
    Read 4 bytes at the PC. Bits[28:25] = x1x0 → loads/stores family → STR with immediate offset. Fields: source X1, base X0, offset 16, size 64-bit.
  • 2 · bridge
    The semantics player calls the JIT's Store() with those decoded fields.
  • 3 · allocate
    X0 is already mapped to RSI, X1 to RDI (earlier instructions touched them).
  • 4 · emit
    The assembler produces mov [rsi+16], rdi — bytes 48 89 7E 10 — plus a paired recovery stub in case the address faults.
  • 5 · install
    The finished region (this store and its neighbors) lands in the TranslationCache under its guest address.
  • 6 · forever after
    Every future visit runs the cached bytes directly. No decoding, no allocation, no emission — just 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 assembler: an x86_64 encoder as an API

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:

what the JIT calls what lands in the code buffer as_.Movq(rcx, rsi) → 48 89 F1 REX.W · MOV · ModR/M as_.Addq(rcx, 42) → 48 83 C1 2A REX.W · ADD imm8 · 42 as_.Movq(mem, rcx) → 48 89 4E 18 REX.W · MOV · +disp8 buffer so far: 48 89 F1 48 83 C1 2A 48 89 4E 18
  • backpatching
    A forward jump's target doesn't exist yet — so the assembler emits a placeholder offset, records the spot, and overwrites it once the target label is bound. Like writing "see page ___" in a draft and filling in the blank when the final page number is known.
  • direct wins
    Some ARM64 instructions map beautifully: byte-reverse REV → one BSWAP; count-leading-zeros CLZBSR + XOR 63. The translator takes every gift the host offers.

VI Making bytes runnable

W^X and the dual-mapped code cache

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:

One memfd memory the OS treats like a file — so it can be mapped twice R+W view the JIT writes fresh code here R+X view the CPU executes from here No mprotect flip writes through one view are instantly visible — and runnable — through the other write execute

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

Packing x86_64 flags into NZCV — with real values

Every flag-setting guest instruction ends with a fixed little epilogue. Here it is for SUBS X0, X5, X6 computing 10 − 10:

movq rbx, rsi ; copy X5 (=10) into X0's slot subq rbx, rdi ; 10-10 = 0 → x86: ZF=1 SF=0 CF=0 OF=0 lahf ; AH ← SF,ZF,…,CF AH = 0x42 seto al ; AL ← OF = 0 andl eax, 0xC101 ; keep N,Z,C,V positions → 0x4000 (Z only) xorl eax, 0x100 ; SUB/CMP only: FLIP carry → 0x4100 movw [rbp+flags], ax ; store packed NZCV: N=0 Z=1 C=1 V=0 ✓

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

The two architectures disagree about subtraction

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.

after 10 − 10

ARM64 says C = 1

after the same subtraction

x86_64 says CF = 0

Reads the bit as "no borrow was needed"
Reads the bit as "a borrow was needed"
So an unsigned "greater or equal" branch expects C = 1
So the identical test expects CF = 0
the fix

One xor

After a subtract or compare, flip that single bit. One instruction, no branch.

the scope

Subtraction only

Additions agree on carry. Applying the flip everywhere would break them just as badly.

if you forget

A silent wrong turn

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

Two details that bite

  • why LAHF
    It grabs Sign/Zero/Carry in one instruction. A subtlety hides here: stack-pointer adjustments before LAHF must use PUSH/POP or LEA — an innocent-looking sub rsp, 8 would destroy the very flags being captured.
  • bit layout
    The packed word puts N at bit 15, Z at 14, C at 8, V at 0 — hence the 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

A crash must belong to the guest

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.

  • 1
    The translated mov rcx, [rsi] faults; the host kernel delivers SIGSEGV to Digitalis's handler.
  • 2
    The handler looks the faulting host address up in a recovery map — every JIT load/store was emitted with a paired recovery stub.
  • 3
    The stub knows the guest PC of the original LDR: it writes that PC into ThreadState, queues the signal, and exits generated code cleanly.
  • 4
    The dispatch loop sees the pending signal and calls the guest's registered handler as translated ARM64 code — with an ARM64-layout context and si_addr = 0xDEAD.
  • 5
    Handler returns → execution resumes wherever the (possibly modified) guest PC points. No handler → a tombstone is written showing the guest registers, X0 = 0xDEAD included.

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

Apps that rewrite their own code

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.

1
The app's JIT writes new ARM64 into a buffer it executed before
2
Digitalis still holds a translation of the OLD bytes at that address
3
The app announces the rewrite with IC IVAU "invalidate instruction cache by address" — ARM64 has no flush-icache syscall; this instruction IS the protocol
4
Digitalis treats it as a translation-cache invalidation the stale translation is dropped; next visit retranslates the new bytes

Treating 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.

Part VII

Talking to the Host

Don't translate what you can borrow — how guest calls reach real system libraries and the real kernel.

  • Q
    Why translate the app but not Vulkan, libc, or the GPU driver?
  • Q
    What happens to a system call whose number means something else on the host?
  • Q
    Can a plain ARM64 command-line binary run too?

VII Proxy libraries

An adapter on the boundary

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:

guest
App calls malloc(64)size in X0
proxy
libberberis_proxy_libcX0 → RDI · ARM64 ABI → x86_64 ABI
host
Real libc.somalloc runs natively
back
Pointer returnsRAX → X0 — valid as-is, one shared address space
21
proxy libraries shipped
5
graphics alone: Vulkan, EGL, GLES 1/2/3
/system/lib64/arm64/
where the guest linker finds them, ahead of any real ARM64 copy

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?

Three reasons a proxy is unavoidable

1 · registers

Arguments live elsewhere

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.

2 · stack

Different stack rules

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.

3 · structs

Layouts can differ

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.

Q
"So what fraction of a real app is actually being translated?"
Only the app's own .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: four kinds of argument

"Marshalling" = repackaging data as it crosses the boundary. Real Vulkan calls show all four cases:

struct pointers

VkInstanceCreateInfo*

The proxy reads the struct out of guest memory and passes it on — for most Vulkan structs the layout is already identical.

stack pointers

&priority

A pointer into the guest's ARM64 stack. Still just an address in the shared space — passed through, valid as-is.

opaque handles

ANativeWindow*

Host-side objects the guest only holds a token to. Passed through untouched — the guest never dereferences them.

bulk data

SPIR-V shader code

Byte blobs (compiled GPU shader programs) — handed to the host driver, which only reads them.

Q
"What about calls that go the other way — the host calling back into the app?"
Callbacks get the mirror treatment: the proxy wraps the guest function pointer in a host-callable thunk that re-enters translation. If the host fires it on a thread the guest has never seen (a binder or audio thread), the thunk auto-attaches a fresh guest context to that thread first.

VII The honest edges

When a symbol can't be auto-bridged

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:

  • safety valve
    Unmappable symbols become tripwires. A symbol the generator can't marshal is stubbed to abort with its own name in the message — so a real app hitting it produces a one-line diagnosis, not a corrupted call.
  • on demand
    Gaps are covered when real apps hit them. Hand-written trampolines fill each observed gap: JNI helpers, binder callbacks, WebView's hardware-acceleration hooks. Coverage is driven by evidence, not speculation.
  • genuinely hard
    A few shapes stay out of reach. Variadic callbacks and functions returning unknown function-pointer types can't be safely bridged — those keep their loud stubs, documented.
Q
"Why does JNIEnv* keep getting special-cased?"
Because it isn't data — it's a table of function pointers. A guest env is full of guest-callable pointers; hand it to host code unchanged and the first call through it jumps x86_64 execution into ARM64 bytes. Every JNI-touching trampoline swaps in the right world's env.

VII The honest edges · vtables

When the API isn't symbols at all

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:

  • the gap
    Methods the generator couldn't marshal became loaded guns. Each was registered as a loud abort-on-first-call stub under its wrap name — invisible to the symbol-table audit, because it never was a symbol. Nine callback registrations in OpenSL ES, and OpenMAX AL's whole interface-lookup dispatch, sat in that state.
  • the seam
    Named overrides intercept at wrap time. A registry maps each wrap name to a working replacement; when the upstream layer registers its fatal stub, the replacement is installed instead — and the displaced upstream trampoline is recorded, so an override that only extends behaviour can delegate the rest back unchanged.
  • the proof
    A host audio thread calls back into translated code. The probe registers the streaming-decode buffer-queue callback, feeds the decoder, and the callback fires — host code invoking a guest function mid-playback, the full round trip, not just a registration that returns success.
Q
"So are there any calls left that abort?"
None an app can reach. Every remaining loud stub sits behind an interface the platform itself never hands out — and the test suite pins each of those refusals, so if a future Android exposes one, it surfaces as a failing sample, not a field crash.

VII Syscall emulation · worked example

write(1, buf, 3), end to end

Libraries have proxies; the kernel gets a translation layer. Nothing lines up — different number register, different number value, different argument registers:

; ARM64 guest — write 3 bytes to stdout mov x0, #1 ; fd = 1 adr x1, buf ; buf = pointer into guest memory mov x2, #3 ; count = 3 mov x8, #64 ; write's number on ARM64 is 64 svc #0 ; trap — lowered inline by the JIT ; …re-issued to the x86_64 kernel as: RAX = 1 ; write's number on x86_64 is 1 (not 64!) RDI = 1 ; fd ← guest X0 RSI = buf ; pointer ← guest X1 — passed UNCHANGED RDX = 3 ; count ← guest X2 ; host kernel runs write(), returns 3 → guest X0
  • inline, not bailed
    The JIT compiles SVC as a direct call into the syscall translator — system calls never detour through the interpreter.
  • the non-change
    The buffer pointer crosses untouched — guest memory is host memory. Four things changed; the pointer deliberately did not.
  • structs again
    Struct-shaped results (like stat) are repacked field by field on the way out, just as proxies do.

VII Syscalls · the long tail

Where real-app compatibility is won

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:

identity

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.

threading

The futex quirk

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.

crash quality

A pipe-sizing fcntl

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.

file descriptors

Playing nice with fdsan

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

binfmt_misc: the kernel joins in

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:

# The registration, decoded — "when you see these magic bytes…" :arm64_exe:M::\x7fELF\x02\x01\x01…\x02\x00\xb7:: /system/bin/berberis_program_runner_binfmt_misc_arm64:P └─ "…run this x86_64 program instead, with the original as argv" \x7fELF = an ELF binary \x02\x00 = an executable \xb7 = machine type 0xB7 = AArch64 — the same value from the "unexpected e_machine: 183" error back on slide 6

Path A · through the app

NativeBridge intercepts an APK's native libraries — every ARM64-only app takes this route.

Path B · through the kernel

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

From execve() to the dispatch loop

  • 1
    adb shell ./my-arm64-tool — the shell calls execve() on an ARM64 ELF.
  • 2
    The kernel reads the file's first bytes and compares them against every entry registered in /proc/sys/fs/binfmt_misc/.
  • 3
    The 19 magic bytes match (\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.
  • 4
    That interpreter is 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.
  • 5
    From here it's the same machinery as an app: dispatch loop, JIT, interpreter, syscall layer, all 21 proxies. Only the front door differed.
Magic fileMatchesWhy two
arm64_exeELF + ET_EXEC + AArch64Classic fixed-address executables
arm64_dynELF + ET_DYN + AArch64Position-independent executables — the modern default for everything the NDK builds
# Registration is automatic at boot (init script, simplified): on property:ro.enable.native.bridge.exec=1 mount binfmt_misc /proc/sys/fs/binfmt_misc copy /system/etc/binfmt_misc/arm64_exe → …/binfmt_misc/register copy /system/etc/binfmt_misc/arm64_dyn → …/binfmt_misc/register

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.

Part VIII

Putting It All Together

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.

  • Q
    When the app finally runs — which subsystem is doing what, at each moment?

VIII The main loop, dissected

Three execution paths, interleaved every frame

// This ARM64 code runs on an x86_64 CPU via translation: void android_main(struct android_app* app) { while (true) { while (ALooper_pollOnce(...) >= 0) { // ← syscall path if (source) source->process(app, source); if (app->destroyRequested) return; // ← JIT path } vulkan_render_frame(&g_vulkan_state); // ← proxy path } }
JIT path

Loop logic

Comparisons, branches, pointer loads — translated once to native x86_64, cached, near-native forever after.

syscall path

Event polling

ALooper_pollOnce bottoms out in epoll_wait — number and arguments remapped inline to the host kernel.

proxy path

Rendering

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

Building a GPU pipeline across the boundary

When the window appears, vulkan_init() fires a burst of proxy calls — each one marshalled guest → host, each returning a handle in X0:

1
vkCreateInstance → vkCreateDevice create-info structs read from guest memory; a queue-priority argument even points into the guest's stack — fine, shared address space
2
vkCreateAndroidSurfaceKHR → vkCreateSwapchainKHR the window handle is a host-side object — passed through untouched
3
vkCreateShaderModule SPIR-V shader bytecode — the GPU's own program format — copied across and compiled by the host driver
4
vkCreateGraphicsPipelines the host GPU now holds a complete pipeline, built entirely by a "foreign" app

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

One frame at 60fps

sync
vkWaitForFenceswait for the GPU's last frame
jit
Build command structsplain stores — translated code
record
vkCmdDraw(3, 1, 0, 0)three vertices, one instance
submit
vkQueueSubmit→ GFXStream → host GPU
present
vkQueuePresentKHRtriangle on screen
Code in the framePathCost
Struct writes, if/else, loopsJITnear-native
vk* API callsproxysmall marshalling overhead
Vertex + fragment shadinghost GPUfull native speed
Memory barriers (DMB)JIT emits nothingfree — x86's stronger memory ordering already guarantees it
Syscallsinline remaponly during event polling — never mid-frame

VIII The expansion

What Digitalis adds to Berberis

Berberis supplied the architecture-neutral engine — cache, dispatch, assembler, exec regions, NativeBridge glue. Digitalis is the ARM64-shaped half that slots in beside it:

Decoder

Every 4-byte ARM64 encoding — including CRC32, AES/SHA/SM3/SM4 crypto, and newer extensions Berberis never decoded.

Lite JIT

The full ARM64→x86_64 code generator, tuned for 31 registers.

Heavy optimizer

The second-gear recompiler for hot regions.

Interpreter

Complete ARM64 semantics as the always-correct fallback.

Syscall layer

Number remapping plus the long tail of real-app fixes.

Proxy coverage

ARM64 marshalling for 21 libraries + gap trampolines + compatibility fixes.

Guest loader config

ARM64 namespaces, vDSO plumbing, linker configuration.

Product + runners

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 proof is the test suite

150
ARM64-only sample apps as the integration suite
~98%
of executed instructions take the fast JIT path
3
execution tiers, each independently unit-tested

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.

Q
"What happens when a sample hits an instruction the translator lacks?"
The project's standing rule: the sample is the spec. The translator catches up — the app is never edited to dodge the gap. That discipline is why coverage keeps ratcheting up instead of accumulating exceptions.
Part IX

Questions People Ask

The five questions every audience raises — answered with the machinery you now know.

  • Q
    How fast is it, really?
  • Q
    Can an app tell it's being translated — and can that break it?
  • Q
    How do you debug code that never runs as written?
  • Q
    Where are the limits?

IX Question 1

"How fast is it, really?"

There's no single number — the honest answer is a profile. Speed depends on which path each piece of work takes:

WorkloadPathCost under translation
App logic (hot loops)JIT, cachedNear-native — ~1–5 host instructions per guest instruction, no re-translation
Hot + register-heavy codeheavy tierUp to ~2× faster than the lite tier's version of the same region
GPU work (the frame itself)nativeZero translation cost — the GPU never sees guest code
System API callsproxyA small fixed marshalling cost per call
Rare instructions (exotic tail)interpreter10–50× — but by design confined to a ~2% sliver of execution
First visit to any regiontranslateOne-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

"Can the app tell? Can that break it?"

Digitalis works hard to be unobservable — and some apps work hard to observe. Both sides of that arms race, honestly:

Kept invisible

The illusion, maintained

Observable in principle

The seams that remain

uname() answers "aarch64" — the machine string anti-emulator SDKs check first
Timing — instruction-level timing differs; a determined check can measure it
Crash dumps show ARM64 registers — even the debugger plays along
Memory-map spelunking — deep inspection of the process's own maps can reveal translator artifacts
Signals arrive ARM64-shaped — and only at ARM64-legal instruction boundaries
Hardened apps — integrity SDKs jump into libraries in unusual ways; each trick needs an explicit counter-mechanism, and several have gotten one
The app's own JITs work — runtime-generated code is retranslated on invalidation
The emulator itself — build fingerprint and GPU strings already say "emulator"; the translator is rarely the weakest link
  • proof in the wild
    Real apps verified running under translation: full browsers (Firefox, Chromium-based Helium), the Unity-engine game Honkai: Star Rail, WeChat, WhatsApp, Facebook, Douyin, Kuaishou, NetEase Cloud Music, three major map apps (Amap, Baidu, Tencent), Qt's Vulkan Caps Viewer, React Native and Lynx-based UIs, plus 3DMark and Geekbench as benchmark workloads — seventeen commercial APKs — the games spanning Unity (Among Us), a custom native engine (Hill Climb Racing) and miHoYo’s (Honkai) — re-run as a regression gate on every change, several shipping aggressive integrity / anti-tamper SDKs.

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

"How do you debug code that never runs as written?"

A translated crash has two locations — the host instruction that faulted and the guest instruction it stands for. The tooling keeps both visible:

  • tombstones
    Crash dumps speak ARM64. Android's crash reporter reads the guest registers out of ThreadState and prints X0–X30 — so a crash reads like it happened on a real ARM device.
  • tracing
    A built-in flight recorder. A system property turns on per-app tracing: every region translated (with its guest address), dispatch activity, interpreter hot-spots — to a file or even a TCP socket, live.
  • disassembly
    The guest PC is the anchor. Take the faulting guest address from the trace, disassemble the app's own .so at that offset, and you're looking at the exact ARM64 instruction that misbehaved.
  • host tests
    Every bug becomes a unit test. A failing instruction gets a host-side test that feeds it through the real JIT and interpreter and compares full CPU state — including a differential fuzzer that diffs the two tiers against each other automatically.
Q
"What's the most common root cause when an app fails?"
Project experience is blunt: the bug is almost always in the translator, not the app. The top classes: an instruction decoded as its near-twin (silent wrong values), a missing instruction, a flag-handling slip, or a syscall edge case. All four are exactly what the tooling above is built to localize.

IX Question 4

"Where are the limits?"

by design

Scope, not gaps

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.

the ~2%

Interpreter-only tail

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.

edges

Bridging edge cases

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.

trajectory

The gap only narrows

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

The whole idea, assembled

  • why
    ARM64-only apps can't run on x86_64 emulators. Native .so code is CPU-specific; many apps ship ARM64 only.
  • how
    Translate the app's machine code at runtime. JIT-compile regions on first sight, cache them forever, gear up the hot ones, interpret the rare tail.
  • where
    Through Android's NativeBridge socket. The standard seam Houdini once filled — now filled openly, by Berberis.
  • what
    Digitalis = the complete ARM64 backend for Berberis. Decoder, two-gear JIT, interpreter, syscall layer, and 21 proxy libraries over a shared open-source engine.
  • proof
    150 sample apps, three tested tiers, real commercial apps. And a triangle, drawn at 60fps by a CPU that never saw the code it was given.
Guest ARM64 translated live Host x86_64

Where to learn the rest

If you want the foundations properly

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.

Cover of Code
Code
Charles Petzold · 2nd ed.
Part I — builds a computer from relays up. Assumes nothing.
Cover of Computer Systems: A Programmer’s Perspective
Computer Systems: A Programmer’s Perspective
Bryant & O’Hallaron · 3rd ed.
Parts I–II — registers, memory, machine code, on x86-64: the host side.
Cover of x64 Assembly Language Step-by-Step
x64 Assembly Language Step-by-Step
Jeff Duntemann · 4th ed.
Part IV — the friendliest on-ramp to writing x86-64 yourself, on Linux.
Cover of The Art of 64-Bit Assembly, Volume 1
Part IV — the x86-64 instruction set in full depth: the vocabulary the JIT writes.
Cover of Computer Organization and Design
Computer Organization and Design
Patterson & Hennessy · ARM ed.
Part IV — the same ground taught in ARM64: the guest side.
Cover of ARM 64-Bit Assembly Language
ARM 64-Bit Assembly Language
Pyeatt & Ughetta
Part IV — the 31 registers, NZCV flags and addressing modes, concretely.
Cover of Programming with 64-Bit ARM Assembly Language
Part IV — hands-on AArch64: write and run the guest’s instruction set yourself.
Cover of Blue Fox: Arm Assembly Internals and Reverse Engineering
Parts IV–VI — A64 encodings, flags and memory access, dissected the way a decoder sees them.
Cover of Virtual Machines
Virtual Machines
Smith & Nair
Part V — binary translation itself: caches, chaining, guest→host register mapping.
Cover of Engineering a Compiler
Engineering a Compiler
Cooper & Torczon · 3rd ed.
Part VII — SSA, register allocation and loop optimisation: the second gear.
Cover of The Linux Programming Interface
Part VIII — the syscall and signal chapters, in far more depth than anywhere else.

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).

ARM64 → x86_64 Overview