Lab4 Traps

Lab4 traps

1. 理解栈和栈帧

故事要从一张图片说起:

进程的创建和程序的运行:

举个例子来说,比如shell要运行一个程序,首先通过fork来创建进程,allocproc会映射user address space顶部的trampoline和trapframe(用于处理trap),然后exec把可执行文件载入到text和data中,然后分配stack和gurde page。(注:写了lab5 lazy allocation以后,可以发现在fork和exec之间还需要sbrk来申请空间)

  • 以下只是我的猜想,不一定准确

    指令的信息都是放在text中的,理由:在user目录中,打开任何一个应用程序的.asm文件,可以发现每个函数的指令的地址都特别小,接近与0,说明它们都在虚拟地址的低处,查看上图可知,应该是在text段。(注:这已经被证明是正确的)

  • 如何理解指令

    内存中不光有数据,也有指令。指令也是在内存中的单元,它们也有自己的地址,由pc指向并执行。

  • 如何理解栈

我现在也不是很懂,暂且记下,方便以后修改和补充。栈是程序的载体,,体现在变量的空间在栈上分配以及函数的调用需要栈。从c语言程序的角度来看,程序执行伴随着函数调用,函数的调用就需要保存现场和恢复现场,这个过程中就需要栈。当一个函数a调用函数b时,需要将函数a的各个状态都记下,以便从b返回时恢复。这些被记录下来的信息就放在栈空间(就是第一幅图的stack),主要保存return addre,to prev. frame,saved register,local variables,图中return address就在ra寄存器里。通过阅读汇编代码可以知道,当a调用b时,在去b之前,会先把这些信息存好,再跳转到b(主要是通过jalr指令,跳转并链接寄存器,该指令会把PC+4存到ra,然后跳转到指定地址。不过也可以手动完成这一个步骤:先通过sd指令保存ra,在通过call指令到指定函数),而在b的开始,会先把sp(stack pointer)向下移动(要做减法,因为栈是向下生长的),腾出空间,保存返回地址,再做相应的操作;若函数b要返回,则先将sp复原,通过ret指令返回。这就形成了栈帧(注意,由于我也没学过汇编语言,以上都是不完全准确的,但是大概是这样)

  • 一个例子:

    int a = 1;
    14:    4785                    li    a5,1
    16:    fcf42623              sw    a5,-52(s0)
    printf("%p\n", &a);    
    1a:    fcc40593              addi    a1,s0,-52
    1e:    00001517              auipc    a0,0x1
    22:    89a50513              addi    a0,a0,-1894 # 8b8 <statistics+0x88>
    26:    00000097              auipc    ra,0x0
    2a:    668080e7              jalr    1640(ra) # 68e <printf>
    

    我在echo.c的main函数中加了int a = 1printf("%p\n", &a),即声明一个变量并打印它的地址,通过gdb调试如下:

    在fork,exec以及sys_sbrk上设置断点,continue,可以发现:

    载入并执行初始程序init,继续continue

    先fork,在载入并执行shell,此时我们可以进行交互了,输入echo hi后,继续continue

    中间多了一步sys_sbrk,用于扩大空间,然后载入并执行echo,此时在0x1a处设置断点(使PC停留在执行完int a = 1;i以后),continue。此时我们先来预测一下结果,看汇编代码,li a5,1表示a5存的是1,也就是变量a的值,sw a5,-52(s0)表示将a5的值存到s0-52的地址处,也就是a的地址,所以s0-52就表示变量a的内存单元,我们不妨打印这个地址和地址中的值:

    可以看到地址中的值正是1,而且值得注意的是变量a的地址在user address space中为0x2f8c,这个地址应该在第3页,从第一副图可以看出这个地址在guard page,很奇怪,guard page中的地址都是非法的呀!原来在xv6 book中有如下论述:xv6 programs have only one program section header, but other systems might have separate sections for instructions and data.在xv6中text和data是合一起的,所以这个地址应该在stack中,而不是guard page中。

  • 一个疑问:在上述程序中,如果我不打印变量a的地址,而只是打印a的值,生成的汇编代码为:

    int a = 1;
    printf("%d\n", a);
    14:    4585                    li    a1,1
    16:    00001517              auipc    a0,0x1
    1a:    89a50513              addi    a0,a0,-1894 # 8b0 <statistics+0x88>
    1e:    00000097              auipc    ra,0x0
    22:    668080e7              jalr    1640(ra) # 686 <printf>
    

    可以看到,int a = 1;这一句根本就没有给任何寄存器赋值,也没有把任何值store进任何地址,而是直接略过了!而是直接将1作为printf的第二个参数load进了a1当中。我猜测这是编译器的某种优化,因为在这种情况下,以上操作都是多余的,只会拖慢速度,占用空间。

