物理内存分配简单总结
==================

> 为什么需要内存管理？

一开始的计算机的确是没有内存管理的，程序员直接操作物理地址，指定每条指令的物理地址应该是多少。

这样的方法具有很大的局限性，因为就不能运行多道程序了。因为每个程序都直接指定了物理地址，如果运行多道程序的话，无法做到程序之间内存的保护。更重要的是，每个程序也可以直接读写操作系统存在的内存空间，导致计算机崩溃。

只有两种方法可以让这样的计算机运行多道程序。

+ 其一，计算机可以跑的所有程序都是预先确定好的，不能更改的。现在的一些嵌入式系统仍然是这样，例如洗衣机的程序，就是事先就烧录好的，每个进程都规定了各自的地址空间，互相不能越界。因为是一个厂商开发的程序，所以不用担心恶意代码。
+ 其二，利用交换机制。也就是实际上内存中的确也只有一个程序，但是可以通过硬盘与内存的换入换出操作，来实现多道程序。

通过上面的讨论，可以看到，想要运行多道程序，就需要程序加载到内存中时可以做到重新定位，这样编写程序的时候是使用的相对于程序头的相对地址，只有在程序加载到内存中时，才知道这个程序头地址是多少，从而得到其中各个变量的绝对地址。这个方法就是静态重定位。

静态重定位已经可以解决多道程序的问题了。用户可以自行安装自己的应用程序，因为这些程序并不需要写死需要被加载到内存的哪个位置，而是可以利用重定位来避免与其他程序冲突。

但是静态重定位还是存在问题，它还是不能实现进程之间的保护。因为恶意进程仍然可以篡改其他进程乃至操作系统的内存区域。这是因为静态重定位本质上还是物理地址，没有引入逻辑地址或者虚拟地址的概念，从而没有机制来做到内存的保护。

下一代的解决方案是引入逻辑地址与动态重定位。动态重定位区别于静态重定位，因为知道某一条语句执行的时候才能知道它所对应的物理地址。这里，在编程阶段就都是使用逻辑地址，在编译时生成可重定位的代码，在链接时进行静态重定位，生成全局的逻辑地址，然后知道运行时才将逻辑地址转化为物理地址。

引入逻辑地址的好处还有可以做到对内存的保护，因为在逻辑地址向物理地址转化时可以进行一些保护措施，如特权级检查，越界检查等，从而做到了进程之间内存的保护。

可见，从一开始完全没有内存管理的物理地址，到静态、动态重定位，内存管理使计算机可以更好地支持多道程序。可以说，内存管理就是为了运行多道程序。

此外还有一些别的内存管理，比如计算机的存储空间是多级分层存储，各层次有寄存器，高速缓存，内存，硬盘等，需要对这些不同层次的存储器进行管理，比如高速缓存未命中时的操作等，这也是内存管理的一部分。

## 连续的内存分配

在有了一定内存管理后，计算机就可以运行多道程序了，然后就涉及到为不同的进程分配存储空间的问题，应该如何安排各个进程的存储空间，以及如何管理这些空间就是这里关注的问题。

最简单的方案应该就是连续内存分配了。就是说每一个进程在内存中占用一个连续的区域，然后操作系统显然应该管理当前已经占用的区域以及尚未占用的区域，以便进程退出时可以将空闲出来的内存还给操作系统，有新的进程加入时可以为其分配一个合适的空间。

那么，何为合适的空间呢？这就涉及到连续内存分配的算法。

> 连续内存分配的算法。

+ 首要的是最先匹配算法(First Fit)。说来非常简单，就是在操作系统管理的空间内存中找到的第一个足以容纳新加入进程的区域，将其分配给新加入的进程。采用这样的算法可以方便地合并被释放的分区，实现起来也比较简单，但是会产生外部碎片(但是所有连续内存分配都不可避免，所以应该不算缺点)

+ 最佳匹配算法(Best Fit)。最佳匹配算法的性能不见得是最佳。算法也非常简单，就是将管理的空闲的内存按照大小次序排列起来，找到刚好可以容下新加入的进程的那一个区域。这样的话可以保留一些较大的空闲空间供较大的程序使用，但是也会产生外部碎片，而且还是无用的小碎片。此外，在回收空间时比较麻烦(因为是按区域大小排序，而不是区域地址，因此合并操作会比较复杂)。

+ 最差匹配算法(Worst Fit)。最差匹配是相对于最佳匹配，分配内存区域时总是分配最大的一块。这样的话就不会有太多的小碎片，但是随之而来，内存中也没什么较大的区段可以供较大的程序使用了。最差匹配与最佳匹配一样，回收空间的时候比较慢，原因也一样。

> 碎片整理办法。

上面也说，凡是连续内存分配，都不可避免会出现外部碎片，这些碎片太多显然会影响到计算机的整体运行。当一个新的进程要求进入内存时，若内存中因为外部碎片的存在，而没有足够的空间来容纳这个新的进程，就需要对碎片进行整理，从而找到足够的空间放下这个进程。

一种方法是紧凑(compaction)。其实思想也很简单，就是既然你有外部碎片，
那我就把你压在一起，把中间碎片给填补掉。	想法虽然很简单，但是实现起来却很难。首先，程序需要在运行的时候更换位置，那么就要求所有的程序都支持动态重定位。其次，这样调整的开销极大。

另一种方法是分区对换(Swapping in/out)。简单说来就是加入了一个新的进程，而当前内存又没有足够的空间(或者足够大的连续空间)容纳这个新来的进程。一个办法就是将处于等待的进程换出，通过抢占并回收处于等待状态进程的分区，以
增大可用内存空间。感觉已经有点虚拟内存的思想了。

## 非连续的内存分配

所有连续的内存分配方法都会产生外部碎片，这是因为有些外部碎片极小，不足以分配给任何进程。此外，采用连续的内存分配方式，很容易产生具有足够的内存总空间，却因为这些空间都是离散的而无法给一个大进程分配空间的情况。在这种情况下，就应该考虑非连续的内存分配。

非连续的内存分配实现起来其实非常复杂。因为要考虑很多问题，比如一个分区的大小为多大，一个字节？十个字节？还是1MB这么大？此外，应用进程需要保存所有这些不连续的分块的起始地址，才可以正确找到他们，而连续的内存分配应用进程只需要知道自己的起始地址。除此以外还有各种问题，让问题更加复杂。

### 段式存储管理

既然问题这么复杂，那就让它稍微简单点。对于一个进程，我也不需要把它划分到多细，只是按照代码，数据，初始化变量等性质相同的区段分别划分就好了，这样一个进程也就几个段，管理起来也比较方便，这就是段式存储管理。

段式存储管理其实已经接触了够多了，前面的lab1已经分析过分段机制的内存访问过程，这里就简单总结一下。

对于逻辑内存或是虚拟内存，主要需要弄清楚三个点：逻辑地址格式；逻辑地址到物理地址的表项结构；转换的方法。对于段式存储管理

+ 逻辑地址格式：段号：段内偏移地址。但是这里和我以前的理解有一点不一样：对于80386而言，地址总线是32位，这里的偏移地址也就对应着这32位，而段号是隐含在段寄存器里面的，所以这样说来的话，逻辑地址应该是48位，映射到32位的物理地址。
+ 段表
+ 查段表的过程也不多说了。

### 页式存储管理

页式存储管理比段式管理要细致得多，这是因为一个段可以划分到非常大，而一个页却往往很小(4K或者更小)。逻辑地址和物理地址都进行了分页，并且分页的大小是一致的。所不同的只是逻辑页号与物理的页号(也称为页帧)往往是不同的，页表也就是建立了从逻辑页号到物理页帧的对应关系。

> 逻辑地址格式

页号：页内偏移量。比如说一个页为4K个字节，那么页内偏移量就是12位，其余的20位全部作为逻辑的页号。

> 页表的结构

页表存储了从逻辑页号到物理页帧的转换关系。页表项包含了物理的页帧号，往往也是20字节，在加上一些控制位来进行访问与权限的控制，比如存在位，读写位，引用位等。页表的起始地址存储在页表基址寄存器(PTBR)内，每一个进程都拥有自己的页表。

> 页表的查询过程

CPU读到了一个逻辑地址，取出其中的逻辑页号(高20位)。通过页表基址寄存器(PTBR)找到当前进程对应的页表，利用逻辑页号作为数组的索引，找到与之对应的物理页帧号。进行了相关校验操作后，将物理页帧号左移12位，加上逻辑地址中的偏移量(低12位)，得到物理地址。

> 页表存在的问题与解决方案

从上面的操作中，可以看到页表存在的一些问题。比如说利用了页表以后，原来一次的访存操作现在变成了两次，第一次需要访问内存中的页表，然后才能第二次进行真正的访问操作。这样就导致访存的效率降低了一倍，我感觉不太能接受。

