操作系统开发:启用内存分页机制
目前我们已进入保护模式,但依然会受到限制,虽然地址空间达到了4GB,但此空间是包括操作系统共享的4GB空间,我们把段基址+段内偏移地址称为线性地址,线性地址是唯一的,只属于某一个进程。在我们机器上即使只有512MB的内存,每个进程自己的内存空间也是4GB,这是指的虚拟内存空间。一直以来我们都是在内存分段机制下工作的,该模式下如果系统里面的应用程序过多,或者内存碎片过多无法容纳新的进程,则可能会出现进程需要等待,或无法直接运行的局面,而内存分页机制,理论上只要4KB内存就可以让程序运行下去。
注释: 该系列笔记是在学习《操作系统真相还原》时通过阅读后简化并适当描述整理的学习笔记,首先,致敬作者郑刚博士,在读本书时能深刻的感觉到作者写书时一丝不苟的态度,书很厚写的,讲解细致幽默,很能让人愿意继续读下去,同时也不得不佩服作者计算机底层功力的深厚,转载本文请一并附带郑刚版权信息。
目前我们已进入保护模式,但依然会受到限制,虽然地址空间达到了4GB,但此空间是包括操作系统共享的4GB空间,我们把段基址+段内偏移地址称为线性地址,线性地址是唯一的,只属于某一个进程。
在我们机器上即使只有512MB的内存,每个进程自己的内存空间也是4GB,这是指的虚拟内存空间。
为什么要分页,分段它不香吗?
一直以来我们都是在内存分段机制下工作的,该模式下如果系统里面的应用程序过多,或者内存碎片过多无法容纳新的进程,则可能会出现进程需要等待,或无法直接运行的局面,而内存分页机制,理论上只要4KB内存就可以让程序运行下去。
-
在分段模式下
-
CPU认为线性地址等于物理地址,而线性地址是由编译器编译出来的,它本身是连续的,所以要想运行程序,物理地址也必须要连续才行,但在多数情况下物理地址并不是连续的。
-
在分页模式下
-
线性地址可以连续而物理地址是分散的,我们通过某种映射关系来解除线性地址与物理地址的一一对应,然后通过映射机制将他们重新关联起来,把线性地址关联到任意的物理地址上面,理论上只要4KB内存就可以运行程序了。
对于分页机制来说,CPU在硬件层面提供了支持,在CPU实现中,这种映射关系是通过页表来实现的,由于地址转换的实时性较高,所以查找页表的功能也是被CPU硬件支持的。
什么是一级页表,它的作用是啥?
在保护模式中段寄存器中的内容是段选择子
,选择子的最终目的就是为了找到段基址,其内存访问的核心机制依然是,段基址:段内偏移地址
,这两个地址相加后才是绝对地址(线性地址)
,此地址在分段机制下被CPU认为是物理地址可以直接被送上总线,该计算过程也是由段部件自动完成。
分页机制依然要建立在分段机制的基础之上,段部件依然需要工作,而分页只能在分段机制之后进行。
CPU在不打开分页机制的情况下,是按照分段方式工作的,将段地址和段内偏移地址经过段部件处理后所输出的线性地址,CPU就认为是物理地址,
而如果打开了分页,段部件输出的线性地址会变成虚拟地址,虚拟地址不等同于物理地址。
而CPU必须要拿到物理地址才行,此虚拟地址对应的物理地址需要在页表中查找,该工作由页部件自动完成。
- 分页机制的思想:
- 通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续。
- 分页作用,将线性地址转换成物理地址,用大小相等的页(页部件)代替大小不相等的段(段部件)。
通常情况下,经过段部件输出的线性地址也可以叫做虚拟地址,所谓的转换是指,从线性地址空间(段)到虚拟地址空间(页)再到物理地址空间(实际),如下图:
前面说过,分页机制建立在分段机制之上,即使在分页机制下的进程也要先经过逻辑上的分段,代码段和数据段在逻辑上被拆分成为以页为单位的小内存块,此时的虚拟地址不能存放任何数据。
接着操作系统开始为这些虚拟内存页分配真实的物理内存页,它查找物理内存中的可用页,然后在页表中登记这些物理页地址,此时就完成了虚拟页到物理页的映射,每个进程都以为自己独享4GB地址空间。
用于存储这种映射关系的表,就是页表(PT)
,页表中每一行(1个单元格)称为页表项(PTE)
,其大小是4字节,页表项的作用是存储内存物理地址,当访问一个线性地址时,实际上就是在访问页表项中所记录的物理内存地址。
一个页可被分为高低位吗?
CPU采用一个页的大小是4KB,32位地址表示4GB空间,可将32为地址分成高低两部分,低地址是内存块大小,高地址是内存块数量,故内存块数*内存块大小=4GB
,页是地址空间的计量单位,只要是4KB的地址空间都可以称为一页,所以线性地址空间也要对应物理地址空间的一页。
一页大小是4KB(页大小是4KB所以页表项中的物理地址都是4K的整数倍),这样一来,4GB地址空间被划分成4GB/4KB=1MB
个页,也就是4GB空间中可容纳1048576
个页,页表中自然也要有1048576
个页表项,这就是一级页表。
任意一个地址最终都会落到某个实际的物理页上,32位地址空间共有1MB个物理页.
首先定位到某个具体物理页,然后给出物理页内的偏移量就可以访问到任意1字节的内存,虚拟地址的高20位用来定位具体的物理页,低12位则用来在该物理页内寻址。
一级分页中的地址转换过程是怎样的?
页表是位于内存中的,在打开分页前需要将页表物理地址加载到CR3寄存器内,只要提供页表物理地址,便能够访问到页表中任意一个页表项地址。
一个页表项对应一个页,所以,用线性地址的高20位作为页表项的索引,每个页表项要占用 4 字节大小,所以这高20位的索引乘以4后才是该页表项相对于页表物理地址的字节偏移量。用 cr3 寄存器中的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。CPU 中集成了专门用来干这项工作的硬件模块,我们把该模块称为页部件。
- 总结一下页部件的工作
- 用线性地址的高 20 位在页表中索引页表项,用线性地址的低 12 位与页表项中的物理地址相加,所求的和便是最终线性地址对应的物理地址。
什么是二级页表,它的作用是啥?
一级页表与二级页表原理一致,但在现代操作系统中一般都采用二级页表结构,两种页表结构区别如下:
- 1.一级页表中最多可容纳 1M(1048576)个页表项,每个页表项是 4 字节,如果页表项全满的话,便是 4MB 大小。
- 2.一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB,用户进程要占用低 3GB。
- 3.每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
- 4.不要一次性地将全部页表项建好,需要时动态创建页表项。
无论是几级页表,标准页的尺寸都是 4KB,所以 4GB 线性地址空间最多有 1M 个标准页。一级页表是将这 1M 个标准页放置到一张页表中,二级页表是将这 1M 个标准页平均放置 1K 个页表中,每个页表中包含有 1K 个页表项。页表项是 4 字节大小,页表包含 1K 个页表项,故页表大小为4KB,这恰恰是一个标准页的大小。
页目录表来存储这些页表,每个页表的物理地址在页目录表中都以页目录项(Page Directory Entry, PDE)的形式存储,页目录项大小同页表项一样,都用来描述一个物理页的物理地址,其大小都是 4 字节,而且最多有 1024 个页表,所以页目录表也是 4KB 大小,同样也是标准页的大小。
页目录表中共 1024 个页目录项,一个页目录项中记录一个页表物理页地址,页大小都是 0x1000,即 4096,每个页表中有 1024 个页表项,每个页表项中是一个物理页地址,最终数据写在这页表项中指定的物理页中。
二级页表是如何工作的?
二级页表地址转换原理是将 32 位虚拟地址拆分成高 10 位、中间 10 位、低 12 位三部分,
- 高 10 位作为页表的索引,用于在页目录表(PT)中定位一个页目录项(PDE),页目录项中有页表物理地址,也就是定位到了某个页表。
- 中间 10 位作为物理页的索引,用于在页表内定位到某个页表项(PTE),页表项中有分配的物理页地址,也就是定位到了某个物理页。
- 低 12 位作为页内偏移量用于在已经定位到的物理页内寻址。
访问任何页表内的数据都要通过物理地址,由于页目录项 PDE 和页表项 PTE 都是 4 字节大小,给出了 PDE 和 PTE 索引后,还需要乘以 4,再加上页表物理地址,这才是最终要访问的绝对物理地址。
- 具体寻址流程如下所示:
- 1.用虚拟地址的高 10 位乘以 4,作为页目录表内的偏移地址,加上页目录表的物理地址(CR3内的基地址),所得的和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
- 2.用虚拟地址的中间 10 位乘以 4,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
- 3.虚拟地址的高 10 位和中间 10 位分别是 PDE 和 PTE 的索引值,所以它们需要乘以 4,而低 12 位作为页内偏移直接与第2步物理页地址相加,即可得到实际内存物理地址。
在建立页表时,会在页目录项(PDE)及页表项(PTE)中写入合适的值,每个任务都有自己的页表,每个任务都活在自己的虚拟地址空间中,另外,任务在切换时,页表也需要跟着切换。
页目录项(PDE)及页表项(PTE)具体结构解析?
页目录项(PDE)及页表项(PTE)结构对比图:
如上图,页目录项跟页表项其大小都是4字节,只有后20位(12-31)是物理地址,其余的12位(0-11)用于增加其他一些属性,如下是属性具体功能:
-
P位(Present)-存在位:
-
为 1 表示该页存在于物理内存中,若为 0 表示该表不在物理内存中。
-
RW位(Read/Write)-读写位:
-
若为 1 表示可读可写,若为 0 表示可读不可写。
-
US位(User/Supervisor)- 普通用户/超级用户位:
-
为 1 表示处于User级,任意级别特权的程序都可以访问该页。若为0处于Supervisor级,特权级别为3的程序不允许访问该页,该页只允许特权级别为0、1、2的程序可以访问。
-
PWT位(Page-level Write-Through)页级通写位、页级写透位:
-
为1表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。
-
PCD位(Page-level Cache Disable)页级高速缓存禁止位:
-
为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存。
-
A位(Accessed)访问位:
-
为 1 表示该页被 CPU 访问过,所以该位是由 CPU 设置的。
-
D位(Dirty)脏页位:
-
CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 1 此项仅针对页表项有效,并不会修改页目录项中的 D 位
-
PAT位(Page Attribute Table)页属性表位:
-
能够在页面一级的粒度上设置内存属性
-
G位(Global)全局位:
-
为了提高获取物理地址的速度,将虚拟地址与物理地址转换结果存储在 TLB,TLB 是用来缓存地址转换结果的高速缓存,此 G 位用来指定该页是否为全局页,为 1 表示是全局页,为 0 表示不是全局页。
-
AVL位(Available)操作系统可用位
-
表示软件可用,CPU不理会该位的值。
启用内存分页时需要做2件事
第一步:准备好页目录表及页表,将页表地址写入控制寄存器CR3
页表需要存储在内存的物理地址内,需要时通常会被载入到CR3寄存器内,CR3寄存器又称为页目录基址寄存器(PDBR),该寄存器的低12位(0-11)全是0,低12位除第3位PWT和第4位PCD位以外(用于设置高速缓存相关的特性,在此将其置为0),其余位都无用,高20位(12-31)用于填充页目录表物理地址,故只需要把页目录表物理地址的高20位写入CR3寄存器即可。
第二步:寄存器CR0的PG位置1
启动分页机制的开关是将控制寄存器 cr0 的 PG 位置 1,PG 位是 cr0 寄存器的最后一位(31位),将PG位置1后便进入了内存分页运行机制,段部件输出的线性地址将变为虚拟地址,在将PG位置1之前,系统都是在内存分段机制下工作,段部件输出的线性地址便直接是物理地址。
CR0 第 0 位是 PE位,用来进入保护模式的开关(拓展)
设计一个页表
设计页表前的基础知识
分页第一步是准备好一个页表,设计页表其实就是设计内存布局。
为了计算机安全,用户进程必须运行在低特权级,当用户进程需要访问硬件相关的资源时,需要向操作系统申请,由操作系统去做,之后将结果返回给用户进程,通常申请资源需要调用系统提供给我们的API接口,进程可以有无限多个,而操作系统只有一个,所以必须将操作系统中的API资源共享给所有用户进程使用。
如何实现操作系统接口共享?
我们可以把4GB虚拟地址空间分成两部分,将4GB内存的高3GB-4GB
以上的地址空间划分给操作系统,用户进程则在0GB-3GB
低位地址空间内。
为了实现共享操作系统,让所有用户进程3GB-4GB
的虚拟地址空间都指向同一个操作系统,也就是所有进程的虚拟地址3GB-4GB
本质上都是指向的同一片物理页地址,这片物理页上是操作系统的实体代码。
所以只要保证,所有用户进程,虚拟地址空间3GB-4GB
对应到操作系统中,页表项中,所记录的物理页地址是相同的即可实现共享。
打开分页机制,我们的程序将会在虚拟地址空间中运行,分页机制得有页目录表,页目录表中的是页目录项,其中记录的是页表的物理地址及相关属性,所以还得有页表,页目录表的位置,我们就放在物理地址0x100000
处,让页表紧挨着页目录表,页目录本身占4KB
,所以第一个页表的物理地址是0x101000
,其内存布局如上图。
代码中我们将充分利用低端1MB内存,我们将 mbr、loader、操作系统内核都放置在这 1MB 空间内,当然这 1MB 是指物理内存的 0~0xffff。
内核在物理内存 1MB 之内,将内核地址映射到虚拟地址 3GB 之上,就是将虚拟地址 0xc0000000之上的 1MB 地址映射到物理内存 1MB 之内。
内存分页测试(代码测试)
编译汇编代码,并让Bochs虚拟机运行,设置vb断点,让程序可以在jmp跳转处停下。
C运行到入口点,单步N运行程序
程序运行到mov cx, 0x0004
时,还没有加载GDT
运行到lgdt ds:0x0b03
时,第一次加载GDT
运行到0008:0000000000000b8b (unk. ctxt): mov eax, cr0
第二次加载GDT,此时还没有开启分页呢。
运行到0008:0000000000000b96 (unk. ctxt): lgdt ds:0x00000b03
分页机制正式开启。
在分页后,GDT 的基址会变成 3GB 之上的虚拟地址,显存段基址也变成了 3GB 这上的虚拟地址.
开启分页之后,可以在物理内存0x100000处后看到页目录表,还可以在虚拟机中利用info tab
命令看到页表中虚拟地址到物理地址的映射关系。
快表 TLB
分页机制的目的是实现虚拟地址到物理地址的转换,最终是想得到虚拟地址所对应的物理地址,但如果每次都经过中间的查表过程那么效率上会大打折扣。
如果给出一个虚拟地址后能直接得到相应的页框物理地址,免去中间的查表过程,用虚拟地址的低12位在该物理页框中寻址,即可极大的提高地址转换速度。
实现方法是将近来常用的地址和指令加载到TLB快表
中,快表是一个专门用来存放虚拟地址页框与物理地址页框的映射关系表。
有了 TLB,处理器在寻址之前会用虚拟地址的高20位作为索引来查找 TLB 中的相关条目,如果命中则返回虚拟地址所映射的物理页框地址,否则会查询内存中的页表,获得页框物理地址后再更新 TLB。
但TLB是高速缓存容量较小,因此 TLB 中的数据只是当前任务的部分页表,而且只有 P 位为 1 的页表项才有资格在 TLB 中,如果 TLB 被装满了,需要将很少使用的条目换出,所以 TLB 必须实时更新。
TLB 并不自动更新,处理器也不负责 TLB 的有效性,它把 TLB 的维护工作交给操作系统开发人员,由开发人员手动控制
尽管 TLB 对开发人员不可见,但依然有两种方法可以间接更新 TLB 一个是针对 TLB 中所有条目的方法—重新加载 CR3,比如将 CR3 寄存器的数据读出来后再写入 CR3,这会使整个 TLB 失效
另一个方法是针对 TLB 中某个条目的更新。处理器提供了指令 invlpg(invalidate page),它用于在 TLB 中刷新某个虚拟地址对应的条目,处理器是用虚拟地址来检索 TLB 的,因此很自然地,指令 invlpg 的操作数也是虚拟地址
本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!