Lab 4: traps
- Lab Guide: Lab: traps
- Lab Code: https://github.com/peakcrosser7/xv6-labs-2020/tree/traps
RISC-V assembly (easy)
预处理
- 使用如下指令编译文件
user/call.c
, 生成可读的汇编程序文件user/call.asm
$ make fs.img
- 阅读其中
g()
,f()
和main()
函数的代码.
0000000000000000 <g>:
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int g(int x) {
0: 1141 addi sp,sp,-16 # 栈顶指针下移16字节
2: e422 sd s0,8(sp) # 存栈底指针s0/fp到sp+8的位置
4: 0800 addi s0,sp,16 # 将sp+16即原sp的值作为新的栈帧
return x+3;
}
6: 250d addiw a0,a0,3 # a0=a0+3, a0即为传入参数x又为返回值
8: 6422 ld s0,8(sp) # 从sp+8恢复原栈帧到s0
a: 0141 addi sp,sp,16 # 回收栈顶指针
c: 8082 ret # 返回
000000000000000e <f>: # 与g()函数系统,相当于将g()内联了
int f(int x) {
e: 1141 addi sp,sp,-16
10: e422 sd s0,8(sp)
12: 0800 addi s0,sp,16
return g(x);
}
14: 250d addiw a0,a0,3
16: 6422 ld s0,8(sp)
18: 0141 addi sp,sp,16
1a: 8082 ret
000000000000001c <main>:
void main(void) {
1c: 1141 addi sp,sp,-16 # 栈顶指针下移16字节
1e: e406 sd ra,8(sp) # 存返回地址到sp+8的位置
20: e022 sd s0,0(sp) # 存栈底指针s0/fp到sp的位置
22: 0800 addi s0,sp,16 # 更新栈帧s0
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13 # 加载13到a2寄存器
26: 45b1 li a1,12
28: 00000517 auipc a0,0x0 # 将pc+0加载到a0
2c: 7b050513 addi a0,a0,1968 # 7d8 <malloc+0xea>
30: 00000097 auipc ra,0x0 # 将pc+0<<12加载到ra寄存器
34: 600080e7 jalr 1536(ra) # 630 <printf> # 将pc设置为ra+1536,并将pc+4写入ra(即进行函数跳转)
exit(0);
38: 4501 li a0,0
3a: 00000097 auipc ra,0x0
3e: 27e080e7 jalr 638(ra) # 2b8 <exit>
思考题
- Q1: Which registers contain arguments to functions? For example, which register holds
13
in main’s call toprintf
?
A: 函数参数的寄存器为 a0~a7.printf
的13
存在寄存器 a2 中 - Q2: Where is the call to function
f
in the assembly code for main? Where is the call tog
? (Hint: the compiler may inline functions.)
A: 在 40 行可以看到, 编译器进行了函数内联, 直接将f(8)+1
的值12
计算出来了. - Q3: At what address is the function
printf
located?
A: 由第 43 和 44 行可以看出,jalr
跳转的地址为0x30+1536=0x630
, 即函数printf
的地址为0x630
- Q4: What value is in the register
ra
just after thejalr
toprintf
inmain
?
A: 根据jalr
指令的功能, 在刚跳转后ra
的值为pc+4=0x34+4=0x38
. - Q5: Run the following code.
What is the output?unsigned int i = 0x00646c72; printf("H%x Wo%s", 57616, &i);
The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you seti
to in order to yield the same output? Would you need to change57616
to a different value?
A: 输出:HE110 World
若为大端对齐,i
需要设置为0x726c6400
, 不需要改变57616
的值(因为他是按照二进制数字读取的而非单个字符). - 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?
A: 根据函数的传参规则,printf("x=%d y=%d", 3);
y=
后跟的值应该为寄存器 a2 的值.
如图所示, 经过 gdb 调试验证,y=
后确实是寄存器 a2 的值.
这种情况发生的原因在于,printf()
的格式字符串的数量和不定参数的数量不一致, 但函数执行时仍然从原本参数应该加载的寄存器取值. 按照 RISC-V 传参规则, 第二个不定参数应该被存于寄存器 a2, 因此在实际输出时也是将 a2 寄存器的值进行输出.
Backtrace (moderate)
要点
- 编写函数
backtrace()
, 遍历读取栈帧(frame pointer)并输出函数返回地址.
步骤
- 在文件
kernel/riscv.h
中添加内联函数r_fp()
读取栈帧值
// read the current frame pointer from s0 register - lab4-2
static inline uint64 r_fp() {
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}
- 在
kernel/printf.c
中编写函数backtrace()
输出所有栈帧
函数的思路很简单, 初始通过调用上述的r_fp()
函数读取寄存器 s0 中的当前函数栈帧 fp. 根据 RISC-V 的栈结构, fp-8 存放返回地址, fp-16 存放原栈帧. 进而通过原栈帧得到上一级栈结构, 直到获取到最初的栈结构.
这里需要考虑获取上一级栈帧的终止条件. RISC-V 的用户栈空间占一个页面, 因此可以通过PGROUNDDOWN()
和PGROUNDUP()
计算得到一个地址所在的页面的最高和最低地址. 初始从寄存器 s0 读取到的栈帧 fp 是在用户栈空间中的地址, 由此可以得到用户栈的页面最高和最低地址作为循环的终止条件.
// print the return address - lab4-2
void backtrace() {
uint64 fp = r_fp(); // 获取当前栈帧
uint64 top = PGROUNDUP(fp); // 获取用户栈最高地址
uint64 bottom = PGROUNDDOWN(fp); // 获取用户栈最低地址
for (;
fp >= bottom && fp < top; // 终止条件
fp = *((uint64 *) (fp - 16)) // 获取下一栈帧
) {
printf("%p\n", *((uint64 *) (fp - 8))); // 输出当前栈中返回地址
}
}
上述代码需要注意的是强制类型转换的对象要么为 (uint64 *)(fp-8)
和 (uint64 *)(fp-16)
, 因为 8 和 16 的单位是字节; 或者为 (uint64 *) fp - 1
和 (uint64 *) fp - 2
, 因为此时 1
和 2
是以 (uint64*)
指针大小(8 字节)为单位的.
3. 添加 backtrace()
函数原型到 kernel/defs.h
.
4. 在 kernel/sysproc.c
的 sys_sleep()
函数中添加对 backtrace()
的调用.
5. 在 kernel/printf.c
的 panic()
函数中添加对 backtrace()
的调用.
测试
- 在 xv6 中运行
bttest
, 输出 3 个栈帧的返回地址; 退出 xv6 后运行addr2line -e kernel/kernel
将bttest
的输出作为输入, 输出对应的调用栈函数, 如下图所示.
根据输出的源码行号找对应的源码, 发现就是backtrace()
函数的所有调用栈的返回地址(函数调用完后的下一代码).
- 运行
/grade-traps backtrace
测试输出.
Alarm (hard)
test0: invoke handler
要点
- 添加系统调用
sigalarm(interval, handler)
和sigreturn()
- 向
kernel/proc.h
的struct proc
中添加新字段, 记录计时间隔(interval), 函数指针(handler), 以及过去的时钟数(passed ticks). sigreturn()
返回 0.
步骤
- 在
user/user.h
中添加两个系统调用的函数原型:
- 在
user/usys.pl
脚本中添加两个系统调用的相应entry
, 在kernel/syscall.h
和kernel/syscall.c
添加相应声明.
- 当前编写
sys_sigreturn()
只需要返回 0. 该函数置于了kernel/sysproc.c
文件中.
// lab4-3
uint64 sys_sigreturn(void) {
return 0;
}
- 在
kernel/proc.h
中的struct proc
结构体中添加记录时间间隔, 调用函数地址, 以及经过时钟数的字段
- 编写
sys_sigalarm()
函数, 将interval
和handler
的值存到当前进程的struct proc
结构体的相应字段中.
在这里在指导书的基础上又做了两点优化: 一方面限定了interval
的值需要非负, 根据定义interval
表示每次调用handler
函数的周期,0
特指取消调用, 而负数在这里是没有意义的, 因此将其视为非法参数; 另一方面同时重置了过去的时钟数p->passedticks
, 此处考虑到可能中途会更新sigalarm()
的调用参数, 这样之前记录的过去时钟数便失效了, 应该重新计数.
// lab4-3
uint64 sys_sigalarm(void) {
int interval;
uint64 handler;
struct proc *p;
// 要求时间间隔非负
if (argint(0, &interval) < 0 || argaddr(1, &handler) < 0 || interval < 0) {
return -1;
}
// lab4-3
p = myproc();
p->interval = interval;
p->handler = handler;
p->passedticks = 0; // 重置过去时钟数
return 0;
}
kernel/proc.c
的allocproc()
函数负责分配并初始化进程, 此处对上述struct proc
新增的三个字段进行初始化赋值.
- 每经过异常时钟间隔, 会引发时钟中断, 调用
kernel/trap.c
中的usertrap()
函数. 对于时钟中断which_dev
变量的值为2
, 由此便可以单独对时钟中断进行操作.
根据指导书要求, 由于handler
函数地址可能为 0, 因此主要通过interval==0
来判断是否终止定时调用函数.
然后每经过一个时钟中断, 对passedticks
加 1, 当达到interval
时便要调用handler()
函数, 同时将passticks
置零用于下次调用定时函数.
此处主要考虑如何调用定时函数handler()
. 这里需要注意到, 在usertrap()
中时页表已经切换为内核页表(切换工作在uservec
函数中完成), 而handler
很显然是用户空间下的函数虚拟地址, 因此不能直接调用. 这里实际上并没有直接调用, 而是将p->trapfram->epc
置为p->handler
, 这样在返回到用户空间时, 程序计数器为handler
定时函数的地址, 便达到了执行定时函数的目的.
void
usertrap(void)
{
int which_dev = 0;
// ...
if(p->killed)
exit(-1);
// lab4-3
if(which_dev == 2){ // timer interrupt
// increase the passed ticks
if(p->interval != 0 && ++p->passedticks == p->interval){
p->passedticks = 0;
p->trapframe->epc = p->handler; // execute handler() when return to user space
}
}
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
- 修改
Makefile
文件中的 UPROGS 部分, 添加对alarmtest.c
的编译.
测试
此时 test0 是可以通过的.
test1/test2(): resume interrupted code
要点
- 需要存储和恢复相关寄存器
- 确保
struct proc
保存足够的状态以可以由sigreturn
返回到中断前的用户代码 - 防止
handler
函数在返回前重入.
思路
sigalarm(interval, handler)
和sigreturn()
两个函数是配合使用的, 在handler
函数返回前会调用sigreturn()
.
根据 test0 的做法可以看到, 调用定时函数handler
实际上是通过修改trapframe->epc
进而在返回到用户空间时调用定时函数. 但这也同时产生了一个问题, 即原本的epc
已被覆盖, 无法回到中断前的用户代码执行的位置, 同时在执行handler()
函数后, 相关的寄存器的值也会受到影响.
因此考虑要在sigalarm()
函数中将寄存器值进行保存, 在sigreturn()
函数中进行恢复. 这样在执行完sigreturn()
后程序能够回到原来的执行位置.- 在系统调用时用户代码中断时会将寄存器记录到
p->trapframe
中, 而前者由于在usertrap()
覆盖了p->trapframe->epc
, 才能够执行定时函数, 执行完后又会导致一些寄存器的值被修改. 因此, 考虑在struct proc
中保存一个trapframe
的副本, 在覆盖 epc 之前先保存副本, 然后在sys_sigreturn()
中将副本还原到p->trapframe
中, 从而在sigreturn
系统调用结束后恢复用户寄存器状态时能够将执行定时函数前的寄存器状态进行恢复. - 对于
trapframe
的副本, 很显然是和struct proc
结构体关联的, 但具体实现可以有多种形式:- 缓冲区副本
char trapframebuf[288]
: 即在struct proc
中设立一个缓冲区字段来存放trapframe
副本. - 结构体副本
struct trapframe trapframecopy
: 本质上与上述缓冲区副本是一样的, 不过省去了计算开辟缓冲区大小的步骤. - 指向新分配页的指针
struct trapframe *trapframecopy
: 前两者直接将副本存到结构体中, 相当于每个proc
结构体都预先分配了该副本的空间. 而使用指针指向新分配的虚拟页, 则可以在需要时进行分配. 此处便类似p->trapframe
指针, 需要借助kalloc()
和kfree()
进行页面分配和释放. 虽然是每次使用时再分配使用完释放, 但每次分配直接分配了 1 page(4096B), 也有大部分字节的浪费. - 共用
trapframe
页面的指针struct trapframe *trapframecopy
: 这里和前者一样同样使用了指针形式. 但前者的缺点在于每次都需要额外分配释放一个页面. 而由于struct trapframe
结构体只有 288B, 而一个页面是 4096B, 可以看到为p->trapframe
分配的页面实际上有大量内存空间未被使用. 此处便是将副本trapframecopy
与trapframe
共用一个页面, 既无需在struct proc
结构体中分配内存, 又无需使用kalloc()
和kfree()
额外分配释放页面, 因此笔者最终选择了该方式.
- 缓冲区副本
- 对于对副本
trapframecopy
与trapframe
之间的拷贝操作, 容易想到有两种方式:- 结构体赋值
=
: 直接进行结构体的赋值 memmove()
字节拷贝: 即直接将结构体视为字节流, 进行字节拷贝. 经过实际测试, 发现使用该方式比结构体赋值的速度更快.
- 结构体赋值
步骤
- 修改
struct proc
结构体, 添加trapframe
的副本字段:
// Per-process state
struct proc {
// ...
char name[16]; // Process name (debugging)
int interval; // alarm interval - lab4-3
uint64 handler; // pointer to the handler function - lab4-3
int passedticks; // ticks have passed since the last call - lab4-3
struct trapframe* trapframecopy; // the copy of trapframe - lab4-3
};
- 在
kernel/trap.c
的usertrap()
中覆盖p->trapframe->epc
前做trapframe
的副本.
void
usertrap(void)
{
// ...
// lab4-3
if(which_dev == 2){ // timer interrupt
// increase the passed ticks
if(p->interval != 0 && ++p->passedticks == p->interval){
// 使用 trapframe 后的一部分内存, trapframe大小为288B, 因此只要在trapframe地址后288以上地址都可, 此处512只是为了取整数幂
p->trapframecopy = p->trapframe + 512;
memmove(p->trapframecopy,p->trapframe,sizeof(struct trapframe)); // copy trapframe
p->trapframe->epc = p->handler; // execute handler() when return to user space
}
}
// ...
}
- 在
sys_sigreturn()
中将副本恢复到原trapframe
.
此处在拷贝副本前额外做了一个地址判断, 是防止用户程序在未调用sigalarm()
便使用了该系统调用, 那么此时没有副本即trapframecopy
是无效的, 应避免错误拷贝. 在拷贝后将trapframecopy
置零, 表示当前没有副本.
// lab4-3
uint64 sys_sigreturn(void) {
struct proc* p = myproc();
// trapframecopy must have the copy of trapframe
if(p->trapframecopy != p->trapframe + 512) {
return -1;
}
memmove(p->trapframe, p->trapframecopy, sizeof(struct trapframe)); // restore the trapframe
p->passedticks = 0; // prevent re-entrant
p->trapframecopy = 0; // 置零
return p->trapframe->a0; // 返回a0,避免被返回值覆盖
}
- 修正
sys_sigreturn()
返回值: 在 2022 年的实验中, 新增了 test3(), 会对sys_sigreturn()
函数的返回值进行检查. 在实验指导的提示中增加了这样一句话: “Make sure to restore a0. sigreturn is a system call, and its return value is stored in a0”. 即 sigreturn 作为一个系统调用, 其返回值会被存在 trapframe 的 a0 中, 这样会覆盖之前memmove()
恢复的 trapframe 的 a0 字段. 因此sys_sigreturn()
函数不能像之前一样直接返回0
, 而是需要返回p->trapframe->a0
, 防止恢复的 a0 被返回值所覆盖. (感谢 Present.679 同学的指正!)
- 为了保证
trapframecopy
的一致性, 在初始进程kernel/proc.c
的allocproc()
中, 初始化p->trapframecopy
为 0, 表明初始时无副本.
- 在指导书中要求定时函数
handler
需要防重入, 即在其未返回时不能触发下一次. 这里需要的改动就是将p->passedticks = 0;
从原本的usertrap()
移至sys_sigreturn()
中.
因为在usertrap()
中重置则后续passedticks
则会继续重新递增, 自然可能满足调用handler
的条件; 而移至sys_sigreturn()
之后, 即在最后函数返回前才会清零, 按照该系统调用的正确使用方法,sigreturn()
的结束就应该标志着handler()
的结束, 也就是说在handler()
还未结束时,passedticks
会继续递增, 从而不会再满足调用handler
的条件, 自然就可以避免重入. 上述代码已经是已经过修改.
测试
- 在 xv6 中执行
alarmtest
和usertests
均通过:
/grade-lab-traps alarmtest
单项测试:
make grade
测试: