这是我自学 MIT6.S081 操作系统课程的 lab 代码笔记第四篇:Traps。此 lab 大致耗时:8小时。

课程地址:pdos.csail.mit.edu/6.S081/2020…
Lab 地址:pdos.csail.mit.edu/6.S081/2020…
我的代码地址:github.com/Miigon/my-x…
Commits: github.com/Miigon/my-x…

本文中代码注释是编写博客的时分加入的,原库房中的代码可能缺少注释或代码不完全相同。

Lab 4: Traps

This lab explores how system calls are implemented using traps. You will first do a warm-up exercises with stacks and then you will implement an example of user-level trap handling.

探索 trap 完结系统调用的方式。

注意本部分主要内容其实都在lecture里(lecture 5、lecture 6),实验不是非常复杂可是以理解概念为重,trap机制、trampoline作用、函数calling convention、调用栈、特权形式、riscv汇编,这些即使都不知道可能仍然能完结 lab。可是不代表这些不重要,相反这些才是主要内容,不然 lab 就算跑起来也仅仅盲狙,没有真实抵达学习作用。

RISC-V assembly (easy)

It will be important to understand a bit of RISC-V assembly, which you were exposed to in 6.004. There is a file user/call.c in your xv6 repo. make fs.img compiles it and also produces a readable assembly version of the program in user/call.asm.

Read the code in call.asm for the functions g, f, and main. The instruction manual for RISC-V is on the reference page. Here are some questions that you should answer (store the answers in a file answers-traps.txt):

阅览 call.asm,以及 RISC-V 指令集教程,回答问题。(学习 RISC-V 汇编)

Q: Which registers contain arguments to functions? For example, which register holds 13 in main's call to printf?
A: a0-a7; a2;
Q: 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: There is none. g(x) is inlined within f(x) and f(x) is further inlined into main()
Q: At what address is the function printf located?
A: 0x0000000000000628, main calls it with pc-relative addressing.
Q: What value is in the register ra just after the jalr to printf in main?
A: 0x0000000000000038, next line of assembly right after the jalr
Q: Run the following code.
	unsigned int i = 0x00646c72;
	printf("H%x Wo%s", 57616, &i);      
What is the output?
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"; 0x726c6400; no, 57616 is 110 in hex regardless of endianness.
Q: 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: A random value depending on what codes there are right before the call.Because printf tried to read more arguments than supplied.
The second argument `3` is passed in a1, and the register for the third argument, a2, is not set to any specific value before the
call, and contains whatever there is before the call.

简略翻译:

Q: 哪些寄存器存储了函数调用的参数?举个比如,main 调用 printf 的时分,13 被存在了哪个寄存器中?
A: a0-a7; a2;
Q: main 中调用函数 f 对应的汇编代码在哪?对 g 的调用呢? (提示:编译器有可能会内链(inline)一些函数)
A: 没有这样的代码。 g(x) 被内链到 f(x) 中,然后 f(x) 又被进一步内链到 main() 中
Q: printf 函数地点的地址是?
A: 0x0000000000000628, main 中使用 pc 相对寻址来计算得到这个地址。
Q: 在 main 中 jalr 跳转到 printf 之后,ra 的值是什么?
A: 0x0000000000000038, jalr 指令的下一条汇编指令的地址。
Q: 运转下面的代码
	unsigned int i = 0x00646c72;
	printf("H%x Wo%s", 57616, &i);      
输出是什么?
假如 RISC-V 是大端序的,要完结同样的作用,需要将 i 设置为什么?需要将 57616 修正为别的值吗?
A: "He110 World"; 0x726c6400; 不需要,57616 的十六进制是 110,无论端序(十六进制和内存中的表示不是同个概念)
Q: 在下面的代码中,'y=' 之后会答应什么? (note: 答案不是一个详细的值) 为什么?
	printf("x=%d y=%d", 3);
A: 输出的是一个受调用前的代码影响的“随机”的值。因为 printf 测验读的参数数量比提供的参数数量多。
第二个参数 `3` 经过 a1 传递,而第三个参数对应的寄存器 a2 在调用前不会被设置为任何详细的值,而是会
包含调用发生前的任何现已在里面的值。

Backtrace (moderate)

For debugging it is often useful to have a backtrace: a list of the function calls on the stack above the point at which the error occurred.

Implement a backtrace() function in kernel/printf.c. Insert a call to this function in sys_sleep, and then run bttest, which calls sys_sleep. Your output should be as follows:

backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898

After bttest exit qemu. In your terminal: the addresses may be slightly different but if you run addr2line -e kernel/kernel (or riscv64-unknown-elf-addr2line -e kernel/kernel) and cut-and-paste the above addresses as follows:

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

You should see something like this:

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

增加 backtrace 功能,打印出调用栈,用于调试。

在 defs.h 中增加声明

// defs.h
void            printf(char*, ...);
void            panic(char*) __attribute__((noreturn));
void            printfinit(void);
void            backtrace(void); // new

在 riscv.h 中增加获取当时 fp(frame pointer)寄存器的办法:

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

fp 指向当时栈帧的开始地址,sp 指向当时栈帧的结束地址。 (栈从高地址往低地址成长,所以 fp 虽然是帧开始地址,可是地址比 sp 高)
栈帧中从高到低第一个 8 字节 fp-8 是 return address,也就是当时调用层应该回来到的地址。
栈帧中从高到低第二个 8 字节 fp-16 是 previous address,指向上一层栈帧的 fp 开始地址。
剩余的为保存的寄存器、局部变量等。一个栈帧的大小不固定,可是至少 16 字节。
在 xv6 中,使用一个页来存储栈,假如 fp 现已抵达栈页的上界,则说明现已抵达栈底。

检查 call.asm,可以看到,一个函数的函数体最开始首要会扩大一个栈帧给该层调用使用,在函数履行结束后再收回,比如:

int g(int x) {
   0:	1141                  addi  sp,sp,-16  // 扩张调用栈,得到一个 16 字节的栈帧
   2:	e422                  sd    s0,8(sp)   // 将回来地址存到栈帧的第一个 8 字节中
   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              // 回来

注意栈的成长方向是从高地址到低地址,所以扩张是 -16,而收回是 +16。

更多关于寄存器、栈帧以及内存调用的细节,请检查 lecture 5,或 这个很有用的 slides。

完结 backtrace 函数:

// printf.c
void backtrace() {
  uint64 fp = r_fp();
  while(fp != PGROUNDUP(fp)) { // 假如现已抵达栈底
    uint64 ra = *(uint64*)(fp - 8); // return address
    printf("%p\n", ra);
    fp = *(uint64*)(fp - 16); // previous fp
  }
}

在 sys_sleep 的开头调用一次 backtrace()

// sysproc.c
uint64
sys_sleep(void)
{
  int n;
  uint ticks0;
  backtrace(); // print stack backtrace.
  if(argint(0, &n) < 0)
    return -1;
  // ......
  return 0;
}

编译运转:

$ bttest
0x0000000080002dea
0x0000000080002cc4
0x00000000800028d0

Alarm (hard)

In this exercise you’ll add a feature to xv6 that periodically alerts a process as it uses CPU time. This might be useful for compute-bound processes that want to limit how much CPU time they chew up, or for processes that want to compute but also want to take some periodic action. More generally, you’ll be implementing a primitive form of user-level interrupt/fault handlers; you could use something similar to handle page faults in the application, for example. Your solution is correct if it passes alarmtest and usertests.

依照如下原型增加系统调用 sigalarmsigreturn(详细步骤不再赘述):

int sigalarm(int ticks, void (*handler)());
int sigreturn(void);

首要,在 proc 结构体的界说中,增加 alarm 相关字段:

  • alarm_interval:时钟周期,0 为禁用
  • alarm_handler:时钟回调处理函数
  • alarm_ticks:下一次时钟响起前还剩余的 ticks 数
  • alarm_trapframe:时钟中止时刻的 trapframe,用于中止处理完结后恢复原程序的正常履行
  • alarm_goingoff:是否现已有一个时钟回调正在履行且还未回来(用于避免在 alarm_handler 中途闹钟到期再次调用 alarm_handler,导致 alarm_trapframe 被覆盖)
struct proc {
  // ......
  int alarm_interval;          // Alarm interval (0 for disabled)
  void(*alarm_handler)();      // Alarm handler
  int alarm_ticks;             // How many ticks left before next alarm goes off
  struct trapframe *alarm_trapframe;  // A copy of trapframe right before running alarm_handler
  int alarm_goingoff;          // Is an alarm currently going off and hasn't not yet returned? (prevent re-entrance of alarm_handler)
};

sigalarm 与 sigreturn 详细完结:

// sysproc.c
uint64 sys_sigalarm(void) {
  int n;
  uint64 fn;
  if(argint(0, &n) < 0)
    return -1;
  if(argaddr(1, &fn) < 0)
    return -1;
  return sigalarm(n, (void(*)())(fn));
}
uint64 sys_sigreturn(void) {
	return sigreturn();
}
// trap.c
int sigalarm(int ticks, void(*handler)()) {
  // 设置 myproc 中的相关特点
  struct proc *p = myproc();
  p->alarm_interval = ticks;
  p->alarm_handler = handler;
  p->alarm_ticks = ticks;
  return 0;
}
int sigreturn() {
  // 将 trapframe 恢复到时钟中止之前的状态,恢复本来正在履行的程序流
  struct proc *p = myproc();
  *p->trapframe = *p->alarm_trapframe;
  p->alarm_goingoff = 0;
  return 0;
}

在 proc.c 中增加初始化与释放代码:

// proc.c
static struct proc*
allocproc(void)
{
  // ......
found:
  p->pid = allocpid();
  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);
    return 0;
  }
  // Allocate a trapframe page for alarm_trapframe.
  if((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);
    return 0;
  }
  p->alarm_interval = 0;
  p->alarm_handler = 0;
  p->alarm_ticks = 0;
  p->alarm_goingoff = 0;
  // ......
  return p;
}
static void
freeproc(struct proc *p)
{
  // ......
  if(p->alarm_trapframe)
    kfree((void*)p->alarm_trapframe);
  p->alarm_trapframe = 0;
  // ......
  p->alarm_interval = 0;
  p->alarm_handler = 0;
  p->alarm_ticks = 0;
  p->alarm_goingoff = 0;
  p->state = UNUSED;
}

