1. 虚拟内存

CPU 是直接操作内存的物理地址。
在这种情况下,如果两个程序占用的内存有重叠,要想同时运行两个程序是不可能的。
如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容。
所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。
因此,有了虚拟内存。每个进程分配独立的一套虚拟地址,互不干涉。(虚拟地址由操作系统负责映射到物理内存)

300

基本知识

虚拟内存地址和物理内存地址

操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

于是,这里就引出了两种地址的概念:

  • 我们程序所使用的内存地址叫做 虚拟内存地址Virtual Memory Address
  • 实际存在硬件里面的空间地址叫 物理内存地址Physical Memory Address)。

操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

500

好处

  • 每个程序都有一个从 0 开始的,独立的虚拟地址空间,这样程序员就不用处理内存偏移这些琐碎的事情了,极大降低了编程的工作难度。
  • 虚拟内存总是连续的,尽管实际映射的物理内存可能不连续。操作系统会把底层那些可用的、碎片化的内存整合成一个统一的虚拟内存地址,这就充分利用了碎片化的物理内存。
  • 每个程序看到的虚拟地址空间大小是一致的,近似于无限,这样程序员就不用担心自己的程序太大而无法加载进内存。
  • 虚拟内存保证了安全性,程序 A 和程序 B 的虚拟地址空间是独立的,它们无法访问到对方的地址,即使通过某种恶意手段访问到,也能被操作系统检测到并触发异常。同时,进程对自己虚拟地址空间的操作也不是为所欲为,如果对一个只读的地址写操作,也会引发操作系统异常,极大的保证了内存读写的安全性。

页表

上面我们也说到,虚拟地址最终会转换成物理地址,即给定一个虚拟地址 X,系统能把他转换成物理地址 Y。因此系统得保存虚拟地址到物理地址的映射关系。很容易想到维护一个 1 : 1 的映射表,即虚拟地址和物理地址一一对应,然而实际不可行,保存这样的一个映射表这需要占用大量的存储空间。

解决办法是通过把虚拟内存和物理内存分成连续、固定大小的块。虚拟内存中,我们把块称之为 虚拟页(Virtual Page),物理内存中,我们把块称之为物理页(Physical Page,有的地方也称之为帧,Frame)。绝大部分情况下,页的大小是 4KB,寻址时,以页为单位进行操作。同时,处理器上有个称为MMU(Memory Management Unit) 的部件,它的功能是将虚拟地址转换为物理地址,虚拟页和物理页间的映射关系保存在一个被称为 页表 的数据结构中,每个进程都有自己的页表,页表存放在内存,由操作系统管理。下图展示了这种映射关系,虚拟内存和物理内存中每个格子表示一个页。当应用程序读写一个虚拟地址时,MMU 会计算它的虚拟页索引,并在页表中找这个虚拟页对应的物理页,一旦找到,就返回给应用程序,这样就完成了一次实际的内存读写。|500

缺页异常

上图 3 中,可以看到有的虚拟页并没有映射到物理页(编号 6、7 的虚拟页),当应用程序访问这种还没有映射的页时,找不到对应的物理页,这种情况被称为 缺页(Page Missing) ,缺页就会触发一个 缺页异常(Page Fault) ,MMU 会检测到缺页异常,并把控制权交给操作系统,操作系统会执行缺页异常处理程序,它会尽可能为这个虚拟页映射一个可用的物理页,如果找不到,就牺牲一个已经映射的物理页,把它回写到硬盘,然后把该页映射到新的虚拟页。当缺页异常处理程序返回时,它会重新执行导致缺页的指令,该指令把导致缺页的虚拟地址重新发给 MMU,由于现在页表已经有了映射关系,因此不会再引发缺页了。

页面调度

上述的这种在硬盘和内存交换页的行为,称为页面调度(Paging),或者页交换(Swapping)。页面调度有可能发生在缺页异常时,也可能发生在其他场景。由于内存总是稀缺资源,当一个应用程序暂时不活动,或者某些映射的物理页暂时未使用到,操作系统会把它先保存到硬盘里,等需要的时候,再从硬盘换到内存。这实际就提供了一种“部分加载”或“懒加载”的机制——直到应用需要某些数据时,才从硬盘中加载,否则先不加载。

页面调度对应用程序是透明的,应用程序不用考虑内存是否足够能加载它,正因为如此,在应用程序看来,内存是无限的,所以,在一个 4G 内存的机器上,可以运行需要 5G 或更多内存的程序。

但凡事都有两面性,换页也有负面作用。我们知道硬盘的速度远慢于内存(10 万倍的数量级差距)。因此换页时,操作系统把数据从硬盘拷贝到内存是一个很耗时的工作,表现就是“电脑突然卡了下”。当系统运行的程序过多,缺页异常频繁发生,系统不停的进行页面调度工作,换页操作花费的时间甚至比运行程序本身还要多,这时系统表现就是卡顿,这种情况称为 系统颠簸(Thrashing) 。处理方式就是对症下药:要么加大内存,或者关掉一些程序。

内存保护

