Skip to content

Mach traps vs BSD syscalls: two kernels in one Mac

XNU is a Mach-3 microkernel with a BSD personality bolted on top. That hybrid is why macOS has two distinct syscall families — here's what each is for, and how they differ in practice.

Published on 4 min read

The single most confusing thing about XNU, the macOS kernel, is that it's two kernels. Underneath, it's a descendant of Mach 3 — a microkernel built at Carnegie Mellon in the late 1980s. On top of that, Apple kept the entire BSD personality alive: the POSIX surface, the VFS, the BSD scheduler glue, the signal model. The result is one of the strangest kernels still in production: a Mach core that talks to itself almost exclusively through message-passing, with a Unix kernel running cooperatively in the same address space.

That hybrid is why macOS exposes two distinct syscall families, dispatched through the same SVC instruction but living in different tables.

What lives where

The split isn't arbitrary. There are clean rules for what shows up on each side:

ConcernFamilyWhy
File I/O, sockets, signalsBSDInherited wholesale from 4.4BSD; POSIX expects them.
Memory management (mmap)BSDWraps the underlying Mach VM primitives.
Process model (fork, exec)BSDPOSIX process API on top of Mach tasks.
Tasks, threads, portsMachThese are first-class Mach objects, owned by the microkernel core.
IPC (any kind, anywhere)MachThe Mach message is the only primitive for cross-task communication.
Virtual memory primitivesMachmach_vm_allocate, mach_vm_protect, etc., back the BSD VM calls.
Synchronization (kernel-side)Machsemaphore_*, mk_timer_*, voucher attribution.

In other words: anything POSIX wants, BSD owns. Anything that touches the microkernel's bookkeeping — tasks, threads, ports, memory objects — is a Mach trap.

How the dispatch differs

When the SVC fires on arm64, XNU looks at the sign of x16:

  • Positive → BSD path. unix_syscall64() indexes sysent[N], copies arguments, calls the handler, sets the carry flag in PSTATE on failure, returns. Errno semantics.
  • Negative → Mach path. mach_call_munger64() indexes mach_trap_table[-N], copies arguments, calls the handler, returns a kern_return_t value directly. No errno.

This is also why every Mach trap on this site has a negative number (mach_msg2_trap is -31, task_for_pid is -45) and every BSD syscall has a positive one.

On x86_64 the encoding is different — a packed (class << 24) | number in EAX — but the conceptual split is the same.

Mach is the only IPC

This is the single most important consequence of the split: on macOS, all inter-process communication goes through Mach. There is no equivalent of Linux's sendmsg ancillary data with SCM_RIGHTS. There's no pidfd_send_signal. If process A wants to talk to process B, it sends a Mach message to a port that B owns. Full stop.

That has enormous downstream consequences:

  • XPC, the modern macOS IPC framework, is built on Mach messages.
  • launchd, the init system, is a Mach port broker — services register names with it, clients look those names up, and launchd hands out send rights.
  • Code injection always involves Mach: task_for_pid gives you a send right to the target's task port, and mach_vm_write / thread_create_running do the rest.
  • Window-server traffic (every CGContext draw, every event delivery) is a Mach message under the hood.

If you've ever wondered why a process injection tool on Linux takes a few hundred lines of ptrace and on macOS takes a few dozen lines of mach_* calls, this is why. Mach makes the operation conceptually cleaner — and, when SIP and AMFI don't intervene, mechanically easier.

The BSD personality

The BSD side of XNU exists mostly to keep POSIX software portable. When you call open(), what actually happens is:

  1. The BSD sys_open handler runs.
  2. It resolves the path through the VFS layer.
  3. It allocates a fileproc + fileglob pair in the BSD fd table.
  4. Internally, it asks Mach for any memory it needs through kmem_alloc and friends.

The same is true for mmap. The BSD handler is a thin shim over mach_vm_map. If you watch the call graph in DTrace, you'll see most BSD syscalls bottom out in Mach VM or Mach IPC.

When to use which

For application code, you almost always want the BSD surface. POSIX is portable, well-documented, and predictable.

You drop into Mach when the BSD API can't express what you need:

  • Bring up an XPC connection without launchd's help. You'll be juggling Mach ports.
  • Inspect another process's memory. task_for_pid + mach_vm_read.
  • Implement a high-fidelity scheduler primitive. semaphore_wait_trap is lower overhead than POSIX semaphores.
  • Reach IOKit user clients. iokit_user_client_trap is the underlying dispatch.

For kernel-extension and DriverKit work, Mach is the default — IOKit objects, memory descriptors, and synchronization primitives are all Mach-native.

Related articles

What actually happens when an arm64 Mac program issues an SVC instruction — from the user-space stub down to the BSD syscall dispatcher and back.
How task_for_pid works, why Apple gates it the way it does, and what its entitlement model means for security tooling on macOS.