[MIT 6.S081] Lab 4: traps

Lab 4: traps

  • Lab Guide: Lab: traps
  • Lab Code: https://github.com/peakcrosser7/xv6-labs-2020/tree/traps

RISC-V assembly (easy)

预处理

  1. 使用如下指令编译文件 user/call.c, 生成可读的汇编程序文件 user/call.asm
$ make fs.img
  1. 阅读其中 g(), f()main() 函数的代码.
0000000000000000 :
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int g(int x) {
   0:	1141                	addi	sp,sp,-16    # 栈顶指针下移16字节
   2:	e422                	sd	s0,8(sp)    # 存栈底指针s0/fp到sp+8的位置
   4:	0800                	addi	s0,sp,16    # 将sp+16即原sp的值作为新的栈帧
  return x+3;
}
   6:	250d                	addiw	a0,a0,3    # a0=a0+3, a0即为传入参数x又为返回值
   8:	6422                	ld	s0,8(sp)    # 从sp+8恢复原栈帧到s0
   a:	0141                	addi	sp,sp,16    # 回收栈顶指针
   c:	8082                	ret    # 返回

000000000000000e :    # 与g()函数系统,相当于将g()内联了

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 
: void main(void) { 1c: 1141 addi sp,sp,-16 # 栈顶指针下移16字节 1e: e406 sd ra,8(sp) # 存返回地址到sp+8的位置 20: e022 sd s0,0(sp) # 存栈底指针s0/fp到sp的位置 22: 0800 addi s0,sp,16 # 更新栈帧s0 printf("%d %d\n", f(8)+1, 13); 24: 4635 li a2,13 # 加载13到a2寄存器 26: 45b1 li a1,12 28: 00000517 auipc a0,0x0 # 将pc+0加载到a0 2c: 7b050513 addi a0,a0,1968 # 7d8 30: 00000097 auipc ra,0x0 # 将pc+0<<12加载到ra寄存器 34: 600080e7 jalr 1536(ra) # 630 # 将pc设置为ra+1536,并将pc+4写入ra(即进行函数跳转) exit(0); 38: 4501 li a0,0 3a: 00000097 auipc ra,0x0 3e: 27e080e7 jalr 638(ra) # 2b8
  • Ref: RISC-V手册 附录 A
    RISC-V Assembly Programmer’s Manual - GitHub

思考题

  • Q1: Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?
    A: 函数参数的寄存器为 a0~a7. printf13 存在寄存器 a2 中
  • Q2: 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.)
    A: 在 40 行可以看到, 编译器进行了函数内联, 直接将 f(8)+1的值 12 计算出来了.
  • Q3: At what address is the function printf located?
    A: 由第 43 和 44 行可以看出, jalr 跳转的地址为 0x30+1536=0x630, 即函数 printf 的地址为 0x630
  • Q4: What value is in the register ra just after the jalr to printf in main?
    A: 根据 jalr 指令的功能, 在刚跳转后 ra 的值为 pc+4=0x34+4=0x38.
  • Q5: 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?
    A: 输出: HE110 World
    [MIT 6.S081] Lab 4: traps_第1张图片
    若为大端对齐, i 需要设置为 0x726c6400, 不需要改变 57616的值(因为他是按照二进制数字读取的而非单个字符).
  • Q6: 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);
    
    A: 根据函数的传参规则, y= 后跟的值应该为寄存器 a2 的值.
    [MIT 6.S081] Lab 4: traps_第2张图片
    如图所示, 经过 gdb 调试验证, y= 后确实是寄存器 a2 的值.
    这种情况发生的原因在于, printf() 的格式字符串的数量和不定参数的数量不一致, 但函数执行时仍然从原本参数应该加载的寄存器取值. 按照 RISC-V 传参规则, 第二个不定参数应该被存于寄存器 a2, 因此在实际输出时也是将 a2 寄存器的值进行输出.

Backtrace (moderate)

要点

  • 编写函数 backtrace(), 遍历读取栈帧(frame pointer)并输出函数返回地址.

步骤

  1. 在文件 kernel/riscv.h 中添加内联函数 r_fp() 读取栈帧值
