Starting with the basics - Part III
Soon, we’re going to explore how an input event, such as a touch on an iPhone screen, is eventually translated into User Interface events. To make it all possible, however, the Operating System needs to be able to talk to hardware input devices. This post will focus on Apple’s Drivers Frameworks and it will briefly explore the modern approach to User Level driver development.
Exploring Apple’s drivers ecosystem
When an Apple Operating System starts up, it loads the drivers required to interact with hardware components, such as connected displays, keyboards, headsets and various integrated hardware devices, such as an iPhone’s screen and its digitizer, or a macbook’s trackpad.
At first, Apple represented physical devices using a complex set of system frameworks, libraries and tools, known as I/O Kit. Later, Apple chose to extend the set of tools, by introducing DriverKit. With MacOS 15, Apple further enriched the toolset, with CoreHID. Each new addition made the creation and maintenance of device drivers slightly more accessible and safe. IOKit components are, in their vast majority, entities running in the kernel space, as Kernel Extensions (kext). They have the potential to bring the entire operating system down, if bugs occur. With macOS 10, DriverKit introduced System Extensions and Driver Extensions (dext), which run in the user space. Unlike kernel extensions, when a driver extension crashes, the operating system (launchd
) simply restarts its process, with limited impact to the overall system. Both frameworks are primarily written in C++ and Objective-C. CoreHID, introduced with macOS 15, is a Swift-based framework.
As mentioned in the post “About (Swift) Applications and (Apple) Operating Systems”, it’s generally a good idea to analyze complex systems in a layered approach, especially if they are unfamiliar. In the context of device driver components, the highest level framework is (currently) CoreHID, followed DriverKit and ending with IOKit, the latter actually controlling the interactions with hardware devices.
At the lowest level, Apple built IOKit by extending the base C, Objective-C and C++ foundation of kernel modules, libkern. They consist of low level OS classes, which you can find in the XNU repository. These classes provide implementations for base ring buffers, meta classes (classes which define the behavior and structure of other classes and their instances) , mach port interfaces and so on.
On a slightly higher level, Apple abstracts devices as IOKit classes. They represent drivers as elements in a driver registry, they categorize components as services and clients, define IOKit abstractions on top of OS Classes (for example, the OS Class IOSharedDataQueue is abstracted as IOHIDEventQueue and IOHIDEventServiceQueue) and so on. The classes we’re interested in, in the context of Human Interface Devices, can be found in the IOHIDFamily and IOKitUserrepositories.
With DriverKit and later CoreHID, Apple further abstracts these lower level components, by providing user space accessible counterparts, via DriverKit base classes, representing Services, Dispatch Queues, Servers and Clients.
CoreHID provides Swift-based abstractions on top of Apple’s lower level Human Interface Device Classes, with its own Swift actors, enums, structs and protocols.
To see these systems in action, you can use ioreg -l
to explore the Kernel IO Registry. You can also use hidutil list
to see the HID Event System services and devices running on your Mac, as well. You can extract a tree of the known drivers on your computer (similar to Windows’ device manager, but in the terminal), allowing you to take a structured approach to understanding the ecosystem.
The diagram below showcases the elements used by the operating system to interact with a mouse connected to the system. You could start by identifying the IOHIDUserDevice element for which the Product field matches the name of your mouse ( look for "Product" =
, under a IOHIDUserDevice <class IOHIDUserDevice...>
entry. You can then find related classes by going up and down the tree. This is also a useful technique to add to your tool belt.
For comparison, the MacBook Trackpad, which is an integrated device connected to the SoC via an SPI bus, appears in the IORegistry as shown in the diagram below.
Keep in mind that IORegistry expands the inheritance chain of both IOKit and DriverKit classes. Not everything you see there is a producer or consumer of events, or even a standalone service. It essentially presents a suggestive view of object-oriented design, with deep dependency chains operating at low levels of the system.
The main purpose of a driver is to manage the flow of data to and from a connected device. In its most basic form, a device driver needs a few basic components to accomplish its purpose:
a way to describe a hardware device and the mechanisms by which the driver can interact with it (Device Service)
a memory area where it can store data it receives from the device, until the higher order components can use the data (Memory Buffers)
a way to describe higher order operating system elements, which would consume data from the device, as well as mechanisms to interact with those higher order components. (Operating System Services)
a mechanism to signal when the Device or OS services to consume data from the memory buffers
a mechanism to properly orchestrate access to memory, in very tightly synchronized order. Otherwise, the OS could read the wrong data at the wrong time, or the device could try to overwrite the data the OS only partly read.
With these basic components, you can manage the process of collecting data from the device, storing it into a memory buffer, then passing it to the operating system clients. You can also manage the reverse process, of writing instructions from the OS Services to a memory buffer, then forwarding those commands to the device. All of this relatively safely, in the context of a very busy parallel and concurrent system.
Modern Operating Systems rely on a large variety of devices and connections, to deliver the functionality we’re accustomed to, as end users. All operating systems rely on some form of a Device Tree, to discover and manage devices connected to the system.
The diagram below showcases the main base classes used by IOKit, with the exception of low level work handling classes (IOWorkLoop, IOCommandGate, OSAction etc). IOKit is a pure object oriented framework, based on the conventions and language features provided by the C-class family (C, C++ and, more notably, Objective-C). It relies heavily on inheritance and composition, where objects are structured as descendants of parent classes, while their functionality is composed through the integration with other classes.
In IOKit, both drivers and services that interact with drivers inherit from the IOService class, which is a subclass of IORegistryEntry. Together, these two classes define how an object fits into the IOKit device tree, as well as the resources it requires in order to deliver its functionality. Device drivers are all grouped into Driver Families, which offer abstractions common to specific types of hardware devices. Typically, drivers are clients of the abstractions that describe their underlying bus (for example, USB) and they inherit from abstractions that describe their purpose (for example, HID).
Using Driver Interfaces, IOKit describes the flow of data, from transport level drivers (such as AppleUSBHIDDriver) to higher order EventServices. In IOKit jargon, driver interfaces decode device reports and bundle their components into IOElement or IOHIDElement objects, to be further processed by IOHIDDevice objects. With IOUserHIDEventService (a service based on IOHIDEventService), drivers gain the ability to process the device-specific report messages, into generic, IOKit native IOHIDEvents.
Finally, leveraging IOHIDEventQueues and IOUserClients, drivers gain the ability to efficiently surface those IOHIDEvents to higher order services.
Both iOS and macOS rely on processes running in the user space to act as a bridge between the kernel and various end-user facing applications. On macOS, this process is the WindowServer. On iOS, it’s the backboardd daemon. Both WindowServer and backboardd use the system-wide singleton IOHIDUserClient (a subclass of IOUserClient) to register as clients that consume IOHIDEvents.
For every Human Interface Device, backboardd and WindowServer also have associated IOHIDEventServiceUserClient instances, which set up a shared memory area, implemented as a ring buffer.
Traces captured in Instruments also expose part of this model. They indicate that backboardd receives input events via IOHIDEventServicePlugin, as shown in the screenshot below.
To transfer data from the kernel space to the user space, Apple relies heavily on Mach Messages and Mach Ports (provided by the Mach Kernel). In Apple’s ecosystem, this is the preferred Inter-Process Communication mechanism. This is especially true for cross-boundary scenarios (user space to kernel or vice-versa). Apple chose these constructs for their lower level and foundational architecture, because they enable asynchronous information delivery with built-in security and flow control. Because it’s a pattern you would encounter often in Apple’s ecosystem, especially while debugging, it’s useful to know how it works.
You will likely never use Mach Ports in applications you would release on the App Store, because Apple provides dedicated, higher level abstractions (such as CFMachPort and XPC).
There are two main models for using Mach ports for inter process communication. The first is a notification-based model, where a client process sends a notification (an empty message) to a server process listening on a dedicated port, as a signal that data is available or that a specific event has occurred. This was also the very first mechanism supported by the operating system. These notifications do not carry any payload; instead, they prompt the receiver to take further action, typically involving reading from the memory area associated with the port.
The second model is message-based, resembling higher level communication patterns. In this case, a Mach port client communicates with a Mach port server through a full message, with a payload. This mechanism is more commonly used for higher order components, such as user space process-to-process communication.
Access to Mach ports is controlled through integer-based flags known as Mach Port Rights, indicating operations that a process may perform over the port.
If you are familiar with Linux constructs, Mach Ports essentially work as Unix Pipes.
Apple also provides a higher level framework, which it recommends when developing modern software requiring Inter Process Communication. This is the XPC Services framework, which leverages the orchestration capabilities of the launchd daemon and the Mach Kernel’s Mach Ports.
Regardless of the model used for communication (notification or message), the kernel manages interactions via Mach Ports in the same fundamental way. When a port is registered, the thread that will use it performs its setup, then eventually blocks on the port. In other words, it enters a waiting, SUSPENDED state, until the kernel reactivates it to resume the execution of its loop. Only a single task can listen on a given Mach port at a time, so only a single thread is ever blocked on it.
When data is written into a Mach port, the kernel marks the associated listener thread as RUNNABLE. Then, the Mach Scheduler assigns the thread to a CPU Core. When the core is ready to take new work and change contexts, it picks up the runnable thread and executes its instructions. Generally, the thread dequeues data structures, in an operation is known as draining the port. Apple also describes this process in the Kernel Programming Guide.
To Be Continued…