MIT6.S081 Lab Traps

本实验主要是关于如何使用陷阱实现系统调用的。

RISC-V assembly (easy)

这个部分主要是回答一些问题。

首先我们按照实验的指示,运行下面的命令得到一份容易读懂的汇编和 C 结合的代码,位于 user/call.asm

make fs.img

Question 1

Which registers contain arguments to functions? For example, which register holds 13 in main's call to printf?

哪些寄存器是用来存放函数的参数的?例如,在 mainprintf 的调用中,那个寄存器存放了参数 13

根据实验补充材料 RISC-V Calling conventions 中所述,

The RISC-V calling convention passes arguments in registers when possible. Up to eight integer registers, a0–a7, and up to eight floating-point registers, fa0–fa7, are used for this purpose.

对于整数参数,使用寄存器 a0-a7 来存放。

而在本程序 mainprintf 的调用中:

void main(void) {
  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>

可以发现,\(13\) 这个数被存进了 a2 寄存器,也就是说第二个小问的答案是 a2

Question 2

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.)

在上一问贴出的 printf 调用代码中,可以发现,本应该存放参数 f(8)+1 的寄存器 a1,被直接放入了数值 12。因此对 f 函数的调用被内联了,没有相应的汇编调用。

同样,在 f 函数中本应调用 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

可以发现,这段汇编代码直接将第一个参数加上 \(3\) 以后返回,而我们知道 g 函数的作用就是返回参数加 \(3\) 的值。因此对 g 的调用也被内联了,汇编程序中找不到调用。

Question 3

At what address is the function printf located?

答案:0x630。(注:不同环境下可能会产生不同的结果)

注意看这两行汇编代码:

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

auipc 的作用是将当前的 PC 加上给定立即数的前二十位,写入寄存器中。因此此时 ra 的值应该是 0x30

下一句话跳转到了 1536(ra) 也就是 1536 + 0x30 = 0x630 的位置。因此,可以得出 printf 的地址是 0x630

其实可以直接从后面的注释中得到答案。

Question 4

What value is in the register ra just after the jalr to printf in main?

jalr 指令会把 ra 设置为调用结束的返回地址,因此应该是下一条指令的位置,也就是 0x34 + 4 = 0x38

Question 5

Run the following code.

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

What is the output? Here's an ASCII table that maps bytes to characters.

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?

Here's a description of little- and big-endian and a more whimsical description.

答案:He110 World

跟在 H 后面的是十六进制的 57616,它的十六进制表示是 e110

跟在 Wo 之后的是将 &i 处的数据以字符串形式输出。我们知道 RISC-V 的数据是小端法表示的,低地址存放低字节,因此在 &i 地址之后的几个字节依次是 72 6c 64 00(十六进制),转化为字符串就是 "rld\0"

Question 6

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);

答案应该是一个不确定的值,这个值取决于运行时寄存器 a2 的值。

作为 printf 格式控制符第二个 %d,应该会从 printf 第三个参数中获取,这个参数是由寄存器 a2 指定的,但是因为在调用 printf 的时候没有给出第三个参数,因此 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

The compiler puts in each stack frame a frame pointer that holds the address of the caller's frame pointer. Your backtrace should use these frame pointers to walk up the stack and print the saved return address in each stack frame.

简单地说,就是创建一个函数 backtrace(),可以打印目前栈上所有被调用的函数的地址。

修改 kernel/defs.hkernel/riscv.h

Add the prototype for backtrace to kernel/defs.h so that you can invoke backtrace in sys_sleep.

The GCC compiler stores the frame pointer of the currently executing function in the register s0. Add the following function to kernel/riscv.h:

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

and call this function in backtrace to read the current frame pointer. This function uses in-line assembly to read s0.

首先在 defs.h 里面需要添加大概一个函数原型:backtrace。很简单,因此不给出代码了。

然后将 Hints 中给出的 r_fp 函数复制到 kernel/riscv.h 的末尾,这个函数的作用是获得当前函数的帧指针(frame pointer)。

大概的原理是通过内嵌汇编,将存储帧指针的寄存器 s0 的值拷贝到一个返回值中。因为这个函数将会是内联的(有 inline 标识),所以获得的将是调用者的帧指针,而不是 r_fp 这个函数的帧指针。

编写 backtrace

The compiler puts in each stack frame a frame pointer that holds the address of the caller's frame pointer. Your backtrace should use these frame pointers to walk up the stack and print the saved return address in each stack frame.

