实验:Lab: System traps

实验开始之前需要将git分支切换到traps分支不然有些文件你是没有的

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

RISC-V assembly (easy)

这个实验主要目的是帮助我们能够认识汇编代码,通过gdb调试user/call.c文件,回答相应问题。

GDB指令

下述的指令格式表述为:全称/简写,下列展示的是实验中普遍使用的指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1.break/b (*地址)/函数名 #设置断点,指令地址可以查看asm文件
#除此之外通常可以使用b *$stvec,在trampoline处设置断点

2.continue/c #运行到断点处然后停下

3.stepi/si #运行单个汇编指令,进入函数

4.n #运行一行c语言代码,不进入函数

5.step/s #运行一行C语言代码,进入函数

6.info/i reg/r #查看32个同一寄存器和PC寄存器的值

7.info/i break/b #查看断点信息

8.info/i frame #打印当前栈帧

9.print/p (/x) 变量/$寄存器 #打印C语言变量值或寄存器的值,/x是将值为打印16进制的值

10.examining/x 地址 #检测内存单元并打印
#可以使用/x十六进制 /i指令 /c字符格式化打印值
#而且可以使用/4c打印4个字节单元并输出为字符
11.list 地址 #打印函数的源代码在指定的位置。

12.layout asm/src/split #分别打开汇编代码窗口、源码窗口、split打开汇编和源码窗口
#ctrl x + a 可以关闭layout窗口

调试call程序

  1. 打开两个终端窗口T1、T2。现在T1运行make qemu-gdb,然后T2在gdb-multiarch(ubuntu20.4)。

  2. 在T2中更改调试程序为user/_call(在gdbinit中指定的调试文件为kernel/kenrel)

  3. 在main函数处设置断点,使用c/continue运行程序

traps1.png

  1. 在T1窗口中输入call,运行call程序。在T2窗口中使用c运行程序到断点,然后使用layout split打开源码和汇编代码的窗口。

traps2.png

  1. 使用si一条一条地运行汇编指令。然后使用p $a1p $a2打印寄存器。我们会发现这两个寄存器保存的是printf函数后两个参数的值。

trap3.png

  1. 那么a0应该就是保存着printf格式字符串的地址,于是我们运行过0x30的指令后,使用x /5c $a0(x = examine)这个gdb指令检测内存值。使用该命令后我们也能够看见每个字符的ASCII码值以及对应的字符。拼接起来也就是”%d %d”

  2. 查看ra寄存器,使用info reg ra(简写为i r ra,ra为寄存器名称保存函数返回后执行的下一条指令的地址),同样可以使用p /x $ra打印。

traps4.png

简述auipc、jalr指令

这两个指令主要是用于函数跳转,一般是一起使用,用于较远指令的地址的跳转。

这样设计的主要原因是RISC-V指令是32位的,且jal指令跳转的立即数也只有20,那么也就是说我们的函数跳转不能超出$2^{20}$,但是寄存器是64位的我们保存的地址却能够高达$2^{64}$。于是我们要设计长地址的跳转。在计算机组成与设计(RISC-V)中有所介绍。

  • 指令格式:auipc rd immediate,将pc的值+立即数左移12位后的值赋给rd(目标寄存器)

例如:auipc ra 0x00,也就是将pc的值赋给ra。

  • 指令格式:jalr immediate(rs),将pc+4后复制给ra寄存器,再将immediate(12位)+rs寄存器的值并赋值给pc,这样就跳转到了指定地址。这里的立即数就是12位,那么也刚好对应了auipc将立即数左移12位留下的空缺了。

例如:jalr 1562(ra),也就是将ra的值+1562(这是十进制数)跳转到指定函数,此时ra的值为该当前pc+4,也就是jalr指令的下一条指令的地址。

实验提问

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

那几个寄存器保存了函数的参数? a0:格式字符串 、a1:12 、a2:13。

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

在该函数中我们并没有调用f、g这两个函数,因为这两个被编译器优化为了内联函数,在编译期就计算得出了值。

  1. At what address is the function printf located?

可以查看call.asm中printf函数的入口地址,也可以通过我们上述jalr指令的计算:0x30(ra原有的值)+0x61a(1562的16进制值) = 0x64a,这就是printf函数的入口了。

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

在上述的分析后和gdb调试过后也可以得知,在运行jalr指令进入printf函数后,ra寄存器的值被设置为了jalr后一条指令的地址了。

Run the following code.

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

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?

添加代码到call中并运行,可以得到下述结果。E110是57616转换获得的值,大端还是小端读取并不会影响这个值的顺序。

ASCII是两个字节也就是占8位,刚好两个16进制数表示。

由于RISC-V中使用小段序读取,因此我们是先读取72这个ASCII码值为r,然后则是6c:l,最后就是64:d。如果改为大端序读取就是反过来先读取d然后是l、r,输出为Wodlr。

trap5.png

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

trap6.png

看上图我们可以得知,$a2寄存器的值为1(在最开始的调试中,理解到了printf的第三个参数是a0的值)并未被清空,于是在输出中就有了y=1的结果。

Backtrace (moderate)

简介

看过实验指导可以跳过简介部分。

目的

简单来说就是实现一个类似于gdb中的backtrace命令的函数,对于调试来说,反向跟踪通常是有用的:在错误发生点上方的栈上的函数调用列表。为了帮助进行回溯,编译器生成机器码,这些机器码在与当前调用链中的每个函数对应的栈上维护一个栈帧。每个栈帧由返回地址和一个指向调用方栈帧的“帧指针”组成。寄存器0包含一个指向当前栈帧的指针(它实际上指向栈上保存的返回地址的地址加上8)。你的回溯应该使用帧指针来遍历栈,并在每个栈帧中打印保存的返回地址。

任务

在kernel/printf.c中实现一个backtrace()函数。在sys_sleep中对该函数进行调用,然后运行bttest该程序调用了sys_sleep。您的输出应该是以下形式的返回地址列表(但数字可能会有所不同):

backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898

在bbtest完成后退出qemu。在终端窗口中:运行addr2line -e kernel/kernel(或riscv64-unknown-elf-addr2line -e kernel/kernel)并从你的反向跟踪中剪切和粘贴地址,如下所示:

$ addr2line -e kernel/kernel
0x0000000080002de2
0x0000000080002f4a
0x0000000080002bfc
Ctrl-D

addr2line的输出为:(简单来说就是将ra寄存器的返回的地址值转换为c语言文件中的行,输出的格式我们就可以知道函数调用的顺序:trap->syscall->sysproc)

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

HINS:

  • 在kernel/def.h文件中添加backtrace的定义,让sys_sleep函数可以调用该函数

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

并在backtrce()中调用此函数以读取当前帧指针。r_fp()使用内联汇编来读取s0。

  • 这堂课的讲义中有栈框架布局的图片。注意,返回地址位于堆栈帧的帧指针的固定偏移量(-8)处,保存的帧指针位于帧指针的固定偏移量(-16)处。

  • backtrace()将需要一种方法来识别它已经看到了最后一个堆栈帧,并且应该停止。一个有用的方法是,为每个内核栈分配的内存由单个与页面对齐的页面组成,因此给定栈的所有栈帧都在同一页面上。可以使用PGROUNDDOWN(fp)(参见kernel/riscv.h)来标识帧指针所指向的页面。

一旦你的backtrace开始工作,在kernel/printf.c中的panic中调用它,当内核奔溃时,这样你就可以看到内核的回溯。

实现

添加提示中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
----riscv.h
//read fp regsiter
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}

----def.h
// printf.c
void backtrace(void);

----sysproc.h
uint64
sys_sleep(void)
{
uint ticks0;

backtrace();

argint(0, &n);
...
}

RISC-V中的栈内存结构:

stack.png

如上图和之前的简介中我们可以得知,FP寄存器的值是地址值,那么FP-8就是返回地址的值,FP-16就是上一个栈指针的地址值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---printf.c---
void
backtrace(void){
printf("barcktrace:\n");

uint64 ra,fp = r_fp();//frame pointer -> address
uint64 pre_fp = *((uint64*)(fp - 16));

while(PGROUNDDOWN(fp)==PGROUNDDOWN(pre_fp)){
ra = *(uint64 *)(fp - 8);
printf("%p\n",ra);
fp = pre_fp;
pre_fp = *((uint64*)(fp - 16));
}

ra = *(uint64 *)(fp - 8);
printf("%p\n",ra);
}
  1. 使用r_fp函数获取fp指针的地址值,那么前一个fp指针的地址为*((uint64*)(fp - 16)),这里主要是将整数值转换为指针并解引用也就是取该地址的值。(ra的值也是这样取出)

  2. 使用PGROUNDDOWN函数判断pre_fp与fp的是否位于同一页中,如果相同则位于同一页,那么就可以取出ra的值并打印,使用%p符号。

  3. 如果不相同则退出,此时pre_fp是在另一页中,但是fp仍处于该页中,仍需要将该栈帧的ra值取出。

