
这学期的操作系统课做到 lab4我盯着题目列表愣了好一会儿——一段 RISC-V 汇编问答、一个栈回溯、一个定时闹钟。如果只看名字这三件事像是互相独立的小作业但做完之后回头看就会发现它们其实是同一个问题的三个阶段用户进程到底是怎么被内核打断又怎么在打断之后完好无损地回到原来的执行现场。这篇东西就是我完成 xv6 lab4traps 实验之后的完整复盘包含解题思路、关键代码和几次差点让人怀疑人生的调试经历。不同课程对 lab4 的编号和内容不完全一样但我这里做的是 xv6 经典系列里的 traps 实验如果你也是正在写操作系统实验报告或者对 RISC-V 的 trap 机制只有一个模糊印象这份内容应该能让你少走几条弯路。1. 先别急着改代码lab4 到底在考察什么1.1 从 lab1 到 lab4一条完整的权限切换主线很多同学做 xv6 系列实验时会把每个 lab 当成独立关卡lab1 写几个用户程序lab2 加两个系统调用lab3 折腾页表lab4 又开始搞汇编。这样一个个通过其实没错但会错过一条暗线——从 lab1 到 lab4xv6 是在让你逐步看清一次系统调用背后要穿过多少层防线。lab1 和 lab2 只是站在用户态调用内核态的处理逻辑对调用者完全透明lab3 开始接触页表你知道了用户地址和内核地址不是同一个世界到 lab4内核终于把权限切换这件事完全摊开当用户进程执行ecall指令、遇到异常、或者收到时钟中断时CPU 会从用户态一脚踏进内核态经由 trampoline 跳到内核的 trap 处理入口用 trapframe 存下现场处理完再从 trapframe 恢复现场并返回用户态。traps 实验的三道题就是围绕这条主线的三次由浅入深。汇编问答是热身考察你对 RISC-V 寄存器约定和编译结果的基本感觉backtrace 让你顺着栈帧回溯调用链本质是理解现场在内存里长什么样alarm 则要求你主动劫持一次时钟中断把流程引到用户自定义的 handler 上再原样恢复现场等价于实现一个极简版的信号机制。所以写实验报告时我不建议把三道题分别回答完就收工。更好的组织方式是先讲清楚 trap 机制的整体路径再说明每个题目分别补全了路径上的哪一块。这样读者一眼就能看出你不是在抄代码而是真的理解了这套机制。1.2 实验环境与源码结构速览我这次用的环境是 xv6-riscv 源码加 qemu 模拟器配合 RISC-V 64 位工具链编译和运行都是老一套make qemu进入系统make grade跑评分脚本。源码里需要重点关注的文件有这几个kernel/trap.c中断和异常的统一入口 usertrap以及时钟中断的处理分支kernel/sysproc.csys_sigalarm 和 sys_sigreturn 两个系统调用的实现位置kernel/proc.h进程结构体需要给它扩展闹钟相关字段kernel/printf.cbacktrace 的实现位置题目要求在sys_sleep里调用它user/alarmtest.c闹钟实验的测试程序包含 test0/test1/test2 三个阶段。如果你之前没注意过kernel/trampoline.S和kernel/trapframe.h这个实验最好先打开看一眼。trampoline 是用户态和内核态切换的第一段汇编代码它保证即使页表被换掉这段代码仍然同时映射在用户地址空间最高页和内核地址空间最高页trapframe 则是每个进程在用户地址空间里预留下的一块内存用于存取所有寄存器的快照。理解了这两样东西后面看 alarm 实验为什么要保存一个独立的 trapframe 副本就会顺理成章。1.3 三个绕不开的概念trapframe、trampoline 与 kernelvec实验报告里最容易被忽略、但我认为最该写清楚的是这三个概念的分工。trapframe 是每个进程独立拥有的一块结构体里面从 ra、sp 到所有通用寄存器、sstatus、epc 都被记录在案。用户态代码不需要知道它的存在但内核每次从用户态陷入时都会先把寄存器的值存进去返回时再从里面取出来从而保证用户程序的现场丝毫无损。trampoline 是一段被双映射的代码同时出现在用户页表最高页和内核页表最高页。为什么不能只映射一份因为从用户陷入内核时需要换页表如果 trampoline 不在新页表里换完页表后 CPU 就会因为取不到下一条指令而崩溃。双映射解决的就是这个换挡瞬间不能断油的问题。kernelvec 是内核态异常入口。用户态的 trap 经过 trampoline 后会走到它它做的事情很朴素把当前内核栈指针、寄存器保存好然后调用 usertrap 进入 C 语言世界。你可以把这条路径想象成用户在家里用户态处理正事突然收到一个通知通过 trampoline 这条公共走廊溜进公司内核态在 trapframe 这个储物柜里存好随身物品然后由 kernelvec 前台带去见 usertrap 经理。整个 lab4你实际上就是在跟这条路径打交道。2. Backtrace用 RISC-V 栈帧布局做一次寻亲2.1 为什么要做栈回溯崩溃时最需要知道谁叫我来的系统卡死或 panic 时打印出当前程序计数器只能告诉我我在哪里但没法告诉我我是怎么到这里来的。栈回溯要做的事就是沿着函数调用链一层层往上找把所有尚未返回的调用点return address都打出来。调试器里常见的 call stack 就是这样实现的。xv6 的 backtrace 题要求在kernel/printf.c里实现一个backtrace()并在sys_sleep中调用它让它在控制台打印出调用链上的返回地址。看起来很简单但如果你不理解 RISC-V 的栈帧布局可能会把指针算错打印出一堆乱码甚至直接把内核搞崩溃。2.2 RISC-V 栈帧布局fp、ra、sp 三者的关系先复习一下 x86 会更容易抓住要点。x86 的传统栈帧用rbp做 frame pointer返回地址通常保存在rbp 8前一个栈帧的rbp保存在rbp指向的位置。RISC-V 的约定略有不同按编译器默认布局每个非叶函数的栈帧里返回地址保存在当前帧的fp - 8偏移处上一帧的 fp 保存在fp - 16偏移处。这个布局是编译器和 ABI 共同约定的不是 CPU 硬件强制要求的。大致可以画成这样基于 gcc 对 RISC-V 的常见生成结果高地址 ------------------ | caller 的栈帧 | ------------------ | 返回地址 | - fp - 8 | 上一帧 fp | - fp - 16 | 本函数局部变量 | - fp ------------------ 低地址这里的 fp 一般会映射到寄存器s0。所以回溯的思路很直接拿到当前s0作为 fp读fp - 8得到返回地址读fp - 16得到上一帧 fp然后让 fp 等于上一帧 fp继续循环直到到达栈的边界。边界判断是这道题最容易忽略的细节。用户态的栈从USTACKTOP也就是MAXVA - PGSIZE往低地址生长内核栈类似也是一页往低地址生长。遍历时如果发现 fp 落在[PGSIZE, MAXVA)区间之外就可以认为栈链已经结束或者栈已经被破坏此时应当停止否则可能去读一个完全无效的地址触发缺页异常。很多同学写while (fp ! USTACKTOP)这种方式在特殊首帧情况下可以工作但遇到栈被破坏或边界不理想时会死循环不如用区间判断更稳。2.3 代码落地的完整过程与边界判断实现方式我分三步来写。第一步需要一个读取 fps0的辅助函数。这个函数不能直接用 C 变量去拿 s0因为 C 变量在栈上s0 是寄存器。最可靠的做法是用内联汇编static inline uint64 r_fp(void) { uint64 x; asm volatile(mv %0, s0 : r (x)); return x; }第二步在kernel/printf.c里实现backtrace()void backtrace(void) { uint64 fp r_fp(); uint64 ra; printf(backtrace:\n); while (fp PGSIZE fp MAXVA) { ra *(uint64 *)(fp - 8); printf(%p\n, ra); fp *(uint64 *)(fp - 16); } }这里有个顺序上的小细节不要在循环里先改 fp 再打印 ra因为printf会创建自己的栈帧虽然它用的是自己的 fp但不影响我们已经保存在局部变量里的 fp。先把 ra 和下一次 fp 读出来再调用 printf逻辑上更安全也不会被 printf 内部的栈操作干扰。第三步按题目要求调用它。xv6 标准做法是在sys_sleep开头加一行uint64 sys_sleep(void) { int n; uint ticks0; ... backtrace(); // 实验验证用真实系统不会在 sleep 里回溯 ... }有的同学想直接在panic里调用backtrace这其实更有价值——拿到的调用链能直接指出 panic 发生在哪一层。不过实验要求明确我建议先按要求实现在实验报告里再提一句生产环境更适合放在 panic 或异常处理中反而显得有思考。2.4 验证跑一次真实的调用链在 qemu 里随便运行一个会触发 sleep 的程序比如执行sleep 1如果实现正确终端会打印一串地址。要验证这些地址确实是调用链上的返回地址最干净的办法是用 qemu 的 gdb 支持make qemu-gdb启动带调试的 qemu再用 gdb 连接在sys_sleep打断点打印当前 fp 附近的字段确认与代码输出一致。我在实验报告里通常会把一次输出样例贴出来并注解每一行的含义最下面一般是sys_sleep的调用点往上能看到syscall、usertrap的帧直到链结束。这样报告读起来就不是空洞的我实现了 backtrace而是我知道它为什么输出这几行。3. 闹钟实验让内核在指定时刻温柔地打断用户进程3.1 三道测试题的递进关系alarm 实验先给出一个测试程序alarmtest.c里面包含三个阶段的要求。我建议大家不要急着开写先用十分钟读懂这三个测试分别想卡住什么。test0 是最朴素的闹钟用户进程调用sigalarm(interval, handler)注册一个定时回调主程序每过段时间检查计数器。期望结果是每隔 interval 个时钟 tickhandler 被调用一次主程序从被打断的地方继续往下走。这一步考察的是基本劫持-恢复流程。test1 增加了重入检查它让 handler 执行时间很长里面循环等若干 ticks如果内核在 handler 还没返回时又触发了一次新的 handler就会导致深度递归、栈溢出。期望结果是 handler 执行期间新到达的闹钟不得再次进入 handler。test2 更进一步handler 内部还调用了write等系统调用。听起来无非是更复杂的执行路径但它真正考验的是你保存的原现场会不会被 handler 自身的系统调用给冲掉。如果保存现场时没有用独立空间或者恢复时机不对后面的调用链就会全乱。所以这道题不是简单地在中断里跳个转而是要把定时计数、现场保存、防止重入、恢复现场四个环节全部做对。3.2 进程结构体扩展与 sys_sigalarm 的实现动手改代码前先确认系统调用怎么注册。在kernel/syscall.h里新增两个编号在kernel/syscall.c里把sys_sigalarm、sys_sigreturn挂到对应位置同时在user/user.h里声明用户态函数。这些都是机械操作如果遗漏测试程序会报unknown sys call。接着给proc结构体加四个字段都在kernel/proc.h里struct proc { ... int alarm_interval; // 多少 ticks 触发一次0 表示关闭 uint64 alarm_handler; // 用户态 handler 的虚拟地址 int alarm_ticks; // 距上次触发已走过的 ticks int alarm_handler_active; // 1 表示当前正处于 handler 中 struct trapframe alarm_saved_trapframe; // 进入 handler 前的完整现场 };这里有一个关键选择保存现场用struct trapframe类型的内嵌变量而不是一个指针。为什么不用指针因为指针通常会指向p-trapframe而每次用户陷入内核时硬件和 trampoline 都会往p-trapframe里写当前寄存器值。也就是说trapframe 本身就是一块每陷入一次就刷新一次的内存。若用指针指向它等 handler 跑了一会儿之后再想恢复进入 handler 前的现场读到的可能早已是新的 trapframe 了。独立的alarm_saved_trapframe是一份只在我们主动保存的时刻写一次的副本不会被后续系统调用覆盖。代价是每个进程多占一点内存但对教学内核可以接受。sys_sigalarm的实现则比较直接uint64 sys_sigalarm(void) { int interval; uint64 handler; struct proc *p myproc(); if (argint(0, interval) 0 || argaddr(1, handler) 0) return -1; p-alarm_interval interval; p-alarm_handler handler; p-alarm_ticks 0; return 0; }这里值得在实验报告里解释的是argint和argaddr是 xv6 提供的参数读取接口它们会从当前进程的 trapframe 里取出用户传入的 a0/a1 寄存器值。用户传入的是用户态的值不能直接把它当内核指针去解引用。interval 为 0 时usertrap 里的判断会直接跳过触发逻辑这就实现了sigalarm(0, 0)的关闭语义。3.3 usertrap 中的跳转逻辑保存现场与修改 epc真正的核心逻辑在kernel/trap.c的usertrap里。xv6 原本的时钟中断分支大致是if (which_dev 2) { yield(); }yield 会切换进程等这个进程再次被调度回来时trapframe 里记录的还是陷入时的现场CPU 会从原来的 pc 处继续执行。我们要做的是在这个分支里插入一段劫持逻辑if (which_dev 2) { if (p-alarm_interval 0) { p-alarm_ticks 1; if (p-alarm_ticks p-alarm_interval) { p-alarm_ticks 0; if (p-alarm_handler_active 0) { // 保存当前陷入现场的完整副本 *p-alarm_saved_trapframe *p-trapframe; // 修改返回地址让用户态下一条指令变成 handler p-trapframe-epc p-alarm_handler; p-alarm_handler_active 1; } } } yield(); }为什么是修改epc而不是修改raepcexception program counter是返回用户态时 CPU 恢复执行的指令地址trampoline 的userret会把它写回sepc再通过sret返回。handler 本质上就是一个用户态函数只要让 epc 指向它下一次返回用户态时CPU 自然会执行 handler 的第一条指令。用户态的栈指针 sp 不需要改因为 handler 和主程序共享同一个用户栈handler 按 RISC-V 调用约定自己管理栈帧即可。3.4 sigreturn 为什么必须恢复整个 trapframe如果只改 epchandler 确实会被执行但问题马上就来了主程序被中断时现场还留在 trapframe 里等 handler 执行完它必须回到主程序被打断的位置继续走而不是回到 handler 开头再跑一遍。这正是sigreturn的职责。在kernel/sysproc.c里实现uint64 sys_sigreturn(void) { struct proc *p myproc(); *p-trapframe *p-alarm_saved_trapframe; p-alarm_handler_active 0; return 0; }这段代码的语义很直白把进入 handler 前保存的那份完整现场覆盖回当前的p-trapframe然后清除 active 标志。返回用户态后CPU 从恢复后的 epc 继续执行也就是主程序原来被时钟中断打断的那条指令之后仿佛中间那段 handler 从未发生过。这里的关键是整个结构体覆盖而不是只恢复几个寄存器。因为中断可能在用户进程执行的任意一条指令处发生所有寄存器都可能是活跃的。如果你只恢复 epc 和 spa0 到 a7 里保存的中间计算结果就全丢了主程序后续运算一定出错。这个设计思路和真实操作系统里保存/恢复上下文的做法是一致的。3.5 重入问题的完整解法现在解释 test1 和 test2 重点考察的重入保护。我的做法是在触发分支里先检查alarm_handler_active为 0 才保存副本、设置 epc为 1 说明当前正处在 handler 内部即使计数又满了一个 interval也不会再次劫持。同时无论是否重入我都会把alarm_ticks清零。这里为什么要清零如果不清零handler 执行期间alarm_ticks会持续累加等 handler 通过 sigreturn 结束后只要计数还有余量很可能立刻又满足 interval的条件导致主程序刚恢复一下就又被劫持看起来就像闹钟在连发。清零之后闹钟的节奏被重置handler 结束后要重新等待完整的一个 interval语义更接近真实信号。有个容易被忽略的微妙点handler 内部如果调用了write这类系统调用会再次陷入内核、更新 trapframe但并不会覆盖alarm_saved_trapframe因为这个副本只有在第一次触发 handler 时才写入。之后无论发生多少次普通系统调用或时钟中断它的内容都保持不变直到 handler 调用 sigreturn 把它取回。这正是 test2 能通过的根本原因。实验报告里我会建议把为什么不需要加锁也写一笔xv6 中同一个进程同一时刻只会被一个 CPU 调度执行usertrap 处理时钟中断时另一个 CPU 不会同时运行这个进程并修改它的 proc 字段因此这里的读写天然是串行化的。这不是侥幸而是 xv6 进程模型保证的性质。4. 调试手记闹钟实验差点让我崩溃的三个瞬间4.1 trapframe 被覆盖的诡异现象我第一次实现时图省事把 handler 地址直接写进了p-trapframe-epc保存现场用了一个指针变量指向p-trapframe。跑 test0 的结果是主程序刚开始执行handler 莫名其妙被调用了很多次有一次还让 qemu 直接 panic。用 gdb 打断点跟踪后发现alarm_saved_trapframe的内容在不断增加每次新中断都会往当前 trapframe 里写旧现场早就被覆盖得不成样子。等到 handler 调用 sigreturn 时恢复出来的是一个已经被第二次甚至第三次中断改写过的现场程序自然就跑飞了。这个坑的教训很简单凡是用于恢复原状的数据必须放在不会被常规流程自动改写的地方。独立的一份 trapframe 副本正是为了对抗这一点。4.2 重入导致的栈越深、越跑越乱解决了第一部分之后test1 又给了我当头一棒handler 里循环等待时闹钟计数还在走次数一满内核又往同一个alarm_saved_trapframe里保存了新的现场并再次把 epc 设为 handler。于是 handler 还没退出又从头开始跑一层套一层用户栈越来越深最后 qemu 报出用户栈溢出或者非法指令。加上alarm_handler_active标志后情况立刻好了。有个细节是active 标志必须在 sigreturn 里清 0而不能在 handler 调用任何系统调用时清。如果你在 sys_sigalarm 里顺手清 0那么在 handler 内部重新设置闹钟时会误以为自己不在 handler 中下一次时钟中断就能重入test2 就会失败。这也是我把 active 的清除动作专门放在 sigreturn 里的原因。4.3 用 gdb 看清 usertrap 的每条分支调试这个实验时我最常用的操作是在usertrap入口打断点然后每次触发时检查几个关键值which_dev、p-alarm_ticks、p-trapframe-epc和p-alarm_handler_active。命令大致是(gdb) b usertrap (gdb) c (gdb) p which_dev (gdb) p *p-trapframe (gdb) p p-alarm_ticks (gdb) p p-alarm_handler_active观察which_dev是不是 2可以确认当前是否是时钟中断观察alarm_ticks是否在预期位置归零可以确认计数逻辑没走错分支观察 epc 是否在触发后被改成了 handler 地址则可以确认劫持是否生效。配合si单步进入 trampoline 的userret能看到 sret 之后 CPU 确实跳到了 handler 的第一条指令这对理解整个 trap 机制帮助巨大。5. 从 lab4 延伸出去trap 机制还能干点什么5.1 通往真实系统信号机制的门票alarm 实验实现的这套定时回调 现场恢复 防重入本质就是 Unix 信号signal的极简版。真实操作系统的SIGALRM、SIGINT处理流程会多考虑用户态与内核态栈切换、信号屏蔽字、可重入函数等问题但底层的用户态 handler sigreturn思路是完全一致的。做完 lab4 再去看真实系统的 signal 实现很多名词就不再抽象了。5.2 调试器、快照和性能分析背后的 trap另一个非常自然的延伸是调试器。gdb 这种工具在用户程序里下断点依赖软件断点指令触发 trap内核把控制权交给调试器单步执行也一样靠的是 CPU 的单步异常。从这个角度看lab4 不光是一次课程作业它把一切执行流的转移都可以用 trap 机制接管这个底层事实摆到了你面前。5.3 如果再做一遍实验报告我会怎么写每次写完 lab4 实验报告我都会发现同一类问题太多人把代码完整贴上去然后配一句运行成功。这种报告对自己没有沉淀对读者也没有价值。更好的写法是实验目的用一句话概括你对 trap 机制的理解而不是抄题目代码只贴关键片段重点是解释每个字段、每个分支为什么是它把调试过程中遇到的两三个反直觉瞬间写下来这是报告最有含金量的地方最后贴运行输出并逐行解释这些输出证明了机制的哪一部分行为。比如 backtrace 的输出我会说明哪一行对应sys_sleep哪一行对应syscall哪一行是usertrapalarm 的输出我会说明为什么 test0 显示 handler 被调用了预期次数为什么 test1 没有出现重入为什么 test2 在 handler 内部调用系统调用之后主程序还能正确恢复。这样写出来的报告哪怕代码不是最优解也一定能让人看出你是真正做过的。如果让我重新做一遍我会把 backtrace 从sys_sleep里挪出来封装成内核的一个通用调试手段在 panic 时自动打印调用链。这个改动对实验评分没什么帮助但对一个操作系统的可观测性来说价值是实打实的——毕竟真实内核的 panic 信息里栈回溯是工程师第一眼要找的东西。