QuanZhou's Wiki

6.s081 Lab4 traps 实现

~/ 6.s081#OS#MIT courses

操作系统的一个重要功能是中断,用户进程通过trap机制让操作系统进行必要的工作。本次实验将探索系统调用是如何使用trap来实现。

kernel/trampoline.S是本实验的重要代码,Lab3中短暂提到过的trampoline机制,会在Lab4中被揭示其作用。

xv6-book Chapter 4 部分内容

这一章的核心内容是:如何在不破坏现场的情况下,安全跳转到内核工作,然后再毫发无损地跳转回来。

一、Trap的三种类型

在xv6中,但凡让CPU暂停当前指令流转而处理其他特殊事件的行为,都称为Trap,主要分为三类:

  • 系统调用:用户进程申请内核服务。
  • 异常:程序运行出错(如访问非法地址、除0等)。
  • 硬件终端:外部设备(如磁盘、时钟)发出信号,需要内核关注。

二、关键寄存器

RISC-V提供了一组控制寄存器,内核在处理Trap时会用到。

寄存器作用
stvec存储Trap处理程序的入口地址(通常指向uservec,如果是内核引发的Trap就指向kernelvec)。
sepc保存发生Trap时的程序计数器(PC),以便稍后返回。
scause记录Trap发生的原因(是系统调用还是中断?)。
sscratch内核在进入uservec前用来暂存a0,从而腾出一个寄存器。
sstatus控制特权状态(比如是否允许中断)。

三、核心机制:Trampoline和Trapframe

这是Lab3和Lab4最难理解的部分,也是Trap的精华。

首先:CPU是盲目的,它每次只会执行PC指向的指令。

Trampoline:为了解决“页表切换”时运行的连续性问题,这个页面在内核页表和用户页表中被映射到相同的虚拟地址。由于虚拟地址一致,satp寄存器(保存页表基地址)的值切换时指令流不会中断。

Trapframe:CPU从用户进程进入内核后,32个寄存器会全部被占用,为了能够恢复到进入内核前的状态,内核为每个进程分配了一个专门的物理页来存放这些寄存器的值。

四、Trap的生命周期(用户态出发)

  1. 硬件动作:执行ecall,硬件切换到内核态;保存断点(将PC的内容保存到sepc中);跳转到stvec指向的地址。

  2. uservec(汇编):保存现场,使用sscratch交换a0中的内容,将所有寄存器的值保存到Trapframe;切换页表,更改satp使其指向内核页表。

  3. usertrap(C语言):识别Trap的原因,如果是系统调用调用syscall,如果是磁盘中断调用devintr。

  4. usertrapret(C语言):重置stvec等寄存器,为下一次进入内核做准备。

  5. userret(汇编):切换satp回用户页表;从Trapframe中恢复现场(将寄存器恢复到Trap发生前);执行sret指令回到用户空间继续执行。

五、内核中的Trap

如果在内核空间中发生了中断(如时钟中断),不需要切换CPU特权级、页表以及栈。

kernelvec与uservec作用类似,是处理内核Trap的入口,但是是将寄存器的内容保存在内核栈上,处理完后再恢复。

Lab4 实现

一、Backtrace (moderate)

本任务要求在kernel/printf.c中实现一个回溯:

backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898

给出了一个内联汇编的函数获取fp寄存器中的内容:

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

fp寄存器存储了当前函数的frame基地址,结合frame的结构可以获取到当前函数的frame,frame中存储了返回地址、上一个调用该函数的frame指针等:

图 4-1 stack frame

返回地址位于frame基地址偏移量(-8)的位置,保存的frame指针位于偏移量(-16)的位置(地址从高向低增长)。

操作系统按页对齐为每个栈分配一个页,再考虑栈地址从高向低增长,所以循环结束的条件为frame pointer<PGROUNDUPframe\ pointer < PGROUNDUP

void
backtrace(void)
{
  uint64 fp = r_fp();
  uint64 top = PGROUNDUP(fp);

  while (fp < top) {
    uint64 ra = *(uint64 *)(fp - 8);
    uint64 *pre_fp_p = (uint64 *)(fp - 16);
    printf("%p\n", ra);
    fp = *pre_fp_p;
  }
}

二、Alarm(hard)

为xv6添加一个新功能,该进程会在进程使用CPU时定时发出alarm。这对于希望限制CPU时间占用的计算密集型进程,或者需要进行计算但同时也需要执行一些周期性操作的进程来说非常有用。本任务将使用一种用户中断/故障处理程序的原始形式,比如通过相似的方法来处理缺页中断。

此任务分为三个子任务:

test0: Invoke handler

只要能够调用handler函数即可通过此测试。

  1. 修改Makefile使alarmtest被编译
  2. 在user/user.h中添加两个系统调用原型: int sigalarm(int ticks, void (*handler)()); int sigreturn(void);
  3. 更新user/usys.pl,kernel/syscall.h以及kernel/syscall.c使sigalarm和sigreturn系统调用可被调用。
  4. sigreturn在这个子任务中可以直接返回0。
  5. 在kernel/proc.h中的struct proc添加以下几个变量,这些变量在allocproc函数(kernel/proc.c)中初始化:
    • alarm_interval记录alarm handler的执行间隔。
    • ticks记录当前已经经过了几个tick。
    • alarm_handler每次发生alarm后被调用。
    • is_alarm_handling表示当前进程是否正在调用alarm handler,防止重复调用。(本实验的测试间隔比较长,handler逻辑简单,因此这个变量即使不设置也能通过测试,但为了安全还是在此说明一下)
  6. 在kernel/trap.c文件中的usertrap函数处理用户进程的Trap。只有发生了时钟中断才应该更改用户进程的tick,更改的位置类似: if(which_dev == 2) …

在此只需要更改p->trapframe->epc就可以决定当时钟中断返回时CPU执行什么指令,因此不需要显式调用handler。

  if(which_dev == 2) {
    if (p->alarm_interval > 0) {
      p->ticks++;
      if (p->ticks >= p->alarm_interval && p->is_alarm_handling == 0) {
        p->is_alarm_handling = 1;
        p->ticks = 0;
        p->trapframe->epc = (uint64)p->alarm_handler;
      }
    }
    yield();
  }
uint64
sys_sigalarm(void)
{
  int ticks;
  uint64 handler;
  struct proc *p = myproc();

  if (argint(0, &ticks) < 0)
    return -1;

  if (argaddr(1, &handler) < 0)
    return -1;

  p->alarm_interval = ticks;
  p->alarm_handler = (void (*))handler;

  return 0;
}

test1/test2: resume interrupted code

上面的task0只能保证alarm被调用,但是调用一次之后,原本p->trapframe->epc中的内容就被覆盖了,因此无法返回继续执行,而test1/test2要求能够重新执行被中断的代码。

test1和test2需要完成保存现场的操作,因此在test0的代码之上还要添加以下一些代码:

  1. kernel/proc.h中添加alarm_tf记录原进程的寄存器状态。
  2. kernel/trap.c中的usertrap函数初始化alarm_tf。
  3. kernel/proc.c中初始化alarm_tf。
  4. kernel/proc.c中的freeproc函数释放alarm_tf。
  5. sigreturn添加恢复p->trapframe。
  if(which_dev == 2) {
    if (p->alarm_interval > 0) {
      p->ticks++;
      if (p->ticks >= p->alarm_interval && p->is_alarm_handling == 0) {
        p->is_alarm_handling = 1;
        memmove(p->alarm_tf, p->trapframe, sizeof(struct trapframe));
        p->ticks = 0;
        p->trapframe->epc = (uint64)p->alarm_handler;
      }
    }
    yield();
  }
static void
freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);
  p->trapframe = 0;
  if(p->alarm_tf)
    kfree((void*)p->alarm_tf);
  p->alarm_tf = 0;
  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  p->pagetable = 0;
  p->sz = 0;
  p->pid = 0;
  p->parent = 0;
  p->name[0] = 0;
  p->chan = 0;
  p->killed = 0;
  p->xstate = 0;
  p->state = UNUSED;
  p->alarm_handler = 0;
  p->ticks = 0;
  p->alarm_interval = 0;
}
uint64
sys_sigreturn(void)
{
  struct proc *p = myproc();
  memmove(p->trapframe, p->alarm_tf, sizeof(struct trapframe));
  p->is_alarm_handling = 0;
  return 0;
}

本实验到此结束,对操作系统处理中断的理解加深但依然不够深刻,希望在后续的实验中能慢慢理解更多的细节。