操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用

本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:Lab3->Page tables
操作系统MIT6.S081:P4->RISC-V calling conventions and stack frames


前言

trap的触发条件

有三种情况会导致CPU搁置普通指令的执行,并强制将控制权转移到处理事件的特殊代码。
①系统调用:当用户程序执行ecall指令以要求内核为其做一些事情时。
②异常:一条用户或内核指令做了一些非法的事情时,例如除以0或使用无效的虚拟地址。
③设备中断:当设备发出需要注意的信号时,例如当磁盘硬件完成读取或写入请求时。

trap的工作流程

本书使用trap作为这些情况的通用术语。通常,在trap发生时正在执行的任何代码稍后都需要恢复,并且不需要知道发生的任何特殊情况。也就是说,我们经常希望trap是透明的。这一点来说对于中断尤其重要,这是因为中断代码通常是无法预料的。trap通常的顺序是:
trap强制将控制权转移到内核中。
②内核保存寄存器和其他状态,以便后续可以恢复运行。
③内核执行适当的处​​理程序代码(例如系统调用实现或设备驱动程序)。
④内核恢复保存的状态并从trap中返回。
⑤原始代码从中断的地方继续执行。

xv6 trap的意义

xv6内核处理所有trap。
①这对于系统调用来说是很自然的。
②这对于中断来说是有意义的,因为隔离性要求用户进程不直接使用设备,而且只有内核具有设备工作所需的状态。
③这对于异常来说是有意义的,因为xv6通过终止有问题的程序来响应来自用户空间的所有异常。

xv6 trap的处理流程

xv6 trap的处理流程分四个阶段进行:
①RISC-V CPU采取的一系列硬件操作
②为内核C代码执行而准备的汇编程序集向量。
③决定如何处理trap的C程序
④系统调用或设备驱动程序服务代码。
虽然三种trap类型之间的共同点表明内核可以使用单个代码路径处理所有trap,但对于三种不同情况使用单独的汇编向量和 C trap处理程序很方便:来自用户空间的trap、来自内核空间的trap、定时器中断。


一、RISC-V的trap机制

重要的控制寄存器

每个RISC-V CPU都有一组控制寄存器,内核写入这些寄存器来告诉CPU如何处理trap,并且内核还可以读取这些寄存器以找出已发生的trap。riscv.h(kernel/riscv.h:1)包含xv6使用的寄存器的定义。以下是最重要的一些寄存器的概述:
stvec:内核在此处写入其trap处理程序的地址,RISC-V跳转到这里来处理trap。
sepc:当trap发生时,RISC-V将程序计数器保存在这里(因为pc会被stvec覆盖)。sret(从trap返回)指令将sepc复制到pc。内核可以写入sepc来控制sret的去向。
scause:RISC-V在此处放置了一个数字来描述trap的原因。
sscratch:内核在此处放置一个值,该值在trap处理程序的一开始就派上用场。
sstatussstatus中的SIE位控制是否启用设备中断。如果内核清除SIE,RISC-V将推迟设备中断,直到内核设置SIESPP位指示trap是来自user模式还是supervisor模式,并控制sret返回到何种模式。
----上述寄存器与在supervisor模式下处理的trap有关,在用户模式下不能读写这些寄存器。对于在机器模式下处理的trap,有一组等效的控制寄存器。xv6仅将它们用于定时器中断的特殊情况。
----多核芯片上的每个CPU都有自己的一组寄存器,在任何给定时间都可能有多个CPU同时处理trap。
----当需要强制trap时,RISC-V硬件对所有类型的trap(定时器中断除外)执行以下操作:
①如果trap是设备中断并且sstatus SIE位清零,请不要进行以下任何操作。
②通过清除SIE禁用中断。
③将pc复制给sepc
④将当前模式(user或supervisor)保存在sstatusSPP位中。
⑤设置scause以反映trap的原因。
⑥将模式设置为supervisor。
⑦将stvec复制给pa
⑧在新pc处开始执行。
----注意CPU不会切换到内核页表,不会切换到内核中的栈,也不会保存除pc以外的任何寄存器。内核软件必须来执行这些任务。CPU在trap期间做最少工作的原因之一是为软件提供了灵活性。例如,某些操作系统在某些情况下不需要页表切换,这可以提高性能。
----你可能想知道是否可以进一步简化CPU硬件的trap处理序列。例如,假设CPU没有切换程序计数器。然后一个trap可以在运行用户指令的同时切换到supervisor模式。这些用户指令可能会破坏用户/内核隔离,例如通过修改satp寄存器以指向允许访问所有物理内存的页表。因此CPU切换到内核指定的指令地址(stvec)是很重要的。


二、来自用户空间的trap

用户空间trap的执行与返回顺序
如果用户程序进行系统调用(ecall指令)、执行非法操作、设备中断,则在用户空间执行时可能会发生trap。来自用户空间的trap的高级路径是uservec(kernel/trampoline.S:16),然后是usertrap(kernel/trap.c:37)。返回时,先是usertrapret(kernel/trap.c:90),然后userret(kernel/trampoline.S:16)。
注: 来自用户代码的trap比来自内核的trap更具挑战性,因为satp指向不映射内核的用户页表,栈指针可能包含无效甚至恶意值。
uservec在用户页表与内核页表中的映射
由于RISC-V硬件在trap期间不切换页表,所以用户页表必须包含uservec的映射,即stvec指向的trap向量指令。uservec必须切换satp以指向内核页表。为了在切换后继续执行指令,uservec必须映射到内核页表中与用户页表中相同的地址。
trampoline在用户页表与内核页表中的映射
xv6使用包含uservec的trampoline页面来满足这些约束。xv6在内核页表和每个用户页表中将trampoline页映射到相同的虚拟地址。这个虚拟地址是TRAMPOLINE)。trampoline的内容在trampoline.S中设置,并且(在执行用户代码时)stvec设置为uservec(kernel/trampoline.S:16)。
sscratch寄存器的作用
uservec启动时,所有32个寄存器都包含被中断代码拥有的值。但是uservec需要能够修改一些寄存器才能设置satp并生成保存寄存器的地址。RISC-V以sscratch寄存器的形式提供了帮助。uservec开头的csrrw指令交换a0sscratch的内容。现在用户代码的a0被保存了。uservec有一个寄存器(a0)可以使用。a0包含内核先前放在sscratch中的值。
trapframe保存用户寄存器
----uservec的下一个任务是保存用户寄存器。在进入用户空间之前,内核之前将sscratch设置为指向每个进程的trapframe,该trapframe(除其他外)有空间保存所有用户寄存器(kernel/proc.h:44)。因为satp仍然指向用户页表,所以uservec需要将trapframe映射到用户地址空间。在创建每个进程时,xv6为进程的trapframe分配一个页面,并安排它始终映射到用户虚拟地址TRAPFRAME,该地址位于TRAMPOLINE的下方。尽管使用物理地址,该进程的p->trapframe也指向trapframe,因此内核可以通过内核页表使用它。
----因此,在交换a0sscratch之后,a0持有一个指向当前进程trapframe的指针。uservec现在将所有用户寄存器保存在那里,包括用户的a0(从sscratch读取)。
----trapframe包含指向当前进程的内核栈、当前CPU的hartid、usertrap地址和内核页表地址。uservec检索这些值,将satp切换到内核页表,并调用usertrap
usertrap的工作
usertrap的工作是确定trap的原因,对其进行处理,然后返回(kernel/-trap.c:37)。
----如上所述,它首先更改stvec,以便内核中的trap将由kernelvec处理。
----它保存了 sepc(保存的用户程序计数器),再次保存是因为usertrap中可能有一个进程切换,这可能导致sepc被覆盖。
----如果trap是系统调用,则由系统调用处理;如果设备中断,则由devintr处理;否则它是一个异常,内核会杀死出错的进程。
----系统调用路径向保存的用户pc添加了4,因为在系统调用的情况下,RISC-V会留下指向ecall指令的程序指针(返回后需要执行ecall之后的下一条指令)。
----在退出的过程中,usertrap检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是计时器中断)。
usertrapret的工作
----返回用户空间的第一步是调用usertrapret(kernel/trap.c:90)。该函数设置RISC-V 控制寄存器,为来自用户空间的未来trap做准备。这涉及将stvec更改为引用 uservec,准备uservec所依赖的trapframe字段,并将sepc设置为先前保存的用户程序计数器。
----最后,usertrapret在映射到用户和内核页表的trampoline页上调用userret;原因是userret中的汇编代码会切换页表。
userret的工作
----usertrapretuserret的调用将指针传递给a0中进程的用户页表和a1中的TRAPFRAME(kernel/trampoline.S:88)。
----userretsatp切换到进程的用户页表。回想一下,用户页表映射了trampoline页和TRAPFRAME,但内核中没有其他内容。同样,trampoline页映射到用户和内核页表中的相同虚拟地址这一事实允许 uservec在更改satp后继续执行。
----userret将trapframe保存的用户a0复制到sscratch以准备以后与TRAPFRAME交换。从此刻开始,userret唯一可以使用的数据是寄存器内容和trapframe的内容。
----下一个userret从trapframe中恢复保存的用户寄存器,最后交换a0sscratch以恢复用户a0并为下一个trap保存TRAPFRAME,并使用sret返回用户空间。


