MIT 6.S081 Lab Traps实验
本文是MIT课程6.S081操作系统学习笔记的一部分:
- Lab util: Unix utilities
- Lab syscall: System calls
- Lab pgtbl: Page tables
- Lab traps: Traps
- Lab cow: Copy-on-write fork
- Lab thread: Multithreading
- Lab net: Network driver
- Lab lock: Parallelism/locking
- Lab fs: File system
- Lab mmap: Mmap
相关的代码都放在了GitHub下了:RayZhang13/MIT-6.S081-2021
距离深圳Shopee 9.19大裁员已经过了一周… 人还是有点懵懵的,只知道先走的是我,没想到mentor也走了,连带着走了好几个小伙伴,前阵子真的有些致郁,也不知道两个月左右的经验能面个锤子社招… 大概真的是部门问题吧,现在真的就走一步看一步了
课程相关的资源、视频和教材见Lab util: Unix utilities开头。
这次的Lab主要考察RISC-V的陷阱处理机制,在课程中以系统调用为例,介绍了从用户态切换内核态并最终退出的全过程。
准备
对应课程
本次的作业是Lab Traps,我们将探索陷阱处理机制等内容,并最终实现一个用户级别的陷阱处理。
我们需要看掉Lecture 5和Lecture 6,也就是B站课程的P4和P5。其中P4主要在讨论RISC-V汇编指令下,栈内存、栈帧相关知识以及调用Call的一些习惯,然后更多的时间花在了介绍如何使用GDB对xv6操作系统进行调试,这个就很优雅。而P5才是这次的主要内容,以一个系统调用为例,主要讲述了用户态和内核态之间是如何进行切换的,信息量还是有点大的,建议多过几遍…
另外请阅读教材第四章Traps and system calls,了解相关内容。
系统环境
使用Arch Linux虚拟机作为实验环境…
环境依赖配置,编译使用make qemu
等,如遇到问题,见第一次的Lab util: Unix utilities
使用准备
参考本次的实验说明书:Lab traps: Traps
从仓库clone下来课程文件:
1 | git clone git://g.csail.mit.edu/xv6-labs-2021 |
切换到本次作业的traps
分支即可:
1 | cd xv6-labs-2021 |
在作业完成后可以使用make grade
对所有结果进行评分。
题目
RISC-V汇编简答题
要求和提示
在本部分中将给出一段RISC-V汇编代码,通过阅读代码我们要回答几个问题,并把答案存储在主目录下的answers-traps.txt
下。
我们在主目录下执行:
1 | make fs.img |
编译user/call.c
,并生成汇编文件user/call.asm
,我们需要观察call.asm
下的g
, f
和main
函数。RISC-V的指令手册可参考reference page。
问题1
请观察在call.asm
下的main
函数:
1 | 000000000000001c <main>: |
请问哪个寄存器会将参数传递给调用的函数?例如在printf
中,参数13
存储在哪个寄存器中?
Which registers contain arguments to functions? For example, which register holds
13
inmain
’s call toprintf
?
通过查阅RISC-V的Calling conventions手册,我们看到:
a0~a7
寄存器用于保存函数参数,因此由于13
属于第三个参数,因此存储的寄存器是a2
。
顺带看到0x24
位置,将13
写入了a2
寄存器,进一步印证了我们的想法。
非常的合理,梦回Bomb Lab了属于是。
问题2
前后对比call.c
和call.asm
:
1 |
|
请问主函数中对f
和g
的调用在哪里(提示:编译器可能会使用内联函数)
Where is the call to function
f
in the assembly code formain
? Where is the call tog
? (Hint: the compiler may inline functions.)
还是看刚才的call.asm
,很明显的可以看到在0x26
位置,程序直接将12
写入了a1
寄存器的位置(也就是第二参数f(8)+1
的位置),在处理完所有的寄存器参数后直接就调用了printf
。那只有一种解释,就是编译器直接计算出了f(8)+1
的结果是12
,主函数中并没直接调用这两个函数。
问题3
printf
函数位于哪个地址?
At what address is the function
printf
located?
额,看看main
函数里面的系统调用怎么做的?在0x34
位置,我们跳转到了printf
地址,也就是ra+1510
,这是偏移量记法,也叫0x616
… 那这就是我们的地址了,你在call.asm
往下翻翻顺便还能看到printf
的汇编代码,非常的合理:
1 | 0000000000000616 <printf>: |
目测这个地址貌似因人而异… 可能大家编译出来的多多少少都会有点不同
问题4
在jalr
跳转至main
函数的printf
时,寄存器ra
中有什么值?
What value is in the register
ra
just after thejalr
toprintf
inmain
?
我们不妨查看一下spec文档RISC-V unprivileged instructions,可以看到:
当程序进行跳转时,我们需要将ra
寄存器存储的返回地址指向printf
执行结束后返回到主程序的位置,也就是当前位置PC
加4,也就是0x38
问题5
运行如下代码:
1 | unsigned int i = 0x00646c72; |
判断输出是什么?可参考ASCII表来查询对应字符。
What is the output? Here’s an ASCII table that maps bytes to characters.
注意观察由于RISC-V是小端序,一个整型变量在内存中是这样存储的。
那很显然,显示结果就应该是HE110 World
… 我们尝试修改一下call.c
的main
函数内容,然后在终端下运行一下,验证一下结果,是一致的:
如果RISC-V不是小端序而是大端序呢?我们需要对i
和57616
做出什么改变吗?
The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set
i
to in order to yield the same output? Would you need to change57616
to a different value?
那很显然,我们需要将i
修改为0x726c6400
,但是57616
并不需要做出改变,因为它转化为二进制仍然是0xe110
。
问题6
当运行如下代码时,在y=
后面会打印出什么?(注意:答案不是一个确定的值)为什么?
1 | printf("x=%d y=%d", 3); |
In the following code, what is going to be printed after
'y='
? (note: the answer is not a specific value.) Why does this happen?
根据刚才的传参规则,打印出来的因该是寄存器a2
的值,但是a2
在这里并没有指定具体的数值…
那就真的是你说啥就是啥了,这个时候a2
的值肯定是受之前的代码影响产生的随机值…
函数调用栈打印
要求和提示
我们在代码调试的过程中为了定位错误的位置,经常需要使用到函数的调用栈打印。当错误发生时,会打印出一系列之前的函数调用信息。
在这里要求我们需要在kernel/printf.c
下实现函数backtrace()
,并在sys_sleep
中插入对此函数的一个调用。当我们在终端中调用bttest
测试时,该命令会调起sleep
系统调用,从而触发打印,运行bttest
时打印结果示例为:
1 | backtrace: |
在bttest
退出了QEMU之后,在终端下,我们也可以使用addr2line
命令来将指令地址转化为具体的代码位置,例如我们使用指令addr2line -e kernel/kernel
:
我们的目标是从顶部开始遍历各个栈帧,并将各个栈帧中保存的返回地址打印出来。(讲道理感觉做个-4
打印调用函数的地址可能会更直观一点,但是他说是就是吧)
相关提示:
-
需要在
kernel/defs.h
中添加backtrace
的声明,使得我们可以在sys_sleep
下调用backtrace
。 -
GCC编译器将当前执行的函数的栈帧指针frame pointer存储在
s0
寄存器中,为此我们需要在kernel/riscv.h
下加入代码:1
2
3
4
5
6
7static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}以方便我们在
backtrace
中调用来读取栈帧的底部地址。P.S.
sp
和fp
是典型的堆栈寄存器,用来标注当前栈帧的底部和顶部地址,由于我们在描述的时候对顶部和底部往往有自己的理解,因为栈地址是向下增长的,这里还是放一张图会比较好: -
由刚才贴出的图可见,注意到当前函数的返回地址和当前的
fp
存在一个-8
的地址偏移量,上一个栈帧的fp
保存位置和当前的fp
存在一个-16
的偏移量。 -
在xv6中,操作系统内核会为每个栈分配一个页面的空间,并地址对齐。因此我们可以使用
PGROUNDDOWN(fp)
和PGROUNDUP(fp)
来寻找栈所在页面的顶部和底部地址,PGROUNDDOWN
和PGROUNDUP
的定义可以看下kernel/riscv.h
:1
2
3
4
5通过确定页面的顶部和底部地址可以方便我们及时的退出遍历循环。
一旦我们确认了backtrace
正确运作了,我们就可以在kernel/printf.c
下的panic
打印函数中加入对backtrace
的调用,好在程序发生panic的时候打印其调用栈。
完成backtrace
函数
r_fp
的代码需要添加在刚才的提示中写的很明白了,这里就不说了。
首先我们在kernel/defs.h
中加入对backtrace
的声明:
1 | // printf.c |
然后我们在kernel/printf.c
中加入backtrace
函数,逻辑比较简单,我们从当前栈帧开始,也就是地址最小,位置最靠顶部的栈帧,通过-8
偏移量获取到返回地址ra
并将其打印。随后我们通过-16
偏移量来确定地址稍大一些、位置更往下的栈帧的底部位置,从而完成跳转。如此不断遍历,直到fp
指针达到栈所在页面的最大值。
1 | void backtrace(void) { |
调用backtrace
为了完成测试功能bttest
,我们需要在kernel/sysproc.c
下的sys_sleep
方法中加入backtrace
方法:
1 | uint64 |
同理,在kernel/printf.c
下的panic
中,我们也要加入backtrace
,使得内核函数执行发生panic的时候可以打印出调用栈情况:
1 | void |
测试
在目录下执行make clean; make qemu
,随后尝试执行bttest
,可以看到对应的栈调用情况:
我们甚至可以进一步代入看看对应的代码位置在哪里:
实现定时器
要求和提示
在本部分中,我们将为xv6添加一个新的功能,使得当一个进程使用CPU时间的时候周期性的发出警告。这种功能对于想要限制它们占用多少CPU时间的计算型进程,或者对于想要计算但又想采取一些定期行动的进程,可能是很有用的。更广泛地说,我们将实现一个原始形式的用户级中断/故障处理程序;例如,我们可以使用类似的东西来处理应用程序中的页面故障。我们需要通过alarmtest
来完成该部分。
我们需要在xv6中添加一个新的系统调用sigalarm(interval, handler)
,例如当程序调用sigalarm(n, fn)
的时候,那么接下来程序每消耗n
个ticks的CPU时间后,内核应该调用fn
。当fn
成功返回时,程序应当继续不受影响的运行,这部分由需要添加的系统调用sigreturn
负责完成。如果应用程序调用sigalarm(0, 0)
,内核应该停止产生周期性的调用。
P.S. 在xv6中,tick是一个相当随意的时间单位,由硬件定时器产生中断的频率决定。
alarmtest
的相关代码在user/alarmtest.c
中可见,为了使得其能够被正确识别,我们需要将其加入到Makefile中,如果对这块已经有点不记得的小伙伴可以看看Lab Utils,可能可以记起来。
由于alarmtest
文件中使用了两个我们待实现的系统调用sigalarm
和sigreturn
,务必将系统调用正确添加,记不得的小伙伴可以再去看看Lab Syscall… 后面也会继续提
在alarmtest
中在test0
调用了sigalarm(2, periodic)
,要求内核每隔2个ticks强制调用periodic()
,然后尝试自旋一段时间。我们可以在user/alarmtest.asm
中看到alarmtest
的汇编代码,这可能对调试很有帮助。
正确运行alarmtest
和usertests
的示例结果如下:
1 | $ alarmtest |
这块内容分为两部分完成,我们首先实现test0
,再实现test1
和test2
。
对于test0
而言,其功能是测试handler函数是否被正确执行了,首先我们需要修改内核代码使得内核可以跳转到用户空间中的periodic
函数,从而打印出"alarm!"
。我们在这里暂时不考虑打印之后怎么处理,如果你在打印出"alarm!"
程序直接crash也是完全正常的。以下是关于test0
的相关提示:
-
请将
alarmtest.c
加入Makefile中,使得其能够被正常编译。 -
两个系统调用需要在
user/user.h
中声明:1
2int sigalarm(int ticks, void (*handler)());
int sigreturn(void); -
你需要更新
user/usys.pl
,kernel/syscall.h
和kernel/syscall.c
来使得alarmtest
可以正确的触发sigslarm
和sigreturn
系统调用。这块不记得的可以看看Lab Syscall。 -
test0
中对sys_sigreturn
暂时没有要求,可以返回0
完事。 -
你的
sys_sigalarm()
需要在kernel/proc.h
下的proc
结构体中开辟新的空间额外存储间隔时间和handler函数的指针。 -
你需要跟踪从最后一次调用直到下一次调用,过去了(或者说还剩下)多少时间,因此我们也需要在
proc
结构体中开辟额外的空间来实现。初始化proc
结构体参数的方法详见kernel/proc.c
下的allocproc()
。 -
每一次tick周期,硬件始终都会强制中断,这块的逻辑代码由
kernel/trap.c
下的usertrap()
实现。 -
在
usertrap()
下,如果我们要控制硬件定时器中断发生时的行为,例如修改proc
中存储的时间参数,我们只需要关注如下代码下的改动:1
if(which_dev == 2)
-
请当进程有一个未完成的定时器时才可调用handler函数。注意到handler函数的地址可能是
0
,例如在user/alarmtest.asm
中,periodic
位于地址0
。 -
请修改
usertrap()
使得当进程的定时间隔过期时,用户进程可以执行handler函数。另外请思考,当在RISC-V上的陷阱返回到用户空间时,是什么决定了接下来用户空间代码开始执行的位置? -
为了方便使用GDB观察陷阱的运行,我们可以让QEMU只使用一个CPU,例如运行如下代码:
1
make CPUS=1 qemu-gdb
-
在本阶段中,只要
alarmtest
成功打印出"alarm!."
即为成功。
对于test1
和test2
而言,其功能主要是测试被中断的代码接下来是否能够继续运行。为了确保程序接下来能够继续正确的运行,我们需要在handler函数执行完毕时,返回至被定时器中断的代码位置,同时保证寄存器内容被正确的存储并恢复了。最后我们需要在每次计时结束后重置计时器的计数器,使得handler函数能不断的被周期调用。
我们假定如下的设计:用户空间的handler函数在结束时需要调用sigreturn
系统调用,参考user/alarmtest.c
的periodic
函数。这意味着我们需要在usertrap
和 sys_sigreturn
中进行修改,使得用户空间进程可以正确的继续执行下去。如下为关于test1
和test2
的相关提示:
- 为了保证被中断程序的正确运行,你需要保存和恢复寄存器。请思考一下,哪些寄存器需要你进行存储和恢复呢?
- 在
usertrap
中需要向proc
结构体中保存一些状态信息,以方便sigreturn
正确返回到原先的代码位置。 - 禁止可重入的调用handler函数,在handler函数没有返回之前,内核不应当再次调起。关于这块的测试将在
test2
中有所体现。
思路分析
好的,那么现在让我们梳理一下思路,想一下sys_sigalarm
,sys_sigreturn
和usertrap
分别需要做些什么。简单的来说,sys_sigalarm
用于设置进程的定时中断信息,并不负责具体的函数调用,例如将需要调用的handler地址和每次的间隔时间,内容自然需要额外保存在proc
结构体中。而usertrap
负责每次硬件定时器触发的中断发生的时候,检查proc
结构体内部的状态信息,看看时间有没有到了,如果到了就从内核态跳转到用户态执行handler函数。sys_sigreturn
则负责从内核态将程序转到用户态代码被中断的位置,同时把计时器ticks总时间重置,重新开始倒计时。
这里的一个需要想明白的点,是如何在内核态下跳转到位于用户态下的函数,其实也很简单,就是覆盖存储在trapframe中的epc
返回地址,最终userret
的时候,pc
寄存器从trapframe中恢复数值,跳转到我们要的用户态函数上去。我们在usertrap
定时器tick检查和sys_sigreturn
到原先被中断代码的时候都需要使用这个小技巧。
值得注意的是,usertrap
判定tick时间耗尽决定跳转到periodic
函数并执行后,寄存器的数值可能就会被破坏,那最后sys_sigreturn
从内核态恢复到原先用户代码被中断位置的时候就可能会有大问题,因此… 我们需要备份,在usertrap
决定跳转到periodic
的时候就要备份好一份trapframe,也存到proc
结构体中。
我们可以大致画一下流程图:
P.S.
关于硬件时钟中断的处理逻辑实际上在提示和流程图中做出了简化。事实上,xv6处理硬件定时器中断的行为有别于trap处理机制,当发生硬件定时器中断时,会进入到machine mode,而非supervisor mode,相关处理代码可见
kernel/kernelvec.S
下的timervec
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 timervec:
# start.c has set up the memory that mscratch points to:
# scratch[0,8,16] : register save area.
# scratch[24] : address of CLINT's MTIMECMP register.
# scratch[32] : desired interval between interrupts.
csrrw a0, mscratch, a0
sd a1, 0(a0)
sd a2, 8(a0)
sd a3, 16(a0)
# schedule the next timer interrupt
# by adding interval to mtimecmp.
ld a1, 24(a0) # CLINT_MTIMECMP(hart)
ld a2, 32(a0) # interval
ld a3, 0(a1)
add a3, a3, a2
sd a3, 0(a1)
# raise a supervisor software interrupt.
li a1, 2
csrw sip, a1
ld a3, 16(a0)
ld a2, 8(a0)
ld a1, 0(a0)
csrrw a0, mscratch, a0
mret在这里除了暂存寄存器、重置硬件定时器外,还发起了一个supervisor软件中断,从而在
mret
结束后进入到trap处理函数中。从外部来看,就是由于发生了硬件定时器中断而跳转到了trap处理函数中。
接下来,我们要考虑需要在proc
结构体中额外插入哪些东西呢?思考下来,有这么几个:
sigalarm
传入的参数,即周期间隔ticks时间和handler函数地址。周期ticks时间若为0
,则表示我们实现的定时器处于关闭状态。- 当前计时器剩余ticks时间也需要计入,这样子每次硬件时钟中断发生时,我们就将数值减一,这个剩余时间也可作为是否要执行handler函数的标准。在
sigreturn
中,我们还需要将定时器剩余时间重置,重新开始新的周期。 - 在
usertrap
中如果需要跳转到handler函数,那么就需要保存一下当前的trapframe,方便后续在sigreturn
进行恢复。 - handler函数不可重入,因此可以用一个
int
当作类似锁的组件,当handler正在执行中保持状态为1
,执行完毕为0
。如果发生了冲突,则不跳转到handler函数,即如果一个硬件时钟到期的时候已经有一个时钟处理函数正在运行,则会推迟到原处理函数运行完成后的下一tick才触发。
系统调用配置
首先我们需要将系统调用的相关配置设置好,这块内容可以参考Lab Syscall。
首先,不要忘记在主目录下的Makefile中将alarmtest.c
纳入编译范围:
1 | UPROGS=\ |
然后,我们在user/user.h
中添加系统调用的声明:
1 | // system calls |
接下来,我们在kernel/syscall.h
中添加新系统调用的编号:
1 | // System call numbers |
并在user/usys.pl
中添加系统调用项:
1 | #!/usr/bin/perl -w |
最后我们在kernel/syscall.c
的syscalls
表中添加新的映射关系,指向需要执行的函数:
1 | extern uint64 sys_chdir(void); |
至于两个关键的系统调用sys_sigalarm
和sys_sigreturn
,我们将在kernel/sysproc.c
下进行实现,不需要实现核心函数,先把参数从用户空间取出即可:
1 | uint64 sys_sigalarm(void) { |
具体的sigalarm
和sigreturn
我打算在kernel/trap.c
下实现。
完善proc
结构体
根据刚才的思考和总结,我们在proc
结构体中需要加入五个部分,在kernel/proc.h
中如下所示:
1 | // Per-process state |
由于添加了几个成员,因此我们的proc
初始化和删除函数也需要处理,见kernel/proc.c
:
1 | // Look in the process table for an UNUSED proc. |
实现usertrap
跳转
在kernel/trap.c
下的usertrap
中主要用于处理中断、异常以及系统调用。
我们在专门处理硬件定时器中断的程序if(which_dev == 2)
下加入相关代码:
1 | // |
其中主要判断当前alarm是否开启,时间是否过期需要触发handler,以及当前handler是否已经返回。如果这三者都满足,那么就可以将返回地址指向handler函数,但是在此之前还需要备份一下trapframe,并且还需要上可重入锁。
实现sigalarm
和sigreturn
接下来我们处理系统调用的核心函数sigalarm
,根据刚才的总结,我们在需要在proc
结构体中记录一些信息,包括周期ticks时间,handler函数地址,并初始化当前剩余ticks。我们将sigalarm
放在kernel/trap.c
下:
1 | int sigalarm(int ticks, void (*handler)()) { |
而在sigreturn
中,我们需要取出刚才备份保存的trapframe,并将其全部覆盖在当前的trapframe中。另外记得将可重入锁取消。我们将sigreturn
放在kernel/trap.c
下:
1 | int sigreturn() { |
最后记得在kernel/defs.h
中声明一下:
1 | // trap.c |
测试
目测问题不大,我们跑一下alarmtest
:
总结
接下来我们来到主目录上使用make grade
进行评分,有几点注意一下:
-
你需要把刚才简答题答案放在主目录下的
answer-pgtbl.txt
下。 -
你需要在主目录下创建
time.txt
表示完成任务的时间。 -
测试过程中
usertests
耗时略长,电脑机能不行的小伙伴可以适当将测试的超时延长一点点,请把主目录下grade-lab-traps
文件里面的timeout
参数改大点,不然可能你的测试项目还没跑完他就跳出给你标个大大的 FAIL…
最后我们看一下测试的结果:
看起来没啥问题,那就这样了吧。