中断和系统调用

在RISC-V中有三种事件会使得CPU放弃对当前执行的程序的运行转而去处理这些事件.

  • 系统调用,当当前程序执行ecall指令的时候
  • 异常:指令的执行出现问题,比如说除0等.内部
  • 中断:当设备传来需要中断的信号.外部

我们首先先注意到一点就是CPU进入到中断然后从中断中恢复,程序本身是不可查的,也就是说程序并不知道它被中断了,犹如做了一个梦一样,这是非常重要的,就是怎么进入中断,怎么样从中断回来.

有了这么一个基本的要求,我们可以得到大概的处理思路,基本上来说就是中断首先要进入内核的状态进行处理.并且可以分成4步:RISC-V的CPU首先在硬件层面上作出一些反应,接着就是执行一段汇编代码来进入到内核状态.进入到内核状态后就是一段中断例程,这个程序是所有中断共享的,然后再根据中断的类型不同再进入到不同的中断处理的程序.

对于中断我们又可以分成三类,对于这三类有不同的做法,分别是内核态中断,用户态中断和时钟中断.对于处理中断的程序,我们一般称为handler.

RISC-V硬件

首先RISC-V有几个处理中断的硬件结构:

  • stvec寄存器:存储中断处理程序(例程)的第一条指令,当中断发生的时候RISC-V的CPU会跳转到stvec寄存器对应的地址.这个寄存器也叫中断入口寄存器
  • sepc寄存器:当中断发生的时候RISC-V CPU会保存当前PC寄存器的值在sepc中.
  • scause寄存器:表示中断的原因和来源,为什么会发生此中断.
  • sscratch寄存器:内核会放一个值在这里,这一个值对于中断程序的开始很有用.
  • sstatus寄存器:设置中断屏蔽的寄存器.

上述寄存器在U状态下不可读写.并且上述的寄存器还有一个M开头的版本,用于处理M模式下的中断.对于每一个CPU都有一套寄存器来管理程序运行.

那么硬件具体会做什么呢;

  1. 如果当前中断是设备中断,并且sstatus寄存器内设置了屏蔽,就不做任何事.
  2. 设置sstatus寄存器的值,屏蔽中断.
  3. 把当前PC寄存器的值copy给sepc寄存器.
  4. 保存当前的模式,在sstatus寄存器.
  5. 设置scause,保存中断的原因.
  6. 设置当前状态为S态.
  7. 把stvec寄存器的值给PC.
  8. 转而执行PC寄存器对应的指令.

用户态的中断

这里讲述了当执行用户态的代码的时候会发生什么.

当用户段代码出现了中断现象的时候,首先就会执行uservec->usertrap->中断处理 ->usertrapret->userret.

对于RISC-V的处理中,主要是内核态空间和用户态空间都维持了页表,但是RISC-V的硬件并没有在中断发生的时候在硬件的层面上更换页表,所以说xv6操作系统需要在处理中断的时候把页表替换成内核的页表,并且这个内核的页表可以与stvec寄存器的值对应,不会发生缺页中断.

xv6的解决之道就是添加一个trampoline页,trampoline就是以个特殊的页,这个页包含了uservec和userret两部分,并且这个页存在于所有进程的页表,自然也存在于内核态空间下的页表.并且这个页是分配在虚拟地址空间的最后一个部分,所以说很难与用户进程发生冲突.

这个trampoline页存在于任何一个进程和内核的页表,并且映射的虚拟地址都是一样的,定义在TRAMPOLINE这个C语言宏中.并且stvec这个寄存器存储的地址,就指向trampoline这个页的uservec这个部分,所以说当用户态发生中断的时候,RISC-V硬件处理完之后就可以立刻转化成内核态然后接着运行.因为U态和S态的页表是部分一样的,起码对于trampoline的记录是一样的

image-20220309213655516

image-20220309210759489

由于stvec寄存器存储了userret的地址,所以中断一开始的时候会进入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 sp, 48(a0)
        sd gp, 56(a0)
        sd tp, 64(a0)
        sd t0, 72(a0)
        sd t1, 80(a0)
        sd t2, 88(a0)
        sd s0, 96(a0)
        sd s1, 104(a0)
        sd a1, 120(a0)
        sd a2, 128(a0)
        sd a3, 136(a0)
        sd a4, 144(a0)
        sd a5, 152(a0)
        sd a6, 160(a0)
        sd a7, 168(a0)
        sd s2, 176(a0)
        sd s3, 184(a0)
        sd s4, 192(a0)
        sd s5, 200(a0)
        sd s6, 208(a0)
        sd s7, 216(a0)
        sd s8, 224(a0)
        sd s9, 232(a0)
        sd s10, 240(a0)
        sd s11, 248(a0)
        sd t3, 256(a0)
        sd t4, 264(a0)
        sd t5, 272(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

对于中断操作,我们知道我们得把所有的寄存器存放到内存中,但是对于RISC-V的汇编语言,我们还得有一个寄存器来存储应该访问的内存的地址.但是通用寄存器都已经失去了作用了,所以说RISC-V提供一个寄存器叫做sscratch寄存器,这个时候就可以把a0先暂时存储到sscratch寄存器中,然后再把a0从sscratch寄存器中取出.在这里这个寄存器主要是存放了栈帧的首地址,新的栈帧就会存放在sscratch表示的地址中,在原文中提到,在返回到U态时,内核通过设置sscratch寄存器来制定下一次中断时栈帧的地址.

对于栈帧的处理同样需要页表,在xv6系统中,对于每一个进程我们都会申请一个trapframe页,这个页的虚拟地址永远指定在TRAPFRAME这个地方上.栈帧元素的一系列初始化都是在进程创建的时候都已经保存好了.其实所有进程都会有一个栈帧,并且栈帧的虚拟地址是一样的,但是虚拟地址是一样的由于每个进程的页表又不是一样的,所以说对应的物理地址是不一样的.

那对于内核态的代码,我们不能通过TRAPFRAME这个虚拟地址来访问进程的栈帧结构,那么我们应该怎么办呢?

我们看到p->trapframe的构造过程.首先就是p->trapframe保存的是kalloc直接分配的物理地址,p->trapframe = (struct trapframe *)kalloc(),直接保存的物理地址.

对于每一个进程,首先要申请一个页面,然后把这个页面的物理地址保存到p->trapframe这个结构中,接着每个进程都要调用proc_pagetable函数,执行mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p->trapframe), PTE_R | PTE_W),把这个物理地址映射到TRAPFRAME这个va中.

所以说对于每个进程,在用户态访问trapframe都是访问TRAPFRAME这个va,由于每个进程的页表映射不同导致最后的实际物理地址不同.

综上所述:内核使用p->trapframe保存的物理地址访问栈帧结构,所有的用户态程序使用TRAPFRAME这个同样的虚拟地址访问栈帧结构,但是由于页表不同导致访问的实际物理地址不一样.

最后就是进程进入到内核态,访问p->trapframe就是物理地址,就不会访问TRAPFRAME这个虚拟地址

由于栈帧已经保存好了内核栈的地址,内核页表的地址,以及CPU的核号,所以说接下来的操作就是读取栈帧,读取内核栈地址,内核页表的地址以及下一个trap函数的入口地址.

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

接着调用trap.c()中的usertrap函数,这个时候就已经进入内核态了,首先第一步就是对stvec寄存器进行修改,因为对于用户态和内核态发生中断,进入的中断程序还是不一样的,然后接着在trapframe里面保存sepc寄存器(就是中断的断点),因为有可能调用yield(),所以说保存断点非常有必要.如果trap是syscall的话,接着就调用syscall函数即可,如果是设备故障的话,就先保存设备的编号,如果不是设备中断的话就是指令的异常这个时候就退出就可以了.如果是时钟中断(which_dev==2)就处理一下.

这个就是中断处理,对于不同类型的中断有不同的处理,处理完之后就要返回U态了

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

首先第一步就是调用usertrapret函数,这个函数首先第一步就是做stvec寄存器的写入,回忆一下:一开始在进入内核的时候为了防止内核出现中断就把stvec寄存器改成kerbelvec,现在要返回U态了就把中断入口改成uservec即可.然后就是处理栈帧了,把内核页表地址,内核栈和usertrap地址,CPU核号保存进去.接着就是改变status寄存器的数值,改成用户态的寄存器,然后调取断点地址,把断点地址写到sepc寄存器里面(这样子就是进入内核态保存用户态断点,退出内核态的时候把断点进行加载,防止内核态也出现中断),接着切换页表,切换到用户态的页表,然后接着跳转到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

