6.S081 Lab: traps

本次实验将会帮助理解程序如何通过 traps 实现系统调用,并进行从 ecall 进入内核到返回用户空间的全过程。

[easy] RISC-V assembly

本节作业需要阅读 call.asm 的汇编源码,并回答相应问题即可。

哪些寄存器包含传递给函数的参数?例如,在 main 调用 printf 时,哪个寄存器保存了 13?

在 call.c 的 main 函数中分别调用了 printf 、exit 函数并传递了参数。

void main(void) {
  printf("%d %d\n", f(8)+1, 13);
  exit(0);
}

阅读汇编可以看到参数都放在了寄存器 a0 、a1 、a2 ,三者分别存储了 "%d %d\n" 字符串的地址和两个整型参数。

printf("%d %d\n", f(8)+1, 13);
2c: 4635                    li  a2,13
2e: 45b1                    li  a1,12
30: 00000517                auipc   a0,0x0

对于后续的 exit 同理,所以整个代码中用于传递参数的寄存器有 a0 、a1 、a2 ,调用 printf 时传入 13 的寄存器是 a2 。

在 main 的汇编代码中,对函数 f 的调用在哪里?对 g 的调用在哪里?(提示:编译器可能会内联函数。)

在编译后的 call.asm 中, g 函数的整个函数体为:

int g(int x) {
   0:   1141                    addi    sp,sp,-16
   2:   e406                    sd  ra,8(sp)
   4:   e022                    sd  s0,0(sp)
   6:   0800                    addi    s0,sp,16
  return x+3;
}
   8:   250d                    addiw   a0,a0,3
   a:   60a2                    ld  ra,8(sp)
   c:   6402                    ld  s0,0(sp)
   e:   0141                    addi    sp,sp,16
  10:   8082                    ret

return x+3; 前都是在开辟栈空间,实际上只有 addiw a0,a0,3 这一条为真正的函数体,该指令后面的指令都是在回收栈空间。

对于 f 同理,f 由于是直接对 g 的调用,最后编译出的函数真正的函数体也只是 addiw a0,a0,3 。

int f(int x) {
  12:   1141                    addi    sp,sp,-16
  14:   e406                    sd  ra,8(sp)
  16:   e022                    sd  s0,0(sp)
  18:   0800                    addi    s0,sp,16
  return g(x);
}
  1a:   250d                    addiw   a0,a0,3
  1c:   60a2                    ld  ra,8(sp)
  1e:   6402                    ld  s0,0(sp)
  20:   0141                    addi    sp,sp,16
  22:   8082                    ret

所以在 main 中看不到其对 g 和 f 函数的调用,编译器在编译期就把真正要传入 printf 的值计算好放入 a1 中了。

函数 printf 位于什么地址?

搜索看到 printf 加载在 0x0000000000000640 。

0000000000000640 <printf>:
​
void
printf(const char *fmt, ...)

在 main 中执行完跳转到 printf 的 jalr 指令之后,寄存器 ra 里的值是什么?

ra 寄存器用于保存返回地址,所以会指向 jalr 下一条指令的地址,也就是 0x40 。

3c: 608080e7            jalr    1544(ra) # 640 <printf>
exit(0);
40: 4501                li  a0,0

下面代码的输出是什么?如果 RISC-V 换成大端(big-endian),为了得到同样的输出,你应该把 i 设成什么值?需要把 57616 改成别的值吗?

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

输出为 "HE110 World" 。大端序需要将 i 设置为 0x726c6400 。

57616 不需要修改,因为 %x 表示将 57616 使用十六进制输出,跟端序无关。

在下面的代码中,y= 后面会打印什么?(注意:答案不是一个具体的值。)为什么会这样?

printf("x=%d y=%d", 3);

y 后面显然会输出一个随机整数。学过 pwn 的我自然知道这是一种格式化字符串漏洞。由于 printf 传入的字符串中期望两个可变参数,而源代码只传入了以一个。所以按照调用约定会将本应传入第二个参数的 a2 寄存器的值打印出来,这个值在调用时没有传入,是随机的。

[moderate] Backtrace

本节任务需要实现在 printf.c 中实现一个 backtrace 函数,该函数能够打印出函数的调用链条。

在 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
  • 将 backtrace 的原型添加到 kernel/defs.h 中,以便你可以在 sys_sleep 中调用 backtrace。

  • GCC 编译器将当前正在执行的函数的帧指针存储在寄存器 s0 中。将以下函数添加到 kernel/riscv.h 中:

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

    并在 backtrace 中调用这个函数来读取当前帧指针。这个函数使用内联汇编来读取 s0。

  • 这些讲义笔记中有一张栈帧布局的图。注意,返回地址位于栈帧指针的固定偏移处(-8),而保存的帧指针位于帧指针的固定偏移处(-16)。

  • Xv6 在 xv6 内核中为每个栈分配一个页面,地址按 PAGE 对齐。你可以使用 PGROUNDDOWN(fp) 和 PGROUNDUP(fp) 来计算栈页的顶部和底部地址(参见 kernel/riscv.h)。这些数值有助于 backtrace 终止其循环。

本节任务的实现原理是在 xv6 中(其实在我们的 x86 主机上也类似)中函数调用栈的栈帧会保存上一个栈地址和返回地址。其中返回地址指向代码段,栈地址指向上一个栈帧,该指针由 s0 寄存器保存。

frame.drawio.png

还不太懂的可以看汇编代码,每一个函数的汇编总以这几个指令开头。可以发现 s0 的值总是指向当前函数调用栈的最高处。

addi    sp,sp,-16
sd      ra,8(sp)
sd      s0,0(sp)
addi    s0,sp,16

任务需要我们打印出函数的调用关系,这需要通过打印返回地址上的值实现。但是目前只能打印出上一个调用该函数的代码地址,如果需要往前回溯,就需要定位到上一个栈帧的返回地址。这就要用到 s0 寄存器了,使用作业给出的 r_fp 函数可以获得当前 s0 寄存器的值。使用 s0 相对 -8 和 -16 的偏移可以分别得到上一个栈帧顶部和返回代码的地址。

然后需要确定往前回溯的范围,xv6 的内存栈空间上限为 4KiB 且无法拓展,所以只需要检测 fp 是否在当前页内即可。使用内核的 PGROUNDUP 和 PGROUNDDOWN 宏可以找到给出虚拟地址的所在内存页的页首和页尾。

void
backtrace()
{
  uint64 fp;
  uint64 retn;
​
  fp = r_fp();
  uint64 top = PGROUNDUP(fp);
  uint64 bottom = PGROUNDDOWN(fp);
​
  printf("backtrace:\n");
  do {
    retn = *(uint64*)(fp - 8);
    printf("%p\n", retn);
    fp = *(uint64*)(fp - 16);
  } while (fp >= bottom && fp < top);
}

[hard] Alarm

实验内容是实现 sys_sigreturn 和 sys_sigalarm 两个系统调用,当 sys_sigalarm 被调用后应在几个 ticks 后调用处理器函数 handler ,并且需要保证在完成调用后能够正确返回用户态。

