本文参考:为什么要分页 ,《操作系统真相还原》《x86汇编:从实模式到保护模式》《操作系统之哲学原理》《装载、链接与库》
本节对应代码:开启分页-代码详解

为什么分页?

分页机制 最早 是为了解决内存碎片利用的问题。举例如下:

在图 1 中,内存被完美利用,还多出 15MB 可用内存。来到图 2,进程 B 运行完毕,从内存中移除,则原来的 20MB 内存变为空闲,则现在一共有 35MB 可用内存。而后进程 D 想要运行,但其需要的内存大小为 20MB+3KB,没有空闲内存段装得下。没办法,即使一共有 35MB 可用内存,由于必须要连续的空闲内存 ,进程 D 就只有等待进程 A 或 进程 C 加载完毕,腾出空间后才能载入内存运行。

显然,等待是不能等待的,谁知道这些进程什么时候运行完呢。细心的你可能会发现,以上问题并不在于内存不够,而在于无法利用“断开”的内存 。当运行许多进程后,可用内存就会参差不齐,形成大量内存碎片,虽然总可用内存数量很客观,但却完全无法利用。为了解决这个问题,便提出了分页的概念。分页和虚拟内存是两个密不可分的概念 。接下来让我们看看这两个概念是如何解决以上问题的。

聚焦问题本质,其根本在于程序必须连续地占用内存 ,你不能将某个程序的一部分代码放在 20MB 处,另一部分放在 15MB 处。那怎么办呢?接下来便是 抽象 大展身手的时候了(抽象贯穿了整个计算机体系):我们让程序运行在虚拟地址空间中,使它以为自己运行在连续的线性地址上,而幕后我们将虚拟地址空间映射到实际物理内存中,实际上它仍运行在物理内存中(这有点废话) ,这样就欺骗了程序,目的达成。哈哈,可能你还有点迷糊,那么请看下图:

通过这种方式,我们就能充分利用内存碎片了。而这只是引入分页机制最初的目的,现在它还具备以下几个重要功能:

  • 控制内存访问权限

  • 方便内存和硬盘的交换

    开启分页后,将以页为单位进行内存和硬盘的交换。某一程序不常用的页会被清出内存,存入硬盘,这也增加了内存的利用率。

  • 分页是平坦模型的基础

    32 位 4G 平坦内存模型下,段的意义不大;64 位模式下段已经完全失去意义。

这几个功能的具体实现后面我们将会说到。

分页实现机制

现在我们知道,开启分页后,程序都运行在虚拟地址空间中,而虚拟地址则被映射到物理内存。那么,总应该找个地方来存放这种映射关系吧?是的,存放这种映射关系的地方就是页表。

一级页表
页的标准大小是 4KB,即 2^{12} 字节;当页数量为 2^{20} 时,覆盖的内存空间就为 2^{32} 字节,即 4GB 。所以在一级页表模型中,一张页表有 2^{20} 个页表项,每个页表项对应着一个页:

页表也是存放在内存中的,页表项大小为 4 字节,所以这样一张页表的大小为 4MB 。现在问题是,线性地址(开启分页后,也叫虚拟地址)如何转化为物理地址呢?很简单,将 32 位线性地址的高 20 位作为页表索引,用来寻找页表项;将低 12 位作为页内偏移。比如虚拟地址为 0x92f11f23 ,则 0x92f11 为索引,对应页表中第 0x92f11 个页表项,假设该页表项中装的 物理页框0x20001 ,那么该物理页框加上低 12 位 0xf23 ,最终得到物理地址 0x20001f23 。这个计算过程由页部件自动实现。

物理页框即为内存中某页的起始地址。物理页框一定是 4K 的整数倍 ,即最后 12 位一定为 0 (二进制下)。

两级页表
两级页表在一级页表的基础上增添了页目录表( 在两级页表结构中,页目录表称为一级页表,页表称为二级页表 ):

1024 个目录项对应 1024 张页表,每张页表有 1024 个页表项,每个页表项对应 4KB 的物理内存,即 1024×1024×4KB=4GB ,仍覆盖了 32 位下的整个内存空间。二级页表下的映射方式为:将虚拟地址的高 10 位作为目录表索引,中间 10 位作为页表索引,低 12 位作为页内偏移 。比如虚拟地址 0x1f25e9a2 ,其高 10 位为 124,即访问第 124 个目录项(起始为第0个目录项),假设该目录项指向 A 页表;中间 10 位为 1136,则访问 A 页表中的第 1136 个页表项,假设该页表项中的物理页框为 0xff120000 ;最后 12 位为 0x9a2 ,作为页内偏移地址;故最终该虚拟地址映射到的物理地址为 ff1209a2

