How macOS syscalls work in 2026
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.
A syscall is the boundary where user-space code stops being in charge and the kernel takes over for a moment. On modern macOS — Apple Silicon and Intel both — almost every interesting thing a program does eventually crosses that boundary: opening a file, allocating memory, signing a hash, drawing to the screen. Understanding how that crossing actually works is the foundation for anything serious in systems programming, reverse engineering, or security research on the platform.
This guide walks the full path: how the request leaves your process, how XNU classifies and dispatches it, and how the result makes its way back.
Three families, one trap instruction
XNU exposes three distinct syscall families, all funnelled through the same trap instruction on arm64:
- BSD syscalls — the POSIX-shaped surface every Unix programmer knows:
open,read,write,kqueue, and ~480 others. Positive syscall numbers. - Mach traps — Carnegie Mellon's microkernel heritage, still very much alive inside XNU.
mach_msg_trap,task_for_pid,semaphore_wait_trap, and ~60 others. Negative syscall numbers. - Diagnostic / private calls — debug-only and Apple-internal calls used by the kernel itself and a handful of trusted daemons.
The instruction that crosses the boundary is the same in every case:
svc #0x80 ; the Supervisor Call that triggers a macOS syscall
What disambiguates them is register x16 — the syscall number — and a class encoding stamped into its high bits.
Step 1: the user-space stub
When you call open() in C, you're not calling the kernel directly. You're calling a tiny stub in libsystem_kernel.dylib. On arm64 that stub looks like this:
_open:
mov x16, #5 ; SYS_open
svc #0x80 ; trap into the kernel
b.cs __cerror ; on carry-set, jump to errno handler
ret
#5 is the BSD syscall number for open. The stub stays out of the way: it puts the number in x16, executes the SVC, and forwards the result. Arguments stay in x0–x7 across the trap; the kernel reads them directly from the trap frame.
On x86_64 (Intel and Rosetta 2), the equivalent uses the syscall instruction and a packed 32-bit number that encodes both the family and the index:
mov eax, 0x2000005 ; SYSCALL_CLASS_UNIX << 24 | 5
syscall
The four classes are:
| Class | Code | Family |
|---|---|---|
SYSCALL_CLASS_UNIX | 2 | BSD syscalls |
SYSCALL_CLASS_MACH | 1 | Mach traps |
SYSCALL_CLASS_MDEP | 3 | machine-dependent |
SYSCALL_CLASS_DIAG | 4 | diagnostic |
On arm64 the equivalent encoding lives in the sign of x16: positive for UNIX, negative for MACH. The MDEP class is what machdep_syscall uses for things like thread_fast_set_cthread_self.
Step 2: the trap handler
When the SVC fires, the CPU jumps to XNU's exception vector. The handler in osfmk/arm64/sleh.c (sleh_synchronous) decodes the exception, sees it's an SVC, and looks at x16. From there:
- x16 > 0 → BSD syscall path. The handler calls
unix_syscall64()inbsd/dev/arm64/systemcalls.c, which looks up the syscall in thesysent[]table generated frombsd/kern/syscalls.master. - x16 < 0 → Mach trap path. The handler calls
mach_call_munger64()inosfmk/arm64/bsd_arm64.c, which indexesmach_trap_table[]by the absolute value.
This split is why the same SVC instruction can mean two completely different things — the kernel reads the sign of x16 first.
Step 3: dispatch
For BSD syscalls, unix_syscall64() does three things:
- Validate the number against
sysent_size. Anything out of range falls through tonosys(), which returnsENOSYS. - Copy arguments from the user trap frame into the syscall's argument struct, with type-aware sign-extension and pointer validation.
- Call the handler —
sysent[N].sy_call(p, &args, &retval). That handler is the real implementation (e.g.sys_open()for syscall #5).
The handler runs in kernel mode, but with the process's address space mapped in, so it can copyin() / copyout() between user and kernel buffers safely.
For Mach traps, mach_call_munger64() does the same dance against mach_trap_table[] — except the table is much shorter, and each entry includes the argument count packed into a single field so the dispatcher knows how many registers to copy.
Step 4: returning
When the handler finishes, the path mostly inverts:
- The return value goes into x0 (and x1 for syscalls that return a wider type).
- If the BSD handler returned non-zero, the kernel sets the carry flag in the trap frame's PSTATE.
- The kernel issues
eret(Exception Return), which restores user state and resumes at the instruction after the SVC. - Back in the stub,
b.cs __cerrorbranches if carry is set;__cerrorstuffs x0 intoerrnoand returns -1.
Mach traps don't use the carry-flag convention. They return a kern_return_t value directly — KERN_SUCCESS (0) or one of the KERN_* error codes — and the caller compares the return register manually.
Where this matters in practice
Knowing the path matters for three different audiences:
- Performance work: every syscall costs at minimum a kernel entry, a copyin, a copyout, and an exception return. On Apple Silicon that's ~150 ns of overhead even before the actual work. Batching with
readv/writev/keventremoves most of it. - Reverse engineering: when you see
svc #0x80preceded bymov x16, #N, you can immediately identify the syscall by number. The same applies tomov eax, 0x2nnnnnnon Intel binaries. The /syscall catalogue is a number-to-name lookup. - Security: every syscall is observable from kernel space. Endpoint Security covers a curated subset; DTrace covers everything. Process injection always bottoms out at
task_for_pidfollowed bymach_vm_writeandthread_create_running— three Mach traps that, together, are arbitrary code execution.
Knowing the boundary is also knowing where to look when something is wrong.
Where to go next
- The next post in this series breaks down the Mach trap vs BSD syscall split — why XNU has both, and what that means for IPC.
- The
task_for_piddeep dive walks through the most security-sensitive Mach trap in the kernel. - For tracing real traffic, see Tracing macOS syscalls with dtrace on Apple Silicon.
And if you just want the data: every syscall in every XNU release Apple has shipped is in the reference catalogue.