Chapter 4 陷阱————系统调用,异常和硬件中断


syscall

在这一章,主要描述的是用户空间与内核空间的交互。

这也就引起了我们在chapter2中讨论的三个问题复用(共享资源)、隔离(防止进程互相干扰)、交互(允许进程间的通信)。

我们将会使用虚拟空间来实现。

通过跳板页,我们实现了一个物理区域,能够同时被两种空间所映射到,都能读取和写入。

通过对跳板页中内容的读取,避免了空间的直接接触,这也是一种分层的思想。


有三种事件会导致CPU中断普通指令的执行

  1. 系统调用ecall,会使得将权限转为内核,执行内核指令
  2. 异常Exception,一条指令(无论是用户态还是内核态)执行了非法操作
    1. 例如除以零或使用无效的虚拟地址。
  3. 设备中断
    1. 对于一些读写请求,需要即时处理,不然缓冲区满了之后,会导致一些数据的丢失

对于陷阱,通常,发生陷阱时正在执行的代码稍后需要恢复执行,并且不应意识到发生了任何特殊事件。也就是说,我们通常希望陷阱是透明的。

通常的流程是:

  • 陷阱强制将控制权转移到内核;
  • 内核保存寄存器和其他状态以便恢复执行;保留上下文
  • 内核执行适当的处理代码(例如系统调用的实现或设备驱动程序);
  • 内核恢复保存的状态并从陷阱返回;恢复现场
  • 原始代码从它离开的地方继续执行。

我们要先回答一个问题,为什么陷阱要在内核中执行。

  1. 对于ecall,这本身就需要提高权限才能执行内核操作。
  2. 对于Exception,我们一般执行panic,将异常的进程直接kill
  3. 对于设备中断,一般诸如读写操作等,使用内核空间相较于user space的反应更快
  • 从用户模式切换到监督模式允许什么?
    • 监督模式可以使用CPU控制寄存器:
      • satp:页表物理地址。
      • stvececall跳转到内核的地址;指向跳板页。
      • sepcecall将用户pc保存在此。
      • sscratch:陷阱帧的地址。
    • 监督模式可以使用没有PTE_U标志的PTE。
    • 但监督模式没有其他权限!
      • 例如,不能使用不在页表中的地址。
      • 因此,内核必须仔细设置,以便它能够工作。

第二如何执行陷阱操作handler

这三种陷阱类型的共性表明内核可以用单一代码路径处理所有陷阱,但事实证明,为三种不同情况分别编写代码更为方便:来自用户空间的陷阱、来自内核空间的陷阱和时钟中断。

处理陷阱的内核代码(无论是汇编还是C)通常被称为“处理程序”(handler);最初的处理程序指令通常用汇编语言编写(而不是C语言),有时被称为“向量”(vector)。

Xv6的陷阱处理分为四个阶段:

  • RISC-V CPU执行的硬件操作、
  • 一些为内核C代码做准备的汇编指令、
  • 一个决定如何处理陷阱的C函数,
  • 以及系统调用或设备驱动服务例程

4.1 RISC-V陷阱机制(硬件准备)

每个RISC-V CPU都有一组控制寄存器,内核可以写入这些寄存器以告诉CPU如何处理陷阱,也可以读取这些寄存器以了解已发生的陷阱

标记陷阱状态,记录上下文,保护现场

因为我们会为三种不同情况,分别编写代码,所以我们要能够实现对于情况的区分