三、代码:调用系统调用

调用系统调用

第2章以initcode.S调用exec系统调用(user/initcode.S:11) 结束。让我们看看用户调用如何在内核中实现exec系统调用。
①用户代码将exec的参数放在寄存器a0a1中,并将系统调用号放在a7中。系统调用号与syscalls数组中的条目相匹配(syscall数组是一个函数指针表 (kernel/syscall.c:108))。ecall指令trap进内核并执行uservecusertrap,然后是 syscall,正如我们在上面看到的。
syscall(kernel/syscall.c:133)从trampoline帧中保存的a7中检索系统调用号,并使用它来索引syscalls。对于第一个系统调用,a7包含SYS_exec(ker nel/syscall.h:8),导致调用系统调用接口函数sys_exec
③当系统调用接口函数返回时,syscall将其返回值记录在p->trapframe->a0中。这将导致原始用户空间对exec()的调用返回该值,因为RISC-V上的C调用约定将返回值放在a0中。系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用号无效,则syscall打印错误并返回-1


四、代码:系统调用参数

系统调用参数

①内核中的系统调用接口需要找到用户代码传递的参数。因为用户代码调用系统调用封装函数,所以参数被放置在RISC-V C调用所约定的位置:寄存器。内核trap代码将用户寄存器保存到当前进程的trapframe中,内核代码可以在其中找到它们。函数argintargaddrargfd从trapframe中检索第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复制到dstwalkaddr(kernel/vm.c:95)检查用户提供的虚拟地址是否为进程用户地址空间的一部分,因此程序无法欺骗内核读取其他内存。类似的函数copyout将数据从内核复制到用户提供的地址。


五、来自内核空间的trap

来自内核空间的trap

xv6中CPU的trap寄存器
xv6对CPU的trap寄存器的配置略有不同,具体取决于执行的是用户代码还是内核代码。当内核在CPU上执行时,内核将stvec指向kernelvec(kernel/kernelvec.S:10)处的汇编代码。由于xv6已经在内核中,kernelvec可以依靠satp设置到内核页表,以及指向有效内核栈的栈指针。kernelvec保存所有寄存器,以便中断的代码最终可以不受干扰地恢复。
kernelvec的工作
----kernelvec将寄存器保存在中断的内核线程的栈上,这是有道理的,因为寄存器值属于该线程。如果trap导致切换到不同的线程,这一点尤其重要----在这种情况下,trap实际上将返回新线程的栈,将被中断线程保存的寄存器安全地留在其栈中。
kerneltrap的工作
----kernelvec在保存寄存器后跳转到kerneltrap(kernel/trap.c:134)。kerneltrap准备了两种类型的trap:设备中断和异常。它调用devintr(kernel/-trap.c:177)来检查和处理前者。如果trap不是设备中断,那它一定是一个异常,内核中的异常将是一个致命的错误;内核调用panic并停止执行。
----如果由于定时器中断而调用kerneltrap,并且进程的内核线程正在运行(而不是调度程序线程),则kerneltrap调用yield以让其他线程有机会运行。在某个时候,其中一个线程将让步,并让我们的线程及其内核trap再次恢复。第7章解释了yield中发生的事情。
----当kerneltrap的工作完成后,它需要返回任何被trap中断的代码。因为yield可能干扰了sstatus中保存的sepc和保存的先前的状态模式,所以kerneltrap在启动时会保存它们。它现在恢复那些控制寄存器并返回到kernelvec(kernel/kernelvec.S:48)。kernelvec从栈中弹出保存的寄存器并执行sret,它将sepc复制到pc并恢复中断的内核代码。
----如果kerneltrap由于定时器中断而调用yield,那么trap返回是如何发生的,这是值得思考的。
注: 当CPU从用户空间进入内核时,xv6将CPU的stvec设置为kernelvec。你可以在usertrap(kernel/trap.c:29)中看到这一点。内核执行时有一个时间窗口,但stvec设置为uservec,在该窗口期间禁用设备中断至关重要。幸运的是,RISC-V 在开始捕获trap时总是禁用中断,而xv6直到设置stvec之后才再次启用它们。


