xv6(RISC-V)操作系统源码分析第四节——陷阱

一、陷阱

有三件事件会导致CPU搁置指令的执行,强制将控制权转移给处理该事件特殊代码,这三件事分别是:

  • 系统调用:用户程序执行ecall指令
  • 异常:一条指令(用户或内核)做非法事件
  • 设备中断:设备发出需要注意的信号

xv6使用陷阱(trap)作为这些情况的通用术语。trap本质上指用户空间与内核空间间的切换。

通常,代码在执行时发生trap,之后都会恢复,而且不需要意识到发生了什么特殊的事情。即,trap对于上一级调用代码而言是透明的。trap机制的执行顺序是:

  1. trap强制控制权转移至内核
  2. 内核保存当前寄存器和其他状态(保护现场)
  3. 内核执行处理程序代码(例如,系统调用实现或设备驱动程序)
  4. 内核恢复保存的状态(恢复现场),并从trap中返回
  5. 代码从原来的地方恢复执行

xv6内核会处理所有的trap。

从上面我们可以知道,我们可以为三种不同的trap情况设置一个单一的代码入口。但事实证明,我们采取按照下面的trap不同类型来分别设置单独的汇编入口和C trap处理程序会更方便:

  • 来自用户空间的trap
  • 来自内核空间的trap
  • 定时器中断

其中,来自用户空间的trap包含三种中断类型,而来自内核空间的trap只包含两种,不包含系统调用。

二、陷阱的硬件支持

每个RISC-V CPU都有一组控制寄存器,内核通过写入这些寄存器来告诉CPU如何处理trap,内核可以通过读取这些寄存器来发现已经发生的trap。同时,在任何时候都可能有多个CPU在处理一个trap。

下表介绍了重要的寄存器

寄存器名功能描述
stvec内核在里面写下trap处理程序的地址,RISC-V 跳转到此处执行trap
sepc当trap发生时,RISC-V 将程序计数器(PC)保存于此,因为PC会被stvec的内存覆盖。当程序执行sret指令时,RISC-V 会将sepc的内容写入PC。
scauseRISC-V 在此处放置数字来描述trap的原因
sscratch内核在此处放置了一个值,在trap处理程序开始时可以方便地使用
sstatus

该寄存器中的SIE位控制设备中断是否被启用,若内核清除SIE,RISC-V 将推迟设备中断,直到内核设置SIE。

SPP位表示trap是来自用户模式还是监督者模式,并控制sret返回到什么模式。

stval专门存放与陷阱有关的信息。

 注意:

  • 在特权模式下,可以读写上述寄存器。
  • 用户模式下不可读写上述寄存器。
  • 机器模式下处理一组等效的控制寄存器,xv6只在定时器中断的情况下使用它们。

 当执行trap时,RISC-V硬件对所有trap类型(定时器中断除外)执行以下操作:

  1. 若trap是设备中断,且sstatus的SIE位为0,此时的设备中断被禁用。则不执行以下任何操作。
  2. 通过清除SIE来禁用中断。
  3. 复制PC到sepc。
  4. 将当前CPU的状态(用户态或特权态)保存在sstatus的SPP位。
  5. 在scause设置该次trap的原因。
  6. 将模式转为特权态。
  7. 将stvec复制到PC。
  8. 从新PC开始执行。

以上就是RISC-V CPU所执行的全部操作,它在trap执行期间做的事情很少,其目的在于为软件提供灵活性。例如,CPU不会切换到内核页表、内核中的栈和保存PC以外的任何寄存器。

三、来自用户空间的陷阱

(一)、流程概况

在用户空间执行程序时,若用户程序进行了系统调用(ecall),或者出现异常,或者设备中断,都可能发生trap。

ecall指令的作用:

对中断的处理,需要更高的权限,而处于用户态的代码的权限较低,所以需要ecall指令来提升CPU的特权等级(从用户模式到监督者模式)。权限等级体现在对寄存器的访问权限指令的执行权限

有趣的一点是,ecall指令的执行会主动触发一个用户态异常!!!从而执行相关操作t,这些的操作全由硬件自动完成。具体操作如下:

  • 从用户模式提升到监督者模式
  • 将当前指令地址或下一条指令地址放入sepc中保存
  • 将stvec中保存的trampoline页的地址放入PC中,准备进入
  • 将当前的陷阱原因记录在scause寄存器中
  • 将当前模式保存在sstatus的SPP位中,并清空sstatus的SIE位来关闭设备中断,而之前的SIE位会保存在SPIE位
  • 更新stval寄存器的值,使其指向异常的地址
  • 复制PC到sepc。
  • 将stvec复制到PC。
  • 从新PC开始执行。

