
2. 整体思路从“代码能跑”到“看得懂为什么能跑”阅读源码这件事我个人的习惯是“三层递进”先跑通实验、再把关键函数逐个点进去看、最后对着手册把最反直觉的设计搞明白。rcore 系列也不例外。第四篇我给自己定的目标是把异常与中断这条链路彻底读穿。说彻底是因为它几乎牵涉到内核里所有“承上启下”的模块——特权级切换、地址空间切换、内核栈分配、trap 上下文保存与恢复。如果想只停留在“知道有个 trap_handler 函数”的层面那你只是会用还没到能改的程度。而实验做到后面不管是写更大的内核、扩展系统调用还是调研调度器都躲不开这一块。所以这篇文章的核心就一句话读透 trap 相关的汇编入口、C 处理函数和上下文切换机制再把每一个“为什么要这样写”的细节抠明白。3. 顺着异常与中断的入口走一遍读 trap 相关代码第一步要先建立“由外到内”的视角。从 CPU 视角看异常与中断的入口是固定的stvec 寄存器指向的地址就是异常入口。rcore 里这个入口在trap.S中通过set_vector函数设置发生在内核初始化阶段。整个过程大致是CPU 触发异常比如指令非法、地址越界、外部中断硬件自动完成一部分现场保存关闭中断使能sstatus的 SIE 位清零切换到内核态sstatus的 SPP 位记录来源特权级把当前 PC 写入sepc依据异常原因写入scause跳转到stvec指向的地址也就是trap_entry汇编入口汇编代码完成剩余现场保存并调用trap_handler函数trap_handler根据scause分发处理处理完成后返回汇编代码恢复现场执行sret回到用户态继续运行。读到这里你可能会觉得“这不就是中断处理的教科书流程吗”。没错但真正的难点不在框架而在细节——特别是那些被汇编指令掩盖掉的硬件行为。4. 核心机制逐一拆解4.1 为什么需要stvec与中断委托别把所有异常都揽进内核先回答一个我在读代码时差点放过去的疑问既然我们的内核需要处理所有异常为什么不直接让stvec指向一个巨型处理函数把所有情况都包圆答案是效率加上一些体系结构上的讲究。在 RISC-V 特权级架构里stvec是 supervisor 模式的异常入口而mtvec是 machine 模式的异常入口。rcore 运行在 supervisor 模式所以使用stvec。但即便如此我们依然可以通过 RISC-V 的中断委托机制medeleg/mideleg这里指 M 模式把一部分异常委托给 S 模式处理把不同来源的 trap 分派到不同入口。rcore 这个教学内核的做法比较简单粗暴所有 trap 都汇聚到trap_entry再由trap_handler分类处理。这种设计的优点主要是实现简单、逻辑清晰符合教学项目的定位。但理解这一点能帮你建立“异常分发的开销是可以优化的”这个意识——比如在 Linux 里不同中断类型确实可以配置到独立入口避免额外分类判断。4.2 临时寄存器里的秘密tp与sscratch的配合trap.S里最精彩的段落其实刚刚开始也就是用tp和sscratch做实现在不同特权级之间“快速换栈”的那几行汇编。我用实际代码路径说明一下如果异常来自用户态sscratch已被初始化为内核栈地址那么进入 trap 时sscratch保存的是内核栈指针此时汇编会把用户栈指针临时放进tp然后从sscratch加载内核栈指针到sp再通过 swap 完成交换。这里的关键点是RISC-V 没有特权级自动切栈的硬件机制不像 x86 有 TSS 可以自动切换特权栈所以操作系统需要自己想办法完成“从用户栈切到内核栈”的操作。rcore 的解法就是在初始化时把内核栈地址写入sscratch利用异常入口处能区分“当前是否来自用户态”的能力通过两条交换指令把栈指针换过来。顺着这个设计你可以推演一个细节如果异常发生在内核态比如内核访问了非法地址此时sscratch中已经不是内核栈而是上次交换后保存的用户栈指针。汇编代码通过判断sscratch是否为零来做区分——rcore 的做法是在从用户态陷入时sscratch被换成内核栈地址还是说执行流不同读代码时自己推理一遍能有效避免“以为是硬件抄好了结果是软件干的活”这种误解。4.3 从头看trap_entry一个极小的“状态打包器”rcore 的汇编入口本质上可以理解为把所有通用寄存器压栈把 sepc、sstatus 等 CSRs 保存到内存里再调 C 函数。压栈的顺序是有讲究的——因为 Rust 的TrapContext结构体字段顺序是固定的汇编压栈的顺序必须跟结构体字段声明保持一致否则trap_handler里读到的就是错位的寄存器值。这种“汇编和高级语言结构体强绑定”的代码最怕的就是后续改动结构体却忘记同步修改汇编。rcore 的处理是把TrapContext定义得相当克制字段顺序固定并在汇编注释里写清楚每一步在保存什么。作为阅读者你最好也保持“改结构体就要查汇编”的警觉。4.4TrapContext结构体保存现场的最小够用集合TrapContext里保存了x0到x31所有通用寄存器x0 可以不用存因为它恒为 0但 rcore 里为了对齐和简化索引还是留了这个位置加上sepc、sstatus等特殊 CSR。这意味着 trap 处理过程中可以放心修改寄存器不需要担心破坏用户态现场因为返回前会完整恢复。我在读这个结构体的代码时最大的感受是“够用就好”。不需要保存satp不需要保存所有 CSR——这既是因为保存所有 CSR 的开销太大也是因为内核处理 trap 时并不需要修改这些值除了sepc可能需要改写比如处理某些系统调用时。理解了保存“最小集合”的原则后续扩展时也就能自然地判断哪些上下文该进结构体、哪些不该进。5. 关键设计推断从代码结构反推作者的“为什么”5.1 为什么 entry 要区分用户态与内核态来源我花时间最久的不是trap_handler本身而是它开头的这个判断if let Some(ctx) current_trap_cx()。看起来平平无奇但这背后是 rcore 对“内核态也能产生 trap”这一场景的应答。一个常见的误解是只有用户态程序才会触发异常内核态代码理论上“应该”不会出错。但实际上内核对某些功能的模拟实现比如缺页异常处理、内核态访问用户态缓冲区也会触发 trap。更重要的是sret能正确恢复的前提是我们返回时得知道“回去以后是什么特权级、什么栈”。rcore 里这个判断的意义在于它规定了trap_handler必须能知道当前 trap 的来源特权级从而决定返回路径。比如从用户态陷入时需要先切换回用户态地址空间再把sp换回用户栈而从内核态陷入时则不需要这些步骤。5.2 为什么带tp参与“换栈游戏”这是一个值得反复品味的细节。RISC-V 的 32 个通用寄存器里x5/tp是线程指针thread pointer在用户态程序中通常指向 TLS 区域。而在trap_entry的入口汇编中它被临时征用为“备份用户态栈指针”的仓库。为什么选它因为tp可以实现原子性的交换而不依赖额外内存访问。如果你用普通内存变量保存用户栈那么从“读变量”到“切栈”之间可能会被其他事件打断产生竞态。而在trap_entry这个原子序列中csrrw指令可以完成“读 sscratch 且写入新值”的操作配合临时寄存器就能把所有步骤串成一条不可分割的指令序列。读代码时如果只是顺带扫过很容易漏掉这种“运算顺序本身就是同步保证”的妙处。5.3 为什么sret之前要“绕一大圈”恢复地址空间操作系统课程里经常说“trap 处理的最后一步是恢复现场”但“恢复现场”四个字在 rcore 里展开其实是一连串动作恢复satp指向用户地址空间、重新填充 TLB通过sfence.vma、恢复通用寄存器、读回用户栈指针、最后执行sret。这里有一个容易被忽略的点执行sret会从sepc恢复 PC但它并不会自动恢复satp。也就是说负责“跳回用户态并切换地址空间”的其实是两条独立指令先写satp再sret。而这两条之间CPU 仍然运行在内核态、却可能使用着新地址空间中的内核映射——所以必须由代码保证这个窗口期内不出现任何异常否则就会陷入“用不完整地址空间处理异常”的绝境。6. 模块级分析时钟中断、缺页与系统调用的处理路径6.1 时钟中断内核重新掌握主动权的关键时钟中断timer interrupt是 rcore 进程调度实现的地基。在 RISC-V 里时钟中断通过mtime与mtimecmp这两个 MMIO 寄存器驱动当mtime mtimecmp时硬件会产生一个中断。rcore 在实验代码里通过set_next_trigger设置下一次时钟中断的时间点。阅读时给我留印象最深的是“中断间隔怎么调”这一决策。set_next_trigger每次被调用时都会把mtimecmp设置为当前值加一个固定间隔比如 100000 个时钟周期。这样内核就能“每过一段时间”获得一次执行机会用于进行时间片调度、运行延时任务等。换句话说时钟中断是内核重新获得 CPU 主动权的唯一时机没有它用户程序一旦loop起来内核就只能干等。这里有一个实操心得在调试时钟中断时如果发现 CPU 卡死在用户态先检查set_next_trigger是否被正确设置为周期触发——很多新手把“触发一次”当成“持续触发”导致第一次中断后系统彻底静默。这种坑真的能让人挠头一晚上。6.2 缺页异常Page Fault虚拟内存机制的心脏起搏器rcore 实验的第三、四章会引入页表与地址空间此时缺页异常就成了最核心的异常类型。所谓的缺页是当 CPU 访问一个地址而当前页表并没有给该虚拟地址建立有效映射时会抛出的异常。读到这里缺页异常的处理路径就很清楚了trap_handler拿到scause为缺页异常后要根据stval保存了那个“不存在的虚拟地址”决定怎么处理。可能的处理方式包括为该地址分配物理页并建立映射按需分配、将该地址连接到一个已存在但暂时旁路的页写时复制、或者直接判定为非法访存并终止进程。rcore 作为一个教学项目对按需分配的处理往往是“简化版”在创建地址空间时把所有可能用到的用户段都提前映射好缺页异常更多是作为一个“该报错了”的信号。但理解缺页异常能干什么对后续阅读更复杂的 OS甚至 Linux 相关文章帮助极大。6.3 系统调用用户态进入内核最“正规”的通道除了时钟中断和异常还有一类特殊的、用户主动触发的 trap系统调用。RISC-V 里用户态通过ecall指令触发。在 rcore 中ecall会把一个特定的异常原因值写入scause表明“这不是错误这是我主动找内核办事”。trap_handler会根据sepc是否指向一条ecall指令、scause是否匹配系统调用标志来判断要不要走系统调用分支。特别地系统调用处理完成后sepc需要被增加到下一条指令ecall之后的指令否则sret会回到同一条ecall无限循环。这个“加 4 字节”的操作通常是在trap_handler里通过修改TrapContext.sepc实现的。这是我在阅读时最先注意到的一个“编程小陷阱”——如果不处理内核会在第一时间陷入死循环而且不容易察觉。所以当你写完一个系统调用后如果程序反复触发同一个异常地址优先怀疑sepc没有修改。7. 避坑清单读这类源码时的几个高发误区看多了 git log 和同学提问我把阅读 trap 相关代码常见的误区整理成一个速查表方便你读到自己卡住的地方时对照误区实际情况排查方向以为stvec是唯一的异常入口RISC-V 还有mtvec在 M 模式处理S 模式异常也可能被委托到 M 模式阅读启动代码区分当前运行模式与委托设置以为异常处理会自动保存所有寄存器硬件只保存极少数 CSR通用寄存器全靠软件自保检查trap_entry的压栈序列是否覆盖全部寄存器以为sret会自动切回用户栈不会栈切换必须由代码显式执行在返回路径上搜索sp的加载操作以为缺页异常一定表示程序出错可能是按需分配、写时复制等机制的一部分结合页表映射状态判断别一看到缺页就 panic以为时钟中断间隔是硬件固定的由mtimecmp决定软件需周期性更新检查set_next_trigger是否在每次中断后调用这张表不是想让你背下来而是提供一个“如果你读得云里雾里先从这几个角度审视自己代码”的思考框架。8. 实操心得带着这三个问题读源码收获更大结合前面拆解的机制我强烈建议你自己动手读trap.S和trap_handler时带着下面三个问题去读。每个问题都能逼你把“读”变成“理解”。第一个问题从用户态触发ecall到回到用户态ecall的下一条指令中间穿过哪些函数、哪些汇编序列把这条路径完整画出来标注每一处的地址空间、栈指针、特权级状态。第二个问题如果把TrapContext结构体里的sepc字段删掉系统会发生什么不要只看答案要亲手把代码改掉跑一次实验观察现象。这种“制造 bug”的阅读法比读十遍注释都管用。第三个问题如果时钟中断发生的时候CPU 恰好在内核态执行某个关键数据结构的修改会发生什么这对“中断嵌套”和“内核态也可中断”的语义思考非常有帮助。rcore 由于在 trap 入口关闭了中断sstatus.SIE清零天然避免了嵌套中断但你要意识到这不是硬件的强制约束而是软件的设计决策。理解这一点你再去看 Linux 等更复杂内核时就能理解为什么它们要花大力气维护“中断上下文”与“进程上下文”的区分。9. 从TrapContext到后续实验这一篇读完了下一步去哪trap 这条链路读完你对 rcore 内核的运行机制基本就有了“主线叙事”。后续再读进程管理第五章、虚拟内存第三章回顾时你会发现大量逻辑都建立在“知道 trap 怎么进出”的基础之上。具体来说进程切换第一步就是“保存当前进程的 TrapContext”切换到下一个进程后“恢复其 TrapContext”。这个说法听起来简单但实现上要求进程的TrapContext放在内核栈的固定位置而且切换过程必须与 trap 返回路径无缝衔接。很多初次读进程切换代码的同学会卡在“为什么切换进程要模拟一次 trap 返回”其实就是没想明白“进程切换 换个 TrapContext 再 sret”。所以我的建议是第四章读完了别急着开新主题先把前面几个实验里跟 trap 相关的代码翻出来逐一对照着读一遍比如进程创建时第一个 TrapContext 是怎么被伪造出来的也就是初始化为“好像刚从用户态陷入内核”的样子。这个“伪造现场”的技巧等你理解了 trap 全程后会由衷觉得精妙。我个人的体验是读到那个伪造 TrapContext 的位置才算真正对“所谓的进程切换”有了概念它不过就是改了一组寄存器的快照再从另一个快照恢复而已。希望这篇文章也能帮你把这条路径走通。10. 一个实验观察修改 trap 上下文尺寸引发的连锁反应最后分享一个我实际踩过的坑作为对读源码方法的补充。我曾在一次实验里为了多保存几个 CSR给TrapContext结构体增加了一个字段导致结构体整体占用的栈空间变大但__restore汇编里的addi sp, sp, ...偏移量没有同步修改。结果就是恢复现场时通用寄存器整体错位看起来就像“寄存器被随机改写了”程序崩溃的现场极其诡异。排查方法跟一般 bug 不太一样不是看逻辑而是对比结构体布局与汇编偏移。这件事给我的教训是trap 周边的代码任何改动都必须“汇编与结构体两处同步”。哪怕是一个不起眼的字段顺序调整都可能触发难以定位的随机崩溃。你要是也在做类似的实验建议在改TrapContext结构体后立刻打印一份core::mem::size_of和字段偏移量和汇编里的字面量对照一遍能省下不少调试时间。