Skip to content

How a USB device gets driven on macOS

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.

Published 6 min read
USB device flow on macOSFull path of a USB device from physical plug-in through host controller enumeration, IOKit matching, driver attachment, to a user app gaining access via IOServiceOpen.HARDWAREdevice plugged inport asserts a lineUSB host controllerXHCI / AOAP raises IRQenumerationGET_DESCRIPTOR · assign addressIOUSBHostDevicepublished in IORegistryIOKit matching enginescore every registered driverWINNING DRIVERin-kernel kext path(Apple drivers, HID, audio…)probe()init()attach()start() — open pipes, claim interface, registerDriverKit dext path(third-party serial, USB audio…)launchd spawns dextIOUserServer proxysame probe/init/attach/start, via Mach IPCUSER-SPACE APPIOServiceOpen → IOUserClient → IOConnectCallMethodentitlements gate access

You plug a USB device into a Mac. A few seconds later, an app can talk to it. In between, the IOKit subsystem walks a long sequence: detect the device, enumerate its descriptors, match it against registered drivers, load (or spawn) the driver, and finally expose it to user-space.

This article traces one device's journey end to end, with the right files in apple-oss-distributions cited at every stop.

Step 1: hardware notifies the host controller

The USB host controller (an XHCI device on modern Macs, AOAP on Apple Silicon) raises an interrupt when a device is connected. The interrupt goes through the AIC and lands in the host controller driver's interrupt handler.

The host controller driver:

  1. Reads the controller's status registers to identify which port has activity.
  2. Resets the port and powers it up.
  3. Performs USB enumeration: sends GET_DESCRIPTOR to address 0, gets back the device descriptor, assigns the device an address, then reads the configuration descriptors.
  4. Publishes a new IOUSBHostDevice in the IORegistry representing the freshly-enumerated device.
apple-oss-distributions/xnuiokit/DriversUSB host controller and IOUSBHost framework live here in the closed-source tree.View on GitHub(line —)

The device descriptor includes:

  • Vendor ID (e.g. 0x05AC for Apple).
  • Product ID (e.g. 0x024F for a Magic Trackpad).
  • Class / Subclass / Protocol codes (e.g. 0x03/0x01/0x02 = HID / Boot Interface / Mouse).
  • USB version, max packet size, number of configurations.

Step 2: IOKit matching

The publication of IOUSBHostDevice triggers IOKit's matching engine (see the IOKit overview). Every registered driver with IOProviderClass = IOUSBHostDevice (or a more specific class for matched interfaces) gets a chance to claim the device.

For a USB device, matching keys typically include:

  • idVendor and idProduct for exact device matches.
  • bDeviceClass / bDeviceSubClass / bDeviceProtocol for class-based matches.
  • bInterfaceClass / etc. for matching against a specific interface within a multi-interface device.

A vendor-specific driver matching by VID/PID outscores a generic class driver. So Apple's AppleHIDMagicTrackpad driver beats the generic IOHIDFamily driver for a Magic Trackpad; both can match, but the vendor-specific one wins.

See the IOKit matching diagram for how scoring works.

Step 3: driver load (kext or dext)

The winning driver is invoked. What happens next depends on whether it's an in-kernel kext or a userspace DriverKit dext:

For an in-kernel kext