上面操作是不是似曾相识,在上一部分提及过,因为这就是通用操作。不论是系统调用,还是异常或中断。实际上,系统调用通过ecall指令转化成了异常来处理。

现在,PC被设置为指向uservec,因为stvec指向uservec。

来自用户空间的trap的处理路径是:

uservec--->usertrap--->usertrapret--->userret

上面路径的前两个函数是进入陷阱,而后两者是从陷阱返回。

uservec的作用——保护现场与切换至内核态

RISC-V 硬件在trap过程中不切换页表,所以用户页表必须包含uservec的映射,即stvec指向trap处理程序的地址。
而从用户页表切换到内核页表这一任务就归到了uservec上。uservec会切换satp,使其指向内核页表。为了在切换后继续执行指令,uservec必须被映射到内核页表与用户页表相同的地址。

说到这里,trampoline(跳板)机制的引入原因似乎就更清楚了。

xv6在内核页表和每个用户页表中的同一虚拟地址处映射了trampoline页。而uservec就包含在trampoline页中。

在执行用户代码时,stvec被uservec写入。

当uservec启动时,所有32个寄存器会保存被中断代码所拥有的值。 uservec会执行csrrw指令将寄存器a0sscratch的内容互换。这样一来,当前用户代码的a0被保存至sscratch中,uservec可以安心使用a0寄存器。而a0寄存器包含了内核之前存放在sscratch中的值。

uservec接着会保存用户寄存器。在进入用户空间时,内核先向sscratch中写入指向该进程的trapframe的地址。trapframe用来保存所有用户寄存器。此时satp仍然指向用户页表,a0寄存器中存放的是指向当前进程的trapframe的地址。

当创建每个进程时,xv6为进程的trapframe分配一页内存,并将它映射到用户虚拟地址TRAPFRAME中,即TRAMPOLINE下面。这样进程的p->trapframe也指向trapframe,不过是指向物理地址,如此一来,内核也可以通过内核页表来使用它。

所以,此时的a0指向当前进程的trapframe,uservec将在trapframe中保存全部的寄存器,包括从sscratch中读取a0来保存a0

trapframe包含指向当前进程的内核栈、当前CPU的硬件线程ID、usertrap的地址和内核页表的地址。uservec将这些值设置到相应的寄存器中,并通过写入satp来切换至内核页表,同时刷新TLB。

最后,uservec调用usertrap。

usertrap的作用——确定trap的处理方式

usertrap的作用就是确定trap的类型,然后处理它,最后返回。

usertrap首先改变stvec,这样在内核中发生的trap会由kernelvec处理。同时,usertrap会保存sepc,此时的sepc存储着发生trap的指令的地址(用户PC),防止发生进程切换导致sepc被覆盖。

  • 若trap是系统调用,syscall会处理它。
  • 若是设备中断,devintr会处理它。
  • 若是异常,内核会杀死异常进程。

usertrap会将用户PC加4,来生成发生trap的指令的下一条指令的地址。

usertrap检查进程是否被杀死或应该让出CPU(若这个trap是一个定时器中断)来决定是否退出。

回到用户空间后,第一步就是调用usertrapret。 

 usertrapret的作用——为trap的返回准备条件

设置RISC-V 控制寄存器,为以后用户空间trap做准备。这包括设置stvec指向uservec,准备uservec所依赖的trapframe字段,并将sepc设置为先前保存的用户程序计数器PC。最后,usertrapret在用户页表和内核页表中映射的trampoline页上调用userret,因为userret中的汇编代码会切换页表。

usertrapret对userret的调用传递了参数a0与a1,a0指向TRAPFRAME,a1指向用户进程页表,userret将satp切换到进程的用户页表。

当然,usertrapret会关中断,直到执行sret前,都不会响应中断。

再次强调,在用户页表和内核页表中,trampoline页被映射在相同的虚拟地址上,这也是允许uservec在改变satp后继续执行的原因。

userret的作用——返回

userret将trapframe中保存的用户的a0复制到sscratch中,为以后与TRAPFRAME交换做准备。从这时开始,userret能使用的数据只有寄存器中的内容和trapframe中的内容。

接着,userret从trapframe中恢复保存的用户寄存器,对a0和sscratch做最后的交换,恢复用户a0并保存TRAPFRAME,为下一次trap做准备,并使用sret返回用户空间。 

sret的执行与作用基本与ecall指令相反。 

(二)、系统调用的实现 

1.系统调用的发生过程

下面介绍用户如何调用exec这一系统调用。

用户代码(这里的用户代码不是使用者写的代码,使用者只需按照格式调用即可,这里的用户代码指我们操作系统设计者写的实现系统调用的代码)首先将exec的参数放在寄存器a0a1中,并将系统调用号放在a7中。

