【MTI 6.S081 Lab】traps


本实验阅读《深入理解计算机系统》第八章异常控制流并做shell实验将会是很有帮助的

本实验探讨了如何使用陷阱实现系统调用。您将首先使用堆栈进行热身练习,然后实现用户级陷阱处理的示例。

RISC-V assembly (easy)

了解一下您在6.1910(6.004)中接触到的RISC-V程序集非常重要。在您的xv6 repo中有一个文件user/call.c。makefs.img编译它,并在user/call.asm中生成程序的可读汇编版本。

阅读call.asm中函数g、f和main的代码。RISC-V的使用说明书见参考页。以下是您应该回答的一些问题(将答案存储在answers-traps.txt文件中):

  • 函数的参数包含在那个寄存器里面?例如,在main调用printf时,13在哪个寄存器中?

    : RISC-V调用约定尽可能在寄存器中传递参数。为此,最多使用八个整数寄存器a0-a7和八个浮点寄存器fa0-fa7。寄存器a2保存13。

  • 在main中哪里是调用函数f的汇编代码?哪里是调用g的

    :在26: 45b1 li a1,12中,为调用f 和 g的代码,由于函数很简单,传入的参数又是一个编译时常量,所以直接将函数的结果在编译器算出来了,即12.

  • printf的地址位于何处?

    auipc rd, immediate x[rd] = pc + sext(immediate[31:12] << 12)把符号位扩展的 20 位(左移 12 位)立即数加到 pc 上,结果写入 x[rd]。

    30:	00000097          	auipc	ra,0x0
    

    当前pc为下一条指令的pc,pc=34,所以ra=0x34+0=34

    34:	600080e7          	jalr	1536(ra) # 630 <printf>
    jalr rd, offset(rs1)  t =pc+4; pc=(x[rs1]+sext(offset))&~1; x[rd]=t
    此处rd为编号为0x01的寄存器,即为ra。
    ra = pc + 4 = 0x38, pc=0x34+1536=0x630
    

    printf的地址位于0x630处。

  • 在jalr跳转到printf后,ra的值是什么?

    : ra放入返回值,jalr存了返回地址在ra中,此时为jalr下一条指令的地址,所以ra中存储的值为0x38

  • 运行下面的代码,输出是什么?

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

    输出HE110 World,说明我的机器是小端机器。

  • 在下面的代码中,“y=”之后将打印什么?(注意:答案不是一个特定的值。)为什么会发生这种情况?

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

    通过gdb调试,发现此时a2=1,所以输出y的值为1,因为函数参数默认在a0~a7寄存器中,此时函数想要有三个参数,一个是格式化输出"x=%d y=%d",一个是x的值,在此处为3,一个是y的值,应该被放入a2中,所以此处为2。

Backtrace (moderate)

对于调试来说,有一个回溯通常很有用:在发生错误的点之上的堆栈上的函数调用列表。为了帮助进行回溯,编译器生成机器代码,在堆栈上维护与当前调用链中的每个函数相对应的堆栈帧。每个堆栈帧由返回地址和指向调用方堆栈帧的“帧指针”组成。寄存器s0包含一个指向当前堆栈帧的指针(它实际上指向堆栈上保存的返回地址的地址加8)。回溯应该使用帧指针向上遍历堆栈,并在每个堆栈帧中打印保存的返回地址。

在这个代码中,我看到进入函数压栈至少为16,从这里的说明来看,这么做的原因是,除了压一个返回地址,其实也压了上一个函数的栈帧位置,这样就可以实现backtrace的功能了。

实验任务

kernel/printf.c中实现函数backtrace()。在sys_sleep中插入对此函数的调用,然后运行bttest,他调用sys_sleep。你的输出应该是有下面格式的返回地址列表:

backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898

在bttest后退出qemu。在终端窗口中:运行addr2line -e kernel/kernel(或者riscv64-unknown-elf-addr2line -e kernel/kernel)(在我的机器上为riscv64-unknown-linux-gnu-addr2line -e kernel/kernel),并且从你的backtrace复制粘贴这个地址,像下面一样:

$ addr2line -e kernel/kernel
0x0000000080002de2
0x0000000080002f4a
0x0000000080002bfc
Ctrl-D

你应该看到类似下面的输出

kernel/sysproc.c:74
kernel/syscall.c:224
kernel/trap.c:85

Hints

  • kernel/defs.h中添加函数backtrace()原型,以便于你在sys_sleep中能调用backtrace

  • GCC编译器存储一个当前执行函数的帧指针在寄存器s0中。在kernel/riscv.h中添加下面的函数,并在backtrace中调用此函数来读取当前帧指针。r_fp()使用内联汇编读取s0。

    static inline uint64
    r_fp()
    {
      uint64 x;
      asm volatile("mv %0, s0" : "=r" (x) );
      return x;
    }
    
  • 这个页面展示了栈帧的布局图。注意到返回地址位于离堆栈帧的帧指针的固定偏移量(-8)处,而保存的帧指针位于离帧指针的恒定偏移量(-16)处。

    在这里插入图片描述

  • backtrace()需要一种方法来识别它已经看到了最后一个堆栈帧,并且应该停止。一个有用的事实是,为每个内核堆栈分配的内存由一个页面对齐的页面组成,因此给定堆栈的所有堆栈帧都在同一页面上。您可以使用PGROUNDDOWN(fp)(请参阅kernel/rescv.h)来标识帧指针所引用的页面。

一旦您的backtrace工作正常,请在kernel/printf.c中从panic调用它,以便在它panic时看到内核的回溯。

解决方案

backtrace

void
backtrace(void) {
  printf("backtrace:\n");
  uint64 fp = r_fp();   // 获取当前栈指针
  uint64 max_fp = PGROUNDDOWN(fp) + PGSIZE;   // 由于栈在一页上,所以此处就能知道最大的栈帧在哪里了,可以等于最大栈帧,等于即最后一个
  do {
    printf("%p\n", *(uint64 *)(fp - 8));    // 打印返回地址
    fp = *(uint64 *)(fp - 16);    // 下一栈帧在固定偏移(-16)
  } while (fp < max_fp);
  return;
}

sys_sleep

uint64
sys_sleep(void)
{
  acquire(&tickslock);
  ...
  release(&tickslock);
  backtrace();
  return 0;
}

注意:

  • 这里不是打印栈帧的位置,而是打印返回地址的位置,通过返回地址,我们运行riscv64-unknown-linux-gnu-addr2line -e kernel/kernel,找到开始位置是这些的地址的函数,如果不是函数的开始,说明backtrace打印错误,会输出??:0

  • 在我的输出中

    $ ./bttest
    backtrace:
    0x00000000800021b0
    0x0000000080002022
    0x0000000080001d18
    
    riscv64-unknown-linux-gnu-addr2line -e kernel/kernel
    0x00000000800021b0
    0x0000000080002022
    0x0000000080001d18
    /home/zhj/MIT6S081OS/xv6-labs-2022/kernel/sysproc.c:71
    /home/zhj/MIT6S081OS/xv6-labs-2022/kernel/syscall.c:141
    /home/zhj/MIT6S081OS/xv6-labs-2022/kernel/trap.c:85
    

Alarm (hard)

实验任务

在本练习中,您将向xv6添加一个功能,该功能在进程使用CPU时间时定期提醒进程。这对于想要限制占用CPU时间的计算绑定进程,或者对于想要计算但也想要采取一些周期性操作的进程来说可能很有用。更一般地说,您将实现用户级中断/故障处理程序的原始形式;例如,您可以使用类似的方法来处理应用程序中的页面错误。如果您的解决方案通过了alarmtest和’usertests-q’,则它是正确的。

你应该添加一个新的sigalarm(interval, handler)系统调用。如果应用程序调用sigalarm(n, fn),那么在程序消耗的CPU时间的每n个“滴答”之后,内核应该调用应用程序函数fn。当fn返回时,应用程序应该从停止的地方恢复。在xv6中,tick是一个相当任意的时间单位,由硬件计时器生成中断的频率决定。如果应用程序调用sigalarm(0,0),内核应该停止生成周期性的警报调用

这个实验有点像CSAPP中所做的第八章,异常控制流的实验,shell实验,信号处理

您将在xv6存储库中找到一个文件user/armtest.c。将其添加到Makefile中。在添加了sigalarm和sigreturn系统调用之前,它不会正确编译(见下文)。

alarmtest在test0中调用sigalarm(2,periodic),要求内核每隔2次强制调用periodic(),然后旋转一段时间。您可以在user/armtest.asm中看到alarmtest的汇编代码,这可能对调试很方便。当alarmtest生成这样的输出并且usertests -q也正确运行时,您的解决方案是正确的:

$ alarmtest
test0 start
........alarm!
test0 passed
test1 start
...alarm!
..alarm!
...alarm!
..alarm!
...alarm!
..alarm!
...alarm!
..alarm!
...alarm!
..alarm!
test1 passed
test2 start
................alarm!
test2 passed
test3 start
test3 passed
$ usertest -q
...
ALL TESTS PASSED
$

当你完成后,你的解决方案将只有几行代码,但可能很难把它做好。我们将使用原始存储库中的alarmtest.c版本来测试您的代码。您可以修改alarmtest.c来帮助您进行调试,但要确保原始的alarmtest表明所有测试都通过了。

test0: invoke handler

首先修改内核以跳转到用户空间中的alarm处理程序,这将导致test0打印“alarm!”。不要担心“alarm!”输出后会发生什么;如果你的程序在打印“alarm!”后崩溃,在当前的完成进度来说,是可以的。

Hint

  • 修改Makefile,添加alarmtest.c

  • user/user.h正确声明

    int sigalarm(int ticks, void (*handler)());
    int sigreturn(void);
    
  • 更新user/usys.plkernel/syscall.hkernel/syscall.c,允许alarm调用sigalarm和sigreturn

  • 现在,sys_sigreturn应该返回0

  • 你的sys_sigalarm应该存储alarm间隔和一个指针指向处理函数,在proc结构中添加新的字段

  • 您需要跟踪从上一次调用(或直到下一次调用)到进程的alarm处理程序已经传递了多少ticks;为此,您还需要在struct proc中添加一个新字段。您可以在proc.c中的allocproc()中初始化proc字段。

  • 每一次tick,硬件时钟都会强制中断,这在kernel/trap.cusertrap中处理

  • 如果有个时钟中断,在下面的语句内操作进程的alarm ticks

    if(which_dev == 2) ...
    
  • 只有当进程有一个未完成的计时器时,才调用报警功能。请注意,用户的报警功能的地址可能为0(例如,在user/armtest.asm中,periodical位于地址0)。

    用户函数的地址可能为0,所以不能通过handler函数指针是否指向0来判断是否启用了这个sig功能

  • 您需要修改usertrap(),以便在进程的警报间隔到期时,用户进程执行处理程序函数。当RISC-V上的陷阱返回到用户空间时,是什么决定了用户空间代码恢复执行的指令地址?

    epc中存储的值,决定了从trap返回后,程序执行的第一条指令的地址,所以将其改为handler的地址即可。然而,这样会导致回不到原来的pc了,所以这里会导致崩溃。这个要等实现sigreturn后才能修复,所以这个任务上说,打印了alarm后崩溃是OK的。

  • 如果你告诉qemu只使用一个CPU,那么用gdb查看陷阱会更容易,这可以通过运行

    make CPUS=1 qemu-gdb
    
  • 如果alarmtest打印“alarm!”,则您已成功。

实验结果 满足实验任务要求

在这里插入图片描述

test1()/test2()/test3(): resume interrupted code

可能是alarmtest在打印“alarm!”后在test0或test1中崩溃,或者alarmtest(最终)打印“test1 failed”,或者alarm test在未打印“test1passed”的情况下退出。要解决此问题,必须确保在完成alarm处理程序时,控制返回到用户程序最初被计时器中断的指令。您必须确保寄存器内容恢复到中断时的值,这样用户程序才能在alarm后不受干扰地继续运行。最后,您应该在每次alarm计数器达到值后“re-arm”它,以便周期性地调用处理程序。

作为起点,我们为您做出了一个设计决定:用户alarm处理程序需要在完成后调用sigreturn系统调用。以alarmtest.c中的periodic为例。这意味着您可以将代码添加到usertrap和sys_sigreturn中,这两个代码协同工作,使用户进程在处理完警报后能够正常恢复。

Hints

  • 您的解决方案将要求您保存和恢复寄存器——您需要保存和恢复哪些寄存器才能正确恢复中断的代码?(提示:会有很多)。

    在这里插入图片描述

    所以,至少要保存调用者寄存器。ra,t0-t6,a0-a7,还要保存pc

    在要进入alarm处理程序时,从蹦床页面保存一些寄存器。sigreturn时恢复那些寄存器。

    在这里插入图片描述

  • 当计时器关闭时,让usertrap在struct proc中保存足够的状态,以便sigreturn能够正确地返回到中断的用户代码。

  • 防止对处理程序的重入调用——如果处理程序还没有返回,内核就不应该再次调用它。test2对此进行了测试。

    在Linux中使用的是一个掩码,待处理的向量。在这里,如果要处理很多信号,其实也可以放一个mask,这样一位就可以代表信号处理了。

  • 确保恢复a0。sigreturn是一个系统调用,其返回值存储在a0中。

通过test0、test1、test2和test3后,运行usertests q以确保没有破坏内核的任何其他部分。

解决方案

trap.c中的时钟中断处理程序

//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
// 处理从用户空间而来的中断、异常或系统调用,被trampoline.S调用
// usertrap的任务是确定陷阱的原因,处理并返回
//
void
usertrap(void)
{
  int which_dev = 0;
  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");
  // send interrupts and exceptions to kerneltrap(),
  // since we're now in the kernel.
  w_stvec((uint64)kernelvec);     // 此时已经陷入内核了,所以要将trap vec 指向处理内核陷阱的代码
  struct proc *p = myproc();
  // save user program counter. 保存用户程序计数器,再次保存是因为usertrap可能有一个进程切换,导致再次覆盖spec的值
  // 中断总是会被RISC-V的trap硬件关闭,所以到这里为止,sepc肯定是没有变过的
  p->trapframe->epc = r_sepc();
  // 根据触发trap的原因,RISC-V的SCAUSE寄存器会有不同的数字。 
  if(r_scause() == 8){
    // system call 陷阱来自系统调用
    if(killed(p))
      exit(-1);
    // sepc points to the ecall instruction,
    // but we want to return to the next instruction.
    p->trapframe->epc += 4;       // 因为系统调用后,用户执行导致系统调用的下一条指令,所以这里+4
    // an interrupt will change sepc, scause, and sstatus,
    // so enable only now that we're done with those registers.
    // 中断总是会被RISC-V的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。
    // 这样中断可以更快的服务,有些系统调用需要许多时间处理。
    intr_on();
    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok 陷阱来自设备中断,devintr已经处理
  } else {
    // 是一个异常,内核会杀死错误进程
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    setkilled(p);
  }

  // 在退出过程中,usertrap检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是一个时钟中断)

  if(killed(p))
    exit(-1);

  // give up the CPU if this is a timer interrupt. 时钟中断
  if(which_dev == 2) {
    if (p->sigalarm.interval) {
      p->sigalarm.ticks++;
      if (p->sigalarm.ticks >= p->sigalarm.interval && !(p->handling_mask & HANDLING_ALARM)) {
        // 保存调用者保存寄存器
        memmove(&(p->sigalarm.trapframe), p->trapframe, sizeof(struct trapframe));
        p->trapframe->epc = p->sigalarm.handler;
        p->handling_mask |= HANDLING_ALARM;   // 标志中断正在处理
        p->sigalarm.ticks = 0;					// 重新计时
      }
    }
    yield();
  }

  usertrapret();
}

sys_sigalarm

信号处理程序的初始化,说明现在需要处理信号了,当handler等于0,意味着取消信号处理程序,或者说恢复默认。

uint64
sys_sigalarm(void) {
  int interval;
  uint64 handler;
  argint(0, &interval);
  argaddr(1, &handler);
  struct proc *p = myproc();
  p->sigalarm.interval = interval;
  p->sigalarm.ticks = 0;
  p->sigalarm.handler = handler;
  return 0;
}

sys_sigreturn

恢复调用者保存寄存器,然后改变程序计数器的指向,使得返回后从原来中断的地方恢复。

uint64
sys_sigreturn(void) {
  // 恢复调用者保存寄存器
  struct proc *p = myproc();
  memmove(p->trapframe, &(p->sigalarm.trapframe), sizeof(struct trapframe));
  p->handling_mask &= (~HANDLING_ALARM);
  return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值