0%

Chapter 7 the I/O Request Packet

IRP介绍

​ IRP的全名是I/O Request Package,即输入输出请求包,它是Windows内核中的一种非常重要的数据结构。上层应用程序与底层驱动程序通信时,应用程序会发出I/O请求,操作系统将相应的I/O请求转换成相应的IRP,不同的IRP会根据类型被分派到不同的派遣函数中进行处理。

​ IRP有两个基本的属性,即MajorFunctionMinorFunction,分别记录IRP的主类型和子类型。操作系统根据MajorFunction决定将IRP分发到哪个派遣函数,然后派遣函数根据MinorFunction进行细分处理。没有设置派遣函数的IRP,默认与IopInvalidDeviceRequest函数关联。

​ 首先一个IRP在被分配时,调用者必须指定要分配多少个IO_STACK_LOCATION,这些结构直接在内存中伴随着IRP,其数量是设备堆栈中设备对象的数量。驱动程序会创建一个个设备对象,并将这些设备对象叠成一个垂直结构,叫做设备栈。IRP会被操作系统发送到设备栈顶层,如果顶层的设备对象的派遣函数结束了IRP的请求,那么这次I/O请求结束,如果没有,那么操作系统将IRP转发到设备栈的下一层设备处理,直到找到能够结束这个IRP请求的派遣函数的设备。

​ 因此,一个IRP请求可能被转发多次,为了记录IRP在每层设备中做的操作,IRP会有个IO_STACK_LOCATION数组(数组中的每个堆栈单元都对应一个将处理该IRP的驱动程序,另外还有一个堆栈单元供IRP的创建者使用。堆栈单元中包含该IRP的类型代码和参数信息以及完成函数的地址),数组的元素个数应该大于IRP穿过的设备数目, 当一个驱动程序接收到一个IRP时,将会获得一个指向IRP结构的指针,对于本层设备对应的IO_STACK_LOCATION,可以通过IoGetCurrentIrpStackLocation函数得到。

image-20220304144523080

WDM与NT驱动程序

​ NT命名设备对象的名称形式为\Device\DeviceName, WDM驱动并不直接命名设备对象,系统规定了一个统一的命名方案,以确保设备名称不会在驱动程序之间发生冲突。WDM驱动程序命名方案:

  • 设备的 PDO 被命名。总线驱动程序为其枚举的设备请求命名 PDO。总线驱动程序在创建设备对象时指定 FILE_AUTOGENERATED_DEVICE_NAME 设备特性。
  • FDO 和FiDO 未命名。创建设备对象时,函数和过滤器驱动程序不请求名称。

共有三种 WDM 设备对象:

  1. 物理设备对象 (PDO) – 表示总线上的设备到总线驱动程序,该设备对象表示在该总线上的该插槽中有某个设备。
  2. 功能设备对象 (FDO) – 代表功能驱动程序的设备,通常由硬件的供应商提供。
  3. 过滤设备对象(FiDO)——代表过滤器驱动程序的设备。

​ 驱动程序通过创建设备对象 (IoCreateDevice)并将其附加到设备堆栈 (IoAttachDeviceToDeviceStack )将自己添加到处理设备 I/O 的驱动程序堆栈中。IoAttachDeviceToDeviceStack确定设备堆栈的当前顶部并将新设备对象附加到设备堆栈的顶部。

设备节点和设备堆栈

​ 大多数发送到设备驱动程序的请求都打包在IRP中。通常,当向设备发送 I/O 请求时,多个驱动程序会帮助处理该请求。这些驱动程序中的每一个都与一个设备对象相关联,并且这些设备对象被安排在一个堆栈中。设备对象及其相关驱动程序的序列称为设备堆栈。每个设备由一个设备节点表示,每个设备节点都有一个设备栈。

​ 即插即用管理器将一个设备节点与每个新创建的 PDO 相关联,并查看注册表以确定哪些驱动程序需要成为该节点的设备堆栈的一部分。设备堆栈必须有一个(并且只有一个)功能驱动程序,并且可以选择有一个或多个过滤器驱动程序

