MIT 6.S081 Lab: traps

Everything will be okay in the end. If it’s not okay, it’s not the end.

开始学习MIT的操作系统6.S081课程的第四个Lab。Lab内容为学习内核如何 Trap 阅读汇编代码回答问题,为 xv6 添加 backtrace 与 支持计时器中断的用户 handler 的系统调用。

RISC-V assembly

阅读 user/call.asm 汇编代码,回答一些关于 RISC-V call convention 有关的问题。

Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?

a0-a8. a2 holds 13 in main’s call to printf.

Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.)

No call to function f in the assembly code of main. No call to function g in the assembly code of main, either. Compiler inline the two functions.

At what address is the function printf located?

0000000000000630 :

What value is in the register ra just after the jalr to printf in main?

0x0000000000000038

这里简单说一下,就是 jal 跳转指令会设置寄存器 ra 为当前 PC + 4 大部分 ISA 都有类似设计。

Run the following code.

  unsigned int i = 0x00646c72;
  printf("H%x Wo%s", 57616, &i);

What is the output? The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value?

Output: He110 World

If the RISC-V were instead big-endian, in order to yield the same output, i need be set to 0x726c6400 no need to change 57616.

大小端只影响存储字节序,而无论如何顺序立即数的表示值不变。

In the following code, what is going to be printed after ‘y=’? (note: the answer is not a specific value.) Why does this happen?

	printf("x=%d y=%d", 3);

Maybe output: x=3 y=0. The register a2 may holds zero, so ‘y=0’

Backtrace

对于调试来说, backtrace 可以输出在错误发生点以上的堆栈函数调用列表。

backtrace实现需要利用 frame pointer 指向当前栈帧顶部的特性。GCC编译器将当前正在执行的函数的帧指针存储在寄存器s0中,通过内联的ASM代码 r_rp 读取。

static inline uint64
r_fp()
{
  uint64 x;
  asm volatile("mv %0, s0" : "=r" (x) );
  return x;
}

根据栈帧的布局图,返回地址与堆栈帧指针的偏移量(-8)是固定的,而保存的帧指针与堆栈帧指针的偏移量(-16)是固定的。 backtrace 通过堆栈帧指针遍历每一个栈帧直到栈顶部,过程中输出返回地址。

stack_fp

识别栈顶部可以利用 xv6 按页为栈分配空间的特性,识别当前页的最高地址即可,实现可以用 PGROUNDUP 宏。代码如下,

// in kernel/printf.c
// Print backtrace
void backtrace()
{
  printf("backtrace:\n");
  uint64 fp = r_fp();
  uint64 bound = PGROUNDUP(fp);
  while (fp < bound) {
    uint64 retaddr = fp - 8;
    uint64 fpaddr = fp -16;
    printf("%p\n", *((uint64*) retaddr));
    fp = *((uint64 *)fpaddr);
  }
}

Alarm

在此练习中,我们将在XV6中添加一个功能,该功能会在进程使用CPU时间时定期提醒该进程。这对于限制进程使用时间片或在计算中采取一些定期动作的进程很有用。这也将实现用户级中断/故障处理程序(user-level interrupt/fault handlers)的原始形式;例如,我们可以使用类似的内容来处理应用程序中的页错误。

实现需要添加一个新的 sigalarm(interval, handler) 系统调用。如果应用程序调用 sigalarm(n, fn),那么在该程序消耗的每 n 个 tick 的CPU时间之后,内核应该使得应用程序函数 fn 被调用。当 fn 返回时,应用程序应在它中断的位置恢复执行。在xv6中,tick 是一个相当随意的时间单位,由硬件计时器生成中断的频率决定。如果应用程序调用sigalarm(0,0),内核应该停止生成周期性的 handler 调用。课程还准备了一个设计决策:当用户 handler 程序完成时,需要调用sigreturn() 系统调用以回到原先中断位置恢复执行。

我们的工作就是实现两个系统调用

    int sigalarm(int ticks, void (*handler)());
    int sigreturn(void)

以及修改 usertrap 的计时器中断逻辑。

系统调用 sys_sigalarm 实现应该在 struct proc 的新字段中存储警报间隔和指向用户处理函数的指针。为了跟踪从上次调用(或直到下一次调用) alarm handler 已经过了多少 ticks ,还需要在 struct proc 再增加字段 elapsed 。另外,为了恢复中断,需要保存和恢复寄存器——思考一下,需要保存和恢复哪些寄存器才能正确地恢复中断的代码?(提示:会有很多)。为了简化保存与恢复过程,我直接额外使用一个 trapframe 用以保存恢复。以下为 struct proc 新增字段。

struct proc {
  ...
  int interval;             // Interval of sig_alarm
  uint64 handler;           // Handler address of sig_alarm
  int elapsed;              // Elapsed time since last call to alarm handler
  struct trapframe *intrptTfr; // Trapframe when interrupted
  ...

具体实现两个系统调用,其实 sigalarm 只为用户进程记录了 intervalhandler 两个字段;而 sigreturn 则是将原来的因计时器中断而陷入内核的 trapframe 覆盖当前 trapframe 以完成恢复,并将字段 intrptTfr 索引的物理页回收资源并置为 0 ,指示当前已退出用户 handler 函数,通过检测这个字段是否为 0 ,内核可以防止重入用户 handler 函数。代码如下,

// in kernel/sysproc.c file
uint64
sys_sigalarm(void)
{
  struct proc *p = mycpu()->proc;
  int interval;
  uint64 fnaddr;
  
  if(argint(0, &interval) < 0 || interval < 0)
    return -1;
  if(argaddr(1, &fnaddr) < 0)
    return -1;
  
  p->interval = interval;
  p->handler = fnaddr;
  return 0;
}

uint64
sys_sigreturn(void)
{
  struct proc *p = mycpu()->proc;

  memmove(p->trapframe, p->intrptTfr, PGSIZE);
  kfree(p->intrptTfr);
  p->intrptTfr = 0;

  return 0;  
}

比较重要的是,修改 trap 流程,利用好系统调用在 struct proc 保存的信息,完成跳转 handler 与恢复中断。我开始希望通过拿到 handler 物理页地址 fnaddr 直接调用,但这样是行不通的。因为即使物理页有代码,但是操作的栈和寄存器,都是处于内核态,这不仅有两态页表的隔离带来的阻碍,甚至给了用户在内核态执行代码的权限!

为了跳转到 handler 可以修改 trapframeepc 字段为 handler 地址,在恢复到用户态时就直接跳转到 handler 的代码位置。这就需要保存计时器中断时的 pc 地址与寄存器,以便执行完 handler 后正确恢复,我的策略是直接为 intrptTfr 分配一个物理页,并复制当前中断 trapframe 内容。即使回到用户态,即使在 handler 执行中有系统调用或其他陷入内核的操作,最初的计时器中断的 trapframe 都不受影响,并可用于恢复。p.s. Need to prevent re-entrant calls to the handler.

  // in kernel/trap.c usertrap(void)
  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2) {
    p->elapsed += 1;
    if (p->interval != 0 && p->elapsed >= p->interval && p->intrptTfr == 0) {
      // save trapframe
      p->intrptTfr = (struct trapframe *)kalloc();
      memmove(p->intrptTfr, p->trapframe, PGSIZE);

      // set hanler as return addres
      p->trapframe->epc = p->handler;
      
      p->elapsed = 0;
      // uint64 fnaddr = walkaddr(p->pagetable, (uint64)p->handler);
      // printf("walkaddr gets fnaddr %p from handler %p\n", fnaddr, p->handler);  
      // ((void(*)())fnaddr)();
      // p->handler();
    }
    else {
      yield();
    }
  }

Conclusion

前两个任务检验对 RISC-V calling convention 与堆栈布局的掌握,最后一个任务的内容稍难,相当于实现了一个用户级中断与故障处理的流程。梳理这个流程,我们可以发现良好的设计是如何方便维护者在上面添加功能的。支持用户级中断也很好地让我了解,

  1. OS 是如何从计时器中断恢复到用户态,跳转到用户故障处理函数 handler
  2. 在用户态执行完 handler 后,OS是如何通过系统调用进入内核态以恢复到原先计时器中断位置的。

当然,Xv6只是提供一种设计,工业级OS实现用户级中断与故障处理的流程也有其他设计。但不变的是,用户 handler 仍然是用户态,隔离是永远需要保证的。

Reference

  1. MIT 6.S081 2021
  2. Lab trps