xv6:trap与系统调用

因为trap机制很复杂,我想尽可能保留细节,所以本篇笔记有大量的原文翻译内容及其整理

trap

[!QUOTE] There are three kinds of event which cause the CPU to set aside ordinary execution of instructions and force a transfer of control to special code that handles the event. One situation is a system call, when a user program executes the ecall instruction to ask the kernel to do something for it. Another situation is an exception: an instruction (user or kernel) does something illegal, such as divide by zero or use an invalid virtual address. The third situation is a device interrupt, when a device signals that it needs attention, for example when the disk hardware finishes a read or write request
[[book-riscv.pdf#page=43&selection=3,0,19,7|book-riscv, page 43]]

有三种事件会让CPU先把普通的指令执行中止,强行切进事件处理程序:

  1. 系统调用
  2. 非法指令(例如除0)
  3. 设备中断 如硬盘完成了读写请求后发起的信号

This book uses trap as a generic term for these situations. Typically whatever code was execut- ing at the time of the trap will later need to resume, and shouldn’t need to be aware that anything special happened. That is, we often want traps to be transparent; this is particularly important for device interrupts, which the interrupted code typically doesn’t expect. The usual sequence is that a trap forces a transfer of control into the kernel; the kernel saves registers and other state so that execution can be resumed; the kernel executes appropriate handler code (e.g., a system call imple- mentation or device driver); the kernel restores the saved state and returns from the trap; and the original code resumes where it left off
[[book-riscv.pdf#page=43&selection=20,0,31,39|book-riscv, page 43]]

书中使用术语trap描述

被打断的代码后续需要恢复,所以会存下那瞬间的各类寄存器数据,以便后续恢复执行
它们不应该知道trap的发生,即让trap是透明的
切进内核后控制权转交给内核代码,然后执行相应的handler
恢复后被打断的程序从它被打断的地方继续执行,一切一如往常

就好像时间暂停对于被暂停的人来说好像没有发生一般


[!QUOTE] it makes sense for interrupts since isolation demands that only the kernel be allowed to use devices, and because the kernel is a convenient mechanism with which to share devices among multiple processes.
[[book-riscv.pdf#page=43&selection=33,37,35,42|book-riscv, page 43]]

隔离性要求只准许内核使用设备是有意义的,因为内核是个便利的机制,对于多处理器之间共享设备来说

Xv6 陷阱处理分四个阶段进行:

  1. RISC-V CPU 执行的硬件操作
  2. 为内核 C 代码准备道路的一些汇编指令
  3. 决定如何处理陷阱的 C 函数以及系统调用或设备驱动程序服务例程。

[!QUOTE] While commonality among the three trap types suggests that a kernel could handle all traps with a single code path, it turns out to be convenient to have separate code for three distinct cases: traps from user space, traps from kernel space, and timer interrupts. Kernel code (assembler or C) that processes a trap is often called a handler
[[book-riscv.pdf#page=43&selection=39,71,45,7|book-riscv, page 43]]

虽然这三种陷阱类型的共性表明内核可以使用单个代码路径处理所有陷阱,但事实证明,为三种不同的情况提供单独的代码能提供便利:来自用户空间的陷阱、来自内核空间的陷阱和计时器中断。处理陷阱的内核代码(汇编程序或 C)通常称为处理程序;

第一个处理程序指令通常用汇编程序编写,有时称为vector。

xv6 trap机制

trap机制调用路径

用户空间 > uservec > ecall > usertrap()>syscall() >系统调用处理 > syscall() > usertrapret() > 用户空间

ecall
保存调用者寄存器
调用
处理完返回
调用
调用汇编
还原调用者寄存器
userspace
uservec
usertrap
syscall
sys_xxx
usertrapret
userret

几个重要的硬件

几个最重要的寄存器:

stvec: 内核将trap handler的地址,也就是trampoline写在这里

sepc: 当一个trap发生,RISC-V将PC的值存入,并用stvec覆盖PC,sret指令拷贝sepcPC,以此实现切入切出

scause: RISC-V放入一个数字描述trap的类型

sscratch: 内核在这里放置了一个值,应该就是tramframe的地址

sstatus: SIE bit控制设备中断是否开启,如果该位被置为clear,设备中断将会被推迟。SPP bit隐含trap来自用户模式还是supervisor模式,控制sret返回到什么模式

A major constraint on the design of xv6’s trap handling is the fact that the RISC-V hardware does not switch page tables when it forces a trap. This means that the trap handler address in stvec must have a valid mapping in the user page table, since that’s the page table in force when the trap handling code starts executing. Furthermore, xv6’s trap handling code needs to switch to the kernel page table; in order to be able to continue executing after that switch, the kernel page table must also have a mapping for the handler pointed to by stvec.
[[book-riscv.pdf#page=45&selection=60,0,71,1|book-riscv, page 45]]

[!NOTE] xv6驱动一个trap时是不切换页表的,所以stvec中的trap处理程序地址必须在用户页表中有一个合法映射。处理过程中需要切换成内核页表,所有内核页表中也要有一个stvec指向的处理程序的映射

xv6通过trampoline页面来满足这个要求
trampoline页面被映射在TRAMPLINE,虚拟内存的末尾
PTE_U标志,supervisor可以直接用

32个通用寄存器需要备份,在uservec开始,csrww指令交换a0sscratch寄存器的值,这样就腾出来一个a0可以用,而且现在a0里面放的是trapframe

trapframe

uservec’s next task is to save the 32 user registers. Before entering user space, the kernel set sscratch to point to a per-process trapframe structure that (among other things) has space to save the 32 user registers (kernel/proc.h:44). Because satp still refers to the user page table, uservec needs the trapframe to be mapped in the user address space. When creating each process, xv6 allocates a page for the process’s trapframe, and arranges for it always to be mapped at user virtual address TRAPFRAME, which is just below TRAMPOLINE. The process’s p->trapframe also points to the trapframe, though at its physical address so the kernel can use it through the kernel page table
[[book-riscv.pdf#page=46&selection=42,0,78,32|book-riscv, page 46]]

在保存32个寄存器之前,satp还指向用户页表,所以需要一个trapframe映射在用户地址空间。每个进程被创建时,xv6为trapframe分配一个页面,映射到TRAPFRAME,就在TRAMPOLINE的下面

p->trapframe指向trapframe页面


Thus after swapping a0 and sscratch, a0 holds a pointer to the current process’s trapframe. uservec now saves all user registers there, including the user’s a0, read from sscratch.

[[book-riscv.pdf#page=46&selection=79,0,100,1|book-riscv, page 46]]

交换a0sscratcha0取得当前进程的trapframe指针
uservec保存所有用户寄存器,包括从sscratch读出来的a0


The trapframe contains the address of the current process’s kernel stack, the current CPU’s hartid, the address of the usertrap function, and the address of the kernel page table. uservec retrieves these values, switches satp to the kernel page table, and calls usertrap.

[[book-riscv.pdf#page=46&selection=101,0,121,1|book-riscv, page 46]]

trapframe包含了当前进程中

  1. 内核栈的地址
  2. CPU hartid
  3. usertrap 函数地址
  4. kernel page table

uservec检索这些值,切换satp到内核页表,然后调用usertrap


trampframe的“初始化”

之前我以为trampframe页的数据是调用usertrapret()获得的,后来发现,其实就是fork()里面的一句拷贝

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);

解引用后是整个结构体进行拷贝,也就是新进程的trapframe完整地复制了当前进程的trapframe

usertrapret

The job of usertrap is to determine the cause of the trap, process it, and return (kernel/- trap.c:37). It first changes stvec so that a trap while in the kernel will be handled by kernelvec rather than uservec. It saves the sepc register (the saved user program counter), because usertrap might call yield to switch to another process’s kernel thread, and that process might return to user space, in the process of which it will modify sepc. If the trap is a system call, usertrap calls syscall to handle it; if a device interrupt, devintr; otherwise it’s an ex- ception, and the kernel kills the faulting process. The system call path adds four to the saved user program counter because RISC-V, in the case of a system call, leaves the program pointer pointing to the ecall instruction but user code needs to resume executing at the subsequent instruction. On the way out, usertrap checks if the process has been killed or should yield the CPU (if this trap is a timer interrupt).
[[book-riscv.pdf#page=46&selection=122,0,181,27|book-riscv, page 46]]

usertrap的工作

  1. 首先就是改变stvec以便kernel中的trapkernelvec处理
  2. 然后保存sepc寄存器,因为usertrap可能调用yield切换到其它内核线程,然后返回用户空间到一个会修改sepc寄存器的进程
  3. 分支
  • 如果trap是系统调用,调用系统调用去处理
  • 如果是硬件中断,devintr
  • 不然是个异常,杀掉这个错误的进程
  1. 系统调用路径在保存的用户程序计数器上增加了 4,因为在系统调用的情况下,RISC-V 使程序指针指向 ecall 指令,但用户代码需要在后续指令中恢复执行。
  2. 在退出时,usertrap 会检查进程是否已终止或应让出 CPU(如果此陷阱是计时器中断)。

usertrapret’s call to userret passes TRAPFRAME in a0 and a pointer to the process’s user page table in a1 (kernel/trampoline.S:88). userret switches satp to the process’s user page table. Recall that the user page table maps both the trampoline page and TRAPFRAME, but nothing else from the kernel. The fact that the trampoline page is mapped at the same virtual address in user and kernel page tables is what allows uservec to keep executing after changing satp. userret copies the trapframe’s saved user a0 to sscratch in preparation for a later swap with TRAPFRAME. From this point on, the only data userret can use is the register contents and the content of the trapframe. Next userret restores saved user registers from the trapframe, does a final swap of a0 and sscratch to restore the user a0 and save TRAPFRAME for the next trap,
[[book-riscv.pdf#page=46&selection=220,0,299,18|book-riscv, page 46]]

  1. usertapret调用userret时,将TRAPFRAME放在a0,将user page table放在a1 (kernel/trampoline.S:88),这是userret的参数约定
  2. userret切换satp到user page table
  3. userret复制trapframe保存的用户a0sscratch,准备与TRAPFEAME交换回去,就像来时两个寄存器交换值一样,去时就交换还原
  4. 从此刻开始,userret唯一能用的数据是寄存器和trampframe中的内容
  5. 下一步,userrettrapframe还原保存的用户寄存器,并对a0sscratch做最后的交换,还原a0,并将TRAPFRAME放入sscratch为下次trap做准备
  6. 执行sret返回用户空间

user page table除了映射了trampolinetrapframe页面,再别无其他来自内核的东西了。实际上trampoline页面被用户和内核映射到相同的虚拟地址,使得uservecsatp改变后还能继续运行

代码:调用系统调用

# Initial process that execs /init.
# This code runs in user space.

#include "syscall.h"

# exec(init, argv)
.globl start
start:
        la a0, init
        la a1, argv
        li a7, SYS_exec
        ecall

# for(;;) exit();
exit:
        li a7, SYS_exit
        ecall
        jal exit

# char init[] = "/init\0";
init:
  .string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
  .long init
  .long 0

这是initCode.S将参数放在a0,与a1,将系统调用编号放入a7,然后ecall就开启系统调用了

系统调用号与在syscalls数组里面系统调用处理程序对应
ecall指令trap进内核,然后执行了uservec (trampoline.s),usertrap (trap.c),然后执行syscall()

sys_exec返回时,syscall记录它的返回值到p->trapframe->a0。这会让用户空间的代码感觉是exec的返回值,因为c语言的对于riscv调用约定是将返回值存放在a0

p->trapframe->a0貌似也是xv6中唯一一个来自用户空间,但被修改过的寄存器
fork()之所以父子进程返回值不一样,就是由于父子进程trapframe->a0值不同

//set the a0 value of new proc to zero 
  np->trapframe->a0 = 0;

如果system call number 是无效的,syscall打印一个报错,并返回-1

代码:系统调用参数

内核中的系统调用实现需要接收来自用户代码传递过来的参数。用户代码调用系统调用封装函数,参数最开始是 由RISC-V C调用约定,将它们放在寄存器中。

故,内核trap代码将用户寄存器存放在当前进程的 trap frame,以便内核代码可以找到它们。

内核函数argintargaddr,以及argfd分别检索来自trap frame的第n个系统调用参数并将它们转成整数,指针,文件描述符。它们都调用argraw检索在trap frame中被保存的合适的寄存器

static uint64
argraw(int n)
{
  struct proc *p = myproc();
  switch (n) {
  case 0:
    return p->trapframe->a0;
  case 1:
    return p->trapframe->a1;
  case 2:
    return p->trapframe->a2;
  case 3:
    return p->trapframe->a3;
  case 4:
    return p->trapframe->a4;
  case 5:
    return p->trapframe->a5;
  }
  panic("argraw");
  return -1;
}

一些系统调用传递指针作为参数,内核必须使用这些指针去读写用户内存。

例如exec系统调用,传给内核一个字符数组指针,这个指针指向用户空间字符串参数

指针传递有两个挑战:

  1. 用户程序可能有bug传给内核一个不合法的指针或者是一个恶意程序,试图传一个指针trick进内核访问内核内存而不是原本期望的用户内存
  2. xv6内核页表映射与用户页表映射是不同的,所以内核不能使用常规指令去从用户提供的地址加载或存储数据

内核实现了一个函数fetchstr,去安全地传输用户提供的地址中的数据,它调用了[[copyinstr()]]

因为物理地址与内核虚拟地址是恒等映射,所以[[copyinstr()]]能直接从pa0拷贝字符串比特到dst

[[copyinstr()]]调用[[walkaddr()]],会检查是否来自用户空间,所以避免了有恶意程序试图trick进内核读取其他的内存。与它相似的一个函数是copyout(),但它是将内核内的数据拷贝到用户空间

来自内核空间的trap

xv6 CPU寄存器的配置有些不同,这取决于发起trap的程序来自内核还是用户空间

  • 当内核正在一个CPU上运行,内核让stvec指向kernelvec的汇编代码
  • 由于 xv6 已经在内核中,kernelvec 可以依赖于将 SATP 设置为内核页表,以及引用有效的内核栈指针。kernelvec 将所有 32 个寄存器推送到堆栈上,稍后将从堆栈中恢复它们,以便中断的内核代码可以不受干扰地恢复。就像线程切换。

kernelvec存下被中断的内核线程的寄存器。kerneltrap应对两种trap:

  • 设备中断与异常
    • 设备中断通过调用devintr检查和处理
    • 如果不是设备中断,那一定是异常。异常在xv6内核中是一个严重的错误。内核会调用panic并停止执行
  • 定时器中断
    • 如果kerneltrap是因为定时器中断被调用,并且该进程的内核线程正在运行,kerneltrap调用yield将运行机会让出去。在某个时刻,这些线程像这样切出,就能再次让这个线程恢复

kerneltrap完成了它的工作,它需要返回到被trap中断的代码。因为yield可能扰乱sepcsstatuskerneltrap会在刚启动时就保存这两个寄存器。
恢复时,它恢复这些控制寄存器到kernelveckernelvec从栈中弹出这些被保存的寄存器,然后执行sret指令,拷贝specpc并恢复中断的内核代码

如果kerneltrap因为定时器中断调用了yield,trap怎么返回?

在[[usertrap()]],可以看见当CPU进从用户空间进入内核,xv6设置CPU的stveckernelvec。在此之间有一个窗口期:内核已开始执行,但STVEC 仍设置为 USERVEC
在这个窗口期间,如果发生设备中断,就可能出事吗,因为会到uservec进行处理。幸运的是,RISC-V指令集在开始trap时总是禁用中断,而 xv6 在设置 stvec 之前不会再次启用它们。

页面故障异常

xv6对异常的响应是相当无趣的:如果页面故障异常发生在用户空间,内核会直接kill掉这个进程
如果页面故障异常发生在内核,这个内核进入panic。真实的操作系统通常以更有趣的方式响应页面故障。

举个栗子,很多内核使用页面故障实现写时复制,英文copy-on-write(COW)。

写时复制的实现

xv6中,fork时子进程初始内存的内容与父进程是相同的。xv6用uvmcopy实现fork ,以此来分配子进程的物理内存并将父进程的内存拷贝。

有一部分内存内容是不会被修改且父子进程都需要的,比如一些外部二进制库。如果我们可以让父子进程共享这部分物理内存,那不就嘎嘎提效率?

但直接实现这点是不行的,因为这会让父子进程因为他们对共享堆栈的写入,干扰彼此的运行。

父子进程能通过合适地使用页表权限和页面故障,安全地分享物理内存。CPU会发起一个页面故障异常,当:

  • 使用一个未被映射在页表的虚拟地址
  • 或有PTE_V标志位被clear
  • 或权限位(PTE_R,PTE_W,PTE_X,PTE_U)的映射禁止尝试操作

riscv区分了三种页面故障:

  • 加载页面异常:加载指令中的虚拟地址不能翻译
  • 存储页面故障:存储指令中的虚拟地址不能翻译
  • 指令页面故障:PC中的地址不能被翻译

[!TIP] scause寄存器隐含了页面故障的类型,stval寄存器存着不能被翻译的地址

基础的cow-fork 实现方案是父子进程最开始共享所有的物理页面,但把门都被映射为read-only(PET_W 被 clear)。父子进程能从共享的物理内存读取数据。对于任意给定页面,riscv CPU发起一个页面故障异常,内核trap处理程序就会响应异常:分配一个新的物理内存页,并将页面故障地址映射的内容拷贝到新的内存页

内核改变了页面故障进程的页表的PTE,让它指向了这个刚刚拷贝的新页面,并开启了读写权限,然后恢复这个页面故障进程

因为PTE允许写,重新执行该指令就不会页面故障了。写时复制需要book-keeping帮助决定何时物理内存能被释放,因为每个页表能被不同数量的页表引用。具体何时释放,这取决于fork,页面故障,exec,exit的历史记录。

[!NOTE] 这种book-keeping可以进行一个重要的优化:
如果某个进程引起一个储存页面故障,但它只从该进程的页表中引用物理页面,那就不必复制了

写时复制使得fork()更快,因为fork()不必再复制内存。虽然一些内存在后续被写入时,还是要被拷贝,但通常来说,大多数的内存是不必拷贝的。

比如说fork()后跟exec():在fork()后写了几页,但随后子进程的exec()释放大量从父进程继承来的内存。写时复制 fork 消除了复制此内存的需要。进一步来说,COW fork 是透明的:应用程序不需要任何修改就可以从中获益


惰性分配

除了COW fork以外,页表与页表故障的组合开辟了各种有趣的可能。另外一个广泛使用的技术叫惰性分配,该技术分为两部分:

  1. 当一个应用通过调用sbrk请求很多的内存,内核会记录下增长的大小,但不分配物理内核,也不给新的虚拟内存创建PTE
  2. 在这些新地址发生页面故障时,内核才分配物理页面并将其映射进页表。类似COW fork,内核能够实现惰性分配,让它对于应用程序是透明的

需求式分页

exec 中,xv6 加载应用程序的所有文本和数据都急切地进入内存。由于应用程序可能很大且磁盘读写很昂贵,这种启动成本可能会对用户来说很明显:当用户启动一个大应用程序,则可能需要很长时间才能看到响应。

为了改善响应时间,现代内核为用户地址空间创建页表,但标记页面无效。

在页面出现错误时,内核会从磁盘读取页面的内容并将其映射到用户地址空间。像 COW fork 和 lazy allocation 一样,内核可以实现这个特性对应用程序透明


磁盘分页

paging to disk的主要思想是:只在RAM中存储用户页面的一部分,将其余的存到磁盘的分页区域(paging area)。将存储在分页区域内存对应的PTE标记为无效。

如果一个应用程序尝试使用被page out到磁盘的页面,该应用会引起页面故障,该页面必须被paged in:内核trap处理程序会分配一个物理RAM的页面,从磁盘将该页面读进内存,并修改对应的PTE指向RAM

[!FAQ] 如果页面需要pageed in,但物理RAM中没有空闲空间,会发生什么?
在这种情况下,内核必须通过切出(paging out)或驱逐(evicting)到磁盘的分页区,先释放一个物理页,并将指向这个物理页的PTE标记为无效。

驱逐是很昂贵的,因为在驱逐不频繁的情况下,分页表现最佳:如果应用程序具有引用的局部性(即它们在任何时候都只使用其内存的一个子集),这个特性就能很好地发挥作用。


结合了分页和页面故障异常的功能还有:自动拓展栈,内存映射文件

现实世界

trampoline和trapframe很复杂。一个驱动力是riscv强制trap时故意尽可能地少做,以便能很快地处理trap。这导致内核trap处理程序的前几条指令必须在用户环境中执行:用户页表,用户寄存器上下文,trap handler对最初的有用的东西一无所知。

riscv提供了受保护的地方,内核可以在进入用户空间之前隐藏信息:sscratch寄存器,指向内核内存但被PTE_U的权限缺失保护的用户页表入口。xv6的trampoline和trapframe利用了这些riscv功能。


如果内核内存被映射进每个进程的用户页表(有合适的PTE权限标志),特殊的trampoline页面是可以消除的。这也将消除从用户空间trap进内核时的页表切换。这反过来,也会让内核中的系统调用实现利用当前进程被映射的用户内存,让内核代码直接解引用用户指针。

许多操作系统使用了这个想法去增加效率。xv6没有这样实现,这是为了减少无意使用用户指针导致的内核中的安全漏洞,并降低确保用户和内核虚拟地址不重叠的一些复杂性

生产操作系统实现写时复制fork,惰性分配,需求式分页,对磁盘分页,内存映射文件等。进一步说,生产操作系统会尝试将所有的物理内存都用于应用程序或缓存。
xv6在这方面很幼稚,你想让你的操作系统去使用你付了钱的物理内存,但xv6不这样。另外,如果xv6把内存用完了,它给正在运行的应用程序返回一个错误并kill掉它,而不是驱逐另外一个应用程序的页表来腾出内存空间

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值