2. RISC-V assembly(easy)

该部分比较简单,就不讲了,不过里面的文章和资料还没有看,等看完以后在此处补上笔记

2.1 分析

下面是riscv中的寄存器(不包含浮点数寄存器)

ABI name Description saver
zero Hard-wired zero
ra Return Address Caller
sp Stack Pointer Callee
gp Global Pointer
tp Thread Pointer
t0-2 Temporaties Caller
s0/fp Saved Register/Frame Pointer Callee
s1 Saved Register Callee
a0-1 Fanction Arguments/Return Value Caller
a2-7 Function Arguments Caller
s2-11 Saved Register Callee
t3-6 Temporaries Caller

3. Backtrace(moderate)

3.1 题目

kernel/printf.c中完成一个backtrace函数,在sys_sleep中调用该函数,然后运行buttest,buttest会调用sys_sleep。编译器为每个stack frame放一个fp,这个fp存着调用该函数的fp的地址(即fp指向父函数的fp),backtrace沿着这一串链来打印每一帧的返回地址ra。

提供了函数

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

来获取当前栈帧的fp

3.2 分析

我们分析一下整个过程,运行buttest,buttest调用sys_sleep,最后sys_sleep调用backtrace,此时应该至少有三个stack frame,buttest在最顶端,backtrace在最底端。需要注意的是,根据提示,return address和to prev. fram在stack frame中具有固定的位置,即return address一定是在fp的-8位置,而to prev. frame则一定是在fp的-16位置,根据这个,我们就可以访问到所有stack frame的ra了

3.3 实现

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

循环终止的条件:stack的空间是一页,通过对fp上取一页,下取一页,得到fp的范围。

4. Alarm(hard)

4.1 题目

上面所有讲到的东西似乎和trap一点关系都没有。。。不过这题就有关系了。

这个实验要求我们在xv6中增加1项特性:在一个进程使用cpu时定期警报。这对与那些希望限制CPU时间消耗的受计算限制的进程,或者对于那些计算的同时执行某些周期性操作的进程可能很有用。

需要添加一个新的syscall:sigalarm(interval, handler),其中intercal是时间间隔,即每interval就警报一次,handler是警报的具体内容,比如在终端中打印一段话。如果一个程序调用了sigalarm(n, fn)那么,每隔n个cpu时间(ticks),kernel就要调用fn来提示警报。当fn返回时,程序需要返回到它离开的位置(因为被中断了)继续执行

4.2 分析

关于trap的原理,这里不多说。具体可以看课程视频。

一个进程trap,可能有3种情况,第一是syscall,因为若要进行系统调用,必须从user space到kernle space;第二是device interrupt(设备中断),比如磁盘完成了读和写都会触发中断,此外还有计时器的中断,也就是这个实验涉及到的中断,它每个一段时间就会触发;第三是exception(异常),比如说使用了非法的虚拟地址如page fault,以及除0等操作都会触发异常。

众所周知,一个进程是不会自己报警的(这不是废话么。。。)所以我们需要打开一个开关,来让进程拥有此功能,而承担开关功能的就是sigalarm函数,请看提供的alarmtest.c的部分代码:

void
test0()
{
  int i;
  printf("test0 start\n");
  count = 0;
  sigalarm(2, periodic);
  for(i = 0; i < 1000*500000; i++){
    if((i % 1000000) == 0)
      write(2, ".", 1);
    if(count > 0)
      break;
  }
    //后面的省略
}

函数sigalarm当然不可能阻塞在那里,然后等着时间流逝,然后发出一个警报,这样实在是太愚蠢了。肯定是sigalarm调用时,通过某种方式,修改了进程的一些信息,调用完后,马上执行后续的代码,可以看出这里的后续代码就是一个很大很大的循环,看到这里,实际上这个lab的意思已经很清楚了!由于这个循环会占用大量的时间,每隔一定的时间,计时器都会触发中断,进入内核,就下来就是在usertrap中处理这个中断了!

