xv6 Lab4 Traps - MIT 6.1810 Fall 2025 Operating System
本文最后更新于 2026年8月8日 上午
做这个 lab 中的一些感触:最好还是选择 Fall 2020 / Fall 2021 版本的,别选 2025 的。因为中文互联网上关于这门课的资料集中于前两个版本,各种源码分析也是。
阅读
本来是看录课的,但是发现自己看不太进去,遂又转向读文档。
归纳一下比较重要的机制:
mode
supervisor mode 相比 user mode 只有两个特殊权限:
- 可以读写控制寄存器
- 可以使用
PTE_U标志位为 0 的 PTE。当PTE_U标志位为1的时候,只有用户代码可以使用这个页表;如果这个标志位为 0,则只有 supervisor mode 可以使用这个页表
没有的权限/设定:
- 不能读写任意物理地址,像普通的用户代码一样,也需要通过 page table 来访问内存
- trap 机制不控制 kernel 查看寄存器的内容,只是保存
一些重要的寄存器
- 如SATP(Supervisor Address Translation and Protection)寄存器,它包含了指向page table的物理内存地址(详见4.3)。
- 如STVEC(Supervisor Trap Vector Base Address Register)寄存器,它指向了内核中处理trap的指令的起始地址。
- SEPC(Supervisor Exception Program Counter)寄存器,在trap的过程中保存程序计数器的值。
ecall
ecall 是 RISC-V 提供的一个系统指令,执行:
- 将代码从 user mode 改到 supervisor mode
- 将程序计数器的值保存在了 SEPC 寄存器
- 跳转到 STVEC 寄存器指向的指令(所谓的跳转就是把 PC 指向这里)
对于 xv6 而言,STVEC 就是指向 trampoline page,包含 trap 处理代码。
ecall并不会切换page table,这是ecall指令的一个非常重要的特点。所以这意味着,trap处理代码必须存在于每一个user page table中。因为ecall并不会切换page table,我们需要在user page table中的某个地方来执行最初的内核代码。而这个trampoline page,是由内核小心的映射到每一个user page table中,以使得当我们仍然在使用user page table时,内核在一个地方能够执行trap机制的最开始的一些指令。
ecall 执行的东西很少,这是 RISC 的体现。
uservec
用于保存用户寄存器的汇编函数。
基本来说,就是每个进程都有一个 trapframe结构体,对应每个 user page table 有一个 trapframe page(在 syscall lab 中见过了),把所有寄存器定义了一遍。汇编的函数实现也就是一堆 sd ra, 40(a0) 的存储。
但是在这之前,还需要做一件事情:
- 在进入到 user space 之前,内核会将 trapframe page 的地址 0x3fffffe000 保存在 SSCRATCH 这个寄存器中
uservec的第一行,执行csrrw a0, sscratch, a0,意思是交换两个寄存器的值
但是这好像是 2020 年的版本,新版已经是 csrw sscratch, a0 li a0, TRAPFRAME 了。

