Back to Blog
App DevelopmentPublished on August 2, 2026

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:

  1. 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 with mmap(), alignment checks must conform to both the file's requested alignment and the host host kernel's page boundaries.
  2. Dynamic Linker Control: Linux delegates binary loading to ld-linux-aarch64.so.1, whereas macOS relies on Apple's dyld (Dynamic Link Editor). A userspace loader must emulate dyld functionality directly inside the host process space.
  3. 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:

  1. Binary Rewriting / Dynamic Binary Translation (DBT): Scanning the mapped __TEXT segments before execution, replacing all svc #0 instructions with BL instructions pointing to an inline translation trampoline function.
  2. ptrace / SECCOMP_RET_TRAP Interception: Using Linux seccomp filters to trap every svc instruction, invoking a signal handler (SIGSYS) in the translation runtime to inspect X16, 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 libSystem expects TPIDR_EL0 to point to a Darwin pthread_t structure, where offsets like 0x0 or 0x8 store specific thread properties, dispatch queues, and system error values (errno).
  • Linux glibc / musl uses TPIDR_EL0 to 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:

  1. Signal Overhead: Catching svc calls via seccomp + SIGSYS causes a kernel-to-userspace trap context switch for every single syscall, introducing microsecond-level latency spikes.
  2. Mitigation via Binary Patching: Modern translation layers scan executable pages during load time and statically rewrite svc #0 calls 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.

#Linux#macOS#ARM64#Systems Programming#ABI Translation