先导知识
有三种方式能够让CPU进入到特权模式:系统调用、中断和异常。
CPU层面的支持
首先需要介绍一下CPU中的寄存器支持。
- stvec:存放中断处理程序的地址
- sepc:将当前将要被中断的程序的PC放到这里。因为PC马上就要放入stvec中的地址了。
- scause:描述了发生trap的原因。
- sscratch:这个值是内核放的,在后面处理的时候非常方便。
- sstatus:这其中有两个位非常重要,一个是SIE,用来屏蔽中断的。还有一个是SPP,用来控制是用户模式还是监督者模式。
还有一个寄存器,也挺重要的,satp用来指向当前所用的页表。
硬件过程
有了CPU的寄存器介绍,就可以稍微简单来看下硬件发生一次trap的时候具体是怎么操作的(定时器的中断是一个例外)。
- 如果发生的是设备中断,而且发生当前屏蔽了中断,那就直接不管,也没有下面那么多过程了。所以如果在屏蔽中断的时候,此时磁盘完成读写,那消息就丢掉了。
- 禁用中断(防止在中断的时候被中断),就是把SIE给清除掉。
- 把当前程序的PC赋值到sepc寄存器中。
- 把目前是用户态还是监督者(内核态)信息记录在SPP。
- 设置好scause寄存器。
- CPU把模式转换成监督者模式(也就是x86中的ring0)
- 把stvec的值写入到pc中,这样就可以执行中断处理程序了。
仔细看一看会发现,基本上就处理了上面的4个寄存器,除此之外,内核栈的切换、页表的切换等等完全没有做。这是为了留给操作系统最大的灵活性,让操作系统自己来实现。
用户空间的trap
根据xv6的实际代码来分析一下。我们在之前分析过,tramponline所有的进程的,内核的虚拟地址都是一样的,都是虚拟内存最顶部的位置。
初始化
首先是操作系统启动的时候,在main.c中,有这么几行:
1 | trapinit(); // trap vectors |
这两个对应的函数实现都是trap.c中。trapinit()其实就是上了一把锁,而trapinithart()就是把中断处理程序kernelvec的地址写入到了stvec里面。
一次用户空间的trap具体流程是uservec(汇编) -> usertrap(C代码) -> usertrapret (C代码)-> userret(汇编),还挺工整的。
uservec
这个函数是用汇编代码写的,而且它就在最上面的traponline中。这是因为硬件本身不切换页表,需要软件实现。所以在用户的进程中,必须有对应的映射能够找到真正的处理程序。
这个地址非常特殊,它必须在用户和内核页表中相同的虚拟地址处。因为切换页表之后,肯定希望继续执行原来的uservec的,而如果内核页表和用户页表的uservec所在的页表不在同一个虚拟地址处,就会发现在执行切换页表之前的代码是正确的,但是一旦切换了页表之后,之后的代码就全部乱套了。
这里有一个让人非常疑惑的点,就是在上一个lab中,明明已经实现了每个进程一个内核页表,这样只需要使用一个页表就可以了,即在从用户态进入内核态的时候不需要切换页表;但是到了这个lab中,我们又退回到内核一个页表+每个进程一个用户页表的原始状态了。
接下来具体看看这个函数的具体逻辑:
开始执行的时候,所有的32个(个人理解这32个应该不包括sscratch)寄存器存放的都是用户之前的值,但是我们现在要去修改这些寄存器的值,所以这个时候sscratch寄存器就派上了用场。最开始的时候,我们交换了a0和sscratch的值,相当于a0被sscratch存起来了,此时的a0寄存器就被解放出来了,那么就可以让a0指向一页,这一页里面可以存放所有的其余的寄存器的值。而a0的值其实就是sscratch的值嘛,所以我们在最最开始的时候就可以设置好sscratch指向这一页。
然后是把sscratch(这里的其实是a0的值)放到对应的值里面。再把一些值从对应的地址处放到寄存器中。最后的最后,切换到内核页表,并且跳转到对应的kernel_trap处执行。
usertrap
到了这里之后,就设置stvec的值为kernelvec,并且将用户的PC保存到对应的frame中。然后判断当前的trap的类型:
- 如果是syscall,那就把用户的PC+4(为了之后继续执行用户的代码),开启设备中断,即打开SIE,还记得硬件在trap层面的时候把SIE关掉了么?
- 如果是设备,那么就执行对应的处理(不是这里的重点,跳过)
- 否则就是我们这里不了解的trap了,报错。
然后调用usertrapret()
usertrapret
关闭SIE。并且设置对应的frame的值为目前进村器的值(为了能够在下次进入trap的时候恢复现场),这些值包括了内核的页表、内核栈、内核的trap地址以及cpu的ID。
然后是设置一些函数需要的东西。
userret
基本上就是uservec反着来一遍就可以了。
内核空间的trap
内核空间的trap和用户空间的trap的不同之处在于,已经在内核了,所以可以完全信任页表还有栈。也就是不需要把p->trapframe->kernel_sp中的值放入到栈指针里面了。所以内核空间引发的trap只需要两个函数,分别是kernelvec和kerneltrap。
kernelvec
首先开辟足够的栈空间(目前已经是内核栈了),然后把寄存器的值放入到栈空间。然后利用call指令执行kerneltrap,执行完成之后再把内核栈中的之前存的寄存器弹出来即可。
kerneltrap
kerneltrap被设计用来处理两种中断,分别是设备的中断或者是exceptions。其中exception是xv6处理不了的,所以直接panic即可。
RISC-V assembly(简单)
哪个寄存器存放了printf函数的参数13?a2。因为a0存放返回值,a1返回第一个参数12,a2返回第二个参数13。
f和g这两个,是在哪里调用的?提示,可能会有inline。被编译器优化掉了,所以看不到了,编译器直接把答案算出来了。
printf这个函数在内存的哪里?
0x0000000000000628在
jalr 1528(ra)这条指令之后,寄存器ra的值是多少?不太了解,应该就是printf返回的地址,所以应该是exit的地址,所以应该是0x38```c
// 运行这个程序,输出什么?
unsigned int i = 0x00646c72;
printf(“H%x Wo%s”, 57616, &i);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
会输出`He110 World`,因为`0xe110` = `57616`。riscv是小端序,如果在大端序中的计算机中,那么i和57616需要修改吗?57616不需要,而i需要改成`0x72626400`
- 这个会输出什么?`printf("x=%d y=%d", 3);`,显然x会输出3,而y应该会输出在寄存器a2的值,但是我们并不能保证a2的值是什么,所以应该会输出一个随机的值。
## Backtrace(中等)
当错误发生的时候,打印一下堆栈的值对于调试来说是很重要的,这一部分来实现这个功能。
hints:
- 在 `kernel/defs.h` 中添加函数的原型,这样可以方便的使用。
- 把指定的函数放到 `kernel/riscv.h`里面。
```c
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}了解fp指向的机制。核心就是下面的图。可以从图中看到返回地址就是fp - 8,然后fp指向的栈帧的“下一个”(即previous)是fp-16。
1 | Stack |
一旦完成,可以在panic中加入对应的功能,这样以后就能看到对应的错误的栈帧调用了。在xv6中,每一个栈帧都是分配在同一页中的。
核心函数也非常简单:
1 | void |
Alarm(困难)
在一些应用场景中,我们希望能够当一些进程运行了一段时间后发出警告(比如某些计算密集型的进程)。
实现一个系统调用,sigalarm(interval, handler),其中第一个参数是间隔的tick(可以理解成毫秒),第二个是处理的函数。当时间间隔到了就去执行对应的函数,执行完成之后返回继续执行原来的函数。特例是sigalarm(0, 0),这个的意思是让内核不要定期生成警告。
最终实现的程序会很短,但是非常tricky。一共需要通过三个test,下面一一分解。
test0
首先来实现处理函数。一些Hints:
- 首先是在makefile中加入
alarmtest - 在
user/user.h中声明对应的系统调用的封装:
1 | int sigalarm(int ticks, void (*handler)()); |
- 更新
user/usys.pl,kernel/syscall.h以及kernel/syscall.c,标准的系统调用的套路。 - 此时此刻的
sys_sigreturn应该返回0。 sys_sigalarm()应该把它的参数放到进程的结构体中。- 你还需要追踪自从上次之后过了多久了。这一部分也推荐加入到进程的结构体中。你可以在proc.c中的
allocproc()中初始化这些值。但是我实测不进行初始化暂时不影响test0。 - 每一个时间的tick,都会强制有一个中断,所以可以在里面进行操作。
首先是简单获取参数,并设置给进程的系统调用实现:
1 | uint64 |
然后在指定的地方,判断一下如果是时间中断,就执行对应的函数。
1 | else if((which_dev = devintr()) != 0){ |
跟着hints老老实实做,这个test0还是非常容易的。顺手还把test2也给通过了。
test1/test2(): resume interrupted code
为了能通过test1,当handler执行完成之后,控制权需要回到对应的被时钟中断给打断的函数处。所以需要恢复指定的状态。
那就是备份对应的各类寄存器,然后当return的时候再把寄存器恢复即可。代码太长了就不贴在这里了。
总结
这部分我感觉是比较简单的,本质上基本就是解释了一个TRAPONLINE和trapframe的作用,我觉得我个人收益最大的是,知道硬件会怎么修改,知道了栈帧的层层实现。
但是非常遗憾的是,在现代的Linux操作系统中,TRAPONLINE和trapframe这种是完全被淘汰的。因为使用的是共享内核页表的方式。