6.S081的Lab学习——Lab4: traps


前言

一个本硕双非的小菜鸡,备战24年秋招。打算尝试6.S081,将它的Lab逐一实现,并记录期间心酸历程。
代码下载

官方网站:6.S081官方网站

安装方式:
通过 APT 安装 (Debian/Ubuntu)
确保你的 debian 版本运行的是 “bullseye” 或 “sid”(在 ubuntu 上,这可以通过运行 cat /etc/debian_version 来检查),然后运行:

sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu 

(“buster”上的 QEMU 版本太旧了,所以你必须单独获取。

qemu-system-misc 修复
此时此刻,似乎软件包 qemu-system-misc 收到了一个更新,该更新破坏了它与我们内核的兼容性。如果运行 make qemu 并且脚本在 qemu-system-riscv64 -machine virt -bios none -kernel/kernel -m 128M -smp 3 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 之后出现挂起

则需要卸载该软件包并安装旧版本:

  $ sudo apt-get remove qemu-system-misc
  $ sudo apt-get install qemu-system-misc=1:4.2-3ubuntu6

在 Arch 上安装

sudo pacman -S riscv64-linux-gnu-binutils riscv64-linux-gnu-gcc riscv64-linux-gnu-gdb qemu-arch-extra

测试您的安装
若要测试安装,应能够检查以下内容:

$ riscv64-unknown-elf-gcc --version
riscv64-unknown-elf-gcc (GCC) 10.1.0
...

$ qemu-system-riscv64 --version
QEMU emulator version 5.1.0

您还应该能够编译并运行 xv6: 要退出 qemu,请键入:Ctrl-a x。

# in the xv6 directory
$ make qemu
# ... lots of output ...
init: starting sh
$

一、RISC-V assembly (easy)

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

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

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

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

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

这里有一个小端和大端存储的描述和一个更异想天开的描述。
在下面的代码中,“y=”之后将打印什么(注:答案不是一个特定的值)?为什么会发生这种情况?

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

切换分支执行操作

git stash
git fetch
git checkout traps
make clean

解析:

首先使用make fs.img编译,得到/kernel/call.asm 汇编文件,如下所示
不全粘了,内容很多,就贴出要求的g、f和main的代码。


user/_call:     file format elf64-littleriscv


Disassembly of section .text:

0000000000000000 <g>:
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int g(int x) {
   0:	1141                	addi	sp,sp,-16
   2:	e422                	sd	s0,8(sp)
   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

000000000000000e <f>:

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

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:	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>
  exit(0);
  38:	4501                	li	a0,0
  3a:	00000097          	auipc	ra,0x0
  3e:	27e080e7          	jalr	638(ra) # 2b8 <exit>
  ......

问题一:哪些寄存器保存函数的参数?

  24:	4635                	li	a2,13

这句直接告诉了答案:13被保存于a2寄存器中。继续根据这个推,可以见到在a0-a7寄存器中保存函数的参数。

问题二:main的汇编代码中对函数f的调用在哪里?对g的调用在哪里?

相信很多人都可以直接看出是main函数调用了f函数,f函数其中调用了g函数
但是其中对应汇编是这句:

  26:	45b1                	li	a1,12

为啥是12捏?因为这句原本是f(8)+1,而f(int x)其中调用的是g(x),而g(x)返回x+3。所以是12。
f函数直接拷贝了g函数的汇编语句,核心就是下面这句+3

  14:	250d                	addiw	a0,a0,3

问题三:printf函数位于哪个地址?

在main函数汇编代码最后一句有写:跳转到630

  34:	600080e7          	jalr	1536(ra) # 630 <printf>

搜索一下果然发现了

void
printf(const char *fmt, ...)
{
 630:	711d                	addi	sp,sp,-96
 632:	ec06                	sd	ra,24(sp)

问题四:在main中printf的jalr之后的寄存器ra中有什么值?

参考大佬解释:
auipc(Add Upper Immediate to PC):auipc rd imm,将高位立即数加到PC上,该指令将20位的立即数左移12位之后(右侧补0)加上PC的值,将结果保存到dest位置。

jalr (jump and link register):jalr rd, offset(rs1)跳转并链接寄存器。jalr指令会将当前PC+4保存在rd中,然后跳转到指定的偏移地址offset(rs1)。
对应程序:

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

第一句:这行代码将0x0左移12位(还是0x0)加到PC(当前为0x30)上并存入ra中,即ra中保存的是0x30。
第二句:ra中保存的是0x30,加上0x600后为0x630,即printf的地址,执行此行代码后,将跳转到printf函数执行,并将PC+4=0X34+0X4=0X38保存到ra中,供之后返回使用。
则ra保存值为38,也就是下一条指令的地址,为了在完成printf函数调用后跳转回来继续执行代码。

问题五:运行以下代码程序的输出是什么?

首先是57616,%x,这是十六进制,将57616转换为16进制,为E110。
其次是0x00646c72,小端存储为72-6c-64-00,按照ASCII码,00:NULL,64:d;6c:l;72:r
则输出为HE110 World

如果是大端存储,i应改为0x726c6400,不需改变57616

问题六:在下面的代码中,“y=”之后将打印什么

不确定。
原本需要两个参数,却只传入了一个,因此y=后面打印的结果取决于之前a2中保存的数据。

二、Backtrace(moderate)

回溯(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

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

  1. 在kernel/defs.h中添加backtrace的原型,那样你就能在sys_sleep中引用backtrace
  2. GCC编译器将当前正在执行的函数的帧指针保存在s0寄存器,将下面的函数添加到kernel/riscv.h
static inline uint64
r_fp()
{
  uint64 x;
  asm volatile("mv %0, s0" : "=r" (x) );
  return x;
}

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

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

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

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

解析:

首先按照提示,先在kernel/defs.h里面添加backtrace的原型

// printf.c
void            printf(char*, ...);
void            panic(char*) __attribute__((noreturn));
void            printfinit(void);
void            backtrace(void);

不需要任何返回值,就打印返回地址就行
然后把给出的函数添加到kernel/riscv.h

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

这段代码是一个C语言中的内联(inline)函数,用于从RISC-V架构的处理器中读取一个特定的寄存器值。具体来说,它读取了RISC-V的s0寄存器,并将该值返回为一个uint64类型的整数

然后就是实现backtrace函数了

那张图的意思是:
fp是当前帧开始地址,sp是当前帧结束地址,但由于栈从高地址往低地址拓展,所以 fp 虽然是当前帧开始地址,但是地址比 sp 高。
栈帧中从高到低第一个 8 字节 fp-8 是 return address,是当前调用层应该返回到的地址。
栈帧中从高到低第二个 8 字节 fp-16 是 to prev frame(fp),是上一层栈帧的 fp 开始地址。这就是保存帧地址。

void
backtrace(void)
{
  printf("backtrace:\n");
  
  //首先对帧指针进行读取
  uint64 fp = r_fp();
  
  //因为栈的分配是由高到低的,所以父方法的栈会在高处,也就是地址比较大。一直向上找但是不能越过这个页表的最高处
  while (fp < PGROUNDUP(fp)) {
    uint64 ret_addr = *(uint64*)(fp - 8);
    printf("%p\n", ret_addr);
    // 前一个帧指针保存在-16偏移的位置
    fp = *(uint64*)(fp - 16);
  }
}

最后在sys_sleep中调用即可(它在sysproc.c中)

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

最后也是成功输出
在这里插入图片描述在这里插入图片描述
地址稍有不同为正常现象。

三、Alarm(Hard)

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

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

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

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

$ 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
$ usertests
...
ALL TESTS PASSED
$

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

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

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

  1. 您需要修改Makefile以使alarmtest.c被编译为xv6用户程序。
  2. 放入user/user.h的正确声明是:
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
  1. 更新user/usys.pl(此文件生成user/usys.S)、kernel/syscall.h和kernel/syscall.c以允许alarmtest调用sigalarm和sigreturn系统调用。
  2. 目前来说,你的sys_sigreturn系统调用返回应该是零。
  3. 你的sys_sigalarm()应该将报警间隔和指向处理程序函数的指针存储在struct proc的新字段中(位于kernel/proc.h)。
  4. 你也需要在struct proc新增一个新字段。用于跟踪自上一次调用(或直到下一次调用)到进程的报警处理程序间经历了多少滴答;您可以在proc.c的allocproc()中初始化proc字段。
  5. 每一个滴答声,硬件时钟就会强制一个中断,这个中断在kernel/trap.c中的usertrap()中处理。
  6. 如果产生了计时器中断,您只想操纵进程的报警滴答;你需要写类似下面的代码
if(which_dev == 2) ...
  1. 仅当进程有未完成的计时器时才调用报警函数。请注意,用户报警函数的地址可能是0(例如,在user/alarmtest.asm中,periodic位于地址0)。
  2. 您需要修改usertrap(),以便当进程的报警间隔期满时,用户进程执行处理程序函数。当RISC-V上的陷阱返回到用户空间时,什么决定了用户空间代码恢复执行的指令地址?
  3. 如果您告诉qemu只使用一个CPU,那么使用gdb查看陷阱会更容易,这可以通过运行
make CPUS=1 qemu-gdb
  1. 如果alarmtest打印“alarm!”,则您已成功。

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

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

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

  1. 您的解决方案将要求您保存和恢复寄存器——您需要保存和恢复哪些寄存器才能正确恢复中断的代码?(提示:会有很多)
  2. 当计时器关闭时,让usertrap在struct proc中保存足够的状态,以使sigreturn可以正确返回中断的用户代码。
  3. 防止对处理程序的重复调用——如果处理程序还没有返回,内核就不应该再次调用它。test2测试这个。
  4. 一旦通过test0、test1和test2,就运行usertests以确保没有破坏内核的任何其他部分。

解析:

首先第一步,将user/alarmtest.c的文件添加到Makefile。

// Makefile
        $U/_wc\
        $U/_zombie\
        $U/_alarmtest\

然后放入user/user.h的正确声明:

//sysprorc.c
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);

更新user/usys.pl(此文件生成user/usys.S)、kernel/syscall.h和kernel/syscall.c以允许alarmtest调用sigalarm和sigreturn系统调用。

//user/usys.pl
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);

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

//kernel/syscall.h
#define SYS_sigalarm  22
#define SYS_sigreturn  23

//kernel/syscall.c
[SYS_sigalarm]   sys_sigalarm,
[SYS_sigreturn]   sys_sigreturn,

接下来在struct proc中增加字段,同时记得在allocproc中将它们初始化为0,并在freeproc中也设为0

//proc.h
  char name[16];               // Process name (debugging)
  int alarm_interval;          // 报警间隔
  uint64 handler;     		   // 报警处理函数指针
  int ticks_count;             // 两次报警间的滴答计数
//proc.c(两个都要添加)
  p->ticks_count = 0;
  p->alarm_interval = 0;
  p->handler = 0;

在sysprorc.c中实现sys_sigalarm和sys_sigreturn

主要是将报警间隔和指向处理程序函数的指针存储在struct proc的新字段中。赋完之后把计数归0

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

uint64
sys_sigreturn(void) {
    return 0;
}

最后在trap.c中的usertrap()中处理.
增加用户报警函数的地址可能是0的判断。当进程的报警间隔期满时(也就是两次报警间的滴答计数达到了报警间隔),用户进程执行处理程序函数。

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2) {
    if (p->alarm_interval != 0) {
      p->ticks_count++;
      if (p->ticks_count == p->alarm_interval) {
        p->ticks_count = 0;
        p->trapframe->epc = p->handler;
      }
    }
  }
  yield();

没有什么问题
在这里插入图片描述

题目二中要解决的主要问题是寄存器保存恢复和防止重复执行的问题。
要在usertrap中再次保存用户寄存器,当handler调用sigreturn时将其恢复,并且要防止在handler执行过程中重复调用。
再在struct proc中新增两个字段

int is_alarming;                    // 是否正在执行告警处理函数
struct trapframe* alarm_trapframe;  // 告警陷阱帧

同时记得在allocproc中将它们初始化为0,并在freeproc中也设为0

  // 增加不是修改
  // 进程初始化,这里主要是防止申请不成功,那就学着已有的代码对进程进行销毁
  if((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  p->pid = allocpid();
  p->ticks_count = 0;
  p->alarm_interval = 0;
  p->handler = 0;
  p->is_alarming = 0;

  //增加不是修改
  if(p->alarm_trapframe)
    kfree((void*)p->alarm_trapframe);
  p->trapframe = 0;
  p->is_alarming = 0;

更改usertrap函数,保存进程陷阱帧p->trapframe到p->alarm_trapframe

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2) {
    if (p->alarm_interval != 0) {
      p->ticks_count++;
      if (p->ticks_count >= p->alarm_interval && p->is_alarming == 0) {
        memmove(p->alarm_trapframe, p->trapframe, sizeof(struct trapframe));
        p->is_alarming == 1;
        p->ticks_count = 0;
        p->trapframe->epc = (uint64)p->handler;
      }
    }
  }
  yield();

  usertrapret();
}

最后修改sys_sigreturn函数,恢复陷阱帧。

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

在这里插入图片描述

总结

这个实验成功的带着我们实现了回溯,中断的处理过程并如何恢复初始状态。从系统调用中梳理中断的全流程。收获颇丰。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

努力找工作的小菜鸡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值