以下是最重要的寄存器概述:

  • stvec:内核将陷阱处理程序的地址写入此寄存器;RISC-V在处理陷阱时会跳转stvec中的地址。
  • sepc:当陷阱发生时,RISC-V将程序计数器(PC)的值保存在此寄存器中(因为PC随后会被stvec中的值覆盖)。sret(从陷阱返回)指令会将sepc的值复制到PC。内核也可以写入sepc以控制sret的目标地址。(保存现场
  • scause:RISC-V在此寄存器中存储一个数字,用于描述陷阱的原因
  • sscratch:内核在此寄存器中放置一个值,该值在陷阱处理程序的最开始阶段非常有用。(装载a0的值)
  • sstatussstatus中的SIE位控制是否启用设备中断。如果内核清除SIE位,RISC-V将推迟设备中断,直到内核设置SIE位。SPP位指示陷阱是来自用户模式还是监督模式,并控制sret返回到哪种模式。(中断状态标志位

当需要强制触发陷阱时,RISC-V硬件对所有陷阱类型(除了时钟中断)执行以下操作:

  1. 如果陷阱是设备中断,且sstatus中的SIE位被清除,则不执行以下操作。
  2. 通过清除sstatus中的SIE位来禁用中断。
  3. 将PC的值复制到sepc
  4. 将当前模式(用户模式或监督模式)保存到sstatus中的SPP位。
  5. 设置scause以反映陷阱的原因。
  6. 将模式切换到监督模式。
  7. stvec的值复制到PC。(将控制权完全交给内核
  8. 从新的PC地址开始执行。

注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存任何寄存器,除了PC。这些任务必须由内核软件完成。

4.2 来自用户空间的陷阱

high-level picture of the procedure of switching from user mode to supervisor mode

用户态通过syscall切换到内核态,并执行系统调用的完整过程:

  1. 用户态程序执行ecall指令。
  2. 触发软件中断,CPU保存当前寄存器状态到跳板页。
  3. CPU跳转到uservec处理向量。
  4. uservec设置寄存器,跳转到usertrap
  5. usertrap保存更多上下文信息,并调用syscall
  6. syscall根据a7中的系统调用号,决定执行哪个系统调用处理函数。
  7. 执行相应的系统调用处理函数。
  8. 完成后,syscall返回,usertrap恢复上下文,跳回用户态继续执行。

“跳板页”(trampoline page)

Xv6陷阱处理设计的一个主要限制是:**RISC-V硬件在强制执行陷阱时不会切换页表。**这意味着stvec中的陷阱处理程序地址必须在用户页表中有有效的映射,因为当陷阱处理代码开始执行时,生效的是用户页表。此外,Xv6的陷阱处理代码需要切换到内核页表;为了在切换后能够继续执行,内核页表也必须对stvec指向的处理程序有映射。(这是一个在内存上,user space和kernel space拥有共同物理地址的地方

Xv6通过一个“跳板页”(trampoline page)来满足这些要求。跳板页包含uservec,这是stvec指向的Xv6陷阱处理代码。跳板页在每个进程的页表中都映射到地址TRAMPOLINE,该地址位于虚拟地址空间的末尾,以便它位于程序为自己使用的内存之上。跳板页也在内核页表中映射到地址TRAMPOLINE因此可以在监督模式下从那里开始执行陷阱处理。由于跳板页在内核地址空间中映射到相同的地址,因此在切换到内核页表后,陷阱处理程序可以继续执行。

uservec的执行

在RISC-V架构中,a0寄存器是一个重要的通用寄存器,主要用于函数调用和返回值的传递。

  1. 交换了a0sscratch的内容

uservec开始执行时,所有32个寄存器都包含被中断的用户代码所拥有的值。这32个值需要被保存到内存中的某个地方,以便在陷阱返回用户空间时可以恢复它们。

将数据存储到内存需要使用一个寄存器来保存地址,但此时没有可用的通用寄存器!

幸运的是,RISC-V提供了一个帮助手段,即sscratch寄存器。uservec开头的csrrw指令交换了a0sscratch的内容。

现在,用户代码的a0被保存在sscratch中;uservec有一个寄存器(a0)可以使用;并且a0包含内核之前放在sscratch中的值。

因此,在交换a0sscratch之后,a0持有当前进程的陷阱帧的指针。uservec现在将所有用户寄存器保存在那里,包括从sscratch读取的用户的a0

  1. 保存32个用户寄存器

在进入用户空间之前,内核将sscratch设置为指向每个进程的陷阱帧结构(trapframe),该结构(除其他内容外)有空间用于保存32个用户寄存器(kernel/proc.h:44)。

由于satp仍然指向用户页表,uservec需要陷阱帧在用户地址空间中被映射。

在创建每个进程时,Xv6为进程的陷阱帧分配一页,并安排它始终映射到用户虚拟地址TRAPFRAME,该地址位于TRAMPOLINE之下。

进程的p->trapframe也指向陷阱帧,尽管是通过其物理地址,因此内核可以通过内核页表使用它。

每个进程在被创建的时候,就有一个trapframe的结构体被分配了,并且映射到跳板页上,这样可以满足所有进程的陷阱要求

陷阱帧包含当前进程的内核栈地址、当前CPU的hartidusertrap函数的地址以及内核页表的地址。uservec检索这些值,将satp切换到内核页表,并调用usertrap

usertrap的执行

usertrap的工作是确定陷阱的原因,处理它并返回

  • 它首先更改stvec,以便在内核中发生的陷阱将由kernelvec而不是uservec处理。
  • 它保存sepc寄存器(保存的用户程序计数器),因为usertrap可能会调用yield切换到另一个进程的内核线程,而该进程可能会返回用户空间,在这个过程中它会修改sepc
  • 处理陷阱
    • 如果陷阱是系统调用,usertrap调用syscall来处理;
    • 如果是设备中断,则调用devintr
    • 否则是异常,内核会杀死违规进程。
  • 在退出时,usertrap检查进程是否已被杀死,或者是否应该让出CPU(如果这个陷阱是时钟中断)。

usertrapret和userret的执行

1. usertrapret函数的作用

usertrapret是内核中的一个C函数,位于kernel/trap.c:90。它的主要任务是为返回用户空间做好准备,具体步骤如下:

1.1 设置RISC-V控制寄存器
  • 设置stvec:将stvec寄存器设置为指向uservecuservec是用户空间陷阱向量,用于处理用户空间的陷阱(如系统调用或异常)。
  • 准备陷阱帧字段:陷阱帧(TRAPFRAME)保存了用户态的上下文信息,包括寄存器状态和程序计数器(sepc)。
  • 设置sepc:将sepc寄存器设置为之前保存的用户程序计数器值,以便在返回用户空间时恢复执行。
1.2 调用userret

usertrapret最后调用userret函数,传递以下参数:

  • TRAPFRAME:通过a0寄存器传递,包含用户态的上下文信息。
  • 用户页表指针:通过a1寄存器传递,指向进程的用户页表。

2. userret的作用

userret是一个汇编函数,位于kernel/trampoline.S:88。它的主要任务是**切换到用户页表并恢复用户态的上下文。**以下是关键步骤:

2.1 切换页表
  • 设置satpuserretsatp寄存器切换到进程的用户页表。用户页表只映射了用户空间的内存和跳板页,而不映射内核的其他内容。
  • 跳板页的映射:跳板页在用户页表和内核页表中都映射到相同的虚拟地址。这使得uservec在切换satp后能够继续执行,而不会因为页表切换而导致地址失效。
2.2 恢复用户态上下文
  • 复制用户a0sscratchuserret将陷阱帧中保存的用户a0值复制到sscratch寄存器。sscratch是一个临时寄存器,用于在内核和用户态之间交换数据。
  • 恢复用户寄存器:从陷阱帧中恢复保存的用户寄存器,包括通用寄存器和程序计数器。
  • 交换a0sscratch:进行最后一次交换,将用户的a0值恢复到a0寄存器,并将TRAPFRAME的地址保存到sscratch中,以便下一次陷阱发生时能够恢复上下文。
  • 执行sret指令:最后,userret执行sret指令,将控制权返回到用户空间,并恢复用户程序的执行。

3. 关键点总结
  1. 页表切换
    • userretsatp切换到用户页表,但用户页表只映射用户空间和跳板页,不映射内核的其他内容。
    • 跳板页在用户和内核页表中映射到相同的虚拟地址,确保切换后能够继续执行。
  2. 上下文恢复
    • 用户态的寄存器状态保存在陷阱帧中。
    • 通过sscratch寄存器交换a0值,确保用户态的a0被正确恢复,同时保存陷阱帧的地址。
  3. 返回用户空间
    • userret通过sret指令返回用户空间,恢复用户程序的执行。

4.3 代码:调用系统调用(如何触发)

在chapter2中,我们提到,ecall的执行是参数放入寄存器a0a1中,并将系统调用号放入a7

系统调用号与syscalls数组的条目匹配,这是一个函数指针表(kernel/syscall.c:108)。ecall指令触发进入内核。

syscallkernel/syscall.c:133)从陷阱帧中保存的a7中获取系统调用号,并用它来索引syscalls数组。对于第一个系统调用,a7包含SYS_execkernel/syscall.h:8),这将导致调用系统调用实现函数sys_exec

sys_exec返回时,syscall将其返回值记录在p->trapframe->a0中。这将导致原始的用户空间调用exec()返回该值,因为RISC-V的C调用约定将返回值放在a0中。系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用号无效,syscall会打印错误并返回-1

4.4 代码:系统调用参数(如何传递)

内核中的系统调用实现需要找到用户代码传递的参数。

因为用户代码调用的是系统调用包装函数,所以参数最初位于RISC-V C调用约定指定的位置:寄存器中。

内核陷阱代码将用户寄存器保存到当前进程的陷阱帧中,内核代码可以从那里找到它们。

内核函数argintargaddrargfd从陷阱帧中获取第n个系统调用参数,分别作为整数、指针或文件描述符。它们都调用argraw来获取相应的保存的用户寄存器(kernel/syscall.c:35)。

有些系统调用将指针作为参数传递,内核必须使用这些指针从用户内存中读取或写入数据。

例如,exec系统调用向内核传递一个指针数组,这些指针指向用户空间中的字符串参数。这些指针带来了两个挑战。首先,用户程序可能存在漏洞或恶意行为,可能会向内核传递无效指针,或者试图欺骗内核访问内核内存而不是用户内存。其次,Xv6内核页表映射与用户页表映射不同,因此内核不能使用普通指令从用户提供的地址加载或存储数据。

内核实现了可以安全地从用户提供的地址传输数据的函数。fetchstr是一个例子(kernel/syscall.c:25)。

文件系统调用(如exec)使用fetchstr从用户空间获取字符串文件名参数。fetchstr调用copyinstr来完成实际工作。

copyinstrkernel/vm.c:398)从用户页表pagetable中的虚拟地址srcva复制最多max字节到dst。由于pagetable不是当前页表,copyinstr使用walkaddr(它调用walk)在pagetable中查找srcva,得到物理地址pa0

内核将每个物理RAM地址映射到相应的内核虚拟地址,因此copyinstr可以直接将字符串字节从pa0复制到dstwalkaddrkernel/vm.c:104)检查用户提供的虚拟地址是否属于进程的用户地址空间,以防止程序欺骗内核读取其他内存。类似的函数copyout将数据从内核复制到用户提供的地址。

