MIT 6.S081 聊聊xv6中的trap

前言

这个星期睡眠和精神状态一直比较差,6.824很多论文没时间回顾,15-445的Lab2又耗费了我巨大的精力,实在写不动代码了。只能写点回顾总结之类的东西。我很久之前就想总结一下xv6中关于进程的知识,后来发现这涉及的范围实在是太大了(废话,这在哪本OS教材里都能占完整的一章),而且无论如何组织结构,trap永远都是一个绕不过去的地方,不讲trap,那么进程的许多内容都将无从谈起。本blog算是对xv6中trap机制的一点粗浅的个人笔记。

请注意:

(1)很多教材要区分trap和interrupt,当初看的我云里雾里,相关内容一圈读下来感觉就是读了个分类学。这里我将它们统称为trap,并按照不同于xv6 book的分类方式(xv6 book将trap分为来自用户的中断、来自内核的中断、来自设备的中断)对trap分类并进行了讨论。

(2)讨论trap必然涉及到硬件,而riscv的硬件相关细节够把blog码到⑨⑩年之后。因此本blog虽然会讨论一点硬件细节,但也点到为止,不会长篇大论去讨论什么M Mode、S Mode等。如果希望看到这方面讨论的可以点右上角的X了。

trap就是修改了PC

trap对我来说算是学OS的时候感觉最为迷惑的概念之一。

作为曾经408的受害者,我对trap的理解一直是《他改变了PC》,正常情况下PC应当随着指令的逐条执行发生变化。如果这条指令不是跳转指令,那么PC += 1。如果是跳转指令,那么就向那里发生跳转。整个程序的执行中,如果不发生中断,PC就像是已经被彻底安排好了一样,只能在程序划定好的范围内反复横跳。

但如果引入了trap,一切就不一样了。用户执行指令时,如果指令执行出现故障就会触发trap,如果使用了特殊的指令(ecall、越权指令等)也会触发trap。更要命的是,哪怕指令正常执行,也可能出现trap——设备trap是完全异步的,这类trap在任意时期都可能出现。无论发生了哪种trap,它都打断了现有程序的执行流。

trap的触发既和软件有关也和硬件有关,如果要思考它们的具体情景是一个很要命很迷惑的问题。我个人的建议是不妨把trap想象的简单些——把trap定义为是PC值被强行修改的行为这种行为既可能来源于用户代码的执行,也可能来自于外部。但它们都做过相同的事情:强行修改了当前的PC值,以及为了能强行修改PC值,也做了一些预备的工作。

trap的引入对于计算机来意义非凡。严格的说,OS的几大功能,进程管理、内存管理、虚拟内存、设备管理、文件管理几乎都需要trap。甚至可以说,如果没有trap,那么OS连基础的输入输出都没办法处理。我曾看到过一种较为民科的说法,它认为计算机可以看做一台状态机,状态随着指令的执行改变。如果没有输入和输出,那么计算机内的一切状态都是可以确定的,而输入输出恰恰打乱了计算机的状态,为计算机注入了活力。这种说法和我前面举的例子有点契合,也只是博人一笑的民科观点而已,但这也足以让我们了解到,trap对于计算机来说到底是多么强大的一项基础功能。

下面我们将仔细讨论trap的细节。

trap的硬件级支持以及中断隐指令

首先思考一个问题,在trap发生到修改PC值之前,这段时间内到底要完成哪些工作呢?

这个疑问在xv6 book中解释的十分清楚:

When it needs to force a trap, the RISC-V hardware does the following for all trap types (other than timer interrupts):
1. If the trap is a device interrupt, and the sstatus SIE bit is clear, don’t do any of the following.
2. Disable interrupts by clearing SIE. 
3. Copy the pc to sepc. 
4. Save the current mode (user or supervisor) in the SPP bit in sstatus. 
5. Set scause to reflect the interrupt’s cause. 
6. Set the mode to supervisor. 
7. Copy stvec to the pc.
8. Start executing at the new pc.

非常值得注意的是,这些工作都没有对应的代码,都是由硬件完成的,也就是许多OS课本中所说的“中断隐指令”。

如果这个trap是由设备引起的,而此时SIE(设备使能中断位)为0,则此时不允许响应设备的trap。

在响应这个trap之前,我们必须保存好那个被打断的程序的现场,在保存现场之前,必须首先关设备中断避免现场被其他中断打断破坏。注意到进程切换的中断也依赖于设备中断(Device Trap),因此在开中断之前,所执行的代码必定都是原子的。

riscv为处理trap提供了sstatuts、scause、stvec、sepc、sscratch等寄存器,这些寄存器都会被中断隐指令所使用到:

(1)stvec寄存器存放着trap wrapper的地址。当trap发生时,stvec的值将被读入到pc中

