Lab4: traps

本实验探索如何使用陷阱实现系统调用。您将首先使用栈做一个热身练习,然后实现一个用户级陷阱处理的示例。

[!WARNING|label:Attention] 开始编码之前,请阅读xv6手册的第4章和相关源文件:

  • kernel/trampoline.S:涉及从用户空间到内核空间再到内核空间的转换的程序集
  • kernel/trap.c:处理所有中断的代码

要启动实验,请切换到traps分支:

  1. $ git fetch
  2. $ git checkout traps
  3. $ make clean

RISC-V assembly (easy)

理解一点RISC-V汇编是很重要的,你应该在6.004中接触过。xv6仓库中有一个文件user/call.c。执行make fs.img编译它,并在user/call.asm中生成可读的汇编版本。

阅读call.asm中函数gfmain的代码。RISC-V的使用手册在参考页上。以下是您应该回答的一些问题(将答案存储在answers-traps.txt文件中):

  1. 哪些寄存器保存函数的参数?例如,在mainprintf的调用中,哪个寄存器保存13?
  2. main的汇编代码中对函数f的调用在哪里?对g的调用在哪里(提示:编译器可能会将函数内联)
  3. printf函数位于哪个地址?
  4. mainprintfjalr之后的寄存器ra中有什么值?
  5. 运行以下代码。
  1. unsigned int i = 0x00646c72;
  2. printf("H%x Wo%s", 57616, &i);

程序的输出是什么?这是将字节映射到字符的ASCII码表

输出取决于RISC-V小端存储的事实。如果RISC-V是大端存储,为了得到相同的输出,你会把i设置成什么?是否需要将57616更改为其他值?

这里有一个小端和大端存储的描述和一个更异想天开的描述

  1. 在下面的代码中,“y=”之后将打印什么(注:答案不是一个特定的值)?为什么会发生这种情况?
  1. printf("x=%d y=%d", 3);

Backtrace(moderate)

回溯(Backtrace)通常对于调试很有用:它是一个存放于栈上用于指示错误发生位置的函数调用列表。

kernel/printf.c中实现名为backtrace()的函数。在sys_sleep中插入一个对此函数的调用,然后运行bttest,它将会调用sys_sleep。你的输出应该如下所示:

  1. backtrace:
  2. 0x0000000080002cda
  3. 0x0000000080002bb6
  4. 0x0000000080002898

​ 在bttest退出qemu后。在你的终端:地址或许会稍有不同,但如果你运行addr2line -e kernel/kernel(或riscv64-unknown-elf-addr2line -e kernel/kernel),并将上面的地址剪切粘贴如下:

  1. $ addr2line -e kernel/kernel
  2. 0x0000000080002de2
  3. 0x0000000080002f4a
  4. 0x0000000080002bfc
  5. Ctrl-D

​ 你应该看到类似下面的输出:

  1. kernel/sysproc.c:74
  2. kernel/syscall.c:224
  3. kernel/trap.c:85

​ 编译器向每一个栈帧中放置一个帧指针(frame pointer)保存调用者帧指针的地址。你的backtrace应当使用这些帧指针来遍历栈,并在每个栈帧中打印保存的返回地址。

提示:

  • kernel/defs.h中添加backtrace的原型,那样你就能在sys_sleep中引用backtrace

  • GCC编译器将当前正在执行的函数的帧指针保存在s0寄存器,将下面的函数添加到kernel/riscv.h

  1. static inline uint64
  2. r_fp()
  3. {
  4. uint64 x;
  5. asm volatile("mv %0, s0" : "=r" (x) );
  6. return x;
  7. }

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

  • 这个课堂笔记中有张栈帧布局图。注意返回地址位于栈帧帧指针的固定偏移(-8)位置,并且保存的帧指针位于帧指针的固定偏移(-16)位置

img

  • XV6在内核中以页面对齐的地址为每个栈分配一个页面。你可以通过PGROUNDDOWN(fp)PGROUNDUP(fp)(参见kernel/riscv.h)来计算栈页面的顶部和底部地址。这些数字对于backtrace终止循环是有帮助的。

一旦你的backtrace能够运行,就在kernel/printf.cpanic中调用它,那样你就可以在panic发生时看到内核的backtrace

Alarm(Hard)

[!TIP|label:YOUR JOB] 在这个练习中你将向XV6添加一个特性,在进程使用CPU的时间内,XV6定期向进程发出警报。这对于那些希望限制CPU时间消耗的受计算限制的进程,或者对于那些计算的同时执行某些周期性操作的进程可能很有用。更普遍的来说,你将实现用户级中断/故障处理程序的一种初级形式。例如,你可以在应用程序中使用类似的一些东西处理页面故障。如果你的解决方案通过了alarmtestusertests就是正确的。

你应当添加一个新的sigalarm(interval, handler)系统调用,如果一个程序调用了sigalarm(n, fn),那么每当程序消耗了CPU时间达到n个“滴答”,内核应当使应用程序函数fn被调用。当fn返回时,应用应当在它离开的地方恢复执行。在XV6中,一个滴答是一段相当任意的时间单元,取决于硬件计时器生成中断的频率。如果一个程序调用了sigalarm(0, 0),系统应当停止生成周期性的报警调用。

