Skip to content

traps

Giovanna

About 2684 wordsAbout 9 min

2024-08-04

Lab: Traps (mit.edu)


开启新实验

git fetch
git checkout traps
make clean

RISC-V assembly

The code in call.asm for the functions gf, and main.

tmpFFF6.png

tmp2D7F.png

tmp6D96.png

The instruction manual for RISC-V is on the reference page.

Q1

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

RISC-V的函数调用过程参数优先使用寄存器传递,即a0~a7共8个寄存器。返回值可以放在a0和a1寄存器。main函数printf调用的13保存在a2。

Q2

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

汇编代码中直接将12移入a2作为printf的参数,12已经是f(8)+1的结果了。

Q3

At what address is the function printf located?

30:   00000097                auipc   ra,0x0
34:   5f8080e7                jalr    1528(ra) # 628 <printf>

auipc 指令将当前指令地址加上一个立即数(0x0)存储到ra寄存器。auipc 指令的全称是 “Add Upper Immediate to PC”,用于计算当前 PC 地址的偏移。

jalr 指令是 “Jump And Link Register”,它将跳转到 ra + 1528 的地址,并将返回地址存储到 ra 寄存器。结合 auipcjalr,我们知道这实际上是一个跳转到 printf 函数的调用。

当前 PC 地址是 0x30,那么 auipc ra,0x0ra 设置为 0x30 + 0x0(即 0x30)。jalr 1528(ra) 将跳转到 0x30 + 1528,这就是printf函数的实际地址。

0x30 + 1528 = 0x30 + 0x5f8 = 0x628

因此,printf函数的地址是0x628

Q4

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

main函数中调用printf之后,ra(返回地址寄存器)中将存储返回到main函数中的地址。这是因为jalr指令的作用是跳转到目标地址并将返回地址存储到ra中。

在执行jalr指令时,ra寄存器将被设置为跳转指令的下一条指令的地址,也就是printf调用后的下一条指令的地址。

根据代码,jalr指令的下一条指令是:

38:   4501                    li      a0,0`

因此,ra寄存器将在执行jalr指令后包含0x38,这是printf返回时程序将继续执行的地址。

Q5

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.

printf 语句将打印两个格式化的字符串:

  1. "H%x Wo" - 打印字符 H,然后是十六进制格式的数字 57616,再是字符串 Wo
  2. "%s" - 打印字符串,该字符串从 i 的地址开始

57616 的十六进制表示为 0xe110

小端

i地址由低到高分别是:72 6c 64 00

i 的地址开始的字符串,转换为 ASCII 字符为:rld

所以输出He110 World

大端

要保持输出不变,i地址由低到高应该依旧是:72 6c 64 00

在大端模式下,这个数字是:0x726c6400

i应该设置为0x726c6400来保持与小端相同的输出。

对于 57616,它的值不受大端或小端影响,因为它只是一个直接的数值参数,不涉及内存存储顺序,所以不需要更改。

Q6

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会从a2寄存器中读取第三个参数作为y的值。

Backtrace

对于调试,回溯跟踪通常很有用:在错误发生点上方的堆栈上调用函数的列表。

任务描述:在kernel/printf.c中实现backtrace()函数。并在sys_sleep中插入对此函数的调用。

  1. GCC 编译器将当前正在执行的函数的帧指针存储在寄存器 s0 中。在kernel/riscv.h 中添加以下函数
static inline uint64
r_fp()
{
  uint64 x;
  asm volatile("mv %0, s0" : "=r" (x) );
  return x;
}

r_fp() 这个函数读出了 s0 这个寄存器的值,然后储存在 x 中,最后又把 x 返回了。

但是我们要读取的明明是fp这个寄存器,为什么这个函数里写的是s0呢,具体可以看看下面这个表:

image.png

在 ABI Name 那一列,可以看到 s0 其实就是 fp 的别名。

  1. kernel/printf.c中实现backtrace()

编译器在每个堆栈帧中放置一个帧指针,该指针保存调用方帧指针的地址。backtrace()应使用这些帧指针在堆栈中向上移动,并在每个堆栈帧中打印保存的返回地址。

image.png

请注意,返回地址位于与堆栈帧的帧指针的固定偏移量 (-8) 处,而上一个函数的帧指针保存于距当前帧指针的固定偏移量 (-16) 处。

Xv6 在 xv6 内核的每个堆栈中与 PAGE 对齐的地址处分配一个页面。可以使用 PGROUNDDOWN(fp) 和 PGROUNDUP(fp) 计算堆栈页面的顶部和底部地址。

需要使用 PGROUNDDOWN 和 PGROUNDUP 是因为,一连串的函数调用最多放在一个页中。那么如果我们在递归打印的时候,超出了这一页的范围,就可以说明已经是最底层的函数,可以停止了。

void backtrace(void)
{       
    printf("backtrace:\n");
    uint64 *fp = (uint64*)r_fp();
    uint64 *top = (uint64*)PGROUNDUP((uint64)fp);
    uint64 *bot = (uint64*)PGROUNDDOWN((uint64)fp);
    while(fp < top && fp > bot){
        printf("%p\n", fp[-1]);
        fp = (uint64*)fp[-2];
    }
}

类型转换好麻烦

可以看到这里用了一些很奇怪的写法,好像是负数下标的数字,其实这个 fp[-1] 等价于 *(fp - 1)。并且,因为这里 fp 是六十四位的指针,所以 *(fp - 1) 是读取 fp 前八个字节位置的数据。

  1. backtrace()添加到kernel/defs.h中,以便在sys_sleep中调用。
void backtrace(void);
  1. kernel/sysproc.csys_sleep函数中插入对backtrace()的调用。

Alarm

任务描述:向 xv6 添加一个功能,该功能会在进程使用 CPU 时间时定期发出警报。添加一个新的sigalarm(interval, handler)系统调用,消耗每interval个 CPU 时间之后,内核应该导致应用程序函数handler被调用,返回时从中断的地方继续。此外还要实现一个sigreturn()系统调用,如果时间到了handler调用了sigreturn(),就应该停止执行handler,然后恢复正常的执行顺序。如果说 sigalarm 的两个参数都为 0,就代表停止执行handler函数。

test0:invoke handler

  1. user/alarmtest.c已经实现,将其添加到 Makefile。

  2. user/user.h添加如下声明

int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
  1. user/usys.pl进行添加
entry("sigalarm");
entry("sigreturn");
  1. kernel/syscall.h中添加以下定义
#define SYS_sigalarm 22
#define SYS_sigreturn 23
  1. 修改kernel/syscall.c,用 extern 全局声明新的内核调用函数,并且在 syscalls 映射表中,加入从前面定义的编号到系统调用函数指针的映射。
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);

static uint64 (*syscalls[])(void) = {
...
[SYS_sigalarm]   sys_sigalarm,
[SYS_sigreturn]  sys_sigreturn,
}
  1. 修改kernel/sysproc.csys_sigreturn()暂时只需返回0
uint64 sys_sigreturn(void)
{
	return 0;
}
  1. kernel/proc.h中,struct proc需要添加一些新成员
struct proc{
	...
	int interval;
	uint64 handler;
	int passed;
}
  1. 修改kernel/sysproc.csys_sigalarm()需要保存一下相关信息
uint64 sys_sigalarm(void)
{
	struct proc *p = myproc();
	int interval;
	uint64* handler;
	if(argint(0, &interval) < 0) return -1;
	if(argaddr(1, &handler) < 0) return -1;
	p->interval = interval;
	p->handler = handler;
	p->passed = 0;
	return 0;
}
  1. 修改kernel/proc.c,在 allocproc() 和 freeproc() 中的初始化和释放
p->interval = 0;
p->handler = 0;
p->passed = 0;
  1. 修改kernel/trap.c中的usertrap(),以便当进程的警报间隔到期时,用户进程将执行处理程序函数。

每个时钟周期,硬件时钟都会强制中断,该中断在usertrap()中处理。

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

这样我们就能顺利的跳转到handler,并且通过 test0,当然也毫无悬念的报错了。

报错的主要原因是还没实现 sys_sigreturn(),这样在执行完handler函数之后就不知道返回哪里了。

test1/test2(): resume interrupted code

  1. 在 struct proc 再加一个 struct trapframe 类的属性,用于备份执行 handler 前的环境
// kernel/proc.h
struct trapframe *alarmframe;
  1. 修改kernel/proc.c,在 allocproc() 和 freeproc() 中的初始化和释放
// allocproc()
if((p->alarmframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
}

// freeproc()
if(p->alarmframe)
    kfree((void*)p->alarmframe);
p->alarmframe = 0;
  1. kernel/trap.c里的 usertrap()需要执行handler的时候,先备份一下环境,然后再执行
// give up the CPU if this is a timer interrupt.
if(which_dev == 2){
    if(p->interval > 0){
        p->passed++;
        if(p->passed > p->interval){
	        p->passed = 0;
	        *p->alarmframe = *p->trapframe;
	        p->trapframe->epc = p->handler;
        }
    }
    yield();
}
  1. 修改kernel/sysproc.c,在 sys_sigreturn() 按照 alarmframe 恢复 trapframe
uint64 sys_sigreturn(void){
    struct proc *p = myproc();
    *p->trapframe = *p->alarmframe;
    return 0;
}

到这里,再去运行alarmtest,会发现还是不能过。

如果 handler 执行的特别慢,自从上次调用 handler 已经过去了规定的时钟周期,但是 handler 还没执行好,这个时候我们又去改一遍 epc,这个 handler 又从头开始执行了,那着不就出大问题了,因为我们每次都会去改 epc,然后就永远执行不完 handler 了。

所以我们需要在 struct proc 里再加一个属性,就是 alarm_state。如果这个属性为 1,就表示,handler 程序正在执行,这个时候就算又过了 tick 个时钟周期,我们也不能去改 epc 让 handler 重复执行。

  • kernel/proc.h添加属性
  • kernel/proc.c添加初始化与释放

这两步很简单就跳过了,还要修改kernel/trap.c里的usetrap()

// give up the CPU if this is a timer interrupt.
if(which_dev == 2){
    if(p->interval > 0){
        p->passed++;
        if(p->passed > p->interval && !p->alarm_state){
	        p->passed = 0;
	        *p->alarmframe = *p->trapframe;
	        p->alarm_state = 1;
	        p->trapframe->epc = p->handler;
        }
    }
    yield();
}

只有alarm_state为0才允许跳转到handler,跳转时将其置1,标志着handler正在执行。

最后,修改kernel/sysproc.c中的sigreturn()

uint64 sys_sigreturn(void){
    struct proc *p = myproc();
    *p->trapframe = *p->alarmframe;
    p->alarm_state = 0;
    return 0;
}

不再执行handler时调用sigreturn(),所以alarm_state要恢复为0。

The End

tmp983.png

复习了系统调用,了解了一点点汇编,学习了xv6的Traps。感觉自己并不是非常的理解陷入的过程,只是在一点一点地跟着实验指导做。而且还是没去仔细扒一下xv6的源码,对于一些结构不是很清楚都储存了什么信息,一些方法也不太会用,所以在实现上还是有点茫然。