Skip to content

Page Faults

Virtual Memory benefit

  1. 隔离性
    隔离进程间的内存,保护进程
  2. 间接性
    CPU 只能使用虚拟内存,由操作系统管理虚拟内存和物理内存的映射。

Information need

要实现页面错误的功能需要什么信息?

  1. 错误的虚拟地址
    它被保存在 stval register 中
  2. 错误的类型
    对于不同的错误,需要做出不同的回应,错误的编号会被存在 scause register 中,其中有读、写、指令三种类型的错误, 15 是页有效但写权限不足,或页面未驻留内存。
  3. 引起页面错误的虚拟地址
    它被保存在 sepc register 和 trapframe 的 epc 中,这时为了在修复错误后,重新执行需要执行的语句

Allocation

sbrk()

sbrk() 是一个 system call ,它可以调整应用进程自己的 heap ,当一个进程启动时, p-sz 会指向 stack 的顶部和 heap 的底部,调用 sbrk() 会调整 p-sz 的大小。

在 xv6 中, sbrk()急分配(eager allocation) ,只要一调用 sbrk() , kernel 就会立刻分配进程需要的物理内存,但是现实中,一个进程需要多大的内存很难预测,因此通常会选择多申请一些防止出现错误,做出最坏的打算。

在现在的 Linux 中, sbrk()懒分配(Lazy Allocation) 的原则, Page Fault 依赖它,sbrk() (实际是 brk() ) 的行为:当你调用 sbrk(n) 时,内核只记录新的堆顶边界(修改 vm_end ),不分配物理页,页表项标记为“存在但无效”。
Page Fault 的行为:当第一次访问这块内存时,触发 Page Fault。内核检查:“这个地址在 sbrk 设定的边界内吗?”

在边界内:分配物理页,映射,重试指令(成功)。
在边界外:发送 SIGSEGV(崩溃)。

Lazy allocation

懒加载的思想很简单,就是 sbrk() 基本什么都不做,只需要记住增加了地址空间,也就是 p-sz + mem ,但是 kernel 并没有真的分配物理内存,当应用程序真的使用到这里的内存时,就会出现 page fault ,这时候就可以 allocate page(分配物理页), zero the page(将物理页置零), Map the Page (映射页面), restart instruction(重启指令)

Zero fill on demand

有一类情况不一样,每个进程在最初 init 时,在内存中会初始化 Text, Data, BSS 段,其中 BSS(Block Started by Symbol) 段是存放那些没有一开始被初始化的全局变量,因此他们的数据全部都是 0 ,而为了节省内存,通常会选择将 BSS 段的所有数据都映射到同一个物理内存的 Page 中,同时设置为只读。
当需要为 BSS 中的数据进行初始化时,则会出现 Page fault ,这时候 Kernel 会新建一个 Page ,置零,令它映射到新的 Page 上,更新数据,更新页表项。

这样做的好处是,懒加载(省内存),程序启动更快

缺点是每次更新 BSS 段的数据都会出现 Page fault ,因此更新 BSS 段的数据时更慢,而且相对来说 Page fault 的成本比启动时就初始化更高,因为 Page fault 需要进入你内核态

Copy-on-write (COW)-fork

类似 BSS 的还有 Copy on write ,也被叫做 COW fork ,简单来说:当父进程创建子进程时,操作系统并不会立即复制父进程的内存数据,而是让父子进程共享同一份物理内存。只有当其中任何一个进程试图“修改”内存时,操作系统才会真正复制那份数据。

它的出现是因为通常调用 fork() 后,会马上调用 exec() 执行新的程序,刚刚 fork() 时复制的内存就会被丢弃,如果复制的内存很大,这整个过程都是在浪费时间。

COW 的工作流程是:

  1. 在 Fork 的瞬间,创建子进程的 PCB
  2. 将父进程的虚拟页表复制一份给子进程,同时标记为只读(此时父子进程都是指向父进程的物理地址)
  3. 增加父进程的物理地址的引用计数(Reference Count),表示有两个进程在使用它们
  4. fork() 结束,返回

