xv6-k210/doc/syscall.md

11 KiB
Raw Blame History



System Call

System Call即系统调用以下简称syscall用于在用户程序中执行一些特权模式下才能完成的操作如I/O操作。 对于处理器而言syscall是一种同步发生的事件在基于RISC-V的操作系统中由ecall指令Environment Call产生。 当系统调用事件结束后处理器将返回到ecall指令的下一条指令继续执行。

原版xv6的文档将“system call”、“exception”和“(device) interrupt”统称为“trap”。这可能与我们平常使用的术语有一定出入 本文档使用“中断”一词时在原版xv6的文档的相应语境中对应的是“trap”。

在RISC-V中请求系统调用

在RISC-V的用户程序中使用ecall指令请求syscall。操作系统提供不同功能的syscall对应不同的请求调用号。 因此请求前,需要向a7寄存器中写入所请求的syscall的调用号。以下为一个请求8号调用的例子

li  a7, 8
ecall

系统调用的处理过程

Syscall属于中断涉及到特权态的转换。对于用户程序而言一般是从用户模式U模式陷入到监管模式S模式。 对于不同类型的中断我们知道操作系统中有中断向量表保存不同类型的中断例程的入口syscall的中断处理例程自然也在其中。 而对于不同类型的调用请求syscall的处理例程也有相应的系统调用表保存不同类型的系统调用功能函数的入口。 根据用户事先写入寄存器的系统调用号syscall的处理例程调用相应的功能函数完成系统调用随后返回至原先的特权态。



Syscall在xv6-k210中的实现与执行

Syscall是如何实现与执行的呢以下从用户程序出发分析在xv6-k210中系统调用的过程。

一、Syscall的封装与使用

使用C语言编写用户程序时可以直接编写内联汇编的形式执行ecall指令,但这不够美观便捷。 xv6-k210将系统调用的汇编指令段进行了封装提供了一个C函数的接口在用户程序中可以直接像使用普通函数一样调用 包括传递参数和接收返回值。

在xv6-uesr/uesr.h中声明了各个Syscall封装后的函数接口。在用户程序中包含该头文件即可使用其中的函数。这些函数又是怎么实现的呢 xv6-k210使用了perl语言脚本xv6-uesr/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-uesr/uesr.h中声明的函数。此处包含了kernel/include/syscall.h文件因为该文件中宏定义了各个系统调用的调用号。

// System call numbers
#define SYS_fork    1
#define SYS_exit    2
#define SYS_wait    3
......

综上所述我们知道xv6-k210为用户程序提供了ecall指令的封装,使得用户程序可以很方便地请求系统调用。

二、特权态的转换过程

执行ecall指令后,系统中发生了什么呢?其实不仅是系统调用,各类中断发生后,首先都会经过这一过程——特权态的转换。 在RISC-V中各个特权级都有一组状态与控制寄存器CSRs当在U模式下中断发生时除了时钟中断RISC-V硬件系统会自动完成下列操作

  1. 如果发生的中断是设备中断,且sstatus寄存器的SIE位为0则不继续下列的操作
  2. 关闭外部设备中断,即将sstatus寄存器的SIE位设为0
  3. 拷贝pc寄存器的值至sepc寄存器;
  4. 保存当前的特权态信息至sstatus寄存器的SPP字段;
  5. 设置scause寄存器,其值表示中断发生的原因;
  6. 将特权态设置为S模式
  7. stvec寄存器的值存入pc寄存器(stvec寄存器存放S模式的中断处理函数的入口地址

但硬件并没有完成我们所需的所有操作,还需进一步通过软件方式实现。 根据xv6-k210的页表与内存映射机制,我们知道, 在用户程序虚拟地址空间的最高地址中,映射了TRAMPOLINETRAPFRAME两个特殊的页面,这两个页面与特权态的转换相关。 TRAMPOLINE对应的物理页中存放着一些代码见kernel/trampoline.S包括uservecuserret两个函数。 其中,uservec函数的入口地址存放在stvec寄存器中也就是S模式中断处理函数的入口地址寄存器。 当执行ecall指令后,pc会被设置为stvec中的值,系统就会进入uservec函数。该函数主要有以下工作:

  1. 在用户进程的TRAPFRAME中,保存用户进程的工作空间(即各个寄存器的内容);
  2. 加载TRAPFRAME中保存的内核所需寄存器内容,如内核栈栈顶、内核态页表等。

随后,该函数通过jr指令(说明不返回)跳转到uesrtrap函数定义在kernel/trap.c中

三、Syscall的执行

uesrtrap函数中进行一些后续操作后,可看到以下代码片段:

......
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的引用。发生中断时,寄存器pc的值已经保存在sepc中, 这里再将其保存在进程的TRAPFRAME中。随后读取S模式CSRs中的scause寄存器,其值为中断产生的原因。 当该值为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中也提供了相关的辅助函数。 在需要参数的系统调用功能函数中,通过myproc函数获取进程的控制块,其TRAPFRAME中保存中断时各通用寄存器的值, 对通过寄存器传递的参数,可直接在其中找到;若参数是指针值,还需要其指向的实际内容,则先通过进程控制块中保存的页表起始地址, 找到进程的页表,再由页表将该指针值(虚拟地址)转换成物理地址,即可找到其实际指向的内容,完成系统调用的具体功能。

四、返回用户模式

系统功能调用完成后,从syscall函数返回到uesrtrap函数,该函数进行其他操作后,调用usertrapret也定义在kernel/trap.c中。 这一函数则为返回U模式做准备包括设置S模式部分CSRs寄存器、在进程TRAPFRAME中保存进程在S模式下所需寄存器的值、重设epc寄存器等, 最后需要调用kernel/trampoline.S中的另一个函数——userret,其需要两个参数,一个是TRAPFRAME在用户空间的虚拟地址(由宏定义), 另一个是用户进程的页表在内核中的地址(已经格式化为正确形式)。

在跳转到userret函数后,首先将用户页表还原至页表寄存器satp并刷新TLB。之后就可以使用虚拟地址TRAPFRAME中保存的各个寄存器还原至对应寄存器,最后使用sret指令返回到用户模式一个Syscall到这里就完成了。



如何在xv6-k210中添加syscall

了解了Syscall在xv6-k210中的执行过程为系统添加一个新的syscall功能就不是什么难事了。添加新系统调用的步骤如下

  1. 在xv6-uesr目录下

    • 在uesr.h文件中添加新系统调用封装后的函数声明假设其函数名为mysyscall

    • 在usys.pl文件末尾添加如下行

      ......
      entry("mysyscall");
      
  2. 在kernel目录下

    • 在include/syscall.h文件中添加新系统调用号的宏定义

      ......
      #define SYS_mysyscall ?
      

      其中,“?”为新的合法系统调用号,本质上是一个数组下标,可根据需要设置,建议按顺序递增添加。

    • 在syscall.c文件中添加功能函数的声明并更新系统调用表

      ......
      extern uint64 sys_mysyscall(void);
      
      static uint64 (*syscalls[])(void) = {
          ......
          [SYS_mysyscall]    sys_mysyscall,
      };
      
      
  3. 根据该系统调用的功能,选择一个适合的内核模块的源文件,在其中实现sys_mysyscall函数的功能。 例如与文件相关的系统调用可以在kernel/sysfile.c中添加。 可以利用syscall.c文件中提供的相关函数sys_mysyscall中获取用户进程传递的参数。



参考文档