Skip to content

The BSD personality: how XNU pretends to be Unix

Processes, file descriptors, signals, sockets — the FreeBSD-derived layer that sits on top of Mach and makes macOS pass POSIX.

Published 5 min read
Syscall dispatch in XNUUserspace traps into the kernel via SVC (arm64) or SYSCALL (x86_64). Positive syscall numbers dispatch through sysent[]; negative numbers through mach_trap_table[].userspacelibc → svc #0x80 / syscalltrap handlerosfmk/arm/trap.csyscall numbersign?sysent[+n]bsd/kern/init_sysent.cpositive — POSIXmach_trap_table[−n]osfmk/kern/syscall_sw.cnegative — Machopen, read, write, ioctl, kqueue, …bsd/kern/*mach_msg_trap, task_for_pid, …osfmk/kern/*

XNU is two kernels in one binary. The bottom is Mach: tasks, threads, ports, virtual memory. The top is BSD: processes, files, signals, sockets, system calls. When you type ls, every syscall you make goes through the BSD layer; the BSD layer turns each one into operations on the Mach objects underneath.

This is the article about that top half. It exists, and it's not a thin shim.

Where the BSD code came from

The BSD subset in XNU was forked from FreeBSD around the 4.x → 5.x transition, then carried forward selectively. Apple pulls fixes and features from upstream FreeBSD when they're useful, but the codebase has diverged enough that you should think of it as its own thing now.

apple-oss-distributions/xnubsd/kern/init_main.c:1bsdinit_task — the first BSD process. Read this once and a lot of boot will make sense.View on GitHub(line 1)

If you grep bsd/ you'll see structures that look exactly like FreeBSD: struct proc, struct vnode, struct mbuf, struct ucred. The function names match. The macros match. What's missing is FreeBSD's scheduler, its VM, and its IPC — XNU does those in Mach, and the BSD code calls down.

The proc is a thin wrapper over a Mach task

Every BSD process is a struct proc. The first interesting field is the one you'd expect:

struct proc {
    LIST_ENTRY(proc) p_list;
    pid_t            p_pid;
    struct proc     *p_pptr;
    ...
    void            *task;        // points to a Mach task_t
    ...
};
apple-oss-distributions/xnubsd/sys/proc_internal.h:180struct proc — the BSD process. Notice that `task` is just a pointer.View on GitHub(line 180)

task is a task_t from Mach. The BSD layer doesn't own the address space, the threads, the port rights — Mach does. The BSD proc is the part with a pid_t, a ucred, a file descriptor table, signal handlers, and the parent/child links every Unix process tree needs.

Conversely, a Mach task has a bsd_info pointer that lets the kernel walk the other way: from a task_t you can reach the proc. That bidirectional pointer is the entire bridge between the two halves of the kernel.

apple-oss-distributions/xnuosfmk/kern/task.h:200task.bsd_info — the back-pointer Mach uses to find a process's BSD state.View on GitHub(line 200)

System calls land here

When userspace executes SVC #0x80 (arm64) or SYSCALL (x86_64), the trap handler picks the table to dispatch from based on the syscall number's sign:

  • Positive numbers → BSD syscalls. Dispatched through sysent[].
  • Negative numbers → Mach traps. Dispatched through mach_trap_table[].

apple-oss-distributions/xnubsd/kern/init_sysent.csysent[] — every positive-numbered POSIX syscall, indexed by its number.View on GitHub(line ) apple-oss-distributions/xnuosfmk/kern/syscall_sw.cmach_trap_table[] — the negative-numbered Mach traps.View on GitHub(line )

The BSD sysent is the auto-generated table that maps SYS_open, SYS_read, SYS_kqueue and so on to the C function that implements them. The build system pulls this from bsd/kern/syscalls.master, which is the human-readable source of truth.

apple-oss-distributions/xnubsd/kern/syscalls.masterThe syscall master file. Edit-then-rebuild generates init_sysent.c.View on GitHub(line )

VFS — the file system you actually touch

Open /etc/hosts and you're going through the BSD VFS layer. VFS gives every filesystem (APFS, HFS+, NFS, devfs, fdesc) a shared API: vnode_t, vnop_* calls, namespaces glued together with mount points.

apple-oss-distributions/xnubsd/vfs/vfs_syscalls.cVFS syscall entry points — open, close, read, write, stat all start here.View on GitHub(line ) apple-oss-distributions/xnubsd/sys/vnode.hvnode — the in-core handle for a file/directory regardless of filesystem.View on GitHub(line )

A vnode is reference-counted. When the count hits zero it's eligible for reuse from a global LRU. There's a fixed number of them (controlled by kern.maxvnodes), and exhausting them is a real failure mode on heavy workloads — vnode_pager is one of the busier sources of pressure on the VM compressor.

File descriptors are not vnodes

int fd indexes into a per-process descriptor table (fdt). Each slot points to a struct fileproc, which points to a struct fileglob, which contains a generic fg_ops and an fg_data payload. For a regular file, fg_data is the vnode. For a kqueue, it's a kqueue struct. For a socket, it's a socket. The same syscalls (read, write, close) work because everything's dispatched through fg_ops.

apple-oss-distributions/xnubsd/kern/kern_descrip.cfdtable — file descriptor allocation, dup, dup2, fcntl, close.View on GitHub(line ) apple-oss-distributions/xnubsd/sys/file_internal.hfileproc / fileglob / fileops — the abstraction that lets sockets, files, and kqueues share read/write.View on GitHub(line )

This is the same UNIX pattern you'd see in any BSD; it's named slightly differently in XNU but the shape is identical.

Signals (and why they're harder than they look)

POSIX signals are per-process, but delivery is per-thread — POSIX says a signal can be handled by any thread that doesn't have it masked. XNU implements this by keeping a process-wide pending mask plus per-thread masks, walking the threads to find a candidate at delivery time.

apple-oss-distributions/xnubsd/kern/kern_sig.cSignal generation, masking, delivery, default actions.View on GitHub(line )

The interesting part is that signal delivery has to interact with Mach. Killing a process needs to abort threads; threads might be blocked in mach_msg_receive on a Mach port. The BSD signal code calls down into Mach's thread state to push a delivery frame onto the user stack — and the AST mechanism (Asynchronous System Trap) is how it gets the thread to notice on its way back to userspace.

Networking lives here, mostly

The sockets and protocol stacks (TCP, UDP, IPv6, the routing table) are BSD code — derived from FreeBSD, kept close enough to upstream that diffs against FreeBSD are tractable. The interesting macOS-specific bits are the network kernel extensions (NKEs) and the filter framework that lets things like Little Snitch intercept connections.

apple-oss-distributions/xnubsd/netinet/tcp_input.cTCP receive path — the busiest function in the stack on most systems.View on GitHub(line ) apple-oss-distributions/xnubsd/net/kpi_protocol.cProtocol KPI — the supported interface for third-party network filters/protocols.View on GitHub(line )

What this layer doesn't do

A lot of "operating system" things you'd expect from BSD code are missing here, because Mach does them:

  • Memory allocation for processes (mmap, anonymous pages, the heap) ends up calling vm_map_enter in Mach.
  • Threadspthread_create is implemented in libpthread against the Mach thread_* calls; BSD doesn't have its own thread type.
  • Inter-process communication beyond pipes and sockets: anything richer (XPC, services, sharing memory) is Mach.

This is why beginners get confused: the BSD layer looks like a complete Unix until you read bsd/kern/init_main.c and notice it's bootstrapping into a Mach task. The Unix you're using is a personality. Mach is the host.

And read bsd/kern/init_main.c end-to-end at least once. It's short. It explains how the two halves of the kernel meet.

Related

POSIX says signals are per-process. Mach says everything is a thread. Here's how XNU bridges the two — pending masks, delivery threads, the AST mechanism, and exception ports.
clonefile, fclonefileat, fs_snapshot — three syscalls that let you copy 50 GB in 50 milliseconds. Here's what happens under each one, and what doesn't get copied.
What changed in XNU when Apple shipped its own ARM silicon — P/E cores, APRR page-permission switching, the AMX matrix coprocessor, and Rosetta 2.