MIT6.S081 lab4 traps
lab4其实是lec05的内容加上第四章中断相关内容。
Risc-V assembly
这一节需要明白:
- 系统调用的参数放在a0~a7寄存器中
- 函数嵌套调用由栈实现
- 必要的几个寄存器:ra、sp、pc、a0~a7
- Caller Saved register和Callee Saved register
Caller register就是函数调用方无论如何都会使用的寄存器,比如ra寄存器要保存函数返回的地址,无论在哪个函数调用中都会使用,因为需要函数调用方自己保存。考虑函数A调用函数B:进入A中后,ra中保存了A的返回地址,但在A中使用jal B
后,ra中的值就变成了从B返回A的地址,那这样A就永远无法正确返回了。因此,ra是属于“在A调用B之前必须保存”的寄存器。
反之,Callee register就是调用方无需担心的寄存器。比如sp栈指针,在调用函数的过程它会被用到,自动压栈,然后函数返回又自动出栈。sp栈指针无需调用方保存,函数返回时会自动恢复。
为什么要区别Caller 和 Callee 保存?我直接保存所有寄存器不就行了么?
是的,保存所有寄存器是可以的,但是这样太浪费空间了。
再这么理解Caller Saved Register是会被 Callee 使用的寄存器,比如前述的ra寄存器,如果Caller不保存,ra的值就会丢失。
Callee Saved Register就是会被Caller使用的寄存器,Caller不用担心这部分寄存器,因为在调用的过程它们会自己保存恢复,比如sp寄存器。
- register 是大家共享的;
- caller save 意思其实是这片寄存器不建议 caller 使用,一旦 caller 使用了就记得保存在 stack 上,用完再复原;同理 callee save;
- 理论上所有的寄存器都 caller save 也是可以愉快地运行,但是这样做的话 stack 使用会很频繁,memory 的代价是比寄存器高很多的,所以得不偿失。
- 理想的情况是 caller 和 callee 都不用 save,用的时候不用操心。这样的话就需要划分一块区域给 caller,向 caller 保证这些是 callee save 的,您尽管放心存这里。另外一块区域自然对称地给 callee,向 callee 保证这些是 caller save 的。
- 这样就可以增加两边都不用操心 save-restore 的概率,因为 caller 和 callee 都有一块放心的自留地。
学生提问,为什在最开始要对sp寄存器减16?
TA:是为了Stack Frame创建空间。减16相当于内存地址向前移16,这样对于我们自己的Stack Frame就有了空间,我们可以在那个空间存数据。我们并不想覆盖原来在Stack Pointer位置的数据。
学生提问:为什么不减4呢?
TA:我认为我们不需要减16那么多,但是4个也太少了,你至少需要减8,因为接下来要存的ra寄存器是64bit(8字节)。这里的习惯是用16字节,因为我们要存Return address和指向上一个Stack Frame的地址,只不过我们这里没有存指向上一个Stack Frame的地址。如果你看kernel.asm,你可以发现16个字节通常就是编译器的给的值。
struct的内存布局:你可以认为struct像是一个数组,但是里面的不同字段的类型可以不一样。
Backtrace
看明白栈帧的内容,以及以下:
- fp(frame pointer)指向当前栈帧的指针
- sp(stack pointer)指向bottom of stack
实验指导给出了一张栈布局的图片。可以看到栈由一个个栈帧组成,sp指向栈的底部,fp指向当前栈帧的顶部。
一个栈帧从上到下包括:
- 返回地址(fp-8)
- 前一个栈帧地址(fp-16)
- 保存的若干寄存器
- 局部变量
而S0寄存器保存了fp,因此就能通过fp遍历栈了。
1 | void backtrace(void){ |
Alarm
这节就是实现一个系统调用,通过调用sigalarm能够触发固定n个ticks调用handler。
怎么实现呢?
其中一个要点就是理解时钟中断。xv6实现了时间片轮转调度的进程切换算法,依靠的就是trap.c
中对于which_dev == 2
类型的中断处理。
1 | void usertrap(void) { |
devintr()
check if it’s an external interrupt or software interrupt, and handle it. returns 2 if timer interrupt, 1 if other device, 0 if not recognized.
yield()
则是让出时间片,将cpu控制权交到调度器手中,然后调度器选择一个可运行的线程进行运行。
test0 invoker handler
test0指导我们完成sigalarm系统调用的stub,但这还不够。我们在进程中需要维护
- 一个变量
bool turn_on
来表示是否开启了sigalarm - 一个变量
int interval
来记录sigalarm中的间隔interval - 一个变量
int ticks
表示累积的tick - 一个函数地址
uint64 handler
来存handler
那么实现sigalarm的逻辑就很清晰了:
- 调用sigalarm就是开启
turn_on
,然后记录间隔到interval
,实际上可以用interval==-1
来表示alarm是否开启; - 每次时钟中断
if(which_dev == 2)
, 检查interval,然后累积tick。再判断tick是否达到interval,如果达到,我们需要执行回调函数。
然而其中还有一些细节:
怎么去执行回调函数?怎么在用户态执行函数?
回调函数执行完,我们还需要返回被中断的代码,这个又怎么实现?
回调函数执行中也会触发时钟中断,怎么保证我N个tick执行一次回调函数?
指导中也提示了,当前我们处于内核中断处理程序中,回忆我们是怎么从中断返回的,没错就是设置sepc寄存器。在进程的trapframe的epc中保存了中断时的pc,我们依靠恢复pc来达到返回中断时的代码。
1 | void usertrapret(void){ |
那么第一个问题就很好解决了,直接将回调函数的地址填到进程trapframe的epc中。副作用是原先epc的值被覆盖,我们再也找不到这个值了。
test1/test2 resume interrupted code
在执行完回调函数数后,我们的代码并不能正确返回用户被中断的函数处,因为原先epc的值被覆盖了:-)
一个直观的想法我在进程的结构体中再开辟一个变量pre_epc保存epc的值。那要怎么将这个变量被重新加载到epc?
在实验指导中给出这样一个设计:在回调函数的返回前,必须调用sigreturn。这就给我们设置返回地址有了可乘之机,sigreturn就是用来设置返回地址。
实现
1 | if(which_dev == 2){ |
1 | uint64 sys_sigalarm(void){ |
最后保证N个tick执行一次回调函数,则是一个实现上的trick:尽管回调函数还是会被时钟中断,在中断中我们会增加tick,但是ticks==interval
的时机只有一个。
这个解答了我在学习操作系统之前的困惑:回调函数也会占用cpu时间片执行,那么说好的N个时间片间隔执行一次回调函数是不准确的。只能说是N个时间片+执行回调函数花费的时间才是每个回调函数之间的时间间隔。
踩坑
关于“二次指针”:保存和恢复trapframe。
1 | uint64 sys_sigreturn(void){ |
p是个指针变量,trapframe也是个指针变量。假如我不解引用,我保存的其实是个地址。当我对这个地址上的值作改变,我第二个指针指向相同的地址,这个地址值其实就是同一份,也改变了。说白了, 就是浅拷贝和深拷贝的区别,这里表现得更加隐晦。
MIT6.S081 lab4 traps