由于一个进程的信息都在struct proc中,所以我们需要在其中加入一些必要的信息,比如两次报警之间的间隔(即interval),以及句柄(即要执行的报警函数hendler)。

4.3 第一次实现

proc.h中的结构体struct proc中加入信息:

struct proc {    
  //增加的信息
  uint64 fn;                 // the address of periodic
  int n;                     // tick num
  int history;               // ticks done
}

sys_sigalarm中获取两个参数并扔进proc中

uint64
sys_sigalarm(void)
{
  int n;
  uint64 fn;

  if (argint(0, &n) < 0)
    return -1;
  if (argaddr(1, &fn) < 0)
    return -1; 
  myproc()->fn = fn;    
  myproc()->n = n;
  myproc()->history = 0;

  return 0;
}

在usertrap中对计时器中断进行处理:

void
usertrap(void)
{
  //...
  if(which_dev == 2) {
  if (++p->history == p->n) {
      p->history = 0;
      p->busy = 1;
      p->trapframe->epc = p->fn;
    }
  }
  //...
}

中断之前的信息都保存在trapframe中了,这里修改了trapframe-epc,目的是让userret不要回到被中断的地方继续执行,而是去handler指向的位置执行报警函数这里留个疑问,为什么不能直接调用handle,通过函数指针的方式来调用,虽然当前是在kernel space,kernel pagetable没有从handler的虚拟地址到物理地址的映射,但是我们可以通过p->pagetable来找到user space的pagetable,然后通过walkaddr找到物理地址。事实上我一开始就是采用这种方式,但是没有成功。我想我可能是忘记了mappages这va和pa了,因为在mmu打开的状态下,一切地址都被认为是va,硬件自动walk来找到pa,虽然kernel address是恒等映射,但是我甚至没有映射,可能这里出错。已破案,感谢群友的解答。因为handler是用户态的函数,如果这个函数在内核态调用,那就运行在内核栈上,内核态有特权,如果用户程序是恶意的,那就存在很大的安全隐患,此外,如果函数内调用用户态的全局变量或者其他函数,有需要手段翻译(因为内核栈上没有这些信息),会很麻烦。

4.4 再次分析

如果按照这样思路,那么通过test0没有问题,但是无法通过test1。因为我们忘记考虑了一个很重要的因素:恢复现场

我们来梳理一下整个过程,用户进程被中断以后,通过trampoline进入usertrap,假设此时trapframe中的状态是状态a,该状态是离开userspace时的状态,从kernel中返回时需要载入该状态。在usertrap中,我们修改了trapframe->epc,为的是返回user space时不回到被中断的地方,而是进入handler,此时trapframe中的状态还是状态a。而在handler中,有一个printf("alarm!"),这里是需要系统调用的,同要要通过trampoline进入usertrap,因为trampoline要保存现场,它会把原有的现场覆盖掉,此时trapframe中的状态已经不是状态a了,而是状态b因而最后返回被中断位置的时候,恢复的状态不是初始状态。test0可以通过的原因是,从它并没有对此进行测试,换句话说,只要顺着这条线走,返回到被中断位置就可以通过,而test1对此进行了测试。

test2没啥好说的,一个中断处理正忙时,不能处理下一个中断。

4.5 再次实现

  • proc.h加入新的变量struct trapframe add_trapframe用于暂存状态a,以及busy变量来表示trap是否正忙

  • 在usertrap中将p->trapframe拷贝到p->add_trapframe

  • 在sigreturn中将p->add_trapframe拷贝回p->trapframe,不得不说,sigreturn的存在真是一个绝妙的设计

void
usertrap(void)
{
  //...
  if(which_dev == 2) {
    if (p->busy == 0 && ++p->history == p->n) {
      memmove(&(p->add_trapframe), p->trapframe, sizeof(p->add_trapframe));
      p->history = 0;
      p->busy = 1;
      p->trapframe->epc = p->fn;
    }
  }
  //...
}
uint64
sys_sigreturn(void)
{
  memmove(myproc()->trapframe, &(myproc()->add_trapframe), sizeof(myproc()->add_trapframe));
  myproc()->busy = 0;
  return 0;
}
posted @ 2022-07-14 22:40  我是小BH  阅读(128)  评论(0)    收藏  举报