170 lines
7.9 KiB
Markdown
170 lines
7.9 KiB
Markdown
# Syscall在xv6-k210中的实现与执行
|
||
Syscall是如何实现与执行的呢?以下从用户程序出发,分析在xv6-k210中系统调用的过程。
|
||
|
||
## 一、Syscall的封装与使用
|
||
使用C语言编写用户程序时,可以直接编写内联汇编的形式,执行`ecall`指令,但这不够美观便捷。
|
||
xv6-k210将系统调用的汇编指令段进行了封装,提供了一个C函数的接口,在用户程序中,可以直接像使用普通函数一样调用,
|
||
包括传递参数和接收返回值。
|
||
|
||
在[xv6-user/user.h](/xv6-user/user.h)中,声明了各个Syscall封装后的函数接口。在用户程序中包含该头文件,即可使用其中的函数。
|
||
|
||
```C
|
||
// xv6-user/user.h
|
||
int fork(void);
|
||
int exit(int) __attribute__((noreturn));
|
||
int wait(int*);
|
||
int pipe(int*);
|
||
int write(int, const void*, int);
|
||
int read(int, void*, int);
|
||
......
|
||
```
|
||
|
||
这些函数又是怎么实现的呢?xv6-k210使用了perl语言脚本(xv6-user/usys.pl),重定向输出生成一个汇编源文件,这些函数就是在其中实现的。
|
||
不需要perl语言的知识,也能大致理解该文件中代码的含义。文件中部分代码如下:
|
||
|
||
```perl
|
||
print "#include \"kernel/include/syscall.h\"\n";
|
||
|
||
sub entry {
|
||
my $name = shift;
|
||
print ".global $name\n";
|
||
print "${name}:\n";
|
||
print " li a7, SYS_${name}\n";
|
||
print " ecall\n";
|
||
print " ret\n";
|
||
}
|
||
|
||
entry("fork");
|
||
entry("exit");
|
||
entry("wait");
|
||
......
|
||
```
|
||
|
||
其中`sub`就是perl语言中的函数定义。这里不深究perl语言的语法含义,只需知道,在xv6-k210编译时,
|
||
通过将***print***函数的输出重定向至一个汇编源文件(具体可查看Makefile),该文件中将会生成若干类似下述的汇编片段:
|
||
|
||
```
|
||
#include "kernel/include/syscall.h"
|
||
.global fork
|
||
fork:
|
||
li a7, SYS_fork
|
||
ecall
|
||
ret
|
||
.global exit
|
||
exit:
|
||
li a7, SYS_exit
|
||
ecall
|
||
ret
|
||
......
|
||
```
|
||
|
||
这就实现了xv6-user/user.h中声明的函数。此处包含了kernel/include/syscall.h文件,因为该文件中宏定义了各个系统调用的调用号。
|
||
|
||
```C
|
||
// System call numbers
|
||
#define SYS_fork 1
|
||
#define SYS_exit 2
|
||
#define SYS_wait 3
|
||
......
|
||
```
|
||
|
||
综上所述,我们知道xv6-k210为用户程序提供了`ecall`指令的封装,使得用户程序可以很方便地请求系统调用。
|
||
这里可能会有疑问,系统调用需要的参数该怎么办呢?RISC-V体系提供了数量不少的寄存器,函数调用所需的参数通常通过寄存器传递。
|
||
后续内容会提到,用户进程在trap后会在进程控制块中保存寄存器现场,系统调用的参数也就存放在里头,可以被访问获取。
|
||
|
||
## 二、特权态的转换的处理
|
||
|
||
RISC-V的处理器在发生trap后会自动完成若干行为(详见[此处](/doc/内核原理-系统调用.md)),
|
||
但硬件并没有完成我们所需的所有操作,例如寄存器现场保护,还需进一步通过软件方式实现。
|
||
根据xv6-k210的[页表与内存映射](/doc/内核原理-内存管理.md)机制,我们知道,
|
||
在用户程序虚拟地址空间的最高地址中,映射了`TRAMPOLINE`和`TRAPFRAME`两个特殊的页面,这两个页面与特权态的转换相关。
|
||
`TRAMPOLINE`对应的物理页中,存放着一些代码(见[kernel/trampoline.S](/kernel/trampoline.S)),包括`uservec`和`userret`两个函数。
|
||
其中,`uservec`函数的入口地址存放在`stvec`寄存器中,也就是S模式trap处理函数的入口地址寄存器。
|
||
当执行`ecall`指令后,`pc`会被设置为`stvec`中的值,系统就会进入`uservec`函数。该函数主要有以下工作:
|
||
|
||
1. 在用户进程的`TRAPFRAME`中,保存用户进程的工作空间(即各个寄存器的内容);
|
||
2. 加载`TRAPFRAME`中保存的内核所需寄存器内容,如内核栈栈顶、内核态页表等。
|
||
|
||
随后,该函数通过`jr`指令跳转到`usertrap`函数(定义在[kernel/trap.c](/kernel/trap.c)中)。Trampoline为“蹦床”之意,由此看来还挺形象的。
|
||
|
||
## 三、Syscall的执行
|
||
|
||
在`usertrap`函数中进行一些后续操作后,可看到以下代码片段:
|
||
|
||
```C
|
||
......
|
||
struct proc *p = myproc();
|
||
p->trapframe->epc = r_sepc();
|
||
if (r_scause() == 8) {
|
||
if (p->killed)
|
||
exit(-1);
|
||
p->trapframe->epc += 4;
|
||
intr_on();
|
||
syscall();
|
||
}
|
||
......
|
||
```
|
||
|
||
这里首先获取该用户进程的进程控制块,该结构中也有对其`TRAPFRAME`的引用。发生trap时,寄存器`pc`的值已经保存在`sepc`中,
|
||
这里再将其保存在进程的`TRAPFRAME`中(以防再次发生trap)。随后读取S模式CSRs中的`scause`寄存器,其值为trap产生的原因。
|
||
当该值为8时,对应的就是syscall。由于syscall返回时是回到`ecall`指令的下一条指令,因此这里将`epc`的值加4。
|
||
至此,已经明确不是外设中断,可以通过`intr_on`将中断打开。
|
||
接下来进入系统调用的处理函数`syscall`(在[kernel/syscall.c](/kernel/syscall.c)中定义),函数体如下:
|
||
|
||
```C
|
||
void syscall(void)
|
||
{
|
||
int num;
|
||
struct proc *p = myproc();
|
||
num = p->trapframe->a7;
|
||
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
|
||
p->trapframe->a0 = syscalls[num]();
|
||
} else {
|
||
printf("%d %s: unknown sys call %d\n", p->pid, p->name, num);
|
||
p->trapframe->a0 = -1;
|
||
}
|
||
}
|
||
```
|
||
|
||
该函数首先获取当前进程的进程控制块,其`TRAPFRAME`中保存的`a7`寄存器的值就是请求的系统调用号。
|
||
检测该值的合法性后,调用相应的函数,函数返回值直接写入`TRAPFRAME`的`a0`寄存器。`a0`寄存器是RISC-V中约定的返回值寄存器,
|
||
当返回用户态时,通过该寄存器就可以获得系统调用的返回结果,就如同普通的函数调用一样。
|
||
|
||
注意到上述代码中有一个`syscalls[]`数组,这就是所谓的系统调用表,实际上就是一个函数指针数组,指向各个系统调用的功能函数。
|
||
该数组和其中的内容也是在[kernel/syscall.c](/kernel/syscall.c)中声明,
|
||
而系统调用号在[kernel/include/syscall.h](/kernel/include/syscall.h)中声明,部分内容如下:
|
||
|
||
```C
|
||
extern uint64 sys_fork(void);
|
||
extern uint64 sys_exit(void);
|
||
extern uint64 sys_wait(void);
|
||
......
|
||
|
||
static uint64 (*syscalls[])(void) = {
|
||
[SYS_fork] sys_fork,
|
||
[SYS_exit] sys_exit,
|
||
[SYS_wait] sys_wait,
|
||
......
|
||
};
|
||
```
|
||
|
||
我们注意到,这里的函数都是无参数的,而有的系统调用是需要参数的,如何解决这一问题呢?
|
||
实际上,参数的获取可以由各功能函数自行完成,[kernel/syscall.c](/kernel/syscall.c)中也提供了相关的辅助函数(如`argint`)。
|
||
在需要参数的系统调用功能函数中,通过`myproc`函数获取进程的控制块,其`TRAPFRAME`中保存中断时各通用寄存器的值,
|
||
对通过寄存器传递的参数,可直接在其中找到;若参数是指针值,还需要其指向的实际内容,则先通过进程控制块中保存的页表起始地址,
|
||
找到进程的页表,再由页表将该指针值(虚拟地址)转换成物理地址,即可找到其实际指向的内容,完成系统调用的具体功能。
|
||
|
||
## 四、返回用户模式
|
||
|
||
系统功能调用完成后,从`syscall`函数返回到`usertrap`函数,该函数进行其他操作后,调用`usertrapret`(也定义在kernel/trap.c中)。
|
||
这一函数则为返回U模式做准备,包括设置S模式部分CSRs寄存器、在进程`TRAPFRAME`中保存进程在S模式下所需寄存器的值、重设`epc`寄存器等,
|
||
最后,需要调用kernel/trampoline.S中的另一个函数——`userret`,其需要两个参数,一个是`TRAPFRAME`在用户空间的虚拟地址(由宏定义),
|
||
另一个是用户进程的页表在内核中的地址(已经格式化为正确形式)。
|
||
|
||
在跳转到`userret`函数后,首先将用户页表还原至页表寄存器`satp`,并刷新TLB。之后就可以使用虚拟地址,
|
||
将`TRAPFRAME`中保存的各个寄存器还原至对应寄存器,最后使用`sret`指令,返回到用户模式,一个Syscall到这里就完成了。
|
||
|
||
|
||
<br>
|
||
<br>
|