4.5 来自内核空间的陷阱

Xv6根据当前执行的是用户代码还是内核代码,以不同的方式配置CPU的陷阱寄存器。

kernelvec执行

当内核在一个CPU上执行时,内核将stvec指向kernelvec处的汇编代码(kernel/kernelvec.S:10)。因为Xv6已经在内核中,kernelvec可以依赖satp已经被设置为内核页表,并且栈指针指向一个有效的内核栈。kernelvec将所有32个寄存器推入栈中,稍后会从栈中恢复它们,以便被中断的内核代码可以无缝恢复执行。

kernelvec将寄存器保存在被中断的内核线程的栈中,这是合理的,因为这些寄存器的值属于该线程。这在陷阱导致切换到不同线程时尤为重要——在这种情况下,陷阱实际上会从新线程的栈返回,而被中断线程的保存寄存器安全地留在它的栈上。

kerneltrap执行

保存寄存器后,kernelvec跳转到kerneltrapkernel/trap.c:134)。kerneltrap为两种类型的陷阱做好了准备:设备中断和异常。它调用devintrkernel/trap.c:177)来检查并处理设备中断。如果陷阱不是设备中断,那么它必定是异常,而如果在Xv6内核中发生异常,这总是致命错误;内核会调用panic并停止执行。

如果kerneltrap是由于时钟中断被调用的,并且一个进程的内核线程正在运行(而不是调度线程),kerneltrap会调用yield,以便其他线程有机会运行。在某个时刻,其中一个线程会yield,并让我们的线程及其kerneltrap再次恢复执行。第7章将解释yield中发生了什么。

