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 probeinitattachstart 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.