Kakehashi: Running macOS ARM64 binaries on Linux ARM (no JIT)
The day a macOS tool “just worked” on Linux
A familiar frustration shows up the moment you switch machines: the toolchain changes, the CPU architecture changes, and the binary you wanted to run suddenly becomes “wrong platform.” One day it’s “ARM vs x86.” Another day it’s “macOS vs Linux.” And then, almost perversely, you try something like running a macOS command-line utility on Linux ARM anyway.
That’s where Kakehashi feels different. It’s an experimental userspace translation layer that targets a very specific problem: running macOS ARM64 binaries on Linux aarch64. The big twist is that it aims for native guest execution—no just-in-time compilation, no “instruction emulator.” Instead, the runtime intervenes at a few well-defined boundaries like system calls and thread start/stop.
So how does a Linux program start acting like macOS? The answer is not “translate every CPU instruction.” It’s “load the macOS binary format, provide the expected macOS runtime pieces, then translate the handful of operating-system interactions that Linux and macOS don’t share.” (github.com)
Translation layers: what Kakehashi does and what it avoids
Most approaches to cross-platform binaries fall into a spectrum:
- Instruction emulation: interpret guest CPU instructions one-by-one (often slow).
- JIT (just-in-time compilation): translate guest instructions to host instructions while running (often faster than pure emulation, but still complex).
- Syscall/ABI bridging: leave CPU execution alone, but rewrite the “contracts” between application and OS.
Kakehashi chooses the last one—at least for its core path. Its architecture explicitly says no JIT and no instruction emulator: guest ARM64 code runs natively, and the runtime steps in only at boundaries such as syscalls, thread create/terminate, and fault handling. ()
That decision shapes everything. Instead of building a CPU emulator, the project builds a loader (to understand macOS binaries) and a runtime “shim” (to convert macOS expectations into Linux realities).
The three-act architecture of Kakehashi
Kakehashi is organized into a few Rust crates that map cleanly onto how the program flows:
1) kh (the CLI-first entry point)
The user-facing binary is kh (from the kakehashi / kh-cli crate). It provides commands like bottle management (preparing the environment) and running guests like kh run 7zz....
2) kh-loader (loading Mach-O)
When you “run a binary,” two things happen:
- The loader parses the file format and finds where code/data should live.
- It resolves imports (symbols) so the binary can call into runtime functions.
kh-loader handles this for Mach-O, which is Apple’s object/binary format. (Mach-O files carry metadata and “load commands” telling the loader where to map segments, and which entry point to jump to.) Kakehashi loads Darwin Mach-O arm64 guests and enters at the Mach-O entry point (called out as LC_MAIN in the design docs). ()
3) kh-runtime (syscalls, traps, memory mapping, threads)
The runtime owns the boundaries: how guest memory maps into host memory, how guest syscalls become BSD-style operations, and how guest threads become host threads.
The special role of kh-libsystem and libSystem.B.dylib
macOS applications expect libSystem-style services (threading, libc-ish behavior, syscalls, etc.). Kakehashi calls this out as a freestanding (clean-room) artifact: it builds a libSystem.B.dylib and embeds it into the runtime.
The point isn’t to reuse Apple’s libraries directly. It’s to ship a guest-visible dylib that exposes the ABI the guest expects. ()
A mental model: “native guest, translated edges”
The easiest way to understand Kakehashi is to picture a guest process as a traveler.
- The traveler walks on the host’s streets (the CPU runs guest code natively).
- But whenever they request something from the city office—say “open this file” or “create a thread”—the runtime translates the request into a form Linux understands.
Kakehashi’s docs make this explicit in the execution pipeline: it resolves/ensures the environment (“bottle”), loads the Mach-O, binds symbols, wires up the guest-to-host boundary, sets up handlers, then jumps to the guest entry point. ()
Walking through a real command
A typical flow is:
cargo install kakehashi
kh bottle ensure
kh install 7zip
kh run 7zz -- --help
That sequence reveals the product’s shape:
kh bottle ensureprepares a guest filesystem “bottle.” (More on this soon.) (github.com)kh install 7zipdownloads a real Darwin ARM647zzbinary and places it into the guest filesystem so the Mach-O loader can find it at/usr/local/bin/7zz.kh runis the actual guest execution.
Kakehashi even tests with real workloads like multi-threaded 7zz -mmt=4 and network access via curl. The repository README lists what’s “green” (passing) and what is still out of scope. ()
The “bottle”: a filesystem bridge with a contract
One of the most beginner-friendly parts of Kakehashi is the bottle.
A bottle here means: a directory tree that looks like a macOS-ish root (/) and contains the guest-visible dylibs and tools.
By default, it lives under something like ~/.local/share/kakehashi/bottle/ and maps host paths into guest paths. The README describes a special bridge where the guest can access host filesystem content under a path like /Volumes/linux/....
That design solves a common translation-layer headache: guest binaries want macOS-style paths, and you still need host access for inputs/outputs.
In Kakehashi, kh uses this bridge so relative -o output paths and archive paths resolve consistently against the host working directory that launches kh. ()
Mach-O loading: more than “run this file”
Mach-O is not “just a binary.” It carries structured instructions for how the loader should map segments, resolve symbols, and choose the entry point.
kh-loader parses Mach-O and then binds symbols so imports land on the guest-visible libSystem.B.dylib and any runtime-provided glue functions.
A key detail from the design: Kakehashi loads the guest with identity-mapped guest virtual addresses into host pointers (with range checks). That’s a fancy way of saying: the runtime tries hard to let guest pointers line up with host pointers so the guest can dereference memory normally. ()
Syscalls and the hypercall boundary
Here’s the core trick that makes “no JIT” viable.
What is a syscall?
A system call (syscall) is the standardized way user programs ask the operating system to do privileged things: open a file, create a socket, read from stdin, spawn threads, etc.
Linux syscalls and macOS “BSD syscalls” are not identical. So Kakehashi translates the guest’s syscall requests into the host’s reality.
What is a hypercall in this context?
A hypercall is a call-like boundary where guest code asks the runtime/host to perform an operation. In Kakehashi, the guest uses a patched call path into the runtime’s host entry.
The design docs describe a production mode where guest syscall dispatch uses a hypercall entry (kh_hypercall_entry) rather than leaving residual svc instructions in an unpatched state.
There’s also a debug mode: when KAKEHASHI_HYPERCALL=0, leftover svc sites are rewritten to trigger SIGTRAP so the runtime can patch up the flow. ()
Why the register/stack juggling is so careful
This is where Kakehashi stops being “just a loader” and becomes a real systems project.
The boundary needs to preserve:
- the guest’s thread pointer register (
TPIDR_EL0on AArch64) - the guest stack vs host stack
- SIMD state (NEON) so compression tools don’t corrupt their own working data
The docs describe how the runtime uses a dedicated host alt stack (an alternative stack) and saves/restores NEON registers around the transition, because macOS “svc” behavior preserves SIMD while Kakehashi’s production hypercall path must explicitly protect it. ()
Threads: a strict 1:1 model
Threading is usually where translation layers get messy, because a lot of “hidden state” is tied to thread-local storage.
Kakehashi’s design uses a straightforward model: 1:1 threads. Each guest pthread becomes a host OS thread (not green threads, and not an M:N scheduler). ()
The project also defines a join protocol and a safe worker teardown approach. It highlights that a wrong teardown path can cause host-side forced unwind to run over guest frames, which historically has led to crashes in certain exit scenarios.
To make joining safe, the host and guest share a small control block with a mandatory ordering of steps: the guest stores the result, then enters guest termination; the host sets a done flag and wakes joiners; then join proceeds and unmappings happen safely. ()
What works (and what is still experimental)
Kakehashi is at “experimental, but proving real tasks” maturity.
From the README, you can expect working paths for:
- Darwin
7zzand curl running under Linux aarch64 (including in Docker/Colima and UTM workflows) () - multi-thread
7zz -mmt=4as a correctness gate () - curl milestones including stable HTTP and HTTPS GET flows ()
From the git milestone docs, there’s also movement toward running Apple Command Line Tools inside the bottle, with kh install xcode-tools pulling down a public software update catalog and assembling a guest bottle that includes git. ()
But even the curl docs stress the “trace-first” approach: implement only what the probe logs show, then expand the syscall and symbol surface as gates pass. ()
Performance: it’s slower than native, but not because of instruction emulation
Kakehashi’s performance section is unusually honest: guest code runs natively, so the overhead isn’t “interpreting instructions.” Instead, the tax is at the syscall boundary plus the runtime’s boundary machinery.
The README reports a measured gap on Ubuntu aarch64 bare metal (under a particular 7zz workload): native Linux 7zz wall time around ~22.5 seconds versus Darwin 7zz under kh around ~118 seconds, a ratio around ~×5.2. It also notes that for compression-heavy, few-file workloads the gap can shrink significantly (around ×1.1–1.2 in some cases). ()
A key implication for beginners: “×5” can still be completely usable in CI. If the alternative is “no macOS runner capacity,” a five-times slower tool that actually runs can still save the day.
The real lesson: boundaries and invariants beat brute force
Kakehashi’s story reads like a lesson plan for anyone building translation layers.
You don’t conquer compatibility by translating everything. You identify the contracts that differ between systems (binary format expectations, threading TLS behavior, syscall semantics) and build narrow translation points that preserve invariants: registers, stack discipline, NEON state, join ordering, and a filesystem layout the guest can trust.
That’s why “no JIT” can still run real programs. The project treats the guest like a native traveler, then carefully translates only the requests that would otherwise break across OS boundaries. ()
In other words: Kakehashi isn’t trying to be a tiny macOS clone. It’s trying to be a disciplined interpreter of macOS assumptions—with a clear boundary where the host can safely respond.
Comments (0)
No comments yet. Be the first to respond!
Leave a Comment
Your comment will be visible after review.