MIT6.S081 ---- Preparation: Read chapter 4
Chapter 4 Traps and system calls
有三种事件会使 CPU 放弃正常的指令执行,强制将控制权交给一段特殊的代码处理这个事件:
- 一种情况是 \(system\ call\):当一个用户程序执行
ecall
指令请求内核为它做事的时候。 - 一种情况是 \(exception\):一条指令(内核或者用户)做一些非法操作,如除零或访问一个无效的虚拟地址。
- 一种情况是 \(device\ interrupt\):当一个设备发出需要被处理的信号,例如当磁盘硬件完成读写请求。
本书使用 \(trap\) 作为上述情况的通用术语。通常,执行在 trap 期间的任何代码稍后都需要恢复,并且不需要知道发生了什么,这是 trap 的透明性;这对设备中断特别重要,被中断的代码不感知中断。通常的流程是:
- trap 强制将控制权转移给内核;
- 内核保存寄存器和其他状态,便于后来恢复执行;
- 内核执行合适的处理代码(如系统调用实现或设备驱动);
- 内核恢复保存的状态并从 trap 返回;
- 原来的代码会在之前停止的地方继续执行;
xv6 在内核中处理所有的 traps;traps 不会被交给用户代码:
- 在内核中处理 traps 对于系统调用很正常。
- 对于中断是有意义的,因为隔离性要求只允许内核使用物理设备,而且内核是一种可以在多进程间共享设备的方便机制。
- 对于异常也是有意义的,因为 xv6 通过 kill 所有有问题的程序响应用户空间的所有异常。
xv6 trap 处理过程分为四步:
- RISC-CPU 采取的硬件措施
- 一些为内核 C 代码做准备的汇编指令
- 一个决定如何处理trap的C函数
- 系统调用或者设备驱动服务程序
三种 trap 类型(异常、中断、系统调用)的共性表明内核可以用单一的 code path 处理所有的 traps ,对于三种不同的情况,使用单独的代码比较方便:来自用户空间的traps,来自内核空间的traps,时钟中断。处理一个trap的内核代码(汇编或者C)通常称为\(handler\),第一部分 \(handler\) 指令通常用汇编写成(而不是C),有时称为 \(vector\)。
RISC-V trap machinery
每个 RISC-V CPU 有一组控制寄存器:
- 内核能写一些寄存器,通知 CPU 如何处理 traps。
- 内核能读一些寄存器,查找已经发生的 trap 。
risc.h
(kernel/risc.h:1)包含xv6使用的定义。这有最重要的相关寄存器的概述:
$stvec
:(Exception Program Counter)内核将它的trap handler的地址写在该寄存器。RISC-V跳转到$stvec
地址处理trap。$sepc
:当trap发生时,RISC-V在$sepc
保存PC(因为PC的值被$stvec
的值覆盖了),记录触发中断的指令地址。sret
(return from trap)指令将$sepc
的值写到PC。内核通过写$sepc
控制sret
指令返回的位置。$scause
:RISC-V用该寄存器的值表明trap的原因。$sscratch
:内核在这里放一个值,该值在trap handler的开始很有用。(这个寄存器在实现线程时起作用,在用户态保存内核地址,在内核态为0)。$sstatus
:$sstatus
的SIE位(Supervisor Interrupt Enable)控制设备中断是否开启。如果内核clear SIE位,RISC-V将推迟设备中断,直到内核设置SIE位。SSP位表明一个trap是来自user-mode还是supervidor-mode并且控制sret
返回的mode。
以上寄存器和 supervisor-mode 下的 traps 处理有关,它们不可能在 user-mode 下被读写。在 machine-mode 下有一组相似的控制寄存器,xv6 只在定时中断的特殊情况下使用它们。
每个 CPU 在一个多核芯片上有自己的一组寄存器,在任何时间,都可能有多个CPU在处理中断。
当需要强制 trap 时,RISC-V 硬件为所有类型的trap(除时钟中断)做如下处理:
- 如果 trap 是一个设备中断,而且
$sstatus
的 SIE 位被 clear,则不做下述操作。 - 通过 clear
$sstatus
的 SIE 位关中断。 - 复制 PC 值到
$sepc
。 - 在
$sstatus
的 SSP 位保存当前mode(user或者supervisor)。 - 设置
$scause
记录 trap 的原因。 - 设置 mode 为 supervisor-mode。
- 复制
$stvec
的值(指向了trampoline
的uservec
的地址,这就是ecall
执行后,将要执行的 trap handling代码 )到 PC。 - 从新的 PC 开始执行。
内核软件必须完成(CPU不负责这些):
- 切换到内核页表
- 切换到内核栈
- 保存除了 PC 的任何寄存器
CPU不负责的原因:CPU在trap期间CPU做少量的工作为软件提供灵活性。如:
- 有些操作系统会省略页表的切换增加trap的性能。(硬件切换页表的话这个操作就是必做的,软件的话可以根据情况调整)。
- 某些系统调用中某些寄存器不需要保存,有些寄存器必须保存,这由软件、语言和编译器决定,通过只保存必要的寄存器而非全部32个寄存器可以提高trap的性能。(如果由硬件负责,则必须保存全部的寄存器,限制优化的空间)。
- 有些简单的系统调用不需要栈。(硬件不限制的话,软件可以进行定制优化)。
思考上面这些步骤是否能省略对于加快 trap 是很有价值的。尽管在一些情况下可以使用更简单的顺序工作,但是通常省略步骤会很危险。例如,假设 CPU 没有切换 PC ,来自用户空间的 trap 可能在运行用户指令的时候切换到 supervisor-mode 。这些用户指令可能打破用户/内核的隔离性。例如通过修改 $satp
寄存器指向一个允许访问所有物理内存的页表。
因此,CPU 必须切换到一个内核指定的指令地址,称为 $stvec
。
Traps from user space
xv6 处理 traps 方式不同,这取决于它执行在内核,还是执行在用户代码。本节讲后者,4.5节讲前者。
当用户程序在用户空间执行时,调用了系统调用(ecall
指令),或者做了一些非法操作,或者来了一个设备中断,这时引起了一个trap。来自用户空间的trap的顶级(调用链)path是:
uservec
(kernel/trampoline.S:16)usertrap
(kernel/trap.c:37)
当返回时usertrapret
(kernel/trap.c:90)userret
(kernel/trampoline.S:88)
xv6 的 trap handling 的设计的主要限制是当 trap 到来时 RISC-V 硬件不切换页表。这意味着$stvec
的 trap handler 地址必须在用户页表中有一个有效的映射,因为当 trap handing 代码开始执行时,那个页表必须有效(因为 ecall
不切换页表,所以内核 trap 代码的早期的指令,必须在所有用户页表中都有映射)。再者,xv6 的 trap handling 代码需要切换到内核页表,为了能在切换后继续执行,内核页表必须也有一个对于 stvec
指向的 handler 的映射。
xv6使用 trampoline
页满足这些要求。 trampoline
页包含 $stvec
指向的 xv6 trap handling 代码 uservec
。trampoline
页被映射在每个进程的页表中,地址为 TRAMPOLINE
,在虚拟地址空间的末尾,在程序自己使用的内存的上面。trampoline 页也被映射在内核页的 TRAMPOLINE
地址。
因为 trampoline 页被映射在用户页表,没有PTE_U
标志(textbook中写的是有,但是这里理论上应该没有,代码中也没有,因为该页的代码不能在user-mode执行),traps在supervisor-mode下在这里开始执行(通过对qemu执行Ctrl+A C
进入monitor模式,info mem
查看当前页表的TRAMPOLINE
的attr得该页的PTE_U是clear,而ecall
之后PC指向了该页的代码,且程序没有崩溃,所以可以推出当前是supervisor-mode态)。
因为trampoline页被映射在内核地址空间的相同地址,在切换到内核页表之后,trampoline能继续执行,不会引起崩溃。
对于uservec
的trap handler代码,在trampoline.S
(kernel/trampoline.S:16)。当uservec
开始执行时,所有32个寄存器包含有被中断的用户代码所拥有的值。这32个值需要被保存在内存中,当trap返回用户空间时需要恢复。存储到内存需要使用寄存器来保存地址,但是当前没有可用的通用寄存器。所幸RISC-V以$sscratch
寄存器的形式提供了帮助。
uservec
的第一条指令csrrw
交换$a0
和$sscratch
寄存器的内容。现在用户代码的$a0
被保存在$sscratch
;uservec
有一个寄存器$a0
可以使用,内核将a0
的值放在了$sscratch
中。
uservec
的下一个任务是保存32个用户寄存器。在进入用户空间之前,内核设置$sscratch
指向这个进程的trapframe
结构(这里有一部分空间用来保存32个用户寄存器)(kernel/proc.h:44)。因为$satp
指向用户页表,uservec
需要trapframe映射在用户地址空间。当创建每个进程的时候,xv6为进程的trapframe分配一页,总是将它映射在虚拟地址TRAMFRAME
处,在TRAMPOLINE
的下面。进程的p->trapframe
也指向trampframe(内核中是恒等映射,所以该指针指向的既是该进程trapframe的物理地址,也是内核虚拟地址),所以内核可以通过内核页表找到该物理地址。
内核为什么要保存这些寄存器?
内核即将运行的C代码会覆盖那些寄存器。如果想要正确恢复用户代码,就需要恢复这些寄存器,使得用户代码的寄存器状态和执行ecall时相同。为什么这些寄存器要保存在单独设置的trapframe区域,而不是保存在user stack中?
不确定用户程序是否有栈,有些编程语言没有栈,栈指针可能指向空。有些编程语言虽然有栈,但是格式内核无法理解,还有些编程语言从堆中分配一些小快作为栈,编程语言runtime可以理解这种格式,但是内核不理解。如果想正确运行不同语言编写的用户程序,内核不能对用户内存部分做任何假设,这部分可以存在或有效,被读或被写,所以内核必须一定程度上独立保存这些寄存器,不受编程语言影响。
交换$a0
和$sscratch
之后,$a0
有指向当前进程trapframe的虚拟地址。uservec
保存所有用户寄存器,包括用户的$a0
,从$sscratch
中读取。
trapframe
包含:
- 当前进程的内核栈的地址
- 当前进程CPU的hartid
usertrap
函数的地址- 内核页表的地址
uservec
恢复这些值,切换$satp
指向内核页表,调用usertrap
。
usertrap
的任务是形成trap的原因并返回(kernel/trap.c:37):
- 首先改变
stvec
以便内核中的trap由kernelvec
处理而不是uservec
处理。(如果trap发生在内核,这个设置才有用) - 保存
$sepc
寄存器(the saved user program counter,执行ecall
之后,CPU会将pc所指向的ecall
指令的地址写入$sepc
,为了返回用户空间回复执行),因为内核的usertrap
可能调用yield()
切换到另一个进程(kernel thread),而这个进程可能返回自己的用户空间,在这期间,它可能修改$sepc
。 - 分支处理:
- 如果trap是一个系统调用,
usertrap
调用syscall()
处理它。在此之前,将保存的PC加4:因为RISC-V在系统调用时中将PC指向了ecall
指令,但是用户代码需要在ecall
的下一条指令恢复执行。开中断,为了内核trap到来时硬件可以操作(RISC-V trap machinery中硬件如果检测到是关中断状态,则一些设备中断将会阻塞)。 - 如果是一个设备中断,
devintr
。 - 否则就是异常(exception)。内核杀死错误进程。
- 如果trap是一个系统调用,
usertrap
检查进程是否被killed或者需要yield CPU(如果这个trap是一个时钟中断)。
为什么不在
trampoline.S
中保存$sepc
,而要在trap.c
中保存sepc
?
没有什么好的理由解释这个问题。但是用户寄存器需要在trampoline.S
中保存,因为所有的C代码都是用编译器生成的,如果用C代码实现,则C代码会占用这些寄存器,所以需要在进入C代码前的汇编代码中保存用户寄存器。
返回用户空间的第一步是调用usertrapret
(kernel/trap.c:90),这个函数设置RISC-V控制寄存器为后续来自用户空间的trap做准备:
- 关中断。因为后面要修改
$stvec
指向uservec
,但是当前代码仍然是内核,如果开中断,那么PC会跳转到uservec
,这可能会导致内核崩溃,为了防止这个结果,应该关中断。 - 改变
$stvec
,指向uservec
- 准备
uservec
依赖的trapframe字段(kernel_satp,kernel_sp,kernel_trap,kernel_hartid
) - 设置
$sstatus
的SSP和SPIE,userret
中的sret
指令会返回user-mode,通过设置$sstatus
的SPIE位,硬件会在进入user-mode时开中断。 - 设置
$sepc
为之前保存的PC(返回用户态后ecall
的吓下一条指令的地址) - 找到进程用户页表的物理地址satp,做参数传给
userret
。不能在这里切换页表,因为当前在trap.c
代码中,这是内核地址空间,只有进入trampoline.S
代码,才可以切换页表为用户进程的页表,因为trampoline页在用户地址空间有映射。 - 最后,
usertrapret
调用trampoline页(被映射在用户页表和内核页表)上的userret
,将进程页表的物理地址作为第二个参数传入。
usertrapret
调用userret
将TRAPFRAME
保存到$a0
,将进程的用户页表的指针保存在$a1
(kernel/trampoline.S:88)。
userret
:
- 切换
$satp
到进程的用户页表。用户页表映射了trampoline页面和TRAPFRAME
,没有映射内核的其他内容。事实是:trampoline页被映射在内核和用户页表相同的虚拟地址,这使得uservec
在切换satp
后继续执行。 - 将trapframe中保存的用户
trapframe->a0
复制到$sscratch
为下次与TRAPFRAME
交换做准备。从这点看,userret
能使用的数据只有:寄存器的内容和trapframe的内容 - 接下来
userret
从trapframe恢复保存的用户寄存器。当前寄存器$a0
保存的是TRAPFRAME
的虚拟地址(usertrapret
传递的第一个参数),通过寄存器$a0
将除了$a0
之外的所有trapframe
保存的寄存器值恢复到相应的寄存器中,然后利用$sscratch
将trapframe->a0
的值恢复到$a0
中,同时将TRAPFRAME
的地址交换到$sscratch
以便下次trap可以暂存$a0
从而保存用户寄存器。 - 最后执行
sret
返回到用户空间。
Code: Calling system calls
Chapter2讲了initcode.S
调用exec
系统调用(user/initcode.S:11)。本节讲用户调用如何进入exec
系统调用在内核中的实现。
initcode.S
将exec
的参数放在寄存器a0
和a1
中,系统调用编号放在a7
中。system call numbers匹配syscalls
数组(syscalls
是一个函数指针表)。ecall
(改变为supervisor-mode,跳转到stvec
)指令trap进内核,ecall
指令执行后pc将跳转到用户地址空间的trampoline
区域,此时状态为supervisor-mode,执行uservec
,usertrap
,syscall
。
syscall
(kernel/syscall.c:133)从trapframe中保存的a7
中恢复系统调用编号,使用这个编号去索引syscalls
。对于第一个系统调用,a7
含有SYS_exec
(kernel/syscall.h:8),引出系统调用实现sys_exec
函数的调用。
当sys_exec
返回时,syscall
将返回值存到p->trapframe-a0
中,这也是exec()
函数的返回值,因为RISC-V的C调用约定将返回值放在a0
中。系统调用返回负数通常表明errors。0或者正数表明success。如果系统调用号无效,syscall
打印一个error并且返回-1。
Code: System call arguments
系统调用在内核中的实现需要找到被用户代码传递的参数。因为用户代码调用系统调用封装的函数,参数按照RISC-V C calling convention放在寄存器里。内核trap代码保存用户寄存器到当前进程的trapframe,内核能在这里找到寄存器的值。内核函数argint()
,argaddr()
,argfd()
从trapframe中恢复系统调用的参数作为一个整数、指针、文件描述符。它们都是调用argraw()
恢复被保存的用户寄存器(kernel/syscall.c:35)。
一些系统调用传递指针作为参数,内核必须使用这些指针去读写用户内存。如:exec
系统调用传给内核一组指向用户空间字符串参数的指针。这些指针带来两个挑战:
- 用户程序可能有bug或者是恶意的,可能传给内核一个无效的指针或者一个有意指针欺骗内核访问内核空间而不是用户空间。
- xv6内核页表映射和用户页表映射不同,内核不可能使用普通的指令
load/store
用户提供的地址。
内核实现了可以安全地对用户提供的地址进行数据传输的函数。fetchstr
是一个例子(kernel/syscall.c:25)。文件系统调用exec
使用fetchstr
从用户空间恢复字符串文件名参数。fetchstr
调用copyinstr
。
copyinstr
(kernel/vm.c:398)最多从用户页表pagetable
的虚拟地址srcva
复制max
字节到dst
。因为pagetable
不是当前页表,copyinstr
使用walkaddr
(walkaddr
调用walk
)在pagetable
中查找srcva
,产生物理地址pa0
(kernel/vm.c:405)。内核映射每个物理地址到相应的内核虚拟地址,所以copyinstr
能直接从pa0
复制字符串字节到dst
。walkaddr
(kernel/vm.c:104)检查用户提供的虚拟地址是否在用户地址空间内,所以应用程序不可能欺骗内核读取其他内存。一个类似的函数,copyout
将数据从内核复制到用户提供的地址。
Traps from kernel space
xv6根据正在执行的是内核代码还是用户代码,对CPU trap寄存器的配置略有不同(内核这个情况主要是处理中断和异常):
当内核正在CPU上执行时,内核将$stvec
指向汇编代码kernelvec
(kernel/kernelvec.S:10)。
因为xv6在内核中,kernelvec
能直接使用:已被设置为内核页表的$satp
和指向有效内核栈的栈指针。
kernelvec
将所有32个寄存器压入栈中,便于后来恢复它们使被中断的内核代码可以不受干扰的执行。
kernelvec
在被中断的 kernel thread 的栈上保存寄存器,这很有意义:因为寄存器的值属于该线程。如果trap导致切换到另一个线程,这点很重要,在这种情况下,trap将从新线程的栈上返回,将中断线程保存的寄存器安全的保留在它的栈上。
保存寄存器后,kernelvec
跳转到kerneltrap()
(kernel/trap.c)。kerneltrap()
为两种trap类型做了准备:设备中断和异常。
调用devintr
(kernel/trap.c)检查并处理中断。
如果trap不是一个设备中断,则必定是异常,如果发生在xv6内核,这总是一个致命的error,内核调用panic
并停止执行。
如果由于时钟中断调用kerneltrap()
(和用户程序运行时来一个时钟中断对比理解),并且进程的kernel thread正在运行(不是调度线程),kerneltrap()
调用yield()
给其他线程一个运行的机会。在某个时刻,这些线程中的一个将放弃运行,让我们的线程和它的kerneltrap
再次恢复。第7章介绍yield
。
当kerneltrap
执行完毕,它将返回被trap中断的代码。因为yield
可能破坏了$sepc
和$sstatus
中的previous mode
,所以kerneltrap
在恢复运行时需要保存它们。它恢复这些控制寄存器,返回到kernelvec
(kernel/kernelvec.S:48)。
kernelvec
从栈中弹出保存的寄存器,执行sret
,复制$sepc
到PC,恢复中断的内核代码。
有意义的思考:如果kerneltrap
因为时钟中断调用yield
,trap返回如何发生。
当CPU从用户空间进入内核空间时,xv6设置CPU的$stvec
为kernelvec
(见 kernel/trap.c:usertrap )。有个时间窗口:内核开始执行(ecall
指令执行之后)但$stvec
仍然设置为uservec
,这期间不能有设备中断,这非常重要(否则的话无法进入kernelvec
,会有问题)。
幸运的是当开始trap时,RISC-V总是关中断的,而xv6在设置$stvec
之前不会开中断。(xv6只有在系统调用 trap 分支中执行 syscall()
之前才会软件主动开启中断。通过前面 RISC-V trap machinery 所学,当 trap 到来时,硬件会主动关中断)。
Page-fault exceptions
xv6响应 exceptions 非常简单:如果用户空间发生了一个 exception ,内核就 kill 掉错误的进程。如果内核发生了一个exception,内核就 panic
。真实的操作系统响应更有趣。
例如:许多内核使用page faults实现 copy-on-write(COW) fork。为了解释 copy-on-write fork ,需要先说明xv6的 fork
。 fork
使子进程的初始内存内容和父进程相同。xv6使用 uvmcopy()
(kernel/vm.c) 实现 fork,这个函数为子进程分配物理内存,将父进程的内存复制到这里。如果子进程和父进程能共享父进程的物理内存,那么会有更高的效率。但是直接实现是不行的,因为随着父子进程写共享的堆和栈,会造成父子进程破坏彼此的执行。
通过适当的使用页表权限和 page faults ,父子进程可以安全的共享物理内存。
CPU会产生一个 page-fault exception 的条件:
- 当要使用的虚拟地址在页表中没有映射。
- 有一个映射但是
PTE_V
标志被clear了。 - 该映射的权限位 (
PTE_R, PTE_W, PTE_X, PTE_U
) 禁止执行的操作。
RISC-V 区别三种 page fault:
- load page faults。load指令不能转换虚拟地址。
- store page faults。store指令不能转换虚拟地址。
- instruction page faults。PC指向的虚拟地址不能转换。
$scause
寄存器
Exception Code Description 0 Instruction address misaligned 1 Instruction access fault 2 Illegal instruction 5 Load access fault 6 AMO address misaligned 7 Store/AMO access fault 8 Environment call 12 Instruction page fault 13 Load page fault 15 Store/AMO page fault
$stval
寄存器
- 含有特定的异常信息
- 有些异常不使用这个寄存器
- page faults 设置这个寄存器为出错(不能转换)的地址
- xv6通过
r_stval()
访问该寄存器
COW fork
COW fork 的基本思想是父子进程最初共享所有的物理页,但是每个映射都是read-only的(清除 PTE_W
标志)。父子进程能从共享物理内存读取。
如果任意进程要写一个给定的页,RISC-V CPU 产生一个 page-fault exception 。
可以利用 PTE 中的 RSW(8-9 bit) 来标识这个页是用来处理 COW 异常的。
因为这个 page fault 本质上是写只读的页从而引起的异常,如果不做特殊标记,无法区分一些非 fork 的代码写只读页引起同样的异常。
如不使用 PTE 的 RSW 位,也可以通过内核维护这个信息。
内核的 trap handler 通过分配一个新的物理页并将错误地址映射到的物理页复制到新的物理页。内核修改出错进程页表的相关PTE,指向副本页(新物理页),并允许读写,然后出错进程在造成 page-fault 的指令处重新出执行。因为 PTE 允许写,所以重新执行的指令不会产生 page-fault 。
Copy-on-write要求通过记录(book-keeping)帮助决策什么时候物理页可以被释放,因为每个页可以被许多页表引用,这取决于forks, page faults, execs, exits 的历史记录。
book-keeping 有一个重要的优化:如果一个进程产生一个 store page fault 并且物理页只被该进程页表引用,则不需要复制。
内核必须维护每个物理页的引用计数 ( book-keeping )
当父进程多次 fork 之后,有的页面会被多次引用,如果父进程 exit ,那么这些被引用的页面需要判断是否可以被释放
copy-on-write 使得 fork
更快,因为 fork
不需要复制内存了。当写操作时,一些内存必须被复制,但通常情况是大多数内存一定不会被复制。一个简单的例子是 fork
之后执行 exec
:fork
之后只有很少的页可能被写,但子进程释放了大量继承自父进程的内存。copy-on-write fork 消除了复制内存的需要。进一步讲,COW fork 是透明的:应用程序无需感知即可受益。
lazy allocation
除了 COW fork 之外,页表和 page-faults 的组合提供了许多有趣的可能。另一广泛使用的特性是 lazy allocation (像 COW fork,内核实现的 lazy allocation 对应用是透明的),分两部分:
- 首先,当一个应用通过调用
sbrk
请求更多的内存时,内核记录增长的大小,但是不分配物理内存,也不为新增的虚拟地址创建 PTEs。 - 其次,这些新的地址发生一次 page-fault 时,内核分配一物理内存页,并将其映射在页表。
因为应用经常请求更多(相对与需求)的内存,lazy allocation 很有用:对于应用程序从不使用的页,内核不需要做什么。此外,如果应用程序要求大量增加地址空间,没有 lazy allocation 机制的 sbrk
开销昂贵:
- 如果应用程序请求 \(1GB\) 的内存,则内核必须分配 \(262144\) 个 \(4096B\) 的页。lazy allocation 允许这个开销随着时间分摊。
- 另一方面,lazy allocation 产生额外的 page-faults 开销,因为这涉及到内核和用户空间的转换。OS 减少这个成本的方法:
- 为每个 page-fault 分配一批连续的页(而不是一页)
- 专门处理这种 page-faults 的内核 entry/exit 代码。
demand paging
另一个利用 page-faults 的广泛使用的特性是 请求分页(demand paging) 。在exec
中,xv6将应用程序的所有 text 和 data 立即地导入内存。因为应用程序可能很大,从硬盘读取代价很大,启动成本对用户很明显:当用户从shell中启动一个大应用时,用户可能需要很长时间才能收到响应。
为了改进响应时间,现代内核为用户地址空间创建页表,但将对应页的 PTEs 标记为无效。当出现 page-fault 时,内核从硬盘中读取页的内容,将其映射在地址空间。
与 COW fork 和 lazy allocation 一样,这个特性对应用程序是透明的。
计算机上运行的程序可能需要比计算机 RAM 还要大的内存。为了优雅的应对这个问题,OS 可能要实现硬盘的分页。
思想是:
- RAM 中只存储一小部分用户页,其余的页存储在硬盘的分页区域。
- 内核将存储在硬盘分页区域(不在RAM中)的内存相对应的 PTEs 标记为无效。
- 如果一个应用尝试使用已经被调出到硬盘的页,应用将产生一个 page-fault。
- 请求的页面必须被调入内存:内核的 trap handler 将在 RAM 上分配一物理页,从硬盘上将请求的页读到RAM,修改相应的PTE指向 RAM。
如果一个页要调入内存,但是 RAM 没有空闲空间,会发生什么?
这种情况下,内核必须首先释放一个物理页:将这个物理页换出到硬盘上的分页区域,将引用该物理页的 PTEs 标记为无效。
换出的代价昂贵,所以换出不频繁时分页性能最好:如果应用只使用内存页的一个子集,这些子集的并集适配 RAM。这个属性常被称作好的引用位置。
和许多虚拟内存技术一样,内核实现的硬盘分页对应用程序是透明的。
无论硬件提供了多少 RAM,计算机通常在很少的物理内存或无空闲物理内存下运行。
例如:
云供应商复用一台机器,让许多客户经济高效的使用他们的硬件。
用户在智能手机上以少量的物理内存运行许多应用。
这些设置中,分配一页可能首先需要换出存在的一页。因此,当空闲物理内存不足时,分配代价昂贵。
other feature
当空闲内存不足时,lazy allocation 和 demand paging 特别有用。在 sbrk
和 exec
中立即(eager, not lazy)分配内存会产生额外的换出(换出使得内存可用)开销。此外,立即分配会造成浪费,因为应用程序使用这个页之前,OS 可能已经将这个页换出。
结合分页和 page-fault exceptions 的其他特性包括:自动扩展栈和 memory-mapped-files。
Real world
trampoline 和 frapframe 可能很复杂。原因是 RISC-V 在遇到一个 trap 时,有意做尽可能少的执行操作,允许快速处理 trap,这被证明很重要。
因此,内核 trap handler 的前几条指令必须在用户空间高效的执行:用户页表和用户寄存器内容。trap handler 最初忽略的有用的内容:运行进程的标识,内核页表的地址。
解决:RISC-V 提供了受保护的空间,内核能在进入用户空间前将信息存在这个受保护的空间:$sscratch
寄存器,指向内核内存的用户 PTEs(通过clear PTE_U
保护用户代码不能访问)。xv6的 trampoline 和 trapframe 利用了这些 RISC-V 特性。
如果内核的内存被映射在每个进程的用户页表(有恰当的 PTE 权限标志),就可以消除特殊的 trampoline 页的需要(每个进程一个trampoline页的需要)。也可以消除从用户空间 trap 到内核空间的页表切换的需要(切换mode之后,不用切换页表代码也可以运行)。反过来也会允许内核中的系统调用实现可以利用被映射的当前进程的用户内存,允许内核代码直接解引用用户指针。
许多操作系统使用这些思想提高效率。xv6不用这些思想:减少由于误用用户指针引起的内核中的 安全bugs ;减少要求确保内核和用户地址不重叠引起的 复杂性(如一些操作系统将内核放在低地址,用户放在高地址,避免而这重叠)。
生产操作系统实现了 copy-on-write fork,lazy allocation,demand paging,paging to disk,memory-mapped files 等。
此外,生产操作系统尝试使用所有的物理内存用于应用程序或者 caches(如:文件系统的buffer cache,8.2章学习)。
xv6在这点是不足的:你想你的 OS 可以使用你购买的全部内存,但xv6没有实现。
而且,如果xv6内存不足,它向正在运行的应用返回一个 error 或者 kill 掉这个应用,而不是换出另一个应用程序的一页。