这里寄存器交换的意义(主要是 a0 的意义):
a0 指向 trapframe,后续的汇编代码采用 a0 作为基址寄存器。
然后:
- 将 kernel 的栈顶指针加载到寄存器 sp 寄存器中
- 将 kernel 的 hartid (CPU 核编号)加载到 tp 寄存器中
- 将
usertrap()函数的地址加载到 t0 寄存器中(打印出来是0x800027a0,属于虚拟地址空间中的 kernel text 区域) - 将 kernel page table 的指针加载到 t1 寄存器中
- 切换 page table,将 kernel page table 的指针加载到 satp 寄存器中
- 通过 jump 指令跳转到函数:
usertrap(),这是内核的C代码
总结一下做了什么:
- 保存用户寄存器数据
- 为内核代码设置好堆栈空间
- 切换页表
usertrap
细节略了,太多了,根本记不住。
usertrap 负责判断触发 trap 的原因,并执行响应的处理:
- 若是 syscall,则调用对应的系统调用
- 若是设备中断,则跳转到响应的处理代码
- 若是 page fault,则杀死进程
- 最后执行
usertrapret()函数
一些修改
从后面开始的部分,2025 的版本和 2020 的不一样。
引用
新版 xv6 已经没有
usertrapret()了,但功能没有消失,只是被重命名并重新组织了。官方在 2025 年 10 月将:
usertrapret()改成了:
prepare_return()(GitHub)
现在的返回路径是:
usertrap() ├─ prepare_return() └─ return 用户页表的 satp ↓ trampoline.S: userret ├─ 切换到用户页表 ├─ 恢复用户寄存器 └─ sret 返回用户态其中
prepare_return()负责旧版usertrapret()的大部分准备工作:
- 设置
stvec = uservec- 填充 trapframe 中的内核信息
- 设置
sstatus- 设置
sepc随后
usertrap()返回,汇编代码直接进入userret。当前源码中已经搜索不到usertrapret。(GitHub)所以看 2020 课程时,可以直接这样对应:
旧版 usertrapret() ≈ 新版 prepare_return() + usertrap 返回 satp + userret你上传的 2025 版 xv6 book 也已经改为讲解
prepare_return()。
RISC-V assembly
一系列不是很难的题目,就直接把答案放出来了:
代码
Which registers contain arguments to functions? For example, which register holds 13 in main's call to printf?
RISC-V 使用 a0~a7 传递前八个函数参数。在 main 对 printf 的调用中,数值 13 位于 a2 寄存器中。
Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.)
没有调用 f 和 g 函数,编译器内联了 f(8) + 1 的表达式为 12,表现为汇编代码 li a1,12
At what address is the function printf located?
根据 30: 6c6000ef jal 6f6 <printf> 这一行,位于 6f6
What value is in the register ra just after the jalr to printf in main?
根据 RISC-V 文档,`jal` 跳转到目标函数时,会把下一条指令的地址保存到 ra,作为目标函数执行完毕之后,跳转到的地址,所以是 0x30 + 4 = 0x34
Run the following code.
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, (char *) &i);
What is the output? Here's an ASCII table that maps bytes to characters.
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 change 57616 to a different value?
输出是 HE110 World$。%x 把 57616 按 16 进制输出则得到 0xe110,然后对于 %s 由于 RISC-V 是小端序,则 i = 0x00646c72,转换成 4 字节无符号整数之后从小到大依次为 r l d 0。如果是大端序则设置 i = 0x726c6400
Here's a description of little- and big-endian and a more whimsical description.
In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?
printf("x=%d y=%d", 3);
读到的会是 a2 寄存器里面残存的值,属于 UB 行为。Backtrace
需要沿着内核栈中的栈指针向上遍历,打印当前函数调用链中的返回地址。
考虑这个核心原理:
当前 fp
fp - 8 → 当前栈帧保存的返回地址
fp - 16 → 调用者的 fp那么,这样不断循环就可以了。
hint 1:
Add the prototype for your
backtrace()tokernel/defs.hso that you can invokebacktraceinsys_pause.
没什么好说的,照做。
hint 2:
The GCC compiler stores the frame pointer of the currently executing function in the register
s0. In the section marked by #ifndef ASSEMBLER ... #endif, add the following function tokernel/riscv.h:static inline uint64 r_fp() { uint64 x; asm volatile("mv %0, s0" : "=r" (x) ); return x; }and call this function in
backtraceto read the current frame pointer.r_fp()uses in-line assembly to reads0.
这也没啥好说的。照做。
hint 3:
就是所说的那个核心原理。
具体到代码上的处理:
uint64 ra = *(uint64 *)(fp - 8);
fp = *(uint64 *)(fp - 16);hint 4:
Your
backtrace()will need a way to recognize that it has seen the last stack frame, and should stop. A useful fact is that the memory allocated for each kernel stack consists of a single page-aligned page, so that all the stack frames for a given stack are on the same page. You can usePGROUNDDOWN(fp)(seekernel/riscv.h) to identify the page that a frame pointer refers to.
意思就是,我们是需要循环的,而循环的终止条件就是 fp 仍然有效,而 fp 的有效性就是看页面是否有效。
首先取得当前 fp 所在页面的起始地址:
uint64 stack_bottom = PGROUNDDOWN(fp);页面结束地址:
uint64 stack_top = stack_bottom + PGSIZE;只要 fp 还在这个页面内,就可以继续遍历:
fp >= stack_bottom + 16 && fp < stack_top别的没什么好说的,照做即可。
代码
void
backtrace(void)
{
printf("backtrace:\n");
uint64 fp = r_fp();
// 当前内核栈所在页面的范围
uint64 stack_bottom = PGROUNDDOWN(fp);
uint64 stack_top = stack_bottom + PGSIZE;
while(fp >= stack_bottom + 16 && fp < stack_top){
// 当前函数返回到调用者后的地址
uint64 ra = *(uint64 *)(fp - 8);
printf("%p\n", (void*)ra);
// 切换到调用者的栈帧
fp = *(uint64 *)(fp - 16);
}
}Alarm
按照 hint 照做。
hint 1 2 3 没什么好说的,唯一值得注意的是,sys_sigalarm 和 sys_sigreturn 函数签名我放在了 sysproc.c 中。
hint 4 5,proc.h 这里记录地址,是直接写 uint64 的,后面强转成指针即可。
hint 6 7,本质是一个点,其实只看 hint7 的指示就可以了。
hint 8 9,照着说明去做。
- hint 8 的意思是,判断 alarm 是否有效,应该使用
p->alarm_interval > 0 - hint 9 的意思是,alarm 到期时,执行
p->trapframe->epc = p->alarm_handler;以改变跳回之后的程序计数器。
hint 10 之后的内容,是这题的重头戏。
hint 11 问,应该保存哪些寄存器?并且提示有很多。答案是应该保存所有的用户寄存器。因为执行 handler 是可能改变很多寄存器的内容的。最简单的方案,是保存整个 trapframe。
因而,hint 12 就是解决这一点的。在 struct proc 中加一个 struct trapframe alarm_trapframe:
p->trapframe:当前正在使用的用户现场,会随着 handler 和系统调用变化p->alarm_trapframe:alarm 发生前原程序现场的固定快照
解决方案,就是:
memmove(&p->alarm_trapframe, p->trapframe, sizeof(struct trapframe));
p->trapframe->epc = p->alarm_handler;memmove() 是后面的参数复制到前面。
hint 13,主要是解决防止 handler 重入的问题。具体而言:handler 运行期间,显然可能重新发生定时器中断,若再次触发 alarm,则原来的 handler 递归进入自己,导致 alarm_trapframe 被执行 handler 过程中的 trapframe 覆盖,sigreturn() 永远无法找到被中断的源程序。
解决方案是加一个 flag int alarm_handling;,在 allocproc 中初始化为 0,只有满足 p->alarm_interval > 0 && !p->alarm_handling 时才能累计触发 alarm,并且触发时置 p->alarm_ticks = 0; 和 p->alarm_handling = 1;。
最后是 hint14,sys_sigreturn() 将备份恢复到当前 trapframe,也就是:
memmove(p->trapframe,
&p->alarm_trapframe,
sizeof(struct trapframe));sigreturn() 是 handler 的最后一步,用来告诉内核 handler 执行结束了,请把我送回原来的程序。
但是 a0 特殊,因为 a0 被认为是返回值。而阅读 syscall() 的代码,会发现执行系统调用的方式是:
p->trapframe->a0 = syscalls[num]();即,必须要保存好原始的 a0 并作为 sigreturn() 的返回值。
uint64
sys_sigreturn(void)
{
struct proc *p = myproc();
uint64 old_a0 = p->alarm_trapframe.a0;
memmove(p->trapframe, &p->alarm_trapframe, sizeof(struct trapframe));
p->alarm_handling = 0;
return old_a0;
}运行 make grade:
