在开始做lab之前务必弄清楚
- 所谓的系统调用,本质上就是内核态和用户态之间的切换
- 内核态和用户态的区别本质上就是一些关键属性的区别,比如页表
而他们的运行方式都一样,就是取指执行,没有魔法
只不过内核态和用户态的执行不在一个体系上,所以切换会比较复杂,但这些切换也就只是修改一些关键属性- 进程的运行本质上就是一些值不断的变化,trapframe中的值完全可以保存并复现一个进程执行到了哪里加粗样式
PreRead
- xv6 book的第四章
-
kernel/trampoline.S
:在用户态和内核态进行切换的汇编代码 -
kernel/trap.c
:处理陷入的代码
RISC-V assembly
task
有一个文件user/call.c
,通过make fs.img
可以编译并生成一个可读性很高的汇编代码user/call.asm
。
阅读这个汇编代码中的函数g
,f
,main
,并回答以下问题
1. a0-a7,a2
2. 被优化了?
3. 630
4. 38
5. He110 World,第一个是直接输出十六进制的表示,第二个是将十六进制的每个字节看做一个字符,并且是小端法
将i改成0x726c6400,57616不用改
6. 随机值,因为相当于调用printf的时候没有给出寄存器a2的值,那么这时候就会根据a2的值随机出现答案
Backtrace
task
如果能够清楚在错误发生之前的一系列函数调用,那么对debug很有帮助
-
在
kernel/printf.c
中实现一个backtrace()
函数是
kernel
文件夹里的,不是user
文件夹里,user
文件夹里也有一个printf.c
-
在
sys_sleep
中插入对backtrace
函数的调用 -
运行
bttest
,它会调用sys_sleep
,你的输出应该是backtrace: 0x0000000080002cda 0x0000000080002bb6 0x0000000080002898
在
bttest
结束之后,在你的终端中运行$ addr2line -e kernel/kernel 0x0000000080002de2 0x0000000080002f4a 0x0000000080002bfc Ctrl-D
你会看到
kernel/sysproc.c:74 kernel/syscall.c:224 kernel/trap.c:85
-
编译器会给每个栈帧一个
frame pointer
,你应该使用这个指针去遍历栈并且打印每个栈帧中保存的返回地址
hints
-
记得在
kernel/defs.h
中声明你的backtrace
函数 -
GCC编译器将栈指针存放在当前执行函数的
s0
寄存器中,将下面这个函数添加到kernel/riscv.h
中,并且在backtrace
函数中调用它static inline uint64 r_fp() { uint64 x; asm volatile("mv %0, s0" : "=r" (x) ); return x; }
-
返回地址位于
fp-8
的固定位置被保存的栈帧地址位于
fp-16
的固定位置 -
你可以使用这两个宏,有助于终止
backtrace
的循环PGROUNDDOWN(fp)
计算栈顶的位置PGROUNDUP(fp)
计算栈底的位置 -
如果你的
backtrace
工作了,可以在kernel/printf.c
的panic
调用它,这样一旦内核出错了,你就可以看到调用栈
思路
一行一行地打印函数调用的地址,思路比较简单
- 首先通过文档提供给我们的
r_fp
函数获得当前的fp
- 通过这个fp不断打印这个函数的返回地址,并将fp置为上一个函数的fp,具体位置如上面那个栈帧的图所示
具体实现上
- 需要通过hints里提示的宏,找到栈帧的终点,也是我们循环的终点。
- 注意了,这个终点也就是最高层的函数,它不需要继续打印了,而它自己也被它调用的函数打印了,因此就直接结束
- fp这个指针,并不能直接取值,将它减8,它才正好指向当前栈帧底部的第一个值,注意!
- 如果想打印出64位,并在前面补0,前面还加上0x,用%p就可以打印出来了,不用自己瞎搞,chatgpt还忽悠人
void backtrace() {
printf("backtrace:\n");
uint64 fp = r_fp();
uint64 up_edge = PGROUNDUP(fp);
while (fp < up_edge) {
printf("%p\n", *(uint64 *)(fp - 8));
fp = *(uint64 *)(fp - 16);
}
}
Alarm
task
在这个练习中,你将给xv6增加一个特性,即会根据CPU时间周期性地alert一个进程
如果你的解答可以通过
alarmtest
和usertests
-
你应该添加一个新的系统调用
sigalarm(interval,handler)
-
如果一个应用调用了
sigalarm(n,fn)
那么在这个应用消耗了n ticks个CPU时间后,内核会调用函数
fn
当
fn
返回后,应用会回到被打断的地方继续执行 -
如果一个应用调用了
sigalarm(0,0)
,内核应该暂停生成周期性的alarm -
在你的xv6文件中有一个文件叫
user/alarmtest.c
,你需要将其加入到Makefile只有你正确添加了
sigalarm
和sigreturn
系统调用之后,才可以正确编译
hints1
-
首先修改内核,跳转到用户空间的
alarm handler
这将让
test0
打印alarm!
-
现在还不用管打印之后发生了什么,你的程序在打印之后崩了就行
-
你需要去修改Makefile,让它去编译
alarmtest.c
成为用户程序 -
在
user/user.h
中正确的声明应该如下int sigalarm(int ticks, void (*handler)()); int sigreturn(void);
-
更新
user/usys.pl
,kernel/syscall.h
和kernel/syscall.c
使得
alarmtest
能够调用sigalarm
和sigreturn
系统调用 -
至此你的
sys_sigreturn
应该只返回0你的
sys_sigalarm
应该存下alarm的间隔和处理函数的指针到proc
结构体的新的区域 -
你需要去跟踪自从上次调用alarm的处理函数到现在已经过去了多少
ticks
这也需要在
struct proc
中增加一个新的字段,你可以在proc.c 的 allocproc()
中初始化这个字段 -
每次来一个
tick
,都会在kernel/trap.c
的usertrap
中被处理你只需要在有时钟中断的时候操作
-
只需要在一个进程有 t i m e r o u t s t a n d i n g timer\ outstanding timer outstanding的时候调用alarm函数
小心函数地址为0的情况,因为函数地址可以为0,我是傻逼!
-
你将需要去修改
usertrap
函数,使得当一个进程的alarm时间间隔到期时,用户进程执行处理函数当一个陷入返回到用户空间时,是什么决定着用户空间代码继续执行的指令地址?
-
如果你运行
make CPUS=1 qemu-gdb
,会使用用gdb查看trap的时候更容易 -
如果
alarmtest
打印了alarm!
,你就成功了
思路1
-
首先根据它的提示去各个文件中把系统调用的声明给弄好
-
然后在
struct proc
中增加如下字段,其中关键在于uint64 handler
,它是函数指针,不过终究也就是个指针,因此可以用uint64
来表示int cur_ticks; uint64 handler; int ticks;
-
在
sysprorc.c
中完成sys_sigalarm
和sys_sigreturn
uint64 sys_sigalarm(void) { struct proc *p; p = myproc(); argint(0, &p->ticks); argaddr(1, &p->handler); p->cur_ticks = 0; return 0; } uint64 sys_sigreturn(void) { return 0; }
-
最后在
trap.c
中完成调用注意了,函数指针可能是0,所以用ticks是否为0判断是否需要计数
if (which_dev == 2) { if (p->ticks != 0) { p->cur_ticks++; if (p->cur_ticks == p->ticks) { p->cur_ticks = 0; p->trapframe->epc = p->handler; } } yield(); }
这里的实现是如果当前已经到了第n个时钟中断,那么会先去中断,等下一次获得cpu使用权时,再去执行handler操作
我试了一下在放弃cpu之前直接
p->handler()
,结果不允许估计是因为地址的原因,现在可是在内核态,怎么可能能够通过这个用户态的虚拟地址来执行
所以,只能等到这个进程再次获得CPU并且回归用户态用,就会用epc这个参数来初始化pc,就会从这里开始执行了
hints2
你需要在执行完
alarm
处理函数之后,正确返回程序被中断的地方,并且各种寄存器的状态也要不变xv6已经为实现提供了一种思路,即每个alarm处理函数的最后都有一个
alarmreturn
函数,你可以通过usertrap
和sys_sigreturn
合作来完成用户进程的恢复
- 你将需要保存和恢复寄存器,很多很多
- 在
struct proc
中保存足够多的状态,使得你可以在sigreturn
中恢复 - 如果一个处理函数还没有结束,内核不应该再次调用它
思路2
到了这一步,必须要先搞清楚系统调用的过程中对于状态的保存和恢复了
uservec
保存了各种常用的寄存器usertrap
将返回的pc地址存到了p->trapframe->epc
usertrapret
通过p->trapframe->epc
恢复pcuserret
恢复各种寄存器
首先,我们希望在时钟中断之后,这个进程被调度回来的时候,去执行alarm处理函数,因此我们需要在时钟中断的处理中,将epc
置为处理函数的地址,这样就完成了task0
但是如果只是这样的话,这个进程在执行完alarm处理函数之后并不能正确的返回需要执行的地方。那如何正确的返回呢?
可以发现,alarm处理函数的最后一句通常是alarmreturn
,这是一个系统调用!如果我们能够在这个系统调用返回之前将trapframe(因为trapframe包括了所有返回用户态需要的信息,所以我们只需要这个就行了)变成在时钟中断处理之前的样子,那么就可以借用alarmreturn
这个系统调用的返回操作回到我们想去的地方
而需要注意的是,如果已经执行了alarm处理函数,那此时的trapframe肯定是不行的,因为包括pc和各种通用寄存器都被破坏的,那哪个时间点的trapframe可以呢?
答案是刚进入if (which_dev == 2)
的时候,想一想,如果我们不需要搞这个什么alarm,那么等之后这个进程再次被调度到cpu之后,那不就是继续正常执行吗?说明这个时间点的trapframe可以通过任何一个系统调用的返回过程使得进程执行到继续执行的地方
不过我们也没必要每次进入这里都保存了,只需要在确定了会去执行alarm处理函数的时候保存,在alarmreturn
中恢复即可
除此之外,题目还要求如果已经有一个alarm处理函数在执行,那么其他的必须等待,因此额外增加一个变量代表是否有在执行
具体实现如下
首先给proc结构体增加如下变量
struct trapframe *alarm_tf;
int is_runing;
并且在进程初始化和终止的时候对这两个变量进行处理
// 进程初始化,这里主要是防止申请不成功,那就学着已有的代码对进程进行销毁
p->alarm_tf = (struct trapframe *)kalloc();
if (p->alarm_tf == 0) {
freeproc(p);
release(&p->lock);
return 0;
}
p->is_runing = 0;
// 进程结束
kfree(p->alarm_tf);
trap.c
中的代码进行如下更新
- 注意
p->cur_ticks >= p->ticks
,这里变成大于等于,是为了保证现在可以等,但之后如果没有正在运行的了,那就可以进入alarm处理函数的流程。如果和之前一样是等于号的好,那可能就错过了
if (which_dev == 2) {
if (p->ticks != 0) {
p->cur_ticks++;
if (p->cur_ticks >= p->ticks && p->is_runing == 0) {
memmove(p->alarm_tf, p->trapframe, sizeof(struct trapframe));
p->is_runing = 1;
p->cur_ticks = 0;
p->trapframe->epc = (uint64)p->handler;
}
}
yield();
}
最后修改sys_sigreturn
函数
uint64
sys_sigreturn(void) {
struct proc *p;
p = myproc();
memmove(p->trapframe, p->alarm_tf, sizeof(struct trapframe));
p->is_runing = 0;
return 0;
}
总结
- 系统调用的过程很复杂,设计的也很巧妙。并且由于内核态和用户态的虚拟地址空间不一样,导致了一些麻烦的操作。不过在各种状态的切换中,进程的trapframe包含了这个进程所有的信息,拥有一个进程某个时刻的trapframe,就可以在任意时候将这个进程恢复到这个状态,这也是这个lab考察的内容
- 在这个lab中我们不需要自己去做各种东西的切换,只需要提供一个正确的trapframe即可
- 系统调用会经历那四个阶段,时钟中断导致的进程切换和恢复最起码也会经历最后的两个阶段