- 驱动程序的主要功能是负责处理IO请求,绝大部分IO请求在派遣函数中完成
- 用户模式下所有对驱动程序的IO请求,全部由操作系统转化为一个叫IRP的数据结构
- 不同的IRP数据会被派遣到不同的派遣函数中
IRP
全名:输入输出请求包
两个基本属性:MajorFunction(IRP主类型)和MinorFunction(IRP子类型)
操作系统根据MajorFunction将IRP派遣到不同的派遣函数中
IRP派遣函数的注册
一般来说,NT式和WDM式驱动程序都是在DriverEntry函数中注册的派遣函数
示例代码:

DriverEntry函数的入参pDriverObject中,有个函数指针数组MajorFunction,该数组中的每个元素都记录着一个函数的地址。例子中只对四种类型的IRP设置了派遣函数,但IRP的类型并不止这四种,对于其他没有设置的IRP派遣函数,系统默认使用_IopInvalidDeviceRequest函数与之关联。在进入DriverEntry之前,操作系统会将_IopInvalidDeviceRequest填满整个MajorFunction数组
IRP与派遣函数的联系:

IRP的类型
IRP的概念与应用层中的消息类似
文件相关的应用层IO函数,CreateFile、ReadFile、WriteFile、CloseHandle等函数会使操作系统产生IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_CLOSE等不同的IRP,这些IRP会被传送到驱动程序的派遣函数中
文件相关的内核层IO函数,ZwCreateFile、ZwReadFile、ZwWriteFile、ZwClose等函数同样会创建相应的IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_CLOSE等IRP,并将IRP传送到相应驱动的派遣函数中
还有些IRP由系统组件所创建,如IRP_MJ_SHUTDOWN是在Windows的即插即用组件在即将关闭系统时发出的

对IRP的简单处理
大部分的IRP都源于文件IO处理win32API时,如CreateFile、ReadFile等
对这些IRP最简单的处理就是在相应的派遣函数中:
- 将IRP状态设置为成功,并填写操作了多少字节
- 调用IoCompleteRequest结束IRP请求
- 让派遣函数返回成功

将IRP的状态设置为STATUS_SUCCESS,此时发起IO请求的win32API(如WriteFile)会返回TRUE;
如果设置为不成功,此时发起IO请求的win32API(如WriteFile)会返回FALSE,调用GatLastError时得到的错误代码会和IRP设置的状态相一致
派遣函数还需设置这个IRP请求操作了多少个字节,例子中设置为0.
如果ReadFile产生的IRP,这个字节数代表从设备读了多少个字节
如果WriteFile产生的IRP,这个字节数代表向设备写了多少个字节
通过IoCompleteRequest结束IRP请求
void IofCompleteRequest(
PIRP Irp,
CCHAR PriorityBoost
);
IRP:代表需要被结束的IRP
PriorityBoost:代表线程恢复时的优先级别,一般设置为IO_NO_INCREMENT

win32文件API操作流程:ReadFile为例
ReadFile调用ntdll中的NtReadFile(ReadFile是win32API,NtReadFile是NativeAPI)- ntdll中的
NtReadFile进入内核模式,并调用系统服务中的NtReadFile函数 - 系统服务函数
NtReadFile创建IRP_MJ_WRITE类型的IRP,然后将这个IRP发送到某个驱动程序的派遣函数中。 - 然后系统服务函数
NtReadFile去等待一个事件,此时当前线程进入睡眠状态,即堵塞住,Pending状态 - 派遣函数中,处理完毕后,一般会将IRP请求结束,结束IRP通过
IofCompleteRequest函数,IofCompleteRequest内部会设置刚才等待的事件 - 睡眠的线程被恢复运行
通过设备的符号链接打开设备
- 每个设备都有设备名称(如
\Device\MyDDKDevice),同时可通过IoCreateSymbolicLink创建符号链接 - 要想打开设备,必须知道设备的名称
- 设备的名称无法由应用层的程序查询到,只能由内核模式下的程序查询到
- 在应用层中可以通过设备的符号链接进行访问
- 在内核模式下,符号链接以
\??\或者\DosDevices\开头 - 在用户模式下,符号链接以
\\.\开头
举例:设备的符号链接在不同模式下的写法
| 内核模式 | 用户模式 |
|---|---|
\??\MyDDKDevice | \\.\MyDDKDevice |
\DOsDevices\MyDDKDevice |

