页中断
基础知识
Linux虚拟内存系统
Linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟内存的连续片(chunk),这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。区域的概念很重要,因为它允许虚拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。
Linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟内存的连续片(chunk),这些页是以某种方式相关联的。如下图所示
下图强调了记录一个进程中虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用户栈的指针、可执行目标文件的名字,以及程序计数器)。
任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。我们感兴趣的两个字段是 pgd和mmap,其中 pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时,就将pgd存放在CR3控制寄存器中。
一个具体的区域结构包含以下字段:
- vm_start:指向这个区域的起始处。
- vm_end:指向这个区域的结束处。
- vm_prot:描述这个区域内包含的所有页的读写许可权限。
- vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。
- vm_next:指向链表中下一个区域结构。
页中断异常处理
假设 MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
- 虚拟地址A是合法的吗?换句话说,A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而婆止这个进程。这个情况在图9-28中标识为“1”。
因为一个进程可以创建任意数量的新虚拟内存区域(使用在下一节中描述的mmap函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux使用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。 - 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图9-28中标识为“2”。
- 此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到 MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
fork原理
fork原理:写保护中断与写时复制
父进程和子进程不仅可以访问共有的变量,还可以各自修改这个变量,并且这个修改对方都看不见。这其实是 fork 的一种写时复制机制,而里面起关键作用的就是写保护中断。
实际上,操作系统为每个进程提供了一个进程管理的结构,在偏理论的书籍里一般会称它为进程控制块(Process Control Block,PCB)。具体到 Linux 系统上,PCB 就是 task_struct 这个结构体。它里面记录了进程的页表基址,打开文件列表、信号、时间片、调度参数和线性空间已经分配的内存区域等等数据。
其中,描述线性空间已分配的内存区域的结构对于内存管理至关重要。在 Linux 源码中,负责这个功能的结构是 vm_area_struct,后面简称 vma【也是前置知识中提到的】。内核将每一段具有相同属性的内存区域当作一个单独的内存对象进行管理。
1 | struct vm_area_struct { |
在操作系统内核里,fork 的第一个动作是把 PCB 复制一份,但类似于物理页等进程资源不会被复制。这样的话,父进程与子进程的代码段、数据段、堆和栈都是相同的,这是因为它们拥有相同的页表,自然也有相同的虚拟空间布局和对物理内存的映射。如果父进程在 fork 子进程之前创建了一个变量,打开了一个文件,那么父子进程都能看到这个变量和文件。
fork 的第二个动作是复制页表和 PCB 中的 vma 数组,并把所有当前正常状态的数据段、堆和栈空间的虚拟内存页,设置为不可写,然后把已经映射的物理页面的引用计数加 1。这一步只需要复制页表和修改 PTE 中的写权限位可以了,并不会真的为子进程的所有内存空间分配物理页面,修改映射,所以它的效率是非常高的。这时,父子进程的页表的情况如下图所示:
上述过程用源码理解的话这样:
复制页表(PTE)和复制虚拟内存区域(VMA)和页的引用计数加1。
1. 复制页表(Page Table Entry, PTE)
在 fork()
的过程中,内核会复制父进程的页表到子进程中。关键的代码在 copy_page_range()
函数中。这个函数遍历父进程的页表,并为子进程创建相应的映射。
1 | int copy_page_range(struct mm_struct *dst_mm, struct mm_struct *src_mm, |
2. 复制虚拟内存区域(VMA)
虚拟内存区域(VMA)是用来描述进程的内存布局的,包括数据段、堆、栈等。在 fork()
调用中,内核会复制父进程的 VMA 列表到子进程。这个操作主要发生在 copy_mm()
函数中。
1 | static struct mm_struct *copy_mm(unsigned long clone_flags, struct task_struct *tsk) |
在上图中,物理页括号中的数字代表该页被多少个进程所引用。Linux 中用于管理物理页面,和维护物理页的引用计数的结构是 mem_map 和 page struct。
接下来,就是写保护中断要发挥作用的地方了。不管是父进程还是子进程,它们接下来都有可能发生写操作,但我们知道在 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 中的权限,简单地从只读变为读写就可以了。
1 | static vm_fault_t do_wp_page(struct vm_fault *vmf) |
总结:
fork 在执行时,子进程只会复制父进程的 PCB 和页表,并且把所有页表项都设为只读,这个过程并不会复制真正的物理页。只有当父子进程其中一个对页进行写操作的时候,才会复制一个副本出来。这种机制被称为写时复制。【1次fork几次缺页,应该是2次】
execve原理
Q:先聊下为什么fork和execve结合使用呢?
A:
在很多情况下,父进程会使用 fork()
创建一个子进程,并希望子进程执行不同的程序。在这种情况下,父进程调用 fork()
之后,子进程会调用 execv()
来执行另一个程序。由于 execv()
替换了子进程的整个内存空间,因此子进程变成了一个新的进程,执行新的程序。
例子:
1 |
|
execve原理:缺页中断
execve 的作用是使当前进程执行一个新的可执行程序,它的原型如下所示:
1 |
|
其中 execve 的第一个参数是可执行程序的文件名,第二个参数用来传递命令行参数,第三个参数用来传递环境变量。
execve 的执行步骤如下所示:
- 清空页表,这样整个进程中的页都变成不存在了,一旦访问这些页,就会发生页中断;
- 打开待加载执行的文件,在内核中创建代表这个文件的 struct file 结构;
- 加载和解析文件头,文件头里描述了这个可执行文件一共有多少 section;
- 创建相应的 vma 来描述代码段,数据段,并且将文件的各个 section 与这些内存区域建立映射关系;
- 如果当前加载的文件还依赖其他共享库文件,则找到这个共享库文件,并跳转到第 2 步继续处理这个共享库文件;
- 最后跳转到可执行程序的入口处执行。
execve 的实现并不负责将文件内容加载到物理页中,它只建立了这种文件 section,与内存区域的映射关系就结束了。真正负责加载文件内容的是缺页中断。
因为由于第 1 步把页表都清空了,这就导致 CPU 在加载指令时会发现代码段是缺失的,此时就会产生缺页中断。
Linux 内核用于处理缺页中断的函数是 do_no_page,如果内核检查,当前出现缺页中断的虚拟地址所在的内存区域 vma(虚拟地址落在该内存区域的 vm_start 和 vm_end 之间)存在文件映射 (vm_file 不为空),那就可以通过虚拟内存地址计算文件中的偏移,这就定位到了内存所缺的页对应到文件的哪一段。然后内核就启动磁盘 IO,将对应的页从磁盘加载进内存。一次缺页中断就这样被解决了。
补充个例子:
在shell中运行ls,底层执行流程
- Shell 解析命令行输入
当你在 shell 中输入 ls
并按下回车键时,shell(如 bash
)会执行以下操作:
- 读取输入
- Shell 读取你输入的命令行字符串
ls
。
- Shell 读取你输入的命令行字符串
- 解析命令
- Shell 解析输入的字符串。因为
ls
是一个简单的命令,没有参数或管道符号,解析过程很简单。
- Shell 解析输入的字符串。因为
- 查找命令
- Shell 会在环境变量
PATH
指定的目录中查找ls
命令的可执行文件。PATH
变量是一个由冒号分隔的目录列表,shell 会按顺序搜索这些目录,直到找到ls
命令的可执行文件。 - 一旦找到,shell 知道
ls
的可执行文件路径,例如/bin/ls
。
- Shell 会在环境变量
- 创建子进程
fork()
系统调用- Shell 使用
fork()
系统调用创建一个子进程。在这个新创建的子进程中,将执行ls
命令。 fork()
会复制当前进程的全部内容,包括文件描述符、变量、程序计数器等,从而创建一个与当前进程几乎完全相同的子进程。
- Shell 使用
- 执行命令
-
execve()
系统调用-
在子进程中,shell 使用
execve()
系统调用,将子进程的代码和数据替换为ls
可执行文件的代码和数据。 -
execve()
需要以下信息:
- 可执行文件的路径(如
/bin/ls
)。 - 命令行参数(在这个例子中只有
ls
)。 - 环境变量。
- 可执行文件的路径(如
-
execve()
调用后,子进程的地址空间被新程序(ls
可执行文件)完全替换,子进程不再运行 shell 代码,而是开始执行ls
程序。
-
- 内核加载可执行文件
- 加载可执行文件
- 内核会开始加载
ls
的可执行文件。它会读取文件头,确定文件类型(如 ELF 格式),并根据文件头信息加载程序的代码段、数据段等到内存中。
- 内核会开始加载
- 设置内存映射
- 内核为
ls
程序创建新的虚拟内存空间,并将可执行文件的各个段(如代码段、数据段)映射到这个新的地址空间。
- 内核为
- 初始化进程状态
- 内核初始化进程的堆栈、程序计数器(PC)等,准备好执行
ls
程序的第一条指令。
- 内核初始化进程的堆栈、程序计数器(PC)等,准备好执行
- 执行
ls
程序
- 开始执行
- 现在,
ls
程序开始在子进程中执行。ls
会执行一系列系统调用来读取文件系统中的目录内容。
- 现在,
- 系统调用
ls
使用系统调用(如open()
,read()
,getdents()
等)来读取当前目录的内容。内核通过这些系统调用与文件系统交互,以获取目录和文件的列表。
- 显示输出
- 输出到终端
ls
将获取的目录内容通过标准输出(stdout)打印到终端。- 输出的内容由
write()
系统调用发送到终端设备,最终显示在屏幕上。
- 进程终止
- 子进程的终止:
- 当
ls
执行完成后(即目录内容已经显示完毕),它会调用exit()
系统调用,通知内核该进程已经完成。 - 内核会清理子进程的资源,并发送一个 SIGCHLD 信号给父进程(shell),表明子进程已经终止。
- 当
- 父进程(shell)继续执行:
- Shell 收到子进程终止的信号后,会使用
wait()
或waitpid()
来获取子进程的退出状态,并继续等待用户输入下一个命令。
- Shell 收到子进程终止的信号后,会使用
- 总结
整个流程可以总结如下:
- Shell 解析并查找
ls
命令。 - Shell 使用
fork()
创建子进程。 - 子进程使用
execve()
执行ls
程序。 - 内核加载
ls
可执行文件,设置内存映射。 ls
程序执行,读取目录内容并输出到终端。ls
程序执行完毕,子进程终止。- Shell 继续等待用户输入。
mmap原理
mmap内存映射过程
mmap内存映射的实现过程,总的来说可以分为三个阶段:
(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
-
进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
-
在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址。
-
为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化。
-
将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中。
(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
-
为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
-
通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
-
内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
-
通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。
(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
-
进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
-
缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
-
调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
-
之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
**注意:**修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。
补充:
mmap指定内存映射行为的标志:
MAP_FIXED
: 指定映射的确切地址。如果该地址不可用,mmap
将失败。MAP_ANONYMOUS
: 创建匿名映射,不涉及文件。通常用于分配内存。MAP_SHARED
: 映射的更改会被共享,即对映射区域的写操作会影响其他映射同一区域的进程。MAP_PRIVATE
: 映射的更改是私有的,不会影响其他进程,通常通过写时复制(Copy-On-Write)机制实现。
mmap的主要作用:
文件映射的过程:
mmap 和常规文件操作的区别
使用系统调用,函数的调用过程:
-
进程发起读文件请求。
-
内核通过查找进程文件描述符表,定位到内核已打开文件集上的文件信息,从而找到此文件的inode。
-
inode在address_space上查找要请求的文件页是否已经缓存在页缓存中。如果存在,则直接返回这片文件页的内容。
-
如果不存在,则通过inode定位到文件磁盘地址,将数据从磁盘复制到页缓存。之后再次发起读页面过程,进而将页缓存中的数据发给用户进程。
【重点】总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。
而使用mmap操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。
总而言之,常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只需要从磁盘到用户主存的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间和内核空间的数据直接交互而省去了空间不同、数据不通的繁琐过程。因此mmap效率更高。
Note:使用mmap需要注意的一个关键点是,mmap映射区域大小必须是物理页大小(page_size)的整倍数(32位系统中通常是4k字节)。
参考资料
- mmap,比想象的更重要一点
- 极客时间
- 《深入理解计算机系统》