虚拟内存漫游

对于理解虚拟内存,从这里开始:用户程序生成的每个地址都是虚拟地址(every address generated by a user program is a virtual address)。操作系统只是为每个进程提供一个假象,具体来说,就是它拥有自己的大量私有内存。在一些硬件帮助下,操作系统会将这些假的虚拟地址变成真实的物理地址,从而能够找到想要的信息。

操作系统为什么要提供这种假象呢?

主要是为了易于使用(ease of use)。操作系统会让每个程序觉得,它有一个很大的连续地址空间(address space)来放入其代码和数据。因此,作为一名程序员,就不必担心诸如"我应该在哪里存储这个变量?"这样的事情,因为程序的虚拟地址空间很大,有很多空间可以存代码和数据。对于程序员来说,如果必须操心将所有的代码数据放入一个小而拥挤的内存,那么生活会变得痛苦得多。

地址空间

早期的操作系统非常简单,因为用户对操作系统期望不高。但是有一些吊毛对操作系统提出了易于使用高性能可靠性 等要求,这才导致虚拟内存非常的复杂。

如上图所示,以前的操作系统就是存在于内存中的一个库(如果你没有想深入了解OS的想法,建议你继续保持这个印象)。操作系统运行一个程序相当的直接,将程序加载进行,整个内存由该程序独享。

一段时间后,由于计算机昂贵,人们开始共享机器。由于经常会出现多个用户都要使用机器的情况,所以让计算机同时运行多个程序就提上了日程------分时系统诞生了。

一种实现时分共享的方法,是让一个进程单独占用全部内存运行一小段时间,然后停止它,并将它所有的状态信息保存在磁盘上,加载其他进程的状态信息,再运行一段时间。

但是,这个办法太慢了,特别是当内存增长的时候,虽然保存寄存器很快,但是保存内存中的所有信息就相当的慢了。所以一种改进的做法就是切换进程的时候,不将进程的内存信息换到磁盘,而是留在内存里面,如下图:

上图中,有3个进程,每个进程只使用内存的一部分空间。假设我们只有一个CPU,操作系统会选择其中的一个进程运行,剩余的排队。

但是这样会出现一个问题,就是进程 B 可能会不小心的访问到进程 C 里面的内存内容。新的要求就出现了:不能让一个进程读取其他进程的内存内容。

还有一个问题:上面的例子中,我们有 A B C 3个进程,它们程序的地址空间都是上图这样的。那么当操作系统决定执行进程 A 的 program code 的时候,它需要找到的是320KB处的内容,而不是 0 处的内容。

为了解决这两个问题,操作系统做了一个抽象:给程序一个虚拟的地址空间,有了地址空间,我们就可以算出程序占用内存的大小,然后记录其大小与存放的内存地址,最后做一个地址转换就行,下图是一个地址空间的例子:

地址转换

为了更好地理解实现地址转换需要做什么,我们先来看一个简单的例子。

csharp 复制代码
void func() {
 int x;
 ...
 x = x + 3; // this is the line of code we are interested in
 ...
}

该函数对应的汇编如下(x 的地址在 ebx 寄存器里面):

perl 复制代码
128: movl 0x0(%ebx), %eax ;将 ebx 储存的地址的值(x的值)放到 eax 里面
132: addl $0x03, %eax ;将 eax 的值加 3
135: movl %eax, 0x0(%ebx) ;将 eax 的值放到 ebx 的储存的地址里面(就是更新 x)

该程序的地址空间如下:

从程序的角度来说,它的地址空间从 0 开始到 16KB 结束。然而,对于操作系统来说,它将该程序加载到内存的时候,一般会随机分配一个位置,而不是将它放到内存里面从 0 开始的位置。因此,我们就遇到了一个问题:怎么样提供一种虚拟地址空间从 0 开始的假象,而实际上地址空间位于另外某个物理地址?下图是一种实现:

它将程序放到了 32KB 开始位置,所以操作系统需要对地址进行处理。具体来说,每个 CPU 需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,基址寄存器帮我们做地址转换,界限寄存器帮我们做内存隔离。

采用这种方式,在编写和编译程序时假设地址空间从零开始。但是,当程序真正执行时,操作系统会决定其在物理内存中的实际加载地址,并将起始地址记录在基址寄存器中。

具体来看前面序列中的一条指令:

perl 复制代码
128: movl 0x0(%ebx), %eax

