第 6 章:内核机制
本章讨论了 Windows 内核提供的各种机制。 其中一些对驱动程序编写者直接有用。 其他机制是驱动程序开发人员需要理解的,因为它有助于调试和对系统中的活动的一般理解。
目录
Using C++ RAII Instead of __try / __finally
总结
中断请求级别
在第一章中,我们讨论了线程和线程优先级。当想要执行的线程数量多于可用处理器时,就会考虑这些优先级。同时,硬件设备需要通知系统有什么事情需要注意。一个简单的例子是 由磁盘驱动器执行的 I/O 操作。一旦操作完成,磁盘驱动器就会通过请求中断来通知完成。该中断连接到中断控制器硬件,然后该硬件将请求发送到处理器进行处理。 下一个问题是,哪个线程应该执行关联的中断服务例程(ISR)?
每个硬件中断都与一个优先级相关联,称为中断请求级别 (IRQL)(不要与称为 IRQ 的中断物理线混淆),由 HAL 确定。 每个处理器的上下文都有自己的 IRQL,就像任何寄存器一样。 IRQL 可能由 CPU 硬件实现,也可能不由 CPU 硬件实现,但这本质上并不重要。 IRQL 应像任何其他 CPU 寄存器一样对待。
基本规则是处理器执行具有最高 IRQL 的代码。 例如,如果 CPU 的 IRQL 在某个时刻为零,并且关联 IRQL 为 5 的中断进来,它将在当前线程的内核堆栈中保存其状态(上下文),将其 IRQL 提高到 5,然后执行 ISR 与中断相关。 一旦 ISR 完成,IRQL 将下降到之前的级别,恢复之前执行的代码,就好像中断不存在一样。 当 ISR 正在执行时,IRQL 为 5 或更低的其他中断无法中断该处理器。 另一方面,如果新中断的 IRQL 高于 5,CPU 将再次保存其状态,将 IRQL 提升到新级别,执行与第二个中断关联的第二个 ISR,完成后将回落到 IRQL 5、恢复其状态并继续执行原来的ISR。 本质上,提高 IRQL 会暂时阻止具有相同或更低 IRQL 的代码。 中断发生时的基本事件顺序如图 6-1 所示。 图 6-2 显示了中断嵌套的样子。
对于图 6-1 和 6-2 中所描述的场景,一个重要事实是所有 ISR 的执行都是由首先被中断的同一个线程完成的。 Windows没有专门的线程来处理中断; 它们由当时在中断的处理器上运行的任何线程处理。 我们很快就会发现,当处理器的 IRQL 为 2 或更高时,上下文切换是不可能的,因此在这些 ISR 执行时,其他线程无法潜入。
被中断的线程不会因为这些“中断”而减少其数量。 可以这么说,这不是它的错。
当用户模式代码执行时,IRQL 始终为零。 这就是任何用户模式文档中都没有提及 IRQL 一词的原因之一 - 它始终为零且无法更改。 大多数内核模式代码也以 IRQL 0 运行。 在内核模式下,可以提高当前处理器上的 IRQL。
重要的IRQL描述如下:
• WDK 中的PASSIVE_LEVEL (0) - 这是CPU 的“正常”IRQL。 用户模式代码始终在此级别运行。 线程调度工作正常,如第一章所述。
• APC_LEVEL (1) - 用于特殊内核APC(异步过程调用将在本章后面讨论)。 线程调度工作正常。
• DISPATCH_LEVEL (2) - 这是事情发生根本变化的地方。 调度程序无法在此 CPU 上唤醒。 不允许分页内存访问 - 此类访问会导致系统崩溃。 由于调度程序无法干预,因此不允许等待内核对象(如果使用,则会导致系统崩溃)。
• 设备IRQL - 用于硬件中断的一系列级别(x64/ARM/ARM64 上为3 到11,x86 上为3 到26)。 IRQL 2 中的所有规则也适用于此。
• 最高级别(HIGH_LEVEL) - 这是最高的IRQL,屏蔽所有中断。 由一些处理链表操作的 API 使用。 实际值为 15 (x64/ARM/ARM64) 和 31 (x86)。
调试时可以使用 !irql 命令查看处理器当前的 IRQL。 可以指定一个可选的 CPU 编号,它显示该 CPU 的 IRQL。
您可以使用 !idt 调试器命令查看系统上注册的中断。
提高和降低 IRQL
正如前面所讨论的,在用户模式中,没有提到 IRQL 的概念,也没有办法改变它。 在内核模式下,可以使用 KeRaiseIrql 函数提高 IRQL,并使用 KeLowerIrql 降低 IRQL。 下面的代码片段将 IRQL 提升到 DISPATCH_LEVEL (2),然后在执行此 IRQL 上的一些指令后将其降低。
// assuming current IRQL <= DISPATCH_LEVEL
KIRQL oldIrql; // typedefed as UCHAR
KeRaiseIrql(DISPATCH_LEVEL, &oldIrql);
NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);
// do work at IRQL DISPATCH_LEVEL
KeLowerIrql(oldIrql);
如果提高 IRQL,请确保在同一函数中降低它。 从 IRQL 高于输入值的函数返回太危险了。 另外,请确保 KeRaiseIrql 实际上提高了 IRQL,而 KeLowerIrql 实际上降低了它; 否则,系统将会崩溃。
线程优先级与 IRQL
IRQL 是处理器的一个属性。 优先级是线程的一个属性。 线程优先级仅在 IRQL < 2 时才有意义。一旦正在执行的线程将 IRQL 提高到 2 或更高,其优先级就不再具有任何意义 - 理论上它具有无限量 - 它将继续执行,直到将 IRQL 降低到 2 以下。
当然,在 IRQL >= 2 上花费大量时间并不是一件好事; 用户模式代码肯定没有运行。 这只是执行代码在这些级别上可以执行的操作受到严格限制的原因之一。
任务管理器使用称为系统中断的伪进程显示在 IRQL 2 或更高级别上花费的 CPU 时间量; Process Explorer 将其称为中断。 图 6-3 显示了任务管理器中的屏幕截图,图 6-4 显示了 Process Explorer 中的相同信息。
延迟程序调用
图 6-5 显示了客户端调用某些 I/O 操作时的典型事件序列。 在此图中,用户模式线程打开文件句柄,并使用 ReadFile 函数发出读取操作。 由于线程可以进行异步调用,因此它几乎立即重新获得控制权并可以执行其他工作。 接收此请求的驱动程序调用文件系统驱动程序(例如 NTFS),文件系统驱动程序可能会调用其下的其他驱动程序,直到请求到达磁盘驱动程序,磁盘驱动程序在实际磁盘硬件上启动操作。 那时,不需要执行任何代码,因为硬件“做它的事情”。
当硬件完成读操作时,它会发出中断。 这会导致与中断关联的中断服务例程在设备 IRQL 处执行(请注意,处理请求的线程是任意的,因为中断是异步到达的)。 典型的 ISR 访问设备的硬件以获取操作结果。 它的最终行为应该是完成初始请求。
正如我们在第 4 章中看到的,通过调用 IoCompleteRequest 来完成请求。 问题是文档指出该函数只能在 IRQL <= DISPATCH_LEVEL (2) 下调用。 这意味着 ISR 无法调用 IoCompleteRequest,否则会使系统崩溃。 那么 ISR 要做什么呢?
您可能想知道为什么会有这样的限制。 原因之一与 IoCompleteRequest 完成的工作有关。 我们将在下一章更详细地讨论这个问题,但最重要的是这个功能相对昂贵。 如果允许调用,则意味着 ISR 将花费更长的时间来执行,并且由于它在高 IRQL 中执行,因此它将在更长的时间内屏蔽其他中断。
允许 ISR 尽快调用 IoCompleteRequest(以及具有类似限制的其他函数)的机制是使用延迟过程调用 (DPC)。 DPC 是一个封装了要在 IRQL DISPATCH_LEVEL 调用的函数的对象。 在此 IRQL 下,允许调用 IoCompleteRequest。
您可能想知道为什么 ISR 不简单地将当前 IRQL 降低到 DISPATCH_LEVEL,调用 IoCompleteRequest,然后将 IRQL 提高回其原始值。 这可能会导致死锁。 我们将在本章后面的“自旋锁”部分讨论其原因。
注册 ISR 的驱动程序通过从非分页池分配 KDPC 结构并使用 KeInitializeDpc 使用回调函数对其进行初始化来提前准备 DPC。 然后,当调用 ISR 时,就在退出该函数之前,ISR 通过使用 KeInsertQueueDpc 将 DPC 排队来请求 DPC 尽快执行。 当DPC函数执行时,它调用IoCompleteRequest。 因此,DPC 是一种折衷方案 - 它在 IRQL DISPATCH_LEVEL 上运行,这意味着不会发生调度,不会进行分页内存访问等,但它的级别还不够高,无法防止硬件中断进入并在同一处理器上提供服务。
系统上的每个处理器都有自己的 DPC 队列。 默认情况下,KeInsertQueueDpc 将 DPC 排队到当前处理器的 DPC 队列中。 当 ISR 返回时,在 IRQL 回落到零之前,会检查处理器队列中是否存在 DPC。 如果存在,则处理器下降到 IRQL DISPATCH_LEVEL (2),然后以先进先出 (FIFO) 方式处理队列中的 DPC,调用相应的函数,直到队列为空。 只有这样,处理器的 IRQL 才能降至零,并恢复执行中断到达时受到干扰的原始代码。
DPC 可以通过某些方式进行定制。 查看函数 KeSetImportantceDpc 和 KeSetTargetProcessorDpc 的文档。
图6-6用DPC例程执行增强了图6-5。
将DPC与计时器一起使用
DPC 最初是为 ISR 使用而创建的。 然而,内核中还有其他利用 DPC 的机制。
其中一种用途是使用内核计时器。 由 KTIMER 结构表示的内核计时器允许根据相对间隔或绝对时间设置计时器在未来某个时间到期。 该计时器是一个调度程序对象,因此可以使用 KeWaitForSingleObject 进行等待(在本章后面的“同步”部分中讨论)。 虽然可以等待,但对于计时器来说很不方便。 一种更简单的方法是在计时器到期时调用一些回调。 这正是内核计时器使用 DPC 作为其回调所提供的。
以下代码片段显示如何配置计时器并将其与 DPC 关联。 当定时器到期时,DPC 被插入到 CPU 的 DPC 队列中,因此会尽快执行。 使用 DPC 比基于零 IRQL 的回调更强大,因为它保证在任何用户模式代码(以及大多数内核模式代码)之前执行。
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 是封装要在 IRQL DISPATCH_LEVEL 调用的函数的对象。 就 DPC 而言,调用线程并不重要。
异步过程调用 (APC) 也是封装要调用的函数的数据结构。 但与 DPC 不同的是,APC 是针对特定线程的,因此只有该线程才能执行该函数。 这意味着每个线程都有一个与其关联的 APC 队列。
APC 分为三种类型:
• 用户模式APC - 仅当线程进入可警报状态时,这些APC 才会在IRQL PASSIVE_LEVEL 用户模式下执行。 这通常通过调用 API(例如 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx 和类似 API)来完成。 这些函数的最后一个参数可以设置为 TRUE 以将线程置于可警报状态。 在此状态下,它会查看其 APC 队列,如果不为空,则 APC 现在会执行,直到队列为空为止。
• 普通内核模式APC - 这些在内核模式下以IRQL PASSIVE_LEVEL 执行,并抢占用户模式代码和用户模式APC。
• 特殊内核APC - 这些在内核模式下以IRQL APC_LEVEL (1) 执行,并抢占用户模式代码、正常内核APC 和用户模式APC。 I/O系统使用这些APC来完成I/O操作。 下一章将讨论常见的场景。
APC API 在内核模式下没有文档记录,因此驱动程序通常不直接使用 APC。
用户模式可以通过调用某些API来使用(用户模式)APC。 例如,调用ReadFileEx或WriteFileEx启动异步I/O操作。 当操作完成时,用户模式 APC 将附加到调用线程。 如前所述,当线程进入可警报状态时,此 APC 将执行。 用户模式下显式生成 APC 的另一个有用函数是 QueueUserAPC。 查看 Windows API 文档以获取更多信息。
关键区域和防护区域
临界区域会阻止用户模式和普通内核 APC 执行(特殊内核 APC 仍然可以执行)。 线程通过 KeEnterCriticalRegion 进入临界区,并通过 KeLeaveCriticalRegion 离开临界区。 内核中的某些功能需要位于关键区域内,特别是在使用执行资源时(请参阅本章后面的“执行资源”部分)。
受保护区域会阻止所有 APC 执行。 调用 KeEnterGuardedRegion 进入防护区域,调用 KeLeaveGuardedRegion 离开它。 对 KeEnterGuardedRegion 的递归调用必须与对 KeLeaveGuardedRegion 的相同数量的调用相匹配。
将 IRQL 提高到 APC_LEVEL 将禁用所有 APC 的传送。
结构化异常处理
异常是由于某条指令执行了导致处理器引发错误的操作而发生的事件。 异常在某些方面类似于中断,主要区别在于异常是同步的并且在相同条件下技术上可再现,而中断是异步的并且可以随时到达。 异常的示例包括被零除、断点、页面错误、堆栈溢出和无效指令。
如果发生异常,内核会捕获该异常并允许代码处理异常(如果可能)。 这种机制称为结构化异常处理 (SEH),可用于用户模式代码和内核模式代码。
内核异常处理程序是根据中断调度表 (IDT) 进行调用的,该表保存中断向量和 ISR 之间的映射。 使用内核调试器,!idt 命令显示所有这些映射。 低编号的中断向量实际上是异常处理程序。 以下是该命令的输出示例:
lkd> !idt
Dumping IDT: fffff8011d941000
00: fffff8011dd6c100 nt!KiDivideErrorFaultShadow
01: fffff8011dd6c180 nt!KiDebugTrapOrFaultShadow
02: fffff8011dd6c200 nt!KiNmiInterruptShadow
03: fffff8011dd6c280 nt!KiBreakpointTrapShadow
04: fffff8011dd6c300 nt!KiOverflowTrapShadow
Stack = 0xFFFFF8011D9459D0
Stack = 0xFFFFF8011D9457D0
05: fffff8011dd6c380 nt!KiBoundFaultShadow
06: fffff8011dd6c400 nt!KiInvalidOpcodeFaultShadow
07: fffff8011dd6c480 nt!KiNpxNotAvailableFaultShadow
08: fffff8011dd6c500 nt!KiDoubleFaultAbortShadow Stack = 0xFFFFF8011D9453D0
09: fffff8011dd6c580 nt!KiNpxSegmentOverrunAbortShadow
0a: fffff8011dd6c600 nt!KiInvalidTssFaultShadow
0b: fffff8011dd6c680 nt!KiSegmentNotPresentFaultShadow
0c: fffff8011dd6c700 nt!KiStackFaultShadow
0d: fffff8011dd6c780 nt!KiGeneralProtectionFaultShadow
0e: fffff8011dd6c800 nt!KiPageFaultShadow
10: fffff8011dd6c880 nt!KiFloatingErrorFaultShadow
11: fffff8011dd6c900 nt!KiAlignmentFaultShadow
(truncated)
请注意函数名称 - 大多数都非常具有描述性。 这些条目与 Intel/AMD(在本例中)故障有关。 一些常见的例外示例包括:
• 除以零 (0)
• 断点 (3) - 内核透明地处理此问题,将控制权传递给附加的调试器
(如果有的话)。
• 无效操作码(6) - 如果CPU 遇到未知指令,则会引发此故障。
• 页面错误(14) - 如果用于将虚拟地址转换为物理地址的页表条目的有效位设置为零,则CPU 将引发此错误,表明(就CPU 而言)该页面未驻留在物理内存中。
由于先前的 CPU 故障,内核会引发一些其他异常。 例如,如果出现页面错误,内存管理器的页面错误处理程序将尝试定位未驻留在 RAM 中的页面。 如果该页恰好根本不存在,内存管理器将引发访问冲突异常。
一旦引发异常,内核就会在发生异常的函数中搜索处理程序(除了一些它透明处理的异常