MIT6.S081 Lab4:Traps

370 阅读10分钟

开始Lab4前,需要阅读Chap4,以及相关代码:

  • kernel/trampoline.S:从用户空间转换到内核空间再转换回来所涉及到的汇编代码
  • kernel/trap.c:处理所有中断的代码 切换到Lab4分支:
  $ git fetch
  $ git checkout traps
  $ make clean

Lab4:Traps

RISC-V assembly(easy)

了解一点RISC-V汇编是很重要的。xv6 repo中有一个文件user/call.c。通过make fs.img编译它,并生成可读版本的汇编程序user/call.asm

阅读user/call.asm中的函数gf,和main。RISC-V指令集见官网阅读材料。

以下是需要回答的问题(将答案存储在一个answers-traps.txt文件中):

  • 哪些寄存器包含函数的参数?比如,在main调用printf时,哪个寄存器持有13?
  • main的汇编代码中对函数f的调用在哪里?对g的调用在哪里?(提示:编译器可以内联函数)
  • printf函数位于什么地址?
  • mainjalr跳转到printf之后,ra的值是什么?
  • 运行下面的代码,输出是什么?(可以查看ASCII表的映射关系):
 unsigned int i = 0x00646c72;
 printf("H%x Wo%s", 57616, &i);

输出取决于RISC-V是小端序这一事实。如果RISC-V是大端序,为了产生相同的输出,你会将 i 设置为什么?你需要将 57616 更改为不同的值吗?

  • 下面的代码中,y=之后将打印什么值?(提示:答案不是一个特定的值),为什么会这样?
 printf("x=%d y=%d", 3);

answers:

  • RISC-V提供了8个寄存器用于传参,为a0 - a7,超过8个参数之后的参数通过栈传递。 在main调用printf时,13应该保存在寄存器a2中,由汇编代码可以看出(li指令,可以用来加载常量):
 void main(void) {
     ...
     printf("%d %d\n", f(8)+1, 13);
     24: 4635                    li  a2,13
     ...
 }
  • 没有对fg函数调用的代码,g函数被内联到f函数中,f函数被内联到main函数中。可以看到main中直接把f(8) + 1的值传递给了a1寄存器,没有对f函数进行调用。
 void main(void) {
     ...
     printf("%d %d\n", f(8)+1, 13);
     24: 4635                    li  a2,13
     26: 45b1                    li  a1,12
     ...
 }
  • printf函数位于的地址是0x630
 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:   7b050513            addi    a0,a0,1968 # 7d8 <malloc+0xea>
   30:   00000097            auipc   ra,0x0
   34:   600080e7            jalr    1536(ra) # 630 <printf>
   exit(0);
   38:   4501                    li  a0,0
   3a:   00000097            auipc   ra,0x0
   3e:   27e080e7            jalr    638(ra) # 2b8 <exit>

由上面的汇编代码可以看到:

   30:   00000097            auipc   ra,0x0
   34:   600080e7            jalr    1536(ra) # 630 

其中,auipc指令将0x0左移12位,然后加到pc上,并将结果写入ra,这一行代码的开头,即为pc当前的值,即pc = 0x30。那么有pc = 0x30 = ra。然后通过jalr指令(jalr指令可以用来进行函数跳转),将pc设置为ra寄存器中的值加上1536这个偏移量,ra + 1536 = 0x30 + 0x0600 = 0x0630,然后无条件跳转到当前pc的位置,即0x0630,所以printf函数的地址在0x0630

  • 上面的jalr指令,不仅设置了pc的值用于函数跳转,还会将下一条指令的地址保存到寄存器ra(即printf调用返回main之后的地址)。由于指令是32位,所以应该将ra设置为当前pc + 4,所以ra = pc + 4 = 34 + 4 = 0x38
  • 由于RISC-V是小端序,对于i,其字节在地址中的存放顺序是72 6c 64,在ASCII码表中分别对应r l d,而数字57616转换成16进制的结果为 0x0000e110,所以上述代码的输出结果是He110 World。由于%x是得到参数的十六进制,所以不管是大端序还是小端序,对于固定的参数其十六进制都是不变的,所以57616不用改变。而为了产生相同的输出,i需要改变,改变后的i = 0x726c6400
  • 输出的是一个受调用前代码影响的随机值printf尝试读取的参数数量比提供的参数多。第二个参数 3 会通过寄存器a1传递,而第三个参数对应的寄存器a2在调用前不会被设置为任何具体的值,而是会包含调用发生前的任何已经在里面的值,即会输出a2寄存器的值,但a2寄存器的值是不确定的,跟之前的其他代码调用有关。

Backtrace(moderate)

对于调试来说,回溯通常很有用:在错误发生点之上的堆栈上的函数调用列表。

要求:

 在 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),并按如下方式复制并粘贴上述地址:
 $ addr2line -e kernel/kernel
 0x0000000080002de2
 0x0000000080002f4a
 0x0000000080002bfc
 Ctrl-D
 ​
 你应该会看到如下的输出:
 kernel/sysproc.c:74
 kernel/syscall.c:224
 kernel/trap.c:85

编译器在每个栈帧中放入一个帧指针,它保存调用者的帧指针的地址。回溯应该使用这些帧指针向上遍历堆栈在每个堆栈帧中打印保存的返回地址

提示:

  • kernel/defs.h中添加 backtrace 的原型,这样你就可以在sys_sleep 中调用回溯
  • GCC编译器将当前执行函数的帧指针存储在寄存器s0中。将以下函数添加到kernel/riscv.h中:
 static inline uint64
 r_fp()
 {
   uint64 x;
   asm volatile("mv %0, s0" : "=r" (x) );
   return x;
 }

并在backtrace中调用此函数来读取当前帧指针,这个函数通过使用 in-line assembly(即内联汇编表达式)(gcc.gnu.org/onlinedocs/…)来读取s0

内联汇编表达式中,"=r" (x),是一个操作表达式,指定了一个输出操作。括号中的部分是一个C/C++表达式,用来保存内联汇编的一个输出值,其操作就等于C/C++的相等赋值 x = output_value,因此括号中的输出表达式只能是C/C++的左值表达式。而output_value由引号中的内容而来("=r"),引号中的内容被称作"操作约束",在这个例子中操作约束等于"=r",它包含两个约束:等号和字母r。其中等号=说明括号中左值表达式x是一个write-only的,只能够作为当前内联汇编的输出,而不能作为输入。而字母r,是寄存器的简写,说明x的值要从寄存器r中获取。操作约束会给出:到底从哪个寄存器传递值给x

  • 栈帧的布局如下,注意:返回地址与栈帧指针的偏移量(-8)是固定的,而保存的帧指针与堆栈帧指针的偏移量(-16)也是固定的。

image-20220515102022493

  • xv6在页面对齐的地址上为xv6内核中的每个堆栈分配一个页面。可以使用PGROUNDDOWN(fp)PGROUNDUP(fp)来计算堆栈页面的顶部和底部地址(见kernel/riscv.h这些数字有助于回溯结束其循环

一旦您的backtrace开始工作,在kernel/printf.c中从 panic 调用它,这样就可以在内核出现panic时看到它的回溯

代码:

 // kernel/printf.c
 // 向上遍历堆栈并打印每个栈帧中保存的返回地址
 void backtrace(void) {
   uint64 fp = r_fp();
   
   // 由于xv6内核中为每一个堆栈分配一个页面,所以可以通过PGROUNDUP来判断栈是否已经到顶
   uint64 bottom = PGROUNDDOWN(fp);
   uint64 top = PGROUNDUP(fp);
   printf("backtrace:\n");
   
   while (fp < top && fp > bottom) {
     /*
     由于fp是帧指针,那么由fp-8可以得到指向返回地址的指针,即可以将fp-8看作一个指针,其中保存的值是返回地址
     为了得到具体的返回地址,需要将fp-8强制转换为一个指针,并对该指针解引用,从而得到其中保存的值
     */
     uint64 ra = *(uint64*)(fp - 8); 
     printf("%p\n", ra);
     fp = *(uint64*)(fp - 16);
   }
 }

测试:

 $ bttest
 backtrace:
 0x0000000080002ce4
 0x0000000080002bbe
 0x00000000800028a8

image-20220516104255058

Alarm(hard)

要求:

 向xv6添加一个特性,该特性在进程使用CPU时间 时定期向进程发出警报
 这对于希望限制消耗多少CPU时间的计算绑定进程,或者希望进行计算但是也希望采取一些周期性操作的进程来说可能很有用
 更一般地说,你将实现用户级中断/错误处理程序的基本形式
 例如,你可以使用类似的方法来处理应用程序中的页面错误

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

你将在xv6库中找到一个文件user/alarmtest.c,将其添加到Makefile中。只有添加了sigalarmsigreturn系统调用,它才能正确编译。

alarmtesttest0中调用sigalarm(2, periodic),要求内核每隔2 ticks强制调用periodic(),然后spin一段时间。可以在user/alarmtest.asm中看到alarmtest的汇编代码,这可能便于debug。

如果alarmtest产生如下输出,并且usertests也正常运行,那么你的解决方案是正确的:

 $ 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
 $ usertests
 ...
 ALL TESTS PASSED
 $

test0:调用handler

test0首先修改内核,使其跳转到用户空间中的alarm handler,这将导致test0打印 "alarm!"。不要担心"alarm!"输出之后会发生什么;现在如果你的程序在打印"alarm!"后崩溃了,这是OK的。

提示:

  • 你需要修改Makefile,使alarmtest.c被编译为一个xv6用户程序
  • user/user.h中正确的声明是:
 int sigalarm(int ticks, void (*handler)());
 int sigreturn(void);
  • 更新user/usys.pl(生成user/usys.S)、kernel/syscall.hkernel/syscall.c,允许alarmtest调用sigalarmsigreturn系统调用
  • 现在,sys_sigreturn应该只返回0
  • 你的sys_sigalarm()应该在proc结构(kernel/proc.h)的新字段中存储alarm间隔和指向handler函数的指针
  • 你需要跟踪从上次调用(或直到下一次调用)进程的alarm handler已经传递了多少ticks。为此,你还需要在struct proc中添加一个新字段。你可以在proc.callocproc()中初始化proc字段。
  • 每个tick,硬件时钟强制一个中断,这在kernel/trap.cusertrap()中处理
  • 只有在存在计时器中断的情况下,你才希望操作进程的alarm ticks,你想要的是:
 if(which_dev == 2) ...
  • 只有在进程有未完成的计时器时才调用alarm函数。请注意,用户的alarm函数的地址可能是0(比如,在user/alarmtest.asmperiodic就是在地址0)
  • 你需要修改usertrap(),以便当进程的alarm间隔过期时,用户进程执行handler函数。当RISC-V上的trap返回到用户空间时,什么决定了用户空间代码恢复执行的指令地址?(寄存器sepc保存了中断时的用户的程序计数器,可以通过修改该寄存器的值,来决定用户空间代码恢复执行的指令地址)
  • 如果告诉qemu只使用一个CPU(通过以下命令实现),那么使用gdb查看trap将会更容易:
 make CPUS=1 qemu-gdb
  • 如果alarmtest打印出"alarm!",则表示成功

test1/test2():恢复中断的代码

很有可能,alarmtesttest0test1中打印"alarm!"之后崩溃,或者alarmtest(最终)打印test1 failed,或者alarmtest退出时没有打印test1 passed。 要解决这个问题,必须确保,当alarm handler程序完成时,控制返回到用户程序最初被计时器中断 中断时的指令。 你必须确保寄存器内容恢复到中断时的值,以便用户程序可以在alarm后继续不受干扰。 最后,你应该在alarm计数器每次发出alarm之后,重新计数,以便定期调用处理程序。

我们已经为你做了一个设计决策:当用户alarm handler程序完成时,需要调用sigreturn系统调用。 以 alarmtest.c中的periodic为例,这意味着你可以向usertrapsys_sigreturn添加代码,在用户进程处理完alarm后,它们会协作使用户进程正常恢复。

提示:

  • 你的解决方案将要求你保存和恢复寄存器——你需要保存和恢复哪些寄存器才能正确地恢复中断的代码?(提示:会有很多)
  • usertrapstruct proc中保存足够的状态,当计时器停止时,sigreturn可以正确地返回被中断的用户代码
  • 防止对处理程序的可重入调用——如果处理程序还没有返回,内核就不应该再次调用它。test2测试了这一点

通过test0后,test1test2将运行usertests,以确保没有破坏内核的任何其他部分。

综上,alarm的总体过程大致如下:

  1. 首先通过系统调用sigalarm设置alarm间隔和alarm处理函数。
  2. 然后,当计时器中断发生时,陷入内核,调用usertrap()(注意,此时需要备份当前的trapframe,这样当之后调用sigreturn时,可以通过备份的trapframe,来恢复计时器中断发生前的指令)。
  3. usertrap()中,发现是计时器中断,那么会进行相应的处理,并返回到用户空间alarm处理函数。
  4. 执行alarm处理函数,经过一系列操作后,调用系统调用sigreturn,表明要返回到计时器中断发生前的执行序列。
  5. 再次陷入内核(这里也会有一个trapframe,但是返回时不应该根据此trapframe进行恢复,而应该根据之前备份的trapframe来恢复到计时器中断之前的状态),调用usertrap(),发现是因为系统调用中断,那么就调用相应的系统调用函数sys_sigreturn
  6. sys_sigreturn中根据之前备份的trapframe进行恢复

代码:

在Makefile中添加alarmtest

 # Makefile
 UPROGS=\
     $U/_cat\
     $U/_echo\
     $U/_forktest\
     $U/_grep\
     $U/_init\
     $U/_kill\
     $U/_ln\
     $U/_ls\
     $U/_mkdir\
     $U/_rm\
     $U/_sh\
     $U/_stressfs\
     $U/_usertests\
     $U/_grind\
     $U/_wc\
     $U/_zombie\
     $U/_alarmtest\

添加sigalarmsigreturn系统调用:

  1. kernel/syscall.h中,添加新的系统调用编号:
 // kernel/syscall.h
 ...
 #define SYS_sigalarm    22
 #define SYS_sigreturn   23
  1. kernel/syscall.c中,用extern全局声明新的内核调用函数,并且在 syscalls映射表中,加入从前面定义的编号到系统调用函数指针的映射:
 // kernel/syscall.c
 ...
 extern uint64 sys_sigalarm(void);
 extern uint64 sys_sigreturn(void);
 ​
 static uint64 (*syscalls[])(void) = {
     ...
     [SYS_sigalarm]  sys_sigalarm,
     [SYS_sigreturn] sys_sigreturn,
 };
  1. user/usys.pl中,加入用户态到内核态的跳板函数:
 # user/usys.pl
 entry("sigalarm");
 entry("sigreturn");
  1. user/user.h中加入一个sigalarmsigreturn函数原型:
 // user/user.h
 // system calls
 ...
 int sigalarm(int ticks, void (*handler)());
 int sigreturn(void);

kernel/proc.h中添加alarm相关成员:

 // kenrel/proc.h
 struct proc {
     ...
     // alarm 相关成员
     void (*alarm_handler)();      // alarm 处理函数
     int alarm_interval;          // 一次alarm的间隔(如果间隔为0,表示禁用alarm)
     int alarm_ticks;             // 距离下次alarm还剩多少ticks
     int handler_is_return;      // 判断是否有handler程序在执行,如果有handler程序没有返回,内核就不应该再次调用它
     struct trapframe* alarm_trapframe; // 存储时钟中断时刻的trapframe,用于handler处理完之后恢复原程序的正常运行
 };

kernel/proc.callocproc()中初始化alarm相关字段:

 // kernel/proc.c
 static struct proc* allocproc(void) {
       ...
       // * 初始化alarm相关字段
       // * 为之后进行备份所用到的trapframe分配页面
       if ((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0) {
         release(&p->lock);
         return 0;
       }
       p->alarm_interval = 0;
       p->alarm_ticks = 0;
       p->handler_is_return = 0;
       p->alarm_handler = 0;
 ​
       return p;
 }

kernel/proc.cfreeproc()中释放alarm相关字段:

 // kernel/proc.c
 static void
 freeproc(struct proc *p)
 {
   ...
   if (p->alarm_trapframe)
     kfree((void*)p->alarm_trapframe);
   ...
   p->alarm_interval = 0;
   p->alarm_ticks = 0;
   p->handler_is_return = 0;
   p->alarm_handler = 0;
   ...
 }

实现sigalarmsigreturn系统调用:

 // kernel/sysproc.c
 // * alarm相关函数
 uint64 sys_sigalarm(void) {
   struct proc* p = myproc();
   int n;
   uint64 func;
   // 获取时间间隔参数和对应的函数指针参数
   if (argint(0, &n) < 0)
     return -1;
   if (argaddr(1, &func) < 0)
     return -1;
   
   p->alarm_interval = n;
   p->alarm_handler = (void (*)())(func);
   p->alarm_ticks = n;
 ​
   return 0;
 }
 ​
 // * 根据备份的trapframe恢复到计时器中断前的状态
 uint64 sys_sigreturn(void) {
   struct proc* p = myproc();
   // * 通过 defs.h 中声明的备份trapframe函数,将之前备份的trapframe拷贝给当前进程的trapframe,这样当前进程从系统调用返回时,就会根据之前备份的trapframe进行恢复
   backupTrapframe(p->alarm_trapframe, p->trapframe);
   // * 同时,将代表是否有alarm处理函数正在运行的成员进行修改
   p->handler_is_return = 0;
   return 0;
 }

修改kernel/trap.cusertrap,使得计时器中断发生时,进行相应的处理,首先判断是否禁用了alarm,然后判断alarm的间隔是否到期,如果到期了,就重置alarm_ticks,然后修改trapframe中的sepc寄存器的值,使得中断返回时,调用用户程序sigalarm

 // kernel/trap.c
 void usertrap(void) {
     ...
     if(r_scause() == 8) {
         ...
     }else if((which_dev = devintr()) != 0) {
     // * 如果是计时器中断,则调用alarm相关函数
     if (which_dev == 2) {
       // * 首先判断是否禁用了alarm
       if (p->alarm_interval != 0) {
         // * 然后判断alarm的间隔是否到期
         if (--p->alarm_ticks == 0) {
           // * 然后判断是否有处理程序正在运行,如果有,内核就不应该再次调用它
           if (p->handler_is_return == 0) {
             // * 如果到期了,且没有处理程序正在运行,就重置alarm_ticks,并修改sepc寄存器的值,使得中断返回时,调用用户的alarm处理函数
             p->alarm_ticks = p->alarm_interval;
             p->handler_is_return = 1;  // * 表示有处理程序正在运行
             // * 同时还需要备份当前的trapframe,当调用sigreturn时,通过备份的trapframe进行恢复
             backupTrapframe(p->trapframe, p->alarm_trapframe);
             // 修改sepc寄存器的值,使得返回时,调用用户的alarm处理函数
             p->trapframe->epc = (uint64)(p->alarm_handler);
           }
         }
       }
     }
     } else {
     printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
     printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
     p->killed = 1;
   }
 }

并在kernel/trap.c中实现备份trapframe的函数:

 // kernel/trap.c
 // * 备份trapframe,当sigreturn被调用时,通过备份的trapframe进行恢复
 void backupTrapframe(struct trapframe* now, struct trapframe* backup) {
   backup->kernel_satp = now->kernel_satp;
   backup->kernel_sp = now->kernel_sp;
   backup->kernel_satp = now->kernel_satp;
   backup->epc = now->epc;
   backup->kernel_hartid = now->kernel_hartid;
   backup->ra = now->ra;
   backup->sp = now->sp;
   backup->gp = now->gp;
   backup->tp = now->tp;
   backup->t0 = now->t0;
   backup->t1 = now->t1;
   backup->t2 = now->t2;
   backup->s0 = now->s0;
   backup->s1 = now->s1;
   backup->a1 = now->a1;
   backup->a2 = now->a2;
   backup->a3 = now->a3;
   backup->a4 = now->a4;
   backup->a5 = now->a5;
   backup->a6 = now->a6;
   backup->a7 = now->a7;
   backup->s2 = now->s2;
   backup->s3 = now->s3;
   backup->s4 = now->s4;
   backup->s5 = now->s5;
   backup->s6 = now->s6;
   backup->s7 = now->s7;
   backup->s8 = now->s8;
   backup->s9 = now->s9;
   backup->s10 = now->s10;
   backup->s11 = now->s11;
   backup->t3 = now->t3;
   backup->t4 = now->t4;
   backup->t5 = now->t5;
   backup->t6 = now->t6;
 }

kernel/defs.h中,添加对函数backupTrapframe的声明:

 // kernel/defs.h
 struct trapframe;
 ...
 // trap.c
 extern uint     ticks;
 void            trapinit(void);
 void            trapinithart(void);
 extern struct spinlock tickslock;
 void            usertrapret(void);
 void            backupTrapframe(struct trapframe*, struct trapframe*);
 ...

测试:

image-20220516232224683

总体测试:

image-20220516232950180