Windows内核机制
理解内核机制有助于我们调试以及从整体上理解系统的运作
6.1 中断请求级别(IRQL)
当要处理的线程多于可用处理器的数量时,就会考虑到线程的优先级。同时硬件设备需要去通知系统来进行进程调度。比如:由磁盘驱动器执行的I/O操作,操作完成后磁盘驱动器会通过请求中断来通知系统操作已经完成。该请求中断连接到中断控制器硬件设备,然后把请求发送到处理器进行处理。现在有一个问题就是哪个线程来执行中断服务程序(ISR Interrupt Service Routine)呢
每个物理硬件中断都与一个优先级有关,叫做IRQL(Interrupt Request Level)中断请求级别,由HAL(硬件抽象层)来决定IRQL为多少。每个处理器的上下文都有自己的IRQL,就像每个处理器有自己的寄存器一样,可以像对待CPU的寄存器一样来对待IRQL。
对于IRQL来说基本规则就是:处理器会执行IRQL级别更高的对应的程序(ISR)。例如:当前处理器的IRQL为0,这时有一个IRQL为5的中断进来,处理器就会在当前线程的内核栈中保存上下文状态,然后将处理器的IRQL提升为5,然后执行中断服务程序(ISR Interrupt Service Routine)。一旦执行结束,IRQL就会回到原来的环境。另一方面如果在中断的IRQL==5的时候又有新中断来了也是一样的,先判断IRQL的大小,如果大于5则调用新中断如果小就等待。
通过以上两张图我们可以知道,所有的ISR(Interrupt Service Routine中断服务程序)都是在被中断的线程中完成的。Windows没有专门的线程来处理中断而是由当前在中断处理器上运行的线程来处理。
在用户态的代码执行时,IRQL总是等于0,所以在用户态开发的时候我们也无需关注IRQL。大部分的内核代码也是在IRQL等于0的环境下运行,在内核态下可以通过内核API提高IRQL。
当处理器的IRQL提升到大于等于2以上时,执行的代码就会有很多限制:
- 访问不存在物理内存的内存会导致系统崩溃,这意味着从非分页池访问数据总是安全的,而从分页池或用户提供的缓冲区访问数据是不安全的,必须避免。
- 等待任何调度程序内核对象(例如互斥锁或事件)会导致系统崩溃,除非将等待超时时间设置为零。
产生限制的原因:因为调度程序是在IRQL(2)上运行,所以当处理器的IRQL大于等于2,调度程序就无法在处理器上运行,因此就不会发生线程上下文切换(用该CPU上的另一个线程替换该线程)。只有更高级别的中断才能临时将代码转移到关联的ISR,但是它仍然是同一个线程里,不会发生线程上下文的切换。总的来说,以上两种状态需要通过调度程序进行线程切换,但是调度程序的IRQL为2无法在当前处理器IRQL大于等于2的时候运行。
提高和降低IRQL
在用户态是不能修改IRQL的,只有内核态可以。IRQL可以被KeRaiseIrql函数提升和被KeLowerIrql函数降低。这里提供一个代码片段来方便理解:
//假设当前IRQL<=DISPATCH_LEVEL 也就是IRQL(2)
KIRQL oldIrql; //KIRQL是对UCHAR的一种typedef重命名
KeRaiseIrql(DISPATCH_LEVEL,&oldIrql);
NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);
// do some work
KeLowerIrql(oldIrql);
如果提高了IRQL,请确保在相同的函数中降低它,只提升了原来的却不降低是非常危险的。用了KeRaiseIrql来提高务必在同一函数用KeLowerIrql来降低
线程优先级和IRQL的异同
IRQL是处理器的一个属性,线程优先级是线程的一个属性,线程优先级只有在IRQL<2时才有意义。
任务管理器用一个叫做System interrupt的伪进程来描述CPU在IRQL>=2的情况下花费的时间,在Process Explorer用interrupt来描述:
6.2 延迟过程调用(DPC)
上图显示了客户端调用I/O操作时的经典事件序列:用户层下的线程打开某个文件句柄,然后调用ReadFile发起一个读操作。由于线程可以异步调用,它几乎马上就可以重新获得控制权并可以做其他工作。收到ReadFile的读取请求的驱动程序会调用文件系统驱动程序(例如 ntfs.sys),它可能会一直往下调用直到磁盘驱动程序,最后磁盘驱动程序对磁盘进行操作。
当硬件完成读操作的时候,会发出一个中断。该中断会引起与之关联的中断服务程序(ISR)在硬件设备的IRQL处执行。一个典型的ISR会访问设备硬件以得到操作的结果,最后完成(CompleteRequest)请求。
如前文所说完成一个请求通常是通过调用IoCompleteRequest函数来完成的,但是该函数的文档说只能在IRQL<=DISPATCH_LEVEL(2)时才能使用。
允许ISR调用IoCompleteRequest(和类似的函数)的机制被称为DPC(Derferred Procedure Call)
注册了ISR的驱动程序需要从非分页池内存中分配KDPC结构体,并用KeInitializeDpc来初始化给后面DPC做调用准备。当ISR被调用时,在退出ISR调用之前,ISR调用KeInsertQueueDpc函数将此DPC插入队列,当DPC函数执行时,就会调用IoCompleteRequest函数了。这是一种调用DPC的折中方案。它在IRQL=DISPATCH_LEVEL状态上运行,这表示它也不能进行调度和访问分页内存。
系统中每一个处理器都有自己的DPC队列,在默认的情况下KeInsertQueueDpc函数将DPC插入当前处理器的DPC队列里。当ISR返回前,再IRQL降回0之前,会检测处理器的队列里面是否还有PDC,如果有处理器降低IRQL等级为DISPATH_LEVEL(2)然后以先进先出(队列的方式)来处理队列里的DPC,直到队列清空,处理器的IRQL等级才降为0,并恢复中断时的环境。
也可以通过这两个函数KeSetImportantceDpc,KeSetTargetProcessorDpc自己定制DPC
Using DPC with a Timer
DPC最初是为了给ISR使用而创建的,但是也有别的机制可以使用DPC。DPC可以和内核时钟绑定一起使用。
KTIMER结构体表示内核时钟(Kernel Timer)允许通过相对或者绝对时间来设置一个时钟。时钟(Timer)是一个调度对象(dispatcher object),可以用KeWaitForSingleObject等函数来等待,但是不太方便。更简单常用的办法是在内核时钟(kernel timer)中使用回调函数(DPC)。
用一个例子来方便理解:
KTIMER Timer; KDPC TimerDpc;
void InitializeAndStartTimer(ULONG msec) {
KeInitializeTimer(&Timer);
KeInitializeDpc(&TimerDpc, OnTimerExpired,// callback function
nullptr); // passed to callback as "context"
// relative interval is in 100nsec units (and must be negative)
// convert to msec by multiplying by 10000
LARGE_INTEGER interval;
interval.QuadPart = -10000LL * msec;
KeSetTimer(&Timer, interval, &TimerDpc);
}
void OnTimerExpired(KDPC* Dpc, PVOID context, PVOID, PVOID) {
UNREFERENCED_PARAMETER(Dpc);
UNREFERENCED_PARAMETER(context);
NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);
// handle timer expiration
}
这段代码表示当内核时钟到期时,DPC会被插入到CPU中的DPC队列中并尽快执行。使用DPC比普通基于IRQL(0)的回调更快,因为它级别比较高,所以能够保证在用户态代码和大多数内核代码之前执行。
6.3 异步过程调用(APC)
DPC被封装成函数会在IRQL等于DISPATCH_LEVEL的时候被调用。异步过程调用(APC)也是被封装成函数来调用,但是和DPC不同,APC是专门给某个特定线程使用,而DPC是和处理器有关。这意味着每个线程都有一个APC队列,每个处理器有DPC队列。
用户模式下可以调用适当的API来使用APC。例如,调用 ReadFileEx 或 WriteFileEx 开始异步 I/O 操作。操作完成后,用户模式 APC 会附加到调用线程。如前文所述,该 APC 当线程进入警报状态的时候执行。在用户模式下显式插入 APC 的另一个API函数是QueueUserAPC。
关键区和警戒区
关键区禁止用户态和普通内核APC执行。线程使用KeEnterCriticalRegion函数来进入关键区,使用KeLeaveCriticalRegion来离开关键区。内核编程中的某些功能函数需要位于关键区(Critical Regions)内。尤其是在处理执行体资源(executive resources)时;警戒区(Guarded Regions)阻止所有APC执行。KeEnterGuardedRegion和KeleaveGuardedRegion必须成套出现不然很危险。
6.4 结构化异常(SEH)
异常是由于某条指令执行某些导致处理器引发错误的操作而发生的事件。异常的常见例子包括:除零,断点,页错误,堆栈溢出和无效指令等。如果发生异常内核会捕获它并在可能的情况下运行代码来处理异常,这种机制称为结构化异常处理(Structured Exception Handling SEH),可以用于用户和内核层,异常也是断点实现的基本原理。
内核异常处理程序由IDT(Interrupt Dispatch Table 中断描述符表)来调用,IDT与中断和ISR之间的映射相同,一一对应。对于Windbg来说,可以使用!idt命令来查看系统IDT表的所有映射。
一些常见的异常:
一旦程序发生了异常,内核会在发生异常的函数中搜索处理程序(除了一些透明处理的异常,例如断点(3)),如果没有找到就会向上搜索调用堆栈,直到找到异常处理程序,如果堆栈耗尽,那么系统崩溃。
Windows提供了四个C语言关键字来让开发者完成异常处理:
关键字的有效组合是 _try/except和 _try/finally,这些关键字在用户态和内核态都可以使用。
6.5 系统奔溃
系统奔溃我们简单的理解就是系统蓝屏了(BSOD),系统蓝屏是一种保护机制,因为如果代码再往下执行就有可能造成毁灭性打击,就所以直接蓝屏不让系统继续执行了。
如果崩溃的系统连接到了一个内核的调试器的话,会在调试器中产生一个中断,可以让你在调试器里面对系统的状态进行检查。可以在Windows里面进行配置使得当出现蓝屏时保存一个dump文件,这个dump文件会保存系统蓝屏的环境。
Dump转储类型决定了什么样的数据会被写入,具体的选项如下
类型 | 描述 |
---|---|
小内存转储 | 非常小,仅包含基本的系统信息和引起崩溃的线程信息 |
核心内存转储 | 捕获所有的内核内存但不包括用户内存,一般来说这个是足够的,因为用户代码一般不会整出蓝屏 |
完整内存转储 | 提供了全部内存的转储,文件大小偏大 |
自动内存转储(Windows8+) | 等同于核心内存转储,在启动时自动调整页面文件大小,来保证有一个合适的大小来存储内核内存转储文件。 |
活跃内存转储(Windows10+) | 类似与完整内存转储,除了崩溃的系统有文件,否则是不会有的。有助于减小服务器系统的转储文件大小。 |
具体如何分析Dump文件可以参考此前的Windbg的使用
6.6 线程同步
一个驱动程序可以被多个应用程序调用,所以就难免会出现线程调度的问题,比如说一个在改一个在读,这样就可能造成不安全访问,这也被称为数据竞争。这种情况下最简单安全的办法就是当一个线程访问某个内容时,其他线程都不能访问,只能等待,这样就不会导致不安全的情况了。Windows提供了一些原子操作来实现线程同步。
6.6.1 互锁操作
一些驱动程序可用的互锁函数
函数 | 描述 |
---|---|
InterlockedIncrement/InterlockedIncrement16/InterlockedIncrement64 | 对32/16/64位的整数原子化加一 |
InterlockedDecrement/16/64 | 对32/16/64位的整数原子化减一 |
InterlockedAdd/InterlockedAdd64 | 原子化的将32/64位数加到一个变量上 |
InterlockedExchange/8/16/64 | 原子化的交换32/8/16/64位整数 |
InterlockedCompareExchange/64/128 | 原子化地比较一个变量与一个值,如果相等则将提供的值交换到变量中并返回TRUE;否则,将当前的值放入变量中并返回FALSE |
6.6.2 分发器对象
分发器对象也叫可等待对象。这些对象有着有信号和无信号两种状态,之所以被称为可等待对象是因为线程可以等待该对象从无信号到有信号然后再使用。这个在用户态下被称为信号对象。
用于等待的主要函数是KeWaitForSingleObject 和 KeWaitForMultipleObject 函数:
返回值有两种:STATUS_SUCCESS 等待完成有信号; STATUS_TIMEOUT:等待完成超时。
注意返回值用NT_SUCCESS宏都是返回真,不能直接用返回值为真来判断是否等待成功。
6.6.3 互斥量
很经典的一种对象,用于解决多个线程的某个线程在任何时候访问共享资源的标准问题。
互斥量Mutex在自由的时候是信号态,一旦被调用这个互斥量就变成无信号态,别的线程就无法调用它了。调用它的线程就被称为拥有者。对于Mutex来说拥有关系很重要。因为:
如果某个线程拥有了它,该线程就是唯一可以释放该互斥量的线程
一个互斥量能多次被同一线程获取,需要注意的是使用完之后必须释放掉,不然别的线程将无法获取。
要使用互斥量Mutex,需要从非分页池(non-paged pool)中分配一个KMUTEX结构。互斥量的API包含了如下与KMUTEX一起工作的函数:
KeInitializeMutex:必须被调用一次来初始化互斥量。
某一个等待函数需要将分配的KMUTEX结构体的地址作为参数传递给它
在某个线程是互斥量的拥有者时需要调用KeReleaseMutex释放互斥量
利用上述函数,这里有一个使用互斥量访问共享数据,使得一次只能有一个线程访问的例子:
KMUTEX MyMutex;
LIST_ENTRY DataHead;
void Init() {
KeInitializeMutex(&MyMutex, 0);
}
void DoWork() {
// wait for the mutex to be available
KeWaitForSingleObject(&MyMutex, Executive, KernelMode, FALSE, nullptr);
// access DataHead freely
// once done, release the mutex
KeReleaseMutex(&MyMutex, FALSE);
}
重要的是,无论怎样都要释放互斥量,因此最好使用前文提到的__try/ \_\_finally 以保证在任何情况下都能释放互斥量:
void DoWork() {
// wait for the mutex to be available
KeWaitForSingleObject(&MyMutex, Executive, KernelMode, FALSE, nullptr);
__try {
// access DataHead freely
}
__finally {
// once done, release the mutex
KeReleaseMutex(&MyMutex, FALSE);
}
}
6.6.4 快速互斥量
快速互斥量是传统互斥量的一种替代,提供了更好的性能有着自己的一套API,和传统互斥量有者以下特点:
不能递归获取,不然会造成死锁
被获取后,CPU的IRQL会提高到APC_LEVEL(1),会阻止线程上的APC传递
只能无限等待,无法指定超时时间
只能用于驱动层
6.6.5 信号量
信号量的主要目的是用来限制某些东西,比如队列的长度。信号量的最大值和初始值(一般初始值等于最大值)用KeInitalizeSemaphore来确定,当信号量内部值大于零时,处于有信号态,等于零为无信号态。调用KeWaitForSingleObject时当信号值大于零会表示等待成功然后计数减一。KeReleaseSemaphore会释放信号量让计数加一
6.6.6 事件
事件封装了一个布尔值的标志,真为有信号,假为无信号。事件的主要目的是在某事发生时发出信号,提供执行流上的同步。事件有两种类型,类型在初始化事件的时候指定:
通知事件N(手动重置):该事件被触发后会释放所有正在等待的线程,并且状态一直保持为有信号,除非被显示重置。
同步事件(自动重置):被触发后最后释放一个线程。触发后回到无信号状态。
创建方法:从非分页池里创建一个KEVENT结构,指明事件类型和初始事件状态,然后调用KeInitalizeEvent初始化,,调用KeSetEvent设置事件为有信号,调用KeResetEvent或KeClearEvent重置。
6.6.7 执行体资源
内核提供了一种单写多读的线程同步原语,就是执行体资源。
6.7 高IRQL同步
自旋锁
实现CPU同步
6.8 工作项目
用来描述在系统线程池中排队的函数