前面讲了虚拟内存的概念。这一部分接着讲分页机制。
三 分页机制
通过上面所述,我们了解了片段的换入换出,但是并没有提到内存交换的单位,也就是片段的大小。到底一次交换进来或者交换出去多大内存片段比较合适,是一K一M还是几M?是要固定还是可以变化的等等。在实际中,为了管理方便,这个大小首先是固定的。可以感觉到,如果大小不固定,实现的复杂度将成倍增加,因为系统得仔细记住每一个片段的大小。这样一来,为表述不同片段大小所使用的字段也变得大小不定,这对硬件实现来说,非常的不利,几乎很难实现,而如果通过软件实现,则操作系统为了将物理上的零碎的内存块在逻辑上看成是一个完整连续的内存块,并在内存分配中使用它们,需要在其实现中有个类似链表的数据管理结构,用于记录所有这些零碎的物理内存块。可以想见,随着内存分配释放的不断进行,这个链表的大小和变化频率都是非常剧烈的,查找时间会非常不稳定,其本身内耗占用也是一个非常大的范围,极端情况下,如果内存碎片都是一个字节的,则链表结构本身反而可能大于实际可用内存,这种情况完全背离了最初的期望,既南辕北辙,又得不偿失,所以现有支持虚拟机制的所有系统都是采用了固定大小的内存片段。对于一片内存具体是多大,实际情况中,不同系统采用的参数不完全一致。在基于Linux的32位系统中,这一大小是4KB。标准概念上,把这种划分内存片段管理虚拟内存的机制,称作分页机制。每一片称为一个页面,很形象的表述。
我们需要再强调一下,虽然采用动态大小的片段,会导致实现复杂度陡然增加,但这并不是固定大小的充分理由。不能说因为实现可能复杂了,就说只能选择固定大小。这里最重要的原因,就是这个映射表的转换过程,是CPU的MMU硬件单元来操作的。前面说了,软件链表操作,效率太低,所以,使用固定大小的页面,CPU就可以使用固定的方式,通过索引和偏移就能变换出实际需要的地址,而不需要频繁的查表。这一点,在后面详细介绍虚拟内存的实现机制后,会有更好的理解。这里先有这样一个概念,那就是有了分页机制后,方便了内存管理,也为虚拟内存实现提供了基础。
之前讲了,因为存在换进换出,所以内存是一个地址空间,外设是一个地址空间,二者之间需要一个映射。这里,我们暂时将外设中的地址空间称为线性地址空间,内存空间称为物理地址空间,这样,通过分页,外设地址向物理地址的映射过程也就变得具体化了。基本过程如下:
首先,确定外设中程序片段在线性地址空间中的地址;
其次,确定内存用于参与映射的空闲片段;
最后,将外设内容读写到内存片段中,并将内存片段的地址通过映射表,映射到线性地址空间中指定的地址位置。CPU后续访问将按线性空间来进行,给映射表一个线性地址,比如上图中的2,实际访问的内存片段则为4。
在进一步展开讨论前,我们来考虑一个问题,就是页面大小的选择。这个大小是随意拍脑袋还是有什么具体的考量?对这个问题的探讨,可进一步加深我们对虚机制的理解。前面讲了,Linux里将页面定义为4k大小,有些系统定义为其他值,但基本上都是K单位的。这里我们简单看看这个大小是如何来的。
很明显的一点,内存的使用是一个动态的过程,这就是说,事先并不能确定要用多少内存,而是要按需分配。首先就同一个进程来讲,随着用户操作的不同,内存使用有差异,比如这次用了这个功能,下次用了另外一个功能;其次就不同进程来讲,用户什么时候执行那个程序也是不确定的,这也需要内存,进一步讲,操作系统自身对内存的需要也是随着进程请求资源的不同而随时变化着。但是,不管什么情况,终归操作系统都需要随时准备好内存供使用。加之内存是紧缺资源,所以操作系统对整个内存的管理就要做到需要时分配,不需要时就释放。问题就来了,分配多大比较合适呢?其实这个值即为我们应该分片的大小。释放好说,是多少就是多少。但是分配就需要斟酌权衡,兼顾效率与需求,减少碎块。对这个问题,拿两个极端来看。
首先,我们看大的极端,即分片很大,基本上能够满足大部分应用的大部分分配请求,比如几十M,这样一来,就很少产生应用间碎片。但是这种方案,存在的问题就是分片的意义还有多大。本来分片就是为了让更多的程序能够同时在较小的内存空间中运行,而大的分片导致的应用内碎片,将导致很多内存不可再用(应用内浪费,极端情况就是一个分片最终只使用了第一个字节,这样几乎整个分片就都浪费了,而且,分片越大,碎块就会越大,这种用不完浪费的情况也就会越严重),这与最初的目标背道而驰。
其次,我们看小的极端,比如就一个字节一个分片。乍一看,这样做,不会存在应用内和应用间的浪费,每一个字节都可能被分配使用,利用率达到了最大化,似乎是一个完美的方案,但是细一想,有两个额外的问题:一是映射表的问题,分片的管理是需要映射表的,其不仅记录着那些内存被分配,被使用,而且其本身也在内存中操作系统部分,所以其本身也占用了内用,这部分内存作为管理辅助用,实际上是一种内耗。这样一来,在这种模型下,随便有个内存分配请求,都将使用一个分片,并产生一个映射表项,因为分片过小,导致分片过多,最终也会导致表中的项目就会急剧增多,管理部分消耗的内存就急剧增加,这变相是一种浪费。这种得不偿失的做法自然也是不可取的。二是中断问题,Linux里面分片的分配需要在软中断中进行【具体原因后面会讲到-----但是,需要说明的是,操作系统为应用程序分配内存空间,是通过系统调用接口完成的,而在Linux系统中,系统调用接口又是通过软中断来实现的】,这样就存在问题了。如果程序每次需要内存时,都通过操作系统的系统调用接口来完成,(因为一个分片就一个字节,所以每次的分配请求必将导致新分片的使用),那么,频繁的申请分片,就会频繁的产生软中断,将严重影响系统的整体效率。
综合来看,选择一个合适的大小,在效率、节约和管理实现上取得平衡,显得非常重要了。当然了,对于到底应该多大呢?还是没有标准答案,实际中的选择也是综合各种因素和情况,并结合实际机器发展产生的,我想第一步还是记住这个值就好,那就是4K(linux在32位机器上大部分采用这个值)。
到此,就定下了一个调,Linux中,操作系统将内存划分为4K大小的片来管理,我们将这个片儿叫做页。现在,我们来看看分页机制实现的细节问题,也就是怎么分页的。即整个内存的分页划分。
目前,Linux系统将内存页的大小定为4K,这样,假设内存大小为16M,那么整个内存可以划分为4096页。同之前在分页原因的讨论中一样,内存的分配和释放,都需要一个管理结构来记录,同样,分页之后,也需要记录那些分页在使用,哪些空闲,即有两个管理,一个管页,更底层,即页在哪里,页的状态;一个管分配释放,逻辑层面,要上层一点,即有哪些页面是空闲的,如何获取合适的页面,如何释放页面等。这两个方面,CPU的内存管理模块都有深度参与。早期(为了简化讨论,这里我们就以早期版本为例,下同。而具体实际的实现,随着技术发展,在不断的变化,但是本质思想并没有改变),Linux中将页面划分为两级来管理,第一级为保存页表目录的页目录表,第二级为保存具体页的页表。第一级保存的是目录所在的页,第二级是目录,保存了具体的内存页地址,两者之间的关系如下图所示。
上图中,左边为分页的物理内存,右边为页表管理的逻辑结构。页目录表记录了页目录所在的位置。比如上图中,页目录表在3号页中,第一个页目录在4号页中,那么3号页中页目录表的第一个表项中的内容就是4,表示第一个页目录在4号物理页中。同样的道理,页目录和具体物理页面也是这样的逻辑关系。
我们可以很容易看出,如果页大小为4K,则右边这棵树实际上是一个满4K树。4K的页目录表说明有1024(32位系统,4个字节一个表项)个页目录表,每一个页目录表同样指示了1024个物理页面,而每一个物理页面为4K空间。这样,这棵树总的可关联的物理内存大小为(就是叶子节点内存总和):1K(页目录数目)*1K(物理页数目)*4K(字节层面)=4GByte。而为了管理内存,产生的内耗为(也就是管理结构本身占用的内存。针对上图,就是非叶子节点占用内存空间,业即橙色和所有绿色页块占用的内存):4K(页目录表)+1K*4K=(4M+4K)Bytes。
基于上图,分页后的内存视图为:(注意:一个页表项可定位4M的物理内存,内存开始部分的页目录表实际为内核数据的一部分)
需要注意上图只是原理性的说明,实际物理内存排布并非这样安排的。现在Linux系统的内核部分被安排在高地址处,而Linux0.11的内核在内存开始部分。以Linux0.11为例,内存开始部分为内核页目录表和页表,接着为中断描述符表,后面为内核代码和数据,还有部分缓冲区空间,再之后才是主内存区。内核页表管理了内核空间,用户进程在主内存区,每个进程有自己的页目录表和页表,用于管理用户进程的内存。
将上图的页目录表和页表内容展开,形成下面的效果图:
结合上面两图及内存布局描述,我们可以看看一个示例图:
上图是一个示意图,非实际真实图,只是为了方便理解。中间蓝色部分实际使用时,并非按4K边界来分割的,需要注意。
通过上面几图,我们对分页后的内存情况有了初步了解。从图中可以看出,CPU只需要通过第一级页目录表就可以完成内存页的映射关联,从而把握全局内存页使用情况。操作系统在构建完两级页表结构后,将第一级页目录表给CPU的内存管理单元的专用寄存器,这样CPU就看到了整个可用物理内存页。(关于物理内存页的具体使用,就涉及到转换了,后面详细介绍)。另外,始终要记住,目录表项完全是为了管理需要,所以对整个内存空间来讲,属于内耗,并不作为有效可用物理页面的一部分。
至此,我们了解了系统如何分页。
有了上面的分页机制后,内存的分片也就确定了。程序加载时,操作系统查找空闲的页面,将程序执行最需要的部分加载进来,运行过程中,如果需要分配新的内存,则继续查找新的页面,然后提供给程序并做好映射记录。当分页占用过多时,可以将最早的或者最可能暂时不用的分页暂存到外部存储设备上,在需要时再调入进来。如此一来,小内存运行大程序的基础就算是具备了。
理想情况下,我们期望内存的使用是连续的,包括程序本身所占用的和程序中动态分配的。但是,实际情况中,何时运行何程序,程序运行过程中,何时分配内存,都是随机的,所以,如果从上帝视角来看,整个内存页面的使用看上去可能会显得十分凌乱,如下示意图:
最极端的情况,可能两个紧挨着的分片,是由完全不相干的两个程序所使用的。这时,就存在两个问题,其一,这种对应关系一定要维护好,哪一片属于哪个程序不能弄错,就是要能够找得到;其二,隔离要维护好,不能出现越过边界的问题,否则将直接修改别的程序内存,出现严重错误。其实,这两个问题,本质上是一个问题,就是我们之前讲的,映射关系要管理好。
最后,补充一点,早期基于INTEL的系统中,还支持分段机制。通过分段,每一个任务被区分开,分为代码段、数据段等,然后段代表的逻辑地址再映射到线性地址空间中,所有任务共享内核的代码段和数据段。