虚拟内存机制也很好的解决了进程的安全问题,有了 MMU 和页表,通过给页表加一些标志,可以实现一个进程只能访问属于它自己的虚拟内存,以及控制页的读写权限。任何试图访问一个不属于进程自己的的内存地址,或者对一个只读的内存地址进行写操作,都会被操作系统检测到,并抛出一个错误,Unix 系统中叫 段错误(Segmentation Fault) ,Windows 系统中叫 非法访问(Access Violation) ,通常此时操作系统会中止或杀掉进程。

虚拟内存还有很多其他的使用方式,例如内存映射文件。读写一个文件时,一般我们会通过 read/write 等系统调用的方式,把文件拷贝到内存。但如果使用内存映射的方式,可以省掉拷贝的操作,直接访问文件,就好像它已经被加载到了内存。当真正需要读写时,虚拟内存会保证把必要的数据从硬盘搬到内存,这种方式可以显著提高文件访问效率。

例如还有共享物理内存,当多个进程需要使用某个数据时,可以在物理内存中只放一份,然后在把各自的虚拟页指向它,这样就能达到共享内存的目的。典型的如 fork 系统调用创建子进程,实际就只是复制了一份父进程的页表,再配合写时复制机制,因此效率是很高的。

内存分段

内存分段是 操作管理虚拟地址与物理地址之间关系 的方式之一,还有一种是内存分页。

程序是由若干个逻辑分段组成的,可由代码分段、数据分段、栈段、堆段组成。 不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

分段机制下,虚拟地址和物理地址是如何映射的?

分段机制下的虚拟地址由两部分组成, 段内选择子段内偏移量

515

  • 段选择子 就保存在段寄存器里面。段选择子里面最重要的是 段号 ,用作段表的索引。 段表 里面保存的是这个 段的基地址、段的界限和特权等级 等。
  • 虚拟地址中的 段内偏移量 应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

虚拟地址是通过 段表 与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:

525

如果要访问段 3 中偏移量 500 虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

  • 第一个就是 内存碎片 的问题。
  • 第二个就是 内存交换的效率低 的问题。

分段为什么会产生内存碎片问题?

我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:

  • 游戏占用了 512MB 内存
  • 浏览器占用了 128MB 内存
  • 音乐占用了 256 MB 内存。

这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。

如果这个 256MB 不是连续的,而是被分成了两段 128 MB 内存,这就会导致没有空间再打开一个空间 200MB 的程序。

515

内存碎片的问题

这里的内存碎片的问题共有两处地方:

  • 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
  • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;这也就涉及到了另一个知识 计算机字节对齐

解决外部内存碎片的问题就是 内存交换

可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的时间 256MB 空间,于是新的 200MB 程序就可以装载进来。

这个内存交换空间,在 Linux 系统里,也就是我们经常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换

分段为什么会导致内存交换效率低?

对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。

所以, 如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

内存分页

分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。

要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换的写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是 内存分页Paging)。

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小 。这样一个连续并且尺寸固定的内存空间,我们叫 Page)。在 Linux 下,每一页的大小为 4KB。

虚拟地址与物理地址之间通过 页表 来映射,如下图:

515

内存映射

页表实际上存储在 CPU 的 内存管理单元MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。

而当进程访问的虚拟地址在页表中查不到时,系统会产生一个 缺页异常 ,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

分页是怎么解决分段的内存碎片、内存交换效率低的问题?

由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而 采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为 换出Swap Out)。一旦需要的时候,再加载进来,称为 换入Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间, 内存交换的效率就相对比较高。

525

更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不是真的把页加载到物理内存里,而是 只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

分页机制下,虚拟地址和物理地址是如何映射的?

在分页机制下,虚拟地址分为两部分, 页号页内偏移 。页号作为页表的索引, 页表 包含物理页每页所在 物理内存的基地址 ,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。

535

内存分页寻址

总结一下,对于一个内存地址转换,其实就是这样三个步骤:

  • 把虚拟内存地址,切分成页号和偏移量;
  • 根据页号,从页表里面,查询对应的物理页号;
  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:

515

简单分页的缺陷

有空间上的缺陷。

因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

多级页表

要解决上面的问题,就需要采用的是一种叫作 多级页表Multi-Level Page Table)的解决方案。

在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成 二级分页 。如下图所示:

|500

分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?

当然如果 4GB 的虚拟地址全部都映射到了物理内上的,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的 局部性原理 么?

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但 如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表 。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB

,这对比单级页表的 4MB 是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以 页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项 (此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 全局页目录项 PGD(Page Global Directory);
  • 上层页目录项 PUD(Page Upper Directory);
  • 中间页目录项 PMD(Page Middle Directory);
  • 页表项 PTE(Page Table Entry);

|500

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。

510

程序的局部性

我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

|475

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。

有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。

段页式内存管理

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为 段页式内存管理

|500

段页式地址空间

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由 段号、段内页号和页内位移 三部分组成。

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

|450

段页式管理中的段表、页表与内存的关系

段页式地址变换中要得到物理地址须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

总结

Note

虚拟内存是连续的

分段和分页的本质区别就是粒度,连续性,按需。
分段是全量,粗,连续的,这就很简单。
分页不是,分页是相反的

对每一个进程来讲其内部的地址都是从 0 开始的完整地址
动态增加,对该进程来说也只是从 8 到 9。至于实际上页表里面会怎么变动,不重要。

参考