页中断和普通的中断一样,它的中断服务程序入口也在 IDT 中,但它是由 MMU 产生的硬件中断。页中断有两类重要的类型:写保护中断和缺页中断。
大多数时候,我们程序员即使不知道页中断的存在,程序也能正常地运行。但是有时候,程序写得不好就有可能造成中断频繁发生,从而带来巨大的性能下降。面对这种情况,第一时间就应该想到统计页中断。因为除了页中断本身会带来性能下降之外,统计页中断也可以反推程序的运行特点,从而为进一步分析程序瓶颈点,提供数据和思路。
页中断有哪些类型
操作系统在启动以后,会把处理页中断的程序入口地址,设置到 IDT 的 14 号中断描述符里。在 Linux 系统上,页中断服务程序的名称是 do_page_fault。当中断发生以后,CPU 会自动地在栈里存放一个错误码,来区分页中断的类型,还会把发生页中断的虚拟地址放到 CR2 寄存器,这样,中断服务程序就可以清楚地知道是什么原因导致的中断,然后才能做出相应的处理。
根据中断来源的不同,页中断大致可以分为以下几种类型:
fork 原理:写保护中断与写时复制
实际上,操作系统为每个进程提供了一个进程管理的结构,在偏理论的书籍里一般会称它为进程控制块(Process Control Block,PCB)。具体到 Linux 系统上,PCB 就是 task_struct 这个结构体。它里面记录了进程的页表基址、打开文件列表、信号、时间片、调度参数和线性空间已经分配的内存区域等等数据。其中,用于描述线性空间已分配的内存区域的结构 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(包括页表、vma 数组等)复制一份,但类似于物理页等进程资源不会被复制。这样的话,父进程与子进程的代码段、数据段、堆和栈都是相同的,这是因为它们拥有相同的页表,自然也有相同的虚拟空间布局和对物理内存的映射。如果父进程在 fork 子进程之前创建了一个变量,打开了一个文件,那么父子进程都能看到这个变量和文件。同时,fork 会把所有当前正常状态的数据段、堆和栈空间的虚拟内存页设置为不可写,然后把已经映射的物理页面的引用计数加 1,而并不会真的为子进程的所有内存空间分配物理页面,因此 fork 的效率是非常高的。这时,父子进程的页表的情况如下图所示:
在上图中,物理页括号中的数字代表该页被多少个进程所引用。Linux 中用于管理物理页面,和维护物理页的引用计数的结构是 mem_map 和 page struct。
fork 调用结束后,操作系统中就有代表父进程和子进程的两个 PCB,操作系统就会把两个进程都加入到调度队列中。当父进程得到执行,它的 IP 寄存器还是指向 fork 调用中,所以它会从这个调用中返回,只不过返回值是子进程的 PID。当子进程得到执行时,它的 IP 寄存器也是停在 fork 调用中,它从这个调用中返回,其返回值是 0。
不管是父进程还是子进程,它们接下来都有可能发生写操作,但原来所有可写的地方都变成不可写了,所以这时必然会发生写保护中断。Linux 系统的页中断的入口函数 do_page_fault 会继续判断中断的类型。由于发生中断的虚拟地址在 vma 中是可写的(虚拟地址已完成物理地址的映射,即页表已构建),在 PTE 中却是的不可写(只读),可以断定这是一次写保护中断。这时候,内核就会转而调用 do_wp_page 来处理这次中断,wp 是 write protection 的缩写。
在 do_wp_page 中,系统会首先判断发生中断的虚拟地址所对应的物理地址的引用计数,如果大于 1,就说明现在存在多个进程共享这一块物理页面,那么它就需要为发生中断的进程再分配一个物理页面,把老的页面内容拷贝进这个新的物理页,最后把发生中断的虚拟地址映射到新的物理页。这就完成了一次写时复制 (Copy On Write, COW)。具体过程如下图所示:
在上图中,当子进程发生写保护中断后,系统就会为它分配新的物理页,然后复制页面,再修改页表映射。这时老的物理页的引用计数就变为 1,同时子进程中的 PTE 的权限也从只读变为读写。当父进程再访问到这个地址时,也会触发一次写保护中断,这时系统发现物理页的引用计数为 1,那就只要把父进程 PTE 中的权限,简单地从只读变为读写就可以了。
execve 原理:缺页中断
fork 之后如果要执行新的程序,那么就需要执行 execve 这个系统调用,它的主要作用是使当前进程执行一个新的可执行程序。
#include <unistd.h>int execve(const char* filename, const char* argv[],const char* envp[])
execve 的执行步骤如下所示:
- 清空页表,这样整个进程中的页都变成不存在了,一旦访问这些页,就会发生页中断;
- 打开待加载执行的文件,在内核中创建代表这个文件的 struct file 结构;
- 加载和解析文件头,文件头里描述了这个可执行文件一共有多少 section;
- 创建相应的 vma 来描述代码段,数据段,并且将文件的各个 section 与这些内存区域 segment 建立映射关系;
- 如果当前加载的文件还依赖其他共享库文件,则找到这个共享库文件,并跳转到第 2 步继续处理这个共享库文件;
- 最后跳转到可执行程序的入口处执行。
如上所示,execve 的实现并不负责将文件内容加载到物理页中,它在建立可执行文件 section 与内存区域 segment 的映射关系后结束了,而真正负责加载文件内容的是缺页中断。
Linux 内核用于处理缺页中断的函数是 do_no_page,如果内核检查,当前出现缺页中断的虚拟地址所在的内存区域 vma(虚拟地址落在该内存区域的 vm_start 和 vm_end 之间)存在文件映射 (vm_file 不为空),那就可以通过虚拟内存地址计算文件中的偏移,这就定位到了内存所缺的页对应到文件的哪一段。然后内核就启动磁盘 I/O,将对应的页从磁盘加载进内存。
因此,可执行程序的加载不是一次性完成的,而是由缺页中断根据需要,将文件的内容以页为单位加载进内存的,一次只会加载一页。
mmap
mmap 的功能十分强大,操作系统综合使用写保护中断、缺页中断和文件机制来实现 mmap 的各种功能。
私有匿名映射,用于分配堆空间
私有匿名映射是最简单的情况,在调用 mmap 时,只需要在文件映射区域分配一块内存,然后创建这块内存所对应的 vma 结构,这次调用就结束了。当访问到这块虚拟内存时,由于这块虚拟内存都没有映射到物理内存上,就会发生缺页中断,但这一次的缺页中断与 execve 时的缺页中断不一样,这次是匿名映射,所以关联文件属性为空。此时,内核就会调用 do_anonymous_page 来分配一个物理内存,并将整个物理页全部初始化为 0,然后在页表里建立起虚拟地址到物理地址的映射关系。
#include <sys/mman.h>#include <stdlib.h>#include <stdio.h>#include <unistd.h>int main() {pid_t pid;char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,MAP_PRIVATE| MAP_ANONYMOUS, -1, 0);if (!(pid = fork())){sleep(1);printf("child got a message: %s\n", shm);sprintf(shm, "%s", "hello, father.");exit(0);}sprintf(shm, "%s", "hello, my child");sleep(2);printf("parent got a message: %s\n", shm);return 0;}
child got a message:parent got a message: hello, my child
shm 被设置为私有匿名映射,因此 fork 创建子进程后,这块内存区域虽对父子进程可见,但是系统会将这块内存区域设置为只读,当子进程使用 sprintf 写这块内存区域时,会触发写时拷贝机制(写保护),因此父子进程最终操作的是各自的独享的内存区域,互不影响。
私有文件映射,用于加载动态链接库
在内核中,如果有一个进程打开了一个文件,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 的返回值,看上去最直观。但实际上,从内核的角度说,共享匿名映射却是最复杂的。
原因是 mmap 并不真正分配物理内存,它只是分配了一段虚拟内存,也就是说只在 PCB 中创建了一个 vma 结构而已,这就导致 fork 在复制页表的时候,页表中共享匿名映射区域都是未映射状态。如果内核不做特殊处理的话,在父进程因为访问共享内存区域而遇到缺页中断时,内核为它分配了物理页面,等子进程再访问共享内存区域时,内核却无法知道子进程的虚拟内存应该映射到哪个物理页面上,因为缺页中断只能知道当前进程是哪个,以及发生缺页的虚拟地址是多少。
在内核中使用虚拟文件系统来解决这个问题之前,早期的 Linux 内核中并不支持共享匿名映射。虚拟文件并不是真实地在磁盘上存在的,它只是由内核模拟出来的,但是它也有自己的 inode 结构。这样一来,内核就能在创建共享匿名映射区域时,创建一个虚拟文件,并将这个文件与映射区域的 vma 关联起来。当 fork 创建子进程时,子进程会复制父进程的全部 vma 信息,这样接下来的过程就和共享文件映射完全一样了。
