RISC-V assembly (easy)

问题

  1. 哪些寄存器保存函数的参数?例如,在main函数对printf的调用中,哪个寄存器保存13?
  2. main函数中哪里调用了汇编代码中的f函数和g函数?(编译器可能内联函数)
  3. printf位于哪个地址?
  4. 紧接着在main中跳转到printf之后,寄存器ra中保存的值是什么?
  5. 运行下列代码:

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

    输出什么?
    输出取决于RISC-V是小端序(little-endian)的事实。若RISC-V是大端序(big-endian),则i应当设置成什么才能给出相同的输出?需要改变57616吗?

  6. 下列代码中,y=之后将会输出什么?(非特定值)为何?

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

回答

  1. a0 a1 a2(main函数只有这仨,实际上到a7); a2保存13
  2. 在0x26处,看到直接将12给寄存器a1,说明f的调用被优化掉了
  3. 在0x34处,可知jalr跳转到1554 + ra地址处。而根据0x30处,寄存器ra保存了0x30的地址,因此jalr跳转到1554(dec)+0x30(hex)=0x642(hex)的目标地址,因此printf的地址是0x642,发现与注释相同
  4. 0x38
  5. 输出:HE110 World

    • 首先%x输出57616的十六进制形式
    • 然后是值为0x00646c72的i%s实际上读取的是字符串,也就是一个又一个char,所以i这个int类型实际上可以看作由4个char类型组成,然后就是这4个char怎么排列的问题
    • 大端序即:0x12345678 -> 0x 12 34 56 78(从左到右地址序号增大)
    • 小端序即:0x12345678 -> 0x 78 56 34 12(从左到右地址序号增大)
    • 资料指出RISC-V采用小端序,由此可知i在内存当中的储存为:0x 72 6c 64 00
    • 因此会先读取值为0x72char类型,在ascii中对应字母r;然后是0x6c,对应字母l0x64对应d0x00对应终止

    因此若是大端序,则i应设置为:i = 0x646c7200。而57616不需要变,因为没有发生改变大小的类型转变。

  6. 理论上讲,由于传入两个参数,因此a0和a1寄存器都保存了确定的值,但在printf中由于使用了第三个参数,因此会使用a2的值作为y=后边的输出值;但a2寄存器中的值并不能确定,因此不确定输出结果。
    但实际上,我尝试多次都是1。可能是因为没有函数使用寄存器a2,a2就一直保持值为1的状态吧

Backtrace (moderate)

要求

回溯对debug非常有用:在error发生时刻的堆栈上系统调用的一个列表。为实现回溯,编译器生成机器码,在堆栈上维护当前调用链上每个函数对应的堆栈帧。每个堆栈帧由返回地址和一个指向调用者堆栈帧的帧指针组成。寄存器s0包括一个指向当前堆栈帧的指针(实际指向堆栈上保存的返回地址+8).你的backtrace应当使用帧指针遍历堆栈并打印堆栈帧上保存的返回地址。

kernel/printf.c中实现backtrace()。在sys_sleep中调用此函数。运行命令bttest,会调用sys_sleep。输出格式应当如下所示:

backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898

在终端运行命令addr2line -e kernel/kernel并将backtrace输出的地址复制粘贴过来就能得到以下输出:

kernel/sysproc.c:74
kernel/syscall.c:224
kernel/trap.c:85

提示

  • defs.h中添加定义。
  • GCC编译器将当前函数的帧指针保存在寄存器s0中。在kernel/risv.h中添加下面的函数:

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

    backtrace中调用该函数可通过内联的机器码读取寄存器s0

  • 堆栈帧布局示意图(如下)。注意,返回地址位于堆栈帧帧指针固定偏移-8的地方;以及保存的帧指针位于(当前)帧指针固定偏移-16的位置。

    Stack
                    .
                    .
        +->          .
        |   +-----------------+   |
        |   | 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 |
            +-----------------+
  • backtrace需要识别最后的栈帧以停止递归。一个有用的事实是,为每个内核堆栈分配的内存由单个对齐内存页组成,因此给定堆栈上的所有堆栈帧都在同一内存页上。你可以使用PGROUNDDOWN(fp)识别帧指针引用的内存页

如果backtrace运行正常,可以在panic中调用它。

实现

  1. defs.h中添加定义(略)
  2. 将提示给的r_fp()函数复制到risv.h(略)
  3. backtrace实现思路:

    • 首先还是可以确定,最简单的方法还是递归调用一个打印函数。
    • 递归就需要确定递归终止条件,这次提示中也给出了终止条件——堆栈帧都保存在同一内存页上,即帧指针都在同一内存页。由此可得递归终止条件:若当前堆栈帧中保存的前一个帧指针与当前帧指针不在同一内存页,则说明保存的并非帧指针,就可以终止递归了。
    • 然后就是返回地址获取方法:当前帧指针偏移-8后解引用
    • 前一个帧指针获取方法:当前帧指针偏移-16后解引用
  4. printf.c中实现backtrace

    void printFramePointer(uint64 fp) {
        uint64 pre_fp = *(uint64*)(fp - 16);
        printf("%p\n", *(uint64*)(fp - 8));
        if (PGROUNDDOWN(fp) == PGROUNDDOWN(pre_fp)) {
            printFramePointer(pre_fp);
        }
    }
    
    void backtrace(void) {
        printFramePointer(r_fp());
    }
  5. sys_sleep()中添加backtrace()(略)

结果

运行结果

image.png

测试结果

image.png

Alarm (hard)

要求

你需要为xv6添加添加一个进程在使用CPU时周期性发出警告的功能。这可能对想要限制其CPU占用时间的计算密集型程序或需要计算但又同时需要采取周期性侗族殴打程序很有用。更一般地说,你将实现一个用户级中断/错误处理程序的原始版本;例如你可以使用与应用程序中处理页面错误相似的方式。测试命令usertests -q

你应添加一个新的系统调用sigalarm(interval, handler)。若一个应用程序调用sigalarm(n, fn),则CPU每运行n滴答后,内核都应调用函数fn。当fn返回时,应用程序应从在中断的地方继续执行。在xv6中,滴答是一个相当任意的时间单位,由硬件计时器生成中断的频率决定。若一个应用程序调用sigalarm(0, 0)则内核应当停止生成周期性警告调用。

user/alarmtest.c添加到Makefile中。你只有在添加sigalarmsigreturn系统调用后该文件才能通过编译。

在test0中alarmtest.c调用sigalarm(2, periodic),要求内核每2滴答都会强制调用 periodic()然后自旋一会儿。你可以看看user/alarmtest.asmalarmtest的机器码,可能对debug有用。当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
$ usertest -q
...
ALL TESTS PASSED
$

test0: invoke handler

通过修改内核使其跳转到用户空间的警告处理开始实验,该警告处理会打印“alarm”

  • user/alarmtest.c添加到Makefile中。
  • user/user.h中正确的声明方法为:

    int sigalarm(int ticks, void (*handler)());
    int sigreturn(void);
  • 更新user/usys.pl, kernel/syscall.h, kernel/syscall.c以允许alarmtest调用sigalarm和sigreturn两个系统调用
  • 当前sys_sigreturn应当返回0
  • 你的sys_sigalarm()应当在struct proc中用新的字段保存警告间隔和处理函数指针。
  • 您需要跟踪自上次调用(或到下一次调用)到进程的警告处理程序的滴答数,为此同样需要在struct proc中添加新字段。你可在allocproc()中初始化这些字段
  • 每次滴答,硬件时钟都会强制中断,该中断在kernel/trap.cusertrap()中被处理。
  • 您只想在有计时器中断时操纵进程的警告滴答,类似于:

    if(which_dev == 2) ...
  • 仅当进程具有未完成的计时器时才调用警报函数。请注意,用户警报函数的地址可能是0例如,在user/alarmtest.asm中,periodic位于地址0)
  • 您需要修改usertrap(),以便在进程的警报间隔到期时,用户进程执行处理函数。当RISC-V上的trap返回用户空间时,是什么决定了用户空间代码恢复执行的指令地址?(寄存器ra)
  • 让qemu只用一个线程可能使用gdb查看traps更简单,可通过运行make CPUS=1 qemu-gdb实现
  • 若alarmtest打印“alarm!”则说明你成功了

test1/test2()/test3(): resume interrupted code

完成上述工作后可以通过test0但不能通过test1。想要彻底完成,你必须确保当警告处理函数完成后,控制返回值返回到用户最初被定时器中断的指令的地方,好让用户能够在警告后不受干扰地继续运行。最后,您应该在每次警报计数器响起后重新计数,以便定期调用处理程序。
我们为您做出了设计决策:用户警报处理程序需要在完成后调用sigreturn系统调用。例如,alarmtest.cperiodic。这意味着您可以向 usertrapsys_sigreturn 添加代码,以配合用户进程在处理警报后正确恢复。

  • 你的解决方案需要你保存和恢复寄存器——为正确唤醒终端的程序,哪些寄存器需要保存和恢复?(可能有很多)
  • 当计时器关闭时,让usertrapstruct proc中保存足够的状态,以便sigreturn可以正确返回到中断的用户代码。
  • 防止处理函数的重入调用——如果处理函数尚未返回,内核不应再次调用它。test2会对此测试
  • 确保恢复a0。sigreturn是系统调用,它返回的值储存在a0中

实现

test0: invoke handler

  1. 按照提示在Makefile中添加指定文件
  2. struct proc中添加新字段

    struct proc {
        ...  // 其他保持不变
        int interval;  // 添加间隔字段
        uint64 handler;  // 处理函数指针(地址)字段
        int ticks;  // 记录ticks
    }
  3. allocproc()中初始化上述字段

    static struct proc* allocproc(void) {
        ...  // 其他保持不变,在随后添加初始化
        // 初始化alarm相关字段
        p->interval = 0;
        p->handler = 0;
        p->ticks = 0;
        return p;
    }
  4. 添加系统调用的流程:

    1. user.h中添加syscall声明
    2. usys.pl中添加syscall entry
    3. syscall.h中添加宏定义
    4. syscall.c中添加syscall声明并放入映射数组中
    5. sysproc.c中完成两个syscall的定义

      uint64 sys_sigalarm(void) {
          struct proc* p = myproc();
          argint(0, &p->interval);
          argaddr(1, &p->handler);
          return 0;
      }
      
      uint64 sys_sigreturn(void) {
          return 0;
      }
  5. usertrap()中添加记录ticks和判断是否到时间的逻辑代码

    • 根据提示,符合which_def == 2条件时是xv6计时器一个时钟周期的中断,因此逻辑代码都应在符合这个条件的if
    • p->handler事实上保存的是最初用户传入系统调用的虚拟地址,因此在内核中不能直接调用。
    • 但用户的PC(程序计数器)使用用户虚拟地址,因此可在内核直接更改PC中的地址使程序返回用户态后下一个地址指向handler。通过查阅资料,可知p->trapframe->epc指的就是PC

      void usertrap(void) {
       ...
       // give up the CPU if this is a timer interrupt.
       if(which_dev == 2) {
           if (p->interval) {  // 若给定间隔非0
               ++p->ticks;     // 就记录经过的ticks
               if (p->ticks == p->interval) {
                   // 若记录ticks到达给定间隔
                   // 将handler地址放入PC寄存器
                   p->trapframe->epc = (uint64)p->handler; 
                   p->ticks = 0;  // 最后将记录ticks置0
               }
           }
           yield();
       }
       ...
      }

test1/test2()/test3(): resume interrupted code

  1. 根据提示分析test1失败的原因——handler完成后没有正确地返回。为此实验给出了设计策略是在usertrap中保存因定时器中断的状态,然后在另一个syscallsys_sigreturn中恢复状态。
  2. 状态备份:第一个提示告诉我们,保存恢复状态其实就算保存恢复程序运行时寄存器的值。由于并不知道我们需要用到哪些寄存器,于是选择全部备份好了。struct proc中有一个字段trapframe专门用于保存进程的各种寄存器的值,我们可以通过备份这个指针指向的内存来保存寄存器。

    • struct proc中添加字段saved_trapframe

      struct proc {
          ... // 其他保持不变
          struct trapframe* saved_trapframe; // 添加字段
      }
    • allocproc()中初始化该字段为0

      static struct proc* allocproc(void) {
          ...  // 其他保持不变,在随后添加初始化
          // 初始化alarm相关字段
          p->interval = 0;
          p->handler = 0;
          p->ticks = 0;
          p->saved_trapframe = 0;
          return p;
      }
    • 确定备份指针指向的位置:通过计算(或sizeof)不难发现struct trapframe的大小为288字节,而系统为trapframe字段分配了一个内存页大小4096个字节,完全可以再放一个trapframe。
      因此,在sys_sigalarm()中我设置备份的saved_trapframe指向p->trapframe + sizeof(struct trapframe)的位置。

      uint64 sys_sigalarm(void) {
          struct proc* p = myproc();
          argint(0, &p->interval);
          argaddr(1, &p->handler);
          p->saved_trapframe = p->trapframe + sizeof(struct trapframe);
          return 0;
      }
    • 调用handler之前备份状态(trapframe):

      void usertrap(void) {
          ...
          // give up the CPU if this is a timer interrupt.
          if(which_dev == 2) {
              if (p->interval) {  // 若给定间隔非0
                  ++p->ticks;     // 就记录经过的ticks
                  if (p->ticks == p->interval) { // 若记录ticks到达给定间隔
                      // 调用hanler之前需要保存状态
                      memmove(p->saved_trapframe, p->trapframe, sizeof(struct trapframe));
                      // 将handler地址放入PC寄存器
                      p->trapframe->epc = (uint64)p->handler; 
                      p->ticks = 0;  // 最后将记录ticks置0(有待改进,改进后舍弃这行)
                  }
              }
              yield();
          }
          ...
      }
  3. 状态恢复:在备份状态后还需要考虑hanler完成后状态恢复的问题,但有思路之后这部分就简单多了,在sys_sigreturn()中把之前保存的状态再移动回来就行了

    uint64 sys_sigreturn(void) {
    // 在这恢复调用前状态
        struct proc* p = myproc();
        memmove(p->trapframe, p->saved_trapframe, sizeof(struct trapframe));
        p->ticks = 0; // 滴答计数器置0 (改进后的)
        return 0;  // (待改进)
    }
  4. 防止函数重入:以上两个步骤完了之后test1就可以pass了,但是由于没有防止函数重入,不能pass test2。我最开始想的是再加一个字段is_handling来表示是否正在运行handler(handler未返回),但后续看到网上有大佬利用用于记录的ticks字段也能完成handler是否返回的方法:把原本在usertrap()中置0 ticks的命令放在sys_sigreturn()的最后。思路是,只有sys_sigreturn运行完成(代表hanler返回)才会把ticks置0,否则ticks就只会大于设定的间隔时间。这部分的代码在上边已经进行了标注。然后就能通过test2。
  5. 寄存器a0:但还没有完,这个时候test3会提示:failed: register a0 changed,说明系统调用改变了原本a0中保存的值,因此可确定问题出在sys_sigreturn中。通过gdb调试,发现a0在sys_sigreturn()中的时候还保持正常(0xac),但一旦返回,就会变奇怪。后通过查阅资料发现,原来是之前没学好:在syscall.c中根据a7寄存器保存的序号,然后通过函数指针调用syscall,但是我忽略了通过函数指针调用后会把返回值赋值给a0(源码p->trapframe->a0 = syscalls[num]();)!——因此,为保证a0不改变,sys_sigreturn应当返回a0寄存器中的值,这样就能pass test3了。

    uint64 sys_sigreturn(void) {
        // 在这恢复调用前状态
        struct proc* p = myproc();
        memmove(p->trapframe, p->saved_trapframe, sizeof(struct trapframe));
        p->ticks = 0; // 滴答计数器置0
        // return 0;
        return p->trapframe->a0;  // 返回a0中保存的地址
    }

结果

  • 运行结果
    image.png
  • 测试结果
    image.png

总结

make grade结果

image.png

个人收获

  • 各寄存器的作用,比如a0-a7保存函数参数,s0保存栈帧等
  • 部分汇编代码的含义(比如jalr, mv等),函数是怎么被调用的(内联或者跳转到指定地址执行命令)
  • 大端序、小端序不同模式下相同数据的储存形式
  • 函数的堆栈帧包括函数返回地址、调用者堆栈帧地址、寄存器状态、局部遍历等
  • traps实际上指的是当中断或异常发生时,内核将通过改变寄存器地址使得程序接下来跳转到给定地址去执行命令
  • 进程保存寄存器状态的方式(trapframe)
  • 通过改变epc寄存器中保存的地址,可以改变用户接下来要执行的命令
  • 系统调用返回的uint64会被储存在a0寄存器中
  • 函数重入的概念、以及防止函数重入的方法(有点类似于并发编程需要信号量同步)

Longfar
1 声望3 粉丝