If you've read the Android series, you know that Android runs on the Linux kernel. Every syscall, every process, every page of memory is managed by Linux — a monolithic kernel that's been around since 1991 and runs on everything from watches to supercomputers.
iOS doesn't use Linux. It uses XNU — a kernel with a very different lineage, different architecture, and different tradeoffs. Understanding XNU is the foundation for understanding everything else in this series, just as understanding the Linux kernel was the foundation for the Android series.
XNU stands for "X is Not Unix" — though this is somewhat ironic, because XNU is very much Unix-like. Your Flutter app on iOS runs as a Unix process with a PID, a UID, file descriptors, and a virtual address space, just like on Android. But the kernel providing these abstractions is structurally different from Linux, and the differences have practical consequences.
The hybrid architecture
XNU isn't a single design. It's a combination of two distinct kernel architectures that were merged in the late 1990s when Apple created Mac OS X (which later became macOS, and whose mobile variant became iOS).
Mach — originally developed at Carnegie Mellon University in the 1980s as a microkernel research project. Mach's core ideas: everything is a message, processes communicate through ports, the kernel provides minimal services (scheduling, virtual memory, IPC) and everything else runs in user space.
BSD — specifically, FreeBSD's implementation of the traditional Unix API. File systems, networking (the TCP/IP stack), the POSIX process model, Unix signals, sockets, the VFS (Virtual File System) layer — all the things that make a system "Unix-like."
XNU takes the Mach microkernel as its foundation and layers the BSD subsystem on top. But it's not a true microkernel — the BSD code runs in kernel space, not in user space. Apple calls it a "hybrid kernel" because it has the architecture of a microkernel (ports, messages, tasks) but the performance characteristics of a monolithic kernel (everything in ring 0, no user-kernel transitions for basic operations).
┌──────────────────────────────────────────┐
│ User Space (your app) │
├──────────────────────────────────────────┤
│ BSD Layer │
│ (POSIX APIs, file systems, networking, │
│ processes, signals, sockets) │
├──────────────────────────────────────────┤
│ Mach Layer │
│ (tasks, threads, ports, messages, │
│ virtual memory, scheduling) │
├──────────────────────────────────────────┤
│ I/O Kit (device driver framework) │
├──────────────────────────────────────────┤
│ Hardware │
└──────────────────────────────────────────┘Mach: the foundation
Mach provides the primitives that everything else builds on.
Tasks and threads. Where Linux has processes, Mach has tasks. A task is a container: a virtual address space, a collection of ports, and a set of threads. A thread is a unit of execution within a task. The BSD layer maps its process abstraction onto Mach tasks — every BSD process has a corresponding Mach task. Your Flutter app is a BSD process, which is a Mach task, which has threads.
The practical consequence: iOS has two parallel representations of your app. As a BSD process, it has a PID, signals, file descriptors. As a Mach task, it has a task port, thread ports, and exception ports. Some system operations go through the BSD interface (file operations, networking). Others go through the Mach interface (memory management, inter-process communication). Apple's frameworks hide this duality, but it surfaces in crash reports and debugging tools.
Ports and messages. Mach's IPC mechanism is message-passing through ports. A port is a kernel-managed message queue. A process (task) sends a message to a port; the process (task) that holds the receive right for that port reads the message. Ports can carry data, and crucially, they can carry port rights — the ability to send to another port. This means IPC channels can be dynamically created and passed between processes.
If this sounds conceptually similar to Android's Binder (Post 4 of the Android series), the intuition is right. Both are kernel-managed IPC mechanisms with identity verification and capability passing. The implementation is completely different, but the purpose — structured, secure communication between processes — is the same.
Virtual memory. Mach's VM system manages the virtual address space for each task. It handles page tables, demand paging, copy-on-write, and shared memory regions. The BSD layer uses Mach VM for its own needs (file mapping, process forking), but Mach VM can also be used directly through Mach APIs.
On Apple Silicon (the A-series chips in iPhones), the hardware page size is 16KB — four times larger than the typical 4KB pages on ARM Android devices. This has implications for memory efficiency: each page allocation consumes at least 16KB of physical memory, which means small allocations waste more space (internal fragmentation), but page table walks are faster (fewer levels needed) and TLB coverage is broader (each TLB entry covers 16KB instead of 4KB).
BSD: the Unix face
The BSD layer is what makes XNU behave like a Unix system. When your Flutter app makes a POSIX call — open(), read(), write(), socket(), mmap() — it's calling into the BSD layer of XNU.
Processes. The BSD layer maintains the process table, PIDs, parent-child relationships, process groups, and sessions. When your Flutter app calls fork() (which it doesn't directly, but the system does during app launch), it's the BSD layer handling the call, creating a new Mach task underneath.
File systems. APFS (Apple File System) is the default file system on iOS. The BSD VFS layer provides the file API; APFS provides the implementation. APFS has features that matter for app performance: copy-on-write (file cloning is nearly free), space sharing between volumes, native encryption, and crash-safe metadata.
Networking. The full TCP/IP stack lives in the BSD layer — socket creation, TCP connection management, UDP, DNS resolution. When your Dart code calls http.get(), the chain is: Dart → dart:io → Foundation's URLSession → BSD sockets → XNU's TCP/IP stack → the network driver. The path is structurally similar to Android (Post 3 of the Android series), but the kernel managing the TCP state machine is XNU-BSD rather than Linux.
Signals. Unix signals (SIGSEGV, SIGKILL, SIGTERM) are BSD concepts. When your app crashes with a segfault, the Mach layer detects the exception (via Mach exception ports), the BSD layer translates it to a POSIX signal, and the process dies. Crash reports from iOS show both representations: the Mach exception type and the BSD signal number.
How system calls work on iOS
The mechanism is similar to Android's Linux syscalls (Post 3 of the Android series) but with Apple Silicon-specific details.
On ARM64 Apple Silicon, a syscall is triggered by the svc instruction, just like on Android's ARM devices:
- The C library (libSystem, Apple's equivalent of Android's Bionic) puts the syscall number into register
x16(notx8like Linux — different ABI). - Arguments go into registers
x0throughx5. svc #0x80executes. The CPU traps into EL1 (kernel mode).- XNU's syscall handler reads
x16and dispatches to the appropriate handler.
But here's where the hybrid nature shows up: XNU has three syscall tables:
- Mach traps (syscall numbers 0x1000000+) — direct Mach kernel calls. These handle ports, messages, tasks, threads.
- BSD syscalls (syscall numbers 0x2000000+) — POSIX/Unix calls.
open(),read(),write(),socket(), etc. - Machine-dependent calls — architecture-specific operations.
When your Flutter app reads a file, the syscall goes through the BSD table. When the system creates a Mach port for IPC, it goes through the Mach trap table. Most app-level code only triggers BSD syscalls; Mach traps are used by system frameworks internally.
You can trace syscalls on iOS similarly to strace on Android — but the tool is dtruss or Instruments on macOS (iOS device tracing requires Instruments):
# On a macOS app (not directly on iOS, but same kernel)
sudo dtruss -p <pid>The output looks similar to strace: syscall name, arguments, return value, timing. On iOS devices, Instruments' "System Call Trace" instrument provides the same data.
I/O Kit: the driver framework
Where Linux has kernel modules (loadable code that extends the kernel), XNU has I/O Kit — an object-oriented, C++ driver framework that runs in kernel space.
I/O Kit is relevant for iOS because it manages all hardware interaction: the GPU (via the Apple GPU driver), the display, the accelerometer, the camera, Bluetooth, NFC, the secure enclave. Device drivers are I/O Kit classes that inherit from a base class and register with the I/O Kit registry.
For Flutter, the most relevant I/O Kit interaction is the GPU driver. When Impeller (Flutter's rendering engine) submits Metal commands to the GPU, those commands eventually reach the GPU I/O Kit driver, which communicates with the Apple GPU hardware. The path is: Impeller → Metal framework (user space) → I/O Kit GPU driver (kernel space) → GPU hardware.
Unlike Android, where the GPU driver can vary wildly between devices (Qualcomm Adreno, ARM Mali, Imagination PowerVR), iOS has exactly one GPU: Apple's custom design. This means:
- One driver, thoroughly tested and optimised.
- No GPU driver fragmentation. A rendering path that works on one iPhone works on all iPhones with that generation's GPU.
- Impeller's Metal backend can target one specific driver's behaviour, rather than accommodating multiple vendors' quirks.
This is one of the reasons Flutter rendering is generally smoother on iOS than on Android — not because the hardware is necessarily faster, but because the driver-level consistency eliminates a class of problems.
XNU vs Linux: the practical differences
For a Flutter developer, most kernel-level differences are invisible — your Dart code behaves the same on both platforms. But some differences surface in debugging and performance:
Page size. iOS uses 16KB pages; most Android devices use 4KB. This means iOS processes have coarser memory granularity. A 1-byte allocation wastes up to 16KB. The Dart VM's heap management and Impeller's texture allocations are both affected by page size, though the VM and engine have internal strategies to mitigate the waste.
No swap. Unlike Android (which uses zram for compressed swap, Post 5 of the Android series), iOS has no swap mechanism. When physical memory is full, the system cannot compress and defer pages — it must free them by killing processes or discarding clean pages. This makes iOS's memory management more aggressive (see Post 4 of this series, on Jetsam).
Mandatory code signing. Every executable page in an iOS process must be signed and verified. The kernel checks the signature before allowing a page to be mapped as executable. This prevents JIT compilation (which requires writing code to a page and then executing it) unless the process has a specific entitlement — which only Apple's WebKit gets. This is why Dart uses AOT compilation on iOS (no JIT in release or even in profile mode on device), while on Android, JIT is available in debug builds.
No `fork()` for apps. On Android, Zygote uses fork() to spawn app processes quickly. iOS apps don't use fork() — each app process is created fresh by the kernel (posix_spawn()). iOS achieves fast app launch through different mechanisms: the dyld shared cache (pre-linked system frameworks), aggressive memory mapping, and, on A15+ chips, hardware prefetch optimisation. The app launch architecture is covered in Post 2.
Unified kernel cache. XNU's kernel is part of a "kernelcache" — a single binary containing the kernel and all built-in I/O Kit drivers, pre-linked and optimised for the specific device. There are no loadable kernel modules at runtime. This reduces kernel attack surface and improves boot time, but means all driver code is fixed at the OS version level.
The kernel and your Flutter app
From your Flutter app's perspective on iOS, the kernel provides:
- A process with a virtual address space — where
libapp.so(actually, the Dart code is embedded in the App.framework on iOS), the Flutter engine, and the system frameworks are all mapped. - Threads — the platform thread (main thread), the Dart UI thread, the raster thread, the I/O thread, plus system framework threads.
- File descriptors — for local storage, network sockets, IPC channels.
- Memory management — virtual memory backed by physical RAM, managed through Mach VM. No swap. When memory is scarce, the kernel sends memory warnings before killing.
- IPC — Mach ports for inter-process communication (the iOS equivalent of Android's Binder, though accessed through higher-level abstractions like XPC).
The kernel doesn't know about Flutter, Dart, widgets, or state management. It sees a process that allocates memory, creates threads, opens files, sends network packets, and submits GPU commands. That's all any process is, from the kernel's perspective.
But the specific characteristics of XNU — the hybrid Mach/BSD architecture, the 16KB pages, the absence of swap, the mandatory code signing — create an environment with different constraints than Android's Linux kernel. The next nine posts explore how these constraints shape every aspect of your Flutter app's life on iOS.
This is Post 1 of the iOS Under the Surface series. Next: [App Launch: From Tap to First Frame on iOS](/blog/ios-app-launch-flutter).