​ 功能驱动程序是设备栈的主要驱动程序,负责处理读取、写入和设备控制请求。过滤器驱动程序在处理读取、写入和设备控制请求时起到辅助作用。在加载每个函数和过滤器驱动程序时,它会创建一个设备对象并将自己附加到设备堆栈。由功能驱动程序创建的设备对象称为功能设备对象(FDO),过滤驱动创建的设备对象称为过滤设备对象(Filter DO)。

​ PDO 始终是设备堆栈中的底部设备对象。这是由设备堆栈的构造方式造成的。首先创建 PDO,当附加设备对象附加到堆栈时,它们将附加到现有堆栈的顶部。

​ 在某些情况下,设备除了其内核模式设备堆栈外,还具有用户模式设备堆栈。用户模式驱动程序通常基于用户模式驱动程序框架 (UMDF),它是Windows 驱动程序框架提供的驱动程序模型之一。在 UMDF 中,驱动程序是用户模式的 DLL,设备对象是实现 IWDFDevice 接口的 COM 对象。UMDF 设备堆栈中的设备对象称为WDF 设备对象(WDF DO)。

image-20220331104749931

IO_STACK_LOCATION结构如下

image-20220331155121884

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
typedef struct _IO_STACK_LOCATION {
UCHAR MajorFunction;
UCHAR MinorFunction;
UCHAR Flags;
UCHAR Control;
union {
struct {
PIO_SECURITY_CONTEXT SecurityContext;
ULONG Options;
USHORT POINTER_ALIGNMENT FileAttributes;
USHORT ShareAccess;
ULONG POINTER_ALIGNMENT EaLength;
} Create;
struct {
ULONG Length;
ULONG POINTER_ALIGNMENT Key;
LARGE_INTEGER ByteOffset;
} Read;
struct {
ULONG Length;
ULONG POINTER_ALIGNMENT Key;
LARGE_INTEGER ByteOffset;
} Write;
... ...
} Parameters;
PDEVICE_OBJECT DeviceObject;
PFILE_OBJECT FileObject;
PIO_COMPLETION_ROUTINE CompletionRoutine;
PVOID Context;
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;

kd> dt nt!_IO_STACK_LOCATION
+0x000 MajorFunction : UChar
+0x001 MinorFunction : UChar
+0x002 Flags : UChar
+0x003 Control : UChar
+0x004 Parameters : <unnamed-tag>
+0x014 DeviceObject : Ptr32 _DEVICE_OBJECT
+0x018 FileObject : Ptr32 _FILE_OBJECT
+0x01c CompletionRoutine : Ptr32 long
+0x020 Context : Ptr32 Void

这个结构的主要成员意义为:

  • MajorFunction(UCHAR) : 是该IRP的主功能码。这个代码应该为类似IRP_MJ_READ一样的值,并与驱动程序对象中MajorFunction表的某个派遣函数指针相对应。如果该代码存在于某个特殊驱动程序的I/O堆栈单元中,它有可能一开始是,例如IRP_MJ_READ,而后被驱动程序转换成其它代码,并沿着驱动程序堆栈发送到低层驱动程序。
  • MinorFunction(UCHAR) : 是该IRP的副功能码。它进一步指出该IRP属于哪个主功能类。例如,IRP_MJ_PNP请求就有约一打的副功能码,如IRP_MN_START_DEVICEIRP_MN_REMOVE_DEVICE
  • Parameters(union) : 是几个子结构的联合,每个请求类型都有自己专用的参数,而每个子结构就是一种参数。这些子结构包括**Create(IRP_MJ_CREATE请求)、Read(IRP_MJ_READ请求)、StartDevice(IRP_MJ_PNP的IRP_MN_START_DEVICE子类型)**,等等。
  • DeviceObject(PDEVICE_OBJECT) :是与该堆栈单元对应的设备对象的地址。该域由IoCallDriver函数负责填写。
  • FileObject(PFILE_OBJECT) : 是内核文件对象的地址,IRP的目标就是这个文件对象。驱动程序通常在处理清除请求(IRP_MJ_CLEANUP)时使用FileObject指针,以区分队列中与该文件对象无关的IRP。
  • CompletionRoutine(PIO_COMPLETION_ROUTINE) : 是一个I/O完成例程的地址,该地址是由与这个堆栈单元对应的驱动程序的更上一层驱动程序设置的。绝对不要直接设置这个域,应该调用IoSetCompletionRoutine函数,该函数知道如何参考下一层驱动程序的堆栈单元。设备堆栈的最低一级驱动程序并不需要完成例程,因为它们必须直接完成请求。然而,请求的发起者有时确实需要一个完成例程,但通常没有自己的堆栈单元。这就是为什么每一级驱动程序都使用下一级驱动程序的堆栈单元保存自己完成例程指针的原因。
  • Context(PVOID) : 是一个任意的与上下文相关的值,将作为参数传递给完成例程。

IRP操作流程

​ 通常大多数IRP是由I/O管理器创建的,该管理器初始化IRP结构和第一个I/O堆栈位置,然后它将IRP的指针传递到最上层。驱动程序在其适当的调度例程中接收到IRP。例如,如果这是一个ReadIRP,那么该驱动程序将从其驱动程序对象中调用其其主函数数组的IRP_MJ_READ索引。此时,驱动程序在处理IRP时可以有几个选项:

​ 1.将请求向下传递。如果这个驱动设备并不是设备节点的最后一个设备,当对该请求不感兴趣时,可以将其向下传递。这是由接收到不感兴趣的请求的过滤器驱动完成的,为了不损害设备的功能,驱动将该请求向下传递。需要调用IoCallDriverIoCallDriver会调用IoGetNextIrpStackLocation下移设备栈的指针,因此我们需要对设备栈做如下之一的操作:

1
2
3
4
5
6
7
8
9
10
11
12
#define IoCopyCurrentIrpStackLocationToNext( Irp ) { \
PIO_STACK_LOCATION irpSp; \
PIO_STACK_LOCATION nextIrpSp; \
irpSp = IoGetCurrentIrpStackLocation( (Irp) ); \
nextIrpSp = IoGetNextIrpStackLocation( (Irp) ); \
RtlCopyMemory( nextIrpSp, irpSp, FIELD_OFFSET(IO_STACK_LOCATION, CompletionRoutine)); \
nextIrpSp->Control = 0; }

