xv6-trap

详细看看 xv6 trap:相关寄存器,trap流程,用户trap,内核trap

相关资料存档:

4.1 RISC-V陷入机制 · 6.S081 All-In-One (dgs.zone)

6.1 Trap机制 | MIT6.S081 (gitbook.io)

riscv 特权级手册

限制vmem:Vmmem进程(WSL)占用CPU或内存资源过高的解决办法-CSDN博客

trap

什么是 trap?

  • 用户空间和内核空间的切换通常被称为trap

special!!:第一次trap的时候在哪里设置的uservec!是在forkret

forkret 调用了 usertrapret() 返回到了用户空间,在这里为第一次 trap 所需的东西,那么我认为冗余的部分,是不是就是因为这个地方没有设置呢

首先介绍一下 S mode 和 U mode 的小区别:

supervisor mode可以控制什么/特权是什么?

  • 读写控制寄存器:satp,stvec,sepc,sscratch

  • 可以使用 PTE_U 标志位为 0 的 PTE。当 PTE_U 标志位为 1 的时候,表明用户代码可以使用这个页表;

    如果这个标志位为 0,则只有 supervisor mode 可以使用这个页表

    比如用户空间最后两条pte,PTE_U 标志为 0,用户代码不能访问,S mode才可以访问

    a 标志代表这条PTE是不是被代码访问过,是不是曾经有一个被访问过的地址包含在这个PTE的范围内

    d标志位告诉内核,这个page最近被修改过

需要特别指出的是,supervisor mode 中的代码并不能读写任意物理地址。在 supervisor mode 中,就像普通的用户代码一样,也需要通过 page table 来访问内存。如果一个虚拟地址并不在当前由 SATP 指向的 page table 中,又或者 SATP 指向的 page table 中 PTE_U=1,那么 supervisor mode 不能使用那个地址。所以,即使我们在 supervisor mode,我们还是受限于当前 page table 设置的虚拟地址。

一个特殊的寄存器:mode标志位。这里的mode表明当前是user mode还是supervisor mode

什么时候会产生 trap

有三种事件会导致中央处理器搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上

包括

  • exception:异常

    • error:(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址
  • syscall: ecall (注意ecall并不会切换page table,所以这意味着,trap处理代码必须存在于每一个user page table中)

    ecall之后就转到stevc寄存器指向的地址开始继续执行指令(trampoline page)

  • interrupt:设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注。

我们希望trap是透明的,也就是用户感觉不到发生 trap 了

通常的顺序是

  1. 陷阱强制将控制权转移到内核
  2. 内核保存寄存器和其他状态,以便可以恢复执行
  3. 内核执行适当的处理程序代码(例如,系统调用接口或设备驱动程序)
  4. 内核恢复保存的状态并从陷阱中返回
  5. 原始代码从它停止的地方恢复。

注意cpu不会保存除 pc 之外的任何寄存器,切换内核页表,切换内核栈,保存寄存器这些工作都需要内核软件自己来实现

ecall时发生了什么

注意 ecall 是从用户空间陷入的意思

ecall 并不会切换 page table 切记,由于 ecall 是 CPU 的指令,你在gdb中是看不到 ecall 指令具体干了啥

第一,ecall将代码从user mode改到supervisor mode。

第二,ecall将程序计数器的值保存在了SEPC寄存器。

第三,ecall会跳转到STVEC寄存器指向的指令。

为什么 ecall 不多做点工作来将代码执行从用户空间切换到内核空间呢?

sscrath(S-mode sratch:S态下可以临时使用的寄存器) 这个寄存器用户是看不到的,在刚刚进入trap的时候操作系统把trapframe的地址放在了里面

satp (S-mode address translation and protection)

Trampoline(跳板):

  • 对ecall瞬间的状态做快照
    • 填充 trapframe
    • 利用 sscratch 保存所有寄存器

trap 相关寄存器

stvec

  • CSR
  • 保存”Handler”代码的地址

sstatus

  • sie (interrupt enabled)
    • 0 -> disabled
    • 1 -> enabled
  • spie (previous interrupts enabled)
  • spp (previous priviledge )
    • 0 -> user
    • 1 -> kernel

sepc(Supervisor Exception Program Counter):

  • 当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。

scause: RISC-V在这里放置一个描述陷阱原因的数字。

sscratch:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。

mstatus

  • 大致和sstatus一样,但是mpp有三个状态:user,supervisor,machine

trap大致流程

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

特别注意这里是硬件完成的,不需要我们手动来,我们也看不到具体的过程

  1. 如果陷阱是设备中断,并且 sstatus.SIE 位被清空,则不执行以下任何操作。

  2. sstatus.SPIE = ssatus.SIE (保存之前的SIE),清除 sstatus.SIE 以禁用中断,

  3. pc复制到sepc,将stvec复制到pc

    看到这你是否有疑问,为什么pc改变了,执行流不应该也改变了吗,为什么接下来几条设置还能继续进行?

    我的理解是这是硬件完成的,可能类似于原子指令吧,要执行完这些剩下的才会跳到另一个执行流来执行 pc

  4. 设置scause以反映产生陷阱的原因 (1 = TIMER; 8 = syscall…) ,设置stval显示附加信息比如 bad Instruction之类的

  5. 将当前hart权限mode保存在 sstatus.SPP中 ,将 mode 设置为 S mode (0 =U ; 1 = S)

  6. 在新的pc上开始执行。

trampoline

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

即使trampoline page是在用户地址空间的user page table完成的映射,用户代码不能写它,因为这些page对应的PTE并没有设置PTE_u标志位。这也是为什么trap机制是安全的。

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

TRAPFRAME

trapframe page里面存放了什么

很多槽位空出来是为了保存32个寄存器

在最开始还有5个数据,这些是内核事先存放在 trapframe 中的数据。

所以如何保存用户寄存器?

  • 内核非常方便的将trapframe page映射到了每个user page table,这个空间可以用于保存用户寄存器

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

    我们使用csrrw指令来交换a0sscratch的值

sfence.vma是清空页表缓存

【重要】从用户空间陷入

什么时候会从用户空间陷入?

用户 ecall or 做了一些非法事件 or 设备中断

由于 RISC-V 硬件在陷阱期间不会切换页表,所以用户页表必须包括 uservecstvec指向的陷阱向量指令)的映射。uservec必须切换satp以指向内核页表;为了在切换后继续执行指令,uservec必须在内核页表中与用户页表中映射相同的地址。