程序计数器(PC)首先被设置为 128。当硬件需要获取这条指令时,它先将这个值加上基址寄存器中的 32KB(32768),得到实际的物理地址 32896,然后硬件从这个物理地址获取指令。接下来,处理器开始执行该指令。这时,进程发起从虚拟地址 15KB 的加载,处理器同样将虚拟地址加上基址寄存器内容(32KB),得到最终的物理地址 47KB,从而获得需要的数据。

分段

上面我们讨论的都是基于一个假设:所有进程的地址空间都能完整的加载到内存中,但是显然不太符合现实情况。同时,我们也会发现在上面的图中,当堆和栈没有被使用的时候,却仍然占用了内存。

为了解决这个问题,我们可以给代码、堆和栈各分配一个基址和界限寄存器对。这样就避免了虚拟地址空间中的未使用部分占用物理内存。

上图中就是使用了 3 组基址和界限寄存器的例子,寄存器的值如下:

来看一个堆中的地址,虚拟地址 4200。如果用虚拟地址 4200 加上堆的基址(34KB),得到物理地址 39016,这是不对的。我们应该先减去堆的偏移量。因为堆从虚拟地址 4K(4096)开始,4200 的偏移量实际上是 4200 减去 4096,即 104,然后用这个偏移量(104)加上基址寄存器中的物理地址(34KB),得到真正的物理地址 34920。

那么,还有一个问题,硬件做地址翻译的时候是如何分辨某一个地址是属于那个段的呢?

看下面的图:

以上面的例子来说,如果前两位是 00,硬件就知道这是属于代码段的地址。如果前两位是 01,则是堆地址。如果前两位是10,则是栈地址。

所以,硬件只需要拿到 offset 即可,因为 offset 刚好就是段内偏移。我们有理由猜测,编译器在生成地址的时候,肯定是做了很多考量的。

我们上面的图中,栈与堆的生长方向一直是相反的,所以我们的寄存器里面还需要记录一个标记:

通过 grow positive?标记我们就可以知道,拿到 offset 后是应该加还是减。

分段的问题

分段也带来了一些新的问题。每个进程都有一些段,每个段的大小都有不同,所以会导致物理内存出现很多空闲的小洞。这些洞很难分配给别的段。这种问题被称为外部碎片(external fragmentation)。

图中,左图展现了分段导致的外部碎片问题。右图是整理过后的,它使用某种方式重新安排了原有的段,在垃圾回收里面的文章里面讨论过整理的麻烦性,而且几乎不可能实现对C这种语言的处理。

所以,一种更简单的做法是利用空闲列表管理算法,保留大的内存块用于分配。这种管理方法在 malloc 等实现中会用到,但是不适用于虚拟内存的管理。既然说到这里呢,再说一个有意思的话题:为什么在你的进程退出时没有内存泄露?

c 复制代码
原因很简单:系统中实际存在两级内存管理。
第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。
第二级管理在每个进程中,例如在调用 malloc()和 free()。当 malloc 的空间不够时,malloc 内部会通过 brk 等系统调用扩大内存。
第二级内存管理就是使用了空闲链表的方式来管理空闲内存。

分页

现在开始进入正题

将空间分割为固定长度的块,这种思想在虚拟内存里面就叫分页。

我们将内存看成一个一个连续的块,每个块叫做物理页(或者是页帧 page frame)。

看下面的一个例子:

操作系统将地址空间的虚拟页 0 放在物理页帧 3,虚拟页 1 放在物理页帧 7,虚拟页 2放在物理页帧 5,虚拟页 3 放在物理页帧 2。页帧 1、4、6 目前是空闲的。

为了记录这个映射关系,操作系统通常为每个进程保存一个数据结构,称为页表(page table)。

那么这个映射是如何建立的呢?我们拿 core i7 的地址翻译来举例。

PPO - phyiscal page offset

从上图可以看出虚拟内存地址为 48 位,物理内存的地址为 52位。这是一个很神奇的事情,物理地址的范围比虚拟地址的范围还要大。但是也有可能某些机器上物理内存的地址范围只有32位。所以从虚拟地址与物理地址范围大小的这个关系是无法确定的。

要实现从虚拟地址到物理地址的转换,只需要实现这样的一个表即可:

css 复制代码
Table[VPN] = PPN

我们将虚拟地址拆成两部分:VPN + VPO。物理页页拆成2部分:PPN + PPO。

VPO/PPO 有 12 位,这并不是随便定的,因为 212是4KB,4KB是一个页的常用大小。

将内存进行分页后,我们只需要保存页面号的映射关系即可,PPO 与 VPO 永远是一致的。

下面的图是一个例子:

页表的进化