这个时候会进行函数调用,进入userret这个函数之前,TRAPFRAME作为第一个参数,第二个参数就是用户态页表的地址,首先第一步就是加载用户态页表(处理逻辑:先获得satp,再加载到a1寄存器,接着取出来),接着就是把栈帧中存储的寄存器值全部加载到真实的寄存器中,最后一步就是把栈帧头部的虚拟地址保存到sscratch寄存器,下一次执行中断操作的时候就可以直接读取sscratch寄存器的内容确定栈帧的地址.

最后执行sret,把sepc寄存器的内容给pc,转换为U态,中断结束

总结下来: 导出保存在寄存器的栈帧首虚拟地址->把寄存器保存到trapframe中->加载内核态页表->存储断点->执行中断处理->加载断点->加载用户态页表->把trapframe的内容加载到真实的寄存器->把栈帧首地址放入寄存器中.

其实内核可以修改trapframe中的寄存器值,在中断结束后再把栈帧的值加载到真实的寄存器中.

调用系统函数.

我们接着第二章来说,在执行userinit函数之后,就执行initcode.S

#include "syscall.h"

# exec(init, argv)
.globl start
start:
        la a0, init
        la a1, argv
        li a7, SYS_exec
        ecall

# for(;;) exit();
exit:
        li a7, SYS_exit
        ecall
        jal exit

# char init[] = "/init\0";
init:
  .string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
  .long init
  .long 0

这是一个标准的调用系统调用的样本,a0~a6存储系统调用需要的参数,a7传递了系统调用号,表示执行何种系统调用,传递完参数后就执行ecall.ecall是一个硬件指令,会把状态调整为S态然后执行uservec函数,接着就是我们熟知的trap处理函数.

在syscall()函数中,我们可以知道这个函数根据a7寄存器表示的系统调用号来找到函数指针然后进行调用,这里构思很巧妙,就是构建系统调用函数指针来进行跳转.

接着返回的时候就把返回值传递给a0寄存器.

static uint64 (*syscalls[])(void) = {
[SYS_fork]    sys_fork,
[SYS_exit]    sys_exit,
[SYS_wait]    sys_wait,
[SYS_pipe]    sys_pipe,
[SYS_read]    sys_read,
[SYS_kill]    sys_kill,
[SYS_exec]    sys_exec,
[SYS_fstat]   sys_fstat,
[SYS_chdir]   sys_chdir,
[SYS_dup]     sys_dup,
[SYS_getpid]  sys_getpid,
[SYS_sbrk]    sys_sbrk,
[SYS_sleep]   sys_sleep,
[SYS_uptime]  sys_uptime,
[SYS_open]    sys_open,
[SYS_write]   sys_write,
[SYS_mknod]   sys_mknod,
[SYS_unlink]  sys_unlink,
[SYS_link]    sys_link,
[SYS_mkdir]   sys_mkdir,
[SYS_close]   sys_close,
};
//系统调用号,系统调用函数
void
syscall(void)
{
  int num;
  struct proc *p = myproc();

  num = p->trapframe->a7;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    p->trapframe->a0 = syscalls[num]();
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

系统调用的参数.

系统调用会传递参数进入,对于RISC-V来说,朴素的思想就是把参数传递到寄存器中,然后系统调用函数读取存储在寄存器中的数据,比如说argint和atgaddr,argfd等.

对于直接传递的参数,我们可以直接读取没有大问题,但是对于传递指针的参数,我们就需要进行额外的处理,第一个问题呢就是我们不知道程序是不是友好的,有可能用户程序通过传递地址来修改内核的内存,这样就导致了不安全的情况的发生.第二个问题就是xv6的内核态和用户态页表是不一样的.

所以说xv6的做法就是对于获得字符串的函数argstr(),去构建一个新的函数fetchstr去安全地获得数据,这个函数就会调用copyinstr()函数.

int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  uint64 n, va0, pa0;
  int got_null = 0;

  while(got_null == 0 && max > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > max)
      n = max;

    char *p = (char *) (pa0 + (srcva - va0));
    while(n > 0){
      if(*p == '\0'){
        *dst = '\0';
        got_null = 1;
        break;
      } else {
        *dst = *p;
      }
      --n;
      --max;
      p++;
      dst++;
    }

    srcva = va0 + PGSIZE;
  }
  if(got_null){
    return 0;
  } else {
    return -1;
  }
}

