前言

MIT6.S081 项目中的许多实验都涉及到了系统调用(System Call)的使用,我在实验过程中往往依葫芦画瓢在原有的System Call基础上使用,没有真正理解System Call的流程,本篇笔记打算sys_sleep为例子梳理一下Xv6系统中 System Call的调用流程。

一、用户态(/user/)中的系统调用

我们可以在/user/user.h中找到sleep函数的函数声明,却无法找到sleep函数的函数定义

实际上我们可以在user/usys.pl中找到除了函数调用以外sleep函数的出现,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// user/usys.pl
#!/usr/bin/perl -w

# Generate usys.S, the stubs for syscalls.

print "# generated by usys.pl - do not edit\n";

print "#include \"kernel/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("sleep");
entry("exit");
....
//剩余代码都是entry("system call")的格式,故省略

/* 代码含义解释来自于chatgpt3.5
这段代码是用 Perl 编写的脚本,用于生成 `usys.S` 文件,其中包含系统调用的存根(stubs)。我来解释一下代码的含义:

1. `#!/usr/bin/perl -w`:这是脚本的 shebang 行,指示系统使用 Perl 解释器执行该脚本。

2. `print "# generated by usys.pl - do not edit\n";`:打印一条注释,说明该文件是由 `usys.pl` 生成的,不应手动编辑。

3. `print "#include \"kernel/syscall.h\"\n";`:打印 `#include` 指令,将 `kernel/syscall.h` 文件包含进来,该文件可能包含了系统调用的相关定义。

4. `sub entry { ... }`:定义了一个名为 `entry` 的子例程,用于生成系统调用的存根。

5. 在 `entry` 子例程中,对于每个传入的系统调用名,都生成了相应的存根。这些存根包括以下几个步骤:
- `.global $name`:将该系统调用名声明为全局符号。
- `${name}:`:定义一个标签,表示系统调用的起始点。
- `li a7, SYS_${name}`:将系统调用号加载到寄存器 a7 中。这里假设 `SYS_${name}` 是一个宏,用于获取该系统调用的编号。
- `ecall`:触发系统调用。
- `ret`:返回。

6. 最后,对于每个系统调用,都调用了 `entry` 子例程,传入相应的系统调用名,以生成相应的存根。

总体来说,这段代码的作用是生成一系列系统调用的存根,这些存根可以用于在特定的环境中进行系统调用。
*/

二、生成的usys.S文件中的汇编语言

通过user/usys.pl的代码我们可以推测其为每一个系统调用(System Call)都进行了entry操作,生成到了user/usys.S文件中,该文件每个系统调用都对应三行汇编语言,我只复制了与sleep相关的代码

1
2
3
4
5
6
7
# generated by usys.pl - do not edit
#include "kernel/syscall.h"
.global sleep
sleep:
li a7, SYS_sleep
ecall
ret

#include "kernel/syscall.h"代表汇编文件前插入了kernel/syscall.h,我们在遇到不懂的内容时,应该去该文件查看寻找问题的答案。

1.li a7, SYS_sleep

这行代码的含义是将系统调用号 SYS_sleep 加载到寄存器 a7 中。在 RISC-V 架构中,寄存器 a7 通常被用作系统调用号的参数寄存器。因此,这行代码的目的是将 SYS_sleep 这个系统调用的编号加载到 a7 寄存器,以便在调用系统调用时使用。

SYS_sleep的值可以在kernel/syscall.h中找到,为13。该文件给每一个system call都对应了一个数字。

1
2
3
4
5
...
#define SYS_sbrk 12
#define SYS_sleep 13
#define SYS_uptime 14
...

2.ecall(该指令为CPU指令)

Lec06 Isolation & system call entry/exit (Robert)课程中我们可以学习到ecall指令的作用(ecall内容的笔记基本摘抄自课程翻译,不理解可以去看原课程):

2.1 ecall将代码从user mode改到supervisor mode

