xv6操作系统系统调用过程解析
我们都知道,unix操作系统有用户模式和内核模式,用户空间中运行的程序在用户模式下,内核空间运行的程序在内核模式下,操作系统位于内核空间。其中操作系统负责连接用户程序和下层硬件,所以操作系统会接收来自用户程序的请求或者来自硬件的响应,具有对硬件的完全访问权限。用户模式则被限制,不能之间访问磁盘,内存等硬件,因为这样可能会导致系统崩溃。而此时,操作系统就作为了硬件与用户程序的中间层,进行资源分配,统一接口的作用。
但是用户进程运行在用户态,操作系统运行在内核态,二者的界限是一个硬性的界限,由于用户态运行的程序存在权限问题,那么用户态的程序如何借助内核态的程序与硬件沟通或者执行一些内核态下才能执行的指令呢?答案就是用户态进程进行系统调用。
系统调用是操作系统封装起来后提供给用户层的接口,例如在xv6操作系统中,就有如下系统调用:
c
//user.h
// system calls
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);
int close(int);
int kill(int);
int exec(char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);
有了这些接口,用户态的程序就可以找到系统调用的函数。在用户态程序找到上述函数声明之后,就会找到usys.pl
中生成的包装函数,这些包装函数提供一个和普通C函数一样的接口,使得用户程序可以直接调用。
perl
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");
entry("pipe");
entry("read");
entry("write");
entry("close");
entry("kill");
entry("exec");
entry("open");
entry("mknod");
entry("unlink");
entry("fstat");
entry("link");
entry("mkdir");
entry("chdir");
entry("dup");
entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");
我们可以看到这些跳转到内核中的跳转函数都调用了一个ECALL指令,ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字到a7寄存器中。这里的数字参数代表了应用程序想要调用的System Call。
ECALL指令就做了以下几件事,首先将CPU切换到内核态,然后记录陷入内核态的原因和程序计数器等到特定寄存器,最后加载固定的从用户态切换到内核态的处理函数的指令地址到PC寄存器,经过调试代码发现,执行ECALL之后就执行到了内核的trampoline.S
这个汇编文件,看代码注释可知,这段汇编代码的作用就是保存了用户程序的寄存器到该程序的trapframe
中,便于执行完系统调用之后再恢复寄存器。之后这个汇编代码会将PC寄存器的值设置为trap.c
文件中的usertrap
函数。
在这个函数中,由于我们执行ecall时已经设置了陷入内核的原因,所以我们会走到以下分支:
c
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// 这里时为了返回之后可以执行ecall的下一跳指令,而不是重新执行ecall,陷入死循环
p->trapframe->epc += 4;
// 只有在关键寄存器操作完成后,才调用 intr_on(),避免中断期间寄存器状态被破坏。
intr_on();
syscall();
}
// Supervisor Trap Cause
static inline uint64
r_scause()
{
uint64 x;
asm volatile("csrr %0, scause" : "=r" (x) );
return x;
}
在这段逻辑中会走到syscall.c
文件中的syscall()
函数中,这是内核态统一系统调用处理函数 syscall(),所有系统调用都会跳到这里来处理。syscall() 根据跳板传进来的系统调用编号,这里就是我们之前在跳板函数中设置的a7寄存器的值,查询 syscalls[] 表,找到对应的内核函数并调用,注意我们要将返回值记录到a0寄存器。
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;
}
}
最后调用syscalls[num]()
时会走到sysproc.c
文件中找到对应的系统调用实现。这么繁琐的调用流程的主要目的是实现用户态和内核态的良好隔离。
执行完系统调用之后就会返回到usertrap
函数中,准备从内核空间返回到用户空间。
c
usertrapret();
这个函数就是将trapframe 里的内容(即用户态的寄存器、程序计数器等)恢复到 CPU。在返回用户态前,切换到用户进程自己的页表,保证用户程序只能访问自己的内存空间。跳转到 trampoline 区域,执行 sret,CPU 切换回用户态,进程从 ecall 的下一条指令继续执行。
这就是整个系统调用的过程了。