If the driver is part of the kernelcache (Apple's USB drivers are; some third-party kexts can be, with user approval):

  1. IOService::probe is called. The driver reads the device descriptors via IOUSBHost and confirms it can handle this specific device.
  2. If probe returns a positive score, init is called, then attach, then start.
  3. start is where the driver does its real work: claim interfaces, open pipes, configure the device, register itself as a service.

For a DriverKit dext

If the driver is a .dext (newer third-party USB drivers, e.g. for novel USB audio interfaces):

  1. The kernel asks launchd to spawn the dext process if not already running.
  2. The dext process loads DriverKit.framework and instantiates the driver class.
  3. The kernel-side IOUserServer proxies the driver's IOService presence into the IORegistry.
  4. The same probe → init → attach → start lifecycle runs, but every callback crosses the Mach-IPC boundary.

See the DriverKit article for the full mechanism.

For our Magic Trackpad example, AppleHIDMagicTrackpad is a kext (Apple's own HID drivers are in-kernel for latency reasons). Its start method:

  1. Opens the HID interrupt-in pipe.
  2. Schedules a recurring read on the pipe to get HID input reports.
  3. Publishes itself as an IOHIDDevice so the HID framework can use it.

Step 4: HID processing

IOHIDFamily is itself a registered driver layered on top. It matches against IOHIDDevice services and provides:

  • Report descriptor parsing — turning the device's HID descriptor into a structured "this device produces a 4-byte report containing 2 buttons + an X delta + a Y delta + a wheel."
  • Event generation — converting incoming reports into HID events the system understands.
  • Multi-touch support — for trackpads, this is where finger contacts get tracked across frames.

The HID events feed up to the WindowServer (which owns mouse and trackpad event delivery to apps) via a private XPC interface.

apple-oss-distributions/xnuiokit/DriversIOHIDFamily lives here in spirit; the kernel-side bits are closed-source for HID-specific reasons.View on GitHub(line —)

Step 5: user-space app opens the device (alternative path)

For non-HID devices — say, a USB serial adapter — the user-space SDK is the natural endpoint. An app opens the device via IOServiceOpen:

  1. The app calls IOServiceMatching("AppleUSBSerial") to get matching dictionary for the driver class.
  2. Iterates the IORegistry, finds the device.
  3. Calls IOServiceOpen(device, mach_task_self(), 0, &connection).
  4. The kernel verifies the app has the right entitlements (com.apple.developer.driverkit.usb for DriverKit clients).
  5. The driver's IOUserClient subclass is instantiated, exposed back to the app as a Mach port.
  6. The app calls IOConnectCallMethod to invoke driver-defined methods through the user client.

For USB specifically, the modern path is IOUSBHost.framework in user-space, which wraps the IOUserClient machinery. An app uses IOUSBHostObject (Objective-C) to open the device, claim an interface, do bulk/control transfers.

Step 6: hot-unplug — running everything in reverse

When the user pulls the device out:

  1. Host controller raises a port-down interrupt.
  2. Host controller driver publishes the disappearance: terminates the IOUSBHostDevice in the IORegistry.
  3. Every driver that matched the device gets its willTerminate callback. They stop in-flight operations, close pipes.
  4. didTerminate follows; the drivers free their resources.
  5. The IORegistry node is removed.
  6. For DriverKit dexts: when the last device the dext was driving disappears, the kernel terminates the dext process (saves memory).
  7. User-space apps using the device get notifications via their IOServiceMatching subscriptions and clean up.

The lifecycle from plug-in to working — and back — is fully automated. Userspace apps never have to poll for device presence; they subscribe to matching notifications and react.

Notable USB-specific bits

  • USB power negotiation: a Mac can supply 500 mA at 5V by default; some Macs and USB-C devices can do USB Power Delivery for more. IOKit's power-management graph integrates with USB PD negotiations.
  • USB hubs are themselves devices. They get their own IOUSBHostDevice and driver, which then enumerates downstream devices.
  • USB-C identity: USB-C cables and adapters identify themselves; the host queries which alternate mode (DisplayPort, Thunderbolt, plain USB) to use.
  • USB On-The-Go on iPad: an iPad can host devices, so IOUSBHost runs on iOS too — same code path.

Observing on a live system

  • ioreg -p IOUSB -l — shows the USB part of the IORegistry. Every plugged-in device with its descriptor properties.
  • system_profiler SPUSBDataType — pretty-printed device list with the same info.
  • Console.app filtered to com.apple.iousbfamily — driver match/load events in real time.
  • USB Prober (in additional developer tools) — packet-level USB tracing.

What surprises newcomers

  • Enumeration happens before driver matching. The kernel knows what the device is (vendor, product, descriptors) before any driver gets to see it.
  • A device may have multiple drivers. A composite USB device (interfaces for audio + video + control) can have a different driver bound to each interface.
  • DriverKit dexts spawn and terminate on hardware presence. Plug a device, dext starts; unplug, dext exits. The process isn't long-lived.
  • HID has its own framework on top of USB. For mice/keyboards/trackpads, the USB driver is the lower half; the HID processing layer adds the input-event semantics.

apple-oss-distributions/xnuiokit/Kernel/IOService.cppIOService::probe / init / attach / start — the lifecycle every driver implements.View on GitHub(line —) apple-oss-distributions/xnuiokit/Kernel/IOUserClient.cppIOUserClient — the kernel ↔ user-space bridge for every driver's app-facing API.View on GitHub(line —)

And the IOKit overview for the matching engine this article assumes.

Related

Same IOKit object model, userland process. Why kexts are dying, what DriverKit gives you, and how a USB driver actually crosses the boundary.
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.
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.