注意:作为代码编写者的我们并没有办法能直接分辨系统处于user mode还是supervisor mode,下图为PTE结构,Flag中存在标志位U,xv6系统标志位U为1的时候,只有user mode下能访问而supervisor mode不能访问,我们通过这个方法来间接分辨系统处于什么状态。

2.2 ecall将程序计数器的值保存在了SEPC寄存器

SEPC寄存器保存的值将在 supervisor mode 返回至 user mode时发挥作用

2.3 ecall会跳转到STVEC寄存器指向的指令(uservec函数)

所以现在,ecall帮我们做了一点点工作,但是实际上我们离执行内核中的C代码还差的很远。接下来:

  • 我们需要保存32个用户寄存器的内容,这样当我们想要恢复用户代码执行时,我们才能恢复这些寄存器的内容。
  • 因为现在我们还在user page table,我们需要切换到kernel page table。
  • 我们需要创建或者找到一个kernel stack,并将Stack Pointer寄存器的内容指向那个kernel stack。这样才能给C代码提供栈。
  • 我们还需要跳转到内核中C代码的某些合理的位置。

ecall并不会为我们做这里的任何一件事(以上四点的详细实现过程在三、uservec上)。

2.3.1为什么ecall指令在xv6做的事情很少?(摘抄自课程翻译)

当然,我们可以通过修改硬件让ecall为我们完成这些工作,而不是交给软件来完成。并且,我们也将会看到,在软件中完成这些工作并不是特别简单。所以你现在就会问,为什么ecall不多做点工作来将代码执行从用户空间切换到内核空间呢?为什么ecall不会保存用户寄存器,或者切换page table指针来指向kernel page table,或者自动的设置Stack Pointer指向kernel stack,或者直接跳转到kernel的C代码,而不是在这里运行复杂的汇编代码?

实际上,有的机器在执行系统调用时,会在硬件中完成所有这些工作。但是RISC-V并不会,RISC-V秉持了这样一个观点:ecall只完成尽量少必须要完成的工作,其他的工作都交给软件完成。这里的原因是,RISC-V设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。所以你可以这样想,尽管XV6并没有使用这里提供的灵活性,但是一些其他的操作系统用到了。

  • 举个例子,因为这里的ecall是如此的简单,或许某些操作系统可以在不切换page table的前提下,执行部分系统调用。切换page table的代价比较高,如果ecall打包完成了这部分工作,那就不能对一些系统调用进行改进,使其不用在不必要的场景切换page table。
  • 某些操作系统同时将user和kernel的虚拟地址映射到一个page table中,这样在user和kernel之间切换时根本就不用切换page table。对于这样的操作系统来说,如果ecall切换了page table那将会是一种浪费,并且也减慢了程序的运行。
  • 或许在一些系统调用过程中,一些寄存器不用保存,而哪些寄存器需要保存,哪些不需要,取决于于软件,编程语言,和编译器。通过不保存所有的32个寄存器或许可以节省大量的程序运行时间,所以你不会想要ecall迫使你保存所有的寄存器。
  • 最后,对于某些简单的系统调用或许根本就不需要任何stack,所以对于一些非常关注性能的操作系统,ecall不会自动为你完成stack切换是极好的。

所以,ecall尽量的简单可以提升软件设计的灵活性

2.3.2 为什么我们在gdb中看不到ecall的具体内容?(课堂学生提问)

ecall只会更新CPU中的mode标志位为supervisor,并且设置程序计数器成STVEC寄存器内的值。在进入到用户空间之前,内核会将trampoline page的地址存在STVEC寄存器中。所以ecall的下一条指令的位置是STVEC指向的地址,也就是trampoline page的起始地址。(注,实际上ecall是CPU的指令,自然在gdb中看不到具体内容)

3.ret

返回到用户态程序原本的运行位置,PC值从SEPC寄存器中读取

三、trampoline page

在ecall函数执行完后,程序pc值指向了uservec函数,该函数位于trampoline page的起始,仍处于 user page table中(意味着页表没有发生切换,该页表上没有处理system call 的内核代码,需要我们切换到kernel page table上)

1.trampoline 映射时的特别之处