(2)scause记录这个trap的发生原因。当trap发生时,这个寄存器被设置

(3)sepc保存pc的值。在PC的值被更新为stvec之前,PC值被保存在这个寄存器中

(4)sstatuts的SPP位记录着当前CPU所处的mode。

(5)sscratch记录着这个进程的trapframe地址。保存现场时,需要将寄存器保存在trapframe上。

总而言之,当trap发生时,首先会“执行中断隐指令”,完成关中断设定trap相关寄存器等操作,而保护现场等操作,是由相应的指令执行的。

trap wrapper与stvec寄存器

stvec:trap处理入口函数

当“中断隐指令”执行完毕后,PC的值已经被修改为了stvec寄存器中的值。在xv6中,stvec的值只可能是uservec或者kernelvec这两个vec我称之为wrapper,即“外壳函数”。usertrap、keneltrap这两个函数都是直接由这两个vec调用的。

stvec记录着这些wrapper的入口,因此意义非凡。在xv6中对stvec寄存器的修改是通过调用w_stvec来实现的。我们不妨查看一下w_stvec在哪里被调用了:

void
trapinithart(void)
{
  w_stvec((uint64)kernelvec);
}

这个函数是内核初始化的时候调用的,将中断处理入口设置成了kernelvec。

void
usertrapret(void)
{
  struct proc *p = myproc();

  // turn off interrupts, since we're switching
  // now from kerneltrap() to usertrap().
  intr_off();

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

........
}

诶等等,你不是前面说过“在xv6中,stvec的值只可能是uservec或者kernelvec”么,那么这个 TRAMPOLINE + (uservec - trampoline) 又是什么东西?

不要慌,readelf,永远的神!

ms@ubuntu:~/public/MIT 6.S081/Lab5 cow/xv6-riscv-fall19$ readelf -a kernel/kernel | grep trampoline
    94: 0000000080008000     0 NOTYPE  GLOBAL DEFAULT    1 trampoline
ms@ubuntu:~/public/MIT 6.S081/Lab5 cow/xv6-riscv-fall19$ readelf -a kernel/kernel | grep uservec
   227: 0000000080008000     0 NOTYPE  GLOBAL DEFAULT    1 uservec
ms@ubuntu:~/public/MIT 6.S081/Lab5 cow/xv6-riscv-fall19$ readelf -a kernel/kernel | grep userret
   131: 0000000080008090     0 NOTYPE  GLOBAL DEFAULT    1 userret

得了,uservec - trampoline正好为0,即 TRAMPOLINE + (uservec - trampoline) 的值,正好就是uservec。简要总结一下:

当进程触发trap后,进入内核代码。由于内核代码的执行也可能会触发trap,因此在usertrap中,要设定stval值为内核的trap wrapper,即kernelvec这样进程进入内核后,如果再次触发trap,负责处理的是kernelvec和kerneltrap

当进程要返回用户态时,要重新设定stval的值为TRAMPOLINE。这样当用户再次进入内核态时,PC的值会被更新为TRAMPOLINE,即uservec,负责处理这个trap的是usertrap。

wrapper函数与多重中断

wrapper函数的功能就是保存与恢复现场。对于来自用户的trap,现场被保存在了trapframe中;而对于来自内核的trap,现场被保存在了内核栈上

kernelvec:
        // make room to save registers.
        addi sp, sp, -256

        // save the registers.
        sd ra, 0(sp)
        sd sp, 8(sp)
        sd gp, 16(sp)
        sd tp, 24(sp)
        sd t0, 32(sp)
        sd t1, 40(sp)
        sd t2, 48(sp)
......

保存现场到内核栈这个操作同样具有巨大的意义。由于只要发生trap,必定会进入内核态,因此不会存在多重的usertrap,也正是因此在kernel/proc.h中,进程只有一个trapframe。

而内核中可能发生多重中断(内核代码的执行也可能出错),处理多重中断必须要有能存放多个trapframe的空间。把内核的trapframe放到内核栈上,就可以实现这一点。如果执行内核代码的过程中发生trap,那就在内核栈上多加一层trapframe就行了。此外,usertrap如果发现trap的原因是系统调用,会执行intr_on,即允许设备中断:

  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->tf->epc += 4;

    // an interrupt will change sstatus &c registers,
    // so don't enable until done with those registers.
    intr_on();  // 系统调用下可以允许设备中断

    syscall();

此外,群友还提出了trapframe存放在内核栈上的另一个重大意义:

上述的保存寄存器是保存在该进程的内核栈上(这一点很重要,因为如果在kerneltrap中切换到其他进程,如kerneltrap中调用yield,这样做就能保证重新切换回来时,能够从该进程的内核栈上恢复寄存器)

源博客链接:https://blog.csdn.net/RedemptionC/article/details/108718347

在xv6 book中,也提示我们:

It’s worth thinking through how the trap return happens if kerneltrap called yield due to a timer interrupt.

这里我们可以得出一个没什么卵用,但xv6 book中没有提及的结论:xv6是可以实现多重中断的

xv6中的trap

Timer Trap

Timer Trap是由定时器触发的trap,这个trap既可以由usertrap处理,也可以由kerneltrap处理。即进程无论是处于S Mode还是U Mode,都应该能处理Timer Trap。

不过对于riscv(注意是riscv,不是xv6)来说,Timer Trap是一种非常特殊的trap,具体表现在以下几个方面:

(1)Timer Trap是由CPU中的定时器周期触发的,

(2)处理Timer Trap时,CPU处于M Mode,而非S Mode

(3)Timer Trap不可被屏蔽

(4)处理Timer Trap的代码不涉及虚地址的映射转换

我们前文中又提到,保存现场的操作必须是原子的,而Timer Trap无法通过关中断屏蔽掉,因此Timer Trap可能破坏当前CPU的现场。这样处理Timer Trap就成了一个相当棘手的问题。

但与之相应的,riscv也为Timer Trap提供了许多专用的寄存器。xv6采用了较为巧妙的办法来解决上述问题,即产生一个软中断将Timer Trap交给kernelvec来处理。我们查看一下kernel/kernelvec.S下处理Timer Trap的wrapper:timervec

timervec:
        # start.c has set up the memory that mscratch points to:
        # scratch[0,8,16] : register save area.
        # scratch[32] : address of CLINT's MTIMECMP register.
        # scratch[40] : desired interval between interrupts.
        
        csrrw a0, mscratch, a0
        sd a1, 0(a0)
        sd a2, 8(a0)
        sd a3, 16(a0)

        # schedule the next timer interrupt
        # by adding interval to mtimecmp.
        ld a1, 32(a0) # CLINT_MTIMECMP(hart)
        ld a2, 40(a0) # interval
        ld a3, 0(a1)
        add a3, a3, a2
        sd a3, 0(a1)

        # raise a supervisor software interrupt.
    li a1, 2
        csrw sip, a1

        ld a3, 16(a0)
        ld a2, 8(a0)
        ld a1, 0(a0)
        csrrw a0, mscratch, a0

        mret

当Timer Trap发生后,处理流程如下:

(1)PC的值将被替换为timervec的值,而原PC的值被保存在了另一个寄存器中(是啥我实在找不到了)

(2)将寄存器a0的值与mscratch置换

(3)将a1、a2、a3三个寄存器存放到内存的某处(是哪儿我也找不到了)

(4)为了让kerneltrap/usertrap发现这是一个设备中断,修改a1、a2、a3的值,并做一些其他操作

(5)修改sip为2,触发软中断(大概)

(6)kerneltrap/usertrap执行,调用devintr(),该函数返回2,于是判断这是一个设备中断,调用yield(),切换到新的进程

(7)本进程得到调度,从kerneltrap/usertrap中返回,回到timervec中

(8)恢复a0 — a3的值,从timervec中返回。

这样就实现了在不破坏现场的情况下处理Timer Trap。

Device Trap

Device Trap既可以被usertrap处理,也可以被kerneltrap处理,但其处理的过程比Timer Trap简单不少。

一个典型的Device Trap是由Console Driver触发的trap(见xv6 book P47)。Console Driver是UART的驱动设备,这个设备是通过qemu模拟的,负责处理从键盘上获得的输入字符串。当UART读取到字符时,会触发Device Trap,控制台将自己的设备id告知给内核,内核调用devinter,确认这是由控制台触发的trap,进入到处理输入的函数uartintr中:

// check if it's an external interrupt or software interrupt,
// and handle it.
// returns 2 if timer interrupt,
// 1 if other device,
// 0 if not recognized.
int
devintr()
{
  uint64 scause = r_scause();

  if((scause & 0x8000000000000000L) &&
     (scause & 0xff) == 9){
    // this is a supervisor external interrupt, via PLIC.

    // irq indicates which device interrupted.
    int irq = plic_claim();

    if(irq == UART0_IRQ){
      uartintr();
    } else if(irq == VIRTIO0_IRQ || irq == VIRTIO1_IRQ ){
      virtio_disk_intr(irq - VIRTIO0_IRQ);
    }

    plic_complete(irq);
    return 1;
  } else if(scause == 0x8000000000000001L){
    // software interrupt from a machine-mode timer interrupt,
    // forwarded by timervec in kernelvec.S.

    if(cpuid() == 0){
      clockintr();
    }
    
    // acknowledge the software interrupt by clearing
    // the SSIP bit in sip.
    w_sip(r_sip() & ~2);

    return 2;
  } else {
    return 0;
  }
}

查看一下uartintr的代码:

void
uartintr(void)
{
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }
}

