xv6-k210/doc/构建调试-系统调用.md

7.9 KiB
Raw Permalink Blame History

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的页表与内存映射机制,我们知道, 在用户程序虚拟地址空间的最高地址中,映射了TRAMPOLINETRAPFRAME两个特殊的页面,这两个页面与特权态的转换相关。 TRAMPOLINE对应的物理页中,存放着一些代码(见kernel/trampoline.S),包括uservecuserret两个函数。 其中,uservec函数的入口地址存放在stvec寄存器中也就是S模式trap处理函数的入口地址寄存器。 当执行ecall指令后,pc会被设置为stvec中的值,系统就会进入uservec函数。该函数主要有以下工作:

  1. 在用户进程的TRAPFRAME中,保存用户进程的工作空间(即各个寄存器的内容);
  2. 加载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寄存器的值就是请求的系统调用号。 检测该值的合法性后,调用相应的函数,函数返回值直接写入TRAPFRAMEa0寄存器。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到这里就完成了。