These lecture notes have a picture of the layout of stack frames. Note that the return address lives at a fixed offset (-8) from the frame pointer of a stackframe, and that the saved frame pointer lives at fixed offset (-16) from the frame pointer.

想要获得栈上的函数调用列表非常简单,根据题目的提示,只需要将栈上存储的返回地址依次输出即可。

通过调用 r_fp,我们可以获得我们当前函数(backtrace)函数的帧指针,从而获得backtrace 的返回地址(也就是 backtrace 调用结束后即将返回到的地方)

这里有几点需要注意:

  • 在 RISC-V 中,并不是每个函数的栈帧中都一定会存储返回地址。对于叶子调用,也就是不会再进行别的调用的函数,是可以不在栈帧中存放返回地址的,因为 RISC-V 是通过 ra 寄存器获得 ret 时应该跳转的地址的,不需要读取栈中的内容,这和我们熟悉的 x86_64 的规则不同。但是,在 backtrace 这个函数中,我们可以肯定其栈帧中一定会有返回地址,因为它调用了 printf,因而不是一个叶调用
  • 每一个函数栈帧中的返回地址,是其调用者中的地址,而不是它自己的。这一点在逻辑上会有点绕,要理清楚。因而,第一个栈帧中的返回地址没有意义。(也可能根本不会有,我没有仔细研读这里的汇编代码)
  • 实际上,这里要输出的只是进入内核地址空间之后的调用列表,不是一个进程完整的调用列表,所以打印出来的结果中,最后一行也就是第一个被调用的函数一定是 trap.c 中的 usertrap 函数,这个函数是在 uservec 函数中直接 jr 跳转过来的,不是通过调用的形式,因此不会更新 ra 寄存器,因此和上一条得出的结论相同,第一个栈帧中即使存在返回地址,也没有意义。

具体地,对于一个存放了返回地址的栈帧(不存放返回地址的栈帧结构还真不一样),帧指针 p(p - 8) 开始的 \(8\) 个字节是返回地址,(p - 16) 开始的 \(8\) 个字节是上一个栈帧的帧指针。

因此,我们只需要遍历每一个栈帧,输出 (p - 8) 处的返回地址,然后将 \(p\) 更新为 (p - 16) 处的上一个栈帧。

因为在 xv6 中,我们的栈刚好是一个页,而且页的开始地址是和 4KB 对齐的,因此我们一开始的栈指针 sp 初始地址应该是这一页最大的地址加 \(1\)(也就是下一页的开始地址)。

因而,我们在遍历栈帧的时候,就可以知道,当我们的帧指针 p 刚好是一页的首地址时,我们就到达了栈底了,可以退出了。

void backtrace(void) {
  uint64 p = r_fp();
  while (p != PGROUNDUP(p)) {
    uint64 ra = *(uint64*)(p - 8);
    uint64 fp = *(uint64*)(p - 16);
    printf("%p\n", ra);
    p = fp;
  }
}

panicproc_sleep 中添加调用

Implement a backtrace() function in kernel/printf.c. Insert a call to this function in sys_sleep...

Once your backtrace is working, call it from panic in kernel/printf.c so that you see the kernel's backtrace when it panics.

void
panic(char *s)
{
  pr.locking = 0;
  printf("panic: ");
  printf(s);
  printf("\n");
  backtrace();
  panicked = 1; // freeze uart output from other CPUs
  for(;;)
    ;
}
uint64
sys_sleep(void)
{
  int n;
  uint ticks0;

  backtrace();

  if(argint(0, &n) < 0)
    return -1;
  acquire(&tickslock);
  ticks0 = ticks;
  while(ticks - ticks0 < n){
    if(myproc()->killed){
      release(&tickslock);
      return -1;
    }
    sleep(&ticks, &tickslock);
  }
  release(&tickslock);
  return 0;
}

没啥好说的,按题目要求添加即可。

结果展示

./20231116-MIT6-S081-traps/image-20231116212855502

./20231116-MIT6-S081-traps/image-20231116212940787

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.

这个任务大概是要实现一个定时器,这个定时器通过函数 sigalarm(interval, handler) 来设置定时,使得每过 interval 个 tick 就会程序自动调用 handler。一个 tick 是指硬件定时中断的周期。

这个任务要求我们对于系统中断、trapframe 以及中断的处理过程比较熟悉。

让我们跟着 Hints 一步步来。

