系统调用 (System Call)

应用程序很多时候需要内核提供一些服务,而这些服务的管理必须通过内核完成,比如读取、写入一个文件,因此,应用程序需要主动地通过同步的方式进入内核,这个方式称为系统调用。

RISCV 中系统调用的实现

在 RISCV 中,系统调用是通过 ecall 指令来实现的。ecall 会产生一个异常,scause 中体现为 Environment call from U-mode。当我们在 trap 时识别到了这个异常,我们就知道这是应用发起了一个系统调用。

不过,仅仅通过 scause,我们只能知道发生了一个系统调用,却无法知道系统调用具体要做什么。通常情况下,我们会利用通用寄存器来传递参数。系统调用的形式和普通的函数调用类似,因此我们一般也会遵守 calling convention 来传递参数。

我们知道 RISCV calling convention 规定了传递参数是通过 a0-7 实现的。此外,我们注意到不同的需求会产生不同的系统调用,比如将一些数据写入文件需要提供足以确定文件的信息以及数据,而建立目录则只需要目录名即可(可能还要加上权限)。因此,我们需要一个系统调用编号来区分不同的系统调用。

我们不妨将系统调用号放在 a0 寄存器中,将其他参数放在 a1-7 中。这样,我们就可以通过 a7 来识别系统调用的类型,通过 a0-6 来传递参数。(当然,我们也可以将系统调用号放在 a1 中,这样就可以使用 a1-7 来传递参数。)

因此,在用户态发起系统调用时,我们需要将系统调用号放在 a7 中,将参数放在 a0-6 中,然后执行 ecall 指令。

ecall 会触发异常,进入到 trampoline 中的 user_trap 函数,user_trap 函数会将通用寄存器保存在 trap context 中,因此 a0-7 的值也会被保存在此处。

user_trap 最后会调用内核中的函数(通常这个函数是由高级语言编写的),来进一步处理函数。这个函数在上一节已经讨论过了。这个函数会识别到这是一个系统调用,然后通过系统调用编号找到合适的处理函数。

系统调用的返回值通常是通过 a0 传递的。因此,我们在处理完系统调用后,将返回值放在 a0 中(其实是 trap context 中),然后返回到用户态。

大数据传递

我们注意到系统调用的参数主要是通过寄存器传递的。不过,有时候,我们需要传输很多数据,比如写入文件。这时候,我们可以通过内存来传递数据。

注意到按照我们的实现,内核和用户的页表是不同的,而且用户的数据可能是跨页的,这时我们就无法很方便地读取数据。一个常规的实现是内核切换到一个临时的页表,然后将用户的数据拷贝到内核的内存中,或者将内核的数据拷贝到用户的内存中,然后再进行处理。通常实现这两个过程的函数叫做 copy_from_usercopy_to_user

除了上面所说的实现,其实还有很多种实现方式,比如在用户地址空间中加若干个用于传递数据的页(其实一个页基本就够了,如果不止一个页,则需要保证在内核中这些页的地址是连续的)。对于从用户地址传输到内核地址空间的情况,我们需要在系统调用前将数据拷贝到这个页中;对于从内核地址空间传输到用户地址空间时,我们需要在系统调用返回前将数据拷贝到用户的内存中。

系统调用库

为了方便编写用户程序,通常我们需要提供一个系统调用的库,这个库会封装系统调用的细节,提供更加友好的接口。比如,我们可以提供一个 write 函数,这个函数会将数据写入到文件中,而不需要用户关心系统调用的细节。