六、页面错误异常

xv6与其它系统的page fault

xv6对异常的响应非常无聊:如果用户空间发生异常,内核会杀死出错的进程。如果内核中发生异常,内核会触发panic。真正的操作系统通常以更有趣的方式做出响应。例如,许多内核使用page fault来实现写时拷贝版本的fork----copy-on-write (COW) fork。要解释COW fork,请考虑第3章中描述的xv6的fork。fork通过调用uvmcopy(kernel/vm.c:309)为子进程分配物理内存,从而使子进程拥有与父进程相同的内存内容。如果子进程和父进程可以共享父进程的物理内存,效率会更高。然而,一个简单的实现是行不通的,因为它会导致父子进程通过对共享栈和堆的写入来中断彼此的执行。

RISC-V的三种page fault

父进程和子进程可以使用由页面错误驱动的COW fork安全地共享物理内存。当CPU无法将虚拟地址转换为物理地址时,CPU会生成page fault异常。RISC-V有3种不同的page fault:加载页面错误(当加载指令无法翻译其虚拟地址时)、存储页面错误(当存储指令无法翻译其虚拟地址时)和指令页面错误(当指令的地址无法转换时)。scause寄存器中的值指示页面错误的类型,而stval寄存器包含无法转换的地址。

COW fork

----COW fork的基本策略是父子进程最初共享所有的物理页面,但将它们映射为只读。因此,当子进程或父进程执行存储指令时,RISC-V CPU 会引发page fault异常。为响应此异常,内核复制包含错误地址的页面。它在子级的地址空间中映射一个权限为读/写的副本,在父级的地址空间中映射另一个权限为读/写的副本。更新页表后,内核在导致故障的指令处恢复故障进程的执行。由于内核已经更新了相关的PTE以允许写入,所以错误指令现在将正确地执行。
----这个COW策略对fork很有效,因为子进程经常在fork之后立即调用exec,用新的地址空间替换它的地址空间。在那种常见的情况下,子进程只会遇到较少的page fault,内核可以避免拷贝父进程内存完整的副本。此外,COW fork是透明的,无需对应用程序进行任何修改即可受益。
page fault的应用
----页表和页错误的结合开辟了除COW fork之外的广泛有趣的可能性。另一个广泛使用的特性称为懒惰分配(lazy allocation)。它有两个部分。首先,当应用程序调用sbrk时,内核增加地址空间,但在页表中将新地址标记为无效。其次,在其中一个新地址出现页面错误时,内核分配物理内存并将其映射到页表中。由于应用程序经常要求比他们需要的更多的内存,因此惰性分配是一个胜利:内核仅在应用程序实际使用它时才分配内存。与 COW fork 一样,内核可以对应用程序透明地实现此功能。
----另一个利用page fault的广泛使用的功能是从磁盘分页。如果应用程序需要比可用物理RAM更多的内存,内核可以驱逐一些页面:将它们写入磁盘等存储设备并将它们的PTE标记为无效。如果应用程序读取或写入被逐出的页面,CPU将遇到page fault。然后内核可以检查故障地址。如果地址属于磁盘上的一个页面,则内核分配一个物理内存页面,将该页面从磁盘读取到该内存,将PTE更新为有效并引用该内存,然后恢复应用程序。为了给页面腾出空间,内核可能不得不驱逐另一个页面。此功能不需要对应用程序进行任何更改,并且如果应用程序具有引用的地址(即它们在任何给定时间仅使用其内存的子集),则效果很好。
----结合分页和页面错误异常的其他功能包括自动扩展栈空间和内存映射文件。


七、现实世界

如果内核内存被映射到每个进程的用户页表(带有适当的PTE权限标志),则可以消除对特殊trampoline page的需求。当从用户空间进入内核时,这也将消除对页表切换的需要。这反过来也将允许内核中的系统调用实现利用当前进程正在映射的用户内存,允许内核代码直接取消引用用户指针。 许多操作系统已经使用这些想法来提高效率。xv6避免使用它们是为了减少由于无意使用用户指针而导致内核中出现安全漏洞的机会,并降低了确保用户和内核虚拟地址不重叠所需的一些复杂性。

猜你喜欢

转载自blog.csdn.net/InnerPeaceHQ/article/details/126058335