因此才使用包含 uservec 的蹦床页面(trampoline page)来满足这些约束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//proc.c
//为用户页表的trampoline和trapframe建立映射
pagetable_t proc_pagetable(struct proc*p){
...
// 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 page just below the trampoline page, 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;
}
...
}
C

stvec 初始时指向 uservec

1
2
3
4
5
6
7
8
trap.c
void usertrapret(void){
...
// send syscalls, interrupts, and exceptions to uservec in trampoline.S
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);
...
}
C

trapframe 预先包含的内容:

  • 指向当前进程内核栈的指针、当前CPU的hartidusertrap的地址和内核页表的地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    usertrapret(){
    ...
    // set up trapframe values that uservec will need when
    // the process next traps into 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()
    ...
    }
    C

uservec 做的工作:

  • 利用 sscratch 做媒介,把 a0 原本的值存在 sscratch,sscratch里面是 TRAPFRAME 的地址(注意这里是否破坏了 sscratch 的值,破坏了无所谓,TRAPFRAME这个值是从外面的宏定义的)

    注意在 xv6-lab 和 xv6-riscv 这二者的实现上有区别,前者没有破坏 sscratch 的值

  • 保存寄存器:(在用户地址空间的TRAPFRAME处)

    利用 a0 在 trapframe 保存所有的寄存器, a0 最后保存,此时所有的寄存器都保存完了,想用哪个用哪个,将 a0 从 sscratch 恢复然后保存到 trapframe

  • 把上面提到的几个已知信息从内存加载到寄存器(LOAD):设置当前进程内核栈、hart_id、usertrap地址、内核satp

  • 切换到内核页表:根据 trapframe 原本保存的 kernel_satp,写入 satp

  • 跳转到 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
//
// 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(killed(p))
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 sepc, scause, and sstatus,
// so enable only now that we're 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());
setkilled(p);
}

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

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

usertrapret();
}

C
  • 首先 assert 这是一个来自用户态的中断:看sstatus.SPP是否为0

  • stvec = kernelvec

  • 保存sepc:p->trapframe->epc = r_sepc()

    • 为什么在这里我们又再次保存了sepc呢?

      因为可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致SEPC寄存器的内容被覆盖。

      问题:此时中断被关闭的,什么东西会导致切换到另一个进程?

  • 根据 scause 的值判断是什么引起的trap,跳转到自己的 trap 处理流程

    主要依据 riscv 特权手册这张表来看 scause的值

    • 回想RVOS的trap - yinsist (ywinh.github.io),在这篇博客提到关于异常和中断对于 mepc 的设置是不同的,这里的 sepc 也是一样的设计
    • syscall :
      • 第一件事情是检查是不是有其他的进程杀掉了当前进程
      • p->trapframe->epc += 4 返回到发生trap的下一条指令
      • 打开中断【回想一下中断是在哪里关闭的,在ecall的时候硬件自动完成的】,为什么要打开中断?有的syscall需要中断,比如说write这种;中断可以更快的服务,有些系统调用需要许多时间处理
      • 执行 syscall
    • 设备中断:
      • devintr() 来处理
      • 特殊:如果是时钟中断,处理了之后要 yield()
    • 其他:发生错误,打印寄存器信息,然后kill这个进程,exit
  • scause 处理完之后再次检查当前用户进程是否被杀掉了,因为我们不想恢复一个被杀掉的进程

  • 调用 usertrapret()

usertrapret(): 未雨绸缪,为下一次 user trap 做准备

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
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 uservec in trampoline.S
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);

// set up trapframe values that uservec will need when
// the process next traps into 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 userret in 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 trampoline_userret = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64))trampoline_userret)(satp);
}
C
  • 关闭中断:现在要从 kernel 返回 user,不希望被打断 (最后userret时 sret 会自动打开中断)
    • 原因:我们将要更新STVEC寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码(6.6)。我们关闭中断因为当我们将STVEC更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
  • 把 stvec = uservec,方便下次trap,这个是从用户空间陷入的起点
  • 写入一些 uservec 需要的 trapframe 信息(比如内核页表,内核栈,hartid),也是为了方便下次
  • sstatus.SPP = 0 (转回user mode), sstatusSPIE = 1 (user mode可以中断)
  • sepc = p->trapfreame->epc(这个epc是在usertrap里面设置好的),sret 要通过 sepc 返回,提前设置好
  • 调用 userret (),这里传入了 satp(用户页表)

问题:为什么这里需要自己来设置 sstatus 相关值,sret 不是会在硬件自动设置吗

userret:从kernel返回到user

  • 将用户页表写入 satp

    蹦床页面映射在用户和内核页表中的同一个虚拟地址上的事实允许用户在更改satp后继续执行

  • 从 TRAPFRAME 地址恢复所有寄存器的值

  • sret 返回用户态(这里要利用spec )

sret是我们在kernel中的最后一条指令,当我执行完这条指令:

  • 程序会切换回user mode
  • SEPC寄存器的数值会被拷贝到PC寄存器(程序计数器)
  • 重新打开中断

When executing an x RET instruction, supposing xPP holds the value y, x IE is set to xPIE; the privilege mode is changed to y; xPIE is set to 1; and xPP is set to U (or M if user-mode is not supported).

从内核空间陷入

stvec 事先指向的是 kernelvec

kernelvec:

  • 保存寄存器(在被中断的内核线程的栈上)
  • 跳转到kerneltrap(),会返回

kerneltrap():

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
// interrupts and exceptions from kernel code go here via kernelvec,
// on whatever the current kernel stack is.
void
kerneltrap()
{
int which_dev = 0;
uint64 sepc = r_sepc();
uint64 sstatus = r_sstatus();
uint64 scause = r_scause();

if((sstatus & SSTATUS_SPP) == 0)
panic("kerneltrap: not from supervisor mode");
if(intr_get() != 0)
panic("kerneltrap: interrupts enabled");

if((which_dev = devintr()) == 0){
printf("scause %p\n", scause);
printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
panic("kerneltrap");
}

// give up the CPU if this is a timer interrupt.
if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
yield();

// the yield() may have caused some traps to occur,
// so restore trap registers for use by kernelvec.S's sepc instruction.
w_sepc(sepc);
w_sstatus(sstatus);
}
C
  • 检查是什么类型的trap
    • 设备中断
    • 异常
  • 返回到被 trap 中断的代码

思考问题:为什么要保存 sepc,sstatus?如果是在时钟中断中发生了 yield,是怎么做的?


xv6-trap
http://example.com/2024/04/28/xv6-trap/
Author
Jianhui Yin
Posted on
April 28, 2024
Licensed under