Why is issuing raw system-call traps on macOS unsafe, and what does Apple treat as the stable kernel ABI instead?
answer
- the library is the contract, not the trap
- numbers are an implementation detail
- no static libc is shipped
- runtimes rewrote their Darwin path
- two families: BSD calls and Mach traps
basics
~20 sOn macOS the stable interface is libSystem, not the trap numbers. Apple reserves the right to change or renumber system calls between releases, so a binary that traps into the kernel directly can break on a future macOS version. Every macOS binary links libSystem dynamically; there is no supported static libc.
solid answer
~50 sUnlike Linux, where the syscall number and calling convention are a deliberately frozen contract, Darwin treats the trap layer as a kernel-internal detail. The supported ABI is **`libSystem.dylib`** — the umbrella library that contains the C library and the kernel-call shims — and Apple can renumber or change the underlying traps in any release. That is also why macOS ships no static libc: every process dynamically links `libSystem`, which is how a system update can move the calls underneath you without recompiling anything. Runtimes that ignored this learned it the hard way; Go changed its Darwin implementation to route system calls through `libSystem` rather than issuing raw traps for exactly this reason. Note too that XNU has two families: BSD system calls, and **Mach traps** such as `mach_msg`, dispatched through their own table. The `syscall(2)` interface itself is documented as deprecated on macOS.
code
bash · 1 lineotool -L /usr/bin/truego deeper
Remember the headline: on macOS you call library functions, never raw traps, and every binary links libSystem. Do not try to build a static macOS executable.
Explain why the dynamic link to libSystem is the mechanism that lets Apple change the kernel interface, and contrast it with Linux's frozen syscall-number ABI.
Diagnose from it: when a prebuilt binary or agent breaks right after a macOS upgrade, reason about which supported boundary it violated, and set the rule for your own toolchain that nothing calls below libSystem.
Treat the platform's ABI promise as a portfolio risk. Decide which macOS releases you support and test, and refuse dependencies — vendored runtimes, agents, private frameworks — whose stability rests on interfaces the vendor never promised.
## Two very different promises On Linux the system-call boundary is a public, versioned contract. Numbers are assigned once and never reused, the register convention is fixed per architecture, and Linus's "we do not break userspace" rule means a statically linked binary that traps directly into the kernel keeps working for years. Whole language runtimes were built on that promise. Darwin makes no such promise. On macOS the boundary Apple supports is **`libSystem`** — shipped as `/usr/lib/libSystem.B.dylib`, an umbrella library that pulls in the C library, the pthread implementation, the malloc implementation and `libsystem_kernel`, which holds the actual kernel-call stubs. Everything below `libSystem` is kernel-internal and may change between releases. ## The consequences that bite in production **Trap numbers are not frozen.** A binary that embeds a system-call number and issues the trap itself has hard-coded an implementation detail. It may work today and fail after an OS update, typically in a way that looks like a mysterious crash or an invalid-argument error deep inside a runtime rather than a clean "unsupported" message. **There is no supported static libc.** Apple does not ship a static `libc.a` you can link against, and a fully static macOS executable is not a supported product. Every macOS binary dynamically links `libSystem`: ```bash otool -L /usr/bin/true # /usr/lib/libSystem.B.dylib (compatibility version ...) ``` That dependency is the mechanism by which Apple can change the kernel interface: an OS update ships a new `libSystem`, and every process picks up the new stubs on next launch. Engineers arriving from Linux, where `CGO_ENABLED=0` or a musl static build is the standard way to ship a dependency-free binary, must adjust: on macOS the dynamic link to `libSystem` is not bloat, it is the contract. **Language runtimes have already been burned.** Go's Darwin port originally issued raw traps, matching what it did on Linux. Apple's interface moved, and Go changed its macOS implementation to call through `libSystem` instead. Rust and other runtimes take the same position. If you are writing a runtime, an instrumentation library or a sandbox escape detector, this is the design constraint. **The `syscall(2)` entry point is deprecated.** Even the indirect "call syscall number N" interface is documented as deprecated on macOS; the supported route is the named wrapper function. ## Two families of kernel entry Because XNU is a hybrid, there is not one kernel-call table but two. **BSD system calls** are the POSIX ones — `open`, `read`, `write`, `kevent`. **Mach traps** are the calls into the Mach core, most importantly `mach_msg` for IPC plus the port and VM operations; they are dispatched through their own table, entirely separate from the BSD path, and are conventionally referred to with negative trap numbers in Darwin lore. So "the macOS system-call interface" is really two interfaces, and both are reached through `libSystem` in supported code. ## How this shapes real decisions - **Do not vendor syscall numbers.** If you are porting a runtime, a tracer or a security agent, call the library function. If you need something with no exported wrapper, that is a signal the interface is not supported for third parties, not an invitation to trap directly. - **Do not chase static linking.** Build advice that works on Alpine or with musl does not transfer. Accept the `libSystem` dependency and version-check with the deployment target flags instead. - **Expect breakage across OS upgrades to look weird.** When a third-party agent or an old prebuilt binary starts failing right after a macOS upgrade, "it made assumptions below `libSystem`" is high on the list of causes, alongside removed private frameworks. - **Test against the OS versions you support.** Because the contract is a library, the meaningful compatibility axis is which macOS releases you build and test against, not which kernel version is running. ## The crisp interview answer "On Linux the syscall table is the ABI; on macOS it is not. `libSystem` is the supported boundary, Apple can move the traps underneath it, there is no static libc, and runtimes like Go had to switch to calling through `libSystem` after assuming otherwise." That answer shows you know the difference between an interface and an implementation detail — which is the real subject of the question.
- Why does macOS not ship a static libc the way Linux distributions do?Because the dynamic link to `libSystem` is how Apple keeps the freedom to change the kernel interface. If binaries statically embedded the kernel stubs, every trap change would break already-shipped software. Dynamic linking means an OS update replaces the stubs once and every process picks them up on next launch, so a fully static macOS executable is simply not a supported product.
- What is the difference between a BSD system call and a Mach trap on Darwin?They are separate entry families dispatched through separate tables. BSD system calls are the POSIX surface — `open`, `read`, `kevent` — implemented in the BSD layer. Mach traps enter the Mach core: `mach_msg` for port IPC, plus port and virtual-memory operations. Supported code reaches both through `libSystem` wrappers rather than trapping directly.
- A prebuilt third-party binary starts crashing right after a macOS upgrade. What would you suspect first?That it depended on something below the supported boundary — hard-coded trap numbers, a private framework, or kernel-internal behaviour — rather than on `libSystem`. Check what it links with `otool -L`, whether the vendor ships a build for the new OS version, and whether the failure reproduces on the previous release. It is a contract violation, not usually a kernel bug.
saying these in an interview costs you the question
- Assuming macOS freezes syscall numbers like Linux
- Trying to produce a fully static macOS binary
- Treating libSystem as optional bloat to strip
- Thinking there is one unified syscall table on Darwin
- Copying Linux syscall numbers into Darwin code