MIT 6.S081 Lecture 7: Page faults
6.S081第七课,讲述以页错误为代表的异常,并教授OS以页表和页错误实现的许多虚拟内存的优化机制。阅读RISC-V Calling Convention第四章第六节部分,观看课程视频。
Page faluts in risc-v exceptions
Xv6对于异常的处理十分简单,来自用户态的异常导致内核kill用户进程,来自内核态的异常导致内核 panic 。而 RISC-V 原生支持 16 种异常(这十六种其实也包括了系统调用,这也是我上一节提到的,所谓不同 Trap 之间的”用语混淆”),控制状态寄存器 scause
记录不同类型。其中有 12 , 13 与 15 号与分页有关,均为页错误。
Exception Code | Description |
---|---|
0 | Instruction address misaligned |
1 | Instruction access fault |
2 | Illegal instruction |
3 | Breakpoint |
4 | Reserved |
5 | Load access fault |
6 | AMO address misaligned |
7 | Store/AMO access fault |
8 | Environment call |
9-11 | Reserved |
12 | Instruction page fault |
13 | Load page fault |
14 | Reserved |
15 | Store/AMO page fault |
>16 | Reserved |
而 CSR stval
记录异常特定的信息。有些异常类型不会用到,将 stval
置零;页错误会将其置为页错误的地址。
发生页错误,OS需要获取信息,
- 引发页错误的虚拟地址记录于
stval
中。 - 引发页错误的类型记录在
scause
中。12 -> 取指令;13 -> 读数据;15 -> 写数据。 - 错误发生的IP与CPU优先级分别记录在 trapframe 的
epc
字段与 CSRsstatus
的SPP
位中。
Optimizations
通过页错误与页表可以实现很多OS 虚拟内存的特性。课程介绍了几种,我小小记录一下。
lazy/on-demand page allocation
系统调用 sbrk 分配堆上内存,但应用可能申请的堆内存没有使用,比如输入缓冲区在应用运行时只用到部分。如果申请太多内存,调用 sbrk 可能过于昂贵,比如物理内存已经大部分使用或用完,再申请大量内存需要等待换页。
现代OS一般是 lazily 分配内存。具体就是当用户进程使用内存时再分配。比如实现系统调用 sbrk ,可以先调整进程的堆大小,当进程使用这块新内存时,OS发生页错误再分配物理内存,并重新执行错误的治理那个。如此可以使用更少内存,如果不用就没有页错误,也没有分配。本质上,这将一次性内存申请分摊到未来多次的页错误上。
注意,以上优化也是针对堆分配的特点。如果是栈分配,肯定不能如此实现。因为栈空间较小,而且是立刻使用,会引起过多密集的页错误。
one zero-filled page
用户进程常常使用大量内容为 0 的内存,例如,C的全局变量,全局数组初始化。在 CSAPP 这一段内存是 bss (block starting symbol 或称为 better saved space) 段。因此,内核将这块内存的页内容初始化为 0 。
现代OS一个技巧是,专门分配一个内容为 0 的页。当内核需要 zeor-filled 页时,将虚拟地址映射到这个特殊页,并设为 copy-on-write ;当需要写这块内存时,再真实分配一页用于写,并替换原来的映射,设为 r/w 权限。
copy-on-write fork
系统调用 fork 往往紧接着 exec 调用。如果内核为子进程真实分配物理页,之后也会被 exec 替换整个页表,造成无用功。
现代OS一般在实现 fork 时,让父进程和子进程共享所有物理页,但每个PTE都将它们映射为只读(清除PTE W标志)。父子进程可以从共享物理内存中读取数据。如果其中任何一个写入给定的页面,CPU将引发一个页面错误异常,由内核分配一个物理页用于写,并修改原来父子进程页表中的映射。
demand paging
简单看,系统调用 exec 将整个程序文件映像从磁盘载入内存。这个过程代价很高,花费时间(如果文件存在低速磁盘上就更慢了),而且不是所有文件内容都立刻使用。
OS优化可以将载入文件实现为按需载入页面,在页表中映射,但不真实分配物理页;发生页错误后,再从文件读入页,分配物理页并更新页表。实现这类优化需要物理页的磁盘定位元信息,这种信息一般在结构 virtual memory area (VMA)中。
用户进程可能需要超过物理内存的内存空间。OS一般将地址空间不常用的页置于磁盘,通过地址空间频繁换页实现。换页可能策略常用的有 LRU (Least recently used)。PTE 的 PTE_A 位可以帮助实现LRU。
memory-mapped files
OS通常使用系统调用 read ,write 与 lseek 读写文件。其实也可以将文件载入内存,读写内存来读写文件。如此不用如调用 lseek 每次改变 offset。Unix 提供 mmap 系统调用实现文件映射到内存。读写时,内核按需 page-in 文件映射页;当物理内存满了,page-out 不最近使用的文件映射页。
shared virtual memory
一般共享内存是一台机器上进程间通信的方式。而课程提及的共享虚拟内存是指不同机器的进程共享内存,通过网络分享 Distributed Memory。这属于分布式内容。
TLB management
CPU通过TLB缓存使用的物理页。Xv6在切换user/kernel时需要刷新整个TLB,因为内核页表与用户空间页表隔离,之前的缓存肯定用不上。
RISC-V提供许多精细化控制TLB的机制。
- PTE_G为global TLB bits不需要被刷新。例如,Xv6的trampoline由于被进程与内核共享,且用于陷入/离开内核,就可以设置为 PTE_G 存放于TLB中不被刷新。
- ASID (Address space identifier) 用于区分不同的地址空间。TLB entries 有 ASID 标记,内核的部分页可以选择性刷新。CSR
satp
也保存 ASID 信息,指令 sfence.vma 也可以使用 ASID 选择性刷新TLB。
Conclusion
由于异常处理流程在上一节课基本覆盖,这节课讲页错误以及页表实现的各种OS优化技巧。虚拟内存的机制仍在不断演化,比较早的如支持 large page,支持 eXecute
标记位,最近的如支持五级页表,支持 ASID等。
有意思的是,Linux为了缓和Meltdown漏洞影响,开始支持KPTI以隔离内核与进程地址空间。Xv6通过内核独立页表基本上也实现了KPTI。维基的记录表明,为了安全需要 KPTI ,但又一定程度上牺牲了陷入内核的效率。从中我们也可以学习内核设计的取舍。
Reference
- MIT 6.S081 2021
- RISC-V Calling Convention
- Videos
- [Meltdown(security vulnerability)](https://en.wikipedia.org/wiki/Meltdown_(security_vulnerability)
- Kernel page table isolation