页表项和页目录项结构

  • 页目录项和页表项中的地址只有 31~12 共计 20 位,这是因为其中装载的都是物理页地址,而标准页大小为 4KB,故地址都是 4KB 的倍数,即低 12 为一定为 0,所以不再花多余空间记录。

  • 标志位:

    • AVL :软件/操作系统使用该位,CPU 不使用该位。

    • G :Global,全局位。表示当前页是否是全局的,而不是属于某一特定任务的。1 表示为全局页,0 则表示非全局页。该位 TLB 相关,详见文末。

    • PAT :页属性表支持位。PAT位使 CPU 能够支持不同页大小的分页管理。当 PAT=0 时,每一页的大小为 4KB;当 PAT=1 时,每一页的大小是 4MB,或是其它大小。该位只存在于页表项。

    • D :Dirty,脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 1 。此项仅对页表项有效,不会修改目录项的 D 位。

      操作系统在进行内存页调度时,如果发现需要被换出的内存页 D 位为 1 时,则需要将对应物理内存页数据写回虚拟页对应的磁盘交换区,保证磁盘/内存数据的一致性;当发现需要被换出的物理内存页的 D 位为 0 时,表示当前页自从换入物理内存以来没有被修改过,和磁盘交换区中的数据一致,便直接将其覆盖,而不进行磁盘的写回 ,减少不必要的 I/O 以提高效率。

    • A :Accessed,访问位。和段描述符中的 A 位和 P 位相同,这两位结合能够实现虚拟内存管理,参见段描述符详解

    • PCD :页级高速缓存禁止位。PCD 为 1 时,表示访问当前物理页禁用高速缓存;PCD 为 0 时,表示访问当前物理页时允许使用高速缓存。

    • PWT :页级通写位。PWT 为 1 时,表示当前物理页的高速缓存采用通写法;PWT 为 0 时,表示当前物理页的高速缓存采用回写法。

      PWT与PCD位的使用,涉及到了80386高速缓存的工作原理与内存一致性问题,笔者暂不清楚。

    • US :User/Supervisor,用户/管理位。当 US 为 1 时,标识当前页是用户级别的,允许所有当前特权级的任务进行访问。当 US 为 0 时,表示当前页是属于管理员级别的,只允许当前特权级为0、1、2的任务进行访问,而当前特权级为 3 的用户态任务无法进行访问。

    • RW :Read/Write,读写位。标识当前页是否能够写入。当 RW 为 1 时,代表当前页可读可写;当 RW 为 0 时,代表当前页是只读的。

    • P :present,存在位。标识当前虚拟内存页是否存在于物理内存页中。当 P 位为 1 时,表示当前虚拟内存页存在于物理内存中,可以直接进行访问。当 P 位为 0 时,表示对应的物理内存页不存在,需要新分配物理内存页或是从磁盘中将其调度回物理内存。该位和 A 位共同实现虚拟内存管理

两级页表的优越性

两级页表比一级页表优越在哪?首先我们来看一级页表的缺点:

  • 一级页表的所有表项必须连续存放 (因为你只能通过索引访问,而不是通过地址访问表项),这需要很大一片连续的空间(4MB)。

  • 一级页表必须完整 。然而进程在一段时间内只会访问几张页表,因此没必要让所有页表项常驻内存。当进程很多时,页表占用的内存将会非常可观!

    为什么一级页表必须完整?因为一级页表承担的职责是将虚拟地址翻译成物理地址,如果虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。

对比之下,我们来看两级页表的优点:

  • 由于页目录项中装载的是页表的物理起始地址(物理页框),而不是索引,所以页表可以不连续存放 (但页表项还是要连续存放)。这也许能使我们利用一些零散的空间。

    需要注意,由于页目录项中只能容纳页表地址的 31~12 位,所以页表必须以 4K 对齐!也就是说页表的起始地址必须是物理页框。

  • 页表可以不存在页目录表覆盖到了全部虚拟地址空间 ,所以页表就可以在需要时创建。相对于一级页表结构(4MB),这大大节省了内存空间。

    顶级页表(页目录表)必须完全创建,且常驻内存,即必须涵盖全部虚拟地址空间 。这里提前说一下,小伙伴们阅读下节内容开启分页-代码详解后会发现实际上我们并没有完全创建页目录项(只创建了第0、768~1022、1023号页目录项),这是为什么呢?注意,“页目录表必须完全创建”指的是必须为页目录表预留 4KB 内存 ,且须将此片内存初始化为 0 ;至于是否安装全部页目录项,可按需进行(一般不会一次性安装全部目录项) 。为什么需要将页目录表初始化为 0 ,这将在内存管理-进阶-分配页内存中详细阐述。
    值得说明的是,在 i386 的 Linux 下,创建虚拟内存空间只是分配一个页目录表就行了,甚至不需要创建页映射关系,这些映射关系会等到后面程序发生缺页错误时再进行映射。

  • 页表可以不在内存中 。由于程序的局部性原理,一段时间内只会访问少量页表,所以可以将其他页表放入硬盘,并标记相应的页目录项(标记 P 位),需要的时候再从硬盘调入内存。

    如果对应的页不在内存中,CPU 将发出缺页中断,由缺页中断程序将所缺页调入内存。那么缺页中断程序如何知道虚拟页面在磁盘中的哪个地方呢?它并不知道。但它知道产生缺页中断进程所对应的源程序文件名和产生缺页中断的虚拟地址,中断程序会根据虚拟地址计算该地址在对应程序文件中的偏移量,然后要求文件系统在该文件此偏移量处进行文件读取,一般会读取多个页。详细内容后续会进行说明。

另外需要说明的是,现代操作系统大多都采用 3~5 级页表了。

开启分页

通过将 CR0 寄存器的 PG(第 31 位) 置 1 开启分页机制

mov eax, cr0                 
or  eax, 0x80000000
mov cr0, eax

但一般而言,在此之前,需要将页目录表的起始地址赋给 CR3 寄存器。CR3 结构如下:

其中 PCD 和 PWT 用户设置高速缓存的相关特性,在此置 0 即可。所以,可以直接用以下方式赋值:

mov eax, PAGE_DIR_POS   ;把页目录地址(0x00100000)赋给cr3
mov cr3, eax

注意,CR3 中装载的是物理地址,而非虚拟地址!

几类地址的关系

  • 物理地址:就是真实的内存地址,不必多说。
  • 逻辑地址:不论在实模式还是保护模式,都指段内偏移地址。
  • 有效地址:和逻辑地址相同。
  • 线性地址:在保护模式下,未开启分页时,线性地址就是物理地址;开启分页后,线性地址又叫虚拟地址。
  • 虚拟地址:用来描述任务或进程的地址空间,每个进程都有 4GB 虚拟地址空间! 开启分页后,进入虚拟地址空间。

TLB

我们已经知道,虚拟地址转化成物理地址需要访问多次内存,以上还只是两级页表,如果换成五级页表,那么转换效率将下降得非常明显!那有没有办法解决呢?有的,仍然利用程序的局部性原理,如果一个页面被访问,该页面中的其他地址很有可能随后就被访问,这样我们就能将该页面的翻译结果放入缓存,后面访问该页内中的地址时直接在缓存中取得相应页框,而无须每次访问该页面中的地址时都翻译一次,这样就能大大提高效率。

该缓存就是 TLB(Translation Lookaside Buffer) ,又称为快表 。快表的结构如下:

处理器会在寻址前用虚拟地址的高 20 位来匹配 TLB 中每个项的虚拟页框号,如果匹配成功(命中),则返回对应的物理页框号如果不中,则按原方式寻找物理页框,获得物理页框后再更新 TLB

需要注意的是,这种匹配方式并不是挨个比较,想一想,如果按顺序挨个比较的话,只要 TLB 中的表项稍多,那么搜索 TLB 的时间就可能多于查找多级表所需要的时间了,这样 TLB 就失去了意义。所以,比较时并不是按顺序比较,而是与所有表项同时比较!这种离谱的操作需要特殊的电路,这也就是 TLB 如此昂贵的原因。

另外,不同于其他普通缓存,TLB 涉及到内存访问(取指令,取数据),如果不能时刻保证其中地址的有效性,那么程序将必然出错!这么说,TLB 就需要时刻更新。可是若实时读取内存中的页表去更新 TLB 的话,这又回到了内存查找映射的老路,TLB 又失去了意义。因此,TLB 并不自动更新,处理器也不负责 TLB 的有效性,它将 TLB 的维护工作交给操作系统开发人员 ,毕竟是由操作系统开发人员负责的页表维护,他们肯定知道何时修改了哪些页表或条目。TLB 对开发人员不可见,但有两种以下方式可以间接更新 TLB:

  1. 重新加载 CR3 。将 CR3 读出来再重新写入,这会使整个 TLB 失效。

  2. 使用指令 invlpg 。该指令用来刷新 TLB 中某个虚拟页框对应的条目,所以操作数也是虚拟地址:invlpg [m]

    未来我们在编写内存管理代码时会用到该指令。

内核与用户的关系

不同于实模式,我们现在实现的是多任务调度系统,多个任务能够同时运行。进程可以有很多个,但操作系统,或者说内核,只有一个,因此内核必须共享给所有用户进程 。进而,我们需要设计内存布局,以达到所有进程共享内核的目的,这需要通过规划页表来实现

保护模式下,用户进程以低特权级身份运行,内核则以高特权级运行,当用户进程要访问硬件资源时,需要向操作系统申请,由操作系统代办,然后将结果返回给用户。换句话说,一个完整的程序分为用户代码(私有部分)和操作系统代码(全局部分) ,两者相互配合才能完成任务。

特权级相关内容后面会详细说到,耐不住的同学请移步特权级剖析

前面我们说过,虚拟地址空间是用来描述任务或进程的,每个进程都有 4GB 的虚拟地址 。为了实现内核共享,我们将虚拟地址空间分为两个部分:一部分划给内核,占高 1 GB;另一部分划给用户进程,占低 3 GB 。进一步说,我们会将所有用户的 3~4GB 虚拟地址空间指向同一个操作系统,也就是所有进程的 3~4GB 虚拟地址都指向同一片物理页。具体方式和代码请参考开启分页-代码详解

文章作者: 极简
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
自制操作系统
喜欢就支持一下吧