第一部分主要涉及Intel架构的基础知识,不涉及Windows相关的内容。如果你熟悉Intel架构,那么可以跳到第二部分。详细的内容可以参考Intel白皮书。
处理器拥有一种叫做当前权限级别(CPL)的内部状态,用于决定某些特定的操作是否被执行。
ring0~ring3共定义了4个级别,ring0的级别是最高的。
Windows只使用了CPL 0和CPL 3,因此我们只讨论这两个级别。
处理器在这两个级别上的影响如下:
一个执行在CPL 3的程序,如果尝试去执行上面禁止的操作,那么会触发异常。简单的说就是处理器会跳到一个操作系统提供的函数地址里去处理这种情况,最终被禁止的操作不会被执行。比如说程序尝试写被保护的内存位置,那么该内存区域的内容并不会改变。
Windows操作系统上,应用程序代码和操作系统的一部分执行在CPL 3,CPL 0只用于执行操作系统代码,当然也包括第三方操作系统扩展,比如设备驱动。
我们经常用环来代表级别,因为他很形象的显示了ring 0的保护最强,ring 3的保护最弱,越往内保护越强。处理器在ring 0 就代表他的CPL是0。
CPL的切换是通过特定指令完成的。比方说syscall
从CPL 3切到CPL 0,sysret
相反。
syscall
跳转的地址只能CPL 0 修改,所以应用程序不能修改操作系统代码的入口点。syscall
指令会报错当前的指令指针rip
到rcx
寄存器,然后目标代码能将地址保存到内存的其他地方,之后sysret
能正常返回。
硬件中断发生后,一个执行ring 3代码的处理器将会跳到其他地址,然后设置CPL为0。中断发生后rip
和CPL(cs
寄存器持有)保存在栈上,之后iret
指令从栈上加载rip
和cs
后来。iret
能从ring 0切到ring 3,但不能从ring 3切到ring 0。
处理器异常时,和中断类似,如果当前CPL是3,那么会跳转到一个不同的地址,然后设置CPL为0。异常是处理器自己产生的,由正在执行的指令引起的。例如div
执行除0操作。和中断类似,rip
和cs
保存在栈上。如果异常发生在ring 3,那么就会存在权限级别切换。
栈是由一些处理器指令隐式使用的地址范围,也会被处理器机制用到,比如说中断处理。处理器隐式访问栈是通过rsp
寄存器进行的。
举个例子,call
指令调用函数时,处理器会执行以下操作:
最终结果就是处理器跳到了一个新的地址,返回地址保存到了栈上,通过ret
指令返回。ret
指令会执行下面的操作:
当有新的值被写到栈上时,栈指针会减小。Windows上栈是向下生长的。当进程开始时,rsp
由系统进行初始化。
返回地址是返回到函数调用后的地址。其他隐式使用栈的指令还有push
和pop
等。
栈的使用还涉及中断和异常。
处理器不同的CPL下使用不同的栈。ring 0 栈和 ring 3栈分别用于CPL 0和CPL 3。
中断和异常发生后的权限切换,rsp由处理器自动完成rsp切换。对于syscall
和sysret
则由操作系统代码来完成rsp切换。
0环的地址,3环是无法访问的。
中断时设备向芯片和处理器发送的电信号,以使后者执行一些与设备本身交互的代码。具体的例子是磁盘控制器发送了一个中断信号表示它完成了数据向内存的传输。我们把处理器对中断的响应行为称为中断处理或者中断服务。
每一个硬件中断都关联一个中断向量号。处理器使用向量号作为索引去取得中断描述符表(IDT)。每个表项叫做门。中断门包含了地址和控制信息。总之一个硬件中断会导致处理器跳到对应的IDT项关联的地址中取执行。
硬件中断发生时,处理器处在在ring 3的话,CPL会改为0,如果处理器已经在ring 0,CPL就不会改变。
IDT只能在CPL 0修改。IDT里找出来的地址被称为中断处理例程。在启动阶段,操作系统加载中断处理到内存,初始化IDT表项。
处理器收到一个中断后的执行流程如下:
上面的步骤形成的栈布局被称为中断帧。
中断返回是通过iret
指令完成的。这条指令负责从栈上恢复原来的寄存器值。处理器的其他寄存器原来的值,则必须有中断例程执行iret
返回之前进行恢复。
iret
指令可以改变CPL从0到3。
中断帧是保存在内核栈上的。
内核中栈地址无效的两个主要原因,一是地址不符合规范,二是该地址没有对应的物理地址。
栈切换保证了代码执行在一个完好的新栈上,不受原来的栈的影响。
那么rsp
从哪里获取呢?处理器使用的是一个叫任务状态栈的数据结构(TSS)。TSS的初始化是由操作系统完成的,一般操作系统会让这个栈在内存中不能被ring 3访问到,所以应用程序无法篡改。
正常来说,每一个处理器都有一个单独的TSS。
异常和中断很类似,不过这是处理器在执行指令时自己产生的。为了避免和硬件中断向量号冲突,向量号0~31被保留为Intel专用。
处理器产生异常后,会建立类似中断帧的栈布局,有一些异常还会额外保存错误码。每一个异常都有一个不同的IDT项,因此都会有自己的处理例程,也相应的决定了中断帧是否包含错误码。
异常和中断的返回指令都是iret
,如果栈上存放了错误码,那么异常处理例程在执行iret
之前,必须自己调整rsp
的值。
不能将Windows异常和Intel架构定义的异常混为一谈。Windows异常是程序出错后系统代码产生的事件。Windows异常是操作系统实现的,他是操作系统定义的数据结构。处理器异常时类似中断的事件,用来响应无效指令。当然处理器异常也是Windows异常的一部分异常来源。比如说0xC0000005,访问异常。这意味着Windows ring 3发生异常后的恢复执行是由Windows系统代码完成的,Windows实现了自己的一套异常机制。iret
不再是简单的重新加载存储在中断帧的状态。实际上,存储的状态由ring 0代码完成,因此当iret
执行后,ring 3的程序神奇的恢复执行了。
错误产生是在指令执行之前,所以中断帧上的rip
指向的就是导致这个错误的指令,被称为错误指令。当异常处理例程将错误的原因移除后,处理器将会重新执行这条错误指令,也许,现在就能执行成功了。
众多的Intel定义的错误中有一个叫页错误的异常,当产生无效内存引用时将会产生这个错误。当一个程序引用的内存内容被移到页面文件里时,一个页错误将产生。VMM的工作就是获取页面的内容,然后重新执行这条错误指令。
陷阱的产生在指令执行之后,rip
此时指向的是陷阱指令后边的指令地址。最常见的例子是调试陷阱,处理器在每一条指令上进行配置,然后实现调试器的单步执行。
中止是在多个错误条件被检测到后触发的,这时已经不能保证被中断的代码能恢复执行,因为rip
可能已经不再指向导致异常的这条指令。中止的异常处理例程应该设法优雅地结束这个进程。
Intel 架构定义了软中断。他实际上不是程序本身执行产生的中断。
int n
指令会使处理器调用IDT表里指向的第n项的处理例程。这中调用时程序本身拥有的,因为int
指令时他代码的一部分。
这条指令实际上完成了一个间接调用,函数的地址存在对应的IDT项里。int n
不会push 错误码到栈上。因为这条指令不能表达是否存在错误码,要么规定存在,要么规定不存在,intel选择了后者。int
指令在ring 3无意义,因为IDT表项只能在ring 0访问。
Windows定义了自己的软中断概念,他和处理器定义的完全不同。
分页是处理器内存管理单元提供的一系列功能,它实现了虚拟内存。拥有了分页以后,可以标记一个地址是无效的,当指令尝试访问这个地址,而该地址没有指向实际内存时,处理器会产生一个异常。异常处理例程会使地址有效,并恢复错误指令的执行。如果内存在哪里了,那么这条指令就会执行成功。
分页是基于地址转换的。代码执行中的每一个地址在实际引用内存前都会转换为一个不同的地址。地址转换不仅仅是存在于包含地址操作数的指令,栈指针里存放的地址也会被转换。
所有引用内存的指令都会存在转换,不管是显式还是隐式。
分页也可以被禁用。那么所有的地址就是实际的地址,不需要转换,直接引用内存。Windows启动运行起来后,总是启用的分页。
通过上面的内容,我们定义了两种类型的地址,虚拟地址和物理地址。虚拟地址需要经过转换才能访问到内存里的东西。物理地址指向实际的内存内容。
由于64位的值包含的地址空间比较大,当前的x64处理器实际上将地址限制为了48位。简单地禁用48-63位使虚拟地址在256TB以下。32位的Windows使用了32bit的虚拟地址空间,然后对半分。但是这种方案在64位上不可行。解决的方案是64位系统上,虚拟地址都是47位。因此有效的地址空间访问就有两个:
通过使用这种方式,内存管理单元得到了简化,因为48-63位在地址转换中就不会被使用了。x64模式里使用的所有地址都是规范地址。不规范的地址访问将会触发页错误异常。
处理器使用了表来存放转换地址。如果一个虚拟地址不能被转换为物理地址,那么这个地址是无效的,反正有效。这就是有效地址与无效地址。
尝试访问一个无效地址会触发页面错误异常。这个异常除了在栈上保存中断帧,还会保存错误码,用来表示访问类型:读取,写入或者指令获取。进一步的,cr2
寄存器会设置代码尝试访问的地址。
异常处理例程要么建立一个有效的转换,要么决定不允许这种访问,然后结束这个进程。
处理器会使用下图的一些表来计算物理地址。
转换的流程是这样的:
我们引入分页结构(PS)代表上面提到的所有表。用PxE缩写来泛指上面提到的表项。分页结构是4kb,每一项是8byte长度,那么一个PS最多可以存储512项,因此虚拟地址拆分为9位来进行表索引的。根据设计,分页结构的起始地址必须是4kb的倍数。所以这些地址的0-11位是设置为0的。而这些位在转换过程中就可以被用来提供控制信息。这种要求也被应用到PTE里。
第7步中,用到的offset是12位,因此范围就是2的12次方,也就是4KB大小。
内存寻址的访问因此是4kb对齐和4kb大小的。我们称这个内存块为物理页。物理页的起始地址除以4k得到的数称为物理页号(PFN)。PFN是一个整数数值,第一个物理页面时 PFN 0,第二个是 PFN 1,依此类推。另外一个计算PFN的方法是物理地址右移12位。
一个分页结构,考虑到其对齐和大小的要求,正好占据一个物理页。这使得操作系统能够以一致的方式管理内存分配,不管一个页面是用来存储程序代码和数据还是分页结构。
虚拟地址除以4k然后向下取整得到的数,我们称为虚拟页号(VPN)。VPN包含了地址转换中用到的的所有位。地址的范围并不会影响转换到同一个物理页。这个范围是4kb对齐和4kb大小的,被称为虚拟页面。一个虚拟页面里所有的地址都会被转换到同一个物理页面,也就是说一个虚拟页面对应一个物理页面。
现在我们可以用虚拟内存来指虚拟地址范围和他关联的内容。无效的地址的内容是未定义的。我们使用物理内存来指实际系统内存。
有趣的是,英特尔的文件曾经将物理地址定义为在完成所有转换工作后,"出现在处理器的地址引脚 "的地址。当代的处理器集成了内存控制器,因此它们与内存芯片的互动使用的信号完全不同于地址总线,而且没有一组引脚可以实际观察到物理地址是一连串的高低电压水平。在这个意义上,物理地址已经不存在了。然而,即使在当代系统中,内存也被安排在由一个数字唯一标识的字节位置,即地址,无论硬件如何设计,处理器都必须使用这个地址来访问它们。在分页结构条目中发现的数值就是用于此目的的,它仍然被称为物理地址。
使用不同的cr3
寄存器,将会是完全不同的转换。PML4Es将会指向不同的PDPTs,他们的项又会指向不同的子结构,直到存储内存内容的物理页。
改变cr3
寄存器将建立一个新的地址空间,这个机制用来实现每一个进程的地址空间分离。
PML4项的映射地址是从39-47开始的,因此每个条目都映射了一个地址范围,这些位保持不变。对应的范围就是右边的39位全0到全1,即是2的39次方字节数,也就是512GB。
同样的道理,PDPT项映射的范围就是1GB。
PD项映射的范围就是2MB。
PT项银蛇的范围就是4KB。
一个有效的PxE存着一个物理页面,不管是子分页结构还是虚拟地址对应的物理页面。PxE的第0位决定是否有效,当设置为1时,这个PxE是有效的。
下面是一个有效PxE的布局:
PxE的各位域含义如下:
P :当这个位被设置时,表示PxE是有效的,要么指向下一个PS,要么就是一个映射的物理页。当这个位置被清0,那么这个PxE是无效的。
R/W:这个位设置时,向这个虚拟地址写入是允许的,当被清零时,写入会导致页面错误异常。
U/S:这个置为1时,表示可以在CPL为3时访问。清零时表示只能在CPL<3访问。这个位存在的意义时为了让部分虚拟地址空间只能被ring 0访问,从而保护系统代码。试图在ring3 访问一个被保护的虚拟地址,将会触发页面异常错误。
PWT:控制内存如何被缓存
PCD:控制内存如何被缓存
A:指令访存时会涉及这个位的使用。这个位让代码知道这片虚拟内存区域是否已经被访问过。这个位在首次访问时会被设置,然后保留这个设置到代码显式更新PxE,才会清零。
D:当指令写入这个地址映射的空间时由处理器进行设置的。这提供了让代码知道这个物理页是否已经被写入的方法。这个位称为dirty位。处理器设置后并不进行clear操作。这个只会设置PTEs和PDEs。
PAT:如果是PTEs,这个控制是内存缓存。更高级别的PxEs,用来控制页面大小。PML4E里是保留位,必须设置为0。PDPTE可以设置为1,但是我们不分析这一位,因为Windows没有使用这一功能。PDE里可以设置为1,这个位会改变虚拟到物理地址转换的方式。当这个位在PDPTE和PDE中被设置为0时,我们之前所描述的转换就发生了。
G:控制这个项在TLB中的缓存。这个位只在PS层次结构的最后一级使用。比如说PTEs和用于大页的PDEs。
i:所有的i位被处理器忽略,软件可以使用他。
PFN:这个位表示该项所在物理页的PFN。对于PTEs和大页的PDEs则是虚拟地址映射的物理页的PFN。因为这个位从第12位开始的,那么我们从PxE取0-51位然后清掉0-11位,我们就可以得到物理地址所在的页面。
XD:这位设置时,从内存区域里提取指令是不被允许的。禁止取指令意味着不允许执行受这位影响的的内存区域代码。这是设计的一个安全功能,防止代码注入到数据内存区域。比如说注入到栈上的栈溢出攻击。
上图是一个大页的地址转换方式。PDE的第7位如果设置了,那么会改变处理器执行寻址的方式。PDE将会直接用于映射虚拟页,PT会从地址转换过程中被移除。这样的话,虚拟地址的21-47位用于PML4E,PDPTE和PDE的获取。PDE存的就是物理地址转换中的基址。虚拟地址的0-20位就作为offset。因此这种转换的映射范围就等于2MB。这样的话,PDE必须就被赋值在2MB的边界上,PDE映射的范围就是2MB对齐和2MB大小的。这种情况下,PDE的0-20必须设置为0,这些位又可以用来存放控制信息或者保留了。大页的PDE格式如下图所示:
和PxE的区别如下:
bit7:这个位必须是1,因为表示映射的是大页。这个位要是clear的话,PDE引用的就是PT了。
PAT:这个位和PTE是一样的,用来控制内存的缓存。
对于大页也需要PAT位,然后移到了12位。对于小页面,12位是不可获得的。因为他是PT的PFN。可以移到12位的原因还是在于大页必须是2MB对齐。
PFN:PFN使用的是21-51位,原因还是2MB对齐问题。
转换层次中的任何一级,都可以设置成无效PxEs,只不过这样包含的无效虚拟地址更多了。
举个例子,PML4E可以无效,然后他就不能引用PDPT。这种情况发生的话,所有的512GB的地址就都无虚拟到物理的转换。同样的应用于PDPTEs和PDEs,分别是1GB和2MB。
0位是无效的,那么处理器会忽略其他所有的位。
这样的话,1-63位就可以被软件使用了。比方说来用存储PxE在分页文件中应该指向的内容的位置。
我们现在可以知道,地址转换做了实现虚拟内存所需的三件基本事情:
我们可以看到,这些功能都不需要我们看到的多级方案。如果我们所需要的只是一个将虚拟地址与物理地址相关联(即映射)的数据结构,我们可以使用一个由虚拟地址索引的简单数组,其中每个条目存储一个物理页的地址。更进一步说,我们可以问自己为什么要有内存页,也就是说,为什么不让我们假设的数组的一个条目只是存储内存中的一个物理地址。
简而言之,这种方法的最大缺点是,我们必须为每一个可能的虚拟页面设置一个条目,包括那些进程永远不会使用的页面。
通过分级,无效的项就不会指向物理内存,这样根据分页结构仅需要映射有效的映射就行了。
这种机制也允许共享分页结构。假设二级地址空间设置PML4项指向同一个PDPT,那么这样就实现了两个地址空间512GB的内存共享。Windows就是使用这种方式在所有进程中共享系统地址空间的。
为了转换一个虚拟地址到物理地址,处理器要执行4次访存。因为内存延时比指令执行时间要长,这会大大降低了处理器的速度。为了避免这种情况,转换使用了两种类型的缓存:TLB和页结构缓存。
如果需要转换的虚拟地址在TLB中找到了,那么处理器就不需要访问页结构的内存,直接完成转换。
页结构缓存存储了部分PML4E,PDPTE,PDE。
这些缓存机制带来了一个重大的代价:软件需要自己显式的设置缓存无效。如果代码修改了PxE,那么处理器是不能检测到这个更新的,也不会刷新缓存里的转换。代码修改PxEs后,必须显式无效TLB和页结构缓存。这是一个比较复杂的任务,多核的系统上,所有的处理器都需要把自己的缓存无效化,这需要处理器内部中断和多核之间的进行同步。这样的操作不能经常进行,否则性能会受到很大影响,所以必须实现更复杂的逻辑,以分批进行缓存转换的无效化。
处理器会自动无效化缓存,当一个值被加载到cr3
后,因为这个操作改变了PML4的地址,切换到了不同的地址空间。
然而Windows有一些内存是共享的,如果每次上下文切换都无效化缓存,那么是比较浪费时间的。这也是PTEs和大页PDEs的G位的作用:如果这一位被set,那么在cr3
切换时,不会刷新TLB,不过页结构缓存总是会被刷新的,因为G位在其他级别的PxEs中是被忽略的。
Intel 架构为缓存定义了内存类型和缓存控制寄存器已经他的位。
内存类型指定了内存是否被缓存,即它的副本是否被带入处理器的缓存层次,以及当指令写到一个地址时,如何将更新写入主存。
控制寄存器和位指定特定物理或虚拟地址范围的内存类型。
这是最多的缓存类型,它使内存接口的流量保持在最低水平。如果内容在读取时它还没有存在的话,内存被复制到处理器的缓存中。随后的读取将不访问主存,而是从缓存中获取数值。写入将更新缓存中的值的副本,而不会被传到主存。
一个系统有一个以上的处理器,也可以有其他设备从系统内存读取和写入,如DMA控制器。总的来说,所有访问主存的组件被称为总线代理。
对于多个总线代理,必须注意确保每个代理对内存内容一致性。例如,如果一个处理器在不更新主存的情况下更新其缓存中的一个内存位置的副本(这在WB内存中是很正常的),其他代理可能会看到该位置的一个陈旧副本。
这可以通过窥探来避免:总线代理交换信号,允许他们每个人窥探其他代理对其缓存数据的更新。如果一个代理在其缓存中更新了一个值,而另一个代理有一个相同值的副本,那么第二个代理就会扔掉它的副本。下次第二个代理需要该特定数据时,将从内存中加载它。然而,这还不够,因为从主存拷贝的也是陈旧的,只要更新的值保留在修改它的代理的缓冲区内。为了解决这个问题,每个代理对其他代理对主存的访问进行窥探。当它检测到另一个代理想要一块已经在缓存中被修改的数据时,两个代理就会合作,这样需要数据的代理就会从缓存数据的代理那里得到最新的副本
有了这种类型的存储器,当一个处理器写到一个不在缓存中的地址时,存储器的内容首先被复制到缓存中,然后在那里更新。换句话说,读和写都会填满缓冲区。
缓存内存是以整个缓存行(通常为64字节边界对齐的64字节块)进行读写的,由内存接口最有效地处理。这意味着,读或写一个在缓存中找不到的单一字节会导致整个缓存行被复制到它。
这种内存类型适用于视频缓冲器,它有一些特殊性。
对视频缓冲区的写入必须及时传播到处理器之外,以更新视频内容,所以不能使用回写存储器。然而,写入不需要按照程序顺序进行,所以允许处理器在一个中间的内部缓冲区中合并它们,以使用最有效的传输周期将数据发送出去。
内存写入不会导致包含数据的高速缓存行被加载到高速缓存中。如果发生这种情况,缓冲区就会被视频缓冲区的数据填满,大部分是无用的。内存读取也不会被缓存。
处理器不保证WC内存的一致性,因为对它的访问不被窥探,所以有一个时间窗口,其他代理会看到陈旧的内容。同样,这对视频缓冲区来说是有意义的,因为它很少被读取,也不用于存储共享数据结构。如果代码需要确保WC内存是最新的,有指令明确地刷新中间缓冲区。
这种内存类型没有缓存,所以读和写总是传播到接口总线上。它主要适用于内存映射的设备,因为读或写地址会导致与设备的交互。通常,设备对其内存映射寄存器的读写顺序很敏感,也就是说,改变读写的顺序会对设备产生不同的影响。在WB存储器的内存中,处理器并不完全按照程序顺序执行读和写。
对UC内存的访问会降低整个系统的性能,有如下原因
MTRRs寄存器是为特定的物理地址范围指定可能的内存类型。一般来说,Windows对MTRRs进行编程,使所有实际的内存(相对于映射的设备)具有WB类型。
虽然MTRRs为物理地址范围指定了内存类型,但PAT允许在虚拟地址上指定相同的信息。因此,一个给定的VA将有一个由PAT指定的类型,并将被翻译成一个由MTRR指定的、可能不同的物理地址类型。正如我们将看到的那样,最终的类型来自于这两者的结合。
PAT寄存器是一个64位的寄存器,每一项有8位。对应的位被设置时对应了不同的内存类型。
Intel架构定义了比上表还多的内存类型,但是Windows程序只使用了上表的。
虚拟到物理转换的最后一步(PTE或者大页的PDE)通过其PWT、PCD和PAT位选择一个PAT条目,从而为页面选择一个内存类型。这三个位根据下表选择一个PAT条目。
一个给定的虚拟页有一个由选定的PAT条目指定的内存类型,并被映射到一个物理地址,该物理地址有一个由MTRR指定的内存类型。intel文档定义了可能的组合的结果内存类型,对于我们的目的来说,只需要说当MTRR指定回写型时,结果类型是由PAT选择的。
当MTRR指定为回写型时,表1中的UC-类型等同于UC,,表示非缓存型。
当MTRR指定了回写型以外的内存类型时,UC-与UC不同。例如,当MTRR指定合并写类型时(通常用于视频缓冲区),在PAT中选择UC-的最终的类型是WC,而选择UC会得到非缓存型。UC-指定的是未缓存的内存,但可以被MTRR中的WC覆盖。PAT中的UC覆盖了MTRR中的WC。
到目前为止,我们所看到的是物理页映射虚拟页的缓存。存储分页结构的物理页也有一个内存类型,这是以同样的方式确定的。唯一的区别是,指向子分页结构的PxE没有PAT位,所以处理器的行为就像PAT为0一样,并根据PCD和PWT选择一个PAT条目。
值得注意的是PxE的缓存控制位与其他控制位如R/W、U/S和XD之间的区别。缓存控制位控制条目所指向的页面的内存类型。如果该条目不是最底层的,例如是一个PDPTE,这些位指定下一级分页结构的内存类型,在我们的例子中是PD。只有最后一级条目的缓存控制位对转换后的物理页的缓存策略有影响。相反,其他的控制位总是作用于转换的结果。例如,一个R/W清零的PDE将PD映射的整个范围保护为只读,但并没有指定它指向的PT是只读的。
Intel架构定义了系列寄存器来配置处理器如何工作。这些寄存器由一个被称为寄存器地址的编号来识别,并且有具体的指令来对其进行读写。
我们可以使用windbg的rdmsr
命令来获得寄存器地址。例如查看PAT寄存器:
白皮书卷三附录B列出了MSRs的地址。
[注意]APP应用上架合规检测服务,协助应用顺利上架!
最后于 2022-2-25 16:41
被VirtualCC编辑
,原因: