Chapter4
Traps and system calls
陷阱与系统调用
有三种情况会导致CPU暂停普通指令的执行并强行将控制权转移给处理该事件的特殊代码:
第一种情况是系统调用,用户程序执行
ecall指令请求内核为其执行某些操作时;第二种情况是异常,当指令执行了非法操作时;
第三种情况是设备中断,当设备发出信号提示其需要注意时。
我们将这些情况统称为trap。通常的流程是:trap强制将控制权转移到内核->内核保存寄存器和其他状态以便后续恢复执行->内核执行适当的处理程序代码->内核恢复保存的状态并从trap返回->原始代码从中断处继续执行。
xv6内核负责统一处理这三种情况。这对于System call是自然的;对于Interrupt也一样,因为隔离要求用户进程不能直接使用设备,而只有内核拥有处理设备所需权限;对Exception来说,xv6通过杀死违规程序来响应来自用户空间的所有异常。
xv6的陷阱处理分为四个阶段:RISC-V CPU执行的硬件操作、为内核代码做准备的汇编向量、C陷阱处理程序、系统调用或设备驱动服务例程。
RISC-V trap machinery
RISC-V陷阱机制
每个RISC-V寄存器都有一组控制寄存器,内核通过写入这些寄存器来告诉CPU如何处理陷阱,且内核可以通过阅读寄存器来得知已经发生的trap。其中几个最重要的寄存器是:
stvec:内核将其陷阱处理程序的地址写入这里,RISC-V通过跳转到这里来处理陷阱;sepc:RISC-V将发生陷阱时的程序计数器保存在这里(随后pc会被stvec覆盖),sret指令(从陷阱返回)将sepc重新复制到pc,内核可以写入sepc以控制sret的去向;scause:RISC-V在这里放置一个数字用于描述陷阱的原因;sscratch:用于陷阱处理时安全保存用户态的上下文信息,这是监督者模式的临时寄存器(Supervisor Scratch Register);sstatus:其中的SIE位控制设备中断是否启用。如果内核清除SIE,RISC-V会推迟设备中断直到内核设置SIE。SPP位指示陷阱是来自用户模式还是监督模式,并控制sret返回到哪种模式。
上述寄存器都无法在用户模式下读写,它们用于监督者模式下的陷阱处理。对于机器模式下的陷阱处理,xv6有一组等效的控制寄存器,它们仅在定时器中断的特殊情况下被启用。多核芯片上的每个CPU都有自己的一组控制寄存器。
当需要强制进入trap时,RISC-V硬件会对所有trap类型(除了定时器中断)执行以下操作:
如果陷阱是设备中断且
sstatus的SIE位被清除,则不用执行以下操作;清除SIE位来禁用中断;
将
pc复制到sepc;将目前的模式(
user或者supervisor)保存到sstatus寄存器中的SPP位;设置
scause反映陷阱原因;将当前模式设置为
supervisor;将
stvec复制到pc;从新的
pc开始执行。
值得注意的是,CPU不会切换到内核页表、内核栈,也不会保存除了pc外的其他寄存器。
Traps from user space
来自用户空间的陷阱
当用户程序进行系统调用(ecall指令)、执行非法操作或设备中断时都可能发生trap,处理用户空间陷阱的代码流程如下:
uservec(在trampoline.S中)这是陷阱处理的入口,由
stvec寄存器指向这里。负责保存用户态寄存器到TRAPFRAME并且切换内核页表和内核栈,然后跳转到usertrap。RISC-V硬件在发生
trap时不切换页表,在跳转向stvec指向的指令时使用的仍然是用户页表,因此用户页表必须包含对uservec的映射。然后uservec切换satp以指向内核页表。为了在切换后继续执行指令,uservec必须在内核页表和用户页表中保持相同的地址映射。xv6通过一个包含uservec的trampoline页来满足这些约束。xv6在内核页表和用户页表中将trampoline页面映射到相同的虚拟地址,这个虚拟地址就是TRAMPOLINE。trampoline的内容在trampoline.S中设置,并且stvec被设置为uservec。当
trap发生时,CPU进入监督者模式,所有32个通用寄存器保持用户态的值。然后代码运行csrrw sscratch, a0,交换a0和sscratch寄存器的内容,这样uservec通过内核预先设置在sscratch中的值(TRAPFRAME的虚拟地址)获得了一个可用寄存器,a0也获得了TRAPFRAME的虚拟地址。这里书中使用的是
csrrw交换指令,但是项目代码中使用的是csrw sscratch, a0和li a0, TRAPFRAME,首先单向将a0的值写入sscratch,然后再加载TRAPFRAME的虚拟地址到a0。然后
uservec需要保存用户寄存器。在进程创建时,进入用户空间前,内核会将sscratch设置为指向每个进程的trapframe,该trapframe有空间保存所有用户寄存器。而此时satp仍然在使用用户页表,因此uservec需要将trapframe映射在用户地址空间中。在创建每个进程时,xv6为进程的trapframe分配一个页,并安排它始终映射在用户虚拟地址TRAPFRAME处,该地址位于TRAMPOLINE下方。进程的p->trapframe也指向trapframe,供内核通过内核页表使用。即trapframe包含指向当前进程内核栈的指针、当前CPU的hartid、usertrap的地址以及内核页表的地址等。uservec检索这些值,将satp切换到内核页表,见csrw satp, t1一句,并调用usertrap。usertrap(在trap.c中)这是陷阱处理的核心逻辑,负责判断陷阱类型并执行对应的处理程序。
它首先更改
stvec,以便内核陷阱由kernelvec处理,w_stvec((uint64)kernelvec)设置内核陷阱向量,这样可以防止在内核态处理用户陷阱时出现陷阱递归;然后保存sepc寄存器,p->trapframe->epc = r_sepc(),防止进程切换覆盖;如果陷阱类型是系统调用,由syscall处理,设备中断由devintr处理,异常直接杀死进程。p->trapframe->epc += 4,系统调用路径下,会将用户pc加4,指向ecall的下一条指令。退出时,检查进程是否被杀死或是否需要让出CPU。定时器中断(which_dev == 2)会触发调度,防止进程独占CPU。usertrapret(在trap.c中)负责重新设置
sstatus和sepc寄存器,并将stvec重新指向uservec。它设置RISC-V控制寄存器,将
stvec指向uservec,填充trapframe字段,并将sepc设置为之前保存的用户程序计数器。最后在用户和内核页表中都有映射的trampoline页面上调用userret,因为userret将切换页表。userret(在trampoline.S中)负责切换回用户页表并恢复用户态寄存器。
它通过
a0接收进程的用户页表指针,然后将satp切换到进程的用户页表,并在切换页表后加载TRAPFRAME地址到a0,然后恢复所有寄存器,并将trapframe中保存的用户a0复制到sscratch。接下来,userret从trapframe中恢复保存的用户寄存器,最后交换a0和sscratch以恢复用户a0并为下一次trap保存TRAPFRAME,然后使用sret回到用户空间。a0寄存器需要最后恢复,因为前面指令都依赖a0作为TRAPFRAME基址。另外,上述描述与代码有一些出入,目前代码的流程应该是:恢复所有寄存器,最后恢复
a0,然后直接返回用户空间。这是因为内核在进程调度时会重新初始化陷阱帧。
Code:Calling system calls
代码:调用系统调用
这部分介绍用户调用如何到达内核中exec系统调用。
用户代码将exec的参数放入寄存器a0和a1中,并将系统调用数放入a7,系统调用数会用于与syscalls数组中的条目匹配,这是一个函数表来索引syscalls。然后ecall触发trap进入内核,执行系统调用触发的相应步骤。
当系统调用实现函数返回时,syscall记录返回值到p->trapframe->a0,这将导致原始用户空间调用的exec返回该值。因为在RISC-V的C调用约定中,返回值放在a0中。
系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用数无效,syscall会打印错误并返回-1。
Code:System call arguments
代码:系统调用参数
内核中的系统调用实现需要找到用户代码传递的参数。参数最初位于寄存器中,内核陷阱代码将所有用户寄存器保存到了当前进程的陷阱帧(trapframe)中。argint、argaddr和argfd函数分别从陷阱帧中检索第n个系统调用参数,分别作为整数、指针和文件描述符。它们都调用argraw来检索适当的已保存的用户寄存器。
当系统调用使用指针作为参数传递时,一方面可能用户程序存在错误或恶意,另外xv6内核的页表映射与用户页表映射也不同,因此内核无法使用普通指令从用户提供的地址加载或存储数据。
内核实现了安全地在用户提供的地址之间传输数据的功能。例如fetchstr,文件系统调用使用它从用户空间检索字符串文件名参数,它又调用copyinstr。
copyinstr从用户页表的虚拟地址srcva中复制最多max字节到dst。它使用walkaddr遍历页表,以确定srcva的物理地址pa0。由于内核将所有物理RAM地址映射到相同的内核虚拟地址,copyinstr可以直接将字符串字节从pa0复制到dst。walkaddr会检查用户提供的虚拟地址是否属于进程的用户地址空间。
Traps from kernel space
内核空间的陷阱
xv6根据用户代码和内核代码的执行情况,对CPU的陷阱寄存器进行了不同的配置。当内核在CPU上执行时,内核将stvec指向kernelvec处的汇编代码。由于xv6已经处于内核态,kernelvec可以通过satp被设置为内核页表,并且栈指针指向一个有效的内核栈。kernelvec会保存所有寄存器,以便被中断的代码后面能不受干扰的恢复执行。
kernelvec将寄存器保存在被中断的内核线程的栈上,因为陷阱可能导致切换到另一个线程,然后在新线程的栈上返回,而被中断线程的寄存器可以安全地保存在其栈上。
kernelvec在保存寄存器后跳转到kerneltrap,kerneltrap调用devintr来检查和处理设备中断,调用panic来处理异常。
如果kerneltrap由于定时器中断而被调用,并且一个非调度器线程的进程的内核线程正在运行,那么kerneltrap会调用yield来给其他线程运行的机会。
Page-fault exceptions
缺页异常
xv6对异常的处理很简单:
如果在用户空间,内核会终止出错的进程;
如果在内核空间,内核会崩溃。
很多内核使用缺页异常(page-fault)来实现copy-on-write(COW)fork。父进程和子进程可以通过COW安全地共享物理内存。当CPU无法将虚拟地址转为物理地址时,CPU会生成一个缺页异常错误。
RISC-V有三种不同类型的page fault:
load page fault加载页面错误(当加载指令无法转换其虚拟地址时);store page fault存储页面错误(当存储指令无法转换其虚拟地址时);instruction page fault指令页面错误(当指令的地址无法转换时)
scause寄存器中的值指示页面错误的类型,stval寄存器包含无法转换的地址。
COW fork让父进程和子进程共享所有物理页面,但将它们映射为只读。因此,当子进程或父进程执行存储指令时,RISC-V CPU会引发缺页异常。然后内核做出响应,它复制包含错误地址的页面,然后将一个副本映射给子进程地址空间读写,另一个副本给父进程。更新页表后,内核在引发错误的指令处恢复故障进程。此时内核已经更新PTE运行写入,因此现在指令可以正确执行。
另一个利用页面错误的功能是从磁盘分页。如果应用程序需要的内存超过了可用的物理RAM,内核可用evict一些页,将它们写入磁盘等存储设备,并将它们的PTE标记为无效。如果应用程序读写这些页面就会触发page fault,然后内核检查错误地址,如果该地址属于磁盘上的页面,内核就会分配一页物理内存,将该页面从磁盘读到该内存中,然后更新PTE,恢复应用程序。
Last updated