如果父进程或子进程需要进行 写(write) 操作

  1. 执行写操作时,会向一个只读的页面进行写入,内存管理单元(MMU)会出现 护性缺页异常(Protection Page Fault)
  2. kernel 会捕获这个异常,识别出这是 COW 机制在起作用
  3. 内核分配一块新的物理内存页
  4. 将被修改页复制到新的物理页中
  5. 对应的进程虚拟页表会更新,指向新的物理页,无论是父进程还是子进程,都会分配到新的页中
  6. 将新的页标记为 可读写
  7. 另一个进程的页表保持不变,依然指向旧的物理页(且保持只读)
  8. 旧物理页的引用计数减 1
  9. 内核重新执行那条导致异常的写入指令

如果引用计数是 1 ,则会直接提升权限,而不会进行复制

Demand Paging

和 COW fork 类似的,还有 exec() 的按需分页 Demand Paging ,它的基本思想是 只有当程序真正需要访问某个内存页时,操作系统才将该页从磁盘加载到物理内存中

当执行 exec() 后,会初始化对应的页表项, kernel 会将页表项的设置为 Invalid ,也就是 V0 ,然后直接返回,当进程访问到了标记为 Invalid 的页面时,触发 Page fault ,然后去磁盘中加载对应的数据到内存中。

优点(Advantages)

内存利用率高:只加载需要的页面,避免浪费
支持更大程序:可运行超过物理内存大小的程序
阿里云官方网站
启动速度快:程序无需等待所有页面加载即可开始执行
支持多道程序:更多进程可同时驻留内存,提高系统吞吐量
降低硬件成本:减少对大容量物理内存的依赖

缺点(Disadvantages)

首次访问延迟:页面故障需要磁盘I/O,造成短暂停顿
页面故障开销:频繁的page fault会降低系统性能
可能产生抖动(Thrashing):当进程工作集大于可用内存时,系统大量时间花在页面置换上
实现复杂:需要MMU硬件支持、页表管理、置换算法等
安全风险:可能受到时序攻击等安全威胁

如果出现 内存溢出(Out of Memory) ,这时候会使用 页面置换算法 将页面移出,加载需要的页面,重启指令
这里重点在于 页面置换算法 ,最常使用是 LRU

算法原理优缺点
FIFO替换最早进入内存的页简单,但可能换出常用页
LRU替换最近最久未使用的页效果好,实现较复杂
LFU替换使用频率最低的页需额外计数开销

同时,如果脏页和非脏页同时出现,通常会有限选择换出非脏页,因为脏页通常后续还会需要写入,如果换出脏页很容易出现写入两次的情况,而非脏页只需要从内存中删除就可以了,不需要做其他的事情

在 PTE 中,有一个 D 位和 A 位, D 表示脏页, A 表示被访问, kernel 需要维护它们,它们也被用来实现 LRU ,kernel 维护它们的算法,通常使用的是 clock 算法

Memory Mapped File

内存映射文件(Memory Mapped File,简称 MMF) ,内存映射文件是一种将磁盘上的文件直接映射到进程虚拟地址空间的技术。映射后,程序可以像访问普通内存(数组或指针)一样访问文件内容,而无需调用传统的 read() 或 write() 系统调用。

它的执行流程是 kernel 在虚拟地址中保留一部分空间,这个空间和磁盘上的文件建立映射,但是并没有将文件读取到内存中,当读取该文件时,触发 Page fault , kernel 这时候将文件对应的部分加载到内存中,更新页表,将虚拟地址指向物理内存
如果进程修改了数据,这些页会变成脏页,操作系统会在适当的时机(如内存压力大、调用 msync 或文件关闭时)将脏页异步刷回磁盘

当多个进程对同一个文件都进行 MMF 时,物理内存并不会加载两份,而是会让这些进程的虚拟页表都映射到同一个物理地址