开始Lab4前,需要阅读Chap4,以及相关代码:
kernel/trampoline.S:从用户空间转换到内核空间再转换回来所涉及到的汇编代码kernel/trap.c:处理所有中断的代码 切换到Lab4分支:
$ git fetch
$ git checkout traps
$ make clean
Lab4:Traps
RISC-V assembly(easy)
了解一点RISC-V汇编是很重要的。xv6 repo中有一个文件user/call.c。通过make fs.img编译它,并生成可读版本的汇编程序user/call.asm
阅读user/call.asm中的函数g,f,和main。RISC-V指令集见官网阅读材料。
以下是需要回答的问题(将答案存储在一个answers-traps.txt文件中):
- 哪些寄存器包含函数的参数?比如,在
main调用printf时,哪个寄存器持有13? - 在
main的汇编代码中对函数f的调用在哪里?对g的调用在哪里?(提示:编译器可以内联函数) printf函数位于什么地址?- 在
main中jalr跳转到printf之后,ra的值是什么? - 运行下面的代码,输出是什么?(可以查看ASCII表的映射关系):
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);
输出取决于RISC-V是小端序这一事实。如果RISC-V是大端序,为了产生相同的输出,你会将 i 设置为什么?你需要将 57616 更改为不同的值吗?
- 下面的代码中,
y=之后将打印什么值?(提示:答案不是一个特定的值),为什么会这样?
printf("x=%d y=%d", 3);
answers:
- RISC-V提供了8个寄存器用于传参,为
a0 - a7,超过8个参数之后的参数通过栈传递。 在main调用printf时,13应该保存在寄存器a2中,由汇编代码可以看出(li指令,可以用来加载常量):
void main(void) {
...
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13
...
}
- 没有对
f和g函数调用的代码,g函数被内联到f函数中,f函数被内联到main函数中。可以看到main中直接把f(8) + 1的值传递给了a1寄存器,没有对f函数进行调用。
void main(void) {
...
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13
26: 45b1 li a1,12
...
}
printf函数位于的地址是0x630
void main(void) {
1c: 1141 addi sp,sp,-16
1e: e406 sd ra,8(sp)
20: e022 sd s0,0(sp)
22: 0800 addi s0,sp,16
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13
26: 45b1 li a1,12
28: 00000517 auipc a0,0x0
2c: 7b050513 addi a0,a0,1968 # 7d8 <malloc+0xea>
30: 00000097 auipc ra,0x0
34: 600080e7 jalr 1536(ra) # 630 <printf>
exit(0);
38: 4501 li a0,0
3a: 00000097 auipc ra,0x0
3e: 27e080e7 jalr 638(ra) # 2b8 <exit>
由上面的汇编代码可以看到:
30: 00000097 auipc ra,0x0
34: 600080e7 jalr 1536(ra) # 630
其中,auipc指令将0x0左移12位,然后加到pc上,并将结果写入ra,这一行代码的开头,即为pc当前的值,即pc = 0x30。那么有pc = 0x30 = ra。然后通过jalr指令(jalr指令可以用来进行函数跳转),将pc设置为ra寄存器中的值加上1536这个偏移量,ra + 1536 = 0x30 + 0x0600 = 0x0630,然后无条件跳转到当前pc的位置,即0x0630,所以printf函数的地址在0x0630
- 上面的
jalr指令,不仅设置了pc的值用于函数跳转,还会将下一条指令的地址保存到寄存器ra(即printf调用返回main之后的地址)。由于指令是32位,所以应该将ra设置为当前pc + 4,所以ra = pc + 4 = 34 + 4 = 0x38 - 由于RISC-V是小端序,对于
i,其字节在地址中的存放顺序是72 6c 64,在ASCII码表中分别对应r l d,而数字57616转换成16进制的结果为 0x0000e110,所以上述代码的输出结果是He110 World。由于%x是得到参数的十六进制,所以不管是大端序还是小端序,对于固定的参数其十六进制都是不变的,所以57616不用改变。而为了产生相同的输出,i需要改变,改变后的i = 0x726c6400。 - 输出的是一个受调用前代码影响的随机值。
printf尝试读取的参数数量比提供的参数多。第二个参数3会通过寄存器a1传递,而第三个参数对应的寄存器a2在调用前不会被设置为任何具体的值,而是会包含调用发生前的任何已经在里面的值,即会输出a2寄存器的值,但a2寄存器的值是不确定的,跟之前的其他代码调用有关。
Backtrace(moderate)
对于调试来说,回溯通常很有用:在错误发生点之上的堆栈上的函数调用列表。
要求:
在 kernel/printf.c 中实现 backtrace() 函数
在 sys_sleep 中插入对这个函数的调用,然后运行 bttest,它调用sys_sleep
你应该产生如下输出:
backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898
bttest 后退出 qemu
在你的终端:地址可能略有不同,但是如果你运行 addr2line -e kernel/kernel(或者 riscv64-unknown-elf-addr2line -e kernel/kernel),并按如下方式复制并粘贴上述地址:
$ addr2line -e kernel/kernel
0x0000000080002de2
0x0000000080002f4a
0x0000000080002bfc
Ctrl-D
你应该会看到如下的输出:
kernel/sysproc.c:74
kernel/syscall.c:224
kernel/trap.c:85
编译器在每个栈帧中放入一个帧指针,它保存调用者的帧指针的地址。回溯应该使用这些帧指针向上遍历堆栈并在每个堆栈帧中打印保存的返回地址。
提示:
- 在
kernel/defs.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;
}
并在backtrace中调用此函数来读取当前帧指针,这个函数通过使用 in-line assembly(即内联汇编表达式)(gcc.gnu.org/onlinedocs/…)来读取s0
内联汇编表达式中,"=r" (x),是一个操作表达式,指定了一个输出操作。括号中的部分是一个C/C++表达式,用来保存内联汇编的一个输出值,其操作就等于C/C++的相等赋值 x = output_value,因此括号中的输出表达式只能是C/C++的左值表达式。而output_value由引号中的内容而来("=r"),引号中的内容被称作"操作约束",在这个例子中操作约束等于"=r",它包含两个约束:等号和字母r。其中等号=说明括号中左值表达式x是一个write-only的,只能够作为当前内联汇编的输出,而不能作为输入。而字母r,是寄存器的简写,说明x的值要从寄存器r中获取。操作约束会给出:到底从哪个寄存器传递值给x。
- 栈帧的布局如下,注意:返回地址与栈帧指针的偏移量(-8)是固定的,而保存的帧指针与堆栈帧指针的偏移量(-16)也是固定的。
- xv6在页面对齐的地址上为xv6内核中的每个堆栈分配一个页面。可以使用
PGROUNDDOWN(fp)和PGROUNDUP(fp)来计算堆栈页面的顶部和底部地址(见kernel/riscv.h)这些数字有助于回溯结束其循环。
一旦您的backtrace开始工作,在kernel/printf.c中从 panic 调用它,这样就可以在内核出现panic时看到它的回溯
代码:
// kernel/printf.c
// 向上遍历堆栈并打印每个栈帧中保存的返回地址
void backtrace(void) {
uint64 fp = r_fp();
// 由于xv6内核中为每一个堆栈分配一个页面,所以可以通过PGROUNDUP来判断栈是否已经到顶
uint64 bottom = PGROUNDDOWN(fp);
uint64 top = PGROUNDUP(fp);
printf("backtrace:\n");
while (fp < top && fp > bottom) {
/*
由于fp是帧指针,那么由fp-8可以得到指向返回地址的指针,即可以将fp-8看作一个指针,其中保存的值是返回地址
为了得到具体的返回地址,需要将fp-8强制转换为一个指针,并对该指针解引用,从而得到其中保存的值
*/
uint64 ra = *(uint64*)(fp - 8);
printf("%p\n", ra);
fp = *(uint64*)(fp - 16);
}
}
测试:
$ bttest
backtrace:
0x0000000080002ce4
0x0000000080002bbe
0x00000000800028a8
Alarm(hard)
要求:
向xv6添加一个特性,该特性在进程使用CPU时间 时定期向进程发出警报
这对于希望限制消耗多少CPU时间的计算绑定进程,或者希望进行计算但是也希望采取一些周期性操作的进程来说可能很有用
更一般地说,你将实现用户级中断/错误处理程序的基本形式
例如,你可以使用类似的方法来处理应用程序中的页面错误
你应该添加一个新的 sigalarm(interval, handler)系统调用,如果应用程序调用sigalarm(n, fn),那么在程序消耗每 n 个 CPU "ticks"之后,内核就应该调用应用程序函数 fn。当fn返回时,应用程序应该从它停止的地方恢复。在xv6中,tick是一个时间单位,由硬件计时器生成中断的频率决定。如果应用程序调用sigalarm(0, 0),内核应该停止周期性的alarm调用。
你将在xv6库中找到一个文件user/alarmtest.c,将其添加到Makefile中。只有添加了sigalarm和sigreturn系统调用,它才能正确编译。
alarmtest在test0中调用sigalarm(2, periodic),要求内核每隔2 ticks强制调用periodic(),然后spin一段时间。可以在user/alarmtest.asm中看到alarmtest的汇编代码,这可能便于debug。
如果alarmtest产生如下输出,并且usertests也正常运行,那么你的解决方案是正确的:
$ 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
$ usertests
...
ALL TESTS PASSED
$
test0:调用handler
test0首先修改内核,使其跳转到用户空间中的alarm handler,这将导致test0打印 "alarm!"。不要担心"alarm!"输出之后会发生什么;现在如果你的程序在打印"alarm!"后崩溃了,这是OK的。
提示:
- 你需要修改Makefile,使
alarmtest.c被编译为一个xv6用户程序 - 在
user/user.h中正确的声明是:
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
- 更新
user/usys.pl(生成user/usys.S)、kernel/syscall.h和kernel/syscall.c,允许alarmtest调用sigalarm和sigreturn系统调用 - 现在,
sys_sigreturn应该只返回0 - 你的
sys_sigalarm()应该在proc结构(kernel/proc.h)的新字段中存储alarm间隔和指向handler函数的指针 - 你需要跟踪从上次调用(或直到下一次调用)进程的
alarm handler已经传递了多少ticks。为此,你还需要在struct proc中添加一个新字段。你可以在proc.c的allocproc()中初始化proc字段。 - 每个
tick,硬件时钟强制一个中断,这在kernel/trap.c的usertrap()中处理 - 只有在存在计时器中断的情况下,你才希望操作进程的
alarm ticks,你想要的是:
if(which_dev == 2) ...
- 只有在进程有未完成的计时器时才调用
alarm函数。请注意,用户的alarm函数的地址可能是0(比如,在user/alarmtest.asm,periodic就是在地址0) - 你需要修改
usertrap(),以便当进程的alarm间隔过期时,用户进程执行handler函数。当RISC-V上的trap返回到用户空间时,什么决定了用户空间代码恢复执行的指令地址?(寄存器sepc保存了中断时的用户的程序计数器,可以通过修改该寄存器的值,来决定用户空间代码恢复执行的指令地址) - 如果告诉qemu只使用一个CPU(通过以下命令实现),那么使用gdb查看
trap将会更容易:
make CPUS=1 qemu-gdb
- 如果
alarmtest打印出"alarm!",则表示成功
test1/test2():恢复中断的代码
很有可能,alarmtest在test0或test1中打印"alarm!"之后崩溃,或者alarmtest(最终)打印test1 failed,或者alarmtest退出时没有打印test1 passed。 要解决这个问题,必须确保,当alarm handler程序完成时,控制返回到用户程序最初被计时器中断 中断时的指令。 你必须确保寄存器内容恢复到中断时的值,以便用户程序可以在alarm后继续不受干扰。 最后,你应该在alarm计数器每次发出alarm之后,重新计数,以便定期调用处理程序。
我们已经为你做了一个设计决策:当用户alarm handler程序完成时,需要调用sigreturn系统调用。 以 alarmtest.c中的periodic为例,这意味着你可以向usertrap和sys_sigreturn添加代码,在用户进程处理完alarm后,它们会协作使用户进程正常恢复。
提示:
- 你的解决方案将要求你保存和恢复寄存器——你需要保存和恢复哪些寄存器才能正确地恢复中断的代码?(提示:会有很多)
- 让
usertrap在struct proc中保存足够的状态,当计时器停止时,sigreturn可以正确地返回被中断的用户代码 - 防止对处理程序的可重入调用——如果处理程序还没有返回,内核就不应该再次调用它。
test2测试了这一点
通过test0后,test1和test2将运行usertests,以确保没有破坏内核的任何其他部分。
综上,alarm的总体过程大致如下:
- 首先通过系统调用
sigalarm设置alarm间隔和alarm处理函数。 - 然后,当计时器中断发生时,陷入内核,调用
usertrap()(注意,此时需要备份当前的trapframe,这样当之后调用sigreturn时,可以通过备份的trapframe,来恢复计时器中断发生前的指令)。 - 在
usertrap()中,发现是计时器中断,那么会进行相应的处理,并返回到用户空间的alarm处理函数。 - 执行
alarm处理函数,经过一系列操作后,调用系统调用sigreturn,表明要返回到计时器中断发生前的执行序列。 - 再次陷入内核(这里也会有一个
trapframe,但是返回时不应该根据此trapframe进行恢复,而应该根据之前备份的trapframe来恢复到计时器中断之前的状态),调用usertrap(),发现是因为系统调用中断,那么就调用相应的系统调用函数sys_sigreturn。 - 在
sys_sigreturn中根据之前备份的trapframe进行恢复
代码:
在Makefile中添加alarmtest:
# Makefile
UPROGS=\
$U/_cat\
$U/_echo\
$U/_forktest\
$U/_grep\
$U/_init\
$U/_kill\
$U/_ln\
$U/_ls\
$U/_mkdir\
$U/_rm\
$U/_sh\
$U/_stressfs\
$U/_usertests\
$U/_grind\
$U/_wc\
$U/_zombie\
$U/_alarmtest\
添加sigalarm和sigreturn系统调用:
- 在
kernel/syscall.h中,添加新的系统调用编号:
// kernel/syscall.h
...
#define SYS_sigalarm 22
#define SYS_sigreturn 23
- 在
kernel/syscall.c中,用extern全局声明新的内核调用函数,并且在syscalls映射表中,加入从前面定义的编号到系统调用函数指针的映射:
// kernel/syscall.c
...
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);
static uint64 (*syscalls[])(void) = {
...
[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn,
};
- 在
user/usys.pl中,加入用户态到内核态的跳板函数:
# user/usys.pl
entry("sigalarm");
entry("sigreturn");
- 在
user/user.h中加入一个sigalarm和sigreturn函数原型:
// user/user.h
// system calls
...
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
在kernel/proc.h中添加alarm相关成员:
// kenrel/proc.h
struct proc {
...
// alarm 相关成员
void (*alarm_handler)(); // alarm 处理函数
int alarm_interval; // 一次alarm的间隔(如果间隔为0,表示禁用alarm)
int alarm_ticks; // 距离下次alarm还剩多少ticks
int handler_is_return; // 判断是否有handler程序在执行,如果有handler程序没有返回,内核就不应该再次调用它
struct trapframe* alarm_trapframe; // 存储时钟中断时刻的trapframe,用于handler处理完之后恢复原程序的正常运行
};
在kernel/proc.c的allocproc()中初始化alarm相关字段:
// kernel/proc.c
static struct proc* allocproc(void) {
...
// * 初始化alarm相关字段
// * 为之后进行备份所用到的trapframe分配页面
if ((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0) {
release(&p->lock);
return 0;
}
p->alarm_interval = 0;
p->alarm_ticks = 0;
p->handler_is_return = 0;
p->alarm_handler = 0;
return p;
}
在kernel/proc.c的freeproc()中释放alarm相关字段:
// kernel/proc.c
static void
freeproc(struct proc *p)
{
...
if (p->alarm_trapframe)
kfree((void*)p->alarm_trapframe);
...
p->alarm_interval = 0;
p->alarm_ticks = 0;
p->handler_is_return = 0;
p->alarm_handler = 0;
...
}
实现sigalarm和sigreturn系统调用:
// kernel/sysproc.c
// * alarm相关函数
uint64 sys_sigalarm(void) {
struct proc* p = myproc();
int n;
uint64 func;
// 获取时间间隔参数和对应的函数指针参数
if (argint(0, &n) < 0)
return -1;
if (argaddr(1, &func) < 0)
return -1;
p->alarm_interval = n;
p->alarm_handler = (void (*)())(func);
p->alarm_ticks = n;
return 0;
}
// * 根据备份的trapframe恢复到计时器中断前的状态
uint64 sys_sigreturn(void) {
struct proc* p = myproc();
// * 通过 defs.h 中声明的备份trapframe函数,将之前备份的trapframe拷贝给当前进程的trapframe,这样当前进程从系统调用返回时,就会根据之前备份的trapframe进行恢复
backupTrapframe(p->alarm_trapframe, p->trapframe);
// * 同时,将代表是否有alarm处理函数正在运行的成员进行修改
p->handler_is_return = 0;
return 0;
}
修改kernel/trap.c的usertrap,使得计时器中断发生时,进行相应的处理,首先判断是否禁用了alarm,然后判断alarm的间隔是否到期,如果到期了,就重置alarm_ticks,然后修改trapframe中的sepc寄存器的值,使得中断返回时,调用用户程序sigalarm:
// kernel/trap.c
void usertrap(void) {
...
if(r_scause() == 8) {
...
}else if((which_dev = devintr()) != 0) {
// * 如果是计时器中断,则调用alarm相关函数
if (which_dev == 2) {
// * 首先判断是否禁用了alarm
if (p->alarm_interval != 0) {
// * 然后判断alarm的间隔是否到期
if (--p->alarm_ticks == 0) {
// * 然后判断是否有处理程序正在运行,如果有,内核就不应该再次调用它
if (p->handler_is_return == 0) {
// * 如果到期了,且没有处理程序正在运行,就重置alarm_ticks,并修改sepc寄存器的值,使得中断返回时,调用用户的alarm处理函数
p->alarm_ticks = p->alarm_interval;
p->handler_is_return = 1; // * 表示有处理程序正在运行
// * 同时还需要备份当前的trapframe,当调用sigreturn时,通过备份的trapframe进行恢复
backupTrapframe(p->trapframe, p->alarm_trapframe);
// 修改sepc寄存器的值,使得返回时,调用用户的alarm处理函数
p->trapframe->epc = (uint64)(p->alarm_handler);
}
}
}
}
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
}
并在kernel/trap.c中实现备份trapframe的函数:
// kernel/trap.c
// * 备份trapframe,当sigreturn被调用时,通过备份的trapframe进行恢复
void backupTrapframe(struct trapframe* now, struct trapframe* backup) {
backup->kernel_satp = now->kernel_satp;
backup->kernel_sp = now->kernel_sp;
backup->kernel_satp = now->kernel_satp;
backup->epc = now->epc;
backup->kernel_hartid = now->kernel_hartid;
backup->ra = now->ra;
backup->sp = now->sp;
backup->gp = now->gp;
backup->tp = now->tp;
backup->t0 = now->t0;
backup->t1 = now->t1;
backup->t2 = now->t2;
backup->s0 = now->s0;
backup->s1 = now->s1;
backup->a1 = now->a1;
backup->a2 = now->a2;
backup->a3 = now->a3;
backup->a4 = now->a4;
backup->a5 = now->a5;
backup->a6 = now->a6;
backup->a7 = now->a7;
backup->s2 = now->s2;
backup->s3 = now->s3;
backup->s4 = now->s4;
backup->s5 = now->s5;
backup->s6 = now->s6;
backup->s7 = now->s7;
backup->s8 = now->s8;
backup->s9 = now->s9;
backup->s10 = now->s10;
backup->s11 = now->s11;
backup->t3 = now->t3;
backup->t4 = now->t4;
backup->t5 = now->t5;
backup->t6 = now->t6;
}
在kernel/defs.h中,添加对函数backupTrapframe的声明:
// kernel/defs.h
struct trapframe;
...
// trap.c
extern uint ticks;
void trapinit(void);
void trapinithart(void);
extern struct spinlock tickslock;
void usertrapret(void);
void backupTrapframe(struct trapframe*, struct trapframe*);
...
测试:
总体测试: