mit6.s081 - xv6系统调用过程(用户态陷入内核)

核心问题:xv6是如何执行系统调用的?系统调用时用户进入内核的一种手段(一般是受限直接执行(Limited Direct Execution, LDE)机制)。如下图所示:

有三种事件会导致CPU搁置普通指令的执行,强制将控制权转移给处理该事件的特殊代码。

  • 系统调用:当用户程序执行ecall指令要求内核为其做某事时。
  • 异常:一条指令(用户或内核)做了一些非法的事情,如除以零或使用无效的虚拟地址。
  • 中断:当一个设备发出需要注意的信号时,例如当磁盘硬件完成一个读写请求时。

在xv6中这三种情况统称为trap。

xv6 trap 处理分为四个阶段:

  • RISC-V CPU采取的硬件行为
  • 为内核C代码准备的汇编入口
  • 处理trap的C 处理程序
  • 系统调用或设备驱动服务。

trap机制-硬件

每个RISC-V CPU都有一组控制寄存器,内核写入这些寄存器来告诉CPU如何处理trap,内核可以通过读取这些寄存器来发现已经发生的trap。监督者模式下的控制寄存器如下:

image-20230313225807767

以下是关于trap机制的寄存器的概述:

  • stvec:内核在这里写下trap处理程序的地址;RISC-V跳转到这里来处理trap。
  • sepc:当trap发生时,RISC-V会将程序计数器保存在这里(因为PC会被stvec覆盖)。sret(从trap中返回)指令将sepc复制到pc中。内核可以写sepc来控制sret的返回到哪里。
  • scause:RISC -V在这里放了一个数字,描述了trap的原因。
  • sscratch:内核在这里放置了一个值,在trap处理程序开始时可以方便地使用。
  • sstatussstatus中的SIE位控制设备中断是否被启用,如果内核清除SIE,RISC-V将推迟设备中断,直到内核设置SIESPP位表示trap是来自用户模式还是supervisor模式,并控制sret返回到什么模式。

多核芯片上的每个CPU都有自己的一组这些寄存器,而且在任何时候都可能有多个CPU在处理一个trap。

重点关注stvecsepcscause 三个寄存器即可。


当需要执行trap时,RISC-V硬件对所有的trap类型(除定时器中断外)进行以下操作:

  1. 如果该trap是设备中断,且sstatus SIE位为0,则不执行以下任何操作。
  2. 通过清除 SIE 来禁用中断。
  3. 复制 pcsepc
  4. 将当前模式(用户态或特权态)保存在 sstatusSPP 位。
  5. scause 设置该次trap的原因。
  6. 将模式转换为特权态。
  7. stvec 复制到 pc
  8. 从新的pc开始执行。

从用户态陷入内核

系统调用的入口

要想理清楚xv6系统调用过程,就必须从用户态程序开始入手,下面以lab1下用户态程序 sleep.c 为例,其c程序如下(调用了sleep系统调用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int
main(int argc, char *argv[])
{
int ticks_num;

if(argc != 2){
fprintf(2, "Usage: sleep times\n");
exit(1);
}

ticks_num = atoi(argv[1]);
sleep(ticks_num);

exit(0);
}

在shell中执行编译命令 make qemu 后,可以看到 sleep.c 生成了对应的 sleep.asm 文件,查看之,有如下代码片段:

1
2
3
4
5
6
7
8
9
000000000000034c <sleep>:
.global sleep
sleep:
li a7, SYS_sleep
34c: 48b5 li a7,13
ecall
34e: 00000073 ecall
ret
352: 8082 ret

其中 ECALL 是RISC-V中的一个专门的指令,可以让应用程序将控制权转移给内核(Entering Kernel)。

ECALL 接收一个数字参数(a7),当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行 ECALL 指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的System Call。

ps: 对应的,x86中就是int 0x80

执行 ecall 指令时发生了什么呢?ecall 使程序跳转到哪里呢?

Risc-v中有一个STVEC 寄存器,这是一个只能在supervisor mode下读写的特权寄存器。在从内核空间进入到用户空间之前,内核会设置好STVEC寄存器指向内核希望trap代码运行的位置。

Xv6在内核页表和每个用户页表中的同一个虚拟地址上映射了 trampoline pageSTVEC 寄存器保存的地址是 trampoline page 的起始位置,主要执行一些保护用户态寄存器的操作。trampoline pageuservec 函数的起始位置。

为什么 STVEC 会跳转到 uservec 函数? STVEC 寄存器的值是什么时候写入的?

在下文的 usertrapret 函数中写入,如下,这个函数在操作系统将内核切换到用户进程的时候就会调用。

1
2
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - trampoline));

安全吗?

trampoline page 是在用户地址空间的user page table完成的映射,用户态代码不能写它,因为这些page对应的PTE并没有设置PTE_u标志位。