// read the current frame pointer from s0 register - lab4-2
static inline uint64 r_fp() {
    uint64 x;
    asm volatile("mv %0, s0" : "=r" (x) );
    return x;
}
  1. kernel/printf.c 中编写函数 backtrace()输出所有栈帧
    函数的思路很简单, 初始通过调用上述的 r_fp() 函数读取寄存器 s0 中的当前函数栈帧 fp. 根据 RISC-V 的栈结构, fp-8 存放返回地址, fp-16 存放原栈帧. 进而通过原栈帧得到上一级栈结构, 直到获取到最初的栈结构.
    这里需要考虑获取上一级栈帧的终止条件. RISC-V 的用户栈空间占一个页面, 因此可以通过 PGROUNDDOWN()PGROUNDUP() 计算得到一个地址所在的页面的最高和最低地址. 初始从寄存器 s0 读取到的栈帧 fp 是在用户栈空间中的地址, 由此可以得到用户栈的页面最高和最低地址作为循环的终止条件.
// print the return address - lab4-2
void backtrace() {
    uint64 fp = r_fp();    // 获取当前栈帧
    uint64 top = PGROUNDUP(fp);    // 获取用户栈最高地址
    uint64 bottom = PGROUNDDOWN(fp);    // 获取用户栈最低地址
    for (; 
        fp >= bottom && fp < top;     // 终止条件
        fp = *((uint64 *) (fp - 16))    // 获取下一栈帧
        ) {
        printf("%p\n", *((uint64 *) (fp - 8)));    // 输出当前栈中返回地址
    }
}

上述代码需要注意的是强制类型转换的对象要么为 (uint64 *)(fp-8)(uint64 *)(fp-16), 因为 8 和 16 的单位是字节; 或者为 (uint64 *) fp - 1(uint64 *) fp - 2, 因为此时 12 是以 (uint64*) 指针大小(8 字节)为单位的.
3. 添加 backtrace() 函数原型到 kernel/defs.h.
4. 在 kernel/sysproc.csys_sleep() 函数中添加对 backtrace() 的调用.
[MIT 6.S081] Lab 4: traps_第3张图片
5. 在 kernel/printf.cpanic() 函数中添加对 backtrace() 的调用.
[MIT 6.S081] Lab 4: traps_第4张图片

测试

  1. 在 xv6 中运行 bttest, 输出 3 个栈帧的返回地址; 退出 xv6 后运行 addr2line -e kernel/kernelbttest 的输出作为输入, 输出对应的调用栈函数, 如下图所示.
    [MIT 6.S081] Lab 4: traps_第5张图片
    根据输出的源码行号找对应的源码, 发现就是 backtrace() 函数的所有调用栈的返回地址(函数调用完后的下一代码).
    [MIT 6.S081] Lab 4: traps_第6张图片
    [MIT 6.S081] Lab 4: traps_第7张图片
    [MIT 6.S081] Lab 4: traps_第8张图片
  2. 运行 /grade-traps backtrace 测试输出.
    在这里插入图片描述

Alarm (hard)

test0: invoke handler

要点

  • 添加系统调用 sigalarm(interval, handler)sigreturn()
  • kernel/proc.hstruct proc 中添加新字段, 记录计时间隔(interval), 函数指针(handler), 以及过去的时钟数(passed ticks).
  • sigreturn() 返回 0.

步骤

  1. user/user.h 中添加两个系统调用的函数原型:
    在这里插入图片描述
  2. user/usys.pl 脚本中添加两个系统调用的相应 entry, 在 kernel/syscall.hkernel/syscall.c 添加相应声明.
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    [MIT 6.S081] Lab 4: traps_第9张图片
  3. 当前编写 sys_sigreturn() 只需要返回 0. 该函数置于了 kernel/sysproc.c 文件中.
// lab4-3
uint64 sys_sigreturn(void) {
    return 0;
}
  1. kernel/proc.h 中的 struct proc 结构体中添加记录时间间隔, 调用函数地址, 以及经过时钟数的字段
    [MIT 6.S081] Lab 4: traps_第10张图片
  2. 编写 sys_sigalarm() 函数, 将 intervalhandler 的值存到当前进程的 struct proc 结构体的相应字段中.
    在这里在指导书的基础上又做了两点优化: 一方面限定了 interval 的值需要非负, 根据定义 interval 表示每次调用 handler 函数的周期, 0 特指取消调用, 而负数在这里是没有意义的, 因此将其视为非法参数; 另一方面同时重置了过去的时钟数 p->passedticks, 此处考虑到可能中途会更新 sigalarm() 的调用参数, 这样之前记录的过去时钟数便失效了, 应该重新计数.
