1. Windows内部概览
1.1 进程
进程包含以下内容:
- 可执行程序,代码和数据
- 私有的虚拟地址空间,分配内存时从这里分配
- 主令牌,保存进程默认安全上下文,进程中的线程执行代码时会用到它
- 私有句柄表,保存进程运行过程中的事件、信号量、文件等的句柄
- 一个或多个执行线程
1.2 虚拟内存
每个进程都有自己的私有虚拟且线性的地址空间
地址空间一开始是空的,然后可执行映像和ntdll.dll首先被映射进内存中,继而是更多的子系统dll
地址空间从0开始(但是第一个64KB大小的地址是不可以被使用的),一直增长最大值(依赖于进程的位数和操作系统的位数)
进程用户地址空间最大值规则如下:
- 32位程序+32位系统,进程用户地址空间大小默认2GB
- 32位程序+32位系统,通过修改PE头的
LARGEADDRESSAWARE
标志增加地址空间,最大可扩展到3GB - 32位程序+64位系统,通过修改PE头的
LARGEADDRESSAWARE
标志增加地址空间,最大可扩展到4GB - 64位程序+64位系统,地址空间大小为8TB(win8及以前),或128TB(win8.1及以后)
虚拟地址可能实际位于物理内存中,也有可能位于页面文件中。
如果是位于物理内存中,则直接访问数据;
如果位于页面文件中,则cpu或产生一个page fault
异常,内存管理器的页错误异常处理程序将会从页面文件中读取数据并复制到物理内存中,然后让cpu重新访问
内存以页面为单位进行管理,页面的大小由cpu的类型决定。windows系统中默认为4KB(小页内存),也支持2MB(x86/x64/ARM64)和4MB(ARM),又叫大页内存
大页内存的优点:
直接使用页目录入口(Page DIrectory Entry, PDE)进行映射,不适用页表,因此转换速度更快,能更好的利用地址转换缓冲区(Translation Lookaside Buffer,TLB)一个由CPU维护的近期转换页面的缓存。单个TLB入口能比使用小页面映射更多的内存
大页内存的缺点:
大页内存需要在RAM中是连续的,因此当内存紧张或非常碎片化时可能会分配失败。
大页内存始终是非分页的并且只能设置读/写保护
1.2.1 页状态
虚拟内存的那每个页面处于如下的三种状态之一:
- 空闲,页面未被分配,没有任何数据存在,对该页面的访问将会引起一个访问违规(Access Violation)异常
- 已提交,页面中已经有数据了,可以在不违反该页面的保护属性的情况下进行访问操作,已提交页面会被映射在RAM或页面文件中
- 保留,页面的地址范围被保留,但还未使用,将来可能会使用,后续进行内存申请时,将不会申请到保留部分的页面地址。同样对保留页面访问将触发访问违规(Access Violation)异常
1.2.2 系统内存
地址空间的较低部分由进程使用,较高部分由操作系统使用
- 32位系统,没有配置增加地址空间时,操作系统地址空间为高端2GB,0x8000,0000 ~ 0xFFFF,FFFF
- 32位系统,配置增加地址空间时,操作系统地址空间为剩余的地址空间,如配置进程最大3GB地址空间,那么操作系统地址为高端1GB,0xC000,0000 ~ 0xFFFF,FFFF,此时最有可能收到影响的就是操作系统的文件系统缓存
- 64位系统,win8及以前,操作系统使用高端8TB地址空间
- 64位系统,win8.1及以后,操作系统使用高端128TB地址空间
操作系统地址空间是进程无关的,而用户进程的地址空间是进程相关的,每个进程都有独立的地址空间,其他进程无法访问
操作系统地址都是绝对地址,无论从任何进程中看同一个地址,内容都是一样的。
从用户模式直接访问系统空间地址会触发访问违规Access violation异常
1.3 线程
线程位于进程中,使用进程提供的资源来工作(如虚拟内存和内核对象的句柄)
线程包含一下内容:
- 当前的访问模式,用户模式或内核模式
- 执行上下文,处理器的寄存器和执行状态
- 一个或两个栈,用于局部变量分配和调用管理
- 线程局部存储(TLS)数组,为存储线程的私有数据提供统一的访问语义
- 基本优先级和当前(动态)优先级
- 处理器亲和性(affinity),指定线程可以在哪个处理器上运行
线程的状态包括:
- 运行,正在一个(逻辑)处理器上执行代码
- 就绪,所有处理都忙或不可用,处于等待被调度执行的状态
- 等待,等待某些事件发生以继续处理,一旦事件发生,线程进入就绪状态
1.3.1 线程栈
每个线程都有栈,用于存放局部变量,传递函数参数,以及在调用函数之前存放返回地址
每个线程至少有一个栈位于系统内核空间内,这个栈很小(32位系统默认12KB,64位系统默认24KB)
每个线程还有一个栈位于用户空间中,这个栈比较大(默认最大1MB)
当线程位于运行或就绪状态时,内核栈一直驻留在RAM中。用户模式下的栈可能被页换出,和所有用户模式的内存一样。
用户模式的栈在起始时,只提交一小部分内存(可能只有一个页面),栈地址空间的其余部分作为保留内存,使用一个保护属性PAGE_GUARD
标记已提交页面的下一页(有时会多于一页),指明这是一个警戒页面(guard page)。当线程需要更多的栈时,它会写到警戒页面,此时会产生一个异常被内存管理器处理,此时内存管理器会移除该页上的警戒保护,并提交该页,然后设置下一页的警戒保护
线程用户模式的栈大小按如下方式确定:
- 可执行映像在其PE文件头内指定了栈的提交大小和保留大小,如果没有指定,则使用默认值
- 当用
CreateThread
及类似函数创建线程时,可以在函数参数中指定线程用户栈的大小
1.4 线程服务
应用程序需要进行一些非纯粹计算的操作,如分配内存、打开文件、创建线程等,最终只能由内核中的代码来完成
打开文件系统服务的执行流程:
- 用户模式调用
CreateFile
(位于kernel32.dll中实现) - kernel32.dll中进行一些错误检查,然后调用
NtCreateFile
(NTDLL.DLL中的一个导出函数),NTDLL.DLL是一个基础DLL,实现了原生API,是位于用户模式的最底层代码。 - NtCreateFile是一个执行到内核模式的转换API,在进行实际的转化之前,它首先将一个叫做系统服务号的数字放到CPU寄存器里(Intel/AMD体系结构中是
EAX
),然后执行一个特殊的CPU指令(x64下是syscall
,x86下是sysenter
)来实际转换到内核模式,并跳转到一个预定义的被称为系统服务分发器(system service dispatcher)的例程中 - 系统服务分发器读取EAX寄存器中的值用作系统服务分发表(System Service Dispatch Table, SSDT)的入口索引,跳转到相应的系统服务中
- 对于NtCreateFile来说,SSDT中相应的入口会指向I/O管理器的同名
NtCreateFile
函数,且参数相同 - 当系统服务执行完毕后,线程会返回到用户模式,执行紧接着syscal或sysenter的指令
1.5 系统总体架构
用户进程
,基于映像文件的普通进程,在系统中执行子系统DLL
,实现子系统API的动态链接库,子系统包含众所周知的文件,kernel32.dll、user32.dll、gdi32.dll、advapi32.dll等NTDLL.DLL
,系统范围的DLL,实现了Windows的原生API,这是用户模式的底层,主要提供为系统调用提供内核模式转换,还有堆管理、映像加载、部分用户模式线程池功能等服务进程
,普通的Windows进程,它和服务控制管理器(SCM,在services.exe中实现)通信,对它的生命周期进行管理执行体
,执行体位于NtOskrnl.dll(内核本体)的高层,包含了绝大部分的内核代码,其中大部分是管理器:对象管理器、内存管理器、IO管理器、即插即用管理器、电源管理器、配置管理器等内核
,内核层实现了最基础和最时间敏感的内核模式操作系统代码,包括线程调度、中断、异常分发、互斥量、信号量等设备驱动程序
,可装载的内核模块,具备完全的内核能力Win32k.sys
,Windows子系统的内核模式组件,本质上是一个内核模块(驱动程序),处理所有Windows用户界面相关的操作硬件抽象层HAL
,最接近CPU的硬件之上的一个抽象层,使得设备驱动可以通过调用API来工作,而不需要知道硬件细节系统进程
,用于描述那些通常就在那干自己事情的进程,如smss.exe、lsass.exe、winlogon.exe、services.exe等子系统进程
,windows子系统进程运行的映像文件是csrss.exe,视为一个助手进程,帮助内核对windows系统中运行的进程进行管理。每个会话都会有一个csrss.exe的实例在运行Hyper-V虚拟机管理器
,位于windows10及以后的版本里,如果cpu支持vbs的话,vbs提供一个额外的安全层,让实际的机器只是一个Hyper-V控制的虚拟机
1.6 句柄和对象
Windows内核提供多种类型的对象以供用户模式进程、内核本身、驱动程序使用。这些对象由对象管理器(执行体的一部分)在用户模式或内核模式代码请求时创建在内核空间中
对象时引用计数的,只有当对象的最后一个引用被释放之后,对象才会被销毁并从内存中释放
由于这些对象位于内核空间中,用户模式无法直接访问,因此通过一种间接的机制来从用户模式访问操作这些对象,即句柄
句柄是一个表格的入口索引,该表格在进程的基础上维护,每个进程都有一个表格,表格中的每一项都指向内核中的一个对象。通过Create或Open等函数创建和打开内核中的对象,并返回内核对象对应的表格中项的索引
内核代码可以使用句柄或对象的直接指针,可以通过ObReferenceObjectByHandle
函数将句柄转化为对象的直接指针,此时对象的索引将+1,用完后需要使用ObDerefenceObject
将索引-1
句柄的值始终的4的倍数,第一个有效的句柄值是4,0并不是有效的句柄值
1.6.1 对象名称
- 并非所有的对象都有名称,进程和线程对象没有名称,它们有标识符;文件对象没有名称,文件名并非文件对象的名称,这是两个概念
- 从用户模式使用名称调用Create函数,在此名称的对象不存在时,将创建一个对象,如果该名称对象已经存在,只会打开已经存在的对象,此时通用GetLastError将会返回
ERROR_ALREADY_EXISTS
提供给Create函数的名称并非对象的最终名称,名称的前面会被添加\Sessions\x\BaseNamedObjects\
,其中x是调用者的会话标识符;
如果是0号会话,名称的前面会加上\BaseNamedObjects\
如果调用者在应用容器内,一般是通用windows平台uwp进程,那么加到前面的字符串将更加复杂,包含了唯一的应用容器SID,如\Sessions\x\AppContainerNamedObjects\{AppContainerSID}
对象的名称是相对于会话的,如果一个对象需要在多个会话间共享,可以通过加上前缀Global\
在0号会话中创建它,应用容器没有使用0号会话名字空间的能力
整个名称空间保留在内存中由对象管理器进行管理
查看对象引用技术的正确方法是使用内核调试器的
!trueref
命令
2. 内核开发环境
打开测试签名:bcdedit /set testsigning on
,重启生效
安装驱动程序:sc create simdrv binpath= c:\users\admin\desktop\simdrv.sys type= kernel start= demand
运行驱动程序:sc start simdrv
暂停驱动程序:sc pause simdrv
停止驱动程序:sc stop simdrv
卸载驱动程序:sc delete simdrv
2.1 输出打印信息<dbgprint.h>
#pragma once
#include <ntddk.h>#define _LogMsg(lvl, lvlname, frmt, ...) { \DbgPrintEx( \DPFLTR_IHVDRIVER_ID, \lvl, \"[" lvlname "]" "[irql:%d pid:%-6Iu tid:%-6Iu %s::%-4d] " frmt "\n", \KeGetCurrentIrql(), \PsGetCurrentProcessId(), \PsGetCurrentThreadId(), \__FILE__, \__LINE__, \__VA_ARGS__ \); \
}#define DbgError(frmt, ...) _LogMsg(DPFLTR_ERROR_LEVEL, "erro", frmt, __VA_ARGS__)
#define DbgWarning(frmt, ...) _LogMsg(DPFLTR_WARNING_LEVEL, "warn", frmt, __VA_ARGS__)
#define DbgInfo(frmt, ...) _LogMsg(DPFLTR_INFO_LEVEL, "info", frmt, __VA_ARGS__)
#define DbgTrace(frmt, ...) _LogMsg(DPFLTR_TRACE_LEVEL, "trac", frmt, __VA_ARGS__)
#define DbgLog(frmt, ...) _LogMsg(DPFLTR_ERROR_LEVEL, "****", frmt, __VA_ARGS__)
3. 内核开发基础
用户模式开发和内核模式开发区别:
3.1 使用C++开发内核代码
- 不支持new和delete操作符
- 非默认构造函数中的全局变量将不会被调用,可创建init函数,在DriverEntry中显式的调用;或将类指针定义为全局变量
- C++异常处理try、catch、throw无法通过编译,可通过使用SEH来代替
- 无法使用标准C++库,但是C++模板作为一个语言特性是可以在内核中使用的
内核中常用的C++特性有:
- nullptr关键字
- auto关键字,自带进行类型推断
- 模板
- 重载new和delete操作符
- 构造和析构函数
3.2 内核API
内核中Zw开头的函数是NTDLL.DLL中的原生API的镜像,是从原生API到执行体实现之间的网关
当调用来自于用户模式时,内核中的Nt系列函数会进行合法性检查,调用者的信息会以线程为基础保存在每个线程对应的KTHREAD结构中未公开的PreviousMode
字段里
当调用来自于内核中时,无需进行合法性检查,因此可以调用Zw系列函数,Zw系列函数会将PreviousMode
设置为KernelMode(0)
,然后调用内核中的Nt系列函数,此时Nt系列函数检查PreviousMode
发现本次调用来自于内核,将跳过安全检查
3.3 函数和错误代码
多数内核API会返回一个状态码来指示操作成功或失败,类型是NTSTATUS
,一个32位的整数
可以通过NT_SUCCESS
宏来检查状态码的最高位,其代表着成功与否
某些情况下,从函数返回的NTSTATUS值最终会返回到用户模式,用户模式通过GetLastError得到转换后的值,内核中的状态码和用户层的错误值并非对应的
3.4 字符串
内核中常用两种字符串类型:wchar_t *
或UNICODE_STRING
通常使用一组Rtl系列的函数来操作UNICODE_STRING
内核中也实现了常用的C运行时库的处理函数,wcscpy、wcscat、wcslen、wcscpy_s、wcschr、strcpy、strcpy_s等
3.5 动态内存分配
内核中的栈非常小,任何大块的内存都必须动态分配
内核提供了两个通用的内存池以供使用
- 分页池,在需要时能够将页面换出的内存池
- 非分页池,永远不会换出页面,保证一直驻留在RAM里的内存池
驱动程序应当尽可能少使用非分页池,除非必须,其他情况都应使用分页池
只有三种类型的内存可以被驱动程序分配使用:
- PagedPool,分页内存
- NonPagedPool,非分页内存
- NonPagedPoolNx,没有执行权限的非分页内存
常用内存分配函数:
3.6 链表
系统中所有进程使用EPROCESS结构进行管理,这些结构使用一个环形双向链表连接在一起,其中链表的头部存储在内核变量PsActiveProcessHead
中,每个进程的EPROCESS.ActiveProcessLinks
字段就是一个LIST_ENTRY
typedef struct _LIST_ENTRY {struct _LIST_ENTRY *Flink;struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;
通过CONTAINING_RECORD
宏可以正确计算出包含LIST_ENTRY字段的结构
常用链表处理函数:
3.7 驱动对象DRIVER_OBJECT
DRIVER_OBJECT结构在驱动加载时,由内核分配,并进行部分初始化,然后传递给DriverEntry函数进一步初始化
DRIVER_OBJECT的MajorFunction
字段是一个指针数组,指明了驱动程序支持那些操作
起初MajorFunction数组会被内核初始化成指向内核的内部例程IopInvalidDeviceRequest
,它会给调用者返回一个错误的状态,表明不支持该操作
3.8 设备对象DEVICE_OBJECT
驱动程序无法直接和应用程序通信,只能通过驱动程序创建出的设备对象来进行通信
通过CreateFile
可以打开一个符号链接,也即内核对象,并返回一个对象的句柄或指针
所有能够在用户模式直接打开的符号链接都位于对象管理器中名为??
的目录下
常用的如C:、Aux、Con等都是合法的符号链接,可以被直接打开
其他一些名字很长很复杂的符号链接,是基于硬件的驱动程序调用IoRegisterDeviceInterface
后由IO系统自动生成的
??
目录中的多数符号链接都指向Device目录下的内部设备名称,用户模式不能直接访问这些设备,但内核中可以通过IoGetDeviceObjectPointer
来访问,用户模式只能通过符号链接来访问这些设备
举例:
Process Explorer会生成一个\Device\PROCEXP152
的设备,符号链接名为PROCEXP152
。当用户程序想要打开时,必须添加\\.\
前缀,否则会被当作当前目录下的文件来对待。
HANDLE hDevice = CreateFile(L"\\\\.\\PROCEXP152", GENERIC_WRITE| GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
创建设备:
驱动程序使用IoCreateDevice
函数创建设备对象,该函数会分配并初始化一个DEVICE_OBJECT结构,并返回指针
一个驱动所包含的设备对象的实例位于DRIVER_OBJECT结构的DeviceObject字段中,DEVICE_OBJECT的NextDevice字段指向下一个设备对象,形成了一个链表
新创建的设备对象是从头部插入链表的,因此第一个创建的设备对象保存在链表的最后,它的NextDevice为NULL
4. 驱动程序结构
4.1 线程优先级
用户模式线程的最终优先级 = 线程所在进程的优先级类别(SetPriorityClass
) + 该线程的优先级偏移值(SetThreadPriority
)
内核模式下可以调用KeSetPriorityThread
直接设置线程的优先级,而没有优先级类别的限制
在应用层只有一小部分的优先级能够被直接设置,但是在内核层中可以绕开这些限制,直接将线程设置为任意的优先级
4.2 DeviceIoControl
DeviceIoControl用于和内核设备通信
控制码必须使用CTL_CODE宏来定义
#define CTL_CODE( DeviceType, Function, Method, Access ) ( \((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
)
DeviceType
,标识设备的类型,FILE_DEVICE_XXX的常数,微软规定第三方的值必须从0x8000开始Function
,一个数字码,用于区分不同的操作,微软规定第三方驱动需要从0x800开始method
,输入输出缓冲区的传递方式Access
,指明这个操作是读还是写,FILE_WRITE_ACCESS、FILE_READ_ACCESS、FILE_ANY_ACCESS
4.3 创建设备
创建设备需要调用IoCreateDevice
这个API
- DriverObject,设备对象所属的驱动对象
- DeviceExtensionSize,设备扩展缓冲区的大小
- DeviceName,内部的设备名称,通常在对象管理器的Device目录下创建
- DeviceType,要创建的设备类型,通常和硬件设备相关,软件驱动程序设置为FILE_DEVICE_UNKNOWN
- DeviceCharacteristics,设备的属性特征,一些标志位的集合
- Exclusive,是否允许同时多次打开设备
- DeviceObject,返回参数,用于接收创建好的设备对象指针
如果IoCreateDeive调用成功,那么将从非分页内存池中分配DEVICE_OBJECT结构
4.4 IRP初探
IRP是一个半文档化的结构,用来表示一个请求
IRP通常来自于执行体中的管理器:IO管理器、即插即用管理器、电源管理器(对于软件驱动,基本上都来自于IO管理器)
IRP通常会包括一个或多个IO_STACK_LOCATION
结构,用于代表设备栈中的每一个设备实例,可通过IoGetCurrentIrpStackLocation
获得
IoCompleteRequest函数会完成IRP,将IRP传送会它的创建者,第二个参数是驱动程序提供给客户程序的优先级临时提升数值,一般为0
IRP例程中需要返回一个值,该值和放到IRP中的值一样
5. 内核机制
5.1 中断请求级别(IRQL)
IRQL是处理器的一个属性
示例:由磁盘启动器执行的IO操作,在操作执行完成后,磁盘驱动器会通过请求中断来通知操作已经完成。此中断连接到中断控制器硬件,然后将请求发送到处理器进行处理。哪个线程该执行相关的中断服务例程
每个硬件中断都与一个优先级相联系,即IRQL,由HAL确定
每个处理器的上下文都有自己的IRQL,可以看作寄存器对待
IRQL的执行规则是,cpu始终执行最高级IRQL代码,比如原本cpu的IRQL级别是0,此时一个IRQL是5的中断进来,那么cpu就在当前线程的内核栈里保存其目标状态(上下文),然后将自己的IRQL上升到5并执行与中断相关的中断服务例程ISR。在ISR完成之后,将自己的IRQL降低到原来的级别,从栈中恢复之前的上下文,并执行
重点:ISR由中断发生时,处理器上正在运行的任一线程处理;因此当IRQL>=2时,cpu无法进行上下文切换,即无法切换线程
重要IRQL描述如下:
- PASSIVE_LEVEL(0),正常IRQL,用户模式代码总是运行在此级别,线程调度正常进行
- APC_LEVEL(1),用于特殊的内核APC(异步过程调用),线程调度正常进行
- DISPATCH_LEVEL(2),线程调度器不会唤醒,不允许访问分页内存,不允许在内核对象上等待(除非等待时间为0)
- 设备IRQL,用于硬件中断的一段级别范围(x64/ARM/ARM64上是3~11,在x86上是3-26),所有IRQL=2规则使用
- HIGH_LEVEL,最高级别中断,被一些链表操作的API使用,实际值是15(x64/ARM/ARM64)或31(x86)
windbg调试时,可以通过
!irql
命令,查看当前IRQL,可以指定一个CPU号码,显示该CPU的IRQL
windbg调试时,可以通过!idt
调试命令,查看系统中已注册的中断和其相关联的ISR
5.1.1 提升和降低IRQL
内核模式下,KeRaiseIrql提升,KeLowerIrql降低
KIRQL oldIrql;
KeRaiseIrql(DISPATCH_LEVEL, &oldIrql);
NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);
//do something
KeLowerIrql(oldIrql);
如果提升了IRQL,请确保在同一个函数中将它降低
5.2 延迟过程调用(DPC)
DPC是一个对象,内部封装了一个函数,该函数会在IRQL在DISPATCH_LEVEL上调用
DPC由来:举例ReadFile
- 应用层调用ReadFile
- 内核层收到读请求的驱动程序调用文件系统驱动NTFS,文件系统驱动继续往下调用直到磁盘驱动程序
- 磁盘驱动程序启动一个对实际磁盘硬件的操作,此时硬件会开始工作读取数据
- 硬件完成读取操作后,会产生一个中断,这会导致与此中断关联的IRS在设备IRQL上执行(中断是异步到达的,会任意选择一个线程处理中断请求),IRS需要做的工作是访问硬件得到操作的结果,然后完成初始请求IRP
由于IRS此时运行在设备IRQL上,无法执行IoCompleteRequest(IRQL<= DISPATCH_LEVEL),因此引入延迟过程调用DPC机制来完成IRP
如果直接在ISR中将当前IRQL降低到DISPATCH_LEVEL,然后调用IoCompleteRequest,再将IRQL提升到原先的值,这样会引起死锁
- 注册了ISR的驱动程序预先准备好DPC对象(在非分页池中分配一个KDPC结构并会一个回调函数调用KeInitializeDpc进行初始化)
- 在ISR被调用退出之前,ISR调用KeInsertQueueDpc将此DPC排队,以请求尽快执行这个DPC(每个处理器都有自己的DPC队列,KeinsertQueueDpc默认将DPC排到当前处理器的DPC队列中)
- ISR调用结束后,在IRQL能降回到PASSIVE_LEVEL之前,会查看在处理器的队列上有没有DPC存在,如果有处理器会把IRQL降到DISPATCH_LEVEL并以先进先出的方式处理队列中的DPC,逐个调用DPC所关联的函数,直到队列清空
- 处理器上的DPC执行完毕后,IRQL降低为PASSIVE_LEVEL,并恢复执行中断到来前被打断的原始代码
由于DPC执行在DISPATCH_LEVEL上,因此不会进行线程切换、访问分页内存等
DPC可以通过某些方式控制,参阅KeSetImportanceDpc和KeSetTargetProcessorDpc
5.2.1 DPC和时钟一起执行
内核时钟(KTIMER)允许被设置成未来的某个时间到期,可以通过KeWaitForSingleObject来等待,或者通过DPC作为回调在始终到期时执行
5.3 异步过程调用(APC)
APC和DPC一样,都是封装了回调函数的数据结构
DPC和调用的线程无关,而APC和调用的线程相关,只有那个线程才能调用APC关联的回调函数
每个线程都有一个相关联的APC队列
APC共有三种类型:
- 用户模式APC,这些APC仅在线程进入警戒(Alert)状态时,才在用户模式的PASSIVE_LEVEL上运行。通常在调用SleepEx、WaitForSingleObjectEx、WaitFOtMultipleObjectsEx等类似API进入警戒状态。在警戒状态下,线程会检查自己的APC队列,如果不是空的,其中的APC就会执行,直到队列为空
- 普通内核APC, 这些APC在内核模式下的PASSIVE_LEVEL中执行,能够抢占用户模式代码和用户模式APC
- 特殊内核APC,这些APC在内核模式下的APC_LEVEL中执行,能够抢占用户模式代码、用户模式APC、普通内核APC,这些APC通常被IO系统用来完成IO操作
APC的API在内核模式下是未公开的,因此驱动程序一般不会直接使用APC
5.3.1 关键区和警戒区
进入关键区:KeEnterCriticalRegion
离开关键区:KeLeaveCriticalRegion
关键区会阻止执行用户模式和普通内核APC(特殊内核APC除外),内核中有些函数需要位于关键区中执行,特别是执行体资源
进入警戒区:KeEnterGuardedRegion
离开警戒区:KeLeaveGuardedRegion
将IRQL提升到APC_LEVEL将禁止所有APC的发送
5.4 结构化异常处理(SEH)
异常是一种事件和中断类似,区别是异常是同步的,中断异步的
异常包括:除零、断点、页错误、栈溢出、非法指令等
内核中的异常处理程序是基于中断分配表(IDT)进行调用的,IDT中还保存了从中断向量到ISR的映射关系
合法的关键字组合是:__try__/__except__
和__try__/__finally__
将EXCEPTION_EXECUTE_HANDLER
放到__except__中表示任何异常都会被处理,还可以调用GetExceptionCode
来检查实际发生的异常,进而有选择的处理,遇到不想处理的异常,可以让内核继续在调用栈上寻找别的处理程序
//使用__try和_except组合
__try
{// do something
}
_except (GetException() == STATUS_ACCESS_VIOLATION ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
{}//使用__try和__finally组合
void foo()
{void *p = ExAllocatePool(PagedPool, 1024);__try {//do something}__finally {ExFreePool(p);}
}
SEH还提供了ExRaiseStatus
和ExRaiseAccessViolation
这样的函数来抛出一个异常
5.4.1 使用C++ RAII代替__try/__finally
template<typename T = void>
struct kunique_ptr {kunique_ptr(T* p = nullptr) : _p(p) {}//remove copy ctor and copy = (single owner)kunique_ptr(const kunique_ptr&) = delete;kunique_ptr& operator=(const kunique_ptr&) = delete;//allow ownership transferkunique_ptr(kunique_ptr&& other) : _p(other._p){other._p = nullptr;}kunique_ptr& operator=(kunique_ptr&& other){if (&other != this){Release();_p = other._p;other._p = nullptr;}return *this;}~kunique_ptr(){Release();}operator bool() const{return _p != nullptr;}T* operator->() const{return _p;}T& operator*() const{return *_p;}void Release() {if (_p){ExFreePool(_p);}}private:T* _p;
};
5.5 线程同步
5.5.1 原子操作(用户模式下也可使用)
函数 | 描述 |
---|---|
InterlockedIncrement InterlockedIncrement16 InterlockedIncrement64 | 对32/16/64位的整数原子化加一 |
InterlockedDecrement InterlockedDecrement16 InterlockedDecrement64 | 对32/16/64位的整数原子化减一 |
InterlockedAdd InterlockedAdd64 | 原子化将一个32/64位整数加到一个变量上 |
InterlockedExchange InterlockedExchange8 InterlockedExchange16 InterlockedExchange64 | 原子化的交换两个32/8/16/64位整数 |
InterlockedCompareExchange InterlockedCompareExchange64 InterlockedCompareExchange128 | 原子化比较一个变量和一个值 |
InterlockedCompareExchange
函数家族再无锁编程中使用
5.5.2 等待函数
NTSTATUS KeWaitForSingleObject
(PVOID Object,KWAIT_REASON WaitReason,KPROCESSOR_MODE WaitMode,BOOLEAN Alertable,PLARGE_INTEGER Timeout
);NTSTATUS KeWaitForMultipleObjects
(ULONG Count,PVOID Object[],WaitType,KWAIT_REASON WaitReason,KPROCESSOR_MODE WaitMode,BOOLEAN Alertable,PLARGE_INTEGER Timeout,PKWAIT_BLOCK WaitBlockArray
);
object
,要等待的对象WaitReason
,等待的原因,驱动程序通常设置为Executive,如果此等待将由用户模式触发,设为UserRequestWaitMode
,UserMode或KernelMode,驱动程序一般设置为KernelModeAlertable
,等待过程中,线程是否处于警戒状态。警戒状态下允许传递APC,如果WaitMode是UserMode还允许传递用户模式APC,驱动程序一般设置为FALSETimeout
,等待超时时间,参数单位是100纳秒,负值表示相对时间值,正值表示从1601年1月1日午夜起计算的绝对时间值。如果为NULL,则一直等待下去Count
,等待对象的数目Object[]
,要等待的对象指针数组WaitType
,指明要等待所有对象编程有信号(waitAll),还是只要一个对象有信号就行(waitAny)WaitBlockArray
,结构数组,用于等待操作的内部管理,如果等待对象的数目<=THREAD_WAIT_OBJECTS(目前是3),那么这个参数是可选的,内核会使用每个线程里内建的数组,如果等待对象数目多于这个数,驱动程序就必须从非分页池中分配正确大小的结构,并在等待结束之后释放它们
KeWaitForSingleObject返回值:
返回值 | 描述 |
---|---|
STATUS_SUCCESS | 等待完成,等待对象变为有信号 |
STATUS_TIMEOUT | 等待完成,超时时间已到 |
KeWaitForMultipleObject返回值:
如果指定WaitAll,
返回值 | 描述 |
---|---|
STATUS_SUCCESS | 等待完成,所有等待对象都变为有信号 |
STATUS_TIMEOUT | 等待完成,超时时间已到 |
如果指定WaitAny,
返回值 | 描述 |
---|---|
索引值 | 其中一个对象变为有信号了,该对象在对象数组中的索引值 |
STATUS_TIMEOUT | 等待完成,超时时间已到 |
常见可等待对象,以及有信号和无信号的含义
5.5.3 可等待对象:互斥量(KMUTEX)
互斥量在自由时是有信号态,一旦某个线程调用等待函数等待互斥量成功,互斥量变为无信号状态,该线程成为互斥量的拥有者
- 如果某个线程是一个互斥量的拥有者,那么此线程是唯一能释放该互斥量的线程
- 一个互斥量能多次被同一线程获取,释放的次数也需要和获取的次数一样多,只有这样互斥量才能再次变为自由态(有信号)
使用方法:
- 从非分页池中分配一个KMUTEX、
- 调用
KeInitializeMutex
初始化互斥量 - 线程中使用等待函数
KeWaitForSingleObject
或KeWaitForMultipleObjects
去等待这个互斥量 - 获取互斥量后的业务代码执行完毕后,调用
KeReleaseMutex
释放该互斥量
如果一个线程没有释放互斥量就死亡了,此时内核会显式的释放这个互斥量,然后下一个企图获取该互斥量的线程会在等待函数中接收到
STATUS_ABANDONED
返回值
5.5.3 快速互斥量(FAST_MUTEX)
快速互斥量是传统互斥量的一个替代,提供了更好的性能
快速互斥量并不是一个可等待对象,它有自己的获取和释放的API
- 快速互斥量不能被递归的获取,会造成死锁
- 快速互斥量被获取后,IRQL会提升到APC_LEVEL,这会阻止线程上APC的传递
- 快速互斥量只能无限等待,不能设置超时时间
- 快速互斥量并未显露给用户模式,用户模式只能使用正式互斥量
多数需要使用互斥量的程序都应当使用快速互斥量,除非有必要的理由去使用正式互斥量
使用方法:
- 从非分页池中分配一个AST_MUTEX
- 调用
ExInitializeFastMutex
初始化快速互斥量 - 线程中使用
ExAcquireFastMutex
或ExAcquireFastMutexUnsafe
来获取快速互斥量 - 获取快速互斥量后的业务代码执行完毕后,调用
ExReleaseFastMutex
或ExReleaseFastMutexUnsafe
释放该快速互斥量
5.5.4 可等待对象:信号量(KSEMAPHORE)
使用方法:
- 调用
KeInitializeSemaphore
初始化信号量,设置一个最大值和初始值(通常是最大值),内部值大于0时,信号量处于有信号 - 工作线程调用
KeWaitForSingleObject
来等待信号量,当有信号时,将结束等待,同时信号量值减一 - 当信号量内部值达到0时,信号量变成无信号状态
一个最大值为1的信号量是否相当于互斥量?区别在于信号量没有所有权,一个线程获得信号量可以被另一个线程释放
5.5.5 可等待对象:事件(KEVENT)
事件封装了一个布尔值的标志,要么真(有信号)要么假(无信号)
事件有两种类型:
- 通知事件(手动重置),这种事件被触发后会释放所有正在等待的线程,并且事件的状态保持有信号的状态直到被显式重置
- 同步事件(自动重置),这种事件被触发后最多释放一个线程,并且释放之后此事件会自动回到无信号状态
使用方法:
- 从非分页池中分配KEVENT结构,并指明事件类型(NotificationEvent或SynchronizationEvent)和事件初始状态(Signaled或non-signaled)
- 然后调用
KeInitializeEvent
进行初始化 - 工作线程调用等待函数等待该事件
- 通过调用
KeSetEvent
触发事件 - 工作线程结束等待开始执行后续代码
- 通过
KeResetEvent
或KeClearEvent
(速度更快,因为无需返回之前的状态)重置该事件为无信号
5.5.6 执行体资源
执行体资源用于单写多读的场景,是一种特殊对象,不属于可等待对象
使用方法:
- 从非分页池中分配
ERESOURCE
结构 - 调用
ExInitializeResourceLite
初始化该执行体资源 - 写线程调用
ExAcquireResourceExclusiveLite
获取排他锁(用于写) - 读线程调用
ExAcquireResourceSharedLite
获取共享锁(用于读) - 无论读线程还是写线程工作完成后,调用
ExReleaseResourceList
释放锁 - 最后,不再使用该执行体资源时,调用
ExDeleteResourceLite
释放执行体资源
调用获取和释放锁的先决条件是,必须禁止通常的内核APC,可以在获取锁前调用
KeEnterCriticalRegion
,以及释放锁后调用KeLeaveCriticalRegion
ERESOURCE resource;
void WriteData()
{KeEnterCriticalRegion();ExAcquireResourceExclusiveLite(&resource, TRUE);//do somethingExReleaseResourceLite(&resource);KeLeaveCriticalRegion();
}
为了简化上述代码,
对于排他锁提供ExEnterCriticalRegionAndAcquireResourceExclusive
函数,进入关键段然后获得排他锁
对于共享锁提供ExEnterCriticalRegionAndAcquireResourceShared
函数,进入关键段然后获得共享锁
提供 ExReleaseResourceAndLeaveCriticalRegion
释放排他锁或共享锁,然后离开关键段
5.6 高IRQL同步
在IRQL>=DISPATCH_LEVEL时,线程不可进行等待,无法调用等待函数
由于此时线程调度器无法工作,因此不存在线程之间的资源冲突,而是存在处理器当前正在运行的线程之间的资源冲突
5.6.1 自旋锁
自旋锁是内存中的一个简单位,用于处理器之间的同步
当CPU想要获取自旋锁而它当前并不自由时,CPU会一直在自旋锁上自旋,即一直进行while空循环,直到自旋锁被另一个CPU释放变成自由状态
自旋锁必须只能在IRQL >= DISPATCH_LEVEL上获取和释放
使用方法:
- 从非分页池中分配一个KSPIN_LOCK结构
- 调用
KeInitializeSpinLock
初始化自旋锁,将自旋锁置于自由状态 - 如果当前IRQL不是DISPATCH_LEVEL,那么提升IRQL到DISPATCH_LEVEL
- 调用自旋锁获取函数
- 进行业务代码
- 释放自旋锁
KeAcquireSpinLock
:获取自旋锁并将IRQL提升至DISPATCH_LEVEL
注释1,只能在IRQL为DISPATCH_LEVEL时调用,只获取锁而不改变IRQL,常用在DPC例程中
注释2,常用于对ISR和别的函数之间的同步,参数是一个中断对象,自旋锁是它的一部分
注释3,用于操作基于LIST_ENTRY的链表,函数将使用提供给它的自旋锁并将IRQL提升至HIGH_LEVEL
取消自旋锁:内核在调用驱动程序注册的取消例程之前,会先获得这个自旋锁,这是驱动程序释放一个不是由它主动显式申请的自旋锁的唯一情形
5.7 工作项目
分离耗时较长业务到单独的线程中可用:PsCreateSystemThread
和IoCreateSystemThread
(windows8及以上可用)
分离耗时较短业务到内核提供的线程池:使用工作项目
工作项目:在系统线程池中排队的函数。驱动程序可以分配和初始化工作项目,使其指向驱动程序希望执行的函数,然后让工作项目在线程池中排队
工作项目类似于DPC,但工作项目总是在IRQL_PASSIVE_LEVEL上执行,这常被用来在IRQL==DISPATCH_LEVEL上执行PASSIVE_LEVEL的操作
创建工作项目两种方式:
- 使用
IoAllocateWorkItem
分配和初始化工作项目。返回一个指向IO_WORKITEM
的指针,用完后调用IoFreeWOrkItem
释放它 - 使用
IoSizeofWorkItem
提供的大小动态分配一个IO_WORKITEM,然后调用IoInitializeWorkItem
初始化,在用完工作项目之后,调用IoUninitializeWorkItem
这两个函数都接收设备对象作为参数,所以需要确保工作项目在排队或执行时,驱动程序没有卸载
将工作项目进行排队:IoQueueWorkItem
void IoQueueWorkItem([in] __drv_aliasesMem PIO_WORKITEM IoWorkItem, //the work item[in] PIO_WORKITEM_ROUTINE WorkerRoutine, //the function to be called[in] WORK_QUEUE_TYPE QueueType, //queue type[in, optional] __drv_aliasesMem PVOID Context //driver-defined value
);//驱动程序提供的回调函数WorkerRoutine原型如下:
IO_WORKITEM_ROUTINE IoWorkitemRoutine;void IoWorkitemRoutine([in] PDEVICE_OBJECT DeviceObject,[in, optional] PVOID Context
)
{...}
系统线程池由多个队列,基于服务这些工作项目的线程优先级进行区分,级别如下:
CriticalWorkQueue
,线程优先级 13
DelayedWorkQueue
,线程优先级 12
HyperCriticalWorkQueue
,线程优先级 15
NormalWorkQueue
,线程优先级 8
BackgroundWorkQueue
,线程优先级 7
RealTimeWorkQueue
,线程优先级 18
SuperCriticalWorkQueue
,线程优先级 14
MaximumWorkQueue
CustomPriorityWorkQueue
虽然文档中指明必须使用
DelayedWorkQueue
,但是实际上任何支持的级别都可以使用
另一个用于将工作项目排队的函数
IoQueueWorkItemEx
,使用一个不同的回调函数,多了一个参数即工作项本身。用于在工作项目函数需要退出之前释放自身
6. 工作请求包(IRP)
6.1 IRP简介
IRP由执行体中IO管理器、即插即用管理器、电源管理器之一从非分页池中分配,也可由驱动程序分配
- IRP遵循谁分配谁释放的原则
- IRP从不单独分配,总是伴随着一个或多个
IO_STACK_LOCATION
一起分配 - 当一个IRP被分配时,调用者必须指明有多少个
IO_STACK_LOCATION
需要跟IRP一起分配 IO_STACK_LOCATION
在内存中紧跟IRP后面,且每一个IO_STACK_LOCATION
对应设备栈中的一个设备对象- 当收到IRP时,需要通过调用
IoGetCurrentIrpStackLocation
函数获得属于当前驱动使用的IO_STACK_LOCATION
6.2 设备栈
Window的IO系统以设备为中心,而非以驱动为中心,一个驱动中可以创建多个设备
设备对象被分为三类:
- PDO物理设备对象,由总线驱动程序创建,即某些设备位于此总线的设备槽里
- FDO功能设备对象,由真正的驱动程序创建,驱动程序由充分了解设备细节的硬件厂商提供
- FIDO过滤设备对象,由过滤驱动程序创建的可选过滤设备
举例PCI网卡驱动安装事件序列如下:
- PCI总线驱动程序(pci.sys)识别出某个槽里有设备,它创建一个PDO来表示这个事实。总线驱动不知道这是个网卡还是显卡还是别的,它只知道有个东西在那里,并能从控制器中取得基本信息,比如开发商的ID和设备ID
- PCI总线驱动程序通知pnp管理器在它的总线上发生了一些变化
- pnp管理器请求由总线驱动程序管理的最新PDO列表
- pnp管理器现在的工作就是发现并装在这个新PDO的驱动程序,发送一个查询给总线驱动,请求完整的硬件设备ID
- pnp管理器得到硬件ID之后,在注册表中查阅
HKLM\System\CurrentControlSet\Enum\PCI\(硬件ID)
,如果该PDO的驱动程序已经装载过了,就会在这个位置注册,pnp管理器就直接装载已经在这个注册表里注册好的驱动程序 - 驱动程序装载并创建FDO,但是增加了一个对
IoAttachDeviceToDeviceStack
的调用,将自己置于PDO之上
如果过滤驱动程序在注册表中注册表了,那么也会被一起装载
过滤器会在两个地方搜索:
HKLM\System\CurrentControlSet\Enum\PCI\(硬件ID)
HKLM\System\CurrentControlSet\ControlClasses\{ClassGuid值}
,硬件ID对应的类别,值名称为LowerFilters
和UpperFilters
6.3 处理IRP的几种方式
IRP由执行体中的管理器创建,管理器只初始化主IRP结构和第一个IO栈的位置,然后就把IRP传递给设备栈的最上层设备
包含设备栈最上层设备的驱动程序在相应的分发例程中收到这个IRP,调用分发例程,分发例程中处理IRP
由于IO管理器只初始化第一个IO栈位置,因此每个接收到IRP的设备都需要给下一层设备初始IO栈位置
处理IRP的方式:
- 将IRP向下传递
如果驱动程序的设备不是设备节点中的最后一个,驱动程序对此IRP不感兴趣可以将请求传递下去
第一种方法是通过IoCopyIrpStackLocationNext
来复制当前的IO栈位置到下一层
第二种方法是通过IoSkipCurrentIrpStackLocation
将IRP内部指向当前IO栈位置的指针减一,后续调用IoCallDriver
时这个指针又会加一,从而下一层设备就能看到和当前设备看到一样的IO栈位置了。- 调用
IoSkipCurrentIrpStackLocation
以确保下一个设备能看到和这个设备一样的信息,必须能看到同一个IO栈位置 - 调用
IoCallDriver
传递IRP给下层设备对象(驱动调用IoAttachDeviceToDeviceStack
时获得)
- 调用
- 完全处理此IRP
收到IRP的驱动程序自己可以完全处理此IRP无需往下传播- 调用
IoCompleteRequest
来结束此IRP,任何下层的设备都不会看到这个请求
- 调用
- 对此IRP进行检查,做一些事情,比如记录请求或对下一个IO栈位置进行修改,然后继续往下传递
- 往下传递请求,并在下层设备完成请求时得到通知
任何一层(除了最终完成IRP的底层)都能设置一个IO完成例程,当某个下层完成请求时,完成例程就会被调用- 调用
IoSetCompletion
设置一个IO完成例程
- 调用
- 开始某种异步IRP处理
驱动程序处理该请求,但是如果处理事件较长,驱动程序可以将此IRP标记挂起- 调用
IoMarkIrpPending
将此IRP标记挂起 - 分发例程返回
STATUS_PENDING
- 调用
IoCompleteRequest
来完成此IRP
- 调用
一旦某层调用IoCompleteRequest
,该IRP被完成,IRP将朝着发起IRP的方向进行依次传送。如果注册了完成例程,也将会按照先注册的后调用的顺序被逐个调用。
6.4 IRP和IRP_STACK_LOCATION结构解析
IRP结构解析
IoStatus
:包含IRP的Status(NT_STATUS)和一个Information字段(意义取决于IRP类型,对于读和写是操作中的字节数)UserBuffer
:包含原始的缓冲区指针,指向相关IRP的用户缓冲区UserEvent
:一个事件对象,如果客户程序调用是异步的,那么客户程序会提供这个事件对象AssociatedIrp
:联合体- SystemBuffer,最常用,指向一个系统分配的非分页缓冲区,用于有缓冲的IO操作
- MasterIrp,如果本IRP是一个关联IRP,那么此成员是指向主IRP的指针。一个主IRP可以有多个关联IRP,一旦所有的关联IRP都完成了,主IRP就自动完成了
- IrpCount,如果IRP是一个主IRP,该成员代表和主IRP关联的IRP数目
Cancel Routine
:取消例程,如果操作被要求取消,如用户模式调用CancelIo
和CancelIoEx
,取消例程会被调用MdlAddress
:指向一个可选的内存描述符列表(MDL),一种内核数据结构,用于描述RAM中的缓冲区,主要用于直接IO
IRP中的IO_STACK_LOCATION结构解析
MajorFunction
:IRP的主功能代码(如IRP_MJ_CREATE、IRP_MJ_READ等)MinorFunction
:部分IRP有次功能代码(如IRP_MJ_PNP、IRP_MJ_POWER、IRP_MJ_SYSTEM_CONTROL等)FileObject
:此IRP相关联的FILE_OBJECTDeviceObject
:此IRP相关联的设备对象,分发例程会收到一个指向此设备对象的指针,因此一般无需访问这个字段CompletionRoutine
:完成例程,由上一层设备对象调用IoSetCompletionRoutine
所设置Context
:传递给完成例程的参数Parameters
:联合体,不同的IRP使用特定的某个操作(如IRP_MJ_READ中Parameters.Read结构表示读操作的更多信息)
使用
IoGetCurrentIrpStackLocation
取得当前的IO_STACK_LOCATION
调试IRP:可以使用!irpfind
命令检索当前非分页池中的IRP,使用!irp
解析单个irp
6.5 分发例程
分发例程就是通过IRP主功能号连接起来的函数,DRVIER_OBJECT结构中的majorFunction字段
分发例程的原型如下:
typedef NTSTATUS DRIVER_DISPATCH
{_In_ PDEVICE_OBJECT DeviceObject,_Inout_ PIRP Irp
};
- IRP_MJ_CREATE,对应用户模式下的CreateFile或内核模式下的ZwCreateFile
- IRP_MJ_CLOSE,对应用户模式下的CloseHandle或内核模式下的ZwClose
- IRP_MJ_READ,对应读操作,通常对应用户模式下的ReadFile或内核模式下的ZwReadFile
- IRP_MJ_DEVICE_CONTROL,对应用户模式下的DeviceIoControl或内核模式下的ZwDeviceIoControlFile(内核中还有其他api可以生成)
- IRP_MJ_INTERNAL_DEVICE_CONTROL,和IRP_MJ_DEVICE_CONTROL类似,但只能从内核调用
6.6 完成请求
一旦驱动程序决定处理IRP,意味着必须最终完成它
完成请求指在填写完请求状态和其他信息后调用IoCompleteRequest
示例代码:
NTSTATUS MyDispatchRoutine(PDEVICE_OBJECT devObj, PIRP Irp)
{Irp->IoStatus.Status = STATUS_XXX;Irp->IoStatus.Information = NumberOfBytesTransfered;IoCompleteRequest(Irp, IO_NO_INCREMENT);return STATUS_XXX;
}
- 在调用
IoCompleteRequest
后,Irp可能就已经被释放掉了,所以在完成Irp之后,不要再去使用Irp了(如return Irp->Iostatus.Status
) - 在处理Irp过程中出现错误时,Information字段必须被设置为零,而在操作成功时,这个字段的含义取决于Irp类型
6.7 访问用户缓冲区
通常分发例程在IRQL==0和请求线程的上下文中被调用,这意味可以轻松直接访问用户提供的缓冲区,因为IRQL==0所以可以忽略页错误,并且由于时请求线程上下文,因此用户模式传递的指针在此处理过程中有效。
但是在一些不便利的情况下,将无法直接访问用户缓冲区,需要用到缓冲IO和直接IO
6.7.1 缓冲I/O
为了支持缓冲IO的读写操作,设备对象必须设置一个标志:DeviceObject->Flags |= DO_BUFFER_IO;
缓冲IO举例,当一个读或写操作到达IO管理器和驱动程序的步骤:
- IO管理器从非分页内存池中分配一个跟用户缓冲区一样大小的缓冲区,并将其指针保存到IRP的AssociatedIrp->SystemBuffer成员中,缓冲区大小可以从Parameters.Read.Length或Parameters.Write.Legnth中获得
- 对于写请求而言,IO管理器会将用户缓冲区复制到系统缓冲区中
- 直到此时驱动程序的分发例程才会被调用,驱动程序无需检查直接访问系统缓冲区(因为IO管理器分配至内核地址空间内,且是非分页内存池)
- 一旦驱动程序完成了这个IRP(
IoCompleteRequest
),(对于读请求)IO管理器就把系统缓冲区复制回用户缓冲区中,复制的大小由Irp的IoStatus.Information字段决定,由驱动程序设置 - 最终IO管理器释放系统缓冲区
IO管理器是怎样在IoCompleteRequest中将系统缓冲区复制到用户缓冲区中的?
答:通过将一个特殊内核APC排队到最初发出请求的线程中来实现,一旦此线程获得CPU的执行权,第一件事就是执行这个APC,APC中执行复制操作
缓冲IO的特点:
- 便于使用,只要在设备对象中指明标志,后续所以事情都由IO管理器完成
- 总是涉及复制, 意味着最好用于小缓存区(通常不超过1页)
6.7.2 直接I/O
直接IO的目的是允许在任何IRQL和线程中访问用户缓冲区,但是不需要在前后进行复制
为了支持直接IO的操作,设备对象必须设置一个标志:DeviceObject->Flags |= DO_DIRECT_IO
直接IO举例,当一个读或写操作到达IO管理器和驱动程序的步骤:
- IO管理器确认用户缓冲区是合法的,然后利用页错误将其装入物理内存
- 然后IO管理器通过
MmProbeAndLockPages
将缓冲区锁定在内存中,因此在另行通知之前它不会被换出。这就解决了缓冲区访问的问题之一,不能发生页错误 - IO管理器构造一个内存描述符列表(MDL),这是一个描述如何将缓冲区映射到RAM的数据结构,此数据结构的地址保存在IRP的MdlAddress字段中
- 此时,驱动的分发例程被调用,分发例程调用
MmGetSystemAddressForMdlSafe
将MDL映射到系统空间内,映射完毕后就能直接读取系统空间地址所描述的内存了 - 一旦驱动完成了请求,IO管理器会移除第二个映射(映射成系统地址那个),释放MDL并将用户缓冲区通过
MmUnlockPages
解锁,因此它就能和其他用户模式内存一样被正常的换出了。
可以多次调用MmGetSystemAddressForMdlSafe。MDL保存有一个标志,用来指示系统映射是否已经被执行过了。如果是,它仅仅返回已经存在的指针
6.7.3 Neither I/O
在设备对象的标志中既没有设置DO_BUFFERED_IO
也没有设置DO_DIRECT_IO
的驱动程序使用无I/O(Neither I/O)方式,这单纯表示驱动程序不会从I/O管理器得到任何帮助,怎么处理用户缓冲区完全取决于驱动程序自身。
6.8 IRP_MJ_DEVICE_CONTROL用户缓冲区
用户模式下DeviceIoControl函数原型:
BOOL DeviceIoControl([in] HANDLE hDevice, //设备或文件句柄[in] DWORD dwIoControlCode, //IOCTL code[in, optional] LPVOID lpInBuffer, //input Buffer[in] DWORD nInBufferSize, //size of input buffer[out, optional] LPVOID lpOutBuffer, //output Buffer[in] DWORD nOutBufferSize, //size of output Buffer[out, optional] LPDWORD lpBytesReturned, //count of bytes actually returned[in, out, optional] LPOVERLAPPED lpOverlapped //for async, operation
);
访问输入输出缓冲区的方式取决于控制码
#define CTL_CODE( DeviceType, Function, Method, Access ) ( \((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
)
DeviceType
,标识设备的类型,FILE_DEVICE_XXX的常数,微软规定第三方的值必须从0x8000开始Function
,一个数字码,用于区分不同的操作,微软规定第三方驱动需要从0x800开始method
,输入输出缓冲区的传递方式Access
,指明这个操作是读还是写,FILE_WRITE_ACCESS、FILE_READ_ACCESS、FILE_ANY_ACCESS
其中method描述了如何访问输入输出缓冲区:
- METHOD_NEITHER,无需IO管理器的帮助,驱动程序自行处理缓冲区,用于控制代码自身就是全部信息,无需缓冲区的情况下。
输入缓冲区的指针在当前IO栈位置的Parameters.DeviceIoControl.Type3InputBuffer
字段中
输出缓冲区的指针在IRP的UserBuffer
字段中 - METHOD_BUFFERED, 输入和输出缓冲区两者均使用缓冲IO。请求开始时,IO管理器使用输入输出缓冲区大小的最大值从非分页池中分配系统缓冲区。然后它将输入缓冲区复制到系统缓冲区,然后才会调用IRP_MJ_DEVICE_CONTROL的分发例程。请求完成后,IO管理器将系统缓冲区复制到输出缓冲区,复制的字节数由
IoStatus.Information
字段指出
系统缓冲区的指针在它常在的地方:IRP结构的AssociatedIrp.SystemBuffer
- METHOD_IN_DIRECT和METHOD_OUT_DIRECT,输入缓冲区使用缓冲IO,输出缓冲区使用直接IO。唯一区别是:
METHOD_IN_DIRECT:输出缓冲区可读,输出缓冲区也能作为输入使用
METHOD_OUT_DIRECT:输出缓冲区可写
Method | 输入缓冲区 | 输出缓冲区 |
---|---|---|
METHOD_NEITHER | 无 | 无 |
METHOD_BUFFERED | 缓冲 | 缓冲 |
METHOD_IN_DIRECT | 缓冲 | 直接 |
METHOD_OUT_DIRECT | 缓冲 | 直接 |