IRP与驱动和设备的关系(IO_STACK_LOCATION结构)
驱动对象会创建一个个设备对象,并将这些设备对象叠成一个垂直的结构(设备栈)
IRP请求会被操作系统发送到设备栈的顶层
如果顶层的设备处理完了IRP(即调用IofCompleteRequest函数结束IRP),则此IRP终止,不会继续向下层设备发送
如果顶层的设备未处理完IRP(即未调用IofCompleteRequest函数结束IRP),则此IRP将被继续将下层设备转发
一个IRP可能被转发多次,为了记录IRP在每层设备中做的操作,IRP有一个IO_STACK_LOCATION数组,数组的元素数目应该大于IRP所穿越的设备数。
每个IO_STACK_LOCATION元素记录着对应设备中做的操作
可通过IoGetCurrentIrpStackLocation函数获得本层设备所对应的IO_STACK_LOCATION
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
IO_STACK_LOCATION结构的MajorFunction子域记录着IRP的类型
可以在DriverEntry中将所有IRP都与同一个派遣函数关联,在派遣函数中通过调用IoGetCurrentIrpStackLocation获得设备对应的IO_STACK_LOCATION,然后再通过IO_STACK_LOCATION的MajorFunction成员,获得派遣函数入参IRP的类型
缓冲区方式读写操作设备
驱动程序所创建的设备一般共有三种读写方式:
- 缓冲区方式
- 直接方式
- 其他方式
驱动创建设备时,需要指定设备何种读写方式,即IoCreateDevice的Flag参数
| Flag | 读写方式 |
|---|---|
| DO_BUFFERED_IO | 缓冲区方式读写 |
| DO_DIRECT_IO | 直接方式读写 |
| 0 | 其他方式读写 |

读写操作一般由ReadFile和WriteFile发起,Write为例
WriteFile调用时,要求用户提供一段缓冲区,缓冲区内存放要写入的数据
然后操作系统将该缓冲区的地址传入驱动程序中
由于操作系统多线程机制,随时切换到其他线程,驱动程序再处理WriteFile调用时,拿到的地址是应用层的地址,而应用层的地址不同程序相互独立。驱动在处理此次调用时线程可能切换到另一个其他进程的线程了,此时将产生崩溃
问题解决:
使用缓冲区读写,操作系统将WriteFile调用时提供的内存复制到内核空间内,此时无论切换到哪个线程,内核空间中的地址始终是不变的。IRP派遣函数将对内核空间中的内存进行操作,而不是操作用户空间的内存
优点:比较简单的解决了将用户地址引入驱动的问题
缺点:需要在用户空间和内核空间中复制数据,影响运行效率
建议:少量内存操作时采用此种方法
- 以缓冲方式读写设备时,操作系统将WriteFile提供的用户模式内存复制到内核模式下,作为缓冲区,该地址由WriteFile创建的IRP的
AssociatedIrp.SystemBuffer子域记录 - 以缓冲方式读写设备时,操作系统会分配一段内核模式下的内存,该段内存的大小等于ReadFile或WriteFile指定的字节数,并且ReadFile或WriteFile创建的IRP的
AssociatedIrp.SystemBuffer会记录这段内存地址,当IRP请求结束时,这段内存会被复制到ReadFile提供的缓冲区中 - 缓冲方式无论"读"还是"写"都会发生用户模式和内核模式地址的数据复制,复制过程由操作系统负责。用户模式地址由ReadFile或WriteFile提供,内核模式由操作系统负责分配和回收
- 在派遣函数中,可以通过
IO_STACK_LOCATION中的Parameters.Read.Length子域知道ReadFile请求多少字节,通过IO_STACK_LOCATION中的Parameters.Write.Length子域知道WriteFile请求多少字节 - 然而ReadFile和WriteFile指定对设备操作多少字节,并不真正意味着操作了这么多字节,派遣函数中应当设置IRP的子域IoStatus.Information,这个子域记录设备实际操作了多少字节
缓冲区读写示例:

直接读写方式操作设备
创建完设备对象后,在设置对象属性时,设置为DO_DIRECT_IO

直接读写设备不会出现复制应用层内存至内核层,而是直接将用户模式下的缓冲区锁住,然后操作系统将这段缓冲区在内核模式地址再次映射一遍。这样用户模式和内核模式的缓冲区指向的是同一区域的物理内存
操作系统将用户模式的缓冲区锁定后,用内存描述符表(MDL数据结构)记录这段内存,
内存描述符表通过pIrp->MdlAddress获得

内存描述符表(MDL)
虚拟内存的大小存储在mdl->ByteCount里
虚拟内存的第一个页地址是mdl->StartVa
虚拟内存的首地址对于第一个页地址的偏移量是mdl->ByteOffset
因此,这段虚拟内存的首地址是:mdl->StartVa + mdl->ByteOffset
DDK提供宏方便获取数值

其他读写操作设备的方式
调用IoCreateDevice之后,对pDevObj->Flags既不设置DO_BUFFERED_IO也不设置DO_DIRECT_IO,此时采用的读写方式就是其他读写方式
在使用其他读写方式时,派遣函数直接读写应用程序提供的缓冲区地址,此时非常危险
只有驱动程序和应用程序运行在相同线程的上下文的情况下,才能使用此种方式
使用其他方式读写时,ReadFile和WriteFile提供的缓冲区内存地址,可以在派遣函数中通过IRP的pIrp->UserBuffer字段得到。读取的字节数可以从I/O堆栈中的stack->Parameters.Read.Length字段中得到
使用用户模式时,要小心用户模式传入的空指针和不可读写内存,需进行探测可读或可写
IO设备控制操作(DeviceIoControl)
应用程序除了用ReadFile和WriteFile以外,还可通过DeviceIoControl操作设备
DeviceIoControl可用于应用程序和驱动进行通信
BOOL DeviceIoControl(
[in] HANDLE hDevice, //已经打开的设备
[in] DWORD dwIoControlCode, //控制码
[in, optional] LPVOID lpInBuffer, //输入缓冲区
[in] DWORD nInBufferSize, //输入缓冲区大小
[out, optional] LPVOID lpOutBuffer, //输出缓冲区
[in] DWORD nOutBufferSize, //输出缓冲区大小
[out, optional] LPDWORD lpBytesReturned, //实际返回的字节数
[in, out, optional] LPOVERLAPPED lpOverlapped //是否OVERLAP操作
);
其中,lpBytesReturned对应派遣函数中的IRP结构中的pIrp->IoStatus.Information
dwIoControlCode:IO控制码,也称IOCTL值,32位无符号整型,需符合DDK规定

DDK提供了一个宏CTL_CODE,便于生产IOCTL码
CTL_CODE(DeviceType, Function, Method, Access)
DeviceType:设备对象的类型,应该与IoCreateDevice时的类型相匹配,形如FILE_DEVICE_XX宏
Function:驱动程序定义好的IOCTL码,其中
- 0x0000 ~ 0x7FFF 微软保留
- 0x8000 ~ 0xFFFF 程序员自己定义
Method:操作模式
| 模式 | 含义 |
|---|---|
| METHOD_BUFFERED | 使用缓冲区方式操作 |
| METHOD_IN_DIRECT | 使用直接写方式操作 |
| METHOD_OUT_DIRECT | 使用直接读方式操作 |
| METHOD_NEITHER | 使用其他方式操作 |
Access:访问权限,一般使用FILE_ANY_ACCESS
缓冲内存模式IOCTL
使用缓存内存模式的IOCTL时,需要在使用CTL_CODE宏定义IOCTL时,指定Method参数为METHOD_BUFFERED
在缓冲内存模式下,DeviceIoControl内部,用户提供的输入缓冲区内容被复制到IRP中的pIrp->AssociatedIrp.SystemBuffer内存地址,复制到字节数是由DeviceIoControl指定的输入字节数
派遣函数可以读取pIrp->AssociatedIrp.SystemBuffer内存地址,获得应用程序提供的缓冲区内容。另外,pIrp->AssociatedIrp.SystemBuffer的内存地址也作为输出缓冲区,由派遣函数写入。操作系统会将这个地址的数据再次复制到DeviceIoControl提供的输出缓冲区,复制的字节数由pIrp->IoStatus.Information决定,这个数值并且作为DeviceIoControl的第七个参数输出
派遣函数使用步骤:
- 通过IoGetCurrentIrpStackLocation函数得到当前IO堆栈
- 通过stack->Parameters.DeviceIoControl.InputBufferLength得到输入缓冲区大小
- 通过stack->Parameters.DeviceIoControl.OutputBufferLength得到输出缓冲区大小
- 最后通过stack->Parameters.DeviceIoControl.IoControlCode得到IOCTL
- 派遣函数中通过switch来处理不同的IOCTL
示例代码:

直接内存模式IOCTL
使用缓存内存模式的IOCTL时,需要在使用CTL_CODE宏定义IOCTL时,指定Method参数为METHOD_IN_DIRECT 或者METHOD_OUT_DIRECT
直接内存模式IOCTL输入缓冲区的内容被复制到Irp->AssosiatedIrp.SystemBuffer内存地址,复制的字节数按照DeviceIoControl指定的输入字节数。这个步骤和缓冲区模式IOCTL一样的
直接内存模式IOCTL输出缓冲区的处理和缓冲区模式IOCTL不同。
操作系统将DeviceIoControl指定的输出缓冲区锁定,然后在内核模式地址下重新映射一段地址,派遣函数中的pIrp->MdlAddress记录DeviceIoControl指定的输出缓冲区,派遣函数应当使用MmGetSystemAddressForMdlSafe将这段内存映射到内核模式下的内存地址
派遣函数使用步骤:
- 通过IoGetCurrentIrpStackLocation函数得到当前IO堆栈
- 通过stack->Parameters.DeviceIoControl.InputBufferLength得到输入缓冲区大小
- 通过stack->Parameters.DeviceIoControl.OutputBufferLength得到输出缓冲区大小
- 最后通过stack->Parameters.DeviceIoControl.IoControlCode得到IOCTL
- 派遣函数中通过switch来处理不同的IOCTL
METHOD_IN_DIRECT和METHOD_OUT_DIRECT的差别:
当以只读模式打开设备时,METHOD_IN_DIRECT将会成功,而METHOD_OUT_DIRECT将会失败。
当以读写模式打开设备时,METHOD_IN_DIRECT和METHOD_OUT_DIRECT都会执行成功
示例代码:

其他内存模式IOCTL
使用其他内存模式的IOCTL时,需要在使用CTL_CODE宏定义IOCTL时,指定Method参数为METHOD_NEITHER
其他内存模式IOCTL很少用到,使用时必须保证调用DeviceIoControl线程与派遣函数运行在同一线程上下文中
DeviceIoControl提供的输入缓冲区地址,派遣函数可以通过IO堆栈的stack->Parameters.DeviceIoControl.Type3InputBuffer得到。
DeviceIoControl提供的输出缓冲区地址,派遣函数可以通过pIrp->UserBuffer得到
派遣函数使用步骤:
- 通过IoGetCurrentIrpStackLocation函数得到当前IO堆栈
- 通过stack->Parameters.DeviceIoControl.InputBufferLength得到输入缓冲区大小
- 通过stack->Parameters.DeviceIoControl.OutputBufferLength得到输出缓冲区大小
- 最后通过stack->Parameters.DeviceIoControl.IoControlCode得到IOCTL
- 派遣函数中通过switch来处理不同的IOCTL
此种模式下,派遣函数最好对传入的用户模式地址进行判断
示例代码:

本文详细阐述了驱动程序如何处理输入输出请求包(IRP),包括IRP的结构、注册与派遣,以及IRP在文件IO、设备访问和DeviceIoControl中的作用。讲解了缓冲区读写、直接内存模式和不同内存模式的IOCTL操作,以及如何在驱动程序中正确处理和传递这些请求。
&spm=1001.2101.3001.5002&articleId=123180160&d=1&t=3&u=9d51b25336df465783aa076043b46997)
1566

被折叠的 条评论
为什么被折叠?



