Skip to content

Interrupt handling in IOKit: from device IRQ to driver callback

The full path of an interrupt from the device asserting a line, through the AIC, through XNU's exception handler, to the driver's IOInterruptDispatchSource callback running on a workloop.

Published 5 min read
IOKit interrupt handling pathFull interrupt path on Apple Silicon: device → AIC → CPU exception → kernel handler → top-half filter → workloop enqueue → bottom-half driver callback.HARDWAREdeviceasserts interrupt lineAICApple Interrupt Controllerroute to P-coreCPU exceptionEL1 IRQ vectorKernelIRQ disabled · top-half contextsleh.c handlerosfmk/arm64/sleh.ctop-half filterack device · mask sourcescheduleenqueue on workloopIOInterruptEventSourceTOP HALF / BOTTOM HALFworkloop thread wakesschedulable contextBOTTOM HALF — DRIVER CALLBACKdriver action callbackcopy buffers · update queues · wake waitersDriverKit: same shape, in userspaceextra Mach hop adds ~µs latency · crashes isolatedTop-half: microseconds, no sleep, no locks. Bottom-half: schedulable thread, real work happens here.

When a device wants the CPU's attention — a network packet arrived, a USB endpoint has data, a timer expired — it raises an interrupt. The path from that hardware event to the driver callback that handles it crosses several layers and a couple of synchronization barriers. This article walks the full path.

Step 1: device asserts an interrupt line

The device is wired to an interrupt input on the AIC — the Apple Interrupt Controller. The AIC is the SoC's interrupt aggregator; every interrupt source on the chip (the GPU, every PCIe device, every USB controller, every sensor) routes through it.

apple-oss-distributions/xnuiokit/DriversApple drivers — the AIC driver lives in spirit here even if specific bits are closed.View on GitHub(line )

The AIC has its own priority and routing logic:

  • Per-source mask/enable bits.
  • Per-source target — which CPU cluster (P or E) receives the interrupt.
  • Priority-based dispatch — high-priority interrupts preempt low-priority ones.

When a device's line asserts, the AIC selects a target CPU (typically a P-core for interactive interrupts, can be routed to E-cores for less-time-critical ones), records the source ID, and raises an interrupt on the chosen core.

Step 2: CPU exception → XNU handler

The target CPU drops out of whatever it was doing and traps into its exception vector at EL1. XNU's exception handler (sleh.c on ARM64) decodes the exception:

  • Synchronous exception — instruction fault, page fault, syscall.
  • IRQ — external interrupt (our case).
  • FIQ — fast interrupt, used for very-low-latency things like the kernel debugger.
apple-oss-distributions/xnuosfmk/arm64/sleh.cARM64 exception/IRQ entry — first responder for every CPU trap.View on GitHub(line )

For an IRQ, the handler:

  1. Saves the interrupted thread's user-mode register state.
  2. Reads the AIC's "what fired" register to identify the source.
  3. Dispatches to the registered handler for that source.

Step 3: registered handler — bottom half vs top half

XNU follows the classic top-half / bottom-half split:

  • Top half (the "primary" handler) runs in interrupt context — interrupts disabled on the local CPU, can't sleep, can't take most locks, can't allocate memory. Job: acknowledge the device, mask the interrupt source, schedule the bottom half.
  • Bottom half (the "secondary" or "deferred" handler) runs in a normal thread context — can sleep, take locks, allocate. Job: do the actual work — copy the packet, update queues, wake threads waiting on data.

The top half must be fast. Microseconds. Anything that could take longer goes to the bottom half.

Step 4: IOInterruptDispatchSource — IOKit's abstraction

A driver doesn't write the top half directly. IOKit provides IOInterruptDispatchSource — an object that:

  • Registers a top-half handler with the kernel.
  • Receives the kernel's call when the interrupt fires.
  • Schedules a bottom-half callback on the driver's workloop (or, for DriverKit dexts, on a dispatch queue in the userspace process).
apple-oss-distributions/xnuiokit/Kernel/IOInterruptEventSource.cppThe classic in-kernel interrupt event source — DispatchSource is its userspace-friendly evolution.View on GitHub(line )

The driver author writes:

  • An interrupt filter — runs in top-half context, decides "is this for me?", acknowledges the device.
  • An interrupt action — runs in bottom-half context, does the real work.

The split lets you separate "must run NOW" from "do the work safely."

Step 5: the workloop — single-threaded execution

Most IOKit drivers use a workloop — a thread that processes events sequentially from a queue. All bottom halves for a single driver run on the same workloop, so the driver's mutable state is implicitly protected: only one event handler runs at a time.

When the top half schedules the bottom half, it's actually enqueueing an event on the workloop. The workloop wakes, takes the event off its queue, calls the registered action.

This single-threaded model is one reason IOKit drivers are usually easier to write than equivalent Linux drivers — you don't need to worry about per-driver locks, because the workloop guarantees serial execution.

DriverKit: same shape, different process

A DriverKit dext (see the DriverKit article) gets the same IOInterruptDispatchSource abstraction, but the dispatch queue is a regular GCD queue in the userspace dext process — not a kernel workloop.

The path is:

  1. Hardware interrupt → AIC → CPU trap → kernel handler (same as in-kernel).
  2. Kernel top-half acknowledges, sends a Mach message to the dext's user-mode server.
  3. DriverKit framework in the dext process receives the message, dispatches to the registered handler on the queue.
  4. Driver code runs in userspace.

The extra Mach hop adds latency (a few microseconds). For USB peripherals this is fine; for high-end audio it can be unacceptable, which is why Core Audio's lowest-latency paths still run in-kernel.

Per-CPU interrupt routing

The AIC can route specific interrupts to specific CPU clusters. XNU uses this to:

  • Keep latency-sensitive interrupts (touch, audio) on P-cores.
  • Route low-priority device polling to E-cores.
  • Balance load across cores when many devices are active.

irqstat and kstat (when available) show per-CPU interrupt counts; you can see this routing in action by watching which cores rack up which sources.

What surprises newcomers

  • Most interrupts are spurious or shared. A driver must check "is this really for me?" before doing real work, even after AIC says it fired.
  • The kernel can lose interrupts if the same source fires twice while the first is being handled with the source masked. Drivers must check device-side status after unmasking.
  • The workloop is a thread, not a goroutine. It has a stack, a name (com.apple.AppleBCM43xx-workloop or similar), and shows up in ps. Tracking down "what's running on this Mac" includes the workloops.
  • DriverKit's userspace interrupt handling is a tradeoff. A 5 µs Mach hop vs millisecond panic-on-crash recovery — Apple's bet is the latter is worth more for almost every device class.

apple-oss-distributions/xnuiokit/Kernel/IOWorkLoop.cppThe workloop implementation — single-threaded event dispatch for IOKit drivers.View on GitHub(line ) apple-oss-distributions/xnuiokit/Kernel/IOService.cppIOService — every driver's base class; ties power management + interrupts + matching together.View on GitHub(line )

And the IOKit overview — interrupts are how driver code actually runs.

Related

From plug-in to working app — IOUSBHost enumeration, IOKit matching, the DriverKit dext load, the user-space SDK. A complete trace of one device's journey through the stack.
How XNU tells every driver to drop power when idle and bring it back when needed — the IORegistry-walked power graph, IOPMrootDomain, and the sleep/wake choreography.
Same IOKit object model, userland process. Why kexts are dying, what DriverKit gives you, and how a USB driver actually crosses the boundary.