假设我们有一个32位物理地址空间的机器,按照4KB分页,它的虚拟地址分成 20 位的 VPN 和 12 位的VPO。我们可以计算一下其VPN的个数为 220,大约一百万。假设我们的页表的每一项只需要4个字节,那么一个页表就会耗费掉4MB内存。如果有100个进程,那么400MB内存就没了!这还仅仅只是为了地址转换。

如果我们的机器有64位的物理地址空间的话,那不是要爆炸了!这个问题该如何解决呢?我们后面说到。

因为页表还是比较耗费空间的,所以没法将他储存到硬件上,只能放到内存里面。我们先看看一个真实的页表结构:

上图中,第12-51位储存了PPN,它可以做到地址转换,该结构里面还有很多其他信息,是否可读,是否被更改等等。

我们再回到之前的问题,如何解决页表过大的问题?

想一下页表为何会过大:是因为我们假设了一个非常大的虚拟地址空间。

但是并不是每一个虚拟地址空间我们都会用到,所以问题转换为:如何去掉页表中的所有无效区域,而不是将它们全部保留在内存中?

一种好的解决办法就是多级页表:它将线性页表变成了类似树的东西。

上面,我们说到虚拟地址空间的时候,经常会给出一个简略的图,但是一个更加真实的例子应该如下图所示:

可以看到,我们的代码段是从 0x400000 处开始,那么从 0 - 0x3FFFFF的虚拟地址就没必要储存到页表里面,因为我们知道这段地址不会被使用。同样的,还有很多其他的地址范围都不会被用到。这些不会被用到的地址占了大头。

多级页表的基本思想很简单。上面我们使用table[vpn] = ppn 描述了页表。多级页表其实就是table嵌套table,下图是一个二级页表的例子:

这样,我们就可以懒加载。上面的图中,PTE2 - PET7 对应的虚拟地址范围没有被使用到,所以它就不用维护映射。

core I7 里面有4级页表:

多级页表中,前面 k-1 级页表项里面储存的是后一级页表的基址

第一级页表的基地址储存在一个寄存器中

虚拟地址的翻译过程:根据虚拟地址的高9位,找到第1级页表的索引,拿到对应的二级页表的地址,然后根据高9-17位再找第2级页表的索引,依次类推,找到第3级,第4级页表和地址与索引,然后取出PPN,拼接VPO,得到物理地址。

每级页表占据 9 位,也就是说,第一级页表有 512 项,第二级页表有 512 * 512 项,以此类推。但是由于每一项可能为空(事实上大部分为空),所以算下来也节省了不少空间。

TLB

看一个例子:

ini 复制代码
int array[1000];
...
for (i = 0; i < 1000; i++)
 array[i] = 0;

其汇编代码如下:

perl 复制代码
0x1024 movl $0x0,(%edi,%eax,4)
0x1028 incl %eax
0x102c cmpl $0x03e8,%eax
0x1030 jne 0x1024

不懂汇编没关系,暂时只需要知道CPU在执行指令之前需要先"取指",也就是 fetch 阶段。

fetch 阶段需要从程序的 .text 段读取内容,由于虚拟地址的原因,所以它也需要先访问页表,然后得到物理地址,才能获取指令。即每个 fetch 阶段,访问了两遍内存(一次是页表,一次是指令)。

其中,mov 指令里面的() 表示访问内存地址,也就是说,计算出寄存器的值后,还要访问这个值表示的虚拟地址里面的内容。这个过程又有2次内存访问。一次循环要访问10次内存,这太慢了。

所以我们需要一个东西加速地址翻译。帮助常常来自操作系统的老朋友:硬件(translation-lookaside buffer,TLB)。

TLB可以简单理解为一个高速缓存,它储存了 VPN 到 PPN 的映射:

上图中,TLB 将VPN又分为了2个部分:TLBT + TLBI。这是由TLB的组织形式决定的,与硬件相关暂时不介绍。简单的将 TLB 理解为一个二维数组,这两部分就是索引。

为了更深入的理解,我们看一个例子:

假设有一个由 10 个 4 字节整型数组成的数组,起始虚地址是 100。有一个 8 位的小虚地址空间,页大小为 16B。我们可以把虚地址划分为 4 位的 VPN和 4 位的VPO。

有这样的一个程序:

ini 复制代码
int sum = 0;
for (i = 0; i < 10; i++) {
 sum += a[i];
}

当访问第一个数组元素(a[0])时,CPU 会看到载入虚存地址 100。硬件从中提取VPN(VPN=06),然后用它来检查 TLB,寻找有效的转换映射。假设这里是程序第一次访问该数组,结果是 TLB 未命中。