consoleintr是向控制台打印字符的函数。

总的来说,UART处理输入是一个字符一个字符的处理的。每键入一个字符,就会触发一次Device Trap,进入到UART的驱动代码中。UART驱动程序从UART设备中读取一个字符,并将它打印到控制台上。在键入换行键之前,这些字符并不是读完就丢弃掉的,而是存放在了console.buf中。当键入换行键后,内核会从console.buf中将这行输入的字符串拷贝到用户态。

指令异常Trap

指令的执行异常同样会触发trap。最耳熟能详的例子就是“除零异常”。如果一条除法指令发现被除数为0,那么就会触发trap;另一个在做Lab经常遇到的trap就是page fault。当访问了某个虚地址p时。xv6会查看本进程的页表,核查相应的权限后,将这个地址翻译为实地址。如果访问权限发生错误,将会触发page fault trap。

系统调用

这个我想大家已经非常熟悉了。当然系统调用与前面的几种情况不同,在xv6中,进程是在用户态主动执行ecall指令触发trap的,这也是唯一一个主动触发trap的例子。触发trap时的机制与其他的trap大差不差,唯一的区别在于系统调用的编号为8,而且系统调用下可以继续允许设备中断(见前文中usertrap.c的代码)。

总结

xv6中的所有trap到目前为止已经总结完毕。前文中提到,xv6 book将trap分为三类,现在我们可以归纳到xv6 book的分类上来了:

(1)内核态下的trap。除了系统调用引发的trap,其他trap均可能发生

(2)用户态下的trap。与内核态下的trap相比,加上一个系统调用

(3)设备引发的trap。就是前文中所提到的Timer Trap和Device Trap。

sscratch的初始化

前文中提到,发生trap时,中断隐指令逻辑从sscratch中读取到trapframe应当存放的地址,这个值应该等于TRAPFRAME。那么,sscratch是什么时候被初始化为TRAPFRAME的呢?

如果想要理解sscratch的初始化,就需要对进程的分配代码有所了解。

当创建一个新进程时,会调用allocproc,allocproc的代码如下:

static struct proc*
allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();

  // Allocate a trapframe page.
  if((p->tf = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);
    return 0;
  }

  // An empty user page table.
  p->pagetable = proc_pagetable(p);

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof p->context);
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

allocproc成功返回后,进程的trapframe已经分配成功。注意后续的p->context.ra = (uint64)forkret。这意味着当进程被分配完成后,并第一次获得时间片时,会首先执行forkret。我们继续查看forkret的代码:

void
forkret(void)
{
  static int first = 1;

  // Still holding p->lock from scheduler.
  release(&myproc()->lock);

  if (first) {
    // File system initialization must be run in the context of a
    // regular process (e.g., because it calls sleep), and thus cannot
    // be run from main().
    first = 0;
    fsinit(minor(ROOTDEV));
  }

  usertrapret();
}

随后这个进程继续执行usertrapret:

void
usertrapret(void)
{
  struct proc *p = myproc();

  // turn off interrupts, since we're switching
  // now from kerneltrap() to usertrap().
  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->tf->kernel_satp = r_satp();         // kernel page table
  p->tf->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
  p->tf->kernel_trap = (uint64)usertrap;
  p->tf->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->tf->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);
}

注意最后一行那个稀奇古怪的函数。TRAPFRAME + (userret - trampoline)的地址,其实就是uservec下的userret。注意这个userret函数还会接受两个参数,其中第一个参数就是TRAPFRAME,trapframe的虚地址。这个参数存放到了a0寄存器中

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

在最后几行指令里,a0的值被存放在了sscratch中。虽然此时这个进程已经跑完了forkret、usertrapret,但它的具体程序代码还没有执行这个程序的入口地址此时被存放在了ra中,而最后的sret指令就是将PC的值更新为ra的值!这样在用户代码执行之前,sscratch寄存器就已经完成了初始化,随后才开始执行了用户代码。

抱歉我没有仔细查看指令的相应手册。在做Lab6(https://www.cnblogs.com/KatyuMarisaBlog/p/13948455.html)时经过调试我发现我弄混了sret指令和ret指令。ret指令会将PC值更新为ra的值,一般来说是U Mode下使用的指令。而sret指令一般是在S Mode下使用的指令,它的作用是利用epc寄存器的值来更新PC的值。执行sret后会开始执行用户代码的原因是我们在usertrapret中使用了w_sepc函数,该函数利用了p->tf->epc的值更新了epc寄存器。执行sret指令后,pc的值就被更新为了p->tf->epc的值。

后记

码到现在已经大半夜了,几个小时后开组会,没啥东西可以报的,现在信谁可能都救不了我了.....

 

posted @ 2020-11-06 01:27  KatyuMarisa  阅读(3240)  评论(2编辑  收藏  举报