kernel page table中,trampoline使用直接映射,映射虚拟地址最高处为trampoline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// map the trampoline page to the highest address,
// in both user and kernel space.
#define TRAMPOLINE (MAXVA - PGSIZE)

void
kvminit()
{
kernel_pagetable = (pagetable_t) kalloc();
memset(kernel_pagetable, 0, PGSIZE);
...
...
...
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

而在为进程分配user page table时,虚拟地址的最高处同样映射为Trampoline,同时PTE_U为复位(0),表明只有supervisor可以使用它,并且映射trampoline时使用的物理地址与kernel page table使用的物理地址一致,这意味着:**kernel page tableuser page table的虚拟地址的最高处映射了同一个Trampoline**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Create a user page table for a given process,
// with no user memory, but with trampoline pages.
pagetable_t
proc_pagetable(struct proc *p)
{
pagetable_t pagetable;

// An empty page table.
pagetable = uvmcreate();
if(pagetable == 0)
return 0;

// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.
if(mappages(pagetable, TRAMPOLINE, PGSIZE,
(uint64)trampoline, PTE_R | PTE_X) < 0){
uvmfree(pagetable, 0);
return 0;
}

// map the trapframe just below TRAMPOLINE, for trampoline.S.
if(mappages(pagetable, TRAPFRAME, PGSIZE,
(uint64)(p->trapframe), PTE_R | PTE_W) < 0){
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmfree(pagetable, 0);
return 0;
}

return pagetable;
}

// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() couldn't
// allocate a needed page-table page.
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm);

2.Uservec函数

uservec 为汇编代码,代码可以在kernel/trampoline.S中查看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
.globl uservec
uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# sscratch points to where the process's p->trapframe is
# mapped into user space, at TRAPFRAME.
#

# swap a0 and sscratch
# so that a0 is TRAPFRAME
csrrw a0, sscratch, a0

# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
sd tp, 64(a0)
sd t0, 72(a0)
sd t1, 80(a0)
sd t2, 88(a0)
sd s0, 96(a0)
sd s1, 104(a0)
sd a1, 120(a0)
sd a2, 128(a0)
sd a3, 136(a0)
sd a4, 144(a0)
sd a5, 152(a0)
sd a6, 160(a0)
sd a7, 168(a0)
sd s2, 176(a0)
sd s3, 184(a0)
sd s4, 192(a0)
sd s5, 200(a0)
sd s6, 208(a0)
sd s7, 216(a0)
sd s8, 224(a0)
sd s9, 232(a0)
sd s10, 240(a0)
sd s11, 248(a0)
sd t3, 256(a0)
sd t4, 264(a0)
sd t5, 272(a0)
sd t6, 280(a0)

# save the user a0 in p->trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)

# restore kernel stack pointer from p->trapframe->kernel_sp
ld sp, 8(a0)

# make tp hold the current hartid, from p->trapframe->kernel_hartid
ld tp, 32(a0)

# load the address of usertrap(), p->trapframe->kernel_trap
ld t0, 16(a0)

# restore kernel page table from p->trapframe->kernel_satp
ld t1, 0(a0)
csrw satp, t1
sfence.vma zero, zero

# a0 is no longer valid, since the kernel page
# table does not specially map p->tf.

# jump to usertrap(), which does not return
jr t0

2.1 csrrw a0, sscratch, a0

在进入到user space之前,内核会将trapframe page的地址保存在SSCRATCH寄存器中,也就是0x3fffffe000这个地址。

csrrw指令,交换了a0与sscratch寄存器的值,现在a0寄存器中存储的是trapframe page的地址,其在user page table

中被映射到了Trampoline的下方。

问题:当与a0寄存器进行交换时,trapframe的地址是怎么出现在SSCRATCH寄存器中的?

在内核前一次切换回用户空间时,内核会执行set sscratch指令,将这个寄存器的内容设置为0x3fffffe000,也就是trapframe page的虚拟地址。所以,当我们在运行用户代码,比如运行Shell时,SSCRATCH保存的就是指向trapframe的地址。之后,Shell执行了ecall指令,跳转到了trampoline page,这个page中的第一条指令会交换a0和SSCRATCH寄存器的内容。所以,SSCRATCH中的值,也就是指向trapframe的指针现在存储与a0寄存器中

