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.
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:
| Concern | Family | Why |
|---|---|---|
| File I/O, sockets, signals | BSD | Inherited wholesale from 4.4BSD; POSIX expects them. |
Memory management (mmap) | BSD | Wraps the underlying Mach VM primitives. |
Process model (fork, exec) | BSD | POSIX process API on top of Mach tasks. |
| Tasks, threads, ports | Mach | These are first-class Mach objects, owned by the microkernel core. |
| IPC (any kind, anywhere) | Mach | The Mach message is the only primitive for cross-task communication. |
| Virtual memory primitives | Mach | mach_vm_allocate, mach_vm_protect, etc., back the BSD VM calls. |
| Synchronization (kernel-side) | Mach | semaphore_*, 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()indexessysent[N], copies arguments, calls the handler, sets the carry flag in PSTATE on failure, returns. Errno semantics. - Negative → Mach path.
mach_call_munger64()indexesmach_trap_table[-N], copies arguments, calls the handler, returns akern_return_tvalue 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_pidgives you a send right to the target's task port, andmach_vm_write/thread_create_runningdo 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:
- The BSD
sys_openhandler runs. - It resolves the path through the VFS layer.
- It allocates a
fileproc+fileglobpair in the BSD fd table. - Internally, it asks Mach for any memory it needs through
kmem_allocand 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_trapis lower overhead than POSIX semaphores. - Reach IOKit user clients.
iokit_user_client_trapis the underlying dispatch.
For kernel-extension and DriverKit work, Mach is the default — IOKit objects, memory descriptors, and synchronization primitives are all Mach-native.
What to read next
- The previous post, How macOS syscalls work in 2026, walks the trap path end-to-end.
task_for_pid: macOS's most dangerous Mach trapcovers the security-critical entry point.- The reference catalogue is searchable and filterable by family — set the filter to "Mach" to scan only the trap side.