最开始,操作系统的内存管理非常简陋。只是简单把物理内存分为两部分:低地址部分,64K分给操作系统,高地址部分分给用户程序。而且每次只执行一个程序。执行完后要重新装载程序,再执行其他程序。接着,由于那是机器非常昂贵,人们希望能够充分利用机器资源。出现了多任务的操作系统。这时操作系统能够支持一次自动执行多个任务,在某些程序执行IO的时候,CPU能够执行其他程序,这样就提升了机器资源的利用率。后来,又有了希望能够和程序及时交互。这样就能方便知道程序执行的状态了,这个对程序员来说太重要了。不然,每次都要等程序执行完才能知道结果。于是有了分时的操作系统,也就是每个进程执行一小部分时间。但是,这里就出现了安全问题,一个进程随意的读写另外一个进程的数据,甚至是操作系统的数据!由此,内存的虚拟化就出现了,它为每个程序提供一个统一抽象的地址空间,程序看到的地址又叫虚拟地址。它的目标包括:
简单来说,把进程的虚拟地址转换成物理地址。同时程序的每次内存访问都在操作系统的控制之下,确保安全。另外,通过硬件的支持保障性能以及地址转换对用户透明。它是内存虚拟化的核心机制,我们遇到的很多技术细节都是为了解决这个问题。
这里包括软件实现的静态重定位(static relocation)和基于硬件的动态重定位(dynamic relocation)。
软件的实现通过一个叫loader的软件,把应用程序中的引用的地址都加上一个偏移地址就变成物理地址了。显然,这里最大的问题是不安全。
硬件就不一样了,它有两个寄存器一个基地址寄存器,一个地址范围限制寄存器。当访问一个内存的时候,先把地址加上基地址寄存器中的值,这个结果就是物理地址了。同时还会对比物理地址和地址范围寄存器比较,如果超过就触发异常。这个硬件一般叫做内存管理单元(MMU)。
因此,硬件需要提供以下几个功能:
同时,操作系统需要提供以下几个功能:
操作系统和硬件的交互,主要包含在启动和执行程序的时候。
启动:
操作系统(kernel mode) 硬件
================================+=================================
初始化异常表
保存系统调用地址
定时处理程序地址
非法内存访问处理程序地址
非法指令异常处理程序
启动定时器中断
开启定时器
初始化进程表
初始内存空闲列表
程序执行:
操作系统(kernel mode) 硬件 程序(user mode)
================================+=======================================+=====================
启动进程A:
进程表中添加进程
分配进程需要的内存
设置基地址和地址范围寄存器
执行进程A的代码
恢复进程A的寄存器,进入user mode
跳转到进程A的起始指令
获取指令
转换指令的物理地址,获取指令内容
执行指令
如果是保存或者获取数据,检查内存
地址是否合法
继续执行。。。
定时器时间到了,进入kernel mode
执行定时器处理程序
停止执行进程A,保存进程A的上
下文到进程控制块:
当前执行的指令地址以及基地址
寄存器和地址范围寄存器的值
加载进程B的寄存器以及指令地址
执行进程B的代码
恢复进程B的寄存器,进入user mode
跳转到进程B的指令
访问非法地址
地址越界,触发异常进入kernel mode
执行非法访问地址异常代码,终止
进程B,回收进程B的内存,释放
进程表中有关进程B的内存
把整个地址空间映射到物理空间如下图(假设地址空间大小是16K):
0KB +----------------+
| |
1KB + program code +
| |
2KB +----------------+
| /////// |
3KB + free +
| /////// |
4KB +----------------+
| |
5KB + +
| heap |
6KB + +
| |
7KB +----------------+
| | |
| V |
| /////// |
| /////// |
| free |
| /////// |
| /////// |
| ^ |
| | |
14KB +----------------+
| |
15KB + stack +
| |
16KB +----------------+
这样的会有两个问题:
注意到地址空间中主要有个三部分数据:代码、堆、栈。代码大小是固定,而堆和栈不是固定的,而且往往使用的比较少。如果把它们分成三个独立的地址空间,独立映射到物理地址,可以把它们映射到不同的物理地址空间,也不要求物理地址连续。通过减少堆和栈的大小这样用不到的地址空间就可以不用映射了,从而节省了物理内存。这就叫分段。
为了支持分段,硬件需要提供额外支持:
同样,操作系统也需要做一些事情:
空闲内存管理主要目标是高效的处理变长内存空间的请求和减少内存碎片(外部碎片和内部碎片)。有几个基本的机制:
在效率和内存碎片的权衡中又有多种不同的分配策略:
在之前的技术中有两个问题:
分页就可以解决这两个问题。分页把地址空间和物理内存都分成大小固定的块,这些块就叫页。这个时候,即使虚拟地址是连续的,但是它映射的物理地址是可以不连续的。所以,不使用的内存可以不分配物理内存。这样就解决了第一个问题。显然,物理内存分配单元大小固定又不需要连续,那就不会有外部碎片了。
地址映射怎么解决呢?先来看下分页以后物理地址的内容。有了页以后,我们需要有一部分信息来寻找地址所在的页,同时还需要一部分信息来寻找在页中的位置,因此物理地址就分成了两部分:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10| 9| 8| 7| 6| 5| 4| 3| 2| 1| 0|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|_______________________page index__________________________|____________page offset____________|
假设物理内存是4G=2^32,每页大小是4K=2^12 。那么,页内偏移(page offset
)就需要12位信息。页索引(page index
也叫PFN/physical frame number
)需要20位。这样物理地址=页索引<<12 + 页内偏移
。
那虚拟地址怎么组织呢?页索引和物理地址是一样的,这样就简化问题了,通过虚拟地址的其他部分信息找到物理页索引。这个寻找过程,通过叫页表的数据结构。它保存了物理内存的页索引。最简单的页表是一个一维数组(线性页表,linear page table),每个页表项(PTE)保存了物理内存的页索引,大小是4个字节。假设地址空间是32位大小,页大小是4K,那么页索引是20位。虚拟地址结构是这样的:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10| 9| 8| 7| 6| 5| 4| 3| 2| 1| 0|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|_______________________page index__________________________|____________page offset____________|
虚拟地址的页索引叫做VPN(virtual page number)。
虚拟地址到物理地址的转换过程:
页表项内容除了包含PFN,一般会有包含以下一些信息:
问题又来了!这样的方式有两个问题:
为了解决地址转换慢的问题,引入例如TLB,全称tanslation-lookaside buffer,本质上是一块缓存,是硬件部分,包含在MMU当中。既然是缓存,那么只有在命中率高的情况下才有效。好在大部分程序在时间和空间上都有局部性,所以能够起到加速效果。引入了TLB会带来什么问题呢?
TLB失效,谁来处理?硬件还是操作系统?
都可以。像x86是硬件来处理的。MIPS R10k是操作系统来处理的。
选择时机进程上下文切换的时候,TLB怎么处理?
TLB满时如何替换?
LRU或者随机。
在线性页表的实现中,每个进程页表太大了,需要4M。怎么破呢?
大页
增加页的大小,比如2M,在32位的地址空间当中,页表索引就只有11位了,页表大小2^11*4=8K。大页还有另外一个好处是提升TLB的命中率。问题是:较多的内部碎片。
混合分段和分页
在分段的基础上,给每个段用页表来保存地址映射。因为,每个段彼此独立,为每个段可以分配不同大小的页表,从而节省空间。问题是:分段的问题依旧,如果段大小很大但是只引用到了较少的部分,导致页表的内存浪费;每个段大小不一请求分配页表大小也不一样,导致外部碎片。
多级页表
大页和混合分段和分页的方式是从整体页表大小的角度来减少页表大小。其实,在页表里面还有很多没用的页表项,比如某块虚拟地址就没有被访问过,就不会有物理地址和它映射,它占据的页表项程序根本不会用到。多级别页表就是通过减少这些页表项来降低页表的大小。
多级页表在简单的线性页表基础上把VPN分成了多个部分,为了方便说明假设是2个部分,分成了页表目录索引和页表索引,如下图页表目录索引和页表索引都是10位。
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10| 9| 8| 7| 6| 5| 4| 3| 2| 1| 0|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|____page directory index_____|____page table index_________|____________page offset____________|
虚拟地址到物理地址的转换过程:
多级页表的问题就是在转换的时候多了几次内存访问。想起TLB!
反向页表(Inverted Page Tables)
维护一个全局的物理地址到虚拟地址的映射。当然,映射的值中还要包含这个虚拟地址所属进程。这样页表的大小就是固定了。问题是:查找耗时。再搞个虚拟地址到物理地址的映射;)
最佳方式分段加多级页表!x86就是这么干的。
当进程运行时需要的总物理内存大小超过实际物理内存大小时,进程就会因为内存申请失败跑不起来了,这时可能是一个进程也可能是多个进程。为了解决这个问题,操作系统需要把当前物理内存中不常用的数据放到外面去。放哪里呢?答案是磁盘。在内存层次磁盘是最下面一层。用来存放内存数据的磁盘空间,又叫交换空间(swap space)。
这样就有几个问题需要解决:
问题1和2都比较明确,简单。关键是第3个问题,在内存层次上看内存相对与磁盘来说可以算是数据的缓存。命中率越高越好!因此,“选择物理内存,写到交换空间,再把数据写入内存”这个过程的目标是让内存访问的有高命中率。
选择时机
当内存满的时候再去替换内存,会影响当前的内存访问。因此,尽量保证物理内存足够满足当前的需要是最理想的。这方面操作系统会维持空闲物理内存两个水位,高水位和低水位。当空闲物理内存低于低水位时就会根据替换策略把数据替换到交换空间,直到空闲物理内存达到高水位。
替换策略
主要策略包括简单的FIFO和随机方式,以及考虑历史访问特性的LRU、 LFU,因为实现这些标准的考虑历史特性的算法空间和时间效率不高,又产生了一些近视算法,比如时钟算法,以及考虑脏页的时钟算法。
选择策略
选择需要写入的数据时,操作系统可以预判哪些数据将会被使用,这样就可以先把这些数据加载到内存,当访问的时候就可以命中了。像linux内核中就有这样的参数去访问文件。
写入策略
因为磁盘操作慢,每次替换就写磁盘也是影响内存访问的时间,考虑批量写回数据可以缓解这个问题。
所有的策略都有很多的复杂度,最简单的就是买更多的内存:)
问题
交换空间还有一个潜在的问题是程序缺页频繁导致系统抖动。linux处理的方式选择一个内存使用比较大的,kill掉!
有了swap空间,地址空间大小限制就小了,程序的空间就大了,简单方便!但是,到磁盘读数据的代价是很大,最简单的方式是买更大的内存!