xv6内核扩展4:traps

发布于 作者: Ethan

RISC-V assembly RISC-V 汇编

需要了解一些 RISC-V 汇编知识,这些知识在 6.1910(6.004)课程中已经接触过。你的 xv6 仓库中有一个 user/call.c 文件。 make fs.img 会编译它,并在 user/call.asm 中生成一个可读的汇编版本程序。

阅读 call.asm 文件中 g 、 f 和 main 函数的代码。RISC-V 指令手册在参考页面上。在 answers-traps.txt 中回答以下问题:

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

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

函数 printf 位于哪个地址?

寄存器 ra 在 jalr 到 printf 之间的值是多少?

运行以下代码。

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

输出是什么?这里是一个将字节映射到字符的 ASCII 表。

输出取决于 RISC-V 是小端模式的事实。如果 RISC-V 是大端模式,你需要将 i 设置为多少才能得到相同的输出?你需要将 57616 更改为不同的值吗?

这是关于小端和大端以及更俏皮的描述。

在以下代码中, 'y=' 之后将打印什么内容?(注意:答案不是特定值。)为什么会这样?

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

首先看一下main()的部分:

000000000000001c <main>:

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: 00001517           auipc a0,0x1
  2c: 85850513           addi a0,a0,-1960 # 880 <malloc+0xe2>
  30: 6ba000ef           jal ra,6ea <printf>
  exit(0);
  34: 4501                 li a0,0
  36: 298000ef           jal ra,2ce <exit>

这里对RISC-V涉及指令做解释:

addi rd, rs1, imm // add immediate, rd = rs1 + sign_extend(imm) 

sd rs2, offset(rs1) // store doubleword, memory[rs1 + sign_extend(offset)] = rs2

li rd, imm // load immediate (pseudo-instr)
// if imm is 12 bit signed, addi rd, x0, imm
// else (larger) lui rd, upper20 + addi rd, rd, lower12

auipc rd, imm20 // add upper immediate to pc, rd = PC + (imm20 << 12)

jal rd, offset // jump and link, rd = PC + 4, PC = PC + signed_extend(offset)

据此,我们可以逐一回答问题:

  • 哪些寄存器包含传递给函数的参数?例如,main 调用 printf 时,哪个寄存器存储 13? 回答:a0a1a2包含参数,13是由a2存储的。
  • 在 main 的汇编代码中,函数 f 的调用在哪里? g 的调用在哪里?(提示:编译器可能会内联函数。) 回答:编译的时候编译器进行了优化,进行了常量折叠+传播,导致调用结果(13)被直接计算出来。
  • 函数 printf 位于哪个地址? 回答:0x6ea
  • 寄存器 ra 在 jalr 到 printf 之间的值是多少? 回答:0x34
  • 输出是什么?如果 RISC-V 是大端模式,你需要将 i 设置为多少才能得到相同的输出?你需要将 57616 更改为不同的值吗? 回答:输出为“He110 World”,因为57616在16进制下为E1100x646c72在ASCII中表示为“dlr”,小端序中为“rld”。如果是大端序,需要i为0x726c6400可以得到相同输出。57616不需要更改,因为%x按数值解释,而非字节序。
  • 在以下代码中, 'y=' 之后将打印什么内容?(注意:答案不是特定值。)为什么会这样? 回答:会打印出任意值,因为printf是变参函数,从ABI来说寄存器参数第二个会放在a1中,但因为没有传参数,所以会打印垃圾旧数据。

Backtrace 回溯

为了调试,通常很有用拥有一个回溯:一个错误发生时栈上函数调用的列表。为了帮助生成回溯,编译器生成机器代码,在栈上为当前调用链中的每个函数维护一个栈帧。每个栈帧包含返回地址和一个指向调用者栈帧的"帧指针"。寄存器 s0 包含一个指向当前栈帧的指针(它实际上指向栈上保存的返回地址的地址加 8)。你的 backtrace 应该使用帧指针遍历栈并打印每个栈帧中的保存返回地址。

在 kernel/printf.c 中实现一个 backtrace() 函数。在 sys_pause 中插入对这个函数的调用,然后运行 bttest ,它调用 sys_pause 。你的输出应该是一个返回地址列表,格式如下(但数字可能不同):

   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_pause 中调用 backtrace 。

  • GCC 编译器将当前执行函数的帧指针存储在寄存器 s0 中。在 #ifndef ASSEMBLER ... #endif 标记的段落中,向 kernel/riscv.h 添加以下函数:并在 backtrace 中调用此函数以读取当前帧指针。 r_fp() 使用内联汇编来读取 s0 。

    static inline uint64
    r_fp()
    {
      uint64 x;
      asm volatile("mv %0, s0" : "=r" (x) );
      return x;
    }
    
  • 这些讲义笔记中有一张显示栈帧布局的图片。请注意,返回地址位于栈帧指针的一个固定偏移量(-8)处,而保存的栈帧指针位于另一个固定偏移量(-16)处。

  • 你的 backtrace() 需要一种方式来识别它已经看到了最后一个栈帧,并且应该停止。一个有用的事实是,为每个内核栈分配的内存由一个单页对齐的页面组成,因此给定栈的所有栈帧都在同一页上。你可以使用 PGROUNDDOWN(fp) (参见 kernel/riscv.h )来识别栈帧指针所指向的页面。 一旦你的回溯程序开始工作,就在 panic 中从 kernel/printf.c 调用它,这样当内核恐慌时,你就能看到内核的回溯。

