Traps and system calls

Traps and system calls

这一章也是非常有趣,学完这一章后,对系统调用和traps有了非常深刻的代码认识,不仅仅停留在八股文字上。

上来课本就说啊, 有三种方式可以让CPU强制进入到一段特殊的代码中。这三种方式分别是: 系统调用, 异常(这里的命名可能过于粗暴),中断。

我记得csapp里面 分成了四种 分别是, interrupt, traps, fault and abort。 对应过来就是中断,陷入(系统调用),错误,以及 致命错误,并且把这些全部归为exception(异常控制流)。 教材中应该是吧后两者归为一类了。然后用syscall 代替traps 。然后把这些全部成为traps。定义上还有有所不同,但是做的事情都是那些。

这三个方式主要是通过 系统调用, 缺页中断, 以及硬件中断来触发(大部分情况下)xv6中traps 来表示所有的这些进入内核的操作。

书上一开始就说了xv6处理这些traps有四个阶段(但是现在肯定是晦涩难懂的,后面就好了):

  1. cpu的硬件操作

  2. 汇编指路

  3. 陷入处理程序

  4. 具体的内核操作

看不懂没关系,后面全部一一介绍。

RISV traps machinery

首先来介绍一下一些硬件寄存器,后面全部都会用到,很重要。

  • stvec: 这时一个地址,储存的是traps hanlder代码的地址,处理traps的时候CPU会根据这个地址跳转
  • sepe: 这个用来储存 pc地址。从traps中返回的时候 要拷贝这里面的地址到pc中
  • scause:存放的是数字,告诉内核traps 的原因
  • sscratch: 存放了一个值,后面详细介绍这个的用法。
  • sstatus: 表示一些标识

当发生一个traps的时候cpu硬件做了下面几件事:

  1. 如果是设备中断触发的traps,并且sstatus中的SIE被清空,表示现在不接受任何的中断,那么不执行下面的任何操作
  2. 清空SIE 屏蔽中断
  3. 将PC复制到sepc中
  4. 保存当前traps的状态,(user or kernel)同样在status中的SPP保存
  5. 将mode‘ 变成 kernelmode
  6. 将stvec 设置到pc中
  7. 开始执行traps handle 处理程序

注意到上述的过程并没有切换页表 只是切换了pc指针和运行模式而已。

Traps from user space

这一讲我打算先完全抛弃课本的内容,先展示一下一个完成的系统调用是如何进行的,然后再回过头来看课本就会非常的容易理解。

我们用write()这个系统调用来作为例子。

当用户调用write() 的时候,一个pl脚本文件就会去执行相应的汇编代码
请添加图片描述

这个汇编其实也就是 将write所对应的系统调用编号写入到 寄存器a7 中 然后直接执行ecall。

执行ecall后呢 硬件会进行三件事情:

  • 将当前的模式从user变成supervisor模式
  • 将当前的pc指针保存在sepc寄存器中
  • 读stvec寄存器的地址,跳转到trappoline代码中

除了sepc 保存pc指针的值外,stvec保存的是trampoline中代码的第一行地址。而sscratch 寄存器保存的是trapframe的地址。

而trapoline 翻译成中文就是跳板,也就是连接 用户态和内核代码的中间程序,不过是要在内核态中执行。 这里面有两种代码 一个是uservec ,一个是userret。 分别表示的是用户代码到内核代码要执行的, 和从内核代码返回到用户代码要执行的。所以现在来看前者

请添加图片描述

首先执行的是 csrrw 指令,该指令是不用其他寄存器的情况下,直接将a0 与 sscratch交换值,此时a0的值就保存在寄存器中了,而a0现在的值就是trapframe的地址。将用户寄存器的值全部保存在已经分配好的trapframe页中。

这里不使用其他寄存器交换的操作原因是 其他寄存器很可能会保存重要的内容,我们在保存之前不能覆盖。

值得注意的是 在trapfram中的最开始40字节的地址是没有被用来储存寄存器的,原因是这些地方最开始内核初始化的时候就有东西在里面了,这些空间不是用来保存东西的,而是给用户来加载东西的。

请添加图片描述

包括内核栈地址,内核页表地址,跳转到哪一个内核指令上,第四个是用来保存用户的pc指针的(前面提导了ecall会将pc值保存在sepc中,但是该寄存器可能在内核中执行一些调用的时候被更改,地址保存在寄存器中永远不是好方法,所以我们仍需要将pc指针的值保存在内存中也就是这里,虽然trampoline中没有写这个操作,但是在usertrap的c代码中会有仔细写) 最后一个有关当前进程运行在哪一个cpu上。

请添加图片描述

现在各种用户寄存器中的值都保存在trapframe中了,所以可以任意使用了,值得注意的是在上面刚进入trampoline的过程中,我们将a0保存在了sscratch中,此时我们需要重新获得a0的值然后继续保存在trapframe中,然后将trapfram中前几个值(内核在创建进程的时候就已经设置好了)加载到对应的寄存器中。然后切换到内核页表。最后跳转进入t0,也就是usertrap()c代码。

为什么这里切换内核页表后,还能够执行继续成功的执行用户区的代码?

原因是内核在给每一个用户进程分配内存的时候,都会将同一段代码的,也就是这列的trampoline代码映射给用户的虚拟空间,同时这段代码的用户的虚拟空间地址,与内核中的虚拟空间地址是一样的,所以尽管切换了代码,但是还是能够在内核页表的映射下也能正确执行。

ok 这样就成功进入了内核代码的 usertrap()中。

再来看看usertrap()

//
// 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);//重新设置stvec,为什么呢?
						 //内核中发生中断的处理方式肯定和用户不一样							  所以要用不同的traps handler code 
  struct proc *p = myproc();//获取当前进程
  
  // save user program counter.
  p->trapframe->epc = r_sepc();//保存用户代码的pc
  
  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;//那么返回的pc一个是原来的下一跳指令

    // 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();//执行返回程序
}

执行返回程序和trampoline就不仔细介绍了,跟前面的非常相似。

Calling system calls

前面我们跳过了 内核中 syscall()这个函数 现在我们来看一下内核如何执行系统调用的。

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

System call arguments

现在我们知道了如何trap 进内核执行系统调用,但是不要忘了我们执行系统调用的时候很多时候都有参数传递,那么用户态的参数如何传递到内核中呢?

在用户态执行普通的函数 如果参数不是很多的话我们会把参数直接放入到对应的寄存器中(x86 是 什么%rdx %rcx 之类的吧 有点忘了)。

这里我们用sys_read 来 看看如何获得用户态的参数。

uint64
sys_read(void)
{
  struct file *f;
  int n;
  uint64 p;

  if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
    return -1;
  return fileread(f, p, n);
}
//很显然 这里用 argfd  argint  argaddr  来分别获取file  int 和addr 来获取不同的参数
//但是本质上都是用  addrraw 获取初始参数 后在进行修改

//addraw 会根据传入参数的索引 来从 trapframe页中读取相应的数据
//其实还是trampoline 执行过程中将原本用户态寄存器中的数据 保存在trapframe页中了。
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;
  }
  panic("argraw");
  return -1;
}

这里还有一个关键的问题,传递普通类型的参数没什么要注意,但是如果要传递的是某个地址,比如read() 里面的传入的就是一个用户态的地址,要求内核把它填满。 那么显然内核态用的页表与用户态不一样,这个地址肯定也不能直接使用,所以这里就有两个函数copyin() copyout() ,也是之前vm.c没有提到的

//传入参数就是用户态的页表,然后源地址和目标地址,源地址肯定就是内核里面的地址,目标地址就是用户地址,所以要先walkaddr求出真正的物理地址然后再进行拷贝。这个拷贝的循环设置有点意思,可以仔细看看。
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (dstva - va0);
    if(n > len)
      n = len;
    memmove((void *)(pa0 + (dstva - va0)), src, n);

    len -= n;
    src += n;
    dstva = va0 + PGSIZE;
  }
  return 0;
}
//跟上面类似,只不过是将用户态的数据拷贝进内核态罢了。
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > len)
      n = len;
    memmove(dst, (void *)(pa0 + (srcva - va0)), n);

    len -= n;
    dst += n;
    srcva = va0 + PGSIZE;
  }
  return 0;
}

在内核中发生trap这一节就跳过了。有兴趣自己可以看看。

Page-fault exceptions

ok 来到lab的重点章节了。这一节主要是讲的page-fault的作用。好处大大的有。试想一下 如果任何的fault都是直接关闭进程等硬手段那可能会有点无聊。xv6中相反充分利用了page-fault 来实现很多节约内存的功能。

第一个就是实现copy-on-write (COW) 写时复制的fork。首先来看一下原始的fork是怎么样的

int
fork(void)
{
  int i, pid;
  struct proc *np;
  struct proc *p = myproc();

  // Allocate process.
  if((np = allocproc()) == 0){
    return -1;
  }

  // Copy user memory from parent to child.
  if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){ //拷贝所有的物理页
    freeproc(np);
    release(&np->lock);
    return -1;
  }
  np->sz = p->sz;

  np->parent = p;

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);

  // Cause fork to return 0 in the child.
  np->trapframe->a0 = 0;

  // increment reference counts on open file descriptors.
  for(i = 0; i < NOFILE; i++)
    if(p->ofile[i])
      np->ofile[i] = filedup(p->ofile[i]);
  np->cwd = idup(p->cwd);

  safestrcpy(np->name, p->name, sizeof(p->name));

  pid = np->pid;

  np->state = RUNNABLE;

  release(&np->lock);

  return pid;
}

简单来说fork() 调用uvmcoppy() 将父进程的所有物理页面全部拷贝给子进程。那么这样做有什么坏处呢?我们很多时候 fork一个进程是为了 exec 新的进程,那么我们刚复制了那么多的页面全部被丢弃,重新执行exec 这样不就非常的浪费吗?所以出现了COW。

COW 的总体思路就是fork()的时候不拷贝物理内存,只是让新的进程的页表 有着与父进程相同的映射,这样就不存在浪费时间复制了,当然子进程的页表项中会将这也映射设置成只读不可写,当子进程需要写时,就会把内存的拷贝,然后在新的拷贝上面去写。

好了 总的思路就是这样,那么该如何去实现呢?

  1. 为每一个物理页 都设置一个 引用计数,因为新的子进程也会引用物理页面,所有当子进程销毁的时候,肯定不能直接的去销毁物理页,只有物理页的引用计数为0时,才能够去真正的销毁。当然这里具体的设置是创建一个int数组,大小就是所有页面的大小。所有的物理页被创建的引用的时候都会让其初始化为1, 当有新的进程引用时 ++;
  2. 修改 uvmcopy() 让其不复制物理内存,只是单纯的复制映射关系,然后新创建的页表中的页表项的标志也要进行设置成只读
  3. 当出现 page fault时,会跟系统调用一样 进入usertrap()函数中, 在usertrap()中要判断 产生的原因,以及产生的地址,根据这个地址 去拷贝对于的物理页产生新的映射。

大概的流程就是这样。

ok 我们再来看看mmap如何实现的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值