#define IoSkipCurrentIrpStackLocation( Irp ) \
(Irp)->CurrentLocation++; \
(Irp)->Tail.Overlay.CurrentStackLocation++;

IoCopyCurrentIrpStackLocationToNext 拷贝IO_STACK_LOCATION 成员到下一层。由于初始化的时候只初始化了第一个I/O堆栈位置,所以每个驱动需要初始化下一个驱动。

IoSkipCurrentIrpStackLocation 上移一层,下次使用的时候仍旧使用当前的IO_STACK_LOCATION

​ 2.完全处理这个请求。接收到这个请求的设备可以调用IoCompleterequest处理这个请求,这样更低层的设备不会看到这个请求。

​ 3.结合1和2,驱动程序可以检查IRP,做一些事情(比如记录请求),然后传递它。或者它可以对下一个I/O堆栈位置进行一些更改,然后传递请求。

​ 4.传递请求并在请求完成时由底层设备通知。任何一层(除了最低的一层)都可以通过在传递请求之前调用IoSetCompletionRoutine来设置I/O完成例程。当其中一个较低的层完成请求时,将会调用驱动程序的完成例程。

​ 5.开始一些异步IRP处理。驱动程序可能想要处理该请求,但如果请求很长(典型的硬件驱动程序,但也可能是软件驱动程序),驱动可能通过调用IoMarkIrpPending标记IRP为挂起,并从它的调度例程返回一个STATUS_PENDING。最终,它将不得不完成IRP。

​ 一旦一些层调用IoCompleteRequest,该IRP就会向反方向回到IRP的发起者(通常是在管理器),如果完成例程已经注册,它们将按注册的相反顺序被调用,即从下到上。

IRP

image-20220331155052079

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
typedef struct _IRP {
PMDL MdlAddress;
ULONG Flags;
union {
struct _IRP* MasterIrp;
PVOID SystemBuffer;
} AssociatedIrp;
IO_STATUS_BLOCK IoStatus;
KPROCESSOR_MODE RequestorMode;
BOOLEAN PendingReturned;
BOOLEAN Cancel;
KIRQL CancelIrql;
PDRIVER_CANCEL CancelRoutine;
PVOID UserBuffer;
union {
struct {
union {
KDEVICE_QUEUE_ENTRY DeviceQueueEntry;
struct {
PVOID DriverContext[4];
};
};
PETHREAD Thread;
LIST_ENTRY ListEntry;
} Overlay;
} Tail;
} IRP, *PIRP;

