What is a driver
Drivers are extensions of the Minoca OS kernel that add support or functionality, usually for a specific device or a class of devices. Drivers in Minoca OS run in the same address space and with the same privileges as the kernel itself. This enables Minoca OS to run on a wide range of processor architectures with blazing fast performance, but means that driver code must be carefully written. A bug in a driver can affect the stability of the system as a whole.
Who should write a driver
Generally drivers are written by the manufacturer of a device, or someone intimately familiar with the functioning of a particular device. Minoca OS comes bundled with a set of drivers that support a range of devices. If your device is not supported and you're familiar with how it works, then the driver model is the API you would use to add support for your device to the system. Folks who have written drivers before should find this interface intuitive and familiar.
Drivers run in kernel mode alongside the kernel itself and get invoked via C language callbacks that handle I/O requests, device state changes, and other events. Drivers link against the kernel to get access to the driver model API. By defining an API for drivers to use (and not simply exporting every kernel function), innovation can continue in the kernel without necessarily disrupting the driver ecosystem. This is what makes automatic system updates possible, even with custom third-party drivers.
At a high level, a driver initializes itself by registering its functions with the system inside its DriverEntry routine. The system in turn calls the driver when a new device that the driver supports is detected, and the driver can then decide whether or not to bind to the device. The kernel uses the driver's registered callbacks to send the driver IRPs (I/O Request Packets) for the driver to handle. These IRPs request everything from device enumeration and I/O to device teardown and removal. An IRP is simply a structure that encapsulates the data in a request. Drivers may also send IRPs to themselves or other devices.
The driver stack
For most devices, multiple drivers work together to manage the device. Devices are enumerated by a parent driver, known as a bus driver, and then additionally attached to a driver known as the function driver via the AddDevice routine.
The bus driver manages aspects of the device that are common to that class of devices. For example, a RealTek PCI ethernet card would have the PCI driver as it's bus driver. The PCI driver would be responsible for powering on the device, reading the BARs (Base Address Registers), and programming back into the device the resources assigned by the operating system. The function driver is the RealTek driver that knows how to communicate with the ethernet hardware specifically. It would be responsible for reading the MAC address out of the device, as well as sending and receiving network packets. The coordination of the bus driver and function driver eliminates the need for the function driver to duplicate boilerplate PCI functionality. As another example, the USB Hub driver acts as the bus driver for all USB devices.
The set of drivers associated with a particular device are known as a driver stack. In addition to the function driver and bus driver mentioned above, filter drivers can also be on the stack in various positions. Filter drivers generally don't provide the bulk of the device functionality themselves, but observe and modify IRPs in flight. For example, a filter driver might be used to provide disk-level encryption, encrypting the data just before it's written and decrypting it just after it's read. The driver stack model is extremely flexible as it allows filters to be installed at any position in the stack. Most device stacks have no filters, so they contain two drivers: the bus driver on the bottom and the function driver on the top.
I/O Request Packets (IRPs)
I/O Request Packets, or IRPs, are sent to the driver via its registered callback routines. They may contain a state change request, like the Start Device or Remove Device IRP, or an I/O request like the ReadWrite IRP. When a driver has satisfied the request, it calls IoCompleteIrp to signal to the system that the request has been met.
When IRPs are sent to the driver stack, they flow down the driver stack from the top and then back up. This means that in some cases each driver is called twice for each IRP, once for the "down" direction and once for the "up" direction. In the absence of any filter drivers and if no drivers complete the IRP, the function driver is called first (the IRP is going down), then the bus driver (also in the down direction), then the bus driver again (this time for the up direction), and finally the function driver again (up). A driver that handles the request can call IoCompleteIrp to halt the IRP's flow down the stack and start it going back up the stack. This means for example that if a function driver completes a ReadWrite IRP, the bus driver will never get called as the IRP was turned around before it reached the bus driver. Though any driver can complete any IRP, there are generally conventions for which drivers should complete which IRPs. For example, the function driver generally completes I/O IRPs, as the function driver generally knows how to perform I/O on the device. On the other hand, the bus driver usually completes the Start Device IRP, as it knows how to provide power to the device. The function driver in this case is careful to perform its own initialization of the device only when the IRP is heading in the "Up" direction after a successful completion by the bus driver. Otherwise, the function driver runs the risk of touching a device that is not powered on. The IRP model is powerful as it allows great flexibility in terms of how drivers interact and coordinate with each other.
Participation in the file system
All drivers have the opportunity to participate in the file system, but are not required to. Participating in the file system means enumerating a tree of directories and files, and handling I/O when those files are opened, closed, deleted, renamed, etc. Minimally participating in the file system is relatively easy. Even implementing a full file system is much more straightforward than on most operating systems.
Asynchronous I/O using the IRP model is quite easy. A driver that wishes to pause the flow of an IRP can call IoPendIrp. When the driver returns from its dispatch function, the system will move on to other things. Often the flow works like this: the driver accepts an I/O request and programs the device to start the I/O. It calls IoPendIrp to pause the IRP flow, and returns from its DispatchIo function. Later, the device interrupts the system to indicate that the I/O has completed. The driver schedules a work item to check the outcome of the I/O request. It then calls IoCompleteIrp or IoContinueIrp to resume processing and completion of the IRP.
The list below enumerates the high level driver functions called by the kernel. With the exception of the DriverEntry entry point, all of these functions are registered with the kernel via a call to IoRegisterDriverFunctions.
DriverEntry (required) - A driver's life cycle begins with a call to the driver's entry point, a function often named DriverEntry. The DriverEntry function takes a single parameter, a pointer to a token representing the driver image itself. It returns a KSTATUS code indicating if the driver load was successful. If a failing status is returned, the driver is immediately unloaded and no further action is taken. Within the DriverEntry function, the driver should initialize any driver-wide structures, and call IoRegisterDriverFunctions to give the kernel pointers to the remainder of the driver's functions. The kernel calls these functions when a new event occurs that the driver should process. The functions a driver can implemented are listed below.
Unload (optional) - The unload function is called after all devices serviced by the driver are removed and destroyed, just before the driver image is unloaded from memory. The driver can use this function to tear down any structures created during the DriverEntry function.
AddDevice (required) - This function is called when a new device appears to the system that the driver may or may not handle. The driver is passed a pointer to the device object, as well as the device ID. If the driver recognizes the device ID, it calls IoAttachDriverToDevice, which binds the driver to the device. An external database manages the information of which drivers' AddDevice routines are called for a given device ID. If the driver chooses to attach to the device, it will begin receiving callbacks regarding the device. The driver can set up software resources to manage the device during AddDevice, but does not attempt to touch or start the device during AddDevice, as it has no resources and may be powered off.
DispatchStateChange (required) - This function is called when one of the devices the driver manages is undergoing some sort of change. For example, shortly after the driver attaches to the device in its AddDevice routine, DispatchStateChange is called several times to enumerate and start the device. A typical enumeration sequence would call DispatchStateChange three times: once to query the device's resource requirements, once to start the device with the resources the system has assigned to it, and a final time to enumerate any child devices the device may have. Most devices do not have children, but devices like a USB Hub or PCI bus would. DispatchStateChange is also called when a device is removed from the system.
DispatchOpen (optional) - This function is called to open a handle to a started device. I/O is then done on this handle.
DispatchClose (optional) - This function is the opposite of DispatchOpen. It is called to close a handle to a started device. The driver would use this call to clean up any resources associated with the open handle.
DispatchIo (optional) - This function is called to do reads from or writes to the device. It takes in an open handle to the device, and performs the I/O.
DispatchSystemControl (optional) - This function responds to standard system requests. For devices that choose to participate in the file system (or drivers implementing file systems), this function is called with operations like create, delete, rename, lookup, and truncate. Think of it like a standardized version of the ioctl() function that cannot be called directly by user-mode applications.
DispatchUserControl (optional) - This function responds to non-standard system requests. This essentially backs the ioctl function in user-mode, and allows applications to perform device-specific control operations on an open device handle.