1 Traps and system call
有三种事件会导致CPU不按照原先的执行顺序执行:系统调用(ecall)、异常、硬件中断。
本书把以上几种统称为 trap(csapp中统称为异常)。
Xv6分四个阶段处理trap:
- RISC-V CPU的硬件处理
- 为内核C语言这边的汇编向量
- C语言形式的trap处理器
- 系统调用程序或者是硬件驱动服务程序
1.1 RIS-V trap machinery
每个RISC-V CPU都有一系列的控制寄存器来处理trap。可参见riscv.h。
以下寄存器只能在内核态才能修改。
以下是主要的几个寄存器(kernel中也有一套一样的寄存器):
- stvec:kernel 把 trap handler的地址放在里面。
- sepc:当trap发生时,stvec寄存器中的内容会写到pc 中,而sepc就是用来存pc之前的副本。trap执行完之后,sret命令会把sepc的内容写回pc中
- scause:RISC-V会存一个表示该trap原因的数在这个寄存器
- sscratch:内核会在此写入一个在trap handler 开始 时很有用的数在里面 (大雾~~)
- sstatus:顾名思义是表示状态。
- SIE bit 表示是否接受设备中断,在清除状态下会推迟设备中断,直到SIE bit设置。
- SPP bit 表示trap 是来自用户态还是内核态,并且控制sret返回的状态。
硬件执行trap的流程
- 如果是设备中断,且sstatus的 SIE 被清除了,那么不再执行以下步骤
- 清除SIE
- 将pc中的内容复制到 sepc 中保存
- 保存目前的状态到 sstatus 中的SPP中
- 设置 scause ,即表示 trap 原因的数字。
- 改为特权模式
- 将 stvec中的内容复制到 pc 中
- 开始执行pc
1.2 Trap from user space
High level 的执行路径 : uservec(trampoline.S) -> usertrap(trap.c) -> usertrapret(trap.c) -> userret(trampoline.S)
用户态的trap会比内核态的复杂,因为用户页表并不映射完内核页表,栈指针可能会带有非法的内容。
uservec
因为用户态的trap不会切换页表,所以用户页表必须映射了uservec的内容,(即stvec寄存器指向的内容,疑惑??)。uservec 必须将satp所指的页表转为内核页表。为了在转换后继续执行指令,uservec必须要内核页表和用户页表都映射到相同的地址。
Xv6使用了trampoline 去包含uservec。对于内核页表以及用户页表,所以trampoline都会映射到同一页物理内存。
当 uservec 开始运行,所有32个寄存器都保存了trap用户的信息,但是uservec需要寄存器去切换页表和产生存放寄存器内容的地址。
RISC-V通过把寄存器内容保存到一页物理内存上面,这个页叫做 trapframe,trapframe在进程创建的时候就会被映射到页表上面。具体实现方法:可见 uservec里面第一步是一个 csrrw命令,这个是把 a0 和sscratch寄存器的内容交换,而sscratch原本就是指向traoframe。因此此时 a0就指向trapframe。(注意此时我们的页表是用户页表,但是在内核页表的时候我们可以通过 p->trapframe 去访问这个保存页。)之后就是将寄存器的内容全部复制到trapframe上面。
trapframe还包括了内核栈指针、CPU的hartid、usertrap 的地址和内核页表的地址。在uservec中取得这些值,加载到寄存器中。并将satp转到kernel页表,并调用 usertrap
# 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的原因,并进行处理。
疑惑:上述说的控制寄存器的修改在哪里。
- 先查看mode是否是用户态。
- 修改 stvec,虽然不知道参数调的 kernelvec,注释和book上的都看不明白。。。
- 把sepc中的副本传到trapframe上面。
- 判断是否是系统调用或者设备中断,并执行相应命令。其中是系统调用的话会使得 pc的内容+4,因为需要执行吓一跳命令。
- 调用 usertrapret
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();
}
usertrapret
usertrapret 主要是把一些寄存器什么的内容复原,为之后的trap做准备。最后会调用userret。
userret
usertrapret 会传递 TRAMPFRAME 和 用户页表地址给 userret 到 a0 和 a1上面。(book上面把这两个搞反了)
会把 a0 的值 传给 sscratch 和 a1 传到 satp上面。然后把trampframe上面的值重新放回寄存器上面。
.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
1.3 Code:Calling system calls
以 initcode.S调用exec为例进行分析。比较简单就不放代码了。
- user code把exec的参数放在 a0 和 a1 寄存器上面,把系统调用号放在 a7 上面。系统调用号会匹配一个系统调用的列表,上面记录了系统调用函数的指针。
- ecall会trap到kernel并且执行uservec,usertrap,最后是syscall。
- syscall会根据a7的值选择执行的系统调用函数,最后会把返回值放到 p->trapframe->a0。
1.4 Code: System call arguments
user code 会把参数通过寄存器传给内核,但是由于上述的trap处理,寄存器的内容会被存到trapframe里面,所以系统调用取值是从p->trapframe->a0 等上面取值的。因为内核页表和用户页表不同,所以需要进行转换。这部分在之前都有涉及,也不多做介绍了。
取参数
# syscall.c
// argint、argaddr、argfd 通过argraw取值为相应的形式
static uint64
argraw(int n)
{
struct proc *p = myproc();
switch (n) {
case 0:
return p->trapframe->a0;
case 1:
return p->trapframe->a1;
case 2:
return p->trapframe->a2;
case 3:
return p->trapframe->a3;
case 4:
return p->trapframe->a4;
case 5:
return p->trapframe->a5;
}
1.5 Traps from kernel space
在kernel在CPU上面运行的时候,stvec就指向 kernelvec代码。因为satp已经指向内核页表,所以不需要切换。kernelvec会保存所有的寄存器内容到 trapframe。
kernelvec -> kerneltrap
kernelvec
kernelvec 会保存所有的寄存器内容到中断内核线程(为什么是线程??)的栈上面。这在转换线程的情况下显得很重要。
kerneltrap
kerneltrap会处理两种类型的trap:设备中断和异常。
- 如果是时钟中断,而且是一个内核线程在运行,那么会调用yield放弃CPU的使用权。
- 当kerneltrap完成了,就要去恢复之前的状态,因为yield 可能会导致状态的混乱,所以之前右用变量巨鹿他们。
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);
}
1.6 Page-fault exception
Xv6中,在用户态出现异常,kernel会直接kill该进程,在kernel态出现异常,会panic。而在真实的OS中会很多不同的处理方式。
在OS中会利用page fault去提高资源利用率。
COW fork
Xv6中未实现Copy on write fork 技术。
子进程直接使用父进程的物理内存,这当然会导致一些读写不一致的问题。
处理方式是:当子进程或父进程进行写的时候,报page exception。然后复制该物理页,然后修改父子进程的页表,使得一人一张对应的物理页。然后再resume同样的命令,此时就可以正常完成。
一般子进程都会采用exec,用新的内容去填充地址空间,所以只会产生少量的page falut,并且可以避免全部复制。
Lazy alloc
一般用sbrk申请的内存我们不会用完,所以在真实需要分配的时候,我们才会进行allocate
到真实需要使用的时候,会报page fault,然后再进程alloc,然后resume命令就可以完成。
虚拟内存
根据局部性原理,有很大一部分程序执行的内容是在短时间内不需要使用的。所以就把他们放在外存中,等到需要使用的时候,因为内存中不存在,所以会有 page fault,然后再调入内存。