80386学习(五) 80386分页机制与虚拟内存
一. 页式内存管理介绍
80386能够将内存分为不同属性的段,并通过段描述符、段表以及段选择子等机制,通过段基址和段内偏移量计算出线性地址进行访问,这一内存管理方式被称为段式内存管理。
这里要介绍的是另一种内存管理的方式:80386在开启了分页机制后,便能够将物理内存划分为一个个大小相同且连续的物理内存页,访问时通过物理内存页号和页内偏移计算出最终需要访问的线性地址进行访问,由于内存管理单元由段变成了页,因此这一内存管理方式被称为页式内存管理。
80386的分页机制只能在保护模式下开启。
为什么需要页式内存管理?
在介绍80386分页机制前,需要先理解为什么CPU在管理内存时,要在段式内存管理的基础上再引入一种有很大差异的页式内存管理方式?页式内存管理与纯段式内存管理相比到底具有哪些优点?
一个很重要的原因是为了解决多任务环境下,段式内存管理中多任务的创建与终止时会产生较多内存碎片,使得内存空间使用率不高的问题。
内存碎片分为外碎片和内碎片两种。
外碎片
对于指令和数据的访问通常都是连续的,所以需要为一个任务分配连续的内存空间。在段式内存管理中,通常为任务分配一个完整的内存段,或是按照任务内段功能的不同,分配包括代码段、数据段和堆栈段在内的多个完整连续段空间。支持多道任务的系统分配的内存空间,会在某些任务退出并释放内存时,产生外部内存碎片。
举个例子,假设当前存在10MB的内存空间,存在A/B/C/D四个任务,并为每个任务分配一整块的内存空间,其所占用的内存空间分别为3MB/2MB/4MB/1MB,如下图所示(一个格子代表1MB内存)。
当任务B和任务D执行完成后,所占用的内存空间被释放,10MB的内存空间中出现了3MB大小的空闲内存。如果此时出现了一个任务E,需要为其分配3MB的内存空间,此时内存虽然存在3MB的内存空间,却由于空闲内存的不连续,碎片化,导致无法直接分配给任务E使用。而这里任务B、任务D结束后释放的空余内存空间就被视为外碎片。
这里的例子任务数量少且内存空间也很小。而在实际的32位甚至64位的系统中,物理内存空间少则4GB,多则几十甚至上百GB,由于任务内存的反复分配和释放,导致出现的外碎片的数量及浪费的内存空间会很多,很大程度上降低了内存空间的利用率。
虽然理论上能够通过操作系统小心翼翼的挪动内存,使得外碎片能够拼接为连续的大块,得以被有效利用(内存紧缩)。但是操作系统挪动、复制内存本身很占用CPU资源,且存在对指令进行地址重定位、暂时暂停对所挪动内存区域的访问等附加问题,造成的效率降低程度几乎是不可忍受的,因此这一解决方案并没有被广泛使用。
内碎片
外碎片指的是不同任务内存之间的碎片,而内碎片指的是一个任务内产生的内存碎片。
通常操作系统为了管理多任务环境下的物理内存,会将内存分隔为固定大小的分区,使用系统表记录对应分区内存的使用情况(如是否已分配等)。分区的大小必须适当,如果分区过小,则相同物理内存大小下,系统表项过多使得所占用的空间过大;可如果分区过大,则会产生过大的内碎片,造成不必要的内存空间浪费。
以上述介绍外碎片的数据为例,系统中的内存分区固定大小为1MB,其中为任务C分配了4个内存分区,共4MB大小。可实际上任务C实际只需要3.5MB的空间即可满足需求,但由于分区是内存管理的最小单元,只能为任务分配整数个的内存分区。3个分区3MB并不满足任务C的3.5MB的内存需求,因此只能分配4个分区给任务C。而这里任务C额外多占用的0.5MB内存就是内碎片。
内碎片就是已经被分配出去,却不能被有效利用的内存空间。
80386是如何解决内存碎片问题的?
外碎片的解决
外碎片问题产生的主要原因是程序所需要分配的内存空间是连续的。为此,80386提供了分页机制,使得最终分配给任务的物理内存空间可以不连续。如果任务所使用的内存不必连续,前面外碎片例子中提到的任务E就能够在1MB+2MB的离散物理内存上正常运行,外碎片问题自然就得到了解决。
内碎片的解决
内碎片从本质上来说是很难完全避免的(内存管理最小单元不能过小),主要的问题在于前面提到的内存分区管理单元大小的较优值不好确定。开启了分页管理的80386,允许将物理内存分割最小为4KB固定大小的管理单元,这个固定大小的内存管理单元被称为页,并由专门的被称为页表的数据结构来追踪内存页的使用情况。
对于页表项过多的问题,80386的设计者提供了多级页表机制,减少了页表所占用的空间。
对于内碎片过大的问题,由于80386所运行的任务所占用的内存段一般远大于一个内存页的大小,因此页机制下所产生的内部碎片是十分有限的,可以达到一个令人满意的内存使用率。
二. 虚拟内存简单介绍
为了解决应用程序高速增长的内存需求与物理内存增加缓慢的矛盾,计算机科学家们提供了虚拟内存的概念。使用了虚拟内存的系统,可以使得系统内运行的程序所占用的内存空间总量,远大于实际物理内存的容量。
能够实现虚拟内存的关键在于程序在特定时刻所需要访问的内存地址是符合局部性原理的。通过操作系统和硬件的紧密配合,能够将任务暂时不需要访问的内存交换到外部硬盘中,而将物理内存留给真正需要访问的那部分内存(工作集内存)。
虚拟内存和分页机制是一对好搭档,分页机制提供了管理内存的基本单位:页,80386的页式虚拟内存实现在工作集内存调度时也依赖分页机制提供的页来进行。随着程序的执行,程序的工作集内存在动态变化,当CPU检测到当前所访问的内存页不在物理内存中时,便会通知操作系统(内存缺页异常),操作系统的缺页异常处理程序会将硬盘交换区中的对应内存页数据写回物理内存。如果物理内存页已经满了的情况下,则还需要根据某种算法将另一个物理内存页替换,来容纳这一换入的内存页。
三. 80386分页机制原理
在介绍分页机制原理之前,需要先理解关于80386保护模式下32位内存寻址时几种地址的概念。
物理地址(Physical Address):
物理地址就是32位的地址总线所对应的真实的硬件存储空间。对于物理内存的访问,无论中间会经过多少次转换,最终必须转换为最终的物理地址进行访问。
逻辑地址(Logical Address):
在80386保护模式的程序指令中,对内存的访问是由段选择子和段内偏移决定的。段选择子+段内偏移 --> 逻辑地址。
线性地址(Linear Address):
CPU在内存寻址时,从指令中获得段选择子和段内偏移,即逻辑地址。由段选择子在段表(GDT或LDT)中找到对应的段描述符,获取段基址。段基址+段内偏移决定线性地址。
如果没有开启分页,CPU就使用生成的线性地址直接作为最终的物理地址进行访问;如果开启了分页,则还需要通过页表等机制,将线性地址进一步处理才能生成物理地址进行访问。
页式虚拟内存实现原理
程序要求访问一个段时,其线性地址必须是连续的。在纯粹的段式内存管理中,线性地址等于物理地址的情况下,就会出现外碎片的问题。而在段式内存管理的基础上,80386如果还开启了页机制,就能通过抽象出一层线性地址到物理地址的映射,使得最终分配给程序的物理内存段不必连续。
80386中的内存页大小为4KB,在32位的内存寻址空间中(4GB),存在着0x10000 = 1048576个页。每个页对应的起始地址低12位都为0,第一个物理内存页的物理地址为0x00000000,第二个物理内存页的物理地址为0x00001000,依此类推,最后一个物理页的物理地址是0xFFFFF000。
页表
在80386的分页机制的实现中,是通过页表来实现线性地址到物理地址映射转换的。每个任务都有一个自己的页表,记录着任务的线性地址到物理地址的映射关系。
开启了页机制后的线性地址也被称为虚拟地址,这是因为线性地址已经不再直接对应真实的物理地址,而是一个不承载真实数据的虚拟内存地址。开启了分页机制后,一个任务的虚拟地址空间依然是连续的,但所占用的物理地址空间却可以不连续。
页表保存着被称为页表项的数据结构集合,每一个页表项都记载着一个虚拟内存页到物理内存页的映射关系。开启了页机制之后,CPU在内存寻址时,在通过段表计算出了线性地址(虚拟地址)后,便可以在连续排布的虚拟地址空间中找到对应的页表项,通过页表项获取虚拟内存页所对应的物理内存页地址,进行物理内存的访问。虚拟地址到物理地址映射的细节会在后面进行展开。
由于是将不断变化的虚拟内存页装载进相对不变的物理内存页中,就像画廊中展示的画会不断的更替,但画框基本不变一样。为了更好的区分这两者,页通常特指虚拟内存页,而物理内存页则被称为页框。
页表项介绍
页表项是32位的,其结构如下图所示。
P位:
P(Present)位,存在位。标识当前虚拟内存页是否存在于物理内存页中。当P位为1时,表示当前虚拟内存页存在于物理内存中,可以直接进行访问。当P位为0时,表示对应的物理内存页不存在,需要新分配物理内存页或是从磁盘中将其调度回物理内存。
分页模式下的内存寻址,如果CPU发现对应的页表项P位为0,会引发缺页异常中断,操作系统在缺页异常处理程序中进行对应的处理,以实现虚拟内存。
RW位:
RW(Read/Write)位,读写位。标识当前页是否能够写入。当RW为1时,代表当前页可读可写;当RW为0时,代表当前页是只读的。
US位:
US(User/Supervisor)位,用户/管理位。当US为1时,标识当前页是用户级别的,允许所有当前特权级的任务进行访问。当US为0时,表示当前页是属于管理员级别的,只允许当前特权级为0、1、2的任务进行访问,而当前特权级为3的用户态任务无法进行访问。
PWT位/PCD位:
PWT(Page-level Write Through)位,页级通写位。PWT为1时,表示当前物理页的高速缓存采用通写法;PWT为0时,表示当前物理页的高速缓存采用回写法。
PCD(Page-level Cache Disable)位,页级高速缓存禁止位。PCD为1时,表示访问当前物理页禁用高速缓存;PCD为0时,表示访问当前物理页时允许使用高速缓存。
PWT与PCD位的使用,涉及到了80386高速缓存的工作原理与内存一致性问题,限于篇幅不在这里展开。
A位:
A(Access)位,访问位。A位为1时,代表当前页曾经被访问过;A位为0时,代表当前页没有被访问过。
A位的设置由CPU固件在对应内存页访问时自动设置为1,且可以由操作系统在适当的时候通过程序指令重置为0,用以计算内存页的访问频率。通过访问频率,操作系统能够以此作为虚拟内存调度算法中评估的依据,在物理内存紧张的情况下,可以选择将最少使用的内存页换出,以减少不必要的虚拟内存页调度时的磁盘I/O,提高虚拟内存的效率。
D位:
D(Dirty)位,脏位。当D位为1时,表示当前页被写入修改过;D位为0时,代表当前页没有被写入修改过。
脏位由CPU在对应内存页被写入时自动设置为1。操作系统在进行内存页调度时,如果发现需要被换出的内存页D位为1时,则需要将对应物理内存页数据写回虚拟页对应的磁盘交换区,保证磁盘/内存数据的一致性;当发现需要被换出的物理内存页的D位为0时,表示当前页自从换入物理内存以来没有被修改过,和磁盘交换区中的数据一致,便直接将其覆盖,而不进行磁盘的写回,减少不必要的I/O以提高效率。
PAT位:
PAT(Page Attribute Table),页属性表支持位。PAT位的存在使得CPU能够支持更复杂的,不同页大小的分页管理。当PAT=0时,每一页的大小为4KB;当PAT=1时,每一页的大小是4MB,或是其它大小(分CPU的情况而定)。
G位:
G(Global),全局位。表示当前页是否是全局的,而不是属于某一特定任务的。G=1时,表示当前页是全局的;G=0时,表示当前页是属于特定任务的。
为了加速页表项的访问,80386提供了TLB快表,作为页表访问的高速缓存。当任务切换时,TLB内所有G=0的非全局页将会被清除,G=1的全局页将会被保留。将操作系统内核中关键的,频繁访问的页设置为全局页,使得其能够一直保存在TLB快表中,加速对其的访问速度,提高效率。
AVL位:
AVL(Avaliable),可用位。和段描述符中的AVL位功能类似,CPU并不使用它,而是提供给操作系统软件自定义使用。
页物理基地址字段:
页物理基地址字段用于标识对应的物理页,共20位。
由于32位的80386的页最小是4KB,而4GB的物理内存被分解为了最多0x10000个4KB的物理页。20位的页物理基地址字段作为物理页的索引标号与每一个具体的物理页一一对应。通过页物理基地址字段,便能找到唯一对应的物理内存页。
多级页表
在32位的CPU中,操作系统可以给每个程序分配至多4GB的虚拟内存空间,如果一个内存页占4KB,那么对应的每个程序的页表中最多需要存放着0x10000个页表项来进行映射。即使每个页表项只占小小的32位共4个字节(4Byte),这依然是一个不小的内存开销(0x10000个页表项的大小为4MB)。
一个应用程序虽然可以被分配4GB的虚拟内存空间,但实际上可能只使用其中的一小部分,例如40MB的大小。通常程序的堆栈段和数据段都分别位于虚拟内存空间的高低两端,并随着程序的执行慢慢的向中间扩展,由于页表项对应与虚拟地址空间的连续性,这就要求任务在执行时必须完整的定义整张页表。
可以看到,一级的平面页表结构存在着明显的页表空间浪费的问题。虽然可以要求应用程序不要一下子就以4GB的内存规格进行编程,而是一开始用较小的内存,并在需要更大内存时梯度的申请更大的内存空间,并重新构造数据段和堆栈段以减少每个任务的无用页表项空间的浪费。但这将页表空间优化的繁重任务强加给了应用程序,并不是一个好的解决办法。
为此,计算机科学家们提出了多级页表的方案来解决页表项过多的问题。多级页表顾名思义,页表的结构不再是一个一级的平面结构(一级页表),而是像一颗树一样,由页目录项节点和页表项节点组成。目录节点中保存着下一级节点的物理页地址等信息,叶子节点中则包含着真正的页表项信息。查询页表项时,从一级页目录节点(根目录)出发,按照一定的规则可以找到对应的下一级子目录节点,直到查询出对应的叶子节点为止。
80386页目录项介绍
80386采用的是二级页表的设计,二级页表由页目录表和页表共同组成。页目录表中存放的是页目录项,页目录项的大小和页表项一致,为4字节。
通过80386指令得到的32位线性地址,其中高20位作为页表项索引,低12位作为页内偏移地址(4KB大小的物理页)。如果采用的是一级页表结构,20位的页表项索引能直接找到4MB页表中的对应页表项。
而对于80386二级页表的设计来说,由于一个物理页大小为4KB,最多可以容纳1024(2^10)个页表项或者页目录项,所以将页表项索引的高10位作为根目录页中页目录项的索引值,通过页目录项中的页表项物理页号可以找到对应的页表物理页;再根据页表项索引的后10位找到页表中对应的页表项。
80386页目录项结构图
80386的二级页表的页目录项占32位,其低12位的含义与页表项一致。主要区别在于其高20位存放的是下一级页表的物理页索引,而不是虚拟地址映射的物理内存页地址。
页表基址寄存器
前面提到过,和LDT一样,每个任务都拥有着自己独立的页表。为此80386CPU提供了一个专门的寄存器用于追踪定位任务自己的页表,这个寄存器的名称叫做页表基址寄存器(Page Directory Base Register,PDBR),也就是控制寄存器CR3。
由于80386分页机制使用的是二级页表,因此PDBR指向的是二级页表结构中的页目录,通过页目录表便能够间接的访问整个二级页表。为了效率其中存放的直接就是页目录表的32位物理地址,一般由操作系统负责在任务切换时将新任务对应的页目录表预先加载进物理内存。
由于PDBR是和当前任务有关的,在任务切换时会被新任务TSS中的PDBR字段值所替换,指向新任务的页目录表,而旧任务的PDBR的值则在保护现场时被存入对应的TSS中。
多级页表是如何解决页表项浪费问题的?
以80386的二级页表设计为例,最大4GB的虚拟内存空间下,无论如何一级页目录表是必须存在的。当不需要为应用程序分配过多的内存时,页目录表中的页目录项所指向的对应页表可以不存在,即页目录项的P位为0,实际不使用的虚拟内存空间将没有对应的二级页表节点,相比一级页表的设计其浪费的内存会少很多。
假设需要为一个虚拟地址首尾各需要分配20MB,共占用40MB内存的任务构建对应的页表。
1. 如果使用一级页表,4GB的虚拟内存空间下需要提供0x10000个页表项,共4MB,页表的体积达到了任务自身所需40MB内存的10%,但其中绝大多数的页表项都是没用的(P位为0),不会对应实际的物理内存,空间效率很低。
2. 如果使用二级页表,除了占一个物理页4KB大小的页目录表是必须存在的外,其页目录表中只有首尾两项的P位为1,分别指向一个实际存在的页表(二级节点),页目录表中间其它的页目录项P位都为0,不需要为这些不会使用到的虚拟地址分配页表。对于这个40MB的程序来说,其页表只占了3个物理页面,共12KB,空间效率相比一级页表高很多。
TLB快表
前面提到了多级页表所带来的好处:通过页表分层,可以减少顺序排列的无效页表项数量,节约内存空间;页表的层级越多,空间效率也越高。
计算机领域中,通常并没有免费的午餐,一个问题的解决,往往会带来新的问题:多级页表本质上是一个树状结构,每一个节点页都是离散的,因此每一层级访问都需要进行一次内存寻址操作,页表的层级越多,访问的次数也就越多,虚拟页地址映射过程也越慢。在32位的80386中,2级页表下问题还不算特别严重;但64位CPU的出现带来了更大的寻址空间,也需要更多的页表项,页表的层级也渐渐的从2级变成了3级、4级甚至更多。页机制开启之后,所有的内存寻址都需要经过CPU的页部件进行转化才能获得最终的物理地址,因此这一过程必须要快,不能因为页表的离散层次访问就严重影响虚拟地址空间到物理地址空间的转换速度。
要加快原本相对耗时的查询操作,一个常用的办法便是引入缓存。为了加速通用内存的访问,80386利用局部性原理提供了高速缓存;为了加速多级页表的页表项访问,80386提供了TLB。
TLB(Translation Lookaside Buffer)直译为地址转换后援缓冲器,根据其作用也被称为页表缓存或是快表(快速页表)。TLB中存放着一张表,其中的每一项用于缓存当前任务虚拟页号和对应页表项中的关键信息,被称为TLB项。
TLB的工作原理和高速缓存类似:当CPU访问某一虚拟页时,通过虚拟页号先在TLB中寻找,如果发现对应的TLB项存在,则直接以TLB项中的数据进行物理地址的转换,这被称为TLB命中;当发现对应的TLB项不存在时(TLB未命中),则进行内存的访问,在获取内存中页表项数据的同时,也将对应页表项缓存入TLB中。如果TLB已满则需要通过某种置换算法选出一个已存在的TLB项将其替换。
TLB的查询速度比内存快,但容量相对内存小很多,因此只能缓存数量有限的页表项。但由于内存访问的局部性,只要通过合理的设计提高TLB的命中率(通常可以达到90%以上),就能达到很好的效果。
四. 80386分页机制下的内存寻址流程
下面总结一下开启了分页机制的80386是如何进行内存寻址的。
1. CPU首先从内存访问指令中获取段选择子和段内偏移地址
2. 根据段选择子从段表(GDT或LDT)中查询出对应的段描述符
3. 根据段描述符中的段基址和指令中的段内偏移地址生成32位的线性地址(页机制下的虚拟地址)
4. 32位的线性地址根据80386二级页表的设计,拆分成三个部分:高10位作为页目录项索引,中间次高10位作为页表项索引,低12位作为页内偏移地址。
5. 通过高10位的页目录项索引从一级页目录表中获取二级页表的物理页地址(通过物理页框号可得),再根据中间10位的页表项索引找到对应的物理页框。根据物理页框号与页内偏移地址共同生成最终的物理地址,进行物理内存的访问。
五. 总结
想要通过学习操作系统来更好的理解计算机程序底层的工作原理,基础的硬件知识是必须要了解的。纸上得来终觉浅,绝知此事要躬行,在理解了基础原理后,还需要通过实践来加深对原理知识的理解,而阅读相关操作系统的实现源码就是一个很好的将实践与原理紧密结合的学习方式。
希望通过对硬件和操作系统的学习能帮助我打开计算机程序底层运行的神秘黑盒子一窥究竟,在思考问题时能够换一个角度从底层的视角出发,去更好的理解和掌握上层的应用技术,以避免迷失在快速发展的技术浪潮中。