7.9 KiB
Syscall在xv6-k210中的实现与执行
Syscall是如何实现与执行的呢?以下从用户程序出发,分析在xv6-k210中系统调用的过程。
一、Syscall的封装与使用
使用C语言编写用户程序时,可以直接编写内联汇编的形式,执行ecall
指令,但这不够美观便捷。
xv6-k210将系统调用的汇编指令段进行了封装,提供了一个C函数的接口,在用户程序中,可以直接像使用普通函数一样调用,
包括传递参数和接收返回值。
在xv6-user/user.h中,声明了各个Syscall封装后的函数接口。在用户程序中包含该头文件,即可使用其中的函数。
// 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语言的知识,也能大致理解该文件中代码的含义。文件中部分代码如下:
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文件,因为该文件中宏定义了各个系统调用的调用号。
// System call numbers
#define SYS_fork 1
#define SYS_exit 2
#define SYS_wait 3
......
综上所述,我们知道xv6-k210为用户程序提供了ecall
指令的封装,使得用户程序可以很方便地请求系统调用。
这里可能会有疑问,系统调用需要的参数该怎么办呢?RISC-V体系提供了数量不少的寄存器,函数调用所需的参数通常通过寄存器传递。
后续内容会提到,用户进程在trap后会在进程控制块中保存寄存器现场,系统调用的参数也就存放在里头,可以被访问获取。
二、特权态的转换的处理
RISC-V的处理器在发生trap后会自动完成若干行为(详见此处),
但硬件并没有完成我们所需的所有操作,例如寄存器现场保护,还需进一步通过软件方式实现。
根据xv6-k210的页表与内存映射机制,我们知道,
在用户程序虚拟地址空间的最高地址中,映射了TRAMPOLINE
和TRAPFRAME
两个特殊的页面,这两个页面与特权态的转换相关。
TRAMPOLINE
对应的物理页中,存放着一些代码(见kernel/trampoline.S),包括uservec
和userret
两个函数。
其中,uservec
函数的入口地址存放在stvec
寄存器中,也就是S模式trap处理函数的入口地址寄存器。
当执行ecall
指令后,pc
会被设置为stvec
中的值,系统就会进入uservec
函数。该函数主要有以下工作:
- 在用户进程的
TRAPFRAME
中,保存用户进程的工作空间(即各个寄存器的内容); - 加载
TRAPFRAME
中保存的内核所需寄存器内容,如内核栈栈顶、内核态页表等。
随后,该函数通过jr
指令跳转到usertrap
函数(定义在kernel/trap.c中)。Trampoline为“蹦床”之意,由此看来还挺形象的。
三、Syscall的执行
在usertrap
函数中进行一些后续操作后,可看到以下代码片段:
......
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中定义),函数体如下:
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/include/syscall.h中声明,部分内容如下:
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中也提供了相关的辅助函数(如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到这里就完成了。