以下是笔记中的栈示意图:

                   .
                   .
      +->          .
      |   +-----------------+   |
      |   | return address  |   |
      |   |   previous fp ------+
      |   | saved registers |
      |   | local variables |
      |   |       ...       | <-+
      |   +-----------------+   |
      |   | return address  |   |
      +------ previous fp   |   |
          | saved registers |   |
          | local variables |   |
      +-> |       ...       |   |
      |   +-----------------+   |
      |   | return address  |   |
      |   |   previous fp ------+
      |   | saved registers |
      |   | local variables |
      |   |       ...       | <-+
      |   +-----------------+   |
      |   | return address  |   |
      +------ previous fp   |   |
          | saved registers |   |
          | local variables |   |
  $fp --> |       ...       |   |
          +-----------------+   |
          | return address  |   |
          |   previous fp ------+
          | saved registers |
  $sp --> | local variables |
          +-----------------+

编码的部分并不困难,主要是要考虑什么时候到达栈顶。在xv6中,一个进程栈只会被分配一页,所以我们可以通过此特性进行判断。当我们拿到寄存器中存储的fp值后,我们可以使用PGROUNDDOWN()将其计算为这个页的开始地址,随后将此地址增加PGSIZE即可确定下一页的起始地址。随后在循环中我们每次判断当前的虚拟地址是否大于等于下一页的起始地址,如果为真,则停止循环。

伪代码如下:

traceback():
   fp = r_fp()
   nxtpgbegin = PGROUNDDOWN(fp) + PGSIZE
   while true:
      if fp >= nxtpgbegin:
         return
      retaddr = *(fp-8)
      nextfp = *(fp-16)
      print(retaddr)
      fp = nextfp

Alarm

在这个练习中,你将为 xv6 添加一个功能,该功能定期提醒进程在它使用 CPU 时间时。这对于想要限制它们消耗的 CPU 时间的计算密集型进程可能很有用,或者对于既想计算又想执行一些定期操作的进程。更一般地说,你将实现一种用户级中断/错误处理程序的原语形式;你可以使用类似的东西来处理应用程序中的页面错误,例如。如果你的解决方案通过了 alarmtestusertests -q,那么它就是正确的。

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

你会在你的 xv6 仓库中找到一个文件 user/alarmtest.c。把它添加到 Makefile 中。在你添加了 sigalarmsigreturn 系统调用之前,它无法正确编译。

alarmtesttest0 中调用 sigalarm(2, periodic),请求内核每 2 个时钟周期强制调用一次 periodic(),然后会自旋一段时间。你可以在 user/alarmtest.asm 中看到 alarmtest 的汇编代码,这在调试时可能很有用。当 alarmtest 产生如下输出,并且 usertests -q 也运行正确时,你的解决方案是正确的:


$ 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
test3 start
test3 passed
$ usertests -q
...
ALL TESTS PASSED
$

当你完成后,你的解决方案将只有几行代码,但正确实现它可能有些棘手。我们将使用原始仓库中的 alarmtest.c 版本来测试你的代码。你可以修改 alarmtest.c 来帮助你调试,但要确保原始的 alarmtest 显示所有测试都通过。


test0:调用处理器

通过修改内核使其跳转到用户空间的闹钟处理器,使 test0 打印 "alarm!"。暂时不用担心 "alarm!" 输出后的情况;现在即使程序在打印 "alarm!" 后崩溃也没关系。这里有一些提示:

  • 你需要修改 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 中)的新字段里存储闹钟间隔和处理器函数指针。
  • 你需要跟踪自上次调用(或直到下次调用)进程闹钟处理器经过的 tick 数;这同样需要在 struct proc 中添加新字段。你可以在 proc.callocproc() 中初始化这些字段。
  • 每个 tick,硬件时钟都会强制触发一个中断,该中断在 kernel/trap.c 中的 usertrap() 里处理。
  • 只有当发生计时器中断时,才需要操作进程的闹钟 tick,例如:
if (which_dev == 2) ...
  • 只有当进程存在未完成的计时器时,才调用闹钟函数。注意,用户闹钟函数的地址可能是 0(例如在 user/alarmtest.asm 中,periodic 位于地址 0)。
  • 你需要修改 usertrap(),使得当进程的闹钟间隔到期时,用户进程会执行处理函数。当 RISC-V 的异常返回到用户空间时,是什么决定了用户空间代码从哪一条指令继续执行?
  • 如果你让 qemu 只使用一个 CPU,用 gdb 查看陷阱会更容易,可以运行:
make CPUS=1 qemu-gdb
  • alarmtest 打印 "alarm!" 时,就说明你已经成功了。

test1 / test2 / test3:恢复被中断的代码

很可能在 alarmtest 打印 "alarm!" 之后,在 test0test1 中崩溃,或者最终打印 "test1 failed",又或者在未打印 "test1 passed" 的情况下退出。要修复这些问题,你必须做到以下几点:

  • 当闹钟处理程序完成后,控制权必须返回到最初被定时器中断打断的那条用户指令。
  • 必须恢复寄存器内容为中断发生时的值,使用户程序在闹钟之后能够不受干扰地继续执行。
  • 每次闹钟触发后,需要重新设置计数器,使处理程序能够周期性执行。

作为起点,我们已经为你做了一个设计决策:用户闹钟处理程序在完成后必须调用 sigreturn 系统调用。可以查看 alarmtest.c 中的 periodic 作为示例。这意味着你可以在 usertrapsys_sigreturn 中添加配合的代码,使用户进程在处理完闹钟后正确恢复。

一些提示:

  • 你的解决方案需要保存和恢复寄存器。为了正确恢复被中断的代码,需要保存和恢复哪些寄存器?(提示:很多)
  • 当计时器中断发生时,usertrap 需要在 struct proc 中保存足够的状态,以便 sigreturn 能正确返回到被中断的用户代码。
  • 防止处理程序重入调用——如果处理程序尚未返回,内核不应再次调用它。test2 会检查这一点。
  • 确保恢复 a0sigreturn 是一个系统调用,其返回值存放在 a0 中。
  • 当你通过了 test0test1test2test3 之后,运行 usertests -q,以确保你没有破坏内核的其他部分。

test 0 Solution

由于不要返回之前的执行点,i.e. 不需要保存先前的PC,解决方案相对比较简单:

  1. 预处理,先将系统调用加到桩中,添加SYS_sigalarmSYS_sigreturn(目前直接返回0)
  2. usertrap()中判断当前中断是否为设备中断+timer中断,如果不是则不进行处理
  3. 判断alarmintervalalarmhandler是否都为0,如果为真,代表当前进程没有设置alarm
  4. 判断当前alarmtick,即距离上次处理新增多少tick。如果没有超过interval,则仅增加alarmticks
  5. 如果超过了,则进行处理,将trapframe中的epc,即中断处理结束后返回地址修改为handler地址,并重置alarmticks

结果:

$ alarmtest
test0 start
...........................alarm!
oops, sigreturn returned!

这里直接返回是因为我们暂未设置sigreturn内容。

test 1-3

这一部分内容紧接上一部分,主要注意的有几点:

  1. 什么时候我们需要保存之前的寄存器? 很明显是在usertrap中,因为之后我们就需要修改pc到新的方法,所以必须在此保存。

  2. 保存哪些寄存器?保存在哪? 我们需要保存先前所有的寄存器,并且由于是以进程为单位的,所以保存在proc中。我们新增一个字段为struct trapframe *alarmframe,保存所有内容。

  3. 新增的字段为指针,所以在进程分配的时候我们需要给它单独分一页内存,并在回收时也进行处理。

  4. 怎么判断当前进程是否正在handle alarm? 新增字段inalarm,在进入usertrap时判断并设置为1,在sigreturn()中设回0。

  5. sigreturn如何复原寄存器? 我们可以观察到在syscall结束后,会根据trapframe复原寄存器。所以我们无需手动恢复,只需要*(p->trapframe) = *(p->alarmframe)即可。

  6. 关键的来了,a0如何保存? a0为固定系统调用返回值存储,我们可以在syscall代码中检查:

    void syscall(void) {
    int num;
    struct proc *p = myproc();
    
    num = p->trapframe->a7;
    if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
       // Use num to lookup the system call function for num, call it,
       // and store its return value in p->trapframe->a0
       p->trapframe->a0 = syscalls[num]();
    } else {
       printf("%d %s: unknown sys call %d\n", p->pid, p->name, num);
       p->trapframe->a0 = -1;
    }
    }
    

    如果仅像先前的方式重设trapframe,原先a0的值会被新的返回值覆盖。但我们可以利用这一点,巧妙地让sigreturn直接返回原先的a0,这样即使syscall重设a0也为之前的值了。

最终结果

== Test answers-traps.txt == 
answers-traps.txt: OK 
== Test backtrace test == 
$ make qemu-gdb
backtrace test: OK (4.6s) 
== Test running alarmtest == 
$ make qemu-gdb
(8.9s) 
== Test   alarmtest: test0 == 
  alarmtest: test0: OK 
== Test   alarmtest: test1 == 
  alarmtest: test1: OK 
== Test   alarmtest: test2 == 
  alarmtest: test2: OK 
== Test   alarmtest: test3 == 
  alarmtest: test3: OK 
== Test usertests == 
$ make qemu-gdb
usertests: OK (124.6s) 
== Test time == 
time: OK 
Score: 95/95