0.briefly speaking
这是MIT 6.S081 Fall 2021课程的第四个实验,它是有关陷阱机制的一系列小问题,如果对陷阱机制仍有疑问,可以参考我之前写的其他3篇博客,它们很好地解释了一些背景知识:
用户态陷阱(以系统调用为例)
内核态陷阱
RISC-V陷阱机制详解
本实验分为以下三个小任务,难度依次递增:
- RISC-V assembly (easy)
- Backtrace (moderate)
- Alarm (hard)
下面我们一个个来研究一下…
1.RISC-V assembly (easy)
这个实验是一个学习性质的实验,设计这个实验本质上是想让我们回顾和熟悉一下RISC-V汇编语言和Calling Convention。这个实验就是阅读一些汇编语言程序并回答一些问题,我们一一来看看吧。
我们的研究的对象是一个叫做call.c的函数,它的内容非常简单,只有两个分别名为f和g的函数以及main函数,全部代码如下所示:
// g函数作用,给传入的值加3
int g(int x) {
return x+3;
}
// f函数就是g函数的直接封装
int f(int x) {
return g(x);
}
// 调用f函数,打印两个值
void main(void) {
printf("%d %d\n", f(8)+1, 13);
exit(0);
}
可以看到,main函数调用了f函数,f函数又是g函数的简单包装。按照实验指导书的说明,我们使用make fs.img指令可以编译这个C代码文件,并可以产生一个可读的汇编语言文件。基于此汇编代码,回答下述问题:
- Q1:哪些寄存器用来存放函数所用的参数?比如在main函数中调用printf时13这个参数是在哪个寄存器中传递进去的?
- A1:我们看看printf这段代码对应的汇编及其注释,如下所示:
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13 # 将13放置到a2寄存器
26: 45b1 li a1,12 # 将12放置到a1寄存器
28: 00000517 auipc a0,0x0 # a0 = pc = 0x28
2c: 7a050513 addi a0,a0,1952 # a0 = a0 + 1952 = 0x28 + 0x7a0 = 0x7c8
30: 00000097 auipc ra,0x0 # ra = pc = 0x30
34: 5f8080e7 jalr 1528(ra) # pc = ra + 1528 = 0x30 + 0x5f8 = 0x628
# ra = pc + 4 = 0x38
我们可以看到13被放置到了a2寄存器中,而表示计算结果的f(8) + 1 = 8 + 3 + 1 = 12则被放置到了a1寄存器中,a0指向了一个地址7c8,可以想见这个地址应该是指向printf函数中第一个输出格式字符串的地址。上述的参数传递是符合RISC-V的calling convention的,事实上在传递整数参数时,如果参数个数少于8个,它们都会被放置在a0-a7中进行传递。
-
Q2:main函数中对f函数调用的汇编代码在哪里?对g函数的调用又在哪里?(提示:编译器可以对函数进行内联)
-
A2:从上述的汇编代码中可以看到,编译器直接将对f函数的调用结果硬编码到了代码中,这样可以极大程度地减少函数调用过程的开销。
-
Q3:printf函数的地址是什么?
-
A3:从上述代码中可以看出,printf函数的地址就是最后一行汇编执行完成之后的PC的值,为0x628。
-
Q4:在main函数中执行完跳转到printf函数的jalr指令之后,ra寄存器的值是什么?
-
A4:上面我们已经计算出来了,ra寄存器的值应该是0x38,我们可以借这个机会熟悉一下GDB的用法,调试一下call.c这个用户程序,看看我们计算结果是否正确,调试的步骤如下,这也是在Xv6中调试用户态程序的基本方法,建议熟练掌握:
打开一个命令行终端,输入:make CPUS=1 qemu-gdb,打开gdb-server等待调试器连接。
打开另外一个命令行终端,输入调试器命令:gdb-multiarch(当然也可以是其他的调试器),如下所示:
然后在调试器一端,将我们要调试的用户态文件加载进来file user/_call,并在jalr指令处(虚拟地址是0x34)打一个断点,如下所示:
接下来,我们使用continue指令继续内核的启动过程,注意因为调试的是用户态程序call,只有在进入操作系统之后执行这个程序才可以出发上述的断点。我们输入continue,切换到GDB服务器端,可以看到内核已经打印出了一些信息,并且GDB显示已经触发了断点:
千万注意,这时候触发的断点并不是call.c这个程序中的,而是在启动内核的某个瞬间PC恰好等于了0x34这个地址值,GDB就触发了这个断点。GDB笨笨的,它只会检查当前PC值是否等于你设定的断点位置,但它并不管这是哪段程序中的。我们可以使用disas指令看看当前上下文的汇编代码:
可以看到当前执行的汇编代码和call.c中的printf的反汇编完全不一致,所以现在触发的这个所谓断点并不是位于call.c中的,我们直接用continue指令跳过这个断点,于是内核这时完全启动成功了,这时我们在终端中执行一下call程序,可以看到它被断点阻塞住了:
回到GDB一端,可以看到再次触发了我们的断点,这次是真的进入到我们的程序中了,用disas查看一下反汇编,发现和call.asm中的代码完全一致:
这次是真的阻塞在了我们期待的地方,于是我们输入stepi指令,让其向前执行一条汇编,此时控制流将转向printf函数,同时ra寄存器会完成设置:
我们看看当前ra寄存器的值即可,使用info registers ra指令,它的值是0x38,和我们计算出的值是一致的,猜想正确。
跑了个很大的题,不过我认为它是值得的,哈哈,让我们重回正轨。
-
Q5<: 执行下面的代码会输出什么?
输出基于这样一个事实,RISC-V是一个基于小端的(little-endian),如果RISC-V是基于大端的那么应该如何修改i的值来产生同样的输出?你需要将57616修改为不同的值吗? -
A5:首先将这段代码写入call.c,编译运行一下,结果如下:
输出了一个"He110 World",其中e110是57616的16进制表示,而rld是无符号整数i中每个字节的对应字符。如果现在RISC-V存储改为大端,i的值就应该初始化为0x00726c46了,而57616无需修改,因为大小端存放并不会改变它转化为16进制数之后的结果。 -
Q6:在下面的代码中,y= 之后会打印出什么?(此值不确定),这是为什么?
-
A6:我们将call.c文件中的main函数替换为上述代码,编译执行,结果如下:
首先上述行为是没有定义的行为(Undefined Behavior),因为printf中传入的参数数量少于格式化字符串中要求的数量,我不打算在此详细介绍前因后果(涉及到va_list等等,而va_list其实是一个指针,指向一块连续的内存区域),写出来篇幅会很长。我使用GDB调试了上述程序,发现5309其实是紧跟在3之后的一块未初始化的内存数据,这就是问题的答案。
2.Backtrace (moderate)
这个实验让我们实现一个backtrace函数,可以打印函数调用栈,从而方便我们进行debug。这个小任务其实不难,最重要的一个前备知识是了解并熟悉Xv6中的函数调用栈帧结构(stack frame),知道了各个寄存器的相对存放位置,这个任务就十分简单了。
首先按照实验指导书的要求,将一个读取fp寄存器的嵌入式汇编定义到riscv.h文件中:
/* read frame pointer*/
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}
下面简单绘制一下Xv6内核中的栈帧结构(stack frame),这对后面的代码实现非常重要。当我们在使用Frame Pointer时,这个寄存器其实指向的是上一级栈帧的最后一个位置,这是因为RISC-V中栈指针(stack pointer)是遵循满递减原则的。那么FP-8一定存放的是上一级函数的返回地址ra,FP-16这个地址一定存放的是上一级栈帧指针Previous FP,这是我们实现回溯的重要条件。
知道了栈帧的基本结构之后,我们会发现这样的多层嵌套调用的函数的栈帧本质上构成了一个链表,链表的入口就是当前存放在FP寄存器中的值,链表的next指针就是Previous FP指针(FP-16)。最后就是回溯的终点,我们知道链表的终点一般是一个空指针(nullptr),这里也是一样的,RISC-V abi中明确规定了FP链表的最后一个Previous FP位置的值为0(The end of the frame record chain is indicated by the address zero appearing as the next link in the chain)。但我们在实验中没有这么做,Xv6的内核实现中整个栈只有一页(4K)大小,所以我们完全可以通过地址值去检测是否回溯到了最后一个栈帧。
最后,给出完整的代码实现:
/* backtrace : print the call stack of function */
void backtrace()
{
// 读出当前FP指针的值,使用上面添加的嵌入式汇编函数
// 根据FP指针的值计算出栈底位置,这将作为循环结束的条件
uint64 FramePointer = r_fp();
uint64 KernelTop = PGROUNDDOWN(FramePointer) + PGSIZE;
printf("backtrace:\n");
// 如果没有到最后一级,则持续向前一级回溯
while(FramePointer < KernelTop)
{
// 从FP-8这个位置取出上一级函数返回地址,打印出来
uint64 ReturnAddr = *(uint64*)(FramePointer - 8);
printf("%p\n", ReturnAddr);
// 回溯到上一层函数栈帧
FramePointer = *(uint64*)(FramePointer - 16);
}
return;
}
我们将这个函数加入到sys_sleep函数的实现中,测试一下:
uint64
sys_sleep(void)
{
int n;
uint ticks0;
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);
backtrace(); // test backtrace here
return 0;
}
启动内核,执行bttest,结果如下:
再使用虚拟地址到代码行数的映射addr2line转换一下,结果如下:
结果是正确的,它们的确指向对应函数的返回地址,并且也可以通过测试程序。现在我们多了一个debug的利器,我们按照指导书上的提示,将其加入panic函数,以备未来debug时使用:)。最后我们执行一下测试程序来判断一下程序的正确性,结果如下:
最后提一下,使用所谓的地址去检测是否回溯到栈顶的做法是非常危险的。我使用GDB去调试了上述程序,发现在跳出循环时FP指针正好和栈底的地址保持一致(故代码实现中地址比较必须是<号,<=号就会酿成大祸),而此时Previous FP是一个极小的值:0x2fe0,这个值是在用户陷入内核态执行系统调用时残存在FP寄存器中的值,它指向一个很小的地址,这个地址本质上指向了一个原先用户态的虚拟地址。如果循此地址继续下去,将会陷入一个死循环中,这是本实验外的一些注意点。
3.Alarm (hard)
这是本次实验的最后一个小任务,也有一些难度。这个实验的目标是让我们实现两个系统调用sigalarm和sigreturn,实现一个进程执行时定时打断转去执行其他函数之后返回的功能。在实验指导书上有较为详尽的说明,它分为了两个部分,分别实现sigalarm和sigreturn。因为这是一个完整的故事,我并不打算将它拆成两个部分来讲,因为这样会破坏故事的完整性。我将尝试一以贯之地把整个调用过程描述出来:)
在开始本任务的代码实现之前,你可能需要回顾一下如何添加一个系统调用,这在我们之前的博客中已经有了总结,详见6.S081——Lab2——system calls中的Briefly Speaking部分,它总结了添加一个系统调用的所有步骤,我们在下面不再对这些步骤做一一描述,而只关注问题本身的逻辑。好的,我们接下来就开始了!
我想首先给出一张完整的概览图,阐述这个任务完整的调用轨迹,如下所示,其实这张图将我们要做的事情都总结的差不多了,现在只需要将其中的细节用代码实现出来即可,在下面的讲解中我会用图中的数字来索引对应的动作,所以这张图是非常有意义的:
第一个小目标是实现系统调用sigalarm,这个系统调用可以设置一个进程被时钟打断的间隔(Inteval)和响应函数(handler),一经设定,这个进程就会以Inteval的时间间隔调用处理函数handler,从而进入上图的完整调用流程。因为时间间隔和调用函数handler都是与进程状态强相关的,所以我们将其保存在struct proc中,经过修改之后的proc结构体如下(新添加的域都以尖括号的形式注释):
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct trapframe alarmframe; // <在发生sigalarm调用时,用于保存之前的trapframe>
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
int AlarmInteval; // <触发alarm handler的时间间隔>
int Counter; // <计时器,记录从上次触发handler到现在经过的ticks数量>
char InHandler; // <标志位,表示当前进程是否处于响应alarm的流程中>
uint64 Handler; // <handler在用户态的虚拟地址>
};
所以其实sigalarm在内核的实现sys_sigalarm非常简单,只是将用户传进来的参数记录到进程结构体中即可,所以它的实现如下所示:
// sigalarm对应的内核代码实现,只需要将用户传入的参数
uint64
sys_sigalarm(void)
{
if(argint(0, &myproc()->AlarmInteval) < 0)
return -1;
if(argaddr(1, &myproc()->Handler) < 0)
return -1;
return 0;
}
在内核中实现了sigalarm系统调用之后,用户可能会在它的某个程序执行期间调用sigalarm系统调用,从而完成中断间隔时间和中断处理函数在内核的注册。一经注册,内核就开始对时间间隔进行统计,并开始周期性地调用这个中断处理函数了。
如同图中的①所示,一个进程在执行时,定时器可能会在其中某个不确定的时刻触发一个时钟中断。这时候将会进入系统调用的相应流程,首先经由trampoline进入usertrap函数,也就是上图中的②,在这里我们将对这个时钟中断进行处理,并适时地进行alarm响应过程,首先给出代码和注释:
void
usertrap(void)
{
// 以上代码从略...
if(p->killed)
exit(-1);
// 如果是时钟中断,且当前开启了alarm功能并且没有处于其他响应过程中,则开始处理中断
// p->AlarmInteval == 0表示我们当前关闭了alarm,因此不要响应
// p->InHandler == 0表示当前进程没有在处理alarm流程中,可以进行下一次响应
if(which_dev == 2 && p->AlarmInteval != 0 && p->InHandler == 0)
{
// 记录当前已经经过的ticks总数,计数器加一
p->Counter ++;
// 如果到了应该触发handler的间隔时间
if(p->Counter == p->AlarmInteval)
{
// 首先将trapframe完整保存在proc的alarmframe中
// 这是为了以后可以不受影响地回到原有进程中执行
// 出于便利,我选择直接将trapframe中的所有信息保存下来
memmove(&p->alarmframe, p->trapframe,
sizeof(struct trapframe));
// 修改trapframe中的epc,使得陷阱将会返回到用户态下的handler函数中
p->trapframe->epc = p->Handler;
// 设置标志位,表明当前进程正处于alarm的处理流程中,不再响应其他alarm
p->InHandler = 1;
}
}
// 以下代码从略...
}
所以这就是我对usertrap的修改逻辑,这里做的事情就是记录经过的时间,如果时间达到了我们设定的间隔,则触发alarm响应过程。首先将trapframe完全保存,然后设置trapframe中的epc寄存器的值设置为handler的虚拟地址,使其可以经由usertrapret返回到用户态下的handler函数中去。别忘了还要设置标志位InHandler,这可以避免重入(re-enter)handler而造成的错误。
经过我们的设置,现在回到了上图中的③,在这里会执行一些用户定义的动作,并在最后执行sigreturn系统调用,于是操作系统再次陷入内核态,经过usertrap的转发(上图中的④)之后进入内核的sys_sigreturn系统调用,sys_sigreturn的实现非常简单,只是简单的恢复现场并重置计数器,如上图中的⑤所示,给出我的代码实现和注释:
uint64
sys_sigreturn(void)
{
struct proc* p = myproc();
// 恢复现场并将计数器和标志重置
memmove(p->trapframe, &p->alarmframe, sizeof(struct trapframe));
p->Counter = 0;
p->InHandler = 0;
return 0;
}
在这里我们恢复了最早程序在执行用户态程序时的现场,并将计时器和标志全部初始化,这样进程就可以成果响应下一次alarm了:),接下来经过usertrapret的恢复,现在进程又回到了最早的程序中,且现场没有受到任何影响,如图中的⑥所示。
这就是整个alarm任务的完整实现思路和解释,其实看起来也并不复杂:)
我执行了alarmtest来测试实现的正确性,结果如下,说明实现没有问题:
usertests我这里就不再展示结果了,它测试的是你加入的代码是否对内核完整性造成了损失,测试的时间比较长,我测试过程序没有问题。这里只给出最后的测试程序报告,没有问题:
本实验至此终,哈哈哈!