// lab4-3
uint64 sys_sigalarm(void) {
    int interval;
    uint64 handler;
    struct proc *p;
    // 要求时间间隔非负
    if (argint(0, &interval) < 0 || argaddr(1, &handler) < 0 || interval < 0) {
        return -1;
    }
    // lab4-3
    p = myproc();
    p->interval = interval;
    p->handler = handler;
    p->passedticks = 0;    // 重置过去时钟数

    return 0;
}
  1. kernel/proc.callocproc() 函数负责分配并初始化进程, 此处对上述 struct proc 新增的三个字段进行初始化赋值.
    [MIT 6.S081] Lab 4: traps_第11张图片
  2. 每经过异常时钟间隔, 会引发时钟中断, 调用 kernel/trap.c 中的 usertrap() 函数. 对于时钟中断 which_dev 变量的值为 2, 由此便可以单独对时钟中断进行操作.
    根据指导书要求, 由于 handler 函数地址可能为 0, 因此主要通过 interval==0 来判断是否终止定时调用函数.
    然后每经过一个时钟中断, 对 passedticks加 1, 当达到 interval 时便要调用 handler() 函数, 同时将 passticks 置零用于下次调用定时函数.
    此处主要考虑如何调用定时函数 handler(). 这里需要注意到, 在 usertrap() 中时页表已经切换为内核页表(切换工作在 uservec 函数中完成), 而 handler 很显然是用户空间下的函数虚拟地址, 因此不能直接调用. 这里实际上并没有直接调用, 而是p->trapfram->epc 置为 p->handler, 这样在返回到用户空间时, 程序计数器为 handler 定时函数的地址, 便达到了执行定时函数的目的.
void
usertrap(void)
{
  int which_dev = 0;
  // ...

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

  // lab4-3
  if(which_dev == 2){   // timer interrupt
    // increase the passed ticks
    if(p->interval != 0 && ++p->passedticks == p->interval){
      p->passedticks = 0;
      p->trapframe->epc = p->handler;   // execute handler() when return to user space 
    }
  }
  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}
  1. 修改 Makefile 文件中的 UPROGS 部分, 添加对 alarmtest.c 的编译.
    在这里插入图片描述

测试

此时 test0 是可以通过的.
[MIT 6.S081] Lab 4: traps_第12张图片

test1/test2(): resume interrupted code

要点

  • 需要存储和恢复相关寄存器
  • 确保 struct proc 保存足够的状态以可以由 sigreturn 返回到中断前的用户代码
  • 防止 handler 函数在返回前重入.

思路

  • sigalarm(interval, handler)sigreturn() 两个函数是配合使用的, 在 handler 函数返回前会调用 sigreturn().
    根据 test0 的做法可以看到, 调用定时函数 handler 实际上是通过修改 trapframe->epc 进而在返回到用户空间时调用定时函数. 但这也同时产生了一个问题, 即原本的 epc已被覆盖, 无法回到中断前的用户代码执行的位置, 同时在执行 handler() 函数后, 相关的寄存器的值也会受到影响.
    因此考虑要sigalarm() 函数中将寄存器值进行保存, 在 sigreturn() 函数中进行恢复. 这样在执行完 sigreturn() 后程序能够回到原来的执行位置.
  • 在系统调用时用户代码中断时会将寄存器记录到 p->trapframe 中, 而前者由于在 usertrap() 覆盖了 p->trapframe->epc, 才能够执行定时函数, 执行完后又会导致一些寄存器的值被修改. 因此, 考虑在 struct proc 中保存一个 trapframe 的副本, 在覆盖 epc 之前先保存副本, 然后在 sys_sigreturn() 中将副本还原到 p->trapframe 中, 从而在 sigreturn 系统调用结束后恢复用户寄存器状态时能够将执行定时函数前的寄存器状态进行恢复.
  • 对于 trapframe 的副本, 很显然是和 struct proc结构体关联的, 但具体实现可以有多种形式:
    • 缓冲区副本 char trapframebuf[288]: 即在 struct proc 中设立一个缓冲区字段来存放 trapframe 副本.
    • 结构体副本 struct trapframe trapframecopy: 本质上与上述缓冲区副本是一样的, 不过省去了计算开辟缓冲区大小的步骤.
    • 指向新分配页的指针 struct trapframe *trapframecopy: 前两者直接将副本存到结构体中, 相当于每个 proc 结构体都预先分配了该副本的空间. 而使用指针指向新分配的虚拟页, 则可以在需要时进行分配. 此处便类似 p->trapframe 指针, 需要借助 kalloc()kfree() 进行页面分配和释放. 虽然是每次使用时再分配使用完释放, 但每次分配直接分配了 1 page(4096B), 也有大部分字节的浪费.
    • 共用 trapframe 页面的指针 struct trapframe *trapframecopy: 这里和前者一样同样使用了指针形式. 但前者的缺点在于每次都需要额外分配释放一个页面. 而由于 struct trapframe 结构体只有 288B, 而一个页面是 4096B, 可以看到为 p->trapframe 分配的页面实际上有大量内存空间未被使用. 此处便是将副本 trapframecopytrapframe 共用一个页面, 既无需在 struct proc 结构体中分配内存, 又无需使用 kalloc()kfree() 额外分配释放页面, 因此笔者最终选择了该方式.
  • 对于对副本 trapframecopytrapframe 之间的拷贝操作, 容易想到有两种方式:
    • 结构体赋值 =: 直接进行结构体的赋值
    • memmove() 字节拷贝: 即直接将结构体视为字节流, 进行字节拷贝. 经过实际测试, 发现使用该方式比结构体赋值的速度更快.

