Just as you need to pick the right key to open a specific lock, an operating system needs to pick the right driver for a specific device. At Apriorit, we have vast experience developing drivers for all kinds of devices and operating systems. The focus of this article is matching devices to drivers; we also discuss a framework that can help you do this smoothly: I/O Kit.
We talk about the main architectural components of I/O Kit and how this framework determines the driver that best fits a particular device. Then we describe how the mechanism behind device and driver matching can be used for building a simple USB device blocker driver.
I/O Kit is a popular collection of system frameworks, tools, and libraries that developers can use to build device drivers for the macOS and iOS operating systems. Using the device-interface mechanism, the I/O Kit framework implements non-kernel access to its main objects, drivers, and nubs.
- A nub is a connection point for a driver. Nubs represent controllable entities such as disks, graphics adapters, and buses.
- A driver is responsible for managing a particular device, logical service, or bus. It presents an abstract view of that device to other parts of the system.
I/O Kit is actively used by both driver developers and application developers. Its most common use is for building kernel-resident drivers, while its second most common use is for communicating with hardware.
Automatic device-driver management is one of the main features of I/O Kit. This framework can automatically load the most suitable driver for a device (if one is found) when the device is connected to the operating system and unload the driver when it’s no longer needed.
Now let’s take a closer look at the main components of the I/O Kit framework and the properties of I/O Kit drivers that play a critical role in the device-driver matching process.
In macOS systems, all kernel extensions (kexts) are implemented as bundles. An I/O Kit device driver is basically a type of kext that tells the kernel how to handle a particular device.
A kext bundle usually contains at least two components:
- An information property list (info.plist) – A text file written in Extensible Markup Language (XML) format. It includes detailed information about the contents, requirements, and settings of a particular driver.
- An executable – A kext binary file with executable code. In some cases, a kext may not include an executable.
A kext bundle may also include optional resources and plugins, including other kexts.
One of the info.plist properties that’s required for I/O Kit drivers is IOKitPersonalities. This property specifies hardware devices supported by a specific driver. Without this information, I/O Kit doesn’t know when (and how) to load this driver.
I/O Kit also provides a special nub, IOResources, that can be used as the provider of a driver with no hardware devices (e.g. drivers of virtual devices).
Properties used for matching a driver to a device are gathered into dictionaries. There may be one or more matching dictionaries for a driver. The basic keys are listed below:
- IOClass – Names the C++ class that will be instantiated by I/O Kit if a driver matches a nub.
- IOProviderClass – Defines a driver’s provider class type. Usually, it’s the nub controlling the port that the device connects to.
- IOMatchCategory – Allows multiple drivers to match the same provider class. A driver should include this property if it matches IOResources or is on a port with multiple devices attached to it.
- IOResourceMatch – Specifies a dependency to a specific resource. If the specified resource is unavailable, a driver won’t be loaded into the kernel.
- IOProbeScore – Specifies the initial match score for a particular driver. The purpose of the probe score will be covered in the following sections.
- IOKitDebug – Indicates specific I/O Kit events that should be logged in the kernel log (/var/log/kernel.log).
- IOUserClient – Defines the class type of the driver’s user interface.
A driver can also be matched against a specific device based on a property such as vendor or device ID.
Before we move to the process of device and driver matching, we should say a few words about I/O Registry and I/O Catalog. Both of these components are actively used to find the matching driver for a device.
I/O Registry is a dynamic database describing a collection of all drivers, families, and nubs. It’s noteworthy that I/O Registry represents only “live” objects that are currently connected to the system.
I/O Registry tracks the provider-client relationships between drivers and nubs, and describes the current hardware configuration. The I/O Registry database can be pictured as an inverted tree, where each object descends from a parent node (its provider) and can have one or several child nodes.
In the I/O Kit framework, I/O Registry plays a critical part in supporting dynamic features of the operating system. For instance, the operating system finds and automatically loads required drivers every time new hardware is connected. Then it updates information in the I/O Registry.
It’s noteworthy that the I/O Registry is never stored to disk or archived between boots and only resides in system memory.
I/O Catalog, on the other hand, is a database of all drivers available in the system. So when a nub discovers a new device, it sends a request to the I/O Catalog for the list of drivers for the device’s family.
You can view the I/O Registry tree using the IORegistryExplorer application or its command-line analog, ioreg.
Now let’s finally move to the basics of the device and driver matching process.
When new hardware is connected to the system, I/O Kit looks for the most suitable driver from the list of candidates. The whole process of matching a driver to a device consists of three phases (see Figure 1).
Let’s take a closer look at each of these phases:
- Class matching – During this phase, I/O Kit checks if the driver’s IOProviderClass property matches the connected device.
- Passive matching – The framework examines the driver’s personalities (IOKitPersonalities) for properties specific to the provider’s family. For instance, a driver may match a specific vendor ID.
- Active matching or probing – The driver’s probe function is called for each remaining candidate. This function allows the driver to communicate with the device to see if it can drive it. The probe score returned by the driver reflects its ability to drive the device.
After all these phases, I/O Kit picks the driver with the highest probe score and starts it. If the driver starts successfully, it’s added to the I/O Registry and any remaining driver candidates are discarded. If the chosen driver is not successfully started, the framework moves to the driver with the next highest probe score and repeats the process.
During the active matching phase, I/O Kit calls the following IOService member functions, which may be overridden by the driver’s class:
- free (if the probe fails)
The init and free functions are the libkern equivalents of the constructor/destructor functions for the class. The init function takes one parameter: an OSDictionary object that contains a copy of the driver’s matching properties.
The attach function attaches the driver to the nub through registration in the I/O Registry, and the detach function detaches the driver from the nub. Rarely, drivers can override these functions for their own purposes.
The probe function has two arguments: the provider argument and a pointer to the probe score (a signed 32-bit integer). Calling this function provides the driver a chance to check the hardware and modify its default probe score as set in the personalities property.
If the driver can control the hardware, it should return an IOService subclass instance (in most cases, it’s the current object). If the driver is unable to control the device, it should return NULL.
Now it’s time we see how this mechanism works in practice. Let’s see how to use the I/O Kit driver matching logic for building a simple USB device filter.
In a nutshell, we’ll set the driver’s personality properties to match any connected USB device and then, during the active matching phase, we’ll decide whether to block the device.
VMware uses this same approach to redirect USB devices into a virtual machine.
Let’s assume that we’ve already created an I/O Kit driver project from the Xcode template.
First, we’ll create a driver class skeleton:
class com_apriorit_USBBlockerDevice : public IOService
virtual IOService *probe(IOService *pProvider, SInt32 *pi32Score) override;
virtual bool init(OSDictionary *pDictionary) override;
bool com_apriorit_USBBlockerDevice::init(OSDictionary *pDictionary)
// Initialize here
IOService* com_apriorit_USBBlockerDevice::probe(IOService *pProvider, SInt32 *pi32Score)
Then we need to set the matching properties list using the IOKitPersonalities property:
- IOClass – The name of the class that I/O Kit will instantiate when a driver matches the device. In our case, it’s the com_apriorit_USBBlockerDevice class.
- IOProviderClass – The class name of the nub to which the driver attaches itself. We want to block USB devices, so the provider class name is IOUSBDevice.
- idProduct / idVendor – Matches any USB device.
- IOProbeScore – Some initial probe score.
At this step, our driver will be one of the candidates for the connected USB device. I/O Kit will probe it during the active matching phase, but because we currently return NULL in the probe function, our driver will be discarded.
In the probe function, we can verify the device or ask the user to decide:
IOService* com_apriorit_USBBlockerDevice::probe(IOService *pProvider, SInt32 *pi32Score)
bool shouldBlockDevice = decideTheVerdict(pProvider);
*pi32Score = INT32_MAX;
Here, we call the decideTheVerdict function, which returns a boolean value that indicates whether we should block the device. If we decide to block the device, we’ll set the probe score to its maximum value and return an instance of our driver’s class.
So the whole process of blocking a connected USB device with a matched driver consists of five steps:
- When the IOUSBDevice nub is published in the I/O Registry, our driver will be one of the candidates after the first matching phase (according to the IOProviderClass property).
- Then I/O Kit will check the driver’s personalities. Because our driver matches a device with any vendor/product ID, it will still be in the list of candidates.
- During the active matching phase, we decide to block the device, and to do so, we set the maximum probe score for our driver.
- After probing all candidates, I/O Kit sorts them by probe score, from highest to lowest. Because we returned the highest probe score, our driver should be at the top of the list.
- I/O Kit will load our driver, which is actually a stub and doesn’t process the device, so the device will be blocked.
We can use the same algorithm to block a USB device through its interface. In this case, the driver’s provider class will be the IOUSBDeviceInterface nub. You can learn more about the driver matching process here.
The I/O Kit framework is helpful for efficiently matching drivers to devices. In this article, we explained what I/O Kit properties play the main role in the device and driver matching process, described the key phases of this process, and showed how you can build a simple USB filter driver with this knowledge.
At Apriorit, we have a team of skilled driver developers and qualified testers who will gladly assist you in building your custom kernel solution. Our experts know how to turn your ambitious ideas into reality.