目录
test1/test2(): resume interrupted code(恢复被中断的代码)
4.2.2 test0: invoke handler(调用处理程序)
4.2.3 test1/test2(): resume interrupted code(恢复被中断的代码
前言:
本节实验的主要内容是使用traps实现系统调用。
本节实验要求:
Backtrace(moderate)
回溯(Backtrace)通常对于调试很有用:它是一个存放于栈上用于指示错误发生位置的函数调用列表。
在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
编译器向每一个栈帧中放置一个帧指针(frame pointer)保存调用者帧指针的地址。你的backtrace
应当使用这些帧指针来遍历栈,并在每个栈帧中打印保存的返回地址。
提示:
-
在kernel/defs.h中添加
backtrace
的原型,那样你就能在sys_sleep
中引用backtrace
-
GCC编译器将当前正在执行的函数的帧指针保存在
s0
寄存器,将下面的函数添加到kernel/riscv.h
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}
并在backtrace
中调用此函数来读取当前的帧指针。这个函数使用内联汇编来读取s0
- 这个课堂笔记中有张栈帧布局图。注意返回地址位于栈帧帧指针的固定偏移(-8)位置,并且保存的帧指针位于帧指针的固定偏移(-16)位置
- XV6在内核中以页面对齐的地址为每个栈分配一个页面。你可以通过
PGROUNDDOWN(fp)
和PGROUNDUP(fp)
(参见kernel/riscv.h)来计算栈页面的顶部和底部地址。这些数字对于backtrace
终止循环是有帮助的。
一旦你的backtrace
能够运行,就在kernel/printf.c的panic
中调用它,那样你就可以在panic
发生时看到内核的backtrace
。
Alarm(Hard)
你的工作:
在这个练习中你将向XV6添加一个特性,在进程使用CPU的时间内,XV6定期向进程发出警报。这对于那些希望限制CPU时间消耗的受计算限制的进程,或者对于那些计算的同时执行某些周期性操作的进程可能很有用。更普遍的来说,你将实现用户级中断/故障处理程序的一种初级形式。例如,你可以在应用程序中使用类似的一些东西处理页面故障。如果你的解决方案通过了alarmtest
和usertests
就是正确的。
你应当添加一个新的sigalarm(interval, handler)
系统调用,如果一个程序调用了sigalarm(n, fn)
,那么每当程序消耗了CPU时间达到n个“滴答”,内核应当使应用程序函数fn
被调用。当fn
返回时,应用应当在它离开的地方恢复执行。在XV6中,一个滴答是一段相当任意的时间单元,取决于硬件计时器生成中断的频率。如果一个程序调用了sigalarm(0, 0)
,系统应当停止生成周期性的报警调用。
你将在XV6的存储库中找到名为user/alarmtest.c的文件。将其添加到Makefile。注意:你必须添加了sigalarm
和sigreturn
系统调用后才能正确编译(往下看)。
alarmtest
在test0
中调用了sigalarm(2, periodic)
来要求内核每隔两个滴答强制调用periodic()
,然后旋转一段时间。你可以在user/alarmtest.asm中看到alarmtest
的汇编代码,这或许会便于调试。当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
$
当你完成后,你的方案也许仅有几行代码,但如何正确运行是一个棘手的问题。我们将使用原始存储库中的alarmtest.c版本测试您的代码。你可以修改alarmtest.c来帮助调试,但是要确保原来的alarmtest
显示所有的测试都通过了。
test0: invoke handler(调用处理程序)
首先修改内核以跳转到用户空间中的报警处理程序,这将导致test0
打印“alarm!”。不用担心输出“alarm!”之后会发生什么;如果您的程序在打印“alarm!”后崩溃,对于目前来说也是正常的。以下是一些提示:
-
您需要修改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
系统调用返回应该是零。 -
你的
sys_sigalarm()
应该将报警间隔和指向处理程序函数的指针存储在struct proc
的新字段中(位于kernel/proc.h)。 -
你也需要在
struct proc
新增一个新字段。用于跟踪自上一次调用(或直到下一次调用)到进程的报警处理程序间经历了多少滴答;您可以在proc.c的allocproc()
中初始化proc
字段。 -
每一个滴答声,硬件时钟就会强制一个中断,这个中断在kernel/trap.c中的
usertrap()
中处理。 -
如果产生了计时器中断,您只想操纵进程的报警滴答;你需要写类似下面的代码
if(which_dev == 2) ...
-
仅当进程有未完成的计时器时才调用报警函数。请注意,用户报警函数的地址可能是0(例如,在user/alarmtest.asm中,
periodic
位于地址0)。 -
您需要修改
usertrap()
,以便当进程的报警间隔期满时,用户进程执行处理程序函数。当RISC-V上的陷阱返回到用户空间时,什么决定了用户空间代码恢复执行的指令地址? -
如果您告诉qemu只使用一个CPU,那么使用gdb查看陷阱会更容易,这可以通过运行
make CPUS=1 qemu-gdb
- 如果
alarmtest
打印“alarm!”,则您已成功。
test1/test2(): resume interrupted code(恢复被中断的代码)
alarmtest
打印“alarm!”后,很可能会在test0
或test1
中崩溃,或者alarmtest
(最后)打印“test1 failed”,或者alarmtest
未打印“test1 passed”就退出。要解决此问题,必须确保完成报警处理程序后返回到用户程序最初被计时器中断的指令执行。必须确保寄存器内容恢复到中断时的值,以便用户程序在报警后可以不受干扰地继续运行。最后,您应该在每次报警计数器关闭后“重新配置”它,以便周期性地调用处理程序。
作为一个起始点,我们为您做了一个设计决策:用户报警处理程序需要在完成后调用sigreturn
系统调用。请查看alarmtest.c中的periodic
作为示例。这意味着您可以将代码添加到usertrap
和sys_sigreturn
中,这两个代码协同工作,以使用户进程在处理完警报后正确恢复。
提示:
-
您的解决方案将要求您保存和恢复寄存器——您需要保存和恢复哪些寄存器才能正确恢复中断的代码?(提示:会有很多)
-
当计时器关闭时,让
usertrap
在struct proc
中保存足够的状态,以使sigreturn
可以正确返回中断的用户代码。 -
防止对处理程序的重复调用——如果处理程序还没有返回,内核就不应该再次调用它。
test2
测试这个。 -
一旦通过
test0
、test1
和test2
,就运行usertests
以确保没有破坏内核的任何其他部分。
4.1 Backtrace(moderate)
这节实验需要实现一个Backtrace的系统调用,该系统调用的作用是遍历栈,并且打印栈中保存的函数的返回地址。
按照实验指导书的提示,首先在kernel/defs.h中添加backtrace函数的原型:
// kernel/defs.h
// printf.c
......
void printfinit(void);
void backtrace(void);
为了能够遍历栈,我们首先需要获取到当前栈的指针,该指针被存放在了S0寄存器中,实验指导书给出了获取到S0寄存器的值的代码,只需要将它添加到kerne/riscv.h中即可:
// kernel/riscv.h
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}
此外,为了防止栈指针fp越界,实验指导书还提供了两个函数PGROUNDDOWN(fp)
和PGROUNDUP(fp),
分别可以获取栈的底部地址和顶部地址。
最后,是调用与实现backtrace函数。backtrace函数被sys_sleep()调用,它实现的流程主要是获取到当前的栈指针以及该栈指针指向的栈帧,按照实验指导书的栈帧布局图可以知道,返回地址位于栈指针-8的位置,上一个栈帧的栈指针位于当前栈指针-16的位置。通过循环递归获取到调用链:
// kernel/printf.c
void backtrace(void)
{
printf("backtrace:\n");
// 获取当前栈指针
uint64 fp = r_fp();
// 获取当前栈的栈帧,为一个数组,frame[0]就是栈指针
uint64 *frame = (uint64 *)fp;
// 获取栈的栈底和栈顶
uint64 up = PGROUNDUP(fp);
uint64 down = PGROUNDDOWN(fp);
// 根据实验指导书,返回地址位于栈帧帧指针的固定偏移(-8)位置,
// 并且保存的帧指针位于帧指针的固定偏移(-16)位置
while(fp < up && fp > down)
{
printf("%p\n", frame[-1]);
// 递归获取上一个栈帧的栈指针
fp = frame[-2];
frame = (uint64 *)fp;
}
}
// kernel/sysproc.c
uint64
sys_sleep(void)
{
int n;
......
release(&tickslock);
// 调用backtrace()
backtrace();
return 0;
}
4.2 Alarm(Hard)
实现一个系统调用,该系统调用可以定期的对占用了CPU的进程发出警告。
4.2.1 准备工作
首先完成修改Makefile、定义系统调用等准备工作,这一部分和Lab2的差不多:
// Makefile
UPROGS=\
$U/_cat\
......
$U/_alarmtest\
// user/user.h
int sigalarm(int, void (*handler)());
int sigreturn(void);
// user/usys.pl
entry("sigalarm");
entry("sigreturn");
// kernel/syscall.h
#define SYS_sigalarm 22
#define SYS_sigreturn 23
// kernel/syscall.c
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);
[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn,
4.2.2 test0: invoke handler(调用处理程序)
按照实验指导书的提示,我们首先需要在进程的proc结构体中新增几个字段,用来存储报警间隔、指向处理程序函数的指针、调用间隔的时钟数,并在allocproc()中对这些字段进行初始化:
// kernel/proc.h
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
......
// 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.
......
// 间隔的时间
int interval;
// 指向处理函数的调用
uint64 handler;
// 自上一次调用(或直到下一次调用)到进程的报警处理程序间经历了多少滴答
......
};
// kernel/proc.c
static struct proc*
allocproc(void)
{
struct proc *p;
......
return 0;
found:
p->pid = allocpid();
p->state = USED;
......
p->interval = 0;
p->handler = 0;
p->ticks_cnt = 0;
......
return p;
}
接着是实现sys_sigalarm(),该函数的主要流程就是接收用户空间传进来的参数,关于如何获取参数,可以参考前几期的实验如lab2:
// kernel/sysproc.c
uint64 sys_sigalarm(void)
{
int interval;
uint64 handler;
struct proc *p;
// 接收用户空间传进来的报警间隔和处理函数
if(argint(0, &interval)<0 || argaddr(1, &handler)<0)
return -1;
// 获取当前占用CPU的进程的proc
p = myproc();
// 填充该进程的proc的相应字段
p->interval = interval;
p->handler = handler;
p->ticks_cnt = 0;
return 0;
}
最后根据实验指导书的提示,修改usertrap(),在发生时钟中断时,如果报警间隔满了,就调用处理函数,此外,为了能够在返回用户空间后执行处理函数,我们需要将处理函数的地址保存到进程的proc结构体中,观察allocproc(),我们可以发现,进程的现场其实是被存储到了trapframe中的epc中,也就是用户的程序计数器,指示了下一条要执行的指令:
// kernel/proc.h
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
......
/* 24 */ uint64 epc; // saved user program counter
......
}
// kernel/trap.c
void
usertrap(void)
{
int which_dev = 0;
......
// 当发生时钟中断时进入该处理逻辑
if(which_dev == 2)
{
if(p->interval > 0)
{
p->ticks_cnt++;
if(p->ticks_cnt > p->interval)
{
// 执行处理函数后,时钟数清零
p->ticks_cnt = 0;
// 将处理函数放入进程的pc中
p->trapframe->epc = p->handler;
}
}
yield();
}
usertrapret();
}
4.2.3 test1/test2(): resume interrupted code(恢复被中断的代码)
这一部分接着test0,由于将中断处理函数放入到了进程的proc结构体中,当函数执行完毕后,我们需要对进程的现场进行恢复,这一部分做的就是这个工作。
首先是在proc结构体中再次新增一个字段,用来保存与恢复现场有关的信息,此外,实验指导书中还提到,当我们已经进入到了处理函数后,就不能再进入到处理函数中,因此还需要一个字段来标识是否在执行中断函数:
// kernel/proc.h
struct proc {
struct spinlock lock;
.......
// 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.
......
// 观察allocproc()函数,我们可以发现,关于进程的现场其实是存储到了trapframe中
// 因此,我们在这里增加一个struct trapframe* 的字段
struct trapframe *tick_trapframe;
// 处理函数是否执行过了
int handler_exec;
};
// kernel/proc.c
// 对新增的字段进行初始化
static struct proc*
allocproc(void)
{
struct proc *p;
......
found:
p->pid = allocpid();
......
if((p->tick_trapframe = (struct trapframe *)kalloc()) == 0){
release(&p->lock);
return 0;
}
p->interval = 0;
p->handler = 0;
p->ticks_cnt = 0;
p->handler_exec = 0;
return p;
}
其次,我们需要再次修改usertrap()函数,这一次是在将进程的trapframe->epc设置为处理函数前,先保存进程的现场:
// kernel/trap.c
void
usertrap(void)
{
int which_dev = 0;
......
// 当发生时钟中断时进入该处理逻辑
if(which_dev == 2)
{
if(p->interval > 0)
{
p->ticks_cnt++;
// 如果没有进入处理函数,且经过的时钟数已经超过了报警间隔,则执行逻辑
if(p->handler_exec == 0 && p->ticks_cnt > p->interval)
{
// 执行处理函数后,时钟数清零
p->ticks_cnt = 0;
// 保存现场
*(p->tick_trapframe) = *(p->trapframe);
// 表示正在执行处理函数
p->handler_exec = 1;
// 将处理函数放入进程的pc中
p->trapframe->epc = p->handler;
}
}
yield();
}
usertrapret();
}
最后,根据指导书的提示,我们可以先看看alarmtest.c中的periodic:
// user/alarmtest.c
void
test0()
{
int i;
......
sigalarm(2, periodic);
......
}
void
periodic()
{
count = count + 1;
printf("alarm!\n");
sigreturn();
}
可以看到,sigalarm()的中断处理函数最后调用了sigreturn(),可见该函数就是用来在执行完处理函数后恢复现场的,因此,相关的操作我们应该在sigretun()中实现:
// kernel/sysproc.c
uint64 sys_sigreturn(void)
{
// 获取当前占用CPU的进程的proc
struct proc *p = myproc();
// 恢复进程的现场
*(p->trapframe) = *(p->tick_trapframe);
// 中断处理函数执行完毕
p->handler_exec = 0;
return 0;
}
至此,这一小节的实验就都完成了。