在 usertrap() 函数中,完结时钟机制详细代码:

void
usertrap(void)
{
  int which_dev = 0;
  // ......
  if(p->killed)
    exit(-1);
  // give up the CPU if this is a timer interrupt.
  // if(which_dev == 2) {
  //   yield();
  // }
  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2) {
    if(p->alarm_interval != 0) { // 假如设定了时钟事情
      if(--p->alarm_ticks <= 0) { // 时钟倒计时 -1 tick,假如现已抵达或超过设定的 tick 数
        if(!p->alarm_goingoff) { // 确保没有时钟正在运转
          p->alarm_ticks = p->alarm_interval;
          // jump to execute alarm_handler
          *p->alarm_trapframe = *p->trapframe; // backup trapframe
          p->trapframe->epc = (uint64)p->alarm_handler;
          p->alarm_goingoff = 1;
        }
        // 假如一个时钟到期的时分现已有一个时钟处理函数正在运转,则会推迟到原处理函数运转完结后的下一个 tick 才触发这次时钟
      }
    }
    yield();
  }
  usertrapret();
}

这样,在每次时钟中止的时分,假如进程有现已设置的时钟(alarm_interval != 0),则进行 alarm_ticks 倒数。当 alarm_ticks 倒数到小于等于 0 的时分,假如没有正在处理的时钟,则测验触发时钟,将本来的程序流保存起来(*alarm_trapframe = *trapframe),然后经过修正 pc 寄存器的值,将程序流转跳到 alarm_handler 中,alarm_handler 履行结束后再恢复本来的履行流(*trapframe = *alarm_trapframe)。这样从本来程序履行流的视角,就是不可感知的中止了。

编译运转:

$ 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

Optional challenge exercises

Print the names of the functions and line numbers in backtrace() instead of numerical addresses (hard).

xv6 默认的编译形式会在生成的可履行文件中,含有调试信息,其间包含了所有符号的名称以及其对应的地址。理论上 backtrace 可以做类似 addr2line 的操作,经过解析可履行文件本身附带的调试信息取得地址对应的源码文件以及行号。这里跳过该 challenge.