当应用程序发起一个读或写操作时,通过给出一个用户模式虚拟地址和长度,应用程序向I/O管理器提供了一个数据缓冲区。正如我在第三章中提到的,内核模式驱动程序几乎从不使用用户模式虚拟地址访问内存,因为你不能把线程上下文确定下来。Windows 2000为驱动程序访问用户模式数据缓冲区提供了三种方法:
- 在buffered方式中,I/O管理器先创建一个与用户模式数据缓冲区大小相等的系统缓冲区。而你的驱动程序将使用这个系统缓冲区工作。I/O管理器负责在系统缓冲区和用户模式缓冲区之间复制数据。
- 在direct方式中,I/O管理器锁定了包含用户模式缓冲区的物理内存页,并创建一个称为MDL(内存描述符表)的辅助数据结构来描述锁定页。因此你的驱动程序将使用MDL工作。
- 在neither方式中,I/O管理器仅简单地把用户模式的虚拟地址传递给你。而使用用户模式地址的驱动程序应十分小心。
指定缓冲方式
为了指定设备读写的缓冲方式,你应该在AddDevice函数中,在创建设备对象后,立即设置其中的标志位:
NTSTATUS AddDevice(...) { PDEVICE_OBJECT fdo; IoCreateDevice(..., &fdo); fdo->Flags |= DO_BUFFERED_IO; <or> fdo->Flags |= DO_DIRECT_IO; <or> fdo->Flags |= 0; // i.e., neither direct nor buffered } |
这之后你不能该变缓冲方式的设置,因为过滤器驱动程序将复制这个标志设置,并且,如果你改变了设置,过滤器驱动程序没有办法知道这个改变。
Buffered方式
当I/O管理器创建IRP_MJ_READ或IRP_MJ_WRITE请求时,它探测设备的缓冲标志以决定如何描述新IRP中的数据缓冲区。如果DO_BUFFERED_IO标志设置,I/O管理器将分配与用户缓冲区大小相同的非分页内存。它把缓冲区的地址和长度保存到两个十分不同的地方,见下面代码片段中用粗体字表示的语句。你可以假定I/O管理器执行下面代码(注意这并不是Windows NT的源代码):
PVOID uva; // user-mode virtual buffer address ULONG length; // length of user-mode buffer PVOID sva = ExAllocatePoolWithQuota(NonPagedPoolCacheAligned, length); if (writing) RtlCopyMemory(sva, uva, length); Irp->AssociatedIrp.SystemBuffer = sva; PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp); if (reading) stack->Parameters.Read.Length = length; else stack->Parameters.Write.Length = length; <code to send and await IRP> if (reading) RtlCopyMemory(uva, sva, length); ExFreePool(sva); |
可以看出,系统缓冲区地址被放在IRP的AssociatedIrp.SystemBuffer域中,而数据的长度被放到stack->Parameters联合中。在这个过程中还包含作为驱动程序开发者不必了解的额外细节。例如,读操作之后的数据复制工作实际发生一个APC期间,在原始线程的上下文中,由一个与构造该IRP完全不同的子例程执行。I/O管理器把用户模式虚拟地址(uva变量)保存到IRP的UserBuffer域中,这样一来复制操作就可以找到这个地址。但你不要使代码依赖这些事实,因为它们有可能会改变。IRP最终完成后,I/O管理器将释放系统缓冲区所占用的内存。
Direct方式
如果你在设备对象中指定DO_DIRECT_IO方式,I/O管理器将创建一个MDL用来描述包含该用户模式数据缓冲区的锁定内存页。MDL结构的声明如下:
typedef struct _MDL { struct _MDL *Next; CSHORT Size; CSHORT MdlFlags; struct _EPROCESS *Process; PVOID MappedSystemVa; PVOID StartVa; ULONG ByteCount; ULONG ByteOffset; } MDL, *PMDL; |
图7-3显示了MDL扮演的角色。StartVa成员给出了用户缓冲区的虚拟地址,这个地址仅在拥有数据缓冲区的用户模式进程上下文中才有效。ByteOffset是缓冲区起始位置在一个页帧中的偏移值,ByteCount是缓冲区的字节长度。Pages数组没有被正式地声明为MDL结构的一部分,在内存中它跟在MDL的后面,包含用户模式虚拟地址映射为物理页帧的个数。
图7-3. 内存描述符表(MDL)结构
顺便说一下,我们不可以直接访问MDL的任何成员。应该使用宏或访问函数,见表7-2。
表7-2. 用于访问MDL的宏和访问函数
宏或函数 | 描述 |
---|---|
IoAllocateMdl | 创建MDL |
IoBuildPartialMdl | 创建一个已存在MDL的子MDL |
IoFreeMdl | 销毁MDL |
MmBuildMdlForNonPagedPool | 修改MDL以描述内核模式中一个非分页内存区域 |
MmGetMdlByteCount | 取缓冲区字节大小 |
MmGetMdlByteOffset | 取缓冲区在第一个内存页中的偏移 |
MmGetMdlVirtualAddress | 取虚拟地址 |
MmGetSystemAddressForMdl | 创建映射到同一内存位置的内核模式虚拟地址 |
MmGetSystemAddressForMdlSafe | 与MmGetSystemAddressForMdl相同,但Windows 2000首选 |
MmInitializeMdl | (再)初始化MDL以描述一个给定的虚拟缓冲区 |
MmPrepareMdlForReuse | 再初始化MDL |
MmProbeAndLockPages | 地址有效性校验后锁定内存页 |
MmSizeOfMdl | 取为描述一个给定的虚拟缓冲区的MDL所占用的内存大小 |
MmUnlockPages | 为该MDL解锁内存页 |
对于I/O管理器执行的Direct方式的读写操作,其过程可以想象为下面代码:
KPROCESSOR_MODE mode; // either KernelMode or UserMode PMDL mdl = IoAllocateMdl(uva, length, FALSE, TRUE, Irp); MmProbeAndLockPages(mdl, mode, reading ? IoWriteAccess : IoReadAccess); <code to send and await IRP> MmUnlockPages(mdl); ExFreePool(mdl); |
I/O管理器首先创建一个描述用户缓冲区的MDL。IoAllocateMdl的第三个参数(FALSE)指出这是一个主数据缓冲区。第四个参数(TRUE)指出内存管理器应把该内存充入进程配额。最后一个参数(Irp)指定该MDL应附着的IRP。在内部,IoAllocateMdl把Irp->MdlAddress设置为新创建MDL的地址,以后你将用到这个成员,并且I/O管理器最后也使用该成员来清除MDL。
这段代码的关键地方是调用MmProbeAndLockPages(以粗体字显示)。该函数校验那个数据缓冲区是否有效,是否可以按适当模式访问。如果我们向设备写数据,我们必须能读缓冲区。如果我们从设备读数据,我们必须能写缓冲区。另外,该函数锁定了包含数据缓冲区的物理内存页,并在MDL的后面填写了页号数组。在效果上,一个锁定的内存页将成为非分页内存池的一部分,直到所有对该页内存加锁的调用者都对其解了锁。
在Direct方式的读写操作中,对MDL你最可能做的事是把它作为参数传递给其它函数。例如,DMA传输的MapTransfer步骤需要一个MDL。另外,在内部,USB读写操作总使用MDL。所以你应该把读写操作设置为DO_DIRECT_IO方式,并把结果MDL传递给USB总线驱动程序。
顺便提一下,I/O管理器确实在stack->Parameters联合中保存了读写请求的长度,但驱动程序应该直接从MDL中获得请求数据的长度。
ULONG length = MmGetMdlByteCount(mdl); |
Neither方式
如果你在设备对象中同时忽略了DO_DIRECT_IO和DO_BUFFERED_IO标志设置,你将得到默认的neither方式。对于这种方式,I/O管理器将简单地把用户模式虚拟地址和字节计数(以粗体显示的代码)交给你,其余的工作由你去做。
Irp->UserBuffer = uva; PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp); if (reading) stack->Parameters.Read.Length = length; else stack->Parameters.Write.Length = length; <code to send and await IRP> |