When the request for a driver isn’t a READ and it isn’t a WRITE, what is it? Why, it’s an IOCTL, of course! IOCTL is the common nickname for "I/O Control". IOCTLs are the types of functions that are passed to drivers to cause them to perform an operation of type "other". You get to define your own IOCTLs that will allow you to control specific activities or attributes of your device. This article explains how.
Unlike IRP_MJ_READ and IRP_MJ_WRITE, for which TransferType is defined according to flags in the device object, IOCTLs define their TransferType on a per I/O control code basis. When combined with the fact that IOCTLs may utilize up to two buffers (one for input and one for output), the whole business of defining IOCTLs is perhaps one of the most confusing aspects of writing NT Drivers. Perhaps we can help.
I/O control codes are usually defined using the CTL_CODE macro, as follows:
CTL_CODE(DeviceType, Function, TransferType, RequiredAccess)
- DeviceType – is the Type field from the device object (e.g. FILE_DEVICE_FOO) which uniquely identifies the device. DeviceTypes in the range of 32768 to 65535 are reserved for customer (i.e. non-Microsoft) defined devices;
- Function – is the code used to uniquely identify this IOCTL function to the driver. User-defined function codes are in the range of 2048-4095;
- TransferType – indicates the method by which user data buffers specified on the DeviceIoControl(...) request will be described. Legal values for TransferType are METHOD_BUFFERED, METHOD_IN_DIRECT, METHOD_OUT_DIRECT, and METHOD_NEITHER;
- RequiredAccess – specifies the minimum required access the user needed to specify when the device was opened for this IOCTL to be allowed. Legal values are FILE_ANY_ACCESS, FILE_READ_DATA, and FILE_WRITE_DATA;
The DeviceType field is simply a number you arbitrarily assign to your driver from the "customer defined" range. Don’t worry about picking a DeviceType that’s already been chosen by another driver. Which DeviceType you pick really doesn’t matter. Just pick one value and use that for all the IOCTLs you define for a particular driver.
Function is a code that uniquely identifies the IOCTL to the driver. Again, when you define your IOCTL just arbitrarily pick a value from the user-defined range. This really is pretty easy stuff.
So what’s the problem? Well, let’s start with the fact that the DeviceIoControl(...)Win32 API function allows up to two user buffers, as follows:
HANDLE Device, // Code
DWORD IoControlCode, // Device handle
LPVOID InBuffer, // Buffer TO driver
DWORD InBufLen, // Size of InBuffer
LPVOID OutBuffer, // Buffer FROM driver
DWORD OutBufLen, // Size of OutBuffer
LPDWORD BytesReturned, // Bytes output
LPOVERLAPPED Overlapped); // Overlapped struc
Note the unusual sense of the "input" and "output" buffers. Ordinarily, a user would probably think of an input buffer as one which contains input to their program, as from a ReadFile(...) function. However, the sense of input and output for the buffers on an IOCTL is reversed: According to the definition, the InBuffer in the IOCTL is the buffer containing data going to the driver, and the OutBuffer in the IOCTL is the buffer containing data coming from the driver. This distinction of input buffer and output buffer is really an arbitrary one, though, since the output buffer may be used for both output and input data under certain circumstances. Also, don’t forget that both of these buffers need not be specified for every IOCTL. In most cases, in fact, you’ll only need one. Given the confusion, we would all have been better off if these buffers had named BufferOne and BufferTwo. So, how do we get at the data in each of these two buffers? Well, that depends on the TransferType indicated in the control code definition.
First of all, let’s deal with the case when TransferType is METHOD_NEITHER. In this case, the user-mode virtual address specified for Outbuffer in the IOCTL is placed in Irp->UserBuffer, and the user-mode virtual address specified for InBuffer is placed in the I/O Stack Location at IoStack->Parameters.DeviceIoControl.Type3InputBufffer. No buffering or copying of data is performed. Of course, for a driver to properly make use of the passed user-mode virtual addresses it must be certain that its IOCTL dispatch function will be called in the context of the requesting user thread.
Next let’s consider the remaining transfer types: BUFFERED, IN_DIRECT, and OUT_DIRECT. For these types, the most important thing to understand is that the input buffer for the IOCTL is always treated as buffered I/O, with data moving from the user’s buffer to the driver. Thus, if the user specifies an input buffer in a DeviceIoControl(...) function call, the data supplied in that input buffer will be copied to (non-paged) system pool and a system virtual address pointer to that data will be supplied to the driver in Irp->AssociatedIrp.SystemBuffer. Finally, note that the data will not be copied back to the input buffer by the I/O Manager on I/O Completion under any circumstance.
Hence, for BUFFERED, IN_DIRECT, and OUT_DIRECT the TransferType argument only really relates to the output buffer on the DeviceIoControl(...) function call. If the TransferType specified was METHOD_BUFFERED, the I/O manager allocates its system pool buffer large enough to accommodate the larger of the input buffer and the output buffer. The driver puts the data to be returned to the user buffer in the buffer pointed to by Irp->AssociatedIrp.SystemBuffer. When the IRP is completed and the requesting thread is next scheduled to run, the data in the system buffer is copied from pool to the output buffer. Of course, if there was an input buffer supplied on the DeviceIoControl(...) function call the data from the input buffer was copied to the system pool prior to the I/O Manager passing the IRP to the driver. Thus, the driver over-writes this data in system pool with the data to be copied back to the output buffer on I/O completion.
If METHOD_IN_DIRECT or METHOD_OUT_DIRECT was specified, the output buffer is treated as direct I/O. That is, the output buffer in the user process is locked into memory and an MDL is built describing it. The MDL address is supplied to the driver in its customary Irp->MdlAddress location. The only difference between these methods is how the user’s buffer is probed for accessibility prior to its being locked. For METHOD_IN_DIRECT the user’s buffer is checked for READ access (input to the driver). For METHOD_OUT_DIRECT the user’s buffer is checked to ensure WRITE access. Thus, the output buffer can in fact be used for direct I/O input, output, or both.