在内核代码中,设置了函数指针表syscalls数组,如下:

// An array mapping syscall numbers from syscall.h
// to the function that handles the system call.
static uint64 (*syscalls[])(void) = {
[SYS_fork]    sys_fork,
[SYS_exit]    sys_exit,
[SYS_wait]    sys_wait,
[SYS_pipe]    sys_pipe,
[SYS_read]    sys_read,
[SYS_kill]    sys_kill,
[SYS_exec]    sys_exec,
[SYS_fstat]   sys_fstat,
[SYS_chdir]   sys_chdir,
[SYS_dup]     sys_dup,
[SYS_getpid]  sys_getpid,
[SYS_sbrk]    sys_sbrk,
[SYS_sleep]   sys_sleep,
[SYS_uptime]  sys_uptime,
[SYS_open]    sys_open,
[SYS_write]   sys_write,
[SYS_mknod]   sys_mknod,
[SYS_unlink]  sys_unlink,
[SYS_link]    sys_link,
[SYS_mkdir]   sys_mkdir,
[SYS_close]   sys_close,
};

注意:上面的索引从1开始,不是从0开始

a7中的系统调用号会与上述数组中的元素进行匹配。匹配成功后,ecall指令被执行从而进入内核。 接着,顺序执行uservec、usertrap与syscall。

syscall代码:

void syscall(void)
{
  int num;
  struct proc *p = myproc();

  num = p->trapframe->a7;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // Use num to lookup the system call function for num, call it,
    // and store its return value in p->trapframe->a0
    p->trapframe->a0 = syscalls[num]();
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

代码解析:

首先得到当前进程的信息,存放于一结构体中。

struct proc *p = myproc();

然后从当前进程的trampoline页中的a7中得到系统调用号。(注意,此处的a7并非指物理上的寄存器,而是指trampoline页中保存的物理寄存器a7中的内容)

num = p->trapframe->a7;

若当前进程的系统调用号在合法范围内(1到系统调用数)且系统调用函数表中有,则执行相应的系统调用函数。系统调用返回时,返回值会存储在p->trapframe->a0中。而在用户空间中,表现为exec函数返回该值。

p->trapframe->a0 = syscalls[num]();

系统调用成功返回0或正数,返回负数表示错误。

printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;

其他所有系统调用的操作同上,根本区别在于系统调用号不一样,就调用不同的系统调用

2.系统调用的参数传递

 上面着重讲解了系统调用是如何发生的,下面重点讲解系统调用的参数传递。

使用者的程序代码会调用系统调用的包装函数,就像我们平时编程调用普通函数一样。

在这背后,内核代码会将参数存储到物理用户寄存器中。

而内核的trap代码会将物理用户寄存器中的值保存到当前进程的trapframe中。

xv6中,函数argint、argaddr和argfd会从trapframe中以整数、指针或文件描述符的形式检索第n个系统调用参数。它们都调用argraw来获取已保存的用户trapframe中寄存器中的值。

// Fetch the nth 32-bit system call argument.
void argint(int n, int *ip)
{
  *ip = argraw(n);
}

// Retrieve an argument as a pointer.
// Doesn't check for legality, since
// copyin/copyout will do that.
void argaddr(int n, uint64 *ip)
{
  *ip = argraw(n);
}

// Fetch the nth word-sized system call argument as a null-terminated string.
// Copies into buf, at most max.
// Returns string length if OK (including nul), -1 if error.
int argstr(int n, char *buf, int max)
{
  uint64 addr;
  argaddr(n, &addr);
  return fetchstr(addr, buf, max);
}
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;
}

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

现在就可能会发生问题,用户程序可能是错误的或恶意的,给内核传递了一个无效指针或是用户内存而非内核内存的指针。并且,xv6的内核页表映射与用户页表映射不一样,所以内核不能使用普通指令从用户提供的地址加载或存储。

以上问题可以归结于一个问题——系统调用参数的安全性问题。

xv6实现了安全的参数传递机制。比如,上述代码中的fetchstr函数。

// Fetch the nul-terminated string at addr from the current process.
// Returns length of string, not including nul, or -1 for error.
int fetchstr(uint64 addr, char *buf, int max)
{
  struct proc *p = myproc();
  if(copyinstr(p->pagetable, buf, addr, max) < 0)
    return -1;
  return strlen(buf);
}

而fetchstr函数的核心是copyinstr函数。

// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  uint64 n, va0, pa0;
  int got_null = 0;

  while(got_null == 0 && max > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > max)
      n = max;

    char *p = (char *) (pa0 + (srcva - va0));
    while(n > 0){
      if(*p == '\0'){
        *dst = '\0';
        got_null = 1;
        break;
      } else {
        *dst = *p;
      }
      --n;
      --max;
      p++;
      dst++;
    }

    srcva = va0 + PGSIZE;
  }
  if(got_null){
    return 0;
  } else {
    return -1;
  }
}

简而言之,copyinstr将用户页表中的虚拟地址srcva复制到dst,然后调用walkaddr函数在软件中模拟分页硬件的操作,确定srcva的物理地址。所以walkaddr检查用户提供的虚拟地址是否是当前进程用户地址空间的一部分。

四、来自内核空间的陷阱 

xv6根据用户代码在执行还是内核代码在执行,会对CPU陷阱寄存器进行不同的配置。

当内核代码在CPU上运行时,内核将stvec指向kernelvec(一个汇编程序)上的汇编代码。kernelvec会使用satp寄存器切换至内核页表,并引用有效内核的堆栈指针。kernel会保存所有寄存器至中断内核线程的堆栈上,因为寄存器值属于该线程,这是合理的。

kernelvec在保存寄存器后跳转到kerneltrap。kerneltrap会处理两种陷阱——设备中断和异常。

  • 设备中断:kerneltrap调用devintr处理
  • 异常:调用panic停止执行,因为发生在xv6内核中的异常一定是严重错误。xv6对异常的响应结果是固定的。发生在用户空间的异常,内核会杀死故障进程。发生在内核空间的异常,内核会panic。

如果是由于计时器中断而调用kerneltrap,并且当前进程的内核线程正在执行,kerneltrap会调用yield让出CPU,允许其他进程执行。在某个时刻,一个线程退出后让上述线程及其kerneltrap恢复。

当kerneltrap的工作完成时,它需要返回到被中断的代码。因为yield可能破坏保存的sepc和在sstatus中保存的之前的模式。kerneltrap在启动时保存它们。它现在恢复那些控制寄存器并返回到kernelvec。kernelvec从堆栈恢复哪些保存的寄存器并执行sret,sret将sepc复制到PC并恢复中断的内核代码。

五、缺页异常

(一)缺页异常相关概念

缺页异常发生的原因:CPU不能将虚拟地址映射为物理地址。

缺页异常的分类:

  1. load页异常:加载指令不能翻译其虚拟地址
  2. store页异常:存储指令不能翻译其虚拟地址
  3. 指令页异常:指令的地址不能翻译

缺页异常涉及两个物理寄存器:

物理寄存器名称功能
scause其值表示缺页异常类型
stval其值包含无法翻译的地址

(二)缺页异常的应用

本部分将介绍缺页异常的三个应用。

  • 在fork中分配内存的应用
  • 在sbrk中分配增长的内存空间
  • 实现页面交换

1.下面介绍缺页异常在fork系统调用中的应用

写时复制(copy-on-write,cow)fork:

前面介绍过,fork调用会为子进程分配物理内存并赋值父进程内容到子进程中。

但若子进程与父进程共享父进程的物理内存,效率会更高。所以,当发生fork后,父进程与子进程起初会共享物理内存,但它们的权限金是只读。因为父进程与子进程对共享栈和堆的写入会中断彼此的执行。所以当发生写指令(store指令)时,CPU会引发缺页异常。作为对这个异常的响应,内核会拷贝一份包含发生异常的地址的页,将其副本映射在子进程的地址空间。然后更新页表,恢复执行。此时,父进程与子进程就分别有一个内容相同的物理内存。

2.懒分配(lazy allocation)

当一个应用程序调用sbrk来获得更多内存空间时,内核会增长地址空间。但是,内核在页表中将新的地址标记为无效。只有当应用程序映射虚拟地址到这些无效时发生了缺页异常后,内核才会分配物理内存并将其映射到页表中。简而言之,内核仅当应用程序实际使用时才分配内存。

3.从磁盘上分页(paging from disk) 

 若应用程序需要的内存超过了可用的物理DRAM,内核可以将一些页写入磁盘,并标记其页表项为无效。若一个应用程序读取或写入一个被换出到磁盘上的页,CPU会发生缺页异常。内核就检查故障地址,若该地址属于磁盘上的页面,内核就会分配一个内存空间的页面,从磁盘上读取页面到该内存,更新页表项为有效并应用该内存,然后恢复应用程序。

总结:

本节重点讲述xv6的陷阱机制的实现,尤其是对系统调用这一重要的陷阱进行较为详细地讲解。

参考资料:

[1] FrankZn/xv6-riscv-book-Chinese (github.com)

[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com) 

[3] 6.S081——陷阱部分(一文读懂xv6系统调用)——xv6源码完全解析系列(5)_xv6源码分析-CSDN博客

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值