前期准备

  • You'll need to modify the Makefile to cause alarmtest.c to be compiled as an xv6 user program.

  • The right declarations to put in user/user.h are:

    int sigalarm(int ticks, void (*handler)());
    int sigreturn(void);
    
  • Update user/usys.pl (which generates user/usys.S), kernel/syscall.h, and kernel/syscall.c to allow alarmtest to invoke the sigalarm and sigreturn system calls.

首先,和前几次实验一样,将 alarmtest 加入 Makefile,使得能够被编译且直接通过终端调用。

UPROGS=\
	# ......
	$U/_alarmtest\

然后将上面的 Hints 中给定的 sigalarmsigreturn 的函数原型加入 user/user.h 的末尾,这里就不做演示了。

user/usys.pl 中添加这两个系统调用的入口:

entry("sigalarm");
entry("sigreturn");

kernel/syscall.h 中添加两个系统调用的宏定义符号:

#define SYS_sigalarm    22
#define SYS_sigreturn   23

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,
};

kernel/proc.h 中添加字段

在做这一步前,让我们通览所有的 Hints,来知晓所有需要添加的字段。

  • Your sys_sigalarm() should store the alarm interval and the pointer to the handler function in new fields in the proc structure (in kernel/proc.h).
  • You'll need to keep track of how many ticks have passed since the last call (or are left until the next call) to a process's alarm handler; you'll need a new field in struct proc for this too. You can initialize proc fields in allocproc() in proc.c.
  • Have usertrap save enough state in struct proc when the timer goes off that sigreturn can correctly return to the interrupted user code.
  • Prevent re-entrant calls to the handler----if a handler hasn't returned yet, the kernel shouldn't call it again. test2 tests this.

首先是 alarm_intervalalarm_handler 两个字段,这两个字段的添加比较显然,都是 sigalarm 函数告诉我们的两个参数,一个是定时间隔,一个定时处理函数。

然后,我们还需要添加一个 alarm_tick_count 字段,这个字段告诉我们,距离上一次调用处理函数,已经过去了多少个 tick 了。显然,这个字段的值是用来判断什么时候该调用处理函数的,可以预见的是,在我们即将完成的处理中断的函数中,如果这个值应该会被判断是否和 alarm_interval 相等。

但是还有两个比较重要的字段,可能在一开始不会被考虑到。

显然,在定时器到达间隔后,调用设定好的处理函数,这会打断正常的程序运行顺序,也会改变程序运行时的那些寄存器,如果不加以处理,将会时程序之后的运行过程完全不符合编写者的预期。

因此,我们需要添加一个字段 alarm_trapframe,其类型是 struct trapframe *。我们应该记得,trapframe 是用来在陷阱和中断时,用来保存包括 SEPC 在内的所有用户寄存器,以及必要的内核状态等。这样,在我们处理 sigreturn 调用返回时,就可以将所有的用户状态恢复如初,不影响程序的继续执行,也不影响寄存器内容的一致性了。