sret

kerneltrap的工作完成后,它需要返回到被陷阱中断的代码。因为yield可能会干扰sepcsstatus中的前一个模式,所以kerneltrap在开始时保存了它们。现在它恢复这些控制寄存器并返回到kernelveckernel/kernelvec.S:48)。kernelvec从栈中弹出保存的寄存器并执行sret,它将sepc复制到pc,并恢复被中断的内核代码。

值得思考的是,如果kerneltrap由于时钟中断调用了yield,陷阱返回是如何发生的。

要先禁用中断,然后设置stvec,不然在上下文切换操作的时候,会被设备中断打断

当一个CPU从用户空间进入内核时,Xv6将该CPU的stvec设置为kernelvec;你可以在usertrapkernel/trap.c:29)中看到这一点。存在一个时间窗口,内核已经开始执行,但stvec仍然设置为uservec,在这个窗口期间,必须确保不会发生设备中断。幸运的是,RISC-V在开始处理陷阱时总是禁用中断,Xv6直到设置stvec后才会再次启用它们。

4.6 页面错误异常

Xv6对异常的响应相当简单:如果在用户空间发生异常,内核会杀死出错的进程;

如果在内核中发生异常,内核会进入恐慌状态。

真实操作系统通常会有更有趣的响应方式。

“写时复制”(Copy-on-Write,COW)

