[自制操作系统] 第07回 认识保护模式之地址映射
目录
一、前景回顾
二、物理地址、线性地址和虚拟地址
三、内存为什么要分页
四、一级页表
五、二级页表
前面我们说到,保护模式下有着三大特点:地址映射、特权级和分时机制。从我的学习角度来说,我认为地址映射这一块的知识点尤为繁杂,所以会花费相对比较多的时间来讲述,话不多说,开整。
在认识地址映射之前,我们来搞懂这三个地址的含义。
物理地址就是物理内存中真正的地址,相当于内存中每一个存储单元的门牌号,具有唯一性。不管在什么模式下,CPU最终都是以物理地址去访问内存的,一定要充分认识到这一点。
在实模式下,“段基址+段内偏移地址”经过段部件的处理,直接输出的地址就是物理地址,CPU可以直接使用此地址访问内存。
而在保护模式下,“段基址+段内偏移地址”经过段部件的处理,输出的地址被称为线性地址,其实此处段基址已经不再是真正的地址了,而是段选择子,它本质上是一个索引,通过这个索引便能在GDT中找到相应的段描述符,而段基址就在段描述符中,这个内容在上一回已经提到过。得到了线性地址后,此时我们需要判断一下系统是否开启了分页机制(分页机制在下面就会提到),如果开启了分页机制,那么此时的线性地址又称为虚拟地址,虚拟地址需要通过页表映射得到真实的物理地址,这样CPU才能访问到内存。如果没有开启分页机制,此时线性地址就被认为是物理地址,将被CPU直接用来访问内存。
总结一下就是下面这张图:
浓缩一下,其实上面讲的东西就是我们常听说的MMU,它的作用其实就是地址转换。
MMU是内存管理单元,我们的每一个线性地址,通过MMU转换后,便能得到其实际对应的物理地址,MMU是硬件上提供的地址转换电路,我们不必操心。我们实际要关心的是,MMU是如何转换的呢?换句话说,应该是有一个类似表格的结构,表中每一个虚拟地址都一一对应了一个物理地址,我们提供给MMU一个虚拟地址,MMU通过该表查询,便能得到该虚拟地址对应的真实地址。实际上的确是有这么一个表格,它的名称叫做页表,页表的构建是基于分页机制的,而不是随随便便划分的页表。
一直以来我们都是在内存分段机制下运行的,目前未出问题看似良好,可是想象一下,当我们的物理内存不足时会怎么办?如下图所示:
当系统中有三个进程A、B、C在运行,物理内存还剩余15MB。此时如果进程B结束了,但是新来了一个进程D需要占用20MB+3KB的物理内存,此时由于运行环境未开启分页功能,“段基址+段内偏移”产生的线性地址就是物理地址,程序中的物理地址是连续的,就导致没有足够的内存空间供进程D使用。此时就需要将进程A的段A3或者进程C的段C1换出,具体换出哪一部分是需要参考换入换出算法,这里不深入讲解,总之只需要知道,我们需要换出一个段来腾出空间给进程D。
问题解决了,但是又没完全解决,这个方法中,如果进程的段特别大,那么换出时要将整个段全部搬运到外存,也就是硬盘上,这种IO操作太多了导致系统响应奇慢无比,令人无法接受。这个问题的本质是因为在我们的进程中,代码和数据是以段为基本单位进行存储,而每个进程的段的大小是不一致的。既然段的大小不固定,于是接下来我们做了一点改变:我们规定:页是段的更小的划分,且页的大小是固定的。目前普遍的操作系统中规定页的大小是4KB,这样一个段就可以被划分成多个页,下次再换入换出时,我们就只需要换出部分页即可,而不用将整个段换出,这样便能避免IO操作太多导致系统响应慢的问题。
可是仔细一想,这个解决方法还是有未能尽善尽美的地方,假如进程A段的A3和进程C的段C1现在都在运行,不允许换出部分页,这该如何是好?
究其本质是因为在我们没有开启分页机制时,程序中使用的线性地址就是真实的物理地址,这两个地址都是连续的。我们知道线性地址是编译器编译得出的,它必须是连续的,所以连带着物理地址也是连续的。如果有这么一个方法,让线性地址依旧连续(因为这是编译器决定的)但是让物理地址不连续,这样不就可以将内存空间中的不连续物理地址被利用起来了么?
于是分页机制就呼之欲出了,分页机制结合前面说的分页方法,将物理内存和线性内存划分为同等大小的页,一页线性内存可以对应一页真实的物理内存,这样就可以让连续的线性地址对应上不连续的物理地址。
说这么多,我们从宏观角度来看看分页机制的实现吧。
我们的CPU进入保护模式后有了4GB的寻址空间,这就是寻址空间就是指的线性地址空间,它在逻辑上是连续的。分页机制将所有段都划分为同等大小的页,与此同时,假设我们的物理内存也是4GB,我们将物理内存页划分为若干个页,虚拟地址空间的每一页通过一个映射关系就可以一一对应到物理地址空间中每一页。这个映射关系就是接下来我们要讲的页表。
前面说过分页机制可以让连续的线性地址通过某种映射关系对应上不连续的物理地址,页表就是这个映射关系。
通常来说,一页的大小是4KB,现在我们来计算一下4GB的空间可以划分为多少个页,即4GB/4KB=1M个页。也就是说4GB的空间可以容纳1048576个页,页表中自然也要有1048576个页表项。也就是我们要说的一级页表,如图所示。
由于页的大小是4KB,所以页表项中的物理地址都是4K的整数倍,用十六进制来表示的地址,其低3位都是0。页表介绍完了,具体如何使用呢?也就是如何将线性地址转换成物理地址呢?还是举一个小小的例子来说明:
在保护模式下的线性地址有32位,低12位被视为页内偏移地址,因为我们知道任何一个线性地址肯定是要落在一个物理页中。所以低12位是用于在物理页中偏移地址的。高20位用来表示页的数量,也就是用来在页表中索引物理页的。假设现在有一个线性地址为0x00001234,其地址转换过程如下图所示:
一级页表就到此为止,接下来我们看看二级页表。
前面讲述了一级页表,并以一级页表作为原型讲述了地址转换过程,既然有了一级页表为什么还要来弄一个二级页表呢?原因如下:
1、一级页表中最多容纳1M个页表项,每个页表项是4个字节(实际只需要3个字节就可以存储了,只是应用中为了方便页表的查询,便让一个页表项占用四个字节,使得每个页面刚好可以装的下1024个页表项),如果页表项全满的话,那就是4MB大小、而且还得是连续的,显然这是不现实的。
2、每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
归根结底,我们要解决的问题是:能否不要一次性地将全部页表项建好,而是在需要时动态创建页表项。
如何解决呢?二级页表采用了两个方法来解决这一问题:
1、对于页表所需的内存空间,采用离散分配内存的方式,以解决难以找到一块连续的大内存空间的问题。
2、只将当前所需要的部分页表项调入内存。
先来看看二级页表的模样,如图所示:
我们有一个页目录表,表中共有1024个页表项,每个页表项中记录了一个页表物理页地址,每一个页表中又记录了1024个物理页的地址,这里的每一个物理页和一级页表一样,依旧是一页4KB大小。故一个页表项能记录的内存容量为1024x4KB=4MB,一个页目录表能记录的内存容量为1024x4MB=4GB,这就达到了32位地址空间的最大容量。所以理论上每一个线性地址都能落在一个物理页中。
我们还是来看看,在二级页表下,给定一个线性地址如何通过二级页表来转换成物理地址:
1、用虚拟地址的高10位乘以4,作为页目录表的索引号,再加上页目录表的物理地址,所得到的就是页目录项的物理地址,读取该页目录项,从中获取到页表的物理地址。
2、用虚拟地址的中间10位乘以4,作为页表的索引号,再加上在第一步中得到的页表物理地址,便是页表项的物理地址,读取该页表项,从中获取到物理页的地址。
3、虚拟地址的低12位是物理页内的偏移量,用低12位加上第二步得到的物理页地址的和,便是最终转换的物理地址。
以虚拟地址0x01234567为例,其转换为物理地址的流程图如下所示:
这里说明一下,页目录表的物理地址是存放在CR3寄存器中的,后面我们设计的二级页表的页目录表物理地址也将会存放在CR3寄存器中,方便CPU调用。
熟悉了二级页表的工作原理,我们回过头来看看,为什么说二级页表能解决前面一级页表的那三个问题。这三个问题的重点就是想要说明一级页表占用的内存空间会过大,那么二级页表占用的内存空间为多少呢?我们试着来分析一下,首先,对于每个进程来说,页目录表是必不可少的,页目录表占用的内存只有4KB。而页表是可以不事先建好的,当进程有换入的请求时,假设此时进程请求从硬盘中换入4MB的数据,如果此时内存中具有空闲的4MB的内存,那么CPU就将该4MB的内存分配给该进程,我们知道,4MB刚好是1M个物理页的大小。CPU会在内存中划定一页空闲的物理页来作为这4MB内存的页表,随后将该页表的地址填入到页目录表中即可。当这4MB的数据不再需要时,CPU又可以将其换出,然后删除相应的页表和页目录表中的页表项信息。这样就实现了动态增减页表,避免页表占用过多内存的问题。
最后我们来看看页目录项和页表项的内容。
因为页目录项和页表项都是记录的物理页的地址,物理页的大小是4KB,所以地址都是4K的倍数,也就是地址的低12位都是0,所以只需要记住物理地址的高20位即可,这也就是为什么我们看图中页目录项和页表项记录的地址只有20位的原因,空出来的12位可以用来添加其他属性。
本回到此结束,下面我们将开始着手实现一个二级页表,使系统进入分页机制下运行。欲知后事如何,请看下回分解。