此外,我们还需要一个字段 alarm_handling,用来标记这个进程是否正处于处理函数调用状态。只要我们的程序正处于处理函数中,我们就不应该让内核再次调用它。(甚至在我看来,这个时候甚至不应该累加 tick_count

struct proc {
  // ...
  int alarm_interval;
  void (*alarm_handler)();
  int alarm_tick_count;
  int alarm_handling;
  struct trapframe *alarm_trapframe;
};

修改 kernel/proc.c 中的 allocprocfreeproc

allocproc 函数是用来新建一个进程以及为新进程分配资源的,相应的,freeproc 是在进程退出的时候释放资源的。

无论是在 allocproc 还是 freeproc 中,我们新建的字段都应该被重置为 \(0\),除了 alarm_trapframe

trapframe 是需要一定的空间来保存的,因此在 allocproc 中,我们应该为 alarm_trapframe 分配一页空间,在 freeproc 中,我们将这一页空间释放掉。

static struct proc*
allocproc(void)
{
  // ...
  p->alarm_interval = 0;
  p->alarm_handler = 0;
  p->alarm_tick_count = 0;
  p->alarm_handling = 0;
  if((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);
    return 0;
  }

  return p;
}

static void
freeproc(struct proc *p)
{
  // ...
  p->alarm_interval = 0;
  p->alarm_handler = 0;
  p->alarm_tick_count = 0;
  p->alarm_handling = 0;
  if(p->alarm_trapframe)
    kfree((void*)p->alarm_trapframe);
}

完成 sys_sigalarm

我们应该还记得,sigalarm 这个系统调用,接受两个参数,一个是定时间隔,一个定时处理函数。

sys_sigalarm 函数继续接受这两个参数。回忆一下,所有的系统调用,都需要通过 argintargstrargaddr 这些函数来获取,因此我们可以通过 argint(0, &ticks)argaddr(1, (uint64 *)&handler) 两次调用来获得间隔和处理函数地址。

获得之后,我们将 alarm_interval 设置为相应的间隔,alarm_handler 设置为相应的处理函数。

然后,将 alarm_tick_countalarm_handling 都初始化为 \(0\)

alarm_trapframe 暂时不需要管,这是在进入处理函数前才需要设置的。

uint64 sys_sigalarm(void) {
  int ticks;
  void (*handler)();
  if (argint(0, &ticks) < 0)
    return -1;
  if (argaddr(1, (uint64 *)&handler) < 0)
    return -1;
  struct proc *p = myproc();
  p->alarm_interval = ticks;
  p->alarm_handler = handler;
  p->alarm_tick_count = 0;
  p->alarm_handling = 0;
  return 0;
}

补全 usertrap

既然是以 tick 为单位的定时,那么我们显然应该在对硬件定时中断的过程中进行定时的判断。

  • Every tick, the hardware clock forces an interrupt, which is handled in usertrap() in kernel/trap.c.

  • You only want to manipulate a process's alarm ticks if there's a timer interrupt; you want something like

    if(which_dev == 2) ...
    

很容易定位到 usertrap 函数中,有一个 else if((which_dev = devintr()) != 0) 的语句。很显然,我们就应该在这个里面补充我们的定时处理。

首先应该判断 p->alarm_interval 是否为 \(0\),如果为 \(0\) 则代表没有设置定时器,因此不应该做任何处理。

接着,还要判断是否正在处于定时处理阶段,如果正在调用处理函数,那么我们不应该继续调用处理函数,甚至不应该去改变 tick_count

如果前面的判断一切顺利,那么我们就要把 p->tick_count\(1\),然后判断是否达到了定时间隔。一旦到达定时间隔,我们就要开始调用处理函数了。

首先,我们要将 p->trapframe 的内容复制到 p->alarm_trapframe 保存,然后将 SEPC 寄存器设置为处理函数的地址,这样在中断返回的时候,就会自动开始执行处理函数中的代码了,最后将 p->alarm_handling 标记为 \(1\) 避免之后重复调用处理函数。

(复习一下,在中断发生的时候,处理器会将原本的 PC 值拷贝到 SEPC 中,而在中断返回的时候,也就是 sret 指令执行的时候,会将 SEPC 的值拷贝进 PC

void
usertrap(void)
{
  // ...
  else if((which_dev = devintr()) != 0){
    // ok
    if (which_dev == 2) {
      if (p->alarm_interval != 0 && !p->alarm_handling) {
        ++p->alarm_tick_count;
        if (p->alarm_tick_count == p->alarm_interval) {
          *(p->alarm_trapframe) = *(p->trapframe);
          p->trapframe->epc = (uint64)(p->alarm_handler);
          p->alarm_handling = 1;
        }
      }
    }
  }
  // ...
  usertrapret();
}

完成 sys_sigreturn

sigreturn 系统调用是用来在处理函数中返回的,只有调用了 sigreturn,处理函数才能正确回到进程原本的执行状态。

那么,首先我们要将我们保存进 alarm_trapframe 中的用户状态存回 p->trapframe 中,以便在中断返回的时候,能够正确将原本的状态写回各个寄存器中。

然后,我们将 alarm_tick_count 清零,重新开始计数。

最后,将 alarm_handling 设置为 \(0\),表示没有正在执行处理函数。

uint64 sys_sigreturn(void) {
  struct proc *p = myproc();
  *(p->trapframe) = *(p->alarm_trapframe);
  p->alarm_tick_count = 0;
  p->alarm_handling = 0;
  return 0;
}

结果展示

alarmtest

./20231116-MIT6-S081-traps/image-20231117113014306

usertests

./20231116-MIT6-S081-traps/image-20231117113047773

./20231116-MIT6-S081-traps/image-20231117113104227

总测试

./20231116-MIT6-S081-traps/image-20231117113659333

posted @ 2024-04-24 14:43  hankeke303  阅读(8)  评论(0编辑  收藏  举报