Skip to content

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.

Published on 5 min read

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:

ClassCodeFamily
SYSCALL_CLASS_UNIX2BSD syscalls
SYSCALL_CLASS_MACH1Mach traps
SYSCALL_CLASS_MDEP3machine-dependent
SYSCALL_CLASS_DIAG4diagnostic

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() in bsd/dev/arm64/systemcalls.c, which looks up the syscall in the sysent[] table generated from bsd/kern/syscalls.master.
  • x16 < 0 → Mach trap path. The handler calls mach_call_munger64() in osfmk/arm64/bsd_arm64.c, which indexes mach_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:

  1. Validate the number against sysent_size. Anything out of range falls through to nosys(), which returns ENOSYS.
  2. Copy arguments from the user trap frame into the syscall's argument struct, with type-aware sign-extension and pointer validation.
  3. Call the handlersysent[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:

  1. The return value goes into x0 (and x1 for syscalls that return a wider type).
  2. If the BSD handler returned non-zero, the kernel sets the carry flag in the trap frame's PSTATE.
  3. The kernel issues eret (Exception Return), which restores user state and resumes at the instruction after the SVC.
  4. Back in the stub, b.cs __cerror branches if carry is set; __cerror stuffs x0 into errno and 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 / kevent removes most of it.
  • Reverse engineering: when you see svc #0x80 preceded by mov x16, #N, you can immediately identify the syscall by number. The same applies to mov eax, 0x2nnnnnn on 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_pid followed by mach_vm_write and thread_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

And if you just want the data: every syscall in every XNU release Apple has shipped is in the reference catalogue.

Related articles

A practical guide to using DTrace to trace BSD syscalls and Mach traps on modern macOS — including SIP requirements, working scripts, and what to do when Apple's syscall probes go missing.
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.
Endpoint Security is the modern, supported path for syscall-level detection on macOS — but its event taxonomy doesn't map 1-to-1 with syscalls. Here's the practical mapping for security work.