win-IO系统(1)
IO管理器
Windows的IO系统中,最重要的是I/O管理器。
I/O管理器定义了一种秩序框架(模型),借此将I/O请求传递给设备驱动程序。IO系统是数据包驱动的。大部分IO请求都可以用一种IO数据请求包(in/out request pakage)代表,IRP是一种数据结构,其中包含了用于完整描述一个IO请求所需的全部信息。IRP会从一个IO系统组件传递至另一个。(快速IO属于例外。)这样的设计方式使得每个应用程序可以并发地管理多个IO请求。
对于每个IO操作,IO管理器会在内存中创建一个IRP来代表,并会将该IRP的指针传递给相应的驱动程序,当IO操作完成后还会销毁该数据包。与之相应的,当驱动程序受到IRP后,会执行该IRP执行的操作,将IRP重新返回给IO管理器,这可能是因为所请求的IO操作已成功完成,或必须传递给另一个驱动程序以进一步处理。
…
虚拟文件可以是任何被当做文件来处理的IO来源或是目标(如设备,文件,目录,管道,邮件槽)。典型的用户模式客户端会调用CreateFile或CreateFile2函数获取道虚拟文件的句柄。该函数名称有点容易产生误导:它们并不是只能用于文件,而是用于对象管理器GLOBALL??目录下任何可被视作符号链接的东西。CreateFile*函数名称“File”后缀实际上意味着虚拟文件对象(FILE_OBJECT),即调用这些函数后执行体创建的实体。
类似C:这样的名称实际上是指向Device这个对象管理器目录下的一个内部名称的符号链接(\Device\HarddiskVolumeX)。
操作系统会将所有IO请求抽象为针对虚拟文件所执行的操作,因为IO管理器只“懂”文件,其它什么都“不懂”,所以要由驱动程序将面向文件的命令(打开,关闭,读取,写入)转换为与具体设备有关的命令。这样的抽象也就形成了供应用程序访问设备的接口。用户模式应用程序会调用已文档化的函数,由这些函数调用IO系统的内部函数读写文件或执行其它操作。IO管理器会动态地将这些虚拟文件请求引导至相应的设备驱动程序。
中断请求级别与延迟过程调用
中断请求级别
为后面的延迟过程调用做铺垫。
IRQL(Interrupt Requset Level)有两个含义:
- 为源自硬件设备的中断所分配的优先级,该数值由HAL(和对应的中断控制器)来设置
- 每颗CPU都有自己的IRQL值
IRQL较低的代码无法干涉IRQL较高的代码,反之依然,但IRQL较高的代码可以抢占以较低IRQL运行的代码。
Windows x64中,等级一般为:
- Passive/Low(0):内核调度器通常工作时所处的常规IRQL
- APC(1)
- Dispatch/DCP(2):内核调度器大部分所处的IRQL。这意味着,如果当前线程IRQL提高到2或更高,该线程本质上将获得无限的量程,无法被其它线程抢占。导致内核调度器无法在当前CPU上唤醒,除非那个线程的IRQL降到2以下。
- 对于2或更高的IRQL,针对内核调度对象(互斥体,信号量,事件)的任何等待都会让系统崩溃。因为等待意味着线程可能进入等待状态,而另一个线程会调度到这颗CPU上。然而调度器不在这个级别,系统会进行bug-check
- 无法处理任何页面错误。因为页面错误需要上下文切换至某一个已修改页面写出器。而此时无法进行上下文切换,系统将崩溃。也就是说,IRQL2或以上的代码只能访问非换页内存,通常指从非换页内存池分配的内存。这些内存会一直驻留在物理内存。
- Device IRQL(DIRQL) 3~12:硬件中断级别。中断抵达后,内核的陷阱调度程序会调用相应的中断服务例程(Interrupt Service Routine, ISR),并将其IRQL提高至与相关中断一致。
- Clock(13)
- IPI/Power(14)
- High/Profile(15)
运行在特定的IRQL下,可以以该IRQL或更低的IRQL产生中断。
延迟过程调用
延迟过程调用(DPC)是一种对象,它封装了对于DPC_Level(2)这个IRQL下运行的函数的调用。它一般用于中断的后处理,因为在DIRQL下运行会遮掩/拖延等待获得服务的其它中断。典型的ISR会尽可能少的只处理最少量的工作,绝大部分工作只是读取设备状态,然后告诉设备停止发送中断信号,随后请求一个DPC,借此将进一步地处理推迟至IRQL(2)
ISR和DPC都是线程无关的,简单来说:假设当前某个CPU在运行一个用户模式/内核模式代码,处于IRQL0,现在,发生了一个硬件中断,假设为5,这颗CPU的状态会被保存,然后提高IRQL为5,并执行对应的ISR,如果本身在运行用户代码,则中断到达时还要切换至内核。处理完这个中断后,会把一个DPC插入DPC队列。当CPU想要回归IRQL0时,内核会检查队列是否还有DPC,如果还有,会优先降至IRQL2,然后处理这些DPC,先进先处理。最后回到IRQL0等级。这样,如果有多次中断,比如0到5到8,DPC的处理顺序是8,5。
设备驱动程序
驱动程序的类型
WDM驱动主要分为以下三个类型
- 总线驱动程序:用于管理逻辑或物理总线,例如PCMCIA,PCI,USB,IEEE1934。总线驱动程序负责检测附加到自己所控制的总线上的设备,并将这些情况通知PnP(即插即用设备)管理器,同时负责管理总线的电源设置。此类驱动通常是由微软默认提供的。
- 功能驱动程序:用于管理某一特定类型的驱动。总线驱动可以通过PnP管理器将设备呈现给功能驱动程序。功能驱动程序性负责将设备的可操作接口导出给操作系统。
- 筛选器驱动程序:位于功能驱动之上(叫做上层筛选器/功能筛选器),或总线驱动之上(下层筛选器/总线筛选器)的逻辑层。可以扩充或更改设备或其它驱动程序的行为。
当 PnP 管理器枚举出一个设备时,会创建对应的设备节点(devnode),并触发相应驱动的加载过程。加载过程中,驱动会为该设备实例建立一个设备堆栈,其每个节点(PDO、FDO、FiDO)都与 devnode 关联。每个Devnode都对应至少一个FDO和PDO,即功能驱动程序创建的逻辑设备对象和总线驱动程序创建的物理驱动对象。它们之间或FDO上层也可以存在筛选器(FiDO),也可以没有。所有针对某一个设备的IRP请求都会由上层流向下层。
在WDM中,并不会让一个驱动程序负责控制特定设备的所欲哦方面。总线驱动程序负责检测总线成员关系的变化(设备的添加或移除),并协助PnP管理器枚举总线上的所有设备,访问与总线有关的配置注册表,并在某些情况下负责控制总线上设备的电源使用。功能驱动通常是唯一需要访问设备硬件的驱动程序。
驱动程序的分层
对特定硬件提供的支持通常由多个不同驱动程序负责,每个驱动程序提供了让设备正常工作所需的部分功能。除了WDM总线驱动程序,功能驱动程序以及筛选器驱动程序,对硬件的支持可能还会拆分到如下几个组件中:类驱动,小型类驱动,端口驱动,小型端口驱动。
驱动程序的结构
- 初始化例程IO:管理器会执行驱动程序的初始化例程,会将驱动程序载入操作系统时,该例程会被WDK设置为GSDriverEntry。GSDriverEntry会初始化编译器为防止栈溢出错误而设置的保护机制,随后调用DriverEntry。该例程由驱动程序的编写者实现。该例程可填充某些系统数据结构,借此向IO管理器注册驱动程序的其它例程,并执行必要的全局驱动程序初始化工作。
- 添加设备例程:支持即插即用的驱动程序需要实现添加设备例程。在检查到该驱动程序负责的设备后,PnP管理器会通过该例程向驱动程序发送通知。在这个例程中,驱动程序通常会创建用于代表该设备的设备对象。
- 一系列分发例程:分发例程是设备驱动程序提供的主要入口点,例如打开,关闭,读取,写入以及即插即用。当调用此类例程执行IO操作时,IO管理器会生成一个IRP并通过驱动程序的某个分发例程来调用驱动程序。
- 一个启动IO例程:驱动程序可以使用启动IO例程向设备(从设备)发起数据传输。只有需要依赖IO管理器将传入的IO请求加入队列的驱动程序才需要定义该例程。通过确保驱动程序一次只处理一个IRP, IO 管理器会对驱动程序的 IRP 进行连续化。驱动程序可以并发处理多个IRP ,但大部分设备通常都会需要连续化,因为它们无法并发处理多个IO请求。
- 一个中断服务例程(ISR):当一个设备中断时,内核的中断调度程序会将控制权传递给例程。在Windows的IO模型中,ISR运行在设备中断请求级别(DIRQL)上,因此为避免阻塞更低IRQI的中断,它们只需要执行尽可能少的工作。ISR通常会对 DPC进行排队,由运行在更低 IRQL(DPC/Dispatch 级别)上的 DPC 执行中断处理过程的后续工作。只有中断驱动的设备驱动程序具备ISR,例如文件系统驱动程序就没有 ISR。
- 一个中断服务DPC例程:在执行 ISR后,将由 DPC 例程执行与设备中断处理有关的大部分工作。DPC例程在IRQL2下执行,这是较高的DIRQL和较低的Passive级别(0)之间的一种“妥协”。典型的DPC例程负责发起IO完成操作并启动设备上下一个正在排队的IO操作。
- 一个或多个IO完成例程:分层式驱动程序可能包含IO完成例程,该例程会在低层驱动程序完成IRP的处理工作后发出通知。例如,IO管理器会在设备驱动程序完成向文件或从文件传输数据的操作后,调用文件系统驱动程序的IO完成例程。该完成例程会通知文件系统驱动程序操作成功、失败或被取消,随后可让文件系统驱动程序执行清理操作。
- 一个取消 IO例程:如果某个 IO操作可以被取消,驱动程序即可定义一个或多个取消 IO。当驱动程序收到某个I/O请求的IRP,并且该请求可以取消时,便会向该IRP分配取消例程。随着 IRP经历处理过程的不同阶段,该例程可以更改,如果当前操作已
出,或者操作被取消(例如使用Windows的Cancello或CancelloEx函数),IO管理器会执行IRP的取消例程(如果分配了取消例程)。取消例程负责执行各种必要的步骤,借此释放该IRP在处理过程中已经获得的各种资源,并使用“已取消”的状态来完成该IRP。- 快速分发例程:使用缓存管理器的驱动程序(例如文件系统驱动程序)通常会提供此类例程,借助内核在访问驱动程序时即可绕过典型的IO处理。例如,诸如读取或写入等操作可以直接访问缓存的数据,而非通过IO管理器的常规路径产生不连续的多个IO操作,借此即可加快操作速度。快速分发例程还可用于从内存管理器和缓存管理器到文件系统驱动程序的回调机制。例如在创建内存时,内存管理器可以回调文件系统驱动程序,并以独占的方式获得文件。
- 一个卸载例程:卸载例程可以释放驱动程序正在使用的任何系统资源,随后IO管理器即可从内存中移除驱动程序。任何初始化例程中获得的资源通常都需要在卸载历程中释放。如果驱动程序支持,即可在系统运行过程中随时加载或卸载,但卸载例程只有在到设备的所有文件句柄都关闭后才能调用。
- 一个系统关机通知例程。该例程可以让驱动程序在系统关机时进行清理。
- 错误记录例程:如果出现非预期错误(例如磁盘出现坏块),驱动程序的错误记录例程会注意到这种情况,并通知IO管理器。随后IO管理器会将相关信息写入错误日志文件。
文件对象和设备对象
文件对象是一种内核模式的数据结构,代表设备的句柄。文件对象为符合以IO为中心的接口资源提供了一种基于内存的表达方式。也就是说:句柄指向的就是这个句柄代表的文件对象(当然也可能有其它类型的对象,比如事件,目录)。这个文件对象是描述某个设备的数据。在
文件对象在VS中的定义如下:
1 | typedef struct _FILE_OBJECT { |
FileObjectExtension中有其它属性,但是vs中找不到定义了。其中比较重要的是重新解析路径的符号链接在这里
总而言之,文件对象表明了一个线程打开一个文件时,使用的句柄相关属性。例如,对于同步打开的文件,当前字节偏移属性值表明了下一次的读取和写入操作作用到文件的哪里。每一个句柄就对应了一个文件对象,然而,虽然文件句柄对进程来说是唯一的。但是底层的物理资源并不唯一。所以必须以同步的方式访问这个文件对象。
Windows中内部设备名在\Device中,必须要在\Global??目录中创建一个指向设备对象的符号链接,这些设备对象才能被Windows应用程序访问。
驱动程序对象和设备对象
当线程尝试打开文件对象的句柄时,IO管理器必须通过文件对象名称决定需要调用哪一个驱动程序来处理该请求。下一次使用同样句柄时,IO管理器也要能定位此信息。为此需要用到驱动程序对象和设备对象。
驱动对象:系统中一个驱动程序对象结构,IO管理器会通过它取得驱动程序的分发例程地址。
设备对象:描述系统中的物理或逻辑设备特征的结构。例如需要缓冲区对齐特性和保存传入的IRP所需的设备队列位置。设备对象是所有IO操作的目标,因为该对象是句柄的通信目标。
下图为设备对象和驱动对象的关系。
驱动对象包含一个指针,指向第一个设备对象结构,设备对象结构中的NextDevice指向下一个设备对象结构。它们的DriverObject域均指回驱动对象。
可以这样理解:驱动对象代表驱动的行为,而每个设备对象代表一个通信端点(end point)。例如在包含4个串口设备的系统中,可能只有1个驱动对象,但会有4个设备对象实例,每个实例对应一个串口,每个串口可在不影响其它串口的情况下打开。对于硬件设备,每个设备都代表一组不同的硬件资源,例如IO端口,内存映射IO以及中断线。Windows以设备为中心,而不是以驱动程序为中心。
和上一节说的一样,必须要在\Global??目录中创建一个指向设备对象的符号链接,Windows应用程序才能访问设备对象。使用IoCreateSymbolicLink函数实现这个操作。如果是PnP设备,则需要使用IoRegisterDeviceInterface函数。此时,设备对象指回驱动程序,IO管理器就可以知道受到IO请求后该调用哪个驱动程序例程。它会使用设备对象查找驱动程序对象,而这个驱动对象代表了为该设备提供服务的驱动程序。随后IO管理器会利用原始请求中提供的函数代码索引到驱动程序对象中。每个函数代码对应一个驱动程序分发例程。
I/O的处理
上面的内容均是IO处理涉及的组件,这一节说明IO请求是如何流动的
I/O的类型
应用程序在发出IO请求时可以选择多种类型:
- 同步IO:默认的方式,ReadFile或WriteFile函数可以用同步的方式执行,在完成IO操作后,把控制权交回调用方。
- 异步IO:设备在执行IO操作时可以继续执行,也可以发出多个IO请求,使用异步IO必须要在CreateFile(2)中指定FILE_FLAG_OVERLAPPED标志。在设备驱动程序完成数据操作前,该线程不能访问来自该IO的任何数据。因此需要监视一个同步对象句柄,等待IO完成后自动发出信号,借此将执行过程与IO操作的完成保持同步。
- 快速IO:绕过生成IRP,直达驱动程序设备栈进而完成IO请求。需要驱动程序将快速IO入口点放入PFAST_IO_DISPATCH指针指向的结构中。如果调用失败,再使用标准的IRP流程。
无论哪一种类型,IO操作在驱动中均以异步的方式执行。一旦成功发起请求,驱动必须尽可能快地返回IO系统。但IO系统是否可以立即返回到调用方,取决于使用的是同步IO还是异步IO。
- 文件映射IO:文件映射IO是IO系统和内存管理器配合的参悟。它是指将磁盘上的文件看作进程虚拟内存的组成成分的能力。程序可以用大数组的形式访问文件,而无需缓冲数据或执行磁盘IO。程序访问内存时,内存管理器会使用换页机制从磁盘文件加载正确的页面,写入时,也会通过正常换页将改动写回文件。可以用CreateFileMapping或MapViewOfFile等函数来实现。
- 分散/聚集IO:通过ReadFileScatter和WriteFileGather函数,让应用程序只需要发出一个读取或写入请求,就可以再虚拟内存中多个缓冲区与磁盘文件的连续区域之间传输数据,而无需为每个文件发送单独的IO请求。
IO请求包
IO请求包(IRP)是IO系统处理IO请求而存储必要信息的地方。当线程调用IO API时,IO管理器会构造一个IRP,并在IO系统进行处理的过程中使用IRP代表对应的请求。
结构如下:
1 | typedef struct DECLSPEC_ALIGN(MEMORY_ALLOCATION_ALIGNMENT) _IRP { |
IRP之后是它所需要的IO栈位置,数量等于该IRP所应用到的设备节点中分层式驱动程序的数量。
具体结构如下:
1 | typedef struct _IO_STACK_LOCATION { |
将这些信息分散到IRP主体和其IO栈位置,这样的作法使得系统可以为设备栈中的下一个设备更改IO栈位置参数,同时可以保留原始请求包含的参数。例如,以USB设备为目标的读取IRP通常会被设备IO控制IRP函数驱动程序所更改,使得设备控制的输入缓冲区参数指向能被下层USB总线驱动程序理解的USB请求包。另外注意,任何层(除了最底层)都可以注册完成例程,且每一层都可以在IO栈位置中拥有自己的一席之地。
如果可能,IO管理器会从下列3个每颗处理器的IRP非换页旁视列表之一中分配IRP:
- 小IRP旁视列表:存储只有一个栈位置的IRP
- 中IRP旁视列表:存储只有二至四个栈位置的IRP
- 大IRP旁视列表:存储超过四个栈位置的IRP,一般包含14个,但是会根据当前所需栈位置数量每分钟调整一次分配的栈位置数量,最多20个
有的列表会得到全局旁视列表的支持,从而实现更高效的跨CPU IRP流动。如果IRP需要的栈位置数量超过大旁视列表中IRP可包含的数量,该IRP会从非换页内存池中分配。
在活跃时,每个IRP通常会在与请求该IO的线程相关的IRP列表中排队;而在执行与线程无关的IO时,它会被存储在文件对象中。借此,如果线程在IO请求完成之前终止,IO系统就可以找到并取消任何尚未处理的IRP。此外换页IO的IRP也会关联至导致页面错误的线程(不过这些IRP无法取消)。这样Windows就可以使用线程无关的IO优化机制:如果当前线程就是发起线程,则无需使用异步过程调用(APC)完成该IO。这意味着页面错误将在线程内部发生,无需传递APC。
IRP流程
IRP由对象管理器创建,随后被发送至目标设备节点中的第一个设备。并非只有IO管理器才可以创建IRP,即插即用设备和电源管理器也可以通过主要函数代码IRP_MJ_PNP和IRP_MJ_POWER创建IRP。
假设设备节点如上图所示,那么以这个设备节点为目标的IRP会创建6个IO栈位置,每层对应一个。IRP始终会被传递给最高层设备,哪怕对处于设备栈中较低的位置命名设备打开了句柄。
驱动接收到IRP后,可以执行以下操作:
- 可以完成此IRP随后调用IoCompleteRequest。这可能是因为该IRP包含一些无效参数(例如缓冲区大小不足,或包含出错的/0控制代码),或因为操作请求本身很快速,可以立即完成,例如获取与设备有关的某些状态,或从注册表读取一个值。驱动程序会调用IoGetCurrentIrpStackLocation获取自己需要引用的栈位置的指针。
- 进行过某些可选处理后,驱动程序可以将该IRP转发至下一层。例如上层筛选器可以对操作进行某些日志记录工作,随后向下传递该IRP以供正常执行。在向下发送该请求前,驱动程序必须准备好下一个IO栈位置,因为随后的下一个驱动程序可能需要查找该位置。如果不希望进行任何修改,可以使用IoSkipCurrentIrpStackLocation宏,或使用IoCopyIrpStackLocationToNext创建副本,使用IoGetNextIrpStackLocation获得指针,并酌情修改栈位置副本。下一个IO栈位置准备好后,驱动程序会调用IoCallDriver执行实际的IRP转发工作。
- 作为上一种方式的扩展,驱动程序还可以调用IoSetCompletionRoutine(Ex)例程注册一个完成例程,随后再向下传递IRP。除了最底层之外,其他任何一层都可以注册完成例程(最底层驱动程序注册完成例程的做法没有任何意义,因为驱动程序必须完成该IRP,所以无须回调)。当下层驱动程序调用IoCompleteRequest后,IRP会向上传递,按照注册顺序的倒序依次调用所有完成例程。实际上,IRP的发起者(IO管理器、PnP管理器或电源管理器)会使用这种机制执行任何IRP后处理操作并最终释放该IRP。
针对单层硬件驱动程序的IO请求
实线表示用户模式和内核模式之间的常规划分。虚线分个类在请求线程上下中运行以及在任意线程上下文中运行的代码。
大方框包裹的4个小方块(分别为分发例程、启动I/O例程、ISR和DPC例程)代表驱动程序提供的代码。所有其他小方块对应的内容均由系统提供。
(3)图6-18中会假设硬件设备可以一次处理一个操作,很多类型的设备属于这种情况。就算设备可以处理多个请求,操作的基本流程也是类似的。
上图所示的事件处理顺序如下:
- 客户端应用程序调用一个 Windows API,如 ReadFile。ReadFile 调用原生NtReadFile(位于Ntdll.dll中),借此将该线程转换至执行体NtReadFile的内核模式
- NtReadFile中实现的IO管理器针对该请求执行一些合理性检查,例如客户端提供的缓冲区是否可以通过正确的页面保护机制访问。随后,I/O管理器将(使用所提供的文件句柄)定位相关驱动程序、分配并初始化一个 IRP、为该 IRP使用 IoCallDriver,以及将驱动程序调用到相应的分发例程(本例中对应于IRP_MJ_READ索引)。
- 这是驱动程序首次看到该IRP。这个调用通常会涉及发出申请的线程,唯一可以不这样做的方法是由上层筛选器保存该IRP,并稍后通过另一个线程调用IoCallDriver。为了更全面地讨论,此处我们会假设实际情况并非如此(并且对于涉及硬件设备的大部分场景,也不会出现这种情况,哪怕存在上层筛选器,也会在同一个线程中执行某些处理工作并立即谓用下层驱动程序)。驱动程序中的分发读取回调承担两个任务:首先,负责执行I/O 管理器无法进行的更多检查,因为 IO管理器并不知道每个请求真正的实际含义。例如驱动程序可能会检查为读取或写入操作提供的缓冲区是否足够大,或者对于 DeviceloContol操作驱动程序会检查所提供的 IO控制代码是否可以得到支持。如果此类检查失败,驱动程序会使用失败状态完成该IRP(IoCompleteRequest)并立即返回。如果检查成功,驱动程序会调用自己的启动110例程来发起操作。然而,如果硬件设备当前正忙(忙于处理上一个IRP),那么该IRP会被插入由驱动程序管理的队列中,并在不完成该IRP的前提下返回一个STATUS_PENDING状态。IO管理器会通过IoStartPacket函数适应这样的场景,该函数会检查设备对象中的“忙碌”位,如果设备正忙,则会将IRP加入队列(该队列也是设备对象结构的一部分)。如果设备不忙,则会将设备位设置为忙碌,然后调用已注册的启动IO例程(别忘了,驱动程序对象中就有这样的成员,并且可能已经在DriverEnty中进行了初始化)。就算驱动程序选择不使用IoStartPacket,也可能会遵循类似的逻辑。
- 如果设备不忙,将直接从分发例程调用启动I/O例程,这意味着目前依然在发出该调用的请求方线程内执行。然而上图所示的启动/O例程是在任意线程上下文中调用的,当讨论到第(8)步的DPC 例程时就会知道,常规情况下确实会如此。这个启动IO例程的作用是获取与IRP有关的参数,并使用它们对硬件设备编程(例如使用WRITE_PORT_UCHAR、 WRITE_REGISTER_ULONG 等HAL 硬件访问例程写入其端口或寄存器)。当启动IO完成后,调用将会返回,驱动程序中不再运行特殊代码,硬件开始“执行自己的任务”。当硬件设备开始工作时,同一个线程可能向设备发出了更多请求(如果使用异步操作),或者其他线程打开了指向该设备的句柄。此时分发例程会意识到设备正忙,因此将IRP插入IRP队列(如上所述,实现这一目标的方式之一是调用IoStartPacket)。
- 设备完成当前操作后,将产生一个中断。内核陷阱处理程序会将被选中执行该中断的CPU上当前执行的任意线程的CPU上下文保存起来,然后将该CPU的IROL提升至中断所关联的IRQL(DIRQL),并跳转至该设备注册的ISR。
- 运行在设备IRQL(高于2)的ISR会执行尽可能少的工作,告诉设备停止中断信号,并获取硬件设备的状态或其他必要信息。在它最后一步的操作中,ISR会将需要在更低IRQL下进一步处理的DPC加入队列。使用DPC执行大部分设备服务,这种做法的优势在于在开始处理较低优先级DPC之前,IROL介于设备IRQL和DPC/分发IRQL(2)之间的、产生了阻塞的中断都是被允许的。因此中间优先级的中断可比不这样做时更迅速地得到服务,进而有助于降低系统延迟。
- 中断解除后,内核会注意到DPC队列非空,因此会在IRQL DPC_LEVEL(2)上使用软件中断跳转至DPC处理环路。
- 最终,DPC被从队列中移出,并在IRQL2下执行,通常会执行如下两个主要操作。
- 获取队列中的下一个IRP(如果存在的话),并为设备启动新操作。为此,首先会防止设备闲置太长时间。如果分发例程使用了IoStartPacket,那么DPC例程将会调用对应的IoStartNextPacket,仅此而已。如果有IRP可用,将从DPC调用启动IO例程。因此一般情况下,启动I/O例程可以从任意线程上下文调用。如果队列中没有其他IRP,设备将被标记为不忙,也就是说已经准备好处理收到的下一个请求。
- 完成IRP,其操作已经由驱动程序调用IoCompleteRequest完成。随后驱动程序将不再负责处理该IRP,也不应再接触该IRP,因为这个IRP可能在调用完成后随时释放。IoCompleteRequest将调用已注册的任何完成例程。最后,1/O管理器将释放该IRP(实际上是使用它自己所拥有的一个完成例程来释放的)。
- 操作完成后,最初发出请求的线程需要获得通知。由于执行DPC的当前线程是任意线程,而非使用初始进程地址空间的初始线程,为了能够在发出请求的线程上下文中执行代码,需要对该线程发出一个特殊的内核APC。APC是一种函数,可用于强制在特定线程的上下文中执行。当发出请求的线程得到CPU时间后,将优先执行这个特殊的内
- 核APC(位于IRQL APC_LEVEL=1下),进而执行所需的工作,例如解除线程的等待状态,为注册到异步操作的事件发送信号等。
关于IO完成还有一个问题需要注意:异步IO函数ReadFileEx和WriteFileEx允许调用方将回调函数作为参数提供。如果调用方这样做,IO管理器会将用户模式APC加入调用方的线程APC队列,并将其作为I/O完成前执行的最后一个步骤。该功能使得调用方可以指定1/O请求完成或取消之后要调用的子例程。用户模式APC完成例程是在发出请求的线程上下文中执行的,且只有在该线程进入可警告的等待状态后才会发出(通过调用诸如SleepEx、WaitForSingleObjectEx 或WaitForMultipleObjectsEx等函数)。
用户地址空间缓冲区访问
IRP的处理过程涉及4个主要驱动程序函数。这些例程中的部分或全部都可能需要访问客户端应用程序提供的位于用户空间的缓冲区。当应用程序或设备驱动程序使用NtReadFile,NtWriteFile或NtDeviceIoControlFile系统服务间接创建了IRP后,将通过IRP主体的UserBuffer成员提供指向用户缓冲区的指针。然而直接访问该缓冲区的做法只能在发出申请的线程上下文中及IRQL0中进行。但是只有分发例程满足上面的两个条件。另外三个例程,均在任意线程中执行且IRQL为2。这样的话,如果直接访问:
- IRQL大于等于2:无法换页,如果用户缓冲区换出,会导致崩溃。
- 可能是任意线程:访问到随机的进程的虚拟内存空间
所以需要一种更加安全的方式去访问内存。IO管理器提供了三种方法来访问用户态缓冲区:
- 缓冲的IO:IO管理器在内核分配一个镜像缓冲区,且从非换页池分配,大小和用户缓冲区在中的大小相同,其中(IRP)存储了指向IRP主体的AssociatedIrp.SystemBuffer成员内新建缓冲区的指针,写入时,在创建IRP的时候IO管理器会将调用方的缓冲区数据复制到已分配的缓冲区。读取时,IRP完成时会将已分配缓冲区中的数据复制到用户缓冲区,随后释放已分配的内存。
- 直接的IO:直接IO提供了一种不需要复制就可以直接访问用户缓冲区的能力。IO管理器创建IRP时,调用MmProbeAndLockPages函数将用户缓冲区锁定到内存(使其不可换页)。IO管理器会以内存描述符列表的形式存储有关内存的描述信息,MFL描述了被缓冲区使用的物理内存,其地址存储在IRP主体的MdlAddress成员中。驱动程序可以通过MmGetSystemAddressForMdlSafe函数将缓冲区映射至系统地址空间,并传入所提供的MDL。借此生成的指针可以在任何进程上下文和任何IRQL中安全的使用。这等于对用户缓冲区进行了双重映射。用户直接地址只能从原始进程上下文使用,而到系统空间的第二次映射使其可在任何上下文中使用。IRP完成后,IO管理器会是以哦那个MmUnlockPages解锁缓冲区。
- 两者皆非的IO:IO管理器不执行任何缓冲区管理工作,这些工作将留给设备驱动程序自行处理。
驱动程序可以通过以下方式选择上面的方法:
读取和写入请求:为设备对象Flags成员设置DO_BUFFER_IO(缓冲的IO)或DO_DIRECT_IO(直接的IO)。如果都没有设置,则是两者皆非的IO。
对于IRP_MJ_DEVICE_CONTROL,可以使用CTL_CODE宏构造每个控制代码,通过其中某些位设置缓冲具体方法。
同步
由于驱动程序的执行可能被其它高优先级线程抢占,进而时间片量程可能到期或被更高IRQL的中断所打断。同时,在多处理器系统中,Windows可以在多处理器上并行地运行驱动程序代码。如果不同步,分发例程运行在IRQL0时可能被设备中断打断,导致其ISR会在设备驱动正在运行的情况下执行,可能会导致它们都想修改一些数据从而导致错误。
在单CPU系统中,如果想要实现在不同IRQL等级的线程之间的同步,只需要在同步时把低IRQL等级的线程提升到它们中最高的即可(KeRaiseIrql)。如果是跨CPU同步,则需要旋转锁。
旋转锁是内存中的一个位,可以被原子测试和修改操作访问,且可用于大于等于IRQL2的场景。由于互斥体只能在IRQL1及以下的等级使用(需要调度器),所以对旋转锁的等待是一种忙等待:线程无法进入常规等待状态,因为这需要调度器被唤醒并切换至该CPU上的其它线程。
获取旋转锁时,CPU的IRQL被提升至与同步对象相同(同上面单CPU系统)。然后旋转锁会通过原子测试的方式获取并设置旋转锁位。需要使用KeAcquireSpinLock和KeReleaseSpinLock函数来设置,释放锁。并用KeInitializeSpinLock来初始化。对于函数和ISR的同步,必须使用另一个函数。每个中断对象内部都保存了一个旋转锁,是ISR执行之前获得的,这说明同一个ISR无法在其它CPU上并发运行。该旋转锁可通过KeAcquireInterruptSpinLock间接获得并可使用KeReleaseInterruptSpinLock来释放;也可以用KeSynchronizeExecution函数,该函数可接受驱动程序提供的回调函数,并会在中断旋转锁的获取和释放之间调用。
尽管ISR需要特别注意,但是设备驱动程序所使用的任何数据都可接受驱动程序所访问,因此设备驱动程序代码在使用任何全局或共享数据,或对物理设备本身进行任何方式的访问时,必须以同步的形式进行访问。
以上内容,绝大部分出自深入解析Windows操作系统第7版并融入了部分个人理解。
这是第一篇,尚未写完。还有2。通信实验放在下一篇博客吧。