虚拟内存
4.1 为什么 要有虚拟内存?
本篇跟大家说说 内存管理,内存管理还是比较重要的一个环节,理解了它,至少对 整个操作系统
的工作会 有一个初步的轮廓,这也难怪面试的时候常问内存管理。
干就完事,本文的提纲:

虚拟内存
如果你是电子相关专业 的,肯定在大学里捣鼓过单片机。
单片机是没有操作系统的,所以每次 写完代码,都需要借助工具把程序烧录进去,这样程序才能
跑起 来。
另外,单片机的 CPU 是直接操作内存的「物理地址 」。

在这种情况下,要想在内存中同时运行两个 程序是不可能的。如果第一个程序在 2000 的位置写入
一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个 程序是根本
行不通的,这两个 程序会立刻崩溃。
操作系统是如何解决这个问题呢?
这里关键的问题是这两个 程序都引用了绝对物理地址 ,而这正是我们最需要避免的。
我们可以把进程所使用的地址 「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址 就行,互不 干涉。但是有个前提每个进程都不能访问物理
地址 ,至于虚拟地址 最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安
排的明明 白白了。

操作系统会提供一种机制,将不同进程的虚拟地址 和不同内存的物理地址 映射起来。
如果程序要访问虚拟地址 的时候,由操作系统转换成不同的物理地址 ,这样不同的进程运行的时
候,写入的是不同的物理地址 ,这样就不会冲突了。
于是,这里就引出了两 种地址 的概念:
我们程序所使用的内存地址 叫做虚拟内存地址 (Virtual Memory Address )
实际存在硬件里面的空间地址 叫物理内存地址 (Physical Memory Address )。
操作系统引入了虚拟内存,进程持有的虚拟地址 会通过 CPU 芯片中的内存管理单元(MMU )的映
射关系,来转换变成物理地址 ,然后再通过物理地址 访问内存,如下图所示:

操作系统是如何管理虚拟地址 与物理地址 之间的关系?
主要有两种方式,分别 是内存分段和内存分⻚,分段是比较早提出的,我们先来看看 内存分段。
内存分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有
不同的属性的,所以就用分段(Segmentation )的形式 把这些段分离出来。
分段机制下,虚拟地址 和物理地址 是如何映射的?
分段机制下的虚拟地址 由两部分组成,段选择因子和段内偏移量。

段选择因子和段内偏移量:
段选择子就保存在段寄存 器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面
保存的是这个段的基地址 、段的界限和特权等级等。
虚拟地址 中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将 段基地址
加上段内偏移量得到物理内存地址 。
在上面,知道了虚拟地址 是通过段表与物理地址 进行映射的,分段机制会把程序的虚拟地址 分成 4
个段,每个段在段表中有一个项,在这一项找到段的基地址 ,再加上偏移量,于是就能找到物理
内存中的地址 ,如下图:

如果要访问段 3 中偏移量 500 的虚拟地址 ,我们可以计算出物理地址 为,段 3 基地址 7000 + 偏
移量 500 = 7500 。分段的办法很好,解决了程序本身不需要关心具体的物理内存地址 的问题,但它也有一些不 足之
处:
第一个就是内存碎片的问题。
第二个就是内存交换的效率低的问题。
接下来,说说 为什么 会有这两个 问题。
我们先来看看 ,分段为什么 会产生内存碎片的问题?
我们来看看 这样一个例子。假设有 1G 的物理内存,用⼾执行了多个程序,其中:
游戏占用了 512MB 内存
浏览器占用了 128MB 内存
音乐占用了 256 MB 内存。
这个时候,如果我们关闭了浏览器, 则空闲内存还有 1024 - 512 - 256 = 256MB 。如果这个 256MB 不是连续的,被分成了两 段 128 MB 内存,这就会导致没有空间再打开一个
200MB 的程序。

内存分段会出现内存碎片吗?
内存碎片主要分为,内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出
现内部内存碎片。
但是由于每个段的⻓度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不 连
续的小物理内存,导致新的程序无法被装 载,所以会出现外部内存碎片的问题。
解决「外部内存碎片」的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回
的时候,我们不能装载回原来的位置,而是紧紧 跟着那已经被占用了的 512MB 内存后面。这样就
能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进 来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出
来的,用于内存与硬盘的 空间交换。
再来看看 ,分段为什么 会导致内存交换效率低的问题?
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,
那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。
因为硬盘的 访问速度要比内存慢太多 了,每一次内存交换,我们都需要把一大段连续的内存数据
写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡
顿。
为了 解决内 存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分⻚。
内存分⻚
分段的好处 就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的
问题。
要解 决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时
候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就
是内存分⻚(Paging )。
分⻚是把整个虚拟和物理内存空间切成一段段 固定尺寸的大小。这样一个连续并且尺寸固定的内
存空间,我们叫⻚(Page )。在 Linux 下,每一⻚的大小为 4KB 。
虚拟地址 与物理地址 之间通过⻚表来映射,如下图:

⻚表是存储在内存里的,内存管理单元 (MMU )就做将虚拟内存地址 转换成物理地址 的工作。
而当进程访问的虚拟地址在 ⻚表中查不到时,系统会产生一个缺⻚异常,进入系统内核空间分配
物理内存、更新进程⻚表,最后再返回用⼾空间,恢复进程的运行。
分⻚是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?
内存分⻚由于内存空间都是预先划分 好的,也就不会像内存分段一样,在段与段之间会产生间隙
非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分⻚,⻚与⻚之间是紧密排列
的,所以不会有外部碎片。
但是,因为内存分⻚机制分 配内存的最小单位是一⻚,即使程序不足一⻚大小,我们最少只能分
配一个⻚,所以⻚内会出现内存浪费,所以针对内存分⻚机制会有内部内存碎片的现象。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存⻚面给释放
掉,也就是暂时 写在硬盘上,称为换出(Swap Out )。一旦需要的时候,再加载进 来,称为换入
(Swap In )。所以,一次性写入磁盘的 也只有少数的一个⻚或者几个⻚,不会花太多 时间,内存交
换的效率就相对比较高。

更进一步地,分⻚的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存
中。我们完全可以在进行虚拟内存和物理内存的⻚之间的映射之后,并不真的把⻚加载到物理内
存里,而是只有在程序运行中,需要用到对应虚拟内存⻚里面的指令和数据时,再加载到物理内
存里面去。
分⻚机制下,虚拟地址 和物理地址 是如何映射的?
在分⻚机制下,虚拟地址 分为两 部分,⻚号和⻚内偏移。⻚号作为⻚表的索引,⻚表包含物理⻚
每⻚所在物理内存的基地址 ,这个基地址 与⻚内偏移的组合就形成了物理内存地址 ,⻅下图。

总结一下,对于一个内存地址 转换,其实就是这样三个 步骤:
把虚拟内存地址 ,切分 成⻚号和 偏移量;
根据⻚号,从⻚表里面,查询对应的物理⻚号;
直接拿 物理⻚号,加上前面的偏移量,就得到了物理内存地址 。
下面举个 例子,虚拟内存中的⻚通过⻚表映射为了 物理内存中的⻚,如下图:

这看起来似乎没什么 毛病,但是放到实际中操作系统,这种简单的分⻚是肯定是会有问题的。
简单的分⻚有什么 缺陷吗?
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着⻚表会非常的庞大。
在 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 个「⻚表项」,形成二级分⻚。如下图所示:

你可能会问,分了二级表,映射 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 );

TLB
多级⻚表虽然解决了空间上的问题,但是虚拟地址 到物理地址 的转换就多了几道转换的工序,这
显然就降低了这俩地址 转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行
所访问的存储空间也局限于某个内存区域。

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

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit )芯片,它用来完成地址 转
换和 TLB 的访问与交互 。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB ,如果没找到,才会继续查常规的⻚表。
TLB 的命中率其实是很高的,因为程序最常访问的⻚就那么几个。
段⻚式内存管理
内存分段和内存分⻚并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来
后,通常称为段⻚式内存管理。

段⻚式内存管理实现的方式:
先将程序划分 为多个有逻辑意义的段,也就是前面提到的分段机制;
接着再把每个段划分 为多个⻚,也就是对分段划分出 来的连续空间,再划分 固定大小的⻚;
这样,地址 结构就由段号、段内⻚号和 ⻚内位移三部分组成。
用于段⻚式地址 变换的数据结构是每一个程序一张段表,每个段又建立一张⻚表,段表中的地址
是⻚表的起始地址 ,而⻚表中的地址 则为某⻚的物理⻚号,如图所示:

段⻚式地址 变换中要得到物理地址 须经过三次内存访问:
第一次访问段表,得到⻚表起始地址 ;
第二次访问⻚表,得到物理⻚号;
第三次将物理⻚号与⻚内位移组合,得到物理地址 。
可用软、硬件相结合的方法实现段⻚式地址 变换,这样虽然增加了硬件成本和系统开销,但提高
了内存的利用率。
Linux 内存布局
那么,Linux 操作系统采用了哪种方式来管理内存呢?
在回答这个问题前,我们得先看看 Intel 处理器的发展历史。
早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快 发现,光有段式内存管理而没
有⻚式内存管理是不够的,这会使 它的 X86 系列会失去市场的竞争力。因此,在不久 以后的
80386 中就实现了⻚式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管
理的同时还实现了⻚式内存管理。
但是这个 80386 的⻚式内存管理设计 时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,⻚式内存管理的作用是在由段式内存管理所映射而成的地址 上再加上一层地址映射。
由于此时由段式内存管理映射而成的地址 不再是“物理地址 ”了,Intel 就称之为 “线性地址 ”(也称虚拟地址 )。于是,段式内存管理先将逻辑地址 映射成线性地址 ,然后再由⻚式内存管理将线性地址映射成物理地址 。

