文章目录
- 前言
- 其他篇章
- 参考链接
- 0. 环境搭建
- 1. RISC-V assembly (easy)
- 1.0 简介
- 1.1 Q1
- 1.2 Q2
- 1.3 Q3
- 1.4 Q4
- 1.5 Q5
- 1.6 Q6
- 2. Backtrace (moderate)
- 2.1 简单分析
- 2.2 实现
- 2.3 测试
- 3. Alarm (hard)
- 3.1 简单分析
- 3.2 test0: invoke handler
- 3.2.1 添加调用
- 3.2.2 获取参数
- 3.2.3 处理中断
- 3.2.4 测试
- 3.3 test1/test2()/test3(): resume interrupted code
- 3.3.1 恢复上下文与防止多次调用
- 3.3.2 恢复寄存器a0的值
- 3.3.3 测试
- 3.4 测试
- 4. 最后测试
前言
这个Lab我觉得比较难,主要是涉及到底层、汇编,这方面我确实接触得少。
其他篇章
环境搭建
Lab1: Utilities
Lab2: System calls
Lab3: Page tables
Lab4: Traps
参考链接
官网链接
xv6手册链接,这个挺重要的,建议做lab之前最好读一读。
xv6手册中文版,这是几位先辈们的辛勤奉献来的呀!再习惯英文文档阅读我还是更喜欢中文一点,开源无敌!
risc-v 指令集
个人代码仓库
官方文档
0. 环境搭建
和Lab3步骤一致,不多解释。**记得make clean!!!**上个lab因为没弄这个卡了一个多小时,难绷。
1. RISC-V assembly (easy)
1.0 简介
这个Task是一个热身Task,带你熟悉一下RISC-V汇编的,它告诉我们执行make fs.img
会生成一个user/call.c
的汇编程序user/call.asm
,我们来看看吧:
make fs.img && vi user/call.asm
顺便看一下原型代码,很基础(碎碎念:void main
不规范啊!):
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"int g(int x) {return x+3;
}int f(int x) {return g(x);
}void main(void) {printf("%d %d\n", f(8)+1, 13);exit(0);
}
然后题目叫把答案存在answers-traps.txt
下,下面来看看这几个问题:
1.1 Q1
Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?
我们来看一看printf
的调用,可以看到,printf
的三个参数分别被压入了a0 - a2寄存器,具体到13这个数,是被保存在了a2里。
另外,这堂课的Lecture中给出了RISC-V各个寄存器的作用,可以看到,a0 - a7寄存器是用于保存函数的参数的,其中头两个寄存器还用于保存函数返回值。
1.2 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.)
我们还是来看刚刚的printf
,可以看到,我们虽然本身调用了f(8)+1
,但是汇编代码并没有展示给我们8 + 3 + 1
的过程,也没有调用函数时的各种压栈。
结合题目的提示以及我们的常识,我们可以知道这个地方直接被编译器优化了,优化后内联展开了f和g的调用,无疑节省了开销,因此,没有调用函数f和g的汇编代码。
1.3 Q3
At what address is the function printf located?
还是那段代码,这里写的就是printf的地址,这是十六进制的,因此是0x642
不过我们来看一看这个0x642是怎么算出来的:
查阅文档我们可以看见,auiqc
指令是将传入的立即数左移12位后加上当前的pc存入ra,因此49行程序执行后ra的值为0x30,然后看一看jalr
指令的文档:
可以看到,这个指令的作用就是跳到指定地址去,在这里就是跳到0x30 + 1554 = 0x30 + 0x612 = 0x642。
1.4 Q4
What value is in the register ra just after the jalr to printf in main?
从刚刚发的文档就可以看见,jalr
会把PC指针往下偏一个,也就是0x34 + 4 = 0x38,因此这时ra中的值为0x38。
1.5 Q5
这个问题叫我们运行一下这两行代码,问运行结果,结果是HE110 World
,是不是很神奇?必须值得指出的一点是,以%s
打印unsigned *
的行为是未定义的!不过这里也可以分析一下它的成因,其中57616转化为16进制即为0xE110,因此前面没什么好说的,而i
的值如果每两个16进制位转化为一个char的话就会变为'\0'dlr
,同时注意到题目给出了一个提示,告诉我们RISC-V是小端序的,即从低位开始看,因此就打出来了rld
,题目还问如果是大端序机器i
应该输入多少,那么只需要反转一下就行了,也就是0x726c6400,而57616的值当然不需要改变,因为这与端序无关。
1.6 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);
依旧需要指出的一点是,printf
中后续传入的参数少于format
(就是第一个参数)中所需要的数量的行为是未定义的!不过我们依旧在这里研究一下编译器行为学。
实际结果应该是个不确定的值,Q1的时候我们就看见了,对于printf传参,会把入参依此压入从a0
开始的寄存器,这里的printf
本来需要三个参数,也就是要到a2
寄存器,因此这里会输出a2
中的值——这个值当然是不确定的、或者说依赖于之前的调用的。
2. Backtrace (moderate)
2.1 简单分析
这个Task叫Backtrace,回溯,经常刷算法题什么的应该对这个词很有印象。这里的backtrace在哲学上和算法中的backtrace类似,不过在具体上又有所不同。在这里题目就告诉你,在出错的时候,backtrace是对debug很有用的,它可以打印出函数的栈帧(stack frame),涉及到这方面确实也非常有用,比如说C++都在p0881r7把栈踪库加入C++23的标准库stacktrace了。
XV6中stack占据一个页的大小,
而函数的调用过程,实际上就是往这个栈里面压栈的过程,可以看到这个图里的解说,压栈是从高地址往低地址压的,每次调用都会在现有的基础上往下添加一个stack frame,我们有一个帧指针fp(frame pointer),指向了这个帧的头部,有一个sp,指向了这个帧的底部,其中帧里的内容,第一项是return address,代表帧应当返回的地址,具体一点的话,就是我们调用这个函数的原来的函数的地址。而我们要做的就是使用帧指针遍历这个栈并打印出每个栈帧保存的return address,这样就可以看出调用链,便于我们调试。
2.2 实现
搞清楚了需求就可以开始写实现了,实现的逻辑其实很简单,我们只需要使用fp从stack的头部遍历到stack页的尾部即可。具体而言,像我们上文说过的那样,fp指向一个栈帧的头部,栈帧的第一个位置放的是需要我们打印的return addr值,第二个位置放的是前一个帧的fp值,根据文档中的it actually points to the the address of the saved return address on the stack plus 8可以知道,在这个机器上每个位置的偏移量为8,因此这两个位置分别为fp - 8
和fp - 16
,这实际上就是指针偏移一次和两次的结果,因此我们可以更直观地写为fp[-1]
(因为stack向下生长所以是减)。
题目告诉我们要实现在kernel/printf.c
中,此外,hint还提示我们可以在kernel/riscv.h
中添加这样一段代码以获取当前fp的值
// kernel/riscv.h
static inline uint64
r_fp()
{uint64 x;asm volatile("mv %0, s0" : "=r" (x) );return x;
}
此外还可以使用kernel/riscv.h
中的PGROUNDDOWN(fp)
来获取页末的fp,整体遍历就好像遍历链表一样,就此我们就可以很容易写出如下代码:
void
backtrace(void)
{for (uint64* fp = (uint64*)r_fp(), *bottom = (uint64*)PGROUNDDOWN((uint64)fp);fp > bottom ; fp = (uint64*)fp[-2]){printf("%p\n", fp[-1]); // 获取并打印返回地址}
}
由于我们这个函数是要被调用的,因此我们还需要在老地方暴露接口:
然后按照要求,在sys_sleep
中调用backtrace
:
2.3 测试
推送后跑一下bttest
:
./grade-lab-traps backtrace
:
3. Alarm (hard)
3.1 简单分析
这个lab是要求我们实现一个feature,用途简单来说就是隔一段时间发出一次中断,调用一次函数,达到周期实现函数的效果。
3.2 test0: invoke handler
3.2.1 添加调用
我们首先按照要求,增加sigalarm
调用与sigreturn
调用,添加方法比较常规:
- 为Makefile添加文件
$U/_alarmtest\
- 在user.h中暴露接口
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
- 在usys.pl中添加脚本
entry("sigalarm");
entry("sigreturn");
- 在syscall.h中注册编号
#define SYS_sigalarm 22
#define SYS_sigreturn 23
- 在syscall.c中添加映射
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);
...[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn,
- 在sysproc.c中添加两个syscall的框架
uint64
sys_sigalarm(void)
{return 0;
}uint64
sys_sigreturn(void)
{return 0;
}
3.2.2 获取参数
sys_sigalarm接受两个参数,分别是ticks和handle,从语义学的角度来看就知道前者是间隔的时间,后者是需要处理的函数,以类似于回调函数的形式传入。同时HintYour sys_sigalarm() should store the alarm interval and the pointer to the handler function in new fields in the proc structure (in kernel/proc.h). 告诉我们这俩参数还应该存在proc
结构体里,同时我们还应当维护一个变量,用于已经经历的时长,因此我们需要先在proc
里开三个字段,然后在实现里获取参数:
// proc.h
struct proc {
...int alarm_past; // 系统调用alarm的过去时间int alarm_ticks; // 系统调用alarm的间隔void (*alarm_handler)(); // 系统调用alarm的待执行的函数
...
// sysproc.c
uint64
sys_sigalarm(void)
{struct proc *p = myproc();// 从用户空间获取参数并存储到procargint(0, &p->alarm_ticks);argaddr(1, (uint64*)&p->alarm_handler);p->alarm_past = 0; // 重置alarm_pastreturn 0;
}
同时,我们应该在proc.c
下的allocproc
中将它们初始化为0。
static struct proc*
allocproc(void)
{
...p->alarm_past = 0;p->alarm_ticks = 0;p->alarm_handler = (void*)0;
3.2.3 处理中断
Hint提示我们时间中断时会在trap.c
的usertrap()
中执行,并且我们只用修改if(which_dev == 2) ...
控制的域,这里就存在一个问题:可以去直接在中断中调用handle吗?答案当然是不行的,我们知道,中断是内核态处理的东西,而handle是一个用户态的地址,用户态和内核态的内存空间是不共享的!因此我们需要找到一种方法去调用这个回调函数。
根据学习我们可以知道,trap时系统会把上下文全给存到proc
下那个trapframe
类型的指针指向的地方,而trapframe
类型中又依赖epc
去保存先前的用户程序的地址,因此我们这里假如粗暴地把epc
重定向到我们的handle
,就可以达成test0
的调用中断处理函数的目的了:
// give up the CPU if this is a timer interrupt.if (which_dev == 2) {if (p->alarm_ticks && ++p->alarm_past == p->alarm_ticks) {p->alarm_past = 0;p->trapframe->epc = (uint64)p->alarm_handler;}yield();}
3.2.4 测试
输入alarmtest
,观察到test0 passed即可。
3.3 test1/test2()/test3(): resume interrupted code
3.3.1 恢复上下文与防止多次调用
在上一个test中,我们会发现,虽然test0通过了,但是test1挂掉了,究其原因其实很简单——trap时有个保存上下文(context)与恢复上下文的过程,但是我们刚刚直接暴力覆盖掉了保存的上下文,在恢复上下文的时候自然就会出现错误,因此,很容易想到我们可以使用一个中间变量去存取被覆盖掉的值,然后调用完handle后再放回去。但是这就有一个问题了——哪些值、或者说寄存器被覆盖了?我们显式覆盖的值自然只有epc
一个,但是在handle
的执行过程中,我们同样可能发生trap
,导致trapframe
的值被搞得一团糟,因此我们应该保存整个tramframe
,此外,还有一个问题是我们的handle可能需要运行一段时间,若是在这段时间里handle再次被调用,很明显它就将占据全部的时间片段,为了避免这一点,我们应当设置一个值来标记当前hadle是否正在被执行。搞清楚了这些就比较简单了:
- 首先在
proc
中定义两个field:
int alarm_on; // 系统调用alarm是否开启int alarm_past; // 系统调用alarm的过去时间int alarm_ticks; // 系统调用alarm的间隔void (*alarm_handler)(); // 系统调用alarm的待执行的函数struct trapframe* pre_trapframe; // 保存上一次的trapframe
- 在alloc中模仿上文分配内存以及为alarm_on置零:
- 别忘了free:
- 检查一下
trapframe
,发现里面并没有什么需要深拷贝的东西,因此直接在trap中赋值:
// give up the CPU if this is a timer interrupt.if (which_dev == 2) {if (!p->alarm_on && p->alarm_ticks && ++p->alarm_past == p->alarm_ticks) {p->alarm_on = 1;*p->pre_trapframe = *p->trapframe;p->alarm_past = 0;p->trapframe->epc = (uint64)p->alarm_handler;}yield();}
然后再sys_sigreturn
中恢复上下文:
uint64
sys_sigreturn(void)
{struct proc *p = myproc();if (p->alarm_on){*p->trapframe = *p->pre_trapframe;p->alarm_past = 0;p->alarm_on = 0;}return 0;
}
3.3.2 恢复寄存器a0的值
这个test3是6.1810,也就是22Fall才有的,之前都没有,说的是最后还存在一个问题,我们在task1学习过,函数的返回值会被保存在寄存器a0、a1
中,而这些系统调用,包括sys_sigreturn
都是有返回值的,这就会导致进行了系统调用后a0
的值会被污染,因此我们直接返回原来的a0
值即可:
话说总觉得这么写不太对,因为我查了一下Linux的sigreturn是返回-1的,这里明显不是返回-1,希望谁来给我解解惑。
3.3.3 测试
alarmtest
后完美运行:
3.4 测试
跑一下./grade-lab-traps alarm
,通过:
4. 最后测试
按照惯例加入time.txt
,然后make grade
:
因为我在task1写了中文,直接显示编码错误,懒得折腾了,直接把测试脚本这里给注释了,反正它也只能读个单词数量(
顺利通过(话说这个usertests是啥玩意啊,花这么多时间)