这个函数会从pagetable这个页表对应的虚拟地址srcva处copy max字节的元素到内核页表的dst处.做法就是调用walkaddr来找到pagetable中srcva对应的物理地址,然后从这个物理地址中拷贝字节到dst中,由于现在是S态,所以说页表是内核态对应的页表,所以说提取用户态地址上的数据要查找用户态页表,然后把数据存放到内核态的地址就直接查询内核态页表就可以了.

总体的思路就是,找到这个用户态虚拟地址对应的物理地址,取出这个物理地址上的元素,给到dst(内核态虚拟地址).

内核态引发的中断

在内核态引发中断,由于stvec寄存器已经发生了改变,所以进入的中断程序已经是kernelvec这个程序了

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)
        sd s0, 56(sp)
        sd s1, 64(sp)
        sd a0, 72(sp)
        sd a1, 80(sp)
        sd a2, 88(sp)
        sd a3, 96(sp)
        sd a4, 104(sp)
        sd a5, 112(sp)
        sd a6, 120(sp)
        sd a7, 128(sp)
        sd s2, 136(sp)
        sd s3, 144(sp)
        sd s4, 152(sp)
        sd s5, 160(sp)
        sd s6, 168(sp)
        sd s7, 176(sp)
        sd s8, 184(sp)
        sd s9, 192(sp)
        sd s10, 200(sp)
        sd s11, 208(sp)
        sd t3, 216(sp)
        sd t4, 224(sp)
        sd t5, 232(sp)
        sd t6, 240(sp)

	// call the C trap handler in trap.c
        call kerneltrap

但是由于已经在内核态了,所以说就不需要切换堆栈,也不需要切换页表,也不需要构建trapframe了,直接把寄存器的数值存到堆栈,调用处理内核中断的函数kerneltrap.

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

kerneltrap就只用处理两种类型的中断了,分别是设备I/O和指令执行错误.在这里和usertrap其实是一样的,先获得设备号,如果设备号没有,那就是指令执行错误,打出错误信息

当然也是一样yiled()执行完了之后会导致其他进程执行,其他进程的时候会继续引发中断,所以说朴素的思想就是把sepc保存下来,再最后中断返回的时候把保存的sepc写入即可.

// restore registers.
        ld ra, 0(sp)
        ld sp, 8(sp)
        ld gp, 16(sp)
        // not this, in case we moved CPUs: ld tp, 24(sp)
        ld t0, 32(sp)
        ld t1, 40(sp)
        ld t2, 48(sp)
        ld s0, 56(sp)
        ld s1, 64(sp)
        ld a0, 72(sp)
        ld a1, 80(sp)
        ld a2, 88(sp)
        ld a3, 96(sp)
        ld a4, 104(sp)
        ld a5, 112(sp)
        ld a6, 120(sp)
        ld a7, 128(sp)
        ld s2, 136(sp)
        ld s3, 144(sp)
        ld s4, 152(sp)
        ld s5, 160(sp)
        ld s6, 168(sp)
        ld s7, 176(sp)
        ld s8, 184(sp)
        ld s9, 192(sp)
        ld s10, 200(sp)
        ld s11, 208(sp)
        ld t3, 216(sp)
        ld t4, 224(sp)
        ld t5, 232(sp)
        ld t6, 240(sp)

        addi sp, sp, 256

        // return to whatever we were doing in the kernel.
        sret

最后返回的时候也是只需要把寄存器取出来即可.

缺页中断

当访问一个va,但是页表中没有对应的pa的时候,就会引发缺页中断,系统会处理这个缺页中断或者退出执行,或者分配一个新的页给这个进程.


0 条评论

发表评论

Avatar placeholder

您的电子邮箱地址不会被公开。 必填项已用*标注

隐藏