本文介绍xv6的trap流程
user空间和kernel空间的切换称为trap
- xv6 系统调用 是如何在user和kernel空间进行切换的 ?
关键步骤及指令如下
- user ecall
- 提高程序的权限 , 保存当前地址($sepc) , 记录trap原因(r_scause()) , 跳转到trampoline
- trampoline uservec
- 将cpu reg中user的上下文保存进trapframe,如user stack pointer , return addr 等; 并将 kernel thread的上下文聪trapframe加载进cpu reg. 如kernel stack pointer , func to jump , kernel pgtbl($satp). 然后跳转到usertrap()
- usertrap
- 根据陷入kernel的原因($scause),执行动作如syscall().
- usertrapret
- 即将离开kernel , 将kernel上下文保存进trapframe , 以便下次陷入内核时trampoline将其加载进来. 跳到userret
- trampoline userret
- 切换到 user pagetable , 将user上下文从trapframe加载进来. 跳转回user($sepc)
- user ret : 跳到user程序的下一指令
- user ecall
之前有人问我个问题hhh,当复习了。
- 从用户到内核需要的寄存器哪些会发生改变啊?
- 答:
比说ecall吧. 他得改变代表权限的reg,从user mode改成supverisor mode ; 还得改变保存$PC到$sepc reg.;还得设置$scause reg代表trap的原因;进入trampoline的uservec之后,得把之前user态时候cpu上的reg保存进内存,然后将该process的kernel thread的上下文加载到cpu的reg上. 比如说kernel stack pointer加载到$sp ; 还得改变$satp,切换成kernel pagetable来切换地址空间,
xv6 系统调用
TRAP机制
- 用户空间和内核空间的切换通常被称为trap,而trap涉及了许多小心的设计和重要的细节,这些细节对于实现安全隔离和性能来说非常重要。
- 因为很多应用程序,要么因为系统调用,要么因为page fault,都会频繁的切换到内核中。所以,trap机制要尽可能的简单,这一点非常重要。
- 一些重要寄存器
- PC (Program Counter Register) :
- 程序计数器
- MODE :
- 当前是supervisor mode还是user mode
- 当我们在运行Shell的时候,自然是在user mode
- vm.c中的函数全部是内核的一部分,运行在supervisor mode下
- SATP (Supervisor Address Translation and Protection)
- page table的物理内存地址
- STVEC (Supervisor Trap Vector Base Address Register)
- 指向了内核中处理trap指令的起始地址。
- SEPC (Supervisor Exception Program Counter)
- trap过程中保存PC值
- 是为了之后从kernel return 到 usrspace时 可以知道要回到哪条指令。
- SSRATCH (Supervisor Scratch Register)
- sscratch points to where the process’s p->trapframe is mapped into user space, at TRAPFRAME
- SCAUSE
- 通过trap机制进入到supervisor mode的原因
- PC (Program Counter Register) :
系统调用
图
流程
user态
- user态
- 我们跟踪sh.c中的write系统调用
ecall
- 指令如下
- 并不是ecall之后就完全进入了内核的C代码,还有很远的距离。
- ecall作用,简要来说就是 提高程序的权限,跳转到特定的地址.
- 那么ecall 都做了什么? ecall只做了四件事。
- 1. ecall将代码从user mode改到supervisor mode
- 这也就是所谓的用户态运行非特权指令,内核态运行特权和非特权指令。
- 话说用户态不能访问内核态得地址空间在xv6中究其原因是user process和kernel 用的根本不是一个页表。那么在linux中也是这个原因吧。
- 2. ecall将当前PC的值(即user态ecall代码所在地址)保存在了$sepc reg。以便将来返回。
- 3. 设置 $scause reg 去反映 trap 的原因 (ecall, 8)
- 4. ecall会使得PC跳转到 $ svec reg指向的指令。
- 在从trap是从user进入kernel的这一步骤,也即,PC指向tramopline的uservec段。
- 以上三个操作都是硬件负责实现的。
- 1. ecall将代码从user mode改到supervisor mode
- syscall语义 : 进入kernel
- ecall 语义 :类似syscall,进入kernel
- ecall : 即将进入trapmpoline。不是ecall之后就完全陷入了内核!
- 注意此时还是user page table
trapframe
- 先说一下trapframe(0x3fffffe000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // kernel code usertrap()
/* 24 */ uint64 epc; // saved user program counter to return to
/* 32 */ uint64 kernel_hartid; // saved kernel tp
/* 40 */ uint64 ra;
/* 48 */ uint64 sp;
/* 56 */ uint64 gp;
/* 64 */ uint64 tp;
/* 72 */ uint64 t0;
/* 80 */ uint64 t1;
/* 88 */ uint64 t2;
/* 96 */ uint64 s0;
/* 104 */ uint64 s1;
// ...
} - 用于保存进程寄存器现场的内存
- 每个userprocess都有一段trapframe
- trapframe保存的重要内容:
- sepc reg 被保存在p->trapframe->epc中
- p->trapframe->epc = r_sepc();
- sepc : process 在user态时的程序计数器。
- process如何实现从kernel中返回待user?(如何进行向用户代码的跳转)
- 在trampoline userret,最后一句sret指令 : 会将pc设置成$sepc reg。
- 而在userret之前的usertrapret:w_sepc(p->trapframe->epc);。会将之前(比如user刚进入usertrap时的sepc reg),保存在p->trapframe->epc中。
- process如何实现从kernel中返回待user?(如何进行向用户代码的跳转)
- 内核的 : 还有kernel pagetable , kernel stack pointer , usertrap()地址等
- 还有其他user寄存器等
- sepc reg 被保存在p->trapframe->epc中
trampoline uservec
trampoline.S (汇编代码)
- 对 ecall 瞬间的状态做快照,保存到trapframe处。
- 填充 struct trapframe (proc.h)
- 利用 $sscratch (S-mode scratch ) 保存所有寄存器
- 切换到内核栈 (相当于切换到进程对应的 “内核线程”, L2)
- 切换到内核地址空间
- 修改 $satp (S-mode address translation and protection)
- sfence.vma
- 跳转到 tf->kernel_trap
- 痛苦时间解除,进入 C 代码
再简要说一下trampoline
- 功能:对 ecall 瞬间的状态做快照,保存到trapframe处。
问题1:为什么切换到页表之后不会崩溃?明明映射都不同了?
- csrw satp, t1 # 切换页表
- 原因见下
- user process 和 kernel process 唯一相同的映射就是trampoline page。
- 即在user virtual address 和 kernel virtual process 的trampoline page 都映射到了同一块DRAM
- 也正是因此,保证了我们在从user切换到kernel pagetable时不会异常终止。
- 因为切换的过程中就是在执行trampoline的uservec代码。且切换完成后,寻址的结果不会改变,我们可以在同一代码序列执行程序而不崩溃。
call之后我们进入了uservec
ecall并不会切换page table,这是ecall指令的一个非常重要的特点。所以这意味着,trap处理代码必须存在于每一个user page table中。
因此每个user的process的虚拟地址里的顶部都是trampoline
刚进入trampoline的uservec
- 使用的还是user的page table。即所看到的、所使用的地址仍然是user pagetable维护的虚拟地址
- 我们找到trampoline的内容也是通过user的page table维护的虚拟地址0x3ffffff000找到的。
- trampoline都做了什么:
- 将当前cpu上运行的user process的上下文从cpu reg上存入user process的trapframe中。,即 保护用户寄存器。
- save user’s context from cpu reg into dram(trapframe)
- 如何实现保护用户寄存器?(利用 $sscratch (S-mode scratch ) 保存所有寄存器)
- XV6在每个user page table映射了trapframe page,这样每个进程都有自己的trapframe page(这个位置的虚拟地址总是0x3ffffffe000)
- 如何找到user process的trapframe ? : sscratch 指向的地址是trapframe的虚拟地址.
- trapframe的地址是怎么出现在SSCRATCH寄存器中的?
- 在内核前一次切换回用户空间时,内核会执行set sscratch指令,将这个寄存器的内容设置为0x3fffffe000,也就是trapframe page的虚拟地址。所以,当我们在运行用户代码,比如运行Shell时,SSCRATCH保存的就是指向trapframe的地址。之后,Shell执行了ecall指令,跳转到了trampoline page,这个page中的第一条指令会交换a0和SSCRATCH寄存器的内容。所以,SSCRATCH中的值,也就是指向trapframe的指针现在存储与a0寄存器中。
- 将当前cpu上运行的user process之前保存在trapframe中的一些kernel context需要用到的上下文加载进cpu的reg
- ld user process context from dram(trapframe) to cpu reg
- 内核栈指针:cpu sp reg = kernel_sp
- 切换到内核栈 (相当于切换到进程对应的 “内核线程”, L2)
- 我认为作用是之后 对于改userprocess trap陷入内核之后 的 在内核的函数调用所开辟的栈帧,都是建立在这个kernel_sp的栈帧中的。
- 当前cpu :p->trapframe->kernel_hartid
- 内核处理trap的函数:t0 = p->trapframe->kernel_trap
- 当前页表替换为全局内核页表:satp = p->trapframe->kernel_satp
- 切换到内核地址空间
- 跳转到usertrap:jr t0
- 从trampoline跳转到内核的C code中。
- 以kernel stack,kernel page table跳转到usertrap函数。
- 将当前cpu上运行的user process的上下文从cpu reg上存入user process的trapframe中。,即 保护用户寄存器。
- uservec code
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
42uservec:
将当前cpu上运行的user process的上下文从cpu寄存器上存入dram的trapframe中
csrrw a0, sscratch, a0
这条指令交换了寄存器a0和sscratch的内容
# a0 = trapframe
# SSCRATCH寄存器的作用就是保存另一个寄存器的值,并将自己的值加载给另一个寄存器
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
sd tp, 64(a0)
...
sd a1, 120(a0)
sd a2, 128(a0)
csrr t0, sscratch # t0 = a0
sd t0, 112(a0)
# **ld user process context from dram(trapframe) to cpu reg**
ld sp, 8(a0)
ld tp, 32(a0)
ld t0, 16(a0)
ld t1, 0(a0)
csrw satp, t1 # 切换页表
sfence.vma zero, zero
# a0 is no longer valid, since the kernel page
jr t0
usertrap
有很多原因都可以让程序运行进入到usertrap函数中来,比如系统调用,运算时除以0,使用了一个未被映射的虚拟地址,或者是设备中断。usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式,我们在接下来执行usertrap的过程中会同时看到这两个行为
usertrap行为:做些前置处理,然后根据发生trap的原因(scause)进行相应动作如系统调用,然后准备返回user态.(usertrapret)
usertrap
syscall
- 向trapframe中的a0赋值的原因是:所有的系统调用都有一个返回值,比如write会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0,所以为了模拟函数的返回,我们将返回值存储在trapframe的a0中**。之后,当我们返回到用户空间,trapframe中的a0槽位的数值会写到实际的a0寄存器,Shell会认为a0寄存器中的数值是write系统调用的返回值。
获取trapframe中的参数
内存trapframe里存储的值,有一部分是user process进入kernel需要用的上下文(就是前几个,内核栈指针啥的)。
有一部分是从process从kernel恢复到user需要使用的寄存器(上下文)。
usertrapret
- usertrapret.c
- uint64 satp = MAKE_SATP(p->pagetable)
- 我们根据user page table地址生成相应的SATP值,这样我们在返回到用户空间的时候才能完成page table的切换。实际上,我们会在汇编代码trampoline中完成page table的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射
trampoline userret (kernel)
流程如下
- 1. 切换page table
- 由kernel page table 变成user page table 。由于trampoline page是两页表映射的相同部分,因此代码可以正常运行。
- 2. 将trapframe中保存的对user process有用的上下文加载进cpu的reg中; 将trapframe的地址存回sscratch中
- 3. sret是我们在kernel中的最后一条指令
- 程序会切换回user mode
- $sepc会被拷贝到PC寄存器(程序计数器)
- trapframe->epc 是在进入内核后/返回user前 内核设置好的,应当返回到user的哪个指令。
- 重新打开中断
- 之后我们回到了用户空间。
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.globl userret
userret:
# 就将trapframe的地址再存入sscratch
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# 将user process的上下文从dram(trapframe)中加载进cpu的reg
# 1. 切换成用户页表
csrw satp, a1
sfence.vma zero, zero
# 2. 将trapframe中保存的对user process有用的上下文加载进cpu的reg中; 将trapframe的地址存回sscratch中
# a0是trapframe地址 临时保存user的a0到sscratch中
ld t0, 112(a0)
csrw sscratch, t0
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
....
ld s1, 104(a0)
ld a1, 120(a0)
# 将user的trapframe放回sscratch
csrrw a0, sscratch, a0
# 3.
sret
程序会切换回user mode
SEPC寄存器的数值会被拷贝到PC寄存器(程序计数器)
重新打开中断
总结
- 系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容.
- 通过trap机制从用户态进入内核态的开销为什么大,大在哪里?
- trampoline.S中,就有很多很多(离开和进入总共100多条)指令来存储当前寄存器
- cpu $satp 扔掉user pagetable , 重新加载 kernel pagetable。清空当前TLB。
1
2
3
4
ld t1, 0(a0)
csrw satp, t1
sfence.vma zero, zero # 清空当前TLB。