你将在XV6的存储库中找到名为user/alarmtest.c的文件。将其添加到Makefile。注意:你必须添加了sigalarmsigreturn系统调用后才能正确编译(往下看)。

alarmtesttest0中调用了sigalarm(2, periodic)来要求内核每隔两个滴答强制调用periodic(),然后旋转一段时间。你可以在user/alarmtest.asm中看到alarmtest的汇编代码,这或许会便于调试。当alarmtest产生如下输出并且usertests也能正常运行时,你的方案就是正确的:

  1. $ alarmtest
  2. test0 start
  3. ........alarm!
  4. test0 passed
  5. test1 start
  6. ...alarm!
  7. ..alarm!
  8. ...alarm!
  9. ..alarm!
  10. ...alarm!
  11. ..alarm!
  12. ...alarm!
  13. ..alarm!
  14. ...alarm!
  15. ..alarm!
  16. test1 passed
  17. test2 start
  18. ................alarm!
  19. test2 passed
  20. $ usertests
  21. ...
  22. ALL TESTS PASSED
  23. $

​ 当你完成后,你的方案也许仅有几行代码,但如何正确运行是一个棘手的问题。我们将使用原始存储库中的alarmtest.c版本测试您的代码。你可以修改alarmtest.c来帮助调试,但是要确保原来的alarmtest显示所有的测试都通过了。

test0: invoke handler(调用处理程序)

首先修改内核以跳转到用户空间中的报警处理程序,这将导致test0打印“alarm!”。不用担心输出“alarm!”之后会发生什么;如果您的程序在打印“alarm!”后崩溃,对于目前来说也是正常的。以下是一些提示

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

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

  1. int sigalarm(int ticks, void (*handler)());
  2. int sigreturn(void);
  • 更新user/usys.pl(此文件生成user/usys.S)、kernel/syscall.hkernel/syscall.c以允许alarmtest调用sigalarmsigreurn系统调用。

  • 目前来说,你的sys_sigreturn系统调用返回应该是零。

  • 你的sys_sigalarm()应该将报警间隔和指向处理程序函数的指针存储在struct proc的新字段中(位于kernel/proc.h)。

  • 你也需要在struct proc新增一个新字段。用于跟踪自上一次调用(或直到下一次调用)到进程的报警处理程序间经历了多少滴答;您可以在proc.callocproc()中初始化proc字段。

  • 每一个滴答声,硬件时钟就会强制一个中断,这个中断在kernel/trap.c中的usertrap()中处理。

  • 如果产生了计时器中断,您只想操纵进程的报警滴答;你需要写类似下面的代码

  1. if(which_dev == 2) ...
  • 仅当进程有未完成的计时器时才调用报警函数。请注意,用户报警函数的地址可能是0(例如,在user/alarmtest.asm中,periodic位于地址0)。

  • 您需要修改usertrap(),以便当进程的报警间隔期满时,用户进程执行处理程序函数。当RISC-V上的陷阱返回到用户空间时,什么决定了用户空间代码恢复执行的指令地址?

  • 如果您告诉qemu只使用一个CPU,那么使用gdb查看陷阱会更容易,这可以通过运行

  1. make CPUS=1 qemu-gdb
  • 如果alarmtest打印“alarm!”,则您已成功。

test1/test2(): resume interrupted code(恢复被中断的代码)

alarmtest打印“alarm!”后,很可能会在test0test1中崩溃,或者alarmtest(最后)打印“test1 failed”,或者alarmtest未打印“test1 passed”就退出。要解决此问题,必须确保完成报警处理程序后返回到用户程序最初被计时器中断的指令执行。必须确保寄存器内容恢复到中断时的值,以便用户程序在报警后可以不受干扰地继续运行。最后,您应该在每次报警计数器关闭后“重新配置”它,以便周期性地调用处理程序。

作为一个起始点,我们为您做了一个设计决策:用户报警处理程序需要在完成后调用sigreurn系统调用。请查看alarmtest.c中的periodic作为示例。这意味着您可以将代码添加到usertrapsys_sigreurn中,这两个代码协同工作,以使用户进程在处理完警报后正确恢复。

提示:

  • 您的解决方案将要求您保存和恢复寄存器——您需要保存和恢复哪些寄存器才能正确恢复中断的代码?(提示:会有很多)

  • 当计时器关闭时,让usertrapstruct proc中保存足够的状态,以使sigreurn可以正确返回中断的用户代码。

  • 防止对处理程序的重复调用——如果处理程序还没有返回,内核就不应该再次调用它。test2测试这个。

  • 一旦通过test0test1test2,就运行usertests以确保没有破坏内核的任何其他部分。

可选的挑战练习

  • backtrace()中打印函数的名称和行号,而不仅仅是数字化的地址。(hard)