深入提问:这是发生在进程创建的过程中吗?这个SSCRATCH寄存器存在于哪?

这个寄存器存在于CPU上,这是CPU上的一个特殊寄存器。内核在什么时候设置的它呢?这有点复杂。它被设置的实际位置,我们可以看下kernel/trampoline.S末尾的代码

1
2
3
4
5
6
7
8
9
10
// kernel/trampoline.S


# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0

# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret

代码是内核在返回到用户空间之前执行的最后两条指令。在内核返回到用户空间时,会恢复所有的用户寄存器。之后会再次执行交换指令,csrrw。**因为之前内核已经设置了a0保存的是trapframe地址(注意:从uservec到trampoline代码结尾过程中执行了诸如usertrap、usertrapret等代码,那么a0寄存器一定不是之前csrrw a0, sscratch, a0代码修改后的a0值)**,经过交换之后SSCRATCH仍然指向了trapframe page地址,而a0也恢复成了之前的数值。

最后sret返回到了用户空间

a0寄存器中的值是怎么来的?

查看kernel/trap.cusertrapret函数的最后两行

1
2
3
4
5
6
  // jump to trampoline.S at the top of memory, which 
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

这是内核返回到用户空间的最后的C函数。C函数做的最后一件事情是调用fn函数,传递的参数是TRAMFRAME和user page table。在C代码中,当你调用函数,第一个参数会存在a0,这就是为什么a0里面的数值是指向trapframe的指针。

当你启动一个进程,之后进程在运行,之后在某个时间点进程执行了ecall指令,那么你是在什么时候执行上一个问题中的fn函数呢?因为这是进程的第一个ecall指令,所以这个进程之前应该没有调用过fn函数吧?(学生提问)

好的,或许对于这个问题的一个答案是:一台机器总是从内核开始运行的,当机器启动的时候,它就是在内核中。 任何时候,不管是进程第一次启动还是从一个系统调用返回,进入到用户空间的唯一方法是就是执行sret指令。sret指令是由RISC-V定义的用来从supervisor mode转换到user mode。所以,在任何用户代码执行之前,内核会执行fn函数,并设置好所有的东西,例如SSCRATCH,STVEC寄存器。

2.2 sd ra, 40(a0)

现在我们保存了a0中原有的值,并且得到了trapframe page的地址,user mode下其余寄存器中的值,可以通过sd指令保存到user page table中trapframe的不同偏移位置

问题 寄存器保存在了trapframe page,但是这些寄存器用户程序也能访问,为什么我们要使用内存中一个新的区域(指的是trapframe page),而不是使用程序的栈?

这里或许有两个问题:

1.为什么我们要保存寄存器?为什么内核要保存寄存器的原因,是因为内核即将要运行会覆盖这些寄存器的C代码。如果我们想正确的恢复用户程序,我们需要将这些寄存器恢复成它们在ecall调用之前的数值,所以我们需要将所有的寄存器都保存在trapframe中,这样才能在之后恢复寄存器的值

2.为什么这些寄存器保存在trapframe,而不是用户代码的栈中?这个问题的答案是,我们不确定用户程序是否有栈,必然有一些编程语言没有栈,对于这些编程语言的程序,Stack Pointer不指向任何地址。当然,也有一些编程语言有栈,但是或许它的格式很奇怪,内核并不能理解。比如,编程语言以堆中以小块来分配栈,编程语言的运行时知道如何使用这些小块的内存来作为栈,但是内核并不知道。所以,如果我们想要运行任意编程语言实现的用户程序,内核就不能假设用户内存的哪部分可以访问,哪部分有效,哪部分存在。所以内核需要自己管理这些寄存器的保存,这就是为什么内核将这些内容保存在属于内核内存的trapframe中,而不是用户内存