在这个练习中,你将向 xv6 添加一个功能,使它在某个进程使用 CPU 时间时周期性地向该进程发出提醒。对于那些希望限制自己消耗多少 CPU 时间的计算密集型进程,或者那些既想进行计算又想定期采取某些行动的进程来说,这可能很有用。更一般地说,你将实现一种用户级中断/故障处理程序的原始形式;例如,你可以使用类似的东西来处理应用程序中的页面错误。如果你的解决方案通过 alarmtest 和 usertests,那么它就是正确的。

  • 你需要修改 Makefile,使 alarmtest.c 作为 xv6 用户程序被编译。

  • 需要放入 user/user.h 的正确声明是:

    int sigalarm(int ticks, void (*handler)());
    int sigreturn(void);
  • 更新 user/usys.pl(它生成 user/usys.S)、kernel/syscall.h 和 kernel/syscall.c,以允许 alarmtest 调用 sigalarm 和 sigreturn 系统调用。

  • 目前,你的 sys_sigreturn 应该只返回零。

  • 你的 sys_sigalarm() 应该把闹钟间隔和处理函数指针存储在 proc 结构中的新字段里(在 kernel/proc.h 中)。

  • 你还需要跟踪自上次调用进程的闹钟处理函数以来已经过去了多少个 tick(或者距离下一次调用还剩多少个 tick);为此你也需要在 struct proc 中增加一个新字段。你可以在 proc.c 的 allocproc() 中初始化 proc 字段。

  • 每个 tick,硬件时钟都会强制产生一个中断,该中断在 kernel/trap.c 的 usertrap() 中处理。

  • 只有在存在定时器中断时,你才想操作进程的闹钟 tick;你需要类似这样的代码:

    if(which_dev == 2) ...
  • 只有当进程有一个尚未到期的定时器时,才调用闹钟函数。注意,用户闹钟函数的地址可能是 0(例如,在 user/alarmtest.asm 中,periodic 的地址就是 0)。

  • 你需要修改 usertrap(),使得当某个进程的闹钟间隔到期时,用户进程执行该处理函数。当 RISC-V 上的 trap 返回用户空间时,是什么决定了用户空间代码从哪个指令地址继续执行?

  • 如果你告诉 qemu 只使用一个 CPU,那么用 gdb 查看 trap 会更容易,你可以通过运行以下命令来做到这一点:

    make CPUS=1 qemu-gdb
  • 如果 alarmtest 打印出 “alarm!”,你就成功了。

  • 你的解决方案将要求你保存和恢复寄存器——为了正确地恢复被中断的代码,你需要保存和恢复哪些寄存器?(提示:会有很多。)

  • 让 usertrap 在定时器触发时在 struct proc 中保存足够的状态,以便 sigreturn 能够正确地返回到被中断的用户代码。

  • 防止对处理函数的重入调用——如果某个处理函数还没有返回,内核就不应该再次调用它。test2 会测试这一点。

首先需要在 proc 结构体中添加几个字段:整型 interval 用于保存需要等待的 ticks 次数;函数指针 handler 为等待时间后需要调用的处理器函数;tickcounts 记录已经经过了多少次 ticks ;in_handler 记录了 handler 目前是否在被调用,避免 handler 被重复调用;alarm_tf 指向一个 trapframe 结构体,用于保存系统调用前的上下文。

int interval;
void (*handler)(void);
int tickcounts;
int in_handler;
struct trapframe *alarm_tf;

在进程被分配时,也就是在 allocproc 中需要为上面的四个字段进行初始化,赋值为 0 即可。

p->interval = 0;
p->handler = 0;
p->tickcounts = 0;
p->in_handler = 0;
p->alarm_tf = 0;

然后在释放进程时,如果 alarm_tf 存在需要释放这一块内存。

if(p->alarm_tf)
  kfree((void*)p->alarm_tf);
p->alarm_tf = 0;

接下来实现系统调用的函数体,对于 sys_sigalarm ,需要将调用进程的 interval 和 handler 改为传入的参数,较为简单。

uint64
sys_sigalarm(void)
{
  struct proc *p;
  int n;
  uint64 handler;
​
  argint(0, &n);
  argaddr(1, &handler);
  
  p = myproc();
  p->interval = n;
  p->handler = (void (*)(void)) handler;
​
  return 0;
}

然后是 sys_sigreturn ,这个系统调用负责将保存的寄存器状态(也就是 alarm_tf )进行恢复。

uint64
sys_sigreturn(void)
{
  struct proc *p;
​
  p = myproc();
  memmove(p->trapframe, p->alarm_tf, sizeof(struct trapframe));
  p->in_handler = 0;
​
  return 0;
}

然后是核心的 trap.c 的逻辑。在原本的逻辑中当遇到时间中断时则直接 yield 退出,现在需要进行计数操作。当 tickcounts 大于等于 interval 时应当保存寄存器状态,然后执行 handler 函数。

if (which_dev == 2) {
    if (p->interval > 0 && !p->in_handler) {
        p->tickcounts++;
        if (p->tickcounts >= p->interval) {
            if (p->alarm_tf == 0) {
                p->alarm_tf = (struct trapframe *)kalloc();
            }
            memmove(p->alarm_tf, p->trapframe, sizeof(struct trapframe));
​
            p->tickcounts = 0;
            p->in_handler = 1;
            p->trapframe->epc = (uint64)p->handler;
        }
    }
    yield();
}