kd> dt nt!_IRP
+0x000 Type : Int2B
+0x002 Size : Uint2B
+0x004 MdlAddress : Ptr32 _MDL
+0x008 Flags : Uint4B
+0x00c AssociatedIrp : <unnamed-tag>
+0x010 ThreadListEntry : _LIST_ENTRY
+0x018 IoStatus : _IO_STATUS_BLOCK
+0x020 RequestorMode : Char
+0x021 PendingReturned : UChar
+0x022 StackCount : Char
+0x023 CurrentLocation : Char
+0x024 Cancel : UChar
+0x025 CancelIrql : UChar
+0x026 ApcEnvironment : Char
+0x027 AllocationFlags : UChar
+0x028 UserIosb : Ptr32 _IO_STATUS_BLOCK
+0x02c UserEvent : Ptr32 _KEVENT
+0x030 Overlay : <unnamed-tag>
+0x038 CancelRoutine : Ptr32 void
+0x03c UserBuffer : Ptr32 Void
+0x040 Tail : <unnamed-tag>

  • MdlAddress : 是一个MDL的指针,当内核层和用户层采用共享内存的结构传递数据的时候,这个MDL就代表共享的内存信息(共享物理内存,通过MDL映射)。这个成员生效的标记为:DO_DIRECT_IO, METHOD_IN_DIRECT 或者METHOD_OUT_DIRECT.
  • AssociatedIrp : 这个成员是个联合体,其中存在一个SystemBuffer程序;当内核层使用用户层的数据的时候是通过拷贝数据的方式来实现的话,那么拷贝后的数据就放在了AssociatedIrp.SystemBuffer中了。这个成员生效的标记是DO_BUFFERED_IO或者METHOD_BUFFERED。
  • IoStatus : 返回的状态信息。
  • RequestorMode : UserMode或KernelMode,指定原始I/O请求的来源。驱动程序有时需要查看这个值来决定是否要信任某些参数。
  • **PendingReturned **: Pending 状态,如果为TRUE,则表明处理该IRP的最低级派遣函数返回了STATUS_PENDING。
  • StackCount : 设备栈的数目。
  • CurrentLocation : 当前处于哪个设备栈的索引。
  • Cancel : IRP是否被取消,如果为TRUE,则表明IoCancelIrp已被调用(该函数用于取消这个请求)。如果为FALSE,则表明没有调用IoCancelIrp函数。
  • **CancelIrql(KIRQL) **: 是一个IRQL值,表明那个专用的取消自旋锁是在这个IRQL上获取的.
  • **CancelRoutine(PDRIVER_CANCEL) **: 是驱动程序取消例程的地址。你应该使用IoSetCancelRoutine函数设置这个域而不是直接修改该域(因为可以原子修改)。
  • UserBuffer(PVOID) : 用户层参数的直接地址,设置标记METHOD_NEITHER时候有效。
  • **Tail.Overlay **是Tail联合中的一种联合结构,如下:
    image-20220331160047247

访问用户缓冲区

驱动程序创建设备对象的时候,需要考虑该设备以何种方式读写。读写主要有缓冲区方式读写、直接方式读写、其他方式读写。一些派遣函数,比如IRP_MJ_READ,IRP_MJ_WRITE,IRP_MJ_DEVICE_CONTROL接受客户端提供的缓冲区——在大多数情况下来自用户模式。通常,派遣函数是在IRQL0和请求线程上下文中调用,这意味着用户模式提供的缓冲区指针非常容易访问:IRQL是0,所以页面错误通常会被处理,线程是请求者,因此指针在这个进程上下文中是有效的。

以WriteFile为例,WriteFile要求用户提供一段缓冲区,并且说明缓冲区大小,然后将这段内存数据传入到驱动程序中,这段缓冲区内存是用户模式的内存地址,驱动程序如果直接引用这段内存很危险。因为操作系统是多任务的,他可能随时切换到别的进程。

缓冲区方式读写