以页面错误为例,许多内核利用页面错误实现“写时复制”(Copy-on-Write,COW)的fork功能。

写时复制,就是一种计算机的“懒惰操作”,将父子进程在一个物理地址内共享内存空间,但是这段空间被设置为仅读。一旦写入内容,就会触发panic,同时将这段内存复制到另一片物理区域上。实现父子进程的复制粘贴

为了说明写时复制fork,考虑Xv6的fork,它在第3章中被描述过。

fork会导致子进程的初始内存内容与父进程在fork时的内存内容相同。Xv6通过uvmcopykernel/vm.c:301)实现fork,它为子进程分配物理内存并将父进程的内存内容复制进去。

如果能让子进程和父进程共享父进程的物理内存,效率会更高。然而,这种直接的实现方式是不可行的,因为它会导致父进程和子进程在写入共享栈和堆时相互干扰。

通过合理使用页表权限和页面错误,父进程和子进程可以安全地共享物理内存。当虚拟地址在页表中没有映射,或者映射的PTE_V标志被清除,或者映射的权限位(PTE_RPTE_WPTE_XPTE_U)禁止正在进行的操作时,CPU会触发页面错误异常。

RISC-V区分三种类型的页面错误:

  1. 加载页面错误(加载指令无法翻译其虚拟地址)

  2. 存储页面错误(存储指令无法翻译其虚拟地址)

  3. 指令页面错误(程序计数器中的地址无法翻译)。

    scause寄存器指示页面错误的类型,而stval寄存器包含无法翻译的地址。

写时复制fork的基本计划是:

父进程和子进程最初共享所有物理页面,但每个进程都将它们映射为只读(清除PTE_W标志)。

父进程和子进程可以从共享的物理内存中读取。如果任一进程写入某个页面,RISC-V CPU会触发页面错误异常。

内核的陷阱处理程序会响应这一异常:分配一个新的物理页面,并将出错地址映射到的物理页面内容复制进去。内核会更改出错进程页表中相关的PTE,使其指向副本,并允许读写操作,然后在触发错误的指令处恢复出错进程。

由于PTE允许写入,重新执行的指令将不再触发错误。写时复制需要进行一些簿记工作,以帮助决定何时可以释放物理页面,因为每个页面可以被不同数量的页表引用,这取决于fork、页面错误、execexit的历史。

这种簿记允许一个重要优化:如果一个进程触发存储页面错误,而物理页面仅被该进程的页表引用,则无需复制。

写时复制使fork更快,因为fork无需复制内存。虽然有些内存最终需要在写入时复制,但通常大部分内存根本不需要复制。

一个常见例子是fork后紧跟exec:在fork后可能会写入几页内存,但随后子进程的exec会释放从父进程继承的大部分内存。

子进程的 exec 系统调用用于加载并运行一个新的程序,替换掉当前进程的地址空间。

写时复制fork消除了这种内存复制的需要。此外,写时复制fork是透明的:应用程序无需修改即可受益。

页表和页面错误的结合为写时复制之外的许多有趣可能性打开了大门。

懒惰分配

另一个广泛使用的特性是懒惰分配,它包含两部分。首先,当应用程序通过调用sbrk请求更多内存时,内核会记录大小的增加,但不会分配物理内存,也不会为新范围的虚拟地址创建PTE。其次,在这些新地址上的页面错误时,内核会分配一个物理页面并将其映射到页表中。与写时复制fork一样,内核可以对应用程序透明地实现懒惰分配。

sbrk 是一个进程用来收缩或扩展其内存的系统调用。

由于应用程序通常会请求比实际需要更多的内存,懒惰分配是一个胜利:内核完全不需要为应用程序从未使用的页面做任何工作。此外,如果应用程序请求大幅增加地址空间,那么没有懒惰分配的sbrk会很昂贵:如果应用程序请求1GB内存,内核需要分配并清零262,144个4096字节的页面。懒惰分配允许这种成本随时间分散。另一方面,懒惰分配会增加页面错误的额外开销,这涉及内核/用户切换。操作系统可以通过每次页面错误分配一批连续页面而不是一个页面,并为这些页面错误专门化内核入口/出口代码,从而降低这种成本。

按需分页
1. 按需分页的背景

在传统的程序加载方式中(如Xv6的exec实现),操作系统会在程序启动时将应用程序的所有文本段(代码)和数据段一次性加载到物理内存中。这种方式被称为“急切加载”(Eager Loading)。然而,这种方式存在一些问题:

  1. 启动成本高:如果应用程序较大,一次性从磁盘加载所有内容到内存会耗费大量时间,导致用户在启动程序时感受到明显的延迟。
  2. 资源浪费:程序可能并不需要立即使用所有加载的页面,一次性加载所有内容会占用大量内存资源。

2. 按需分页的工作原理

为了优化程序加载过程,现代操作系统采用了“按需分页”技术。按需分页的核心思想是:只在真正需要时才加载页面内容。具体实现方式如下:

2.1 创建页表并标记PTE为无效

  1. 创建页表:操作系统为用户进程创建页表,但并不立即加载所有页面的内容。
  2. 标记PTE为无效:在页表中,页面表项(PTE)被标记为无效(即页面不存在于内存中)。这意味着当进程访问这些页面时,会产生页面错误(Page Fault)。

2.2 页面错误处理

当进程访问一个标记为无效的页面时,会触发页面错误。操作系统捕获页面错误并执行以下操作:

  1. 从磁盘加载页面:操作系统从磁盘读取所需的页面内容。
  2. 更新页表:将对应的PTE标记为有效,并将页面映射到进程的用户地址空间。
  3. 恢复执行:修复页面错误后,进程继续执行。

3. 按需分页的优点
  1. 提高响应速度
    • 按需分页避免了一次性加载所有页面的开销,用户可以更快地看到程序的响应。
    • 只有当进程真正访问某个页面时,才会触发页面错误并加载内容。
  2. 节省内存资源
    • 程序可能永远不会用到某些页面,按需分页避免了不必要的内存占用。
    • 提高了内存的利用率,支持运行更多或更大的程序。
  3. 透明性
    • 按需分页对应用程序是透明的,应用程序无需修改即可受益于这一机制。
    • 操作系统在后台处理页面错误和页面加载,应用程序无需感知这些细节。

4. 与写时复制和懒惰分配的相似性

按需分页与写时复制(Copy-On-Write,COW)和懒惰分配(Lazy Allocation)有相似之处:

  1. 写时复制(COW)
    • fork操作中,子进程的页面最初与父进程共享。
    • 只有当页面被写入时,操作系统才会复制页面内容。
    • 这种机制减少了fork操作的开销,提高了效率。
  2. 懒惰分配(Lazy Allocation)
    • 在内存分配时(如malloc),操作系统并不立即分配物理页面。
    • 只有当进程访问分配的内存时,才会触发页面错误并分配物理页面。
    • 这种机制避免了不必要的内存分配,节省了资源。
  3. 按需分页
    • 本质上是因为如果一开始就将所有页都进行分配,如果这是一个小程序,可能会有大量的资源浪费

4.7 现实世界中的应用

跳板(trampoline)和陷阱帧(trapframe)可能看起来过于复杂。

一个主要的推动力是RISC-V在触发陷阱时尽可能少地执行操作,以实现非常快速的陷阱处理,这实际上非常重要。

因此,内核陷阱处理程序的最初几条指令实际上需要在用户环境中执行:使用用户页表和用户寄存器的内容。而且,陷阱处理程序最初并不知道一些有用的信息,例如正在运行的进程的身份或内核页表的地址。

解决方案之所以可能,是因为RISC-V提供了内核可以在进入用户空间之前存储信息的受保护位置:sscratch寄存器以及指向内核内存但因缺少PTE_U而受到保护的用户页表条目。Xv6的跳板和陷阱帧利用了这些RISC-V特性。

如果内核内存被映射到每个进程的用户页表中(并设置适当的PTE权限标志),那么就可以消除对特殊跳板页的需求。这也将消除从用户空间进入内核时的页表切换需求。这反过来又允许内核中的系统调用实现利用当前进程的用户内存被映射的事实,从而允许内核代码直接解引用用户指针。许多操作系统已经使用这些想法来提高效率。Xv6避免采用这些方法,以减少由于意外使用用户指针而导致内核中安全漏洞的可能性,并减少一些必要的复杂性,以确保用户和内核虚拟地址不重叠。

生产级操作系统实现了写时复制fork、懒惰分配、按需分页、磁盘分页、内存映射文件等功能。此外,生产级操作系统会尽量使用所有的物理内存,无论是用于应用程序还是缓存(例如文件系统的缓冲区缓存,我们将在第8.2节中讨论)。在这方面,Xv6显得非常天真:你希望你的操作系统能够使用你购买的物理内存,但Xv6并没有做到。此外,如果Xv6耗尽了内存,它会向正在运行的应用程序返回错误或将其杀死,而不是例如驱逐另一个应用程序的页面。


文章作者: MIKA
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 MIKA !
  目录