Deconstructing Mach-O ABI Translation: How Userspace Emulators Execute macOS Binaries on Linux ARM64
Explore the low-level systems engineering required to run macOS binaries natively on Linux ARM64. Learn how Mach-O dynamic loaders, syscall translation layers, and ARM64 TLS registers bridge the gap between Darwin and Linux.
Beyond Virtualization: The Search for Native Cross-OS Execution
For decades, running binaries built for one operating system on another meant accepting the heavy performance tax of full-system hardware virtualization. While virtualization technologies like KVM and Hypervisor.framework have dramatically reduced this overhead, they still require booting an entire guest kernel, managing virtualized memory tables, and reserving dedicated hardware resources.
With Apple's transition to Apple Silicon (ARM64), both macOS and Linux now share a common, highly optimized CPU instruction set architecture. This shared hardware substrate creates a compelling opportunity: Why virtualize an entire operating system when you can execute Mach-O binaries natively on a Linux ARM64 kernel via ABI translation?
Projects like Wine proved that Application Binary Interface (ABI) translation could bring Windows binaries to Linux x86_64 with minimal performance penalty. Today, systems engineers are tackling a similar challenge on ARM64: building userspace loaders capable of parsing Apple’s Mach-O binary format, dynamic linking against stubbed Darwin libraries, and intercepting Mach traps and BSD system calls to map them directly to Linux kernel primitives.
In this article, we will unpack the internal architecture of userspace Mach-O emulators, exploring binary structures, memory alignment quirks, register-level thread local storage (TLS) manipulation, and system call translation.
Anatomy of the Binaries: Mach-O vs. ELF
To understand why executing macOS binaries on Linux is challenging, we must first analyze how both operating systems structure compiled executables, dynamic libraries, and memory layouts.
Linux relies on the Executable and Linkable Format (ELF), whereas macOS uses the Mach-O (Mach Object) format. While both serve the same fundamental purpose—providing header metadata, code segments, data segments, and symbol tables to the OS kernel and dynamic linker—their structural layouts differ significantly.
+-------------------------------------------------------------+
| Mach-O Header |
| - Magic Number (0xFEEDFACF for 64-bit) |
| - CPU Type (CPU_TYPE_ARM64) |
| - Number of Load Commands |
+-------------------------------------------------------------+
| Load Commands |
| - LC_SEGMENT_64 (__TEXT, __DATA, __LINKEDIT) |
| - LC_LOAD_DYLINKER (/usr/lib/dyld) |
| - LC_LOAD_DYLIB (libSystem.B.dylib, CoreFoundation, etc.) |
| - LC_MAIN (Entry point offset) |
+-------------------------------------------------------------+
| Segment Payload |
| - __TEXT segment (executable instructions) |
| - __DATA segment (read/write global variables) |
| - __LINKEDIT segment (symbolic lookup tables) |
+-------------------------------------------------------------+
Key Differences That Complicate Loading:
- Page Size Mismatches: Apple Silicon hardware and macOS defaults operate on a 16 KiB memory page size (
0x4000). Many Linux ARM64 kernel distributions default to 4 KiB pages (0x1000) or 64 KiB pages. When mapping segments withmmap(), alignment checks must conform to both the file's requested alignment and the host host kernel's page boundaries. - Dynamic Linker Control: Linux delegates binary loading to
ld-linux-aarch64.so.1, whereas macOS relies on Apple'sdyld(Dynamic Link Editor). A userspace loader must emulatedyldfunctionality directly inside the host process space. - Relocation & Rebase Tables: Modern macOS binaries are built as Position-Independent Executables (PIE) utilizing chaining fixups (
LC_DYLD_CHAINED_FIXUPS), requiring complex pointer rebasing during memory layout initialization.
Step 1: Architecting a Userspace Mach-O Loader in Rust/C
To execute a Mach-O binary on Linux, our custom runtime emulator must parse the executable headers, map its memory segments into the virtual address space of a host Linux process, and manually wire up execution pointers.
Here is a simplified high-level conceptual implementation of parsing a Mach-O 64-bit header and mapping executable segments into Linux memory using mmap:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <mach-o/loader.h>
void load_macho_segments(int fd, struct mach_header_64 *header) {
size_t offset = sizeof(struct mach_header_64);
for (uint32_t i = 0; i < header->ncmds; i++) {
struct load_command *lc = (struct load_command *)(macho_buffer + offset);
if (lc->cmd == LC_SEGMENT_64) {
struct segment_command_64 *seg = (struct segment_command_64 *)lc;
// Calculate protection flags
int prot = 0;
if (seg->initprot & VM_PROT_READ) prot |= PROT_READ;
if (seg->initprot & VM_PROT_WRITE) prot |= PROT_WRITE;
if (seg->initprot & VM_PROT_EXECUTE) prot |= PROT_EXEC;
// Map segment directly into Linux memory space
void *mapped_addr = mmap(
(void *)seg->vmaddr,
seg->vmsize,
prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1,
0
);
// Read section bytes into the allocated segment
lseek(fd, seg->fileoff, SEEK_SET);
read(fd, mapped_addr, seg->filesize);
printf("[Loader] Mapped segment %s at %p (size: 0x%llx)\n",
seg->segname, mapped_addr, seg->vmsize);
}
offset += lc->cmdsize;
}
}
Once segments like __TEXT (read/execute) and __DATA (read/write) are successfully mapped, the loader must handle dynamic symbol lookup, connecting binary dynamic dependencies to custom compatibility shims.
Step 2: System Call Translation – Darwin Traps to Linux Syscalls
Parsing the executable format is only half the battle. When the compiled macOS program attempts to print to stdout, allocate memory, create threads, or query system clocks, it executes assembly-level system call instructions.
On ARM64, system calls are invoked via the svc #0 instruction. However, the system call numbers and calling conventions differ fundamentally between Darwin (XNU) and Linux.
Register Breakdown for System Calls on ARM64:
| OS Target | Syscall Register | Return Value Register | Negative Error Format |
| :--- | :--- | :--- | :--- |
| Linux ARM64 | X8 | X0 | -errno in X0 |
| macOS (Darwin) ARM64| X16 | X0 (and X1 for 64-bit pair) | Carry Flag set (PSTATE.C) + X0 |
Furthermore, Darwin divides system calls into classes indicated by the higher bits of X16:
- Class 1 (POSIX / BSD calls): e.g.,
0x2000004->write - Class 2 (Mach Traps): e.g.,
0x100001F->mach_absolute_time - Class 3 (Diagnostics/Private)
Intercepting svc #0 in Userspace
Because the host OS kernel is Linux, executing a Darwin svc #0 raw instruction directly would cause the Linux kernel to interpret Darwin syscall numbers as Linux syscall numbers, resulting in catastrophic crashes or unexpected behavior.
To fix this without kernel modules, advanced userspace emulators use one of two main approaches:
- Binary Rewriting / Dynamic Binary Translation (DBT): Scanning the mapped
__TEXTsegments before execution, replacing allsvc #0instructions withBLinstructions pointing to an inline translation trampoline function. ptrace/SECCOMP_RET_TRAPInterception: Using Linuxseccompfilters to trap everysvcinstruction, invoking a signal handler (SIGSYS) in the translation runtime to inspectX16, map the operation to Linux API calls, adjust host registers, and step over the instruction.
// Conceptual Syscall Translation Router inside SIGSYS handler
void handle_darwin_syscall(ucontext_t *ctx) {
uint64_t darwin_nr = ctx->uc_mcontext.regs[16]; // X16
switch (darwin_nr) {
case 0x2000004: { // Darwin BSD write()
uint64_t fd = ctx->uc_mcontext.regs[0]; // X0
uint64_t buf = ctx->uc_mcontext.regs[1]; // X1
uint64_t count = ctx->uc_mcontext.regs[2]; // X2
// Forward directly to Linux write syscall
ssize_t ret = syscall(__NR_write, fd, buf, count);
ctx->uc_mcontext.regs[0] = ret; // Set return value
break;
}
case 0x1000021: { // Mach trap: mach_msg
// Synthesize internal Mach message passing on top of Linux epoll/futex primitives
ctx->uc_mcontext.regs[0] = emulate_mach_msg(...);
break;
}
default:
fprintf(stderr, "Unhandled Darwin Syscall: 0x%llx\n", darwin_nr);
exit(1);
}
}
Step 3: Thread Local Storage (TLS) and Register Management
One of the most elusive technical pitfalls in multi-threaded binary translation is the handling of Thread Local Storage (TLS).
On ARM64, the system CPU architecture reserves thread registers specifically for thread-local pointers:
TPIDR_EL0: Reserved for user-space thread management.TPIDRRO_EL0: Read-only Thread ID Register for EL0 (User Mode).
macOS and Linux use TPIDR_EL0 differently:
- macOS
libSystemexpectsTPIDR_EL0to point to a Darwinpthread_tstructure, where offsets like0x0or0x8store specific thread properties, dispatch queues, and system error values (errno). - Linux
glibc/muslusesTPIDR_EL0to point to its own control blocks (struct pthread).
When bridging executing code between host Linux libraries and translated macOS dynamic libraries, switching execution context requires swapping the TPIDR_EL0 pointer on every register boundary crossed, or allocating a synthesized unified thread control block that satisfies both Darwin memory offset expectations and Linux thread layout requirements.
// Context-switching snippet to pass thread control to a Darwin entrypoint
.global switch_to_darwin_tls
switch_to_darwin_tls:
// Save current host Linux TLS pointer stored in X0 to stack
mrs x1, tpidr_el0
str x1, [sp, #-16]!
// Load target Darwin thread structure address passed in X1 into tpidr_el0
msr tpidr_el0, x1
// Call macOS function pointer in X2
blr x2
// Restore original Linux TLS pointer
ldr x1, [sp], #16
msr tpidr_el0, x1
ret
Solving the Runtime Overhead Problem
Userspace ABI translation presents unique performance characteristics. Unlike emulation across different architectures (e.g., running x86_64 on ARM via Rosetta 2 or QEMU), running native ARM64 instructions on an ARM64 kernel bypasses CPU instruction translation entirely. The CPU executes instructions at native hardware speeds.
However, system call interception overhead can become a significant bottleneck:
- Signal Overhead: Catching
svccalls viaseccomp+SIGSYScauses a kernel-to-userspace trap context switch for every single syscall, introducing microsecond-level latency spikes. - Mitigation via Binary Patching: Modern translation layers scan executable pages during load time and statically rewrite
svc #0calls into direct jumps to a shared inline translation vector block in memory. This reduces system call routing latency down to a few CPU cycles.
The Horizon: Unifying Unix Ecosystems
As developer toolchains solidify around ARM64 across cloud servers, workstations, and edge devices, strict OS-level binaries are increasingly becoming runtime implementation details rather than hardware blockers.
Projects proving that Mach-O Darwin binaries can execute seamlessly alongside Linux ELF executables showcase the flexibility of modern systems programming. By stripping away hypervisor overhead and bridging operating systems through binary analysis, page alignment normalization, and ABI mapping, we move closer to a unified ARM64 computing ecosystem.