-
-
[原创] Windows 7 x64 虚拟内存管理 第二部分笔记
-
发表于: 2022-3-2 13:06 13671
-
Windows 内核基础
第二部分解释一些Windows内核概念,便于理解后续的内容。这部分将涉及IRQL,中断处理和同步三个概念。
1. 用户模式和内核模式
用户模式和内核模式对应于处理器的CPL 3和CPL 0。用户模式代码主要是一些可执行程序和DLLs,不过也包含部分操作系统自身的东西。内核模式指的是内核,硬件抽象层和内核模式驱动,以及第三方驱动等。
应用程序代码执行在用户模式,但是处理器在这些情况下会切换到内核模式:
- 硬件中断请求
- 处理器产生了一个异常
- 模式切换指令,如
syscall
。一些API完全实现在用户模式,还有一些需要调用内核或者执行体,里的代码,所以需要切到内核模式。比如说调用WaitForSingleObject
暂停一个线程直到满足特定条件。为了完成这个功能就必须去调用线程调度器,然后保存线程状态到栈上,恢复其他线程的执行。线程调度器就是内核代码的一部分。
处理器从内核切到用户的情景如下:
iret
从中断或者异常处理例程返回- 模式切换指令,如
sysret
,从内核API实现里返回用户模式。
2. IRQL
Windows定义了中断请求级别(IRQL)的概念用来管理一个代码块如何被中断以及线程切换的抢占规则。
IRQL是一个数值,他决定了处理器上什么中断是被启用的。任何时间,系统中的每一个处理器都有自己的IRQL值,而且不同的处理器可以有不同的IRQLs。
每一个硬件中断都被赋予一个IRQL值,它的范围是0~15,同样的值可以赋给不同的设备。
当处理器没有响应中断时,通常他当前的IRQL设置为PASSIVE,也就是0。当处理器为硬件中断服务时,处理例程首先会提升自己的IRQL到设备指定的值。在执行iret
前,handler会恢复中断发生时的IRQL,然后恢复执行被中断的代码。
如果当前的IRQL被提升到了i,那么所有IRQL<=i的中断将被屏蔽,处理器不会被这些中断打断执行。这种情况下,这类中断会被标记为pending状态,等当前中断服务完成后才会得到服务。如果当其他处理器的IRQL<=i时,IRQL<i的中断仍然可以中断这些处理器上执行的代码。
处理器当前IRQL存储在cr8
里。处理器会忽略优先级等于或者小于自己IRQL的中断。中断优先级是通过对中断控制器进行编程来确定的,这是Windows在其初始化期间所做的事情。对IRQL等于或小于当前IRQL的硬件中断的屏蔽是由处理器本身在硬件中完成的。
在硬件层面上,这个方案是为了保证不太重要的中断不允许延迟更重要的中断handler的执行。这个方案对中断处理例程的代码如何执行也产生了逻辑上的影响。
3. IRQL对代码执行的影响
在接下来的章节中,我们将分析通过IRQL屏蔽中断对执行中断处理程序代码的方式有什么影响。在这样做的时候,我们将考虑一般的IRQL值,在它们之间没有区别。
IRQL 1和2有特殊的含义,并以特殊的方式使用。在讨论这些细节之前,研究一下中断屏蔽对代码执行的一般影响。
中断处理程序由不同的部分组成的。这是因为IDT所指向的第一级代码通常会调用内核的其他部分,而这些部分又可能调用属于系统中安装的驱动程序的代码。驱动程序必须被允许安装他们自己的中断服务程序,当中断发生时必须调用这些程序。这允许驱动程序与它的设备进行交互。
IRQL 1和2有特殊的含义,并以特殊的方式使用。
中断处理程序由不同的部分组成的:IDT所指向的第一级代码通常会调用内核的其他部分,而这些部分又可能调用属于系统中安装的驱动程序的代码。驱动程序必须被允许安装他们自己的中断服务例程,当中断发生时必须调用这些例程。这样就允许驱动程序与它的设备进行交互。
3.1 对处理器的影响
中断屏蔽对一个特定的处理器何时能执行某部分的代码施加了一些限制。
处理器在执行中断时,可以被其他中断打断,然后执行其他handler。但是在恢复之前的IRQL之前,不会发生重入。
中断代码可以保存状态信息到每个处理器的数据结构里,比如说处理器控制域(_KPCR) 和处理器控制块(KPRCB)。
3.2 对线程的影响
当IRQL大于或者等于2时,处理器不会执行线程调度代码。简单点说,线程调度就是暂停当前线程然后恢复其他线程。注意:线程调度和线程分发是一个意思。
当中断的IRQL是1时,处理器可以执行线程调度。
IRQL为1时的线程抢占存在如下情况:
- 处理器 p 通过线程 t 服务一个IRQL为 1的中断,转到了handler去执行。handler会设置当前IRQL为1。
- handler执行的过程中,线程切换发生了,线程 t被抢占。
- 线程 s 被处理器恢复执行,因为s运行在PASSIVE,处理器会设置IRQL为 0 。
- 由于线程s在PASSIVE不存在中断屏蔽,同样的IRQL为1的中断再次被处理器p接收到,处理器又转去执行中断服务。
- 于是这个中断就在p处理器上发生了重入。
在这种情况下,如果中断处理例程的状态保存在每个处理器的变量中,这些变量会被第一次调用处理例程的处理器部分更新,而这个处理例程又因为被线程切换而冻结。然后,对处理例程的第二次调用又更新相同的变量,因为执行的处理器是同一个,此时线程t被恢复后,就已经破坏了第一次调用的执行。
因此中断处理在IRQL为1时会屏蔽掉IRQL为1的其他中断,这样在串行访问下,IRQL 为1的handler就可以安全的恢复存在每个线程变量里的状态信息。
3.3 用户模式代码
用户模式的代码总是在IRQL=PASSIVE上执行,也没有API能改变当前的IRQL。因为PASSIVE是最低的IRQL,所以用户模式代码总是可以被中断和上下文切换。通常情况下,用户模式代码不会受到中断的干扰,因为它的任何部分都不会被中断handler调用,所以代码不会因为中断而在同一个线程的上下文中发生函数重入。用户模式代码只需要在不同的线程之间同步执行。
3.4 编写中断处理函数的黄金法则
中断代码绝对不要降低自己的IRQL值到小于设备指定的IRQL值,除非在其最后阶段,当它即将从中断中返回时,不然会因为中断重入,可能会影响到前面同一中断源的执行结果。
3.5 权限的提升和下降
一块代码可以显式地提升IRQL,用来禁用更高的IRQL中断。这种手段可以在中断代码里使用,也可以在其他代码中使用。
4 软中断
Windows定义了软中断,千万不要与Intel架构定义的混淆,虽然他们有着同样的名字。
Windows的软中断实现在内核代码中。每一个处理器的变量都可以设置这样一个中断请求。内核代码会在各种阶段检测这些变量,如果检测到了相应的flag,那么就会去调用对应的handler。
目前Windows定义了APC和DPC两种软件中断。APC的IRQL是1,DPC是的IRQL是2。当代码检测到软件中断处于阻塞状态,然后当前的IRQL有小于中断的IRQL时,就会转去处理软中断请求。
所有的硬件中断的IRQL都比DPC高,因此当处理器在执行硬件中断时,软件中断是被屏蔽的。
举个例子,设想以下情形:
- 执行在PASSIVE的代码被硬件中断给打断了
- handler的代码请求了一个DPC中断。由于当前的IRQL比DPC的IRQL高,因此DPC中断保持阻塞状态。
- handler代码返回,恢复IRQL为PASSIVE。
- 权限降低的代码中检测到了阻塞的DPC中断,由于当前的IRQL为PASSIVE,那么中断可以被服务。因此IRQL就又被改为DPC级别,去执行DPC中断处理函数。
- 当中断返回后,IRQL再次被恢复为PASSIVE。此时也没有进一步的中断在阻塞,被中断的代码恢复执行。
4.1 APC中断
APC也被称为异步过程调用,用来执行一个回调例程。APC是线程相关的,因此每个线程都有自己关联的APC队列,这是一个很特殊的中断。APCs是内核内部使用的机制,因为实现和使用他们的函数并没有文档化。
4.2 DPC中断
延时过程调用(DPC)中断也用于请求异步执行一个回调函数,但是和APC中断有以下区别:
- 只要中断请求服务被允许的话,可以中断任意线程上下文。
- 拥有更高的IRQL,处理的优先级比APC中断高。
- 每一个处理器在系统上都有自己的DPC队列
设备驱动接口提供了DPC中断给中断服务例程(ISR),因为在硬件中断下,所有的软件中断都会被屏蔽,因此要求ISR应该尽快返回,将耗时的工作推迟到较低的IRQL下执行,这就是DPC的意义之一。
在驱动编程中,完成一个IRP请求时需要调用IoCompleteRequest
函数,而文档里明确说到IRQL<=2。这意味着ISR不能调用这个函数,那么就无法完成请求。DPC正是提供了这样的一种机制,使其能在延时完成IRP请求,因为DPC中断的IRQL=2,这样就可以在DPC中断里完成IRP请求。
Windows组件中的线程调度就是使用的DPC中断来调用线程分发函数的。这个中断会被很多内核函数请求。比如说时钟中断会检查当前执行的线程时间片是否用完,如果用完了就会请求DPC中断去调用线程分发函数。当时钟中断和其他阻塞的硬件中断得到服务后,DPC中断就会得到处理器执行,接着分发函数就会被调用,现象就是当前线程被抢占,其他线程恢复执行。
现在我们可以理解为什么设置IRQL>=2之后将会抑制该处理器上的线程上下文切换:因为在这个处理器上,DPC中断已经被屏蔽了,线程分发函数也就不会被调用。
5 对在DPC/dispatch或以上权限执行的代码的限制
核模式和用户模式的代码都可以自愿调用线程调度器,使其暂停运行,直到满足某个逻辑条件。这通常是通过API或DDI提供的数据结构来实现的,代码可以在上面等待。例如,两个线程可以通过使用一个事件对象在它们之间进行同步:线程t等待事件被激活,当线程t所等待的逻辑条件为真时,线程s对事件进行激活操作。线程t所做的事情就会进入等待状态。比方说,应用程序代码可以调用WaitOnSingleObject
来进行等待。当这种情况发生时,线程被暂停,另一个线程被恢复。
执行在DPC/dispatch以上权限的代码不能进入等待状态,像事件,互斥体等同步对象都不能去使用。
运行在这个级别的代码也不能导致页错误。因为解决页错误,可能需要对文件进行I/O操作,这会导致线程进入等待状态,等待的线程需要等待设备触发中断信号,表示数据可获取了才会恢复执行。为了避免这种情况,页面错误handler首先就会检查当前处理器的IRQL。如果处于DPC或者更高的级别上,handler会让系统直接崩溃。
6 历史痕迹:VAX/VMS中的IRQL和软中断
Window架构的源于数字VAX/VMS系统。VMS的每一个字母的下一个组合起来就是WNT。
VAX/VMS中,PSL CPU寄存器记录了处理器的状态,其中有一个域叫做中断优先级(IPL)。
64位系统中使用cr8
寄存器来存IRQL,和VAX 的PSL很像,也用来屏蔽中断。32位系统上,Windows的IRQL通过软件实现的,他将IRQL存在了每个处理器的变量里,然后通过对中断控制器进行编程来实现IRQL改变时的中断屏蔽。
7 同步
在多线程的操作系统里,两个线程可能同时会执行一些代码块,如果涉及同一数据操作,又不进行同步,很可能导致数据不一致。
为了解决这种问题,系统必须提供机制去保证在一个时间点上只有一个线程在执行这些代码。虽然解决了多线程问题,但是如果线程被中断处理打断了,处理例程里也用到了同样的变量的话,仍然会存在同样的问题,中断返回后,数据还是不一致了。因此系统还得提供其他机制处理这种情况。
对于设备寄存器的访问也面临着相似的问题。执行访问的过程中被中断或者其他线程抢占了的话,也可能导致寄存器不一致的行为产生。
我们可以把需要同步的东西称为资源,比如说变量和寄存器,他们必须被保证串行访问。
Windows根据不同的IRQL划分了3个不同的场景。
- 资源在PASSIVE IRQL上的代码路径才会被访问,这个代码路径不属于任何中断的一部分,无论是硬件还是软件中断,都不行。APC回调存在着执行在PASSIVE的情况,所以IRQL等级并不代表着代码不属于中断的一部分。因此我们定义的这种场景是非handler的同步,他是执行在PASSVE权限上的。我们可以称为PASSIVE 同步。
- 这类资源可以被APC中断handler或者PASSIVE级别的非中断代码访问到,我们称为APC同步。
- 这类资源在IRQL>=DPC的级别下也会被访问到。我们称这种场景为高IRQL同步。
前面定义的同步场景有一个限制,那就是资源要么是被内核模式代码访问,要么是被用户模式代码访问。如果存在混合,那么意义不大,原因是这样的:
- 设备寄存器通常在用户模式代码是不可访问的
- 所有内核分配的数据结构内存区域对于用户代码也是不可访问的
- 内核模式的代码可以访问用户模式内存中的数据结构,但是它需要确定分配数据结构的进程的特定地址空间是当前映射的空间。
在这种限制下,唯一适用于用户模式的情况是PASSIVE同步,因为其他两种情况意味着至少有一条代码路径必须在内核模式下执行,才能成为handler的一部分。虽然有一种APC叫做用户模式APC,它执行用户模式的回调,但它不会异步中断用户模式的代码。这种回调是在代码主动调用某些API时执行的,所以我们不打算分析这种情况。
7.1 Passive 同步
这个场景应用在用户模式。仅当共享的资源从不会在handler代码里访问时,也可以用在内核模式代码。因此我们这里不需要考虑中断,只需要关心并发线程访问共享资源的问题。
因为IRQL时PASSIVE,那么线程就可以进入等待状态。Windows就是提供了APIs或者DDIs让并发的线程暂停继而实现串行执行。比如说,我们可以在用户模式代码里创建一个互斥体。互斥体是一种Windows管理的对象,它拥有两种状态,一是被线程拥有,二是被线程释放。WaitForSingleObject
用于请求获得互斥体。当互斥体是释放状态,调用线程就拥有了这个互斥体,后面的代码执行就允许继续下去。如果互斥体被其他线程占用,那么尝试获取的线程将会被暂停。从代码的角度来说,就是线程一直暂停在了调用WaitForSingleObject
的地方。当其他线程释放时,Windows才会通知等待的线程,把拥有权给他,然后把线程插入就绪列表。代码逻辑就是,当WaitForSingleObject
返回后,就意味着线程拥有了互斥体,其他线程尝试获取这个互斥体的话,会被阻塞掉。为了对资源的串行访问,所有访问该资源的代码路径在这样做之前都要对同一个互斥体执行WaitForSingleObject
的调用,并且在他们对资源的访问完成之后才释放互斥体。
Windows API提供了很多其他的同步对象,他们的区别只是行为不同,但是最终的效果就是暂停一个或者多个线程直到满足特定条件。
在内核模式代码里也有类似的同步对象。
7.2 APC 同步
为了完整起见,我们才考虑APC同步。可能内核中没有资源被这样访问,但是由于APC中断确实存在,我们将研究这种情况发生下的同步问题。
APC同步可以通过互斥等对象来实现串行并发线程的执行,虽然这样能保证只有一个线程能访问到这个资源,但是会发生下面这种情况:
- 线程在获取互斥体包含资源后,开始访问资源
- 此时APC中断发生了,导致线程去执行其他代码路径,而这条路径也尝试访问同一个资源
- 代码在这条路径上的获取资源会成功,因为这个对象允许多次被同一线程获取。
- 执行过程中会通过互斥体获取并访问资源从而破坏了他的状态。
可以发现,互斥体并没有完成对资源的保护,他的状态被其他代码路径给破坏了。
获得一个互斥体可以禁止某些APC的传递,在上面的例子中,部分APC就不能中断第一条代码路径。然而,其他的特殊内核模式APC仍然可以被传递,中断第一条代码路径的执行。
对于一个互斥体来说,被同一个线程多次获取的操作是被允许的,否则一个线程试图两次获取同一个互斥体会永远阻塞,因为他需要等待自己释放它。
那么这种场景的同步应该如何去实现呢?其实可以这样,不属于APC路径的代码可以通过提高IRQL到APC来完全禁用APC,或者通过调用KeEnterGuardedRegion
DDI之后再获取互斥体。当这两个步骤都被执行后,只有后面的代码才能接触到资源,也不会被多个线程同时执行了。
APC路径上的代码不需要担心非handler代码的问题。它只需要保护自己不被多个线程并发执行,这可以通过在访问资源之前获取互斥体来做到这一点。值得重申的是,如果APC的IRQL小于DPC/dispatch,APC路径上的代码就可以阻塞,否则它就不能阻塞。
7.3 高IRQL同步
这种场景下,一个或者多个执行在IRQL>=DISPATCH的代码路径会接触到资源。为了不过分限制,我们可以考虑在不同的IRQL执行的多个handler,包括APC回调,都能访问资源,以及非handler的一部分代码路径也能访问资源。
由于这种情况下的串行访问存在IRQL不允许调用等待函数的情况,已经不能通过暂停线程来实现。
此外,所有的代码路径必须考虑到被访问资源的代码打断的可能性。比如说非handler代码被handler打断,handler也可以被更高IRQL的中断给打断。
因此要解决两个问题:
- 找到一个方式解决同一时间只能有一条代码路径能访问资源
- 找到一个方式解决中断带来的同步问题
先不考虑多线程的情况下,解决这些问题的第一步是让所有访问资源的代码路径提高IRQL。例如,考虑一个由非handler代码和DPC回调都能访问的资源。访问该资源的最高IRQL是DPC/dispatch。在PASSIVE执行的代码,在访问资源之前将IRQL提高到DPC/dispatch。
如果资源也被一个在IRQL 4执行的中断处理程序访问,非handler路径和DPC回调在访问它之前则将IRQL提高到4。简而言之,所有的代码路径都是以最高的IRQL值接触资源的。
在上面的例子中,这对中断意味着什么。非handler代码将IRQL提高到4,所以它既不能被DPC打断,也不能被硬件中断打断。只要IRQL保持4,就只有这个代码路径可以访问资源。
DPC代码也提高IRQL,这样它就不能被硬件中断打断。根据定义,非handler代码不是中断的一部分,所以只要IRQL为4,DPC代码将是唯一一个访问资源的人。
同样的,硬件中断处理程序的代码不能被IRQL较低的DPC打断,因为在中断处理开始时IRQL已经被设置为4,它是访问资源的唯一可能的代码路径。
如果为了完整起见,我们设想该资源也被APC中断处理程序调用的代码所访问,同样的推理也适用:在其他代码路径上将IRQL提高到4,也会抑制APC中断,APC调用的代码可以通过提高IRQL来保护自己,就像其他人所做的一样。
现在让我们把注意力转向其他线程。假设一个处理器正在执行我们例子中三个代码路径中的一个。在IRQL提高到4之后,线程切换在该处理器上被抑制,因为它需要一个DPC中断。需要高IRQL同步的情况下资源的访问需要发生在DPC/dispatch IRQL。如果系统中只有一个处理器,我们就不必担心其他线程的问题,这仅仅是由于提高了IRQL。实际上,在Windows的旧版本中,有一个专门针对单处理器系统的内核构建,这种同步在该构建中就是这样工作的。
现在的操作系统基本不止一个处理器,,只是提高IRQL是不够的。其他处理器执行的线程可能会进入我们例子中三个代码路径的任何一个。因此,仍然有必要在每个代码路径上放置一些逻辑,确保每次只有一个线程可以进入访问资源的代码块。然而,由于至少有两个代码路径是在IRQL>=DPC/dispatch时执行的,它们两个都不能进入等待状态。
剩下的唯一解决方案是让代码检测另一个线程是否在执行被保护的块,如果是,就在一个循环中旋转,直到另一个线程完成其工作。为了达到这个目的,内核使用了一个叫做自旋锁的数据结构,它类似于一个互斥体,有两种状态:自由和获取。为了获得一个自旋锁,代码使用一个能够测试其状态的处理器指令,并在同一个不可中断的访问中改变它。
这就保证了每次只有一个处理器可以发现自旋锁是自由的,并获取它。之后,所有试图获取自旋锁的代码块都会导致执行中的处理器在一个循环中旋转,直到它再次变得自由。
在我们的例子中,所有的代码路径在提高IRQL后获得相同的自旋锁,并在对受保护资源的访问结束后,在降低IRQL前释放它。这保证了每次只有一个处理器可以执行访问受保护资源的代码,并解决了多核心下的线程同步问题。
每个访问受保护资源的代码路径都必须执行两个操作:提高IRQL和获取自旋锁,所以我们很自然地想知道它们应该以什么顺序执行。
正确的顺序是先提高IRQL,然后再获取自旋锁。这是有必要的,因为一个自旋锁不能被同一个处理器多次获取。换句话说,在一个处理器成功获得自旋锁后,它不能执行试图再次获得相同自旋锁的代码。由于没有任何与自旋锁状态相关的信息来记录它是由先前获取它的同一个处理器请求的事实,这段代码会在一个无限循环中旋转。在这一点上,自旋锁与互斥体不同。
现在,假设我们例子中的PASSIVE代码路径执行了错误的顺序:先获取自旋锁,随后提高IRQL。在获得自旋锁后,处理器可以被IRQL为 4的handler代码路径中断。这条路径就会获取相同的自旋锁,导致无限循环,也就是死锁。
相反,先提高IRQL,确保在试图获取自旋锁时,相关的中断已经被屏蔽后,再获取自旋锁,执行的处理器就可以不受干扰地继续工作,直到它释放自旋锁,而不会被中断打扰。
正确的操作顺序是如此重要,以至于用于自旋锁的内核DDI在内部就实现了提高和恢复IRQL的操作,以确保一切以正确顺序发生。例如,KeAcquireSpinlock
,当各种代码路径的最高IRQL是DPC/dispatch时,它在内部提高IRQL,然后获取自旋锁。
在这个整体逻辑下,所有获取一个特定自旋锁的代码路径都在同一个IRQL级别(在我们的例子中是4)上进行。因此,一个自旋锁总是与一个IRQL值相关,也就是资源被获取的最高IRQL。
当获取自旋锁的代码,如KeAcquireSpinlock
,发现它已经被获取,就会在一个循环中旋转,从而使处理器空转。自旋锁的等待应该保持在尽可能短的时间内(Windows WDK建议最多25微秒)。如果在获得自旋锁后发生线程切换进而导致在线程到达释放自旋锁的点之前暂停的话,那对处理器来说将是一场灾难。然而,这种情况不可能发生,因为自旋锁内部总是将IRQL提高到DPC/dispatch或更高之后才进行获取操作。
赞赏
- 定位Windows分页结构内存区域 6948
- [原创]2022年,工业级EDR绕过蓝图 28448
- [分享]《探索现代化C++》泛读笔记摘要20 完! 7317
- [分享]《探索现代化C++》泛读笔记摘要19 7566
- [原创]摘微过滤驱动回调的研究-续 10174