接下来访问 a[1],TLB 命中!因为数组的第二个元素在第一个元素之后,它们在同一页。TLB中缓存了该页的转换映射,因此成功命中。访问 a[2]同样成功。

当程序访问 a[3]时,会导致 TLB 未命中。但同样,接下来几项(a[4] ... a[6])都会命中 TLB,因为它们位于内存中的同一页。

最后,访问 a[7]会导致最后一次 TLB 未命中。系统会再次查找页表,弄清楚这个虚拟页在物理内存中的位置,并相应地更新 TLB。最后两次访问(a[8]、a[9])两次都命中。

我们算出该程序 TLB 命中率(hit rate)为 70%,不算很好,但是想想后面我们再访问数组就是 100% 的命中率。

TLB没有命中的情况下:硬件会"遍历"页表,找到正确的页表项,取出想要的转换映射,用它更新 TLB,并重试该指令

上下文切换带来的问题

有了 TLB,在进程间切换时(因此有地址空间切换),会面临一些新问题。具体来说,TLB 中包含的虚拟到物理的地址映射只对当前进程有效,对其他进程是没有意义的。

我们来看一个例子。当一个进程(P1)正在运行时,假设TLB 缓存了对它有效的地址映射,即来自 P1 的页表。对这个例子,假设 P1 的 10 号虚拟页映射到了 100 号物理帧。在这个例子中,假设还有一个进程(P2),操作系统不久后决定进行一次上下文切换,运行 P2。这里假定 P2 的 10 号虚拟页映射到 170 号物理帧。如果这两个进程的地址映射都在 TLB 中:

很明显有一个问题:VPN 10 被转换成了 PFN 100(P1)和 PFN 170(P2),但硬件分不清哪个项属于哪个进程。

一种方法是在上下文切换时,简单地清空(flush)TLB。但是,有一定开销:每次进程运行,当它访问数据和代码页时,都会触发 TLB 未命中。如果操作系统频繁地切换进程,这种开销会很高。

所以,我们可以给 TLB 添加一些位(ASID 储存进程 的 PID):

内存映射

有了上面的知识,配合前面文件系统的文章,内存映射就非常的好理解了。

我们已经知道,单纯的读取一个文件,需要经过内核缓冲区,也就是说,数据先从磁盘拷贝到内核缓存区,然后再从内核缓冲区拷贝到用户空间。

内核缓存很好,但是在某些情况下,我们不希望多这一道缓存,比如,加载程序的 .text 段的时候,因为 .text 段经常会用到,所以我们希望它能直接储存在内存中,而不是还要经过一道缓存。所以操作系统就实现了这样的一个功能:

这种做法的神奇之处就在于它绕过了文件系统,直接在程序的虚拟地址空间里面开辟一块位置出来,给 mmap 使用。

假设我们是对一个文件做了映射,那么当程序要访问这个文件,发现这个虚拟页在页表中对应的物理页不存在,会触发缺页异常 ,通过缺页异常处理程序在物理内存中分配物理页帧。

有了物理页帧后,将磁盘文件加载到物理内存中,并修改页表上对应虚拟页号的物理页号。

这样一来,映射到物理内存中的每一个页对应的就是磁盘文件中的数据。达到了读取物理内存的数据相当于读取磁盘文件中的数据的目的。如果对物理内存数据进行修改,那么磁盘文件的数据也会被修改(操作系统会帮我们处理脏页,有兴趣的可以自己深入研究)。

可以看出,内存映射并不是什么特殊的东西,很多地方都会用到,比如加载程序的代码段等等。

共享内存

上面介绍了内存映射,它除了可以映射文件,还可以映射匿名文件。

一个匿名映射没有对应的文件。或者换一种角度理解:把它看成是一个内容总是被初始化为 0 的虚拟文件的映射。

在 Android 中,我们可以使用 MemoryFile 来创建一个匿名共享内存。从这个类的名字可以看出,它也是一个文件。

这是因为创建共享内存的时候,操作系统替我们在 临时文件系统(tmpfs)里面创建了一个文件, 但是它又不是一个真正的文件,可以体会一下Linux万物皆文件的思想。操作这个文件就是操作了内存。

打开了这个文件后,就有了描述符,有了文件描述符,就可以将它"传递"给其他进程。

可以看到,客户端进程在自己的文件描述符表中,copy and insert 了服务端进程的匿名文件信息。

相关推荐
轻口味25 分钟前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王1 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发1 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀2 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪2 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef4 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6414 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js