2.3 csrr t0, sscratch ;sd t0, 112(a0)

sscratch中的值(实际上是a0寄存器中的原始值)保存在t0寄存器当中,将t0寄存器中的值保存在trapframe page偏移112字节的位置当中。

1
2
3
4
5
6
//kernel/proc.h
struct trapframe {
......
/* 112 */ uint64 a0;
.....
};

根据结构体定义,trapframe page 偏移112字节的位置是a0寄存器。

所以这两行代码的实际作用:将a0寄存器原本的值正确的保存在trapframe 当中

2.4 ld sp, 8(a0)

该指令将a0偏移8个字节的数据加载到sp(stack pointer)寄存器当中,a0寄存器当中存储的是trapframe page的地址。通过查看trapframe page的结构:

1
2
3
4
5
6
7
8
9
10
11
//kernel/proc.h
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap()
/* 24 */ uint64 epc; // saved user program counter
/* 32 */ uint64 kernel_hartid; // saved kernel tp
......
......
.....
};

所以这条指令的作用是初始化Stack Pointer指向这个进程的kernel stack的最顶端。

接下来的代码作用相似,从trapframe page当中取出current hartid(因为在RISC-V中,没有一个直接的方法来确认当前运行在多核处理器的哪个核上,XV6会将CPU核的编号也就是hartid保存在tp寄存器)address of usertrap(将要执行的第一个C函数的指针,也就是函数usertrap的指针。我们在后面会使用这个指针)

2.5 ld t1, 0(a0); csrw satp, t1; sfence.vma zero, zero

第一行代码的作用是将kernel_satp 代码加载到t1寄存器

第二行代码的作用是将stap寄存器与t1寄存器的交换,当前程序会从user page table切换到kernel page table

第三行代码的作用是,清除TLB缓存,当我们切换stap寄存器所存的值时,一般都需要清除TLB缓存。

为什么代码没有崩溃?(来自于老师提问)

毕竟我们在内存中的某个位置执行代码,程序计数器保存的是虚拟地址,如果我们切换了page table,为什么同一个虚拟地址不会通过新的page table寻址走到一些无关的page中?看起来我们现在没有崩溃并且还在执行这些指令。

答案

trampoline page在user page table中的映射与kernel page table中的映射是完全一样的。这两个page table中其他所有的映射都是不同的,只有trampoline page的映射是一样的,因此我们在切换page table时,寻址的结果不会改变,我们实际上就可以继续在同一个代码序列中执行程序而不崩溃。这是trampoline page的特殊之处,它同时在user page table和kernel page table都有相同的映射关系。

之所以叫trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。

2.6 jr t0

t0寄存器的值在2.4中提到过,现在其中的值为usertrap函数的地址,通过这段代码我们跳转到了usertrap函数,进行接下来的操作。

四、Usertrap函数

Uservec函数的结尾,我们跳转到了Usertrap函数,所以程序现在运行usertrap函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
int which_dev = 0;

if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");

/*
更改STVEC寄存器。取决于trap是来自于用户空间还是内核空间,实际上XV6处理trap的方法是不一样的。目前为止,我们只讨论过当trap是由用户空间发起时会发生什么。如果trap从内核空间发起,将会是一个非常不同的处理流程,因为从内核发起的话,程序已经在使用kernel page table。所以当trap发生时,程序执行仍然在内核的话,很多处理都不必存在
*/
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);

//获取当前正在运行的进程
struct proc *p = myproc();
/*
保存用户程序计数器,它仍然保存在SEPC寄存器中
但是可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致SEPC寄存器的内容被覆盖。所以,我们需要保存当前进程的SEPC寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。这里我们使用trapframe来保存这个程序计数器
*/
// save user program counter.
p->trapframe->epc = r_sepc();
//根据触发trap的原因,RISC-V的SCAUSE寄存器会有不同的数字。数字8表明,我们现在在trap代码中是因为系统调用
if(r_scause() == 8){
// system call

if(p->killed)
exit(-1);

// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;


// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
/*
XV6会在处理系统调用的时候使能中断,这样中断可以更快的服务,有些系统调用需要许多时间处理。中断总是会被RISC-V的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。(发生中断时,中断会关闭)
*/
intr_on();
//进入syscall函数
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}

