MIT6.1810 Lab4

783 阅读9分钟

Lab: traps

实验链接

实验准备

切换到traps分支

  $ git fetch
  $ git checkout traps
  $ make clean

RISC-V assembly

需求

理解一点RISC-V汇编是很重要的。在您的xv6 repo中有一个文件user/call.c。make fs.img对其进行编译,并在user/call.asm中生成程序的可读汇编版本。读取call.asm中函数g, f和main的汇编代码。RISC-V的使用手册在参考页面。这里有一些你应该回答的问题(将答案存储在一个文件answers-traps.txt中):

  • 哪些寄存器包含函数的参数?例如,在main调用printf时,哪个寄存器保存13 ?
  • 在main的汇编代码中对函数f的调用在哪里?函数g的调用在哪里?(提示:编译器可以内联函数)
  • 函数printf位于什么地址?
  • 在main中调用printf后ra寄存器中的值是什么?
  • 运行以下代码。输出是什么? (输出取决于RISC-V是小端制的这一事实。如果RISC-V是大端的,你会把它设置成什么,以产生相同的输出?您需要将57616更改为不同的值吗?)
        unsigned int i = 0x00646c72;
        printf("H%x Wo%s", 57616, &i);
  • 在下面的代码中,在'y='之后会输出什么?(注:答案不是一个具体的值。)为什么会发生这种情况?
    	printf("x=%d y=%d", 3);

The solution

首先查看user/call.asm中有关汇编

    int g(int x) {
       0:	1141                	addi	sp,sp,-16
       2:	e422                	sd	s0,8(sp)
       4:	0800                	addi	s0,sp,16
      return x+3;
    }
       6:	250d                	addiw	a0,a0,3
       8:	6422                	ld	s0,8(sp)
       a:	0141                	addi	sp,sp,16
       c:	8082                	ret

    000000000000000e <f>:

    int f(int x) {
       e:	1141                	addi	sp,sp,-16
      10:	e422                	sd	s0,8(sp)
      12:	0800                	addi	s0,sp,16
      return g(x);
    }
      14:	250d                	addiw	a0,a0,3
      16:	6422                	ld	s0,8(sp)
      18:	0141                	addi	sp,sp,16
      1a:	8082                	ret

    000000000000001c <main>:

    void main(void) {
      1c:	1141                	addi	sp,sp,-16
      1e:	e406                	sd	ra,8(sp)
      20:	e022                	sd	s0,0(sp)
      22:	0800                	addi	s0,sp,16
      printf("%d %d\n", f(8)+1, 13);
      24:	4635                	li	a2,13
      26:	45b1                	li	a1,12
      28:	00000517          	auipc	a0,0x0
      2c:	7b850513          	addi	a0,a0,1976 # 7e0 <malloc+0xe6>
      30:	00000097          	auipc	ra,0x0
      34:	612080e7          	jalr	1554(ra) # 642 <printf>
      exit(0);
      38:	4501                	li	a0,0
      3a:	00000097          	auipc	ra,0x0
      3e:	28e080e7          	jalr	654(ra) # 2c8 <exit>
  1. 寄存器a0,a1,a2包含函数参数,调用printf时寄存器a2保存13
  2. 在main函数的汇编中找不到对函数f的调用,但可看见调用结果作为常数直接赋给了寄存器a2,故推测编译器使用了函数内联结合常量传播对其调用进行了优化。在f函数中亦找不到对函数g的调用,但由汇编代码可知函数g内联于函数f中。
  3. 由指令30: 00000097 auipc ra,0x034: 612080e7 jalr 1554(ra) # 642 <printf>可知printf的入口地址为30+1554即0x642。
  4. ra存储的是printf的返回地址即0x38
  5. 本问考察了16进制与10进制,16进制与2进制之间的转化以及大小端问题。首先代码的输出为He110 World,若RISC-V是大端的则需要将i调整为0x726C64以获得相同输出,57616无需更改。
  6. 'y='之后会输出一个随机值,这是由于未提供实参给printf,故printf从寄存器a2中获取的是一个垃圾值。

Backtrace

需求

对于调试来说,回溯通常是有用的:它能提供在错误发生点上方的堆栈上的函数调用列表。为了帮助进行回溯,编译器生成机器码,这些机器码为当前调用链中的每个函数在堆栈上维护一个栈帧。寄存器s0包含一个指向当前栈帧底的帧指针(它实际上指向栈帧上保存返回地址的位置加上8)。你的回溯应该使用帧指针来遍历堆栈,并在每个栈帧中打印保存的返回地址。

注:栈帧(Stack Frame)是一种数据结构,用于在程序执行过程中跟踪函数调用和返回的信息。每当一个函数被调用时,栈帧就会被创建并添加到程序的堆栈(Stack)中。xv6的栈帧底部存储着当前函数的返回地址以及上一函数的帧指针。

The solution

xv6堆栈结构如图:1.png fp(帧指针)与sp(栈指针)界定了一个函数的栈帧。

首先为了获取当前栈帧的帧指针,我们将以下函数添加到kernel/riscv.h:

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

然后在kernel/printf.c中填写backtrace函数定义:

    void backtrace(void){
      printf("backtrace:\n");
      uint64 *p = (uint64 *)r_fp();//获取当前栈帧的帧指针
      uint64 fp = PGROUNDDOWN((uint64)p);
      while(PGROUNDDOWN((uint64)p) == fp){//识别它是否到了最后一个栈帧
        printf("%p\n",*(p-1));//输出当前栈帧返回值
        p = (uint64 *)*(p-2);//获取上一栈帧帧指针。
      }
    }

注:为了让backtrace能识别它是否看到了最后一个栈帧。可利用内核堆栈的特性,即:xv6为每个内核堆栈分配的内存由单个与页面对齐的页面组成,因此给定堆栈的所有栈帧都在同一页面上。您可以使用PGROUNDDOWN(fp)(参见kernel/riscv.h)来标识帧指针所指向的页面。

Alarm

需求

在这个练习中,你将向 xv6 添加一个功能,在进程使用 CPU 时间时定期发送警报。这对于需要限制CPU时间的计算密集型进程或希望在计算过程中定期执行某些操作的进程可能很有用。更一般地说,你将实现一种基本形式的用户级中断/故障处理程序;例如,你可以使用类似的方式来处理应用程序中的页面故障。只要你的解决方案通过 alarmtest 和 ‘usertests -q’ 就被认为是正确的。

你应该添加一个新的 sigalarm(interval, handler) 系统调用。如果一个应用程序调用 sigalarm(n, fn),那么在程序消耗的每个 n 个 CPU 时间“ticks”之后,内核应该调用函数 fn。当 fn 返回时,应用程序应该从离开的地方继续执行。为了让应用程序从离开的地方继续执行,这就要求我们添加第二个调用 sigreturn 以恢复寄存器的状态,进而使程序恢复原状执行。

注:在 xv6 中,ticks是一个相当随意的时间单位,由硬件定时器生成中断的频率决定。如果一个应用程序调用 sigalarm(0, 0),内核应该停止生成周期性的警报调用。

The solution

下文以fn称呼被内核调用的函数

  1. 每经过一个ticks,硬件定时器就会强制发生一个中断,该中断在内核的kernel/trap.c中的usertrap()函数中进行处理。需要判断中断的类型以确保该中断是由硬件定时器触发if(which_dev == 2)
  2. 我们要在经过指定数量的ticks后切换pc以执行fn,由kernel/trap.c的usertrap函数可知,在p->trapframe->epc保存着从trap恢复后的pc地址,我们要将旧地址保存,再将要切换的新函数地址写入p->trapframe->epc
  3. 在执行fn的过程中进程可能又经过了指定数量的ticks使得内核再次调用fn,为了避免该情况发生我们需要记录一下进程是否在执行fn。
  4. 在执行完fn后我们要还原原程序的执行状态,这就要求我们在切换前保存好可能被使用的寄存器的状态,经过观察user/alarmtest.c内的代码可了解到可能被使用的寄存器有a0 a1 sp s0 ra

综上我们要先修改kernel/proc.h中的struct proc以保存需要的信息:

    struct proc {
      ...
      int ticks;//要求tick数
      void (*handler)();//函数入口地址
      int ticks_num;//tick记数
      int flag;//记录进程是否执行fn
      uint64 epc;//记录pc
      uint64 ra; //记录寄存器状态
      uint64 sp;
      uint64 s0;
      uint64 a0;
      uint64 a1;
    };

如果一个应用程序调用 sigalarm(0, 0),内核应该停止生成周期性的警报调用,为了处理该种情况我们将struct proc记录的ticks字段置为-1。

系统调用sigalarm定义如下:

    uint64 sys_sigalarm(void){
      struct proc *p = myproc();
      argint(0,&p->ticks);//保存所需间隔与fn入口地址
      argaddr(1,(uint64*)&p->handler);
      p->ticks_num = 0;
      if(!p->ticks && !p->handler)p->ticks = -1;//sigalarm(0, 0)
      return 0;
    }

在用户未调用sys_sigalarm前我们也无需进行生成周期性的警报调用,故应对ticks字段初始化,flag字段同理。修改kernel/proc.c

    static struct proc*
    allocproc(void)
    {
      ...
    found:
      p->ticks = -1;
      p->flag = 0;
      ...
    }

接下来就是在kernel/trap.c中处理经过指定数目的tick而引发的内核调用fn:

    void
    usertrap(void)
    {
      ...
       else if((which_dev = devintr()) != 0){
        // ok
        if(which_dev == 2 && p->ticks>=0){//判断中断的类型以及是否是需要周期性的警报调用
    	p->ticks_num++;//tick计数
        	if(p->ticks_num == p->ticks){//到间隔时间
        	  p->ticks_num = 0;//重置计数
        	  if(p->flag == 0){//判断当前是否在执行fn
        	    p->epc = p->trapframe->epc;//保存pc以及寄存器状态
        	    p->ra = p->trapframe->ra;
        	    p->sp = p->trapframe->sp;
        	    p->s0 = p->trapframe->s0;
        	    p->a0 = p->trapframe->a0;
        	    p->a1 = p->trapframe->a1;
        	    p->trapframe->epc = (uint64)p->handler;//修改从trap恢复的pc所指地址,令其指向fn入口
        	    p->flag = 1;
        	  }
        	}
        }
      }
      ...
    }

最后我们需要完成sigreturn的定义以还原原函数的寄存器状态

    uint64 sys_sigreturn(void){
      struct proc *p = myproc();
      p->trapframe->epc = p->epc;
      p->trapframe->ra = p->ra;
      p->trapframe->sp = p->sp;
      p->trapframe->s0 = p->s0; 
      p->trapframe->a1 = p->a1;
      p->flag = 0;//退出fn需要把标记置0
      return p->a0;//系统调用的返回值会在syscall中写入p->trapframe->a0
    }