这里说明下逻辑地址 和线性地址 :
程序所使用的地址 ,通常是没被段式内存管理映射的地址 ,称为逻辑地址 ;
通过段式内存管理映射的地址 ,称为线性地址 ,也叫虚拟地址 ;
逻辑地址 是「段式内存管理」转换前的地址 ,线性地址 则是「⻚式内存管理」转换前的地址 。
了解完 Intel 处理器的发展历史后 ,我们再来说说 Linux 采用了什么 方式管理内存?
Linux 内存主要采用的是⻚式内存管理,但同时也不 可避免地涉及了段机制。
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址 先进行
段式映射,然后才能进行⻚式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的
选择。
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不 起什么 作用。也就是说,“上
有政策,下有对策”,若惹不起就躲着走。
Linux 系统中的每个段都是从 0 地址 开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段
的起始地址 都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代
码,所面对的地址 空间都是线性地址 空间(虚拟地址 ),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
我们再来瞧一瞧,Linux 的虚拟地址 空间是如何分布的?
在 Linux 操作系统中,虚拟地址 空间的内部又被分为内核空间和用⼾空间两部分,不同位数的系
统,地址 空间的范围也不 同。比如最常⻅的 32 位和 64 位系统,如下所示:

通过这 里可以看出:
32 位系统的内核空间占用 1G ,位于最高处,剩下的 3G 是用⼾空间;
64 位系统的内核空间和用⼾空间都是 128T ,分别 占据整个内存空间的最高和最低处,剩下
的中间部分是未定义的。
再来说说 ,内核空间与用⼾空间的区别:
进程在用⼾态时,只能访问用⼾空间内存;
只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址 ,其实关联的都是相同
的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

接下来,进一步了解虚拟空间的划分 情况,用⼾空间和内核空间划分 的方式是不同的,内核空间
的分布情况就不多说了。
我们看看 用⼾空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:

通过这 张图你可以看到,用⼾空间内存,从低到高分别 是 6 种不同的内存段:
代码段,包括二进制可执行代码;
数据段,包括已初始化的静态常量和全局变量;
BSS 段,包括未初始化的静态变量和全局变量;
堆段,包括动态分配的内存,从低地址 开始向上增⻓;
文件映射段,包括动态库、共享内存等,从低地址 开始向上增⻓(跟硬件和内核版本有 关 );
栈段,包括局部变量和函数调用的上下 文等。栈的大小是固定的,一般是 8 MB 。当然系统也
提供了参数,以便 我们自定义大小;
上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留
区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址 不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL 。因此,这里会出现一段不可访问
的内存保留区,防止程序因为出现 bug ,导致读或写了一些小内存地址 的数据,而使得程序跑
⻜。
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
或者 mmap() ,就可以分别 在堆和文件映射段动态分配内存。总结
为了 在多进程环境下,使得进程之间的内存地址 不受影响,相互隔离,于是操作系统就为每个进
程独立分配一套虚拟地址 空间,每个程序只关心自己的虚拟地址 就可以,实际上大家的虚拟地址
都是一样的,但分布到物理地址 内存是不一样的。作为程序,也不 用关心物理地址 的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然
会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时 存放到硬盘(换出),在需
要的时候再装载回物理内存(换入)。
那既然有了虚拟地址 空间,那必然要把虚拟地址 「映射」到物理地址 ,这个事 情通常由操作系统
来维护。
那么对于虚拟地址 与物理地址 的映射关系,可以有分段和分⻚的方式,同时两者结合都是可以
的。
内存分段是根据程序的逻辑⻆度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同
属性的段,同时是 一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片
和内存交换效率低的问题。
于是,就出现了内存分⻚,把虚拟空间和物理空间分成大小固定的⻚,如在 Linux 系统中,每一⻚
的大小为 4KB 。由于分了⻚后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问
题。同时在内存交换的时候,写入硬盘也就一个⻚或几个⻚,这就大大提高了内存交换的效率。
再来,为了 解决简单分⻚产生的⻚表过大的问题,就有了多级⻚表,它解决了空间上的问题,但
这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的
局部性原理,在 CPU 芯片中加入了 TLB ,负责 缓存最近常被访问的⻚表项,大大提高了地址 的转
换速度。
Linux 系统主要采用了分⻚管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管
理。于是 Linux 就把所 有段的基地址 设为 0 ,也就意味着所有程序的地址 空间都是线性地址 空间
(虚拟地址 ),相当于屏蔽了 CPU 逻辑地址 的概念,所以段只被用于访问控制和内存保护。
另外,Linux 系统中虚拟空间分布可分为用⼾态和内核态两部分,其中用⼾态的分布:代码段、全
局变量、BSS 、函数栈、堆内存、映射区。
最后,说下虚拟内存有什么 作用?
第一,虚拟内存可以使 得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,
CPU 访问内存会有很明显 的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以
把它换出到 物理内存之外,比如硬盘上的 swap 区域。
第二,由于每个进程都有自己的⻚表,所以每个进程的虚拟内存空间就是相互独立的。进程也
没有办法访问其他进程的⻚表,所以这些⻚表是私有的,这就解决了多进程之间地址 冲突的问
题。
第三,⻚表里的⻚表项中除了物理地址 之外,还有一些标记属性的比特,比如控制一个⻚的读
写权限,标记该 ⻚是否存在等。在内存访问方面,操作系统提供了更好的安全性。