针对解决上述问题,缓冲区方式读写这种方法中,操作系统将应用程序提供的缓冲区的数据复制到内核模式下的地址中。IRP的派遣函数操作的是内核模式下的缓冲区而不是用户模式下的。这样的方法的缺点是,影响了运行效率,在少量内存操作时,可以采用这种方法。

1.I/O管理器会从非分页池中分配一个与用户模式下的缓冲区相同大小的缓冲区。并且Read/WriteFile创建的IRP的AssociatedIrp->SystemBuffer子域会记录这段内存地址。(可以通过IO_STACK_LOCATION中的Parameters.Read(or Write).Length知道请求了多少字节。)

2.I/O管理器会进行用户模式地址和内核模式地址的数据复制。

3.一旦驱动程序完成了IRP,I/O管理器(对于ReadFile请求)将系统缓冲区复制回用户的缓冲区(复制的大小由IoStatus.Information(记录了实际操作了多少字节)决定)

4.最后,I/O管理器释放内核缓冲区。

特点:使用简单,只需在设备对象中指定标志,其他所有事情都由I/O管理器处理。总是有一个副本,所以它最好用于小的缓冲区(通常最多一页)。大型缓冲区的复制成本可能会很高。在这种情况下,应该使用直接I/O来代替。

直接方式读写

与前一种方式不同,该方式读写设备时,操作系统会将用户模式下的缓冲区锁住。然后操作系统将这段缓冲区在内核模式地址再映射一遍。这样,用户模式和内核模式的缓冲区指向的是同一区域的物理内存。无论操作系统如何切换进程,内核模式地址都保持不变。

锁定后,操作系统用内存描述符表(MDL)记录这段内存,该数据结构描述了缓冲区是如何映射到RAM的,该数据结构存储在IRP的pIrp->MdlAddress。

image-20220511194722486

从上图可知,这段虚拟内存首地址应该是mdl->StartVa+mdl->ByteOffset。

实现中主要用到MmGetSystemAddressForMdlSafe函数(该函数第二个参数是指定优先级),得到MDL在内核模式下的映射,返回值是内核地址。

如果返回NULL,这意味着系统超出系统页表或系统页表很低(取决于上面的优先级参数)。这种情况可能出现在内存很少的情况下,如果出现这种情况,那么IRP的完成状态应该是STATUS_INSUFFICIENT_RESOURCES。

其他方式读写

派遣函数直接读写应用程序提供的额缓冲区地址,只有把驱动程序和应用程序运行在相同线程上下文的情况下,才能使用这种方式。

缓冲区内存地址,可以在派遣函数中通过IRP的pIrp->UsersBuffer得到。因为ReadFile可能把空指针地址或者非法地址传递给驱动程序,所以驱动程序在使用用户模式地址前,需要探测这段内存是否可读或者可写(使用ProbeForWrite函数和try块)

IO设备控制操作

除了前面说的ReadFile,WriteFile之外,还可以通过另外一种方式操作设备。DeviceIoControl内部会使操作系统创建一个IRP_MJ_DEVICE_CONTROL类型的IRP,然后操作系统会将这个IRP转发到派遣函数。

DeviceIoControl与驱动交互
1
2
3
4
5
6
7
8
9
BOOL DeviceIoControl(
HANDLE hDevice, // handle to device or file
DWORD dwIoControlCode, // IOCTL code (控制码)
PVOID lpInBuffer, // input buffer
DWORD nInBufferSize, // size of input buffer
PVOID lpOutBuffer, // output buffer
DWORD nOutBufferSize, // size of output buffer
PDWORD lpdwBytesReturned, // # of bytes actually returned
LPOVERLAPPED lpOverlapped);

(IOCTL)控制码主要由四个参数构成,由CTL_CODE宏提供

1
2
#define CTL_CODE( DeviceType, Function, Method, Access ) ( \
((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))

第三个参数比较关键,指的是操作模式(METHOD_BUFFERED、METHOD_IN_DIRECT、METHOD_OUT_DIRECT、METHOD_NEITHER),这几种操作模式与前面提到的缓冲区、直接和其他访问方式类似,对于METHOD_IN/OUT_DIRECT的区别是,当以只读权限打开设备的时候,前者会成功,后者会失败。如果以读写权限打开设备,两者都成功。

参考链接:windows驱动之IRP结构微软官方文档