Traps and system calls
这一章也是非常有趣,学完这一章后,对系统调用和traps有了非常深刻的代码认识,不仅仅停留在八股文字上。
上来课本就说啊, 有三种方式可以让CPU强制进入到一段特殊的代码中。这三种方式分别是: 系统调用, 异常(这里的命名可能过于粗暴),中断。
我记得csapp里面 分成了四种 分别是, interrupt, traps, fault and abort。 对应过来就是中断,陷入(系统调用),错误,以及 致命错误,并且把这些全部归为exception(异常控制流)。 教材中应该是吧后两者归为一类了。然后用syscall 代替traps 。然后把这些全部成为traps。定义上还有有所不同,但是做的事情都是那些。
这三个方式主要是通过 系统调用, 缺页中断, 以及硬件中断来触发(大部分情况下)xv6中traps 来表示所有的这些进入内核的操作。
书上一开始就说了xv6处理这些traps有四个阶段(但是现在肯定是晦涩难懂的,后面就好了):
-
cpu的硬件操作
-
汇编指路
-
陷入处理程序
-
具体的内核操作
看不懂没关系,后面全部一一介绍。
RISV traps machinery
首先来介绍一下一些硬件寄存器,后面全部都会用到,很重要。
- stvec: 这时一个地址,储存的是traps hanlder代码的地址,处理traps的时候CPU会根据这个地址跳转
- sepe: 这个用来储存 pc地址。从traps中返回的时候 要拷贝这里面的地址到pc中
- scause:存放的是数字,告诉内核traps 的原因
- sscratch: 存放了一个值,后面详细介绍这个的用法。
- sstatus: 表示一些标识
当发生一个traps的时候cpu硬件做了下面几件事:
- 如果是设备中断触发的traps,并且sstatus中的SIE被清空,表示现在不接受任何的中断,那么不执行下面的任何操作
- 清空SIE 屏蔽中断
- 将PC复制到sepc中
- 保存当前traps的状态,(user or kernel)同样在status中的SPP保存
- 将mode‘ 变成 kernelmode
- 将stvec 设置到pc中
- 开始执行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()的时候不拷贝物理内存,只是让新的进程的页表 有着与父进程相同的映射,这样就不存在浪费时间复制了,当然子进程的页表项中会将这也映射设置成只读不可写,当子进程需要写时,就会把内存的拷贝,然后在新的拷贝上面去写。
好了 总的思路就是这样,那么该如何去实现呢?
- 为每一个物理页 都设置一个 引用计数,因为新的子进程也会引用物理页面,所有当子进程销毁的时候,肯定不能直接的去销毁物理页,只有物理页的引用计数为0时,才能够去真正的销毁。当然这里具体的设置是创建一个int数组,大小就是所有页面的大小。所有的物理页被创建的引用的时候都会让其初始化为1, 当有新的进程引用时 ++;
- 修改
uvmcopy()
让其不复制物理内存,只是单纯的复制映射关系,然后新创建的页表中的页表项的标志也要进行设置成只读 - 当出现 page fault时,会跟系统调用一样 进入
usertrap()
函数中, 在usertrap()
中要判断 产生的原因,以及产生的地址,根据这个地址 去拷贝对于的物理页产生新的映射。
大概的流程就是这样。
ok 我们再来看看mmap如何实现的