前言
一个本硕双非的小菜鸡,备战24年秋招。打算尝试6.S081,将它的Lab逐一实现,并记录期间心酸历程。
代码下载
官方网站:6.S081官方网站
陷阱
有三种事件会导致中央处理器搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上。
一种情况是系统调用,当用户程序执行ecall指令要求内核为其做些什么时;
另一种情况是异常:(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址;
第三种情况是设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注。
本书使用陷阱(trap)作为这些情况的通用术语。通常,陷阱发生时正在执行的任何代码都需要稍后恢复,并且不需要意识到发生了任何特殊的事情。也就是说,我们经常希望陷阱是透明的;这对于中断尤其重要,中断代码通常难以预料。通常的顺序是陷阱强制将控制权转移到内核;内核保存寄存器和其他状态,以便可以恢复执行;内核执行适当的处理程序代码(例如,系统调用接口或设备驱动程序);内核恢复保存的状态并从陷阱中返回;原始代码从它停止的地方恢复。
xv6内核处理所有陷阱。这对于系统调用来说是顺理成章的。由于隔离性要求用户进程不直接使用设备,而且只有内核具有设备处理所需的状态,因而对中断也是有意义的。因为xv6通过杀死违规程序来响应用户空间中的所有异常,它也对异常有意义。
Xv6陷阱处理分为四个阶段: **RISC-V CPU采取的硬件操作、为内核C代码执行而准备的汇编程序集“向量”、决定如何处理陷阱的C陷阱处理程序以及系统调用或设备驱动程序服务例程。**虽然三种陷阱类型之间的共性表明内核可以用一个代码路径处理所有陷阱,但对于三种不同的情况:来自用户空间的陷阱、来自内核空间的陷阱和定时器中断,分别使用单独的程序集向量和C陷阱处理程序更加方便。
陷入机制
每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。RISC-V文档包含了完整的内容。riscv.h(kernel/riscv.h:1)包含在xv6中使用到的内容的定义。以下是最重要的一些寄存器概述:
stvec:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。
sepc:当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。
scause: RISC-V在这里放置一个描述陷阱原因的数字。
sscratch:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。
sstatus:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIE。SPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式。
上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。
多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
清除SIE以禁用中断。
将pc复制到sepc。
将当前模式(用户或管理)保存在状态的SPP位中。
设置scause以反映产生陷阱的原因。
将模式设置为管理模式。
将stvec复制到pc。
在新的pc上开始执行。
请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc之外的任何寄存器。内核软件必须执行这些任务。CPU在陷阱期间执行尽可能少量工作的一个原因是为软件提供灵活性;例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能。
你可能想知道CPU硬件的陷阱处理顺序是否可以进一步简化。例如,假设CPU不切换程序计数器。那么陷阱可以在仍然运行用户指令的情况下切换到管理模式。但因此这些用户指令可以打破用户/内核的隔离机制,例如通过修改satp寄存器来指向允许访问所有物理内存的页表。因此,CPU使用专门的寄存器切换到内核指定的指令地址,即stvec,是很重要的。
从用户空间陷入
如果用户程序发出系统调用(ecall指令),或者做了一些非法的事情,或者设备中断,那么在用户空间中执行时就可能会产生陷阱。来自用户空间的陷阱的高级路径是uservec (kernel/trampoline.S:16),然后是usertrap (kernel/trap.c:37);返回时,先是usertrapret (kernel/trap.c:90),然后是userret (kernel/trampoline.S:16)。
来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为satp指向不映射内核的用户页表,栈指针可能包含无效甚至恶意的值。
由于RISC-V硬件在陷阱期间不会切换页表,所以用户页表必须包括uservec(stvec指向的陷阱向量指令)的映射。uservec必须切换satp以指向内核页表;为了在切换后继续执行指令,uservec必须在内核页表中与用户页表中映射相同的地址。
xv6使用包含uservec的蹦床页面(trampoline page)来满足这些约束。xv6将蹦床页面映射到内核页表和每个用户页表中相同的虚拟地址。这个虚拟地址是TRAMPOLINE(如图2.3和图3.3所示)。蹦床内容在trampoline.S中设置,并且(当执行用户代码时)stvec设置为uservec (kernel/trampoline.S:16)。
当uservec启动时,所有32个寄存器都包含被中断代码所拥有的值。但是uservec需要能够修改一些寄存器,以便设置satp并生成保存寄存器的地址。RISC-V以sscratch寄存器的形式提供了帮助。uservec开始时的csrrw指令交换了a0和sscratch的内容。现在用户代码的a0被保存了;uservec有一个寄存器(a0)可以使用;a0包含内核以前放在sscratch中的值。
uservec的下一个任务是保存用户寄存器。在进入用户空间之前,内核先前将sscratch设置为指向一个每个进程的陷阱帧,该帧(除此之外)具有保存所有用户寄存器的空间(kernel/proc.h:44)。因为satp仍然指向用户页表,所以uservec需要将陷阱帧映射到用户地址空间中。每当创建一个进程时,xv6就为该进程的陷阱帧分配一个页面,并安排它始终映射在用户虚拟地址TRAPFRAME,该地址就在TRAMPOLINE下面。尽管使用物理地址,该进程的p->trapframe仍指向陷阱帧,这样内核就可以通过内核页表使用它。
因此在交换a0和sscratch之后,a0持有指向当前进程陷阱帧的指针。uservec现在保存那里的所有用户寄存器,包括从sscratch读取的用户的a0。
陷阱帧
陷阱帧包含指向当前进程内核栈的指针、当前CPU的hartid、usertrap的地址和内核页表的地址。uservec取得这些值,将satp切换到内核页表,并调用usertrap。
usertrap的任务是确定陷阱的原因,处理并返回(kernel/trap.c:37)。如上所述,它首先改变stvec,这样内核中的陷阱将由kernelvec处理。它保存了sepc(保存的用户程序计数器),再次保存是因为usertrap中可能有一个进程切换,可能导致sepc被覆盖。如果陷阱来自系统调用,syscall会处理它;如果是设备中断,devintr会处理;否则它是一个异常,内核会杀死错误进程。系统调用路径在保存的用户程序计数器pc上加4,因为在系统调用的情况下,RISC-V会留下指向ecall指令的程序指针(返回后需要执行ecall之后的下一条指令)。在退出的过程中,usertrap检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是计时器中断)。
返回用户空间的第一步是调用usertrapret (kernel/trap.c:90)。该函数设置RISC-V控制寄存器,为将来来自用户空间的陷阱做准备。这涉及到将stvec更改为指向uservec,准备uservec所依赖的陷阱帧字段,并将sepc设置为之前保存的用户程序计数器。最后,usertrapret在用户和内核页表中都映射的蹦床页面上调用userret;原因是userret中的汇编代码会切换页表。
usertrapret对userret的调用将指针传递到a0中的进程用户页表和a1中的TRAPFRAME (kernel/trampoline.S:88)。userret将satp切换到进程的用户页表。回想一下,用户页表同时映射蹦床页面和TRAPFRAME,但没有从内核映射其他内容。同样,蹦床页面映射在用户和内核页表中的同一个虚拟地址上的事实允许用户在更改satp后继续执行。userret复制陷阱帧保存的用户a0到sscratch,为以后与TRAPFRAME的交换做准备。从此刻开始,userret可以使用的唯一数据是寄存器内容和陷阱帧的内容。下一个userret从陷阱帧中恢复保存的用户寄存器,做a0与sscratch的最后一次交换来恢复用户a0并为下一个陷阱保存TRAPFRAME,并使用sret返回用户空间。
系统调用参数
内核中的系统调用接口需要找到用户代码传递的参数。因为用户代码调用了系统调用封装函数,所以参数最初被放置在RISC-V C调用所约定的地方:寄存器。内核陷阱代码将用户寄存器保存到当前进程的陷阱框架中,内核代码可以在那里找到它们。函数artint、artaddr和artfd从陷阱框架中检索第n个系统调用参数并以整数、指针或文件描述符的形式保存。他们都调用argraw来检索相应的保存的用户寄存器(kernel/syscall.c:35)。
有些系统调用传递指针作为参数,内核必须使用这些指针来读取或写入用户内存。例如:exec系统调用传递给内核一个指向用户空间中字符串参数的指针数组。这些指针带来了两个挑战。首先,用户程序可能有缺陷或恶意,可能会传递给内核一个无效的指针,或者一个旨在欺骗内核访问内核内存而不是用户内存的指针。其次,xv6内核页表映射与用户页表映射不同,因此内核不能使用普通指令从用户提供的地址加载或存储。
内核实现了安全地将数据传输到用户提供的地址和从用户提供的地址传输数据的功能。fetchstr是一个例子(kernel/syscall.c:25)。文件系统调用,如exec,使用fetchstr从用户空间检索字符串文件名参数。fetchstr调用copyinstr来完成这项困难的工作。
copyinstr(kernel/vm.c:406)从用户页表页表中的虚拟地址srcva复制max字节到dst。它使用walkaddr(它又调用walk)在软件中遍历页表,以确定srcva的物理地址pa0。由于内核将所有物理RAM地址映射到同一个内核虚拟地址,copyinstr可以直接将字符串字节从pa0复制到dst。walkaddr(kernel/vm.c:95)检查用户提供的虚拟地址是否为进程用户地址空间的一部分,因此程序不能欺骗内核读取其他内存。一个类似的函数copyout,将数据从内核复制到用户提供的地址。