That's Just the Way It Is - How NT Describes I/O Requests
The NT Insider, Vol 5, Issue 1, Jan-Feb 1998 | Published: 15-Feb-98| Modified: 20-Aug-02
声明:原文来自www.osr.com,所有权利归原作者所有,翻译并贴在这里的目的只为学习和交流,除了商用你可以随意地使用这篇译文。但请不要删除声明。
——by jununfly
版本:20090124
NT驱动中的根本问题:IRPs和数据缓冲… 在此之前我们从未真正地进行详论的细节.
NT用一个基于包的架构描述I/O请求。即任何一个I/O请求都能用一个单一的I/O请求包也就是IRP来描述。当发出一个I/O系统服务时(比如创建文件或读文件的请求),I/O管理器服务通过构造一个描述此请求的IRP并把该IRP的一个指针传给设备驱动来开始对这个请求的处理。
一个IRP,包含要向I/O管理器和设备驱动完整地描述一个I/O请求的所有必需的信息。IRP是一个标准的NT结构,其定义在NTDDK.H中。结构如图1:
图1 – 一个IRP的结构
如图1所示,任何一个IRP都能被认为是由两部分组成:一个"固定的"部分和一个I/O栈。IRP的固定部分包含这个请求的相关信息,有可能不同驱动中的IRP的固定部分相同,也可能在不同驱动间传递IRP时不需要关注它。I/O栈包含被指定给任何一个要处理该请求的驱动的信息。
虽然一个IRP被I/O管理器创建时它的大小是固定的,但是I/O管理器创建的任何一个IRP的大小都不一样。当I/O管理器分配一个IRP时,它为IRP分配的空间是:IRP的固定部分的空间,加上其数量至少等于驱动栈中将会处理这个请求的驱动的数量的I/O栈位置的空间。因此,如果对一个软盘上的文件发出一个写I/O请求,要处理这个请求的驱动栈如图2。I/O管理器将会创建一个IRP来描述这个请求,这个IRP中至少有两个I/O栈位置:一个位置是FSD的,第二个位置是软盘设备驱动的。
图2 – 驱动栈示例
注意我们说的例子中的IRP将至少有两个I/O栈位置。为避免每一个IRP都要从NT的非分页池中分配,I/O管理器维护一对保存有预分配的IRPs的旁观列表。NT V4.0中,其中一个旁观列表保存带有一个单一的I/O栈位置的IRP。
另外一个旁观列表保存带有三个I/O栈位置的旁观列表。I/O管理器总是尽可能使用这些旁观列表中的IRP。因此,对一个被引导到带有两个驱动的驱动栈上的I/O请求来说,I/O管理器会试图从保存带有三个I/O栈位置的IRP的旁观列表中分配一个IRP。如果没有可用的IRP,或者要分配的IRP所需的I/O栈位置超过三个,I/O管理器就会从非分页池中分配IRP。
IRP的固定部分中需要特别关注或有用的域如下:
o MdlAddres, UserBuffer和AssociatedIrp.SystemBuffer – 如果有一个关联I/O操作请求者的数据缓冲,则这三个域被用来描述这个缓冲。后面会加以详述。
o Flags – 顾名思义,这个域包含描述I/O请求的标记。例如,如果在此域中设置了IRP_PAGING_IO标记,则表示IRP所描述的读或写操作是一个分页请求。以此类推,如果设置了IRP_NOCACHE则表示这个请求是在没有中间缓冲的情况下被处理的。一般只有文件系统才关心这个域。
o IoStatus – 当IRP被完成时,完成这个IRP的驱动把IoStatus.Status域设置为I/O操作的完成状态,而把IoStatus.Information域设置为一些要返回给调用者的额外信息。典型的,在一个传输请求(读或写等会引起数据传输的请求)中IoStatus.Information 将会包含实际上被读或被写的字节数。
o RequestorMode – 指示这个请求是从哪种模式(内核模式或用户模式)发动的。
o Cancel, CancelIrql, and CancelRoutine – 如果正在进行时需要取消这个IRP就会用到它们。Cancel是一个BOOLEAN类型值,当被I/O管理器设置为TRUE时表示这个IRP正被取消。CancelRoutine是一个指针,在把这个IRP保存到一个队列中时由一个驱动设置它指向一个函数,I/O管理器会调用这个函数来让该驱动取消此IRP。因为CancelRoutine是在IRQL DISPATCH_LEVEL被调用, 而CancelIRQL是驱动应该返回到的IRQL。(看The NT Insider的卷4第6期的Part I,有篇文章关于取消处理)
o Tail.Overlay.Thread – 指向请求线程的ETHREAD.
o TailOverlay.ListEntry – 可能被驱动用来排队的位置,尽管它拥有这个IRP。
IRP中的任何一个I/O栈位置都包含一个指定的驱动与此I/O请求相关的信息。在NTDDK.H 中I/O栈位置的定义如结构IO_STACK_LOCATION.要在一个给定的IRP中定位当前的I/O栈位置,驱动得调用函数IoGetCurrentIrpStackLocation(…).其唯一的参数是一个指向IRP的指针。返回值是当前I/O栈位置的一个指针。
当I/O管理器开始分配IRP并初始化它的固定部分时,它也初始化IRP中的首个I/O栈位置。这个位置中的信息与被传到将会处理这个请求的驱动栈中的首个驱动的信息相一致。I/O栈位置中有以下域:
o MajorFunction – 关联这个请求的主I/O函数代码。它从总体上指示了要执行的I/O操作的类型。
o MinorFunction – 关联这个请求的辅助I/O函数代码。当被使用时,这会改变主函数代码。Minor function几乎是被网络传输驱动和文件系统独占地使用,大多数设备驱动会忽略它们。
o Flags – 指定给正被执行的I/O函数的处理标记。此域主要为FSD所关注。
o Control – 一组指示I/O管理器如何处理这个IRP的标记,由I/O管理器设置并参阅。例如, 若设置了SL_PENDING位(因驱动调用了IoMarkIrpPending(…))就指示给I/O管理器此IRP还需其他处理。类推之, SL_INVOKE_ON_CANCEL, SL_INVOKE_ON_ERROR和SL_INVOKE_ON_SUCCESS都指示了驱动的I/O完成例程什么时候应该被调用。
o Parameters – 这个域由几个子成员组成,任何一个都基于正被执行的I/O主函数被指定。
o DeviceObject – 这个I/O请求的目标设备对象的一个指针。
o FileObject – 关联这个请求的文件对象的一个指针。
在IRP的固定部分和首个I/O栈位置之后的部分都被适当地初始化,I/O管理器在它的与此请求的主函数代码对应的分发入口点中调用驱动栈中的顶部驱动。因此,如果I/O管理器刚好已经构造了一个IRP来描述一个读请求,那么它会在它的读分发入口点中调用首个驱动。在此分发入口点上,刚构造的IRP的指针,和驱动要在其上处理这个请求的设备所对应的设备对象的指针会被I/O管理器作为参数传给驱动。
描述数据缓冲
请求者的数据缓冲的描述符出现在IRP的固定部分中。为了让驱动程序员能描述关联一个I/O操作的请求者的数据缓冲,NT提供了三个不同的选项:
o 若缓冲被一个叫做内存描述符列表(MDL)的结构,以该缓冲在请求者的物理地址空间中的真实位置的方式描述了,那这就叫做Direct I/O.
o 若缓冲中的数据会从请求者的地址空间中被复制到系统地址空间中的一个中间位置,且驱动被提供了这个数据副本的一个指针,那这就叫做Buffered I/O.
o 若驱动被提供了请求者的缓冲的虚拟地址,那这就叫做Neither I/O.
驱动必须为I/O管理器选择一种方式来描述所有被发送到一个特定设备上的读写请求。通过设置设备对象中的Flags域可以做出这个选择 (通常在设备对象被创建的初始化阶段) 。任何一个由驱动支持的设备I/O控制(IOCTL)代码,都会为读写请求选择不同的方式。
如果驱动选择Direct I/O,一切关联读或写I/O请求的数据缓冲都会被I/O管理器用MDL描述。MDL描述了这个数据缓冲在请求者的物理地址空间中的真实的位置。MDL的地址在被传给驱动的IRP的MdlAddress域中.
在IRP被传给驱动之前,I/O管理器会做检查来确保调用者对整个数据缓冲有适当的访问权。如果访问检查失败,I/O管理器会以一个错误状态来完成这个请求,而此请求也永远不会被传给驱动。
在访问检查完成之后,IRP被传给驱动之前,I/O管理器锁住内存中由数据缓冲组成的物理页。如果这个操作失败了(例如,在某一时间由于数据缓冲太大而不适合内存),此请求会被I/O管理器以一个错误状态来完成。如果锁定操作成功,页会在这个I/O操作的整个时间段内都保持被锁状态(直到IRP最终完成).
MDL能描述一个单一的逻辑连续的数据缓冲而不是物理连续的。它被设计为使获得物理地址和由数据缓冲组成的段的长度变得快捷。MDL结构的定义在NTDDK.H中.示意图如图3:
图3 – 一个MDL
即使大家都知道了MDL的结构,这也只是NT中真正不透明的数据结构的冰山一角。通过不透明,我们要表达的意思是驱动绝对不能假设MDL的结构。因此,驱动绝对不能直接引用MDL中的域。I/O和内存管理器为使用MDL的数据缓冲提供了获得相关信息的函数。这些函数包括:
· MmGetSystemAddressForMdl(…) – 返回在一个任意线程上下文环境中的一个内核虚拟地址,它用于引用MDL描述的缓冲。由图4看出, MDL中的一个域(如果存在的话)会包含关联此MDL的被映射的系统虚拟地址.实际上这个函数就是NTDDK.H中的一个宏.这个函数被首次调用时,它调用MmMapLockedPages(…)来映射缓冲到内核虚拟地址空间中。然后返回的内核虚拟地址被存储到MDL的MappedSystemVa域中.在后来的MmGetSystemAddressForMdl(…)调用中,之前被存到MappedSystemVa中的值会被返回给调用者。当MDL被释放时(例如当I/O请求最终被完成了)一切映射都会被删除。
· IoMapTransfer(…) – 这个函数主要是被DMA设备驱动用来获得物理地址和数据缓冲组成的段的长度。
· MmGetMdlVirtualAddress(…) – 这个函数(实际上是NTDDK.H中的一个宏)返回被MDL描述的缓冲的请求者的虚拟地址。这个虚拟地址只能在调用进程的上下文中使用。这个函数也常常像请求者的虚拟地址一样被DMA设备驱动用作IoMapTransfer(…)的一个入参。
· MmGetMdlByteCount(…) – NTDDK.H中的另外一个宏,此函数返回被MDL描述的缓冲的长度。
· MmGetMdlByteOffset(…) – 这个函数也是NTDDK.H中的一个宏,它返回MDL的首页中的数据缓冲的起始偏移。
图4 – MDL的域
MDL中的一个值得关注的域是Next.这个域是用于构造一个”MDL”链,该链上的所有MDL共同描述一个单一的逻辑不连续的缓冲。链中的任何一个MDL都描述一个单一的逻辑连续的缓冲。MDL 链仅用于网络驱动,且不被I/O管理器的大多数标准函数支持。因此,在NT中MDL链不能被标准的设备驱动使用。
Direct I/O一般用于”基于包的”DMA设备驱动。但它也能被一切想在一个用户缓冲和一个外设之间直接传输数据,而没有重缓冲这个数据的消耗(Buffer I/O的情形)也无需在调用进程的上下文中执行传输(Neither I/O的情形)。
当IRP最终被完成(通过调用IoCompleteRequest(…))而MdlAddress域非零时,I/O管理器解映射和解锁那些被映射和被锁的页,然后释放关联这个IRP的MDL。
在Buffered I/O中系统空间中的一个中间缓冲被用作一个数据缓冲。I/O管理器负责在中间缓冲和请求者的原始数据缓冲之间移动数据。当IRP最终被完成时,系统缓冲被I/O管理器释放。
图5 -- Buffered I/O方式的写
为准备一个Buffered I/O请求,I/O管理器会做检查来确保调用者对整个数据缓冲有合适的访问权,这与Direct I/O一样.如果调用者没有合适的访问权,则这个请求被完成且不会被传给驱动。作为一个优化,如果请求是从内核模式发起的那么就不会执行这个检查。
然后I/O管理器从非分页内存池中分配一个与该数据缓冲等大的系统缓冲。如果这是一个写操作,那么I/O管理器会从请求者的缓冲中复制数据到中间缓冲中。不论请求是读还是写,中间缓冲的内核虚拟地址都会在被传给驱动的IRP的AssociatedIrp.SystemAddress域中.注意即使这个域时IRP中"AssociatedIrp"结构的一部分,这个域也与关联的IRP没有任何关系。因为中间缓冲的地址对应系统的非分页池中的位置,这个地址能被在任意线程上下文中的驱动使用。当然,这个系统缓冲是逻辑连续的。但它不一定是逻辑连续的。
当使用Buffered I/O时,在I/O操作期间,由原始数据缓冲组成的内存页没有被锁住。因此,它们能被分页的能力不受I/O操作的影响。
当一个使用Buffer I/O的读操作最终完成时,I/O管理器负责从中间缓冲中复制数据到请求者的数据缓冲。作为一个优化,在发出这个I/O请求的线程成为下一个要被执行的线程之前(通过"指定I/O完成的内核APC"),I/O管理器会延迟这个复制操作。当请求者线程是下一个要被执行的线程时,I/O管理器从中间系统缓冲中复制数据到请求者的缓冲,并释放系统缓冲。此优化不仅避免了可能会有的页负担,而且也为一个”缓存热身”函数提供了服务 – 用来自缓冲的数据预加载处理器缓存,从而使得处理器能在从这个I/O请求返回的时候进行快速访问。
Buffered I/O常常被用于控制设备I/O的数据传输量小的驱动。在此驱动中,用一个系统虚拟地址描述请求者的数据非常方便。
I/O管理器描述请求者的数据缓冲的最后一种方式叫做"Neither I/O".它如此命名仅仅是因为这类驱动请求既不是Buffered I/O也不是I/O.在此方式中,I/O管理器为驱动提供数据缓冲的请求者的虚拟地址。这个缓冲没有被锁到内存中;也没有中间的数据缓冲。这个地址在IRP的UserBuffer 域中.
很显然,请求者的虚拟地址仅在调用进程的上下文中有用.结果就是,只有用Neither I/O的驱动才能无需任何中间驱动就能从I/O管理器中被直接进入,并能在调用进程的上下文中执行(并完成)I/O操作。因此,传统的存储器设备的驱动不能使用Neither I/O,因为它们的分发例程是在任意线程上下文中被调用的。大多数常规的设备驱动也不能使用Neither I/O,因为这些驱动的I/O请求常常是从DPC例程中开始的,也就是说它们也在任意线程的上下文中。
如果使用得当且谨慎,Neither I/O可能会是一些设备驱动的最理想的方式。例如,如果一个在调用线程的上下文中的驱动同步地执行其大部分的工作,Neither I/O就能节省创建一个MDL或再次复制数据的消耗。即使这个驱动偶尔中间地缓冲下数据或创建一个描述符来允许从任意线程的上下文中引用数据缓冲,Neither I/O能节省的消耗也是相当可观的。
那在你的驱动中,你怎么判断要用Direct I/O,Buffered I/O或Neither I/O中的哪个? 有一个规则:如果你正在写一个中间的驱动,而它将会被分层到另一个驱动之上的话,那么你所使用的缓冲方式必须与在你之下的设备所使用的缓冲方式保持一致。
对设备驱动来说,这种的选择无疑是一个架构决策,它将影响驱动的复杂性和性能。如果你因客观因素而不得不用Neither I/O,而且你能应付要使用它的需求,你就应该选I/O.
然而,因其严格的限制,大多数驱动不会使用Neither I/O.一般,每次传输要操作至少一页的数据的驱动最好是使用Direct I/O.在传输期间,I/O管理器将锁住内存中的页,这就避免了数据的再次复制的消耗。用Direct I/O来进行大量数据的传输也避免了众多系统池的占用。大多数DMA驱动也会用Direct I/O.基于DMA设备的信息包的驱动会用它是因为这会允许它们轻易地获得物理基础地址和由数据缓冲组成的段的长度。”公有缓冲”DMA设备的驱动用它来避免一次额外的复制操作的消耗。
用程序的I/O相对缓慢地移动数据的驱动,存在长时间的未决操作的驱动和传输小块数据的驱动大多会用Buffered I/O.这包括传统的串口和并口的设备,也包括大多数简单的机器控制驱动。Buffered I/O是最容易实现的方式,因为在IRP中提供了在系统空间中一个包含数据的逻辑连续的缓冲的一个指针。
在非临界情形的大多数情况中,相对Direct I/O来说Buffered是很重要的。几乎所有NT驱动程序员刚开始的时候都会花费大量的时间来决定要用哪种方式。如果你不确定,而你正在写一个程序I/O类型的设备的驱动的话,从用Buffered I/O开始吧。一旦你写好了你的驱动,你就可以尝试来看看转到Direct I/O会不会给你更多性能增效。毕竟,从编程的观点来看,二者的仅有的区别是你从IRP的哪个地方找你的信息(Irp->AssociatedIrp.SystemBuffer还是Irp->DmaAddress)和一个函数调用(即调用MmGetSystemAddressForMdl(…)来获得映射被一个MDL描述的缓冲的一个系统虚拟地址). 这不是小菜一碟嘛!