System Call Entry / Exit
Chapter4: Traps and system calls
有三种事件会导致 CPU 暂停普通指令的执行,并强制将 控制转移 到处理该事件的特殊代码上:系统调用 system call。用户程序通过 ecall 请求系统服务;异常 exception。一条指令执行了非法操作时引发(无论用户程序还是内核);设备中断 interrupt。设备发出信号表示需要处理。
本书使用 trap 作为这些情况的统称
通常来说,trap 发生并处理前后,程序不应意识到有任何特殊情况发生,也就是说, trap 对程序应该是透明的
这一点对于中断尤其重要
通常来说,trap 发生前后的流程 是:trap 强制转移控制权给内核;内核保存寄存器和其他状态;内核执行 trap 的处理代码;内核恢复保存的状态并从 trap 中返回;原始代码继续执行
由 xv6 内核处理所有的 trap。这对于系统调用来说是自然而然的。对于中断来说也是合理的,因为隔离要求用户进程不能直接使用设备,而且只有内核拥有处理设备所需的状态。对于异常也是合理的,因为 xv6 会通过终止发生异常的程序来响应所有用户空间的异常。
trap 处理 分为四个阶段:CPU 执行一些硬件操作;一段 assembly “vector” 为C代码准备环境;C trap handler;系统调用或设备驱动服务例程。
尽管发生 trap 时可以执行单一的代码处理所有 trap,但分别为用户空间、内核空间、定时器中断设置独立的汇编向量和 C trap 处理程序会更加方便。
kernel/riscv.h:1
每个 RISC-V CPU 都有一组 控制寄存器,内核可以通过写入这些寄存器来告知 CPU 如何处理 trap,同时内核也可以读取这些寄存器以了解发生的 trap。
以上寄存器与 内核模式下处理的异常 相关,用户模式下无法读写这些寄存器
这些寄存器在机器模式下也有相应的版本,用于处理机器模式下的 trap;xv6 仅在处理定时器中断 的特殊情况下使用它们。
多核芯片的每一个 CPU 都有自己的一套这些寄存器,可以在同一时间处理不同的 trap。
当需要强制触发 trap 时,RISC-V 硬件 对所有 trap 类型(除定时器中断外)执行以下操作:
CPU 在处理 trap 时 不会切换到内核页表、不会切换到内核中的堆栈,也不会保存除 PC 以外的任何寄存器。 这些操作由内核软件执行。CPU 在 trap 期间执行的工作量最小,目的是为软件提供灵活性。
你可能会想,CPU 的 trap 处理序列是否可以进一步简化。例如,假设 CPU 不切换程序计数器,那么 trap 可能会在切换到主管模式时仍然执行用户指令。这些用户指令可能会破坏用户/内核隔离,例如通过修改
satp
寄存器指向允许访问所有物理内存的页表。因此,CPU 切换到内核指定的指令地址(即stvec
)是很重要的。
这种情况可能发生在:用户程序使用 syscall、非法操作、设备中断
其代码逻辑是:uservec(kernel/trampoline.S) -> usertrap(kernel/trap.c)-> 内核,返回时的路径为:内核 -> usertrapret(kernel/trap.c)-> userret(kernel/trampoline.S)
来自用户代码的 trap 比来自内核的 trap 更具挑战性,因为 satp 指向一个不映射内核的用户页表,而且堆栈指针可能包含无效或甚至恶意的值。
由于 RISC-V 硬件在 trap 期间不会切换页表,用户页表必须包含对 uservec 地址的映射,这是 stvec 指向的 trap 向量指令。由 uservec 设置 satp,切换到内核页表;为了在切换后继续执行指令,uservec 必须在内核页表和用户页表中映射到相同的地址。
这个包含 uservec的页叫做 trampoline,xv6 在内核页表和每个用户页表中将 trampoline 页面映射到相同的虚拟地址。trampoline 的内容在 trampoline.S 中设置,而在执行用户代码时,stvec 被设置为 uservec。
uservec 开始执行时 并没有可用的寄存器和数据,所有 Unprivileged 寄存器放的都是用户的数据。
RISC-V 提供了 sscratch 寄存器和 csrrw 指令。csrrw 将 a0 的值与 sscratch 交换,从而保存a0,提供可用的寄存器:a0,并给我们传递了一个指针放到 a0 中,它指向本进程的 trapframe,预先保存到 sscratch 中
下一个任务是保存用户寄存器,这通过 trapframe 实现。uservec 将所有用户寄存器保存到 trapframe 中。此时我们仍处在用户地址空间,所以需要对 trapframe 进行映射 。创建进程时,kernel 为 trapframe 分配一个页,放在TRAMPOLINE的正下方:TRAPFRAME。进程的 p->trapframe 指向 trapframe 的是物理地址,以便内核可以通过内核页表使用它。
trapframe 包含指向当前进程的内核堆栈、当前 CPU 的 hartid、usertrap 的地址以及内核页表地址的指针
uservec 于是可以设置 satp,切换到内核页表。最后,调用 usertrap
usertrap 根据 trapframe 中的内容确定 trap 的原因,处理,并返回。
kernel/trap.c:37
在返回用户空间的过程中,第一步是调用 usertrapret。这个函数设置 RISC-V 控制寄存器,为将来来自用户空间的 trap 做准备。
这包括:
最后调用 trampoline 上的 userret,userret 准备用户数据并使得程序返回到用户空间
userret 根据 a0 切换到用户页表,然后设置 a0 为 TRAPFRAME
请注意,用户页表只映射了 trampoline 页面和 TRAPFRAME,但没有映射其他内核内容。同样,trampoline 页面在用户和内核页表中映射到相同的虚拟地址,这使得 uservec 在更改 satp 后能够继续执行。
userret 将 trapframe 中的 a0 放到 sscratch 中,再恢复除了a0外的所有寄存器。然后交换 a0(此时存放TRAPFRAME)和 sscratch,成功恢复所有用户寄存器。
最后调用 sret 返回到用户空间
让我们看看 initcode.S 如何一路调用到 exec 的:
代码将 exec 的参数放到 a0 和 a1 中,并将系统调用号放到 a7 中,然后调用 ecall。ecall 触发进入内核,并依次执行 uservec、usertrap、syscall。syscall 根据系统调用号调用相应的函数。函数返回时,syscall 将返回值放到 p->trapframe->a0
用户按照调用约定将参数放到固定的位置,uservec 将其放到 trapframe中,经过 usertrap、syscall,调用相关的系统调用函数。
系统调用函数使用函数 argint、argaddr 和 argfd 通过调用 argraw,获取 trapframe 中的第 n 个系统调用参数。
用户有时候会传递指针给内核让内核读写自己的内存,但由于无效或恶意的指针、页表不同的原因,这一过程无法用正常的方式进行,必须使用专门的函数读写用户提供的指针指向的内存
如 fetchstr,它调用 copyinstr 完成这项工作:
copyinstr 使用 walkaddr 模拟 mmu,通过用户提供的页表和虚拟地址,获取实际的物理地址,并将其复制到指定的容器中
walkaddr 会检查用户提供的虚拟地址是否属于该进程
类似的函数有:copyout
根据操作模式的不同,stvec 指向的汇编代码不同。当从用户模式进入内核模式时,会使 stvec 指向 kernelvec。
见 usertrap
kernelvec 保存所有的寄存器到中断的内核线程的堆栈上,以便中断的代码可以在稍后恢复而不受干扰。
这很合理,因为寄存器的值属于该线程。如果 trap 导致了线程切换,这一点尤为重要——在这种情况下,trap 实际上会在新线程的堆栈上返回,从而将被中断的线程的保存寄存器安全地留在其堆栈上。
然后跳转到 kerneltrap
kerneltrap 只需要处理两种类型的异常:设备中断则调用 devintr,异常则 panic 并停止执行
如果设备中断由定时器中断引起,并且有至少一个非调度器线程的内核线程,则通过 yield 让出CPU给其他线程。
最终,其中一个线程会调用 yield,让我们的线程和其 kerneltrap 再次恢复。
由于 yield 可能扰乱 sepc 和 sstaus 中的值,kerneltrap 会在最开始保存它们,并在处理 trap 后恢复
最后返回到 kernelvec
返回到 kernelvec 后,kernelvec 从堆栈中弹出保存的寄存器,通过 sret 将 sepc 复制到 pc,并恢复中断的代码
当从用户模式进入内核模式时,最初的 stvec 指向 uservec,在这个窗口期必须禁用设备中断,在设置 stvec 之前不会重新启用它们。
适当的带权限映射可以避免使用 trampoline 页,从而避免页表切换、直接使用用户地址空间
但这也可能使得不慎使用用户指针而导致安全问题、增加了用户和内核虚拟地址重叠所带来的的复杂性。
每当系统调用、异常、设备中断时,会发生用户和内核空间的切换,称为 trap。
trap 发生得很频繁,其设计中有很多对安全隔离和性能来说非常重要的细节
那么问题来了,如何从只有 user 权限的空间切换到内核?主要依靠硬件
32个用户寄存器中,很多寄存器都有特殊的作用,如:SP、RA。此外还有一些权限寄存器,用户模式下不能访问:pc、操作模式标志位、satp、stvec、sepc、sscratch,这些寄存器都表明了发生 trap 时计算机的状态
具体介绍见课前
改变操作模式后可以干什么?读写控制寄存器、以及使用用户模式下被禁用的PTE
——除此之外没有更多的事情可以干,我们仍受限于PT,所以不能读写任意物理地址:不在PT中的不能读写,设置PTE_U的也不能读写
当在用户空间发生trap时,所有的计算机状态都被设置为用户模式相关,我们需要更改一些状态,以切换到内核模式。有一些操作是我们必须要做的:保存32个用户寄存器、保存pc、切换操作模式、切换satp、设置堆栈、跳转到内核C代码
操作系统的一些high-level的目标能帮我们过滤一些实现选项。其中一个目标是安全和隔离,我们不想让用户代码介入到这里的user/kernel切换,否则有可能会破坏安全性。所以这意味着,trap中涉及到的硬件和内核机制不能依赖任何来自用户空间东西。比如说我们不能依赖32个用户寄存器,它们可能保存的是恶意的数据,所以,XV6的trap机制不会查看这些寄存器,而只是将它们保存起来。
在操作系统的trap机制中,我们仍然想保留隔离性并防御来自用户代码的可能的恶意攻击。同样也很重要的是,另一方面,我们想要让trap机制对用户代码是透明的,也就是说我们想要执行trap,然后在内核中执行代码,同时用户代码并不用察觉到任何有意思的事情。这样也更容易写用户代码。
我首先会简单介绍一下trap代码的执行流程,但是这节课大部分时间都会通过gdb来跟踪代码是如何通过trap进入到内核空间,这里会涉及到很多的细节。为了帮助你提前了解接下来的内容,我们会跟踪如何在Shell中调用write系统调用。
在 write 调用的过程中,首先把参数放到指定寄存器,通过 ecall 触发进入内核:
首先执行的是一个汇编函数:uservec,然后我们跳转到一个C函数 usertrap,它根据 trap 类型调用了 syscall,syscall 调用实现了相关功能的系统调用函数 sys_write,sys_write 执行后就会返回到 syscall。
返回时我们需要恢复用户空间的数据,syscall 会调用 usertrapret,除此之外还有一些工作只能在汇编中完成,于是 usertrapret 会调用 userret,userret 会调用机器指令返回到用户空间,恢复 ecall 之后的用户程序执行
学生提问:这个问题或许并不完全相关,read和write系统调用,相比内存的读写,他们的代价都高的多,因为它们需要切换模式,并来回捣腾。有没有可能当你执行打开一个文件的系统调用时, 直接得到一个page table映射,而不是返回一个文件描述符?这样只需要向对应于设备的特定的地址写数据,程序就能通过page table访问特定的设备。你可以设置好限制,就像文件描述符只允许修改特定文件一样,这样就不用像系统调用一样在用户空间和内核空间来回捣腾了。
Robert教授:这是个很好的想法。实际上很多操作系统都提供这种叫做内存映射文件(Memory-mapped file access)的机制,在这个机制里面通过page table,可以将用户空间的虚拟地址空间,对应到文件内容,这样你就可以通过内存地址直接读写文件。实际上,你们将在mmap 实验中完成这个机制。对于许多程序来说,这个机制的确会比直接调用read/write系统调用要快的多。
以 write 为例(user/usys.S)
放了个系统调用号到 a7,然后就ecall,ecall 返回后就 ret 到之后的指令
找到 ecall 地址并设置断点:
查看此时的寄存器:
a0 为fd,a1为要写入的字符串,a2为写入的字符数。另外,我们此时确实处于用户空间,上述图片显示我们此时离0很近,而内核会使用大得多的内存地址放指令。
此时的 satp(页表物理地址) 为:
从 QEMU 界面查看当前页表(ctrl a+c):
这是 shell 的页表,第五行为栈,第四行为 guard 页(没有设置u),第六行为我们之前设置的共享空间。再往下就是 trapframe 和 trampoline,用户不能使用它们。
对于这里page table,有一件事情需要注意:它并没有包含任何内核部分的地址映射,这里既没有对于kernel data的映射,也没有对于kernel指令的映射。除了最后两条PTE,这个page table几乎是完全为用户代码执行而创建,所以它对于在内核执行代码并没有直接特殊的作用。
a表示access,是否使用过;d表示 dirty,是否写过。这些标志为由硬件维护以方便OS使用
对于比XV6更复杂的操作系统,当物理内存吃紧的时候,可能会通过将一些内存写入到磁盘来,同时将相应的PTE设置成无效,来释放物理内存page。你可以想到,这里有很多策略可以让操作系统来挑选哪些page可以释放。我们可以查看a标志位来判断这条PTE是否被使用过,如果它没有被使用或者最近没有被使用,那么这条PTE对应的page适合用来保存到磁盘中。类似的,d标志位告诉内核,这个page最近被修改过。
不过XV6没有这样的策略。
执行 ecall,根据上面的页表,可以知道我们现在在 trampoline 页:
可以看到ecall使用了 stvec 中的值,帮助我们跳转到 trampoline 页:
我们现在已经处在内核模式了,最直观的,我们可以使用trampoline页而程序没有崩溃
ecall 还把 pc 保存到 sepc:
也就是说,ecall 总共干了三件事:切换到内核模式、保存pc到sepc、跳转到 stvec 指向的指令(通过保存它的地址到pc实现)
如果在 qemu 中查看当前页表,会发现页表并没有发生变化,也就是说,ecall并不会切换page table,这意味着要跳转到 trap 处理的下一步的代码,必须在每一个进程中都映射 trampoline 页
所以现在,ecall帮我们做了一点点工作,但是实际上我们离执行内核中的C代码还差的很远
我们下来需要:保存32个用户寄存器、切换到内核页表、找一个内核栈、跳转到合适的C代码
ecall 是一条CPU指令
在软件中完成这些工作并不是特别简单
实际上,有的机器在执行系统调用时,会在硬件中完成所有这些工作。但是RISC-V并不会,RISC-V秉持了这样一个观点:ecall只完成尽量少必须要完成的工作,其他的工作都交给软件完成。这里的原因是,RISC-V设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。所以你可以这样想,尽管XV6并没有使用这里提供的灵活性,但是一些其他的操作系统用到了。
举个例子,因为这里的ecall是如此的简单,或许某些操作系统可以在不切换page table的前提下,执行部分系统调用。切换page table的代价比较高,如果ecall打包完成了这部分工作,那就不能对一些系统调用进行改进,使其不用在不必要的场景切换page table。
某些操作系统同时将user和kernel的虚拟地址映射到一个page table中,这样在user和kernel之间切换时根本就不用切换page table。对于这样的操作系统来说,如果ecall切换了page table那将会是一种浪费,并且也减慢了程序的运行。
或许在一些系统调用过程中,一些寄存器不用保存,而哪些寄存器需要保存,哪些不需要,取决于于软件,编程语言,和编译器。通过不保存所有的32个寄存器或许可以节省大量的程序运行时间,所以你不会想要ecall迫使你保存所有的寄存器。
最后,对于某些简单的系统调用或许根本就不需要任何stack,所以对于一些非常关注性能的操作系统,ecall不会自动为你完成stack切换是极好的。
此时的寄存器全是用户数据,在保存它们之前,我们不能使用任何寄存器,也就基本上不能干任何事。页表也没有切换,我们也不能把这些寄存器直接保存到内存(其他机制的机器或许可以)
一种可能的机制是,ecall 直接将SATP寄存器指向kernel page table,然后就可以存储用户寄存器
Q:为什么不保存到用户栈中?
A:我们不确定用户程序是否有栈,有些编程语言没有栈,它的sp指向一个无效值,可能使用堆保存数据。也即,OS不能假设用户地址空间的格式,除非自己给他安排(trampoline和trapframe)。不能假设用户内存的哪部分可以访问,哪部分有效,哪部分存在。所以内核需要自己管理这些寄存器的保存,这就是为什么内核将这些内容保存在属于内核内存的trapframe中,而不是用户内存。
对于保存用户寄存器,xv6 在创建每个进程时,为其页表映射了一个 TRAPFRAME 页,专门用于保存这些数据:
此外还有一些切换到用户空间前设置好的数据:kernel_*,epc 保存 sepc 中的数据
我们之前提到过 SSCRATCH 寄存器,在进入到用户进程前,内核会保存本进程的 trapframe 地址到这个寄存器,也就是 TRAPFRAME。
RISC-V 还提供了一个指令允许交换任意两个寄存器的值:csrw,我们做的第一件事就是交换 a0 和 sscratch。然后我们保存除a0外的所有寄存器:
为什么不直接用 ssratch 的值做偏移保存?因为这是控制和状态寄存器 csr,必须用特殊的指令访问
接着我们从 ssratch 中取回a0并保存:
于是我们保存了所有的用户寄存器,现在我们可以使用所有的用户寄存器。接下来我们需要为内核的运行准备环境
a0偏移8刚好是 kernel_sp
这便是本内核进程的内核栈位置
每个内核栈下面都有guard page
严格来说这不是内核页表的地址,它需要经过移位
此时的页表和之前完全不同:
于是我们成功设置了内核栈指针、切换了内核页表,可以跳转到C代码了:
t0中的vm不变,而我们切换了页表,为什么 t0 中 usertrap 的地址仍然有效,而不是让我们跳转到一些无关的页面?
因为 trampoline 在用户和内核空间中映射到了相同的虚拟地址。其他页映射完全不同,这就是 trampoline page 的特殊之处
emm 就算映射不同,t0是从 trapframe 中来的,可以在返回用户空间前就给 trapframe 赋值内核中 usertrap 的vm吧?所以映射不同似乎也完全ok?
这是所有来自用户空间的 trap entry,也是 trap exit。系统调用、异常、设备中断都会途径这段代码。由于这些原因,usertrap 有必要保存并恢复硬件状态、以及根据 trap 类型跳转到相应的函数
它所做的第一件事就是 更改 stvec,使其指向 kernelvec。
我们目前处于内核空间,如果再次发生 trap 需要进行与 trap from user space 非常不同的处理。因为在内核空间,我们可以用堆栈、页表,一些对于用户来说有必要的操作在内核面前完全没必要
出于各种原因,我们还需要知道当前运行的是什么进程。
myproc 调用链上的一系列函数会根据 tp 中的 hartid 找到当前的CPU信息结构体,进而获得运行的进程信息结构体指针
接着我们把保存在 sepc 中的 pc 值存储到 trapframe 中的 epc 中
一旦发生 trap,pc 直接就被硬件复制到 sepc,并且关闭设备中断,紧接着的 uservec 会覆盖掉 pc。执行过程中,如果再次发生进程调度,切换到另一个进程,然后发生 trap,sepc 也会被覆盖掉,所以需要将 sepc 保存到与该进程相关的 epc 中
接着 根据 scause 找出发生 trap 的原因,8 表示发生系统调用。如果是系统调用,先检查当前进程有没有被杀掉,如果没有杀掉则 进入 syscall。
值得注意的是,进入syscall前需要给 trapframe 中的 epc 加4,以使得返回时执行下一条语句,并开启中断,防止系统调用耽搁太多时间
然后就是我们熟悉的 syscall:根据a7调用sys_*,返回时将返回值放到a0(trapframe)。相关sys函数执行时也是通过argxxx,使用trapframe获取参数。
接着返回到 usertrap,先检查要返回的进程有没有被杀死,若没有则继续执行 usertrapret
它首先关闭了设备中断,为了防止更新 stvec 后再次发生 trap,而走向 uservec——现在处于内核空间
接着 更新 stvec 指向 trampoline 中的 uservec
然后 填入 trapframe 中的 kernel_satp、kernel_sp、kernel_trap、kernel_hartid 字段,其中的 kernel_trap 其实是kernel代码中的 usertrap 函数,kernel_hartid 用于下次进入该线程时设置 tp
每次返回用户进程都会设置 kernel_hartid,而被其他CPU调度也需要从内核返回用户空间,所以下次进入trap时,kernel_hartid一定是当前CPU的id
任何经过编译的代码都不能修改用户寄存器——编译器要组织使用这些寄存器,所以保存用户寄存器的工作被放在汇编代码 uservec 中。实际上也可以在 uservec 中保存 sepc 到 epc,这都是ok的。对于用户寄存器,必须再进入C代码前保存好,而控制寄存器如 sepc 早点晚点都无所谓
接下来设置控制寄存器:
在 trampoline 中切换页表,因为此时还需要用到内核空间的一些数据
我们有两个参数分别放在 a0 和 a1中:TRAPFRAME 以及 satp值,首先 切换页表,并清空页表缓存:
trampoline 在用户和内核空间中被映射到了相同的vm,所以我们的程序在切换页表后还能正常运行
然后设置 SSCRATCH 为 系统调用的返回值 a0:
a0此时放的是 trapframe 指针,trapframe 中的 a0 会覆盖原来 trapframe保存的a0
接着恢复除a0外所有的寄存器,最后交换SSCRATCH和a0,从而得到返回值,以及使得SSCRATCH指向trapframe
最后是 sret:设置 pc 为sepc的值,切换操作模式为SPP标识的模式,打开中断
最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。
另一方面,XV6实现trap的方式比较特殊,XV6并不关心性能。但是通常来说,操作系统的设计人员和CPU设计人员非常关心如何提升trap的效率和速度。必然还有跟我们这里不一样的方式来实现trap,当你在实现的时候,可以从以下几个问题出发:
硬件和软件需要协同工作,你可能需要重新设计XV6,重新设计RISC-V来使得这里的处理流程更加简单,更加快速。
另一个需要时刻记住的问题是,恶意软件是否能滥用这里的机制来打破隔离性。
好的,这就是这节课的全部内容。