if(p->killed)
exit(-1);

// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}

usertrap函数中我们可以看到,倘若中断的原因是由system call引起的(r_scause() == 8),那么程序将先执行syscall()函数,再执行usertrapret()函数

什么时候r_scause()被设置

每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。

以下是最重要的一些寄存器概述:

  • stvec:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。
  • sepc:当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。
  • scause: RISC-V在这里放置一个描述陷阱原因的数字。
  • sscratch:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。
  • sstatus:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIESPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式。

上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。

多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。

当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:

  1. 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
  2. 清除SIE禁用中断
  3. pc复制到sepc
  4. 将当前模式(用户或管理)保存在状态的SPP位中。
  5. 设置scause以反映产生陷阱的原因。
  6. 将模式设置为管理模式。
  7. stvec复制到pc
  8. 在新的pc上开始执行。

综上所述,这是硬件进行的操作,所以我们在代码中看不见过程

五、Systemcall

usertrap函数中我们进入了systemcall函数,在用户态执行ecall指令前,我们将系统调用号设置到了寄存器a7,执行ecall指令后,uservec函数将进程寄存器的所有值都被存入到了trapframe当中,所以我们通过num = p->trapframe->a7;取出系统调用号。

p->trapframe->a0 = syscalls[num]();

这里向trapframe中的a0赋值的原因是:所有的系统调用都有一个返回值,比如write会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0,所以为了模拟函数的返回,我们将返回值存储在trapframe的a0中。之后,当我们返回到用户空间,trapframe中的a0槽位的数值会写到实际的a0寄存器,Shell会认为a0寄存器中的数值是write系统调用的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//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;
}
}

kernel/syscall.c中,我们可以看出syscalls是一个函数指针数组,根据我们取出的系统调用号来调用不同的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};

我们传入的系统调用号对应的是SYS_sleep,所以执行sys_sleep函数,函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
uint64
sys_sleep(void)
{
int n;
uint ticks0;

if(argint(0, &n) < 0)
return -1;
acquire(&tickslock);
ticks0 = ticks;
while(ticks - ticks0 < n){
if(myproc()->killed){
release(&tickslock);
return -1;
}
sleep(&ticks, &tickslock);
}
release(&tickslock);
return 0;
}

注意:我们传入到内核的只有系统调用号,并没有其他途径告知内核传入参数的个数,所以我们自定义系统调用时,需要在用户态将函数声明中形参的个数与内核中系统调用函数中取参的个数相匹配,数目对不上编译器检查不出来错误。

sys_sleep函数利用argint函数取出应该休眠的时间(若用户态为sleep(10),则n==10),进行接下来的休眠操作。

六、Usertrapret

Usertrap函数中我们可以看出,系统调用执行完成后,我们应该执行usertrapret函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//
// return to user space
//
void
usertrapret(void)
{
struct proc *p = myproc();

// we're about to switch the destination of traps from
// kerneltrap() to usertrap(), so turn off interrupts until
// we're back in user space, where usertrap() is correct.
/*
我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新STVEC寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码(6.6)。我们关闭中断因为当我们将STVEC更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断
*/
intr_off();

/*
STVEC寄存器指向trampoline代码,在那里最终会执行sret指令返回到用户空间。位于trampoline代码最后的sret指令会重新打开中断
*/
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - trampoline));

/*填入了trapframe的内容,这些内容对于执行trampoline代码非常有用。这样下一次从用户空间转换到内核空间时可以用到这些数据
*/
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()

// set up the registers that trampoline.S's sret will use
// to get to user space.

// set S Previous Privilege mode to User.
/*
设置SSTATUS寄存器,这是一个控制寄存器。这个寄存器的SPP bit位控制了sret指令的行为,该bit为0表示下次执行sret的时候,我们想要返回user mode而不是supervisor mode。这个寄存器的SPIE bit位控制了,在执行完sret之后,是否打开中断。因为我们在返回到用户空间之后,我们的确希望打开中断,所以这里将SPIE bit位设置为1。修改完这些bit位之后,我们会把新的值写回到SSTATUS寄存器
*/
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
/*
trampoline代码的最后执行了sret指令。这条指令会将程序计数器设置成SEPC寄存器的值,所以现在我们将SEPC寄存器的值设置成之前保存的用户程序计数器的值。在不久之前,我们在usertrap函数中将用户程序计数器保存在trapframe中的epc字段
*/
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
/*
们根据user page table地址生成相应的SATP值,这样我们在返回到用户空间的时候才能完成page table的切换。实际上,我们会在汇编代码trampoline中完成page table的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射。但是我们现在还没有在trampoline代码中,我们现在还在一个普通的C函数中,所以这里我们将page table指针准备好,并将这个指针作为第二个参数传递给汇编代码,这个参数会出现在a1寄存器
*/
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);

// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
/*
倒数第二行的作用是计算出我们将要跳转到汇编代码的地址。我们期望跳转的地址是tampoline中的userret函数,这个函数包含了所有能将我们带回到用户空间的指令。所以这里我们计算出了userret函数的地址
*/
uint64 fn = TRAMPOLINE + (userret - trampoline);
/*
倒数第一行,将fn指针作为一个函数指针,执行相应的函数(也就是userret函数)并传入两个参数,两个参数存储在a0,a1寄存器中
*/
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

七、Userret

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
.globl userret
userret:
# userret(TRAPFRAME, pagetable)
# switch from kernel to user.
# usertrapret() calls here.
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.

# switch to the user page table.
/*
在执行csrw satp, a1之前,page table应该还是巨大的kernel page table。这条指令会将user page table(在usertrapret中作为第二个参数传递给了这里的userret函数,所以存在a1寄存器中)存储在SATP寄存器中。执行完这条指令之后,page table就变成了小得多的user page table。但是幸运的是,user page table也映射了trampoline page,所以程序还能继续执行而不是崩溃
*/
csrw satp, a1
// 清空页表缓存
sfence.vma zero, zero

# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
/*
将SSCRATCH寄存器恢复成保存好的用户的a0寄存器。在这里a0是trapframe的地址,因为C代码usertrapret函数中将trapframe地址作为第一个参数传递过来了。112是a0寄存器在trapframe中的位置
*/
ld t0, 112(a0)
csrw sscratch, t0
/*
之前保存的寄存器的值加载到对应的各个寄存器中
*/
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
ld t1, 80(a0)
ld t2, 88(a0)
ld s0, 96(a0)
ld s1, 104(a0)
ld a1, 120(a0)
ld a2, 128(a0)
ld a3, 136(a0)
ld a4, 144(a0)
ld a5, 152(a0)
ld a6, 160(a0)
ld a7, 168(a0)
ld s2, 176(a0)
ld s3, 184(a0)
ld s4, 192(a0)
ld s5, 200(a0)
ld s6, 208(a0)
ld s7, 216(a0)
ld s8, 224(a0)
ld s9, 232(a0)
ld s10, 240(a0)
ld s11, 248(a0)
ld t3, 256(a0)
ld t4, 264(a0)
ld t5, 272(a0)
ld t6, 280(a0)
//a0寄存器现在还是个例外,它现在仍然是指向trapframe的指针,而不是保存了的用户数据。

/*
交换SSCRATCH寄存器和a0寄存器的值。前面我们看过了SSCRATCH现在的值是系统调用的返回值
*/
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0

# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
/*
sret 的三个作用:
1.程序会切换回user mode
2.SEPC寄存器的数值会被拷贝到PC寄存器(程序计数器) (usertrap中保存的)
3.重新打开中断(usertrapret中保存的)
*/
sret

sret最终返回到usermode,此时 **我们处于 系统调用 化身成为的三条汇编指令的最后一句:ret**,执行后回到调用sleep函数的地方

八、总结

系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。