结果

运行xv6,调用bttest程序,那么就会打印栈帧中ra的值,这都是一些地址值。

退出qemu,并使用addr2line程序,对地址进行转换就可以获得返回地址对应文件的行了。

Alarm (hard)

简介

由于实验简介过长,请自行观看官方文档。

本次实验主要是实现两个系统调用sigalram(int,void(*)())sigreturn()。通过这两个系统实现alarm功能,监测用户程序使用CPU的时长,从而对用户程序发出提醒。

  • sigalarm:第一个参数是设置时钟中断个数当达到个数时调用指定函数,第二个参数时设置alarm的处理函数。
  • sigreturn:在alarm处理函数中执行完所以代码后调用,主要功能是恢复中断前的状态

监测时间主要是以时钟中断为单位(进程调度的基础),如果时钟中断为50ms,那么每50ms进行一次中断,那么通过统计用户进程进行了几次中断,就可以获知用户进程到达了警报的CPU使用时长,从而给出提醒。

实现

准备

1.makeflie添加alarmtest用户程序

1
2
3
UPROGS=\
...
$U/_alarmtest\

2.kernel:添加系统调用声明

1
2
3
4
5
6
7
8
9
10
11
12
13
----syscall.c
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);

static uint64 (*syscalls[])(void) = {
...
[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn,
}

----syscall.h
#define SYS_sigalarm 22
#define SYS_sigreturn 23

3.user:添加系统调用入口与函数定义

1
2
3
----user.h
int sigalarm(int, void (*)());
int sigreturn(void);
1
2
3
----usys.pl
entry("sigalarm");
entry("sigreturn");

test0

过程

  1. 调用sigalarm系统调用,该系统调用用于设置proc的alarm字段

  2. 进入test0的循环结构,不断调用write系统调用。

  3. 在调用write的间隙,内核产生时钟中断。

  4. 用户程序中断:从trap进入内核,时钟中断数量到达触发alarm的数量,设置frame中的epc寄存器值为alarm处理函数的入口地址。

因为此时epc是发生中断时用户程序的指令地址(像是系统调用一样被赋值),将其修改为处理函数的入口便能够在从trap返回时,直接执行alarm处理函数

  1. 中断返回:从trap中返回后,执行alarm处理函数,调用sigreturn系统调用(这里并没什么用)。

  2. 调用sigalarm(0,0),关闭alarm。

Q:有一个问题则是,在触发alarm条件后将epc设置为alarm处理函数入口,但是执行完alarm处理函数后,为什么会返回到用户程序发生中断的地址(ra寄存器)

代码

  1. 在proc.h进程结构体中添加结构体字段
    • interval:触发alarm的时钟中断数量
    • ticks:需要统计的时钟中断的数量
    • handler:记录处理alarm的函数的地址
1
2
3
4
5
6
7
8
----proc.h
struct proc{
...
int interval;
int ticks;
uint64 handler;
...
}

2.在sysproc中添加系统调用函数的实现

  • sys_sigaram:初始化proc有关alarm字段

  • sys_sigreturn:这时只返回0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    uint64
sys_sigalarm(void){
int interval;
uint64 f_addr;
struct proc *p = myproc();


argint(0,&interval);
argaddr(1,&f_addr);

p->interval = interval;
p->handler = f_addr;
p->ticks = 0;

return 0;
}

uint64
sys_sigreturn(void){
return 0;
}

3.在usertrap函数中的时间中断处处理alarm,判断设备中断为时钟中断则增加ticks字段,当时钟中断次数到达指定数量时,就将内存中epc的寄存器值改变为alarm处理函数的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
void
usertrap(){
...
if(which_dev == 2){
p->ticks++;
if(p->ticks == p->interval && 0 < p->interval){
p->trapframe->epc = p->handler;
}else{
yield();
}
}
...
}

A:完成test0,回答一下前面的问题,为什么我们能够返回用户发生中断的地址。

看下图中,达到alarm条件时,这时是由于中断从trap进入内核处理,于是查看栈帧中ra寄存器的值,将ra的值对比alarmtest的文件中,发现这个地址刚好对应了if(count>0)的地址,也就是中断发生的地址。

那么这样的话在中断产生时,就将返回地址的值写入了ra=0x14a,epc=0x154。这样就算从trap返回到alarm处理函数中,ret指令也会将ra写入pc,使得alarm处理函数能够返回test0。

NOTE中断位置的pcra地址不同也会导致中断前的状态被复写。因此需要在sigreturn调用中恢复中断前的epc的,让alarm处理函数不需要正常返回。

trap7.png

test1/2/3

alarm处理函数完成后,控制权返回到用户被时间中断的指令处

在之前的实现中可以完成了基本的alarm程序,但是现在会有一个问题:中断前的状态怎样恢复?

在test0后面也说到了ra与epc地址值不同,会导致一些寄存器被复写。(如果函数调用栈没有保存中断前使用的寄存器也会导致这种情况)

这个问题也困惑了我很久,为什么需要恢复中断前的状态?我们再在脑海中构建一下中断后返回到alarm处理程序的过程。接着以test1为例简述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void
test1()
{
...
sigalarm(2, periodic);
for(i = 0; i < 500000000; i++){
if(count >= 10)
break;
foo(i, &j);
}
sigalarm(0, periodic);
...
}

void __attribute__ ((noinline)) foo(int i, int *j) {
if((i % 2500000) == 0) {
write(2, ".", 1);
}
*j += 1;
}
  1. 使用sigalarm系统调用,通过trap进入内核并设置proc结构体有关alarm字段,同时时钟中断也在进行。

  2. 在test1循环中调用foo函数,只有调用10次alarm处理程序后才能退出循环,并再次调用sigalarm关闭alarm。(foo程序只是将j值加一)

  3. 发生时钟中断:当发生中断时,用户程序执行periodic程序,并返回中断处。

  4. 返回中断处执行foo函数,之后判断i与j的值是否相同,按照直觉来说肯定是相同的,但实际上会发生错误,因为寄存器会被复写

什么时候会中断?

  1. 在for循环执行时被中断

  2. 在调用foo程序时候中断

  3. 在调用periodic时中断(几乎不可能,两次时钟中断肯定会执行完这部分代码)

以foo函数调用时中断为例分析:为什么需要恢复中断前的状态

如果仅仅以test0的代码运行test1会导致,内核直接杀死进程,给出的错误是缺页错误,也就是访问了错误地址。

在test0最后也说到在触发中断alarm时,需要将epc修改为handler的地址,同时也要记录当前的epc,以便能够在调用periodic函数之后调用sigreturn恢复中断处的epc并返回。

那么在foo函数的汇编代码如下

1
2
3
4
5
6
7
8
void __attribute__ ((noinline)) foo(int i, int *j) {
1ae: 1101 addi sp,sp,-32
1b0: ec06 sd ra,24(sp)
1b2: e822 sd s0,16(sp)
1b4: e426 sd s1,8(sp)
1b6: 1000 addi s0,sp,32
1b8: 84ae mv s1,a1
....

在上述代码中我们修改的sp、s0(fp)、s1寄存器,在此之后中断调用periodic函数(设置sp、s0、ra寄存器),在调用sigreturn函数后并没有恢复sp寄存器,而是直接返回foo函数,那么此时sp、s0的寄存器的值肯定是不同的,也就会导致缺页错误了。

修复缺页错误

在文档的提示中告诉我们需要恢复很多寄存器的值,那么我们就直接在proc结构体中添加pre_trapframe字段保存中断前的寄存器的值。

注意我们还需要分配物理页给这个中断帧(因为这个pre_trapframe内存占用十分大,proc中剩余的物理页内存不足以放下这个结构体)、也需要在进程结束时释放这个物理页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
----proc.h
struct proc{
struct trapframe *pre_trapframe;
}
----proc.c
static struct proc*
allocproc(void)
{
....
if((p->pre_trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
....
}
static void
freeproc(struct proc *p)
{
....
if(p->pre_trapframe)
kfree((void*)p->pre_trapframe);
p->pre_trapframe = 0;
....
}

在触发alarm的中断条件时,那么将ra、sp、s0、epc寄存器的值保存。并在完成alarm处理函数后,进入sigreturn系统调用恢复保存的寄存器。如下所示,我们再次运行代码那么就不会出现缺页错误了(这时的问题就是有i与j不同的错误了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
----trap.c
void
usertrap(){
...
if(which_dev == 2){
p->ticks++;
if(p->ticks == p->interval && 0 < p->interval){
p->pre_trapframe->ra = p->trapframe->ra;
p->pre_trapframe->sp = p->trapframe->sp;
p->pre_trapframe->s0 = p->trapframe->s0;
p->pre_trapframe->epc = p->trapframe->epc;
p->trapframe->epc = p->handler;
}else{
yield();
}
}
...
}
----sysproc.c
uint64
sys_sigreturn(void){
struct proc* p = myproc();
p->ticks = 0;
p->trapframe->ra =p->pre_trapframe->ra;
p->trapframe->sp =p->pre_trapframe->sp;
p->trapframe->epc =p->pre_trapframe->epc;
p->trapframe->s0 = p->pre_trapframe->s0 ;
return 0;
}

查看alarmtest汇编代码,修复i、j值不同的问题

  1. 在test1函数查看得知j的值保存于s0-52的地址处(使用gdb的examine命令:x /u $s0-52可以查看内存的值),i的值则保存于s1寄存器中。a0、a1的寄存器会保存调用foo函数的i、j参数值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    00000000000001ee <test1>:
    {
    ....
    j = 0;
    #在s0寄存器-52的地址写入j的值:s0-52 = 0x3f8c
    216: fc042623 sw zero,-52(s0)
    for(i = 0; i < 500000000; i++){
    #s1保存i的值
    22c: 4481 li s1,0
    ....
    foo(i, &j);
    #a0保存了第一个参数i(s1)、a1保存第二个参数j(s0-52)的地址
    24a: fcc40593 addi a1,s0,-52
    24e: 8526 mv a0,s1
    250: 00000097 auipc ra,0x0
    254: f5e080e7 jalr -162(ra) # 1ae <foo>
  2. 查看foo函数,在该代码中可以发现a5、a0、s1,参与了i、j的运算

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    00000000000001ae <foo>:
    void __attribute__ ((noinline)) foo(int i, int *j) {
    ....
    if((i % 2500000) == 0) {
    1ba: 002627b7 lui a5,0x262
    1be: 5a07879b addiw a5,a5,1440
    1c2: 02f5653b remw a0,a0,a5
    1c6: c909 beqz a0,1d8 <foo+0x2a>
    *j += 1;
    1c8: 409c lw a5,0(s1)
    1ca: 2785 addiw a5,a5,1
    1cc: c09c sw a5,0(s1)
    ....

    通过上述分析便可以得知,我们需要恢复a0、a1、a5、s1的值。这时再像之前一样保存和恢复这些寄存器的值即可。

完善中断恢复机制

在之前中我们使用了太多的C代码去保存/恢复寄存器的值。有两个弊端:

  1. 代码并不简介
  2. 当用户程序中使用更多的寄存器时仍然会出现恢复中断现场失败的问题

于是我们直接选择保存,恢复整个陷阱帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
----trap.c
void
usertrap(){
...
if(which_dev == 2){
p->ticks++;
if(p->ticks == p->interval && 0 < p->interval){
*p->pre_trapframe = *p->trapframe;
p->trapframe->epc = p->handler;
}else{
yield();
}
}
...
}
----sysproc.c
uint64
sys_sigreturn(void){
struct proc* p = myproc();
*p->trapframe = *p->pre_trapframe;
p->ticks = 0;
return p->pre_trapframe->a0;
}

在test0中说到简单的讲sigreturn的值设置为0即可,但这个0值是无用的,在test3中需要让我们也恢复a0寄存器的值,因为函数调用的值会保存在a0寄存器中,所以sigreturn的返回值设置为了p->pre_trapframe->a0。

总结

本次实验算是做这课以来算是我分析时长最长的实验了,特别是alarm实验对其中中断恢复的理解以及gdb的调试也花了十分多的时间,但是理解之后也确实对trap的知识点有了更加深厚的理解了。

结果

traps result.png