解决的方案其实很简单，利用缓存，这里的话就叫快表(大概因为它够快吧)。简单说来就是在CPU中设置一个缓存，可以缓存近期访问过的页表的条目。这样，每次进行访存操作时先到快表中去查询逻辑页号，如果找不到再到内存中去查找页表，并且把找到的项目加入CPU中的快表中。这样实际上还是进行两次访问操作，但由于第一次是在CPU中进行的，因此速度极快。如果快表的命中率可以达到99%的话，对访存效率的提升还是非常可观的。

此外还有一个问题就是当内存空间很大的时候，例如64位总线的内存空间，页表的体积会非常大。就说64位的内存吧，如果一页仍然是4K的话，就需要$2^{52}$页，每个页表项占用4B，也就是$2^{54}B$，反正我是不太能接受。这样，无论是存储空间，还是访问效率，都会变得非常差。

解决方案是采用多级页表，建立页表[树]，从而减少页表的长度。举一个简单一点的例子，如果是32位总线，一页大小为4K，建立两级页表，每级都占用10位。这样，第一级页表就只有$2^{10} = 1024$个页表项，从而只占用4096B，刚好只占一页的大小。需要注意的是，顶级页表一般都只能占用一页，因为页表基址寄存器就一个，你占多了就存不上了......

需要注意的是，使用多级页表其实并不能实质上减少存储空间，因为其实还是有那么多页，只不过你给它分了个域而已。接着上面的例子，一级页表只有1024个表项，每个表项都对应了1024个表项，这加到一起还是$2^{20}$页，这是不可改变的。但是实际运行的时候，往往只有第一级页表在内存中，其他低等级的都在硬盘里面存着。只有要索引到它的时候，才会把它调入内存，从而减小内存的开销。

从本质上解决页表存储空间问题的方法是利用页表寄存器和反置页表。

> 如何通过页表寄存器和反置页表实现页式存储管理？

页表寄存器是基于这样一个思想：传统的页表是对应于逻辑地址，也就是虚拟地址的。而虚拟地址增长的速度非常快，所以才导致页表所占空间过大。使用页表寄存器的思路是不让页表与逻辑地址相对应，而是与物理地址相对应，这样就可以减小页表的大小了。

具体实现的方法就是每一个页帧都有一个页表寄存器与之对应，其中的内容包括使用位、对应的逻辑页号，以及保护位。这样就可以使页表大小与逻辑地址无关了。但是又产生了一个问题：页表信息对调以后，应该怎么通过逻辑地址来查询物理地址呢？

这里是建立了一个Hash映射，将逻辑地址利用Hash函数映射到对应的页帧上，这样也需要解决可能出现的冲突。解决方案是页表项再增加一个字段，用以指示发生冲突时下一个页帧号是多少。

这样进行逻辑地址向物理地址转化所需要的步骤如下：

+ 对逻辑地址进行Hash变换
+ 在快表中查找对应的页表项
+ 有冲突时遍历冲突项链表
+ 查找失败时，产生异常

### 段页式存储管理

从上面的讨论可以看到，段式存储管理比较方便实现进程间的内存保护机制，而页式存储管理在内存的利用和优化转移到后备存储方面具有优势。段页式存储管理就是将这两者接合到一起的存储管理方法。其基本实现方法就是在段式存储管理的基础上，给每个段加一个页表，从而细化存储。

> 逻辑地址格式

段号：页号：页内偏移量。这里的段号仍然是段寄存器里面的段选择子，不是在32位里面的。

> 段表和页表

段表结构和页表结构和前面的没有什么区别。唯一的区别就是查段表得到的并不是段的基址，而是页表的起始地址，从而可以进行页表的索引。

> 地址转化过程

+ CPU要读到了一个逻辑地址，首先取出其段选择子(从段寄存器中)，查询该进程的段表，读到页表的起始地址。
+ 读出逻辑地址的逻辑页号，用来索引页表，找到其对应的页帧号。
+ 利用页帧号左移12位，加上逻辑地址中偏移部分，得到该逻辑地址的物理地址。

此外，利用段页式存储管理，可以轻松地实现进程间内存的共享。例如两个进程要共享内存中某一个区段，他们都拥有相应的共享段。此时只需要将两个进程的共享段都指向一个公用的页表，通过这个页表可以访问到内存中的共享区域。