步骤

  1. 修改 struct proc 结构体, 添加 trapframe 的副本字段:
// Per-process state
struct proc {
  // ...
  char name[16];               // Process name (debugging)
  int interval;                // alarm interval - lab4-3
  uint64 handler;              // pointer to the handler function - lab4-3
  int passedticks;             // ticks have passed since the last call - lab4-3
  struct trapframe* trapframecopy;      // the copy of trapframe - lab4-3
};
  1. kernel/trap.cusertrap() 中覆盖 p->trapframe->epc 前做 trapframe 的副本.
void
usertrap(void)
{
  // ...
  // lab4-3
  if(which_dev == 2){   // timer interrupt
    // increase the passed ticks
    if(p->interval != 0 && ++p->passedticks == p->interval){  
      // 使用 trapframe 后的一部分内存, trapframe大小为288B, 因此只要在trapframe地址后288以上地址都可, 此处512只是为了取整数幂
      p->trapframecopy = p->trapframe + 512;  
      memmove(p->trapframecopy,p->trapframe,sizeof(struct trapframe));    // copy trapframe
      p->trapframe->epc = p->handler;   // execute handler() when return to user space
    }
  }
  // ...
}
  1. sys_sigreturn() 中将副本恢复到原 trapframe.
    此处在拷贝副本前额外做了一个地址判断, 是防止用户程序在未调用 sigalarm() 便使用了该系统调用, 那么此时没有副本即 trapframecopy 是无效的, 应避免错误拷贝. 在拷贝后将 trapframecopy 置零, 表示当前没有副本.
// lab4-3
uint64 sys_sigreturn(void) {
    struct proc* p = myproc();
    // trapframecopy must have the copy of trapframe
    if(p->trapframecopy != p->trapframe + 512) {
        return -1;
    }
    memmove(p->trapframe, p->trapframecopy, sizeof(struct trapframe));   // restore the trapframe
    p->passedticks = 0;     // prevent re-entrant
    p->trapframecopy = 0;    // 置零
    return 0;
}
  1. 为了保证 trapframecopy 的一致性, 在初始进程 kernel/proc.callocproc() 中, 初始化 p->trapframecopy 为 0, 表明初始时无副本.
    [MIT 6.S081] Lab 4: traps_第13张图片
  2. 在指导书中要求定时函数 handler 需要防重入, 即在其未返回时不能触发下一次. 这里需要的改动就是将 p->passedticks = 0; 从原本的 usertrap() 移至 sys_sigreturn() 中.
    因为在 usertrap() 中重置则后续 passedticks 则会继续重新递增, 自然可能满足调用 handler 的条件; 而移至 sys_sigreturn() 之后, 即在最后函数返回前才会清零, 按照该系统调用的正确使用方法, sigreturn() 的结束就应该标志着 handler() 的结束, 也就是说在 handler() 还未结束时, passedticks 会继续递增, 从而不会再满足调用 handler 的条件, 自然就可以避免重入. 上述代码已经是已经过修改.

测试

  • 在 xv6 中执行 alarmtestusertests 均通过:
    [MIT 6.S081] Lab 4: traps_第14张图片
    [MIT 6.S081] Lab 4: traps_第15张图片
  • /grade-lab-traps alarmtest 单项测试:
    [MIT 6.S081] Lab 4: traps_第16张图片
  • make grade测试:
    [MIT 6.S081] Lab 4: traps_第17张图片

你可能感兴趣的:(MIT,6.S081,Labs,操作系统)