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类型(除了定时器中断)执行以下操作:

  1. 如果陷阱是设备中断且sstatus的SIE位被清除,则不用执行以下操作;

  2. 清除SIE位来禁用中断;

  3. pc复制到sepc

  4. 将目前的模式(user或者supervisor)保存到sstatus寄存器中的SPP位;

  5. 设置scause反映陷阱原因;

  6. 将当前模式设置为supervisor

  7. stvec复制到pc

  8. 从新的pc开始执行。

值得注意的是,CPU不会切换到内核页表、内核栈,也不会保存除了pc外的其他寄存器。


Traps from user space

来自用户空间的陷阱

当用户程序进行系统调用(ecall指令)、执行非法操作或设备中断时都可能发生trap,处理用户空间陷阱的代码流程如下:

  1. uservec(在trampoline.S中)

    这是陷阱处理的入口,由stvec寄存器指向这里。负责保存用户态寄存器到TRAPFRAME并且切换内核页表和内核栈,然后跳转到usertrap

    RISC-V硬件在发生trap时不切换页表,在跳转向stvec指向的指令时使用的仍然是用户页表,因此用户页表必须包含对uservec的映射。然后uservec切换satp以指向内核页表。为了在切换后继续执行指令,uservec必须在内核页表和用户页表中保持相同的地址映射。

    xv6通过一个包含uservectrampoline页来满足这些约束。xv6在内核页表和用户页表中将trampoline页面映射到相同的虚拟地址,这个虚拟地址就是TRAMPOLINEtrampoline的内容在trampoline.S中设置,并且stvec被设置为uservec

    trap发生时,CPU进入监督者模式,所有32个通用寄存器保持用户态的值。然后代码运行csrrw sscratch, a0,交换a0sscratch寄存器的内容,这样uservec通过内核预先设置在sscratch中的值(TRAPFRAME的虚拟地址)获得了一个可用寄存器,a0也获得了TRAPFRAME的虚拟地址。

    这里书中使用的是csrrw交换指令,但是项目代码中使用的是csrw sscratch, a0li a0, TRAPFRAME,首先单向将a0的值写入sscratch,然后再加载TRAPFRAME的虚拟地址到a0

    然后uservec需要保存用户寄存器。在进程创建时,进入用户空间前,内核会将sscratch设置为指向每个进程的trapframe,该trapframe有空间保存所有用户寄存器。而此时satp仍然在使用用户页表,因此uservec需要将trapframe映射在用户地址空间中。在创建每个进程时,xv6为进程的trapframe分配一个页,并安排它始终映射在用户虚拟地址TRAPFRAME处,该地址位于TRAMPOLINE下方。进程的p->trapframe也指向trapframe,供内核通过内核页表使用。即

    trapframe包含指向当前进程内核栈的指针、当前CPU的hartidusertrap的地址以及内核页表的地址等。

    uservec检索这些值,将satp切换到内核页表,见csrw satp, t1一句,并调用usertrap

  2. 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。

  3. usertrapret(在trap.c中)

    负责重新设置sstatussepc寄存器,并将stvec重新指向uservec

    它设置RISC-V控制寄存器,将stvec指向uservec,填充trapframe字段,并将sepc设置为之前保存的用户程序计数器。最后在用户和内核页表中都有映射的trampoline页面上调用userret,因为userret将切换页表。

  4. userret(在trampoline.S中)

    负责切换回用户页表并恢复用户态寄存器。

    它通过a0接收进程的用户页表指针,然后将satp切换到进程的用户页表,并在切换页表后加载TRAPFRAME地址到a0,然后恢复所有寄存器,并将trapframe中保存的用户 a0复制到sscratch。接下来,userrettrapframe中恢复保存的用户寄存器,最后交换a0sscratch以恢复用户a0并为下一次trap保存TRAPFRAME,然后使用sret回到用户空间。

    a0寄存器需要最后恢复,因为前面指令都依赖a0作为TRAPFRAME基址。

    另外,上述描述与代码有一些出入,目前代码的流程应该是:恢复所有寄存器,最后恢复a0,然后直接返回用户空间。这是因为内核在进程调度时会重新初始化陷阱帧。


Code:Calling system calls

代码:调用系统调用

这部分介绍用户调用如何到达内核中exec系统调用。

用户代码将exec的参数放入寄存器a0a1中,并将系统调用数放入a7,系统调用数会用于与syscalls数组中的条目匹配,这是一个函数表来索引syscalls。然后ecall触发trap进入内核,执行系统调用触发的相应步骤。

当系统调用实现函数返回时,syscall记录返回值到p->trapframe->a0,这将导致原始用户空间调用的exec返回该值。因为在RISC-V的C调用约定中,返回值放在a0中。

系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用数无效,syscall会打印错误并返回-1


Code:System call arguments

代码:系统调用参数

内核中的系统调用实现需要找到用户代码传递的参数。参数最初位于寄存器中,内核陷阱代码将所有用户寄存器保存到了当前进程的陷阱帧(trapframe)中。argintargaddrargfd函数分别从陷阱帧中检索第n个系统调用参数,分别作为整数、指针和文件描述符。它们都调用argraw来检索适当的已保存的用户寄存器。

当系统调用使用指针作为参数传递时,一方面可能用户程序存在错误或恶意,另外xv6内核的页表映射与用户页表映射也不同,因此内核无法使用普通指令从用户提供的地址加载或存储数据。

内核实现了安全地在用户提供的地址之间传输数据的功能。例如fetchstr,文件系统调用使用它从用户空间检索字符串文件名参数,它又调用copyinstr

copyinstr从用户页表的虚拟地址srcva中复制最多max字节到dst。它使用walkaddr遍历页表,以确定srcva的物理地址pa0。由于内核将所有物理RAM地址映射到相同的内核虚拟地址,copyinstr可以直接将字符串字节从pa0复制到dstwalkaddr会检查用户提供的虚拟地址是否属于进程的用户地址空间。


Traps from kernel space

内核空间的陷阱

xv6根据用户代码和内核代码的执行情况,对CPU的陷阱寄存器进行了不同的配置。当内核在CPU上执行时,内核将stvec指向kernelvec处的汇编代码。由于xv6已经处于内核态,kernelvec可以通过satp被设置为内核页表,并且栈指针指向一个有效的内核栈。kernelvec会保存所有寄存器,以便被中断的代码后面能不受干扰的恢复执行。

kernelvec将寄存器保存在被中断的内核线程的栈上,因为陷阱可能导致切换到另一个线程,然后在新线程的栈上返回,而被中断线程的寄存器可以安全地保存在其栈上。

kernelvec在保存寄存器后跳转到kerneltrapkerneltrap调用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