系统调用

中断的概念

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

  • 系统调用,当当前程序执行ecall指令的时候

  • 异常:指令的执行出现问题,比如说除0等.内部

  • 中断:当设备传来需要中断的信号.外部

所以说对于中断的处理,基本上可以分成4步:

  • 硬件的中断响应.

  • 中断进入,进入内核态.

  • 进入中断例程,进入中断处理.

  • 中断返回

RISC-V中断寄存器

RISC-V保留了关键的寄存器来保存一些关键信息.

  • stvec寄存器:存储中断处理程序(例程)的第一条指令,当中断发生的时候RISC-V的CPU会跳转到stvec寄存器对应的地址.这个寄存器也叫中断入口寄存器

  • sepc寄存器:当中断发生的时候RISC-V CPU会保存当前PC寄存器的值在sepc中.

  • scause寄存器:表示中断的原因和来源,为什么会发生此中断.

  • sscratch寄存器:内核会放一个值在这里,这一个值对于中断程序的开始很有用.

  • sstatus寄存器:设置中断屏蔽的寄存器.

上述寄存器在U态下不可读写.

中断响应

  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并没有在中断响应的时候更换页表,这代表操作系统需要在处理中断的时候把页表替换成内核的页表,这个时候可能会出现缺页中断.

解决之道就是添加一个trampoline页,trampoline就是以个特殊的页,这个页包含了uservecuserret两部分,这个特殊的页作为页表项存在于内核态和所有的进程的页表中.所以说无论是什么进程请求系统调用,都可以进入到统一的trampoline的代码中.

由于stvec寄存器存储了uservec的地址,所以中断一开始的时候会进入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寄存器来制定下一次中断时栈帧的地址.

对于栈帧的处理同样需要页表,在操作系统中,对于每一个进程我们都会申请一个trapframe页,对于用户态的地址空间中,这个页的虚拟地址永远指定在TRAPFRAME这个地址上.但是每个进程的页表是相互独立的,那么trapframe页的物理地址是不一样的.

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

我们看到p->trapframe的初始化过程:p->trapframe = (struct trapframe)kalloc(),在初始化的时候就把物理地址给保存好了.这样问题就解决了.

由于kernel_trap已经保存到t0寄存器,那我们可以跳转到usertrap继续执行.

接着调用trap.c()中的usertrap函数,这个时候就已经进入内核态了,这个函数做下面的操作:

  • 修改stvec寄存器,因为对于在用户态和内核态发生中断请求,进入的中断程序是不一样的

  • 接着在trapframe里面保存sepc寄存器(就是中断的断点)

  • 对于不同的中断类型调用不同的处理函数如果trap是syscall的话,接着就调用syscall函数即可,如果是设备故障的话,就先保存设备的编号,如果不是设备中断的话就是指令的异常这个时候就退出就可以了.如果是时钟中断(which_dev==2)就处理一下.

//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
  //printf("run in usertrap\n");
  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);

  //printf("user trap scause:%p\n",r_scause());
  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("\nusertrap(): unexpected scause %p pid=%d %s\n", r_scause(), p->pid, p->name);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    // trapframedump(p->trapframe);
    p->killed = 1;
  }

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

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

  usertrapret();
}

syscall()处理

我们在usys.pl中编写了渲染中断的脚本:

sub entry {
    my $name = shift;
    print ".global $name\n";
    print "${name}:\n";
    print " li a7, SYS_${name}\n";
    print " ecall\n";
    print " ret\n";
}

这个时候我们知道,调用中断的中断号保存在a7寄存器中,我们可以读取a7寄存器的值,通过调用函数指针数组的值来跳转到对应的函数即可.

中断返回

中断返回的第一步就是调用usertrapret函数:

  • 写入stvec寄存器:一开始在进入内核的时候为了防止内核出现中断就把stvec寄存器改成kernelvec,现在改成uservec即可.

  • 处理trapframe把内核页表地址,内核栈和usertrap地址,CPU核号保存进去.

  • 改变sstatus寄存器.

  • 把断点地址写入到sepc寄存器中

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

进入userret这个函数之前,首先传递参数:TRAPFRAME作为第一个参数,第二个参数就是用户态页表的地址:

  • 加载用户态页表

  • 栈帧中存储的寄存器值全部加载到真实的寄存器中.

  • 栈帧头部的虚拟地址保存到sscratch寄存器

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

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

#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寄存器.

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函数等,但是对于传递指针的参数,我们就需要进行额外的处理,第一个问题呢就是我们不知道程序是不是友好的,有可能用户程序通过传递地址来修改内核的内存,这样就导致了不安全的情况的发生.第二个问题就是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处copymax字节的元素到内核页表的dst处.做法就是调用walkaddr来找到pagetablesrcva对应的物理地址,然后从这个物理地址中拷贝字节到dst中.

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

Last updated