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
2
3
4
5
6
7
8
9
10
void backtrace(void){
printf("backtrace\n");
uint64 fp = r_fp();
uint64 top = PGROUNDUP(fp);
uint64 down = PGROUNDDOWN(fp);
while(down<fp && fp<top){
printf("%p\n", fp);
fp = *((uint64*)(fp-16));
}
}

Alarm

这节就是实现一个系统调用,通过调用sigalarm能够触发固定n个ticks调用handler。

怎么实现呢?

其中一个要点就是理解时钟中断。xv6实现了时间片轮转调度的进程切换算法,依靠的就是trap.c中对于which_dev == 2类型的中断处理。

1
2
3
4
5
6
7
8
9
10
void usertrap(void) {
...
else if((which_dev = devintr()) != 0){
// ok
}
...
if(which_dev == 2){
yield();
}
}

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的逻辑就很清晰了:

  1. 调用sigalarm就是开启turn_on,然后记录间隔到interval,实际上可以用interval==-1来表示alarm是否开启;
  2. 每次时钟中断if(which_dev == 2) , 检查interval,然后累积tick。再判断tick是否达到interval,如果达到,我们需要执行回调函数。

然而其中还有一些细节:

  • 怎么去执行回调函数?怎么在用户态执行函数?

  • 回调函数执行完,我们还需要返回被中断的代码,这个又怎么实现?

  • 回调函数执行中也会触发时钟中断,怎么保证我N个tick执行一次回调函数?

指导中也提示了,当前我们处于内核中断处理程序中,回忆我们是怎么从中断返回的,没错就是设置sepc寄存器。在进程的trapframe的epc中保存了中断时的pc,我们依靠恢复pc来达到返回中断时的代码。

1
2
3
4
5
void usertrapret(void){
...
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
}

那么第一个问题就很好解决了,直接将回调函数的地址填到进程trapframe的epc中。副作用是原先epc的值被覆盖,我们再也找不到这个值了。

test1/test2 resume interrupted code

在执行完回调函数数后,我们的代码并不能正确返回用户被中断的函数处,因为原先epc的值被覆盖了:-)

一个直观的想法我在进程的结构体中再开辟一个变量pre_epc保存epc的值。那要怎么将这个变量被重新加载到epc?

在实验指导中给出这样一个设计:在回调函数的返回前,必须调用sigreturn。这就给我们设置返回地址有了可乘之机,sigreturn就是用来设置返回地址。

实现

1
2
3
4
5
6
7
8
9
10
if(which_dev == 2){
if(p->interval != 0){
if(p->ticks==p->interval){
*p->ptrapframe = *p->trapframe;
p->trapframe->epc = p->handler;
}
p->ticks++;
}
yield();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint64 sys_sigalarm(void){
int interval;
uint64 handler;
struct proc * p;
if(argint(0, &interval) < 0 || argaddr(1, &handler) < 0 || interval < 0) {
return -1;
}
p = myproc();
p->interval = interval;
p->handler = handler;
p->ticks = 0;
return 0;
}
uint64 sys_sigreturn(void){
struct proc * p = myproc();
*p->trapframe = *p->ptrapframe;
p->ticks = 0;// re alarm
return 0;
}

最后保证N个tick执行一次回调函数,则是一个实现上的trick:尽管回调函数还是会被时钟中断,在中断中我们会增加tick,但是ticks==interval的时机只有一个。

这个解答了我在学习操作系统之前的困惑:回调函数也会占用cpu时间片执行,那么说好的N个时间片间隔执行一次回调函数是不准确的。只能说是N个时间片+执行回调函数花费的时间才是每个回调函数之间的时间间隔。

踩坑

关于“二次指针”:保存和恢复trapframe。

1
2
3
4
5
6
7
uint64 sys_sigreturn(void){
struct proc * p = myproc();
//p->trapframe = p->ptrapframe;
*p->trapframe = *p->ptrapframe;
p->ticks = 0;// re alarm
return 0;
}

p是个指针变量,trapframe也是个指针变量。假如我不解引用,我保存的其实是个地址。当我对这个地址上的值作改变,我第二个指针指向相同的地址,这个地址值其实就是同一份,也改变了。说白了, 就是浅拷贝和深拷贝的区别,这里表现得更加隐晦。

作者

Desirer

发布于

2024-01-06

更新于

2024-02-02

许可协议