一、陷阱
有三件事件会导致CPU搁置指令的执行,强制将控制权转移给处理该事件特殊代码,这三件事分别是:
- 系统调用:用户程序执行ecall指令
- 异常:一条指令(用户或内核)做非法事件
- 设备中断:设备发出需要注意的信号
xv6使用陷阱(trap)作为这些情况的通用术语。trap本质上指用户空间与内核空间间的切换。
通常,代码在执行时发生trap,之后都会恢复,而且不需要意识到发生了什么特殊的事情。即,trap对于上一级调用代码而言是透明的。trap机制的执行顺序是:
- trap强制控制权转移至内核
- 内核保存当前寄存器和其他状态(保护现场)
- 内核执行处理程序代码(例如,系统调用实现或设备驱动程序)
- 内核恢复保存的状态(恢复现场),并从trap中返回
- 代码从原来的地方恢复执行
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。 |
scause | RISC-V 在此处放置数字来描述trap的原因 |
sscratch | 内核在此处放置了一个值,在trap处理程序开始时可以方便地使用 |
sstatus | 该寄存器中的SIE位控制设备中断是否被启用,若内核清除SIE,RISC-V 将推迟设备中断,直到内核设置SIE。 SPP位表示trap是来自用户模式还是监督者模式,并控制sret返回到什么模式。 |
stval | 专门存放与陷阱有关的信息。 |
注意:
- 在特权模式下,可以读写上述寄存器。
- 用户模式下不可读写上述寄存器。
- 机器模式下处理一组等效的控制寄存器,xv6只在定时器中断的情况下使用它们。
当执行trap时,RISC-V硬件对所有trap类型(定时器中断除外)执行以下操作:
- 若trap是设备中断,且sstatus的SIE位为0,此时的设备中断被禁用。则不执行以下任何操作。
- 通过清除SIE来禁用中断。
- 复制PC到sepc。
- 将当前CPU的状态(用户态或特权态)保存在sstatus的SPP位。
- 在scause设置该次trap的原因。
- 将模式转为特权态。
- 将stvec复制到PC。
- 从新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指令将寄存器a0与sscratch的内容互换。这样一来,当前用户代码的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的参数放在寄存器a0与a1中,并将系统调用号放在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不能将虚拟地址映射为物理地址。
缺页异常的分类:
- load页异常:加载指令不能翻译其虚拟地址
- store页异常:存储指令不能翻译其虚拟地址
- 指令页异常:指令的地址不能翻译
缺页异常涉及两个物理寄存器:
物理寄存器名称 | 功能 |
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博客