引言这节课是对前面所有课程的一次总结和回顾。前面我们介绍了很多内存管理的相关机制其实都是为了把这节课的故事讲完整。在前面的课程里我们了解了进程内部的分布但也留下了三个关键的问题没有讲清楚fork 的工作方式非常奇怪一方面父进程和子进程还可以访问共有的变量另一方面它们又可以各自修改这个变量且这个修改对方都看不见这是怎么做到的呢我们在第 1 节课讲内存映射时就讲过页表中未映射状态的页表项并不存在一块具体的物理内存与之对应。但是当我们访问到这一页的时候页表项可以自动变成已映射的正常状态。谁在背后做了什么事情呢mmap 的功能十分强大这些强大的能力是怎么完成的呢这三个问题虽然看上去相互之间关系不大但实际上它们背后都依赖页中断机制。页中断和普通的中断一样它的中断服务程序入口也在 IDT 中第 2 节课的内容但它是由 MMU 产生的硬件中断。页中断有两类重要的类型写保护中断和缺页中断。正是这两类中断在整个系统的后台默默地工作着就像守护神一样支撑着内存系统正常工作。大多数时候我们即使不知道它们的存在程序也能正常地运行。但是有时候程序写得不好就有可能造成中断频繁发生从而带来巨大的性能下降。面对这种情况我们第一时间就应该想到统计页中断。因为除了页中断本身会带来性能下降之外统计页中断也可以反推程序的运行特点从而为进一步分析程序瓶颈点提供数据和思路。讲到这我想你应该意识到掌握页中断的必要性了其实这也是我们这节课的学习目标同时我们还将借此解决上面提到的三个问题。好不啰嗦了我们先了解下页中断有哪些类型吧。页中断有哪些类型在之前的课程里我们介绍了页表映射的原理也提到过页表项里定义了页的读写属性等等。如果物理页不在内存中或者页表未映射或者读写请求不满足页表项内的权限定义时MMU 单元就会产生一次中断。我们在之前详细介绍了中断机制和 IDT 的结构并且在介绍中断向量时提到过页中断的向量是 14。所以操作系统在启动以后它会把处理页中断的程序入口地址设置到 IDT 的 14 号中断描述符里。在 Linux 系统上页中断服务程序的名称是 do_page_fault。当中断发生以后CPU 会自动地在栈里存放一个错误码来区分页中断的类型还会把发生页中断的虚拟地址放到 CR2 寄存器这样中断服务程序就可以清楚地知道是什么原因导致的中断然后才能做出相应的处理。根据中断来源的不同页中断大致可以分为以下几种类型从这个表格里你会发现页中断服务程序根据不同的情况兢兢业业地为整个系统的内存管理默默做着贡献。接下来我们就带着这节课开头提出的三个问题来看看页中断是怎么工作的。我们先从第一个问题fork 的原理是什么开始吧。fork 原理写保护中断与写时复制我们前面说父进程和子进程不仅可以访问共有的变量还可以各自修改这个变量并且这个修改对方都看不见。这其实是 fork 的一种写时复制机制这一点我们在第 5 节课中模糊提到过而里面起关键作用的就是写保护中断。下面我们来看看这到底是怎么一回事。实际上操作系统为每个进程提供了一个进程管理的结构在偏理论的书籍里一般会称它为进程控制块Process Control BlockPCB)。具体到 Linux 系统上PCB 就是 task_struct 这个结构体。它里面记录了进程的页表基址打开文件列表、信号、时间片、调度参数和线性空间已经分配的内存区域等等数据。其中描述线性空间已分配的内存区域的结构对于内存管理至关重要我们先来看一下这个结构。在 Linux 源码中负责这个功能的结构是 vm_area_struct后面简称 vma。内核将每一段具有相同属性的内存区域当作一个单独的内存对象进行管理。vma 中比较重要的属性我列在下面struct vm_area_struct { unsigned long vm_start; // 区间首地址 unsigned long vm_end; // 区间尾地址 pgprot_t vm_page_prot; // 访问控制权限 unsigned long vm_flags; // 标志位 struct file * vm_file; // 被映射的文件 unsigned long vm_pgoff; // 文件中的偏移量 ... }在操作系统内核里fork 的第一个动作是把 PCB 复制一份但类似于物理页等进程资源不会被复制。这样的话父进程与子进程的代码段、数据段、堆和栈都是相同的这是因为它们拥有相同的页表自然也有相同的虚拟空间布局和对物理内存的映射。如果父进程在 fork 子进程之前创建了一个变量打开了一个文件那么父子进程都能看到这个变量和文件。fork 的第二个动作是复制页表和 PCB 中的 vma 数组并把所有当前正常状态的数据段、堆和栈空间的虚拟内存页设置为不可写然后把已经映射的物理页面的引用计数加 1。这一步只需要复制页表和修改 PTE 中的写权限位可以了并不会真的为子进程的所有内存空间分配物理页面修改映射所以它的效率是非常高的。这时父子进程的页表的情况如下图所示在上图中物理页括号中的数字代表该页被多少个进程所引用。Linux 中用于管理物理页面和维护物理页的引用计数的结构是 mem_map 和 page struct。这两个动作执行完后fork 调用就结束了。此时由于有父进程和子进程两个 PCB操作系统就会把两个进程都加入到调度队列中。当父进程得到执行它的 IP 寄存器还是指向 fork 调用中所以它会从这个调用中返回只不过返回值是子进程的 PID。当子进程得到执行时它的 IP 寄存器也是停在 fork 调用中它从这个调用中返回其返回值是 0。接下来就是写保护中断要发挥作用的地方了。不管是父进程还是子进程它们接下来都有可能发生写操作但我们知道在 fork 的第二步操作中已经将所有原来可写的地方都变成不可写了所以这时必然会发生写保护中断。我们刚才说Linux 系统的页中断的入口地址是 do_page_fault在这个函数里它会继续判断中断的类型。由于发生中断的虚拟地址在 vma 中是可写的在 PTE 中却是只读的可以断定这是一次写保护中断。这时候内核就会转而调用 do_wp_page 来处理这次中断wp 是 write protection 的缩写。在 do_wp_page 中系统会首先判断发生中断的虚拟地址所对应的物理地址的引用计数如果大于 1就说明现在存在多个进程共享这一块物理页面那么它就需要为发生中断的进程再分配一个物理页面把老的页面内容拷贝进这个新的物理页最后把发生中断的虚拟地址映射到新的物理页。这就完成了一次写时复制 (Copy On Write COW。具体过程如下图所示在上图中当子进程发生写保护中断后系统就会为它分配新的物理页然后复制页面再修改页表映射。这时老的物理页的引用计数就变为 1同时子进程中的 PTE 的权限也从只读变为读写。当父进程再访问到这个地址时也会触发一次写保护中断这时系统发现物理页的引用计数为 1那就只要把父进程 PTE 中的权限简单地从只读变为读写就可以了。这个过程比较简单我就不画图了你可以自己思考一下。fork 之后如果要执行新的程序那么就需要执行 execve 这个系统调用。它的主要作用是加载可执行程序并运行。接下来我们就看看这个函数背后的故事。execve 原理缺页中断接着来说这节课开始时所提到的第二个问题未映射页面是如何自动变成正常页面的我们将通过 execve 的例子来进行分析。execve 的作用是使当前进程执行一个新的可执行程序它的原型如下所示#include unistd.h int execve(const char* filename, const char* argv[], const char* envp[])其中 execve 的第一个参数是可执行程序的文件名第二个参数用来传递命令行参数第三个参数用来传递环境变量。execve 的执行步骤如下所示清空页表这样整个进程中的页都变成不存在了一旦访问这些页就会发生页中断打开待加载执行的文件在内核中创建代表这个文件的 struct file 结构加载和解析文件头文件头里描述了这个可执行文件一共有多少 section创建相应的 vma 来描述代码段数据段并且将文件的各个 section 与这些内存区域建立映射关系如果当前加载的文件还依赖其他共享库文件则找到这个共享库文件并跳转到第 2 步继续处理这个共享库文件最后跳转到可执行程序的入口处执行。我们之前讲了 section 与内存中的 segment 的对应关系。execve 的实现并不负责将文件内容加载到物理页中它只建立了这种文件 section与内存区域的映射关系就结束了。真正负责加载文件内容的是缺页中断接下来我们就看看缺页中断是如何加载物理页的。在 execve 的执行步骤中我们讲了内核为可执行程序创建一个 vma 结构体实例然后将它的 vm_file 属性设成第 2 步所打开的文件这就建立起了内存区域和文件的映射关系。这个内核区域的区间首地址、区间尾地址和控制权限都是由第 3 步解析的信息决定的。例如.text 段被加载到的内存首地址也就是链接时所决定的起始地址它就决定了内存代码段的起始地址。由于第 1 步把页表都清空了这就导致 CPU 在加载指令时会发现代码段是缺失的此时就会产生缺页中断。Linux 内核用于处理缺页中断的函数是 do_no_page如果内核检查当前出现缺页中断的虚拟地址所在的内存区域 vma虚拟地址落在该内存区域的 vm_start 和 vm_end 之间存在文件映射 (vm_file 不为空那就可以通过虚拟内存地址计算文件中的偏移这就定位到了内存所缺的页对应到文件的哪一段。然后内核就启动磁盘 IO将对应的页从磁盘加载进内存。一次缺页中断就这样被解决了。到这里第二个问题的答案你就都搞清楚了。可执行程序的加载不是一次性完成的而是由缺页中断根据需要将文件的内容以页为单位加载进内存的一次只会加载一页。搞清楚了 execve 背后的原理我们再来分析 mmap 的原理你就很容易理解了因为它背后的机制仍然是围绕着 vm_area_struct 这个核心结构由页中断来完成各种功能。mmap 强大的能力是怎么来的在回答这节课开始提出的第三个问题也就是 mmap 的功能十分强大这些强大的能力是怎么完成的前我们先回顾下之前的内容mmap 根据映射的类型有四种最常用的组合私有匿名映射用于分配堆空间共享匿名映射用于父子进程之间通讯私有文件映射用于加载动态链接库共享文件映射用于多进程之间通讯。我们接下来针对这四种情况依次进行分析。私有匿名映射私有匿名映射是最简单的情况在调用 mmap 时只需要在文件映射区域分配一块内存然后创建这块内存所对应的 vma 结构这次调用就结束了。当访问到这块虚拟内存时由于这块虚拟内存都没有映射到物理内存上就会发生缺页中断但这一次的缺页中断与 execve 时的缺页中断不一样这次是匿名映射所以关联文件属性为空。此时内核就会调用 do_anonymous_page 来分配一个物理内存并将整个物理页全部初始化为 0然后在页表里建立起虚拟地址到物理地址的映射关系。私有文件映射在内核中如果有一个进程打开了一个文件PCB 中就会有一个 struct file 结构与这个文件对应。struct file 结构是与进程相关假如进程 A 与进程 B 都打开了文件 f那么进程 A 中就会有一个 struct file 结构进程 B 中也会有一个。Linux 的文件系统中有一个叫做 inode 的结构这个结构与具体的磁盘上的文件是一一对应的也就是说对于同一个文件整个内核中只会有一个 inode 结构。所以进程 A 与进程 B 的 file struct 结构都有一个指针指向 inode 结构这就将 file struct 与 inode 结构联系起来了。在 inode 结构中有一个哈希表以文件的页号为 key以物理内存页为 value。当进程 A 打开了文件 f然后读取了它的第 4 页这时内核就会把 4 和这个物理页放入这个哈希表中。当进程 B 再打开文件 f要读取它的第 4 页时因为 f 的第 4 页的内容已经被加载到物理页中了所以就不用再加载一次了。只需要将 B 的虚拟地址与这个物理页建立映射就可以了如下图所示我要提醒你的是哈希表在现代的 Linux 内核中已经被优化成了 Radix tree 和最小堆的一种优化的数据结构它们比哈希表有更好的时间效率所以你在阅读不同版本的 Linux 内核代码时要注意这个变化。如果文件是只读的话那这个文件在物理页的层面上其实是共享的。也就是进程 A 和进程 B 都有一页虚拟内存被映射到了相同的物理页上。但如果要写文件的时候因为这一段内存区域的属性是私有的所以内核就会做一次写时复制为写文件的进程单独地创建一份副本。这样一个进程在写文件时并不会影响到其他进程的读。对于共享库文件代码段的私有属性其实并不影响它在所有进程间共享但如果数据段在执行的过程发生变化内核就可以通过写时复制机制为每个进程创建一个副本。这就是对于共享库文件要选择私有文件映射的根本原因。这里我们就有这样一个结论私有文件映射的只读页是多进程间共享的可写页是每个进程都有一个独立的副本创建副本的时机仍然是写时复制。共享文件映射在私有文件映射的基础上共享文件映射就很简单了对于可写的页面在写的时候不进行复制就可以了。这样的话无论何时也无论是读还是写多个进程在访问同一个文件的同一个页时访问的都是相同的物理页面。共享匿名映射在这节课之前你可能会觉得共享匿名映射在父子进程间通讯是最简单的因为父子进程共享了相同的 mmap 的返回值看上去最直观。但实际上从内核的角度说它却是最复杂的。原因是 mmap 并不真正分配物理内存它只是分配了一段虚拟内存也就是说只在 PCB 中创建了一个 vma 结构而已。这就导致 fork 在复制页表的时候页表中共享匿名映射区域都是未映射状态。请你设想一下如果内核不做特殊处理的话在父进程因为访问共享内存区域而遇到缺页中断时内核为它分配了物理页面等子进程再访问共享内存区域时内核也没有办法知道子进程的虚拟内存应该映射到哪个物理页面上因为缺页中断只能知道当前进程是谁以及发生问题的虚拟地址是什么这些信息不足够计算出是否有其他进程已经把共享内存准备好了。在内核中使用虚拟文件系统来解决这个问题之前早期的 Linux 内核中并不支持共享匿名映射。虚拟文件并不是真实地在磁盘上存在的。它只是由内核模拟出来的但是它也有自己的 inode 结构。这样一来内核就能在创建共享匿名映射区域时创建一个虚拟文件并将这个文件与映射区域的 vma 关联起来。当 fork 创建子进程时子进程会复制父进程的全部 vma 信息。接下来发生的事情就和共享文件映射完全一样了我们就不再重复了。至此我们才终于把 mmap 的核心原理分析清楚。第三个问题的答案也就很清楚了mmap 的功能之所以十分强大主是因为操作系统综合使用写保护中断、缺页中断和文件机制来实现 mmap 的各种功能。总结这节课我们先介绍了页中断产生的原因大致可以分为缺页中断、写保护中断和非法访问造成的中断等等。接下来我们深入地分析了 fork 的原理。fork 在执行时子进程只会复制父进程的 PCB 和页表并且把所有页表项都设为只读这个过程并不会复制真正的物理页。只有当父子进程其中一个对页进行写操作的时候才会复制一个副本出来。这种机制被称为写时复制。execve 是一种系统调用用于加载并运行一个可执行文件。它会打开文件并做好文件的 section 与内存 segment 的映射这种映射关系维护在 vm_area_struct 中然后就清空页表退出执行了。当指令真正访问到内存的时候由于页表被清空这时会产生缺页中断然后内核就使用 vma 中的文件映射关系去磁盘上读取相应的内容将它放到物理页中最后建立好虚拟地址到物理地址的映射。这是一种按需加载的机制。我们分析了 mmap 背后的页中断原理根据映射的类型我们还介绍了它常用的 4 种组合和作用。其中私有匿名映射在缺页中断的处理过程会通过 do_anonymous_page 函数申请一块全零的物理页并建立虚拟地址到物理页的映射以达成分配内存的目标私有文件映射则借助文件的 inode 结构共享文件的物理缓存页当发生写操作时则会出现写时复制从而保证每一个进程中都有自己的副本共享文件映射在私有文件映射的基础上只取消了写时复制这样一个进程就可以看到其他进程对这个页的修改了共享匿名映射借助了虚拟文件系统。内核在父子进程间使用自己创建的虚拟文件和共享文件映射来实现共享匿名映射。最后你要特别注意的一点是Linux 内核为了优化性能还引入了大量的结构这使得研究内存管理的源代码变得非常困难。我们这里主要介绍了设计思路而不会涉及到具体的细节如果想研究 Linux 内存管理的源码的话你还可以继续参考《Understanding the Linux Virtual Memory Manager 》、《Linux 内核设计与实现》和《深入理解 LINUX 内核 》等资料。