总结一下,ecall 调用时发生了什么 :

  1. 关中断。
  2. ecall 将代码从user mode改到supervisor mode。
  3. ecall 将程序计数器的值保存在了 SEPC 寄存器。
  4. ecall 会跳转到 STVEC 寄存器指向的指令。

uservec 函数

trampoline page 中的 uservec 函数主要执行以下几个功能:

  1. 保存用户寄存器的内容至 trapframe(xv6在每个user page table映射了 trapframe page,每个进程都有自己的trapframe page,定义可见kernel/proc.h:44) ,此时需要使用 sscratch 以一定技巧腾出 a0 寄存器。

    Figure-2.3
  2. 恢复内核栈、内核页表,刷新TLB,设置下一步trap处理函数 usertrap 的地址。

  3. 跳转至 usertrap 函数执行。

uservec 函数代码如下:

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
	.section trampsec
.globl trampoline
trampoline:
.align 4
.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 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

usertrap 函数

usertrap 的作用是确定trap的原因,处理它,然后返回(kernel/ trap.c:37)。若是系统调用,其 scause 寄存器值为8,usertrap 函数调用 syscall 函数进行处理。

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
//
// 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");

// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);

struct proc *p = myproc();

// save user program counter.
p->trapframe->epc = r_sepc();

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.
intr_on();

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();
}

为什么要改变 stvec

因为当前已经在内核中了,在内核中发生的trap将由 kernelvec 处理。所以要写入 stvec 寄存器的值为kernelvec

1
2
3
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);

为什么要保存 sepc?

此时,sepc 中的数据为用户pc。因为xv6在执行 ecall 的时候,程序计数器的值被保存在了 SEPC 寄存器中。

当程序还在内核中执行时,可能需要切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致SEPC寄存器的内容被覆盖。因此需要保存一下用户pc,代码如下:

1
2
// save user program counter.
p->trapframe->epc = r_sepc();
syscall 函数

经过种种艰辛,终于来到了系统调用的处理函数。

  • 用户态代码在调用系统调用时,将系统调用函数的参数放在寄存器a0a1 等寄存器中中,并将系统调用号放在a7中。
  • syscall(kernel/syscall.c:133)从 trapframe 中的 a7 中得到系统调用号,并其作为索引在 syscalls 查找相应函数。对于系统调用sleep,其a7寄存器值为13(SYS_sleep),这会让 syscall 函数调用 sleep 系统调用的实现函数 sys_sleep
  • 当系统调用函数返回时,syscall 将其返回值记录在 p->trapframe->a0 中。

其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
syscall(void)
{
int num;
struct proc *p = myproc();

num = p->trapframe->a7; // a7是系统调用号
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// 查表得到函数指针,然后通过函数指针调用对应系统调用的实际实现。
// a0为系统调用函数的返回值
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}

从内核中返回用户态

usertrapret函数

usertrap 函数的最后调用了 usertrapret 函数,设置RISC-V控制寄存器,为以后用户空间trap做准备。

  • 改变 stvec 来引用 uservec
  • 准备 uservec 所依赖的 trapframe 字段
  • sepc 设置为先前保存的用户程序计数器。
  • usertrapret 在用户页表和内核页表中映射的trampoline页上调用 userret(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
//
// 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.
intr_off();

// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - 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.
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);

// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);

// 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.
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

userret函数

usertrapretuserret 的调用传递了参数a0a1, 其中a0指向TRAPFRAMEa1指向用户进程页表。

  • userret将 satp 切换到进程的用户页表。在用户页表和内核页表中,trampoline 页被映射在相同的虚拟地址上,这也是允许 userret (以及 uservec)在改变 satp 后继续执行的原因。

  • userrettrapframe 中保存的用户 a0 复制到 sscratch 中,为以后与TRAPFRAME交换做准备。(此时,userret能使用的数据只有寄存器内容和trapframe的内容。)

    • 此时 sscratch: 用户的 a0,从 trapframe 中读取的; a0: trapframe 地址,函数参数传入。
  • userrettrapframe 中恢复保存的用户寄存器,

  • a0sscratch 做最后的交换,恢复用户 a0 并保存 TRAPFRAME,为下一次trap做准备。

    • 此时 sscratch: trapframe 地址,下次trap使用; a0: 用户的 a0
  • 使用 sret 返回用户空间。

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
.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
sfence.vma zero, zero

# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
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)

# 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

总结一下,sret 调用时发生了什么 :

  • 程序会切换回user mode

  • SEPC 寄存器的数值会被拷贝到 PC 寄存器

  • 重新打开中断

总体流程

从用户态陷入(trap)内核的流程如下:

hizh3qoc

参考资料

[1] xv6-books-chinese

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值