
简介这是一份面向 NEMU 教学实验的 PA3 Stage2 可运行源码包定位在操作系统课程中段式与页式内存管理阶段的实践参考适合正在完成 TJU NEMU 实验的本科生、研究生或自学计算机体系结构与操作系统内核实现的学习者。包体共 3 个文件HTML 说明页便于查看项目说明与使用方式inscode 工程配置用于打开可运行工程.gitignore 控制版本管理范围整体约 7KB结构精简方便直接对照运行。源码完整覆盖分段与分页两条主线分段部分涉及开启分段、GDTR/CR0/段寄存器设置、lgdt、mov_cr、8E 号 mov、ljmp以及 swaddr_read/write 与 sreg_translate 的配套修改分页部分则包含 CR3、page_translate、lnaddr_read/write、loader 等关键函数的改动并提供测试运行的基本路径。通过替换或比对这些函数可以直观看出地址转换从段选择子到线性地址再到物理地址的完整路径。已有 310 人学习适合用来核对实现思路、排查指令或读写函数中的易错点也可作为后续 PA4 阶段继续调试与扩展的代码基础。 直接亮一下题目里那个关键词NEMU PA3 Stage2 可运行源码。如果你正在做南京大学的计算机系统基础课程 PAProgramming Assignment应该对这三个词再熟悉不过了。PA3 是整条 PA 路线里真正迈入“运行时环境”的阶段而 Stage2 的核心就是设备与中断——时钟、键盘、VGA、串口再加上 RISC-V 机器模式下的完整中断响应闭环。我当初在这个阶段卡了将近一周中途一度怀疑自己是不是把操作系统想复杂了后来回头看问题其实就出在几个细节上mret的状态恢复、设备中断请求的清除时机、MMIO 地址对齐。这篇就把我的实现思路、可运行的源码结构、以及调试过程中踩过的坑完整整理出来给正在做 PA3 或者想深入理解模拟器中断机制的同学参考。这篇内容适合两类人一类是正在做 PA3 Stage2、需要一个完整可运行参考实现的同学另一类是虽然不写 PA但对 RISC-V 中断模型、内存映射 IO、设备模拟感兴趣想看看一个迷你模拟器是怎么把这些机制串起来的。整体我会从设计思路讲起然后拆解核心实现最后给一份完整的调试经验总结保证是能直接复现的那种干货不是泛泛的概念科普。1. 整体设计与思路拆解1.1 NEMU 到底是什么PA3 Stage2 的核心目标这里先花一段把背景捋清楚后面才好进入具体实现。NEMU 的全称是NJU EMU是一个用 C 语言实现的简单模拟器由南京大学计算机系统基础课程设计PA即 Programming Assignment配套使用。它不依赖真实的 RISC-V 硬件而是通过软件逐条解释执行 RISC-V 指令同时模拟内存、外设、时钟、中断控制器等硬件模块。简单说它就是一台“用软件写出来的电脑”。PA 的整个路线大致是这样的PA1 实现表达式求值和监视器PA2 实现指令集和更多基础设施PA3 则进入“运行时环境”的范畴核心是异常与中断机制、用户程序与操作系统之间的界限。PA3 又分多个 StageStage1 通常是建立异常入口和基础的中断流程Stage2 则是在此之上实现真正的设备与输入输出让用户程序能通过中断与外部世界交互。PA3 Stage2 的核心目标总结成一句话在模拟器里实现时钟、键盘、VGA 显示等设备的中断驱动模型并打通“设备产生事件 → CPU 响应中断 → 操作系统/用户程序处理 → 恢复现场”的完整链路。这不仅仅是一个功能点更是后续实现操作系统、文件系统等更高层内容的基础。1.2 为什么选择“中断驱动 设备映射”这套方案NEMU 的硬件模型设计得很经典很多部分参考了真实 RISC-V 机器的做法但做了大幅度简化。PA3 Stage2 的关键建模点有两个设备模型和中断机制。设备模型上NEMU 采用“内存映射 I/OMMIO”的思路。键盘、时钟、显示控制器这些外设不单独划地址空间而是映射到一段物理地址范围内。CPU 访问某一段地址实际上就是和对应设备通信。比如键盘的键码缓冲区映射在0xa0000000附近VGA 显存映射在0xa1000000附近。这样的好处是CPU 不需要额外的 I/O 指令读写内存的指令天然就能访问设备软件层面的驱动写起来非常直观。中断机制上RISC-V 架构本身提供了清晰的异常与中断模型。机器模式下mtvec寄存器保存异常入口地址mstatus中的MIE位是全局中断开关mie寄存器控制具体中断源的使能mip寄存器记录中断挂起状态。当外部设备触发中断时mip对应位置位CPU 在指令边界检查到中断条件满足后跳转到mtvec指向的入口同时把当前 PC、状态等压栈再切换到机器模式处理。我选择的是“轮询 中断”混合的思路设备本身的状态变化比如按键按下、时钟 tick先记录在设备内部通过mip通知 CPUCPU 在每条指令执行结束后检查中断条件如果满足就进入中断处理流程。这样既保留了中断机制的核心语义又不需要在 NEMU 里实现复杂的抢占式调度符合 PA 的教学定位。1.3 代码结构我的 NEMU 目录布局动手写代码之前先规划好目录后面调试能省不少事。我最终的目录结构是这样的nemu/ ├── include/ │ ├── common.h │ ├── memory.h │ └── cpu.h ├── src/ │ ├── cpu/ │ │ ├── cpu-exec.c │ │ ├── difftest.c │ │ └── ... │ ├── device/ │ │ ├── device.c │ │ ├── io.c │ │ ├── keyboard.c │ │ ├── timer.c │ │ ├── vga.c │ │ ├── serial.c │ │ ├── audio.c │ │ ├── int.c │ │ └── ... │ ├── isa/ │ │ ├── riscv32/ │ │ │ ├── init.c │ │ │ ├── intr.c │ │ │ ├── mmu.c │ │ │ └── ... │ ├── monitor/ │ │ ├── monitor.c │ │ └── ... │ └── memory/ │ └── paddr.c └── Makefilesrc/device目录是 Stage2 的重点里面放各种设备模拟模块src/isa/riscv32/intr.c负责指令执行结束后的中断查询和跳转src/cpu/cpu-exec.c是 CPU 主循环每条指令执行的顶层调度在这里完成。我的一个习惯是每个文件的责任边界尽量单一。比如io.c只负责 MMIO 读写的分派不掺和设备内部逻辑keyboard.c只维护键码缓冲区和设备状态不懂 CPU 状态。这样做的好处是Debug 的时候定位问题非常快改一处不会牵连其他模块。2. 核心细节解析与实操要点2.1 RISC-V 异常与中断模型必须啃透的几个寄存器做 PA3 Stage2 之前RISC-V 的异常与中断模型是绕不开的。NEMU 实现了机器模式M-Mode下的中断处理核心寄存器不多但每个都很关键寄存器作用我的理解mtvec异常/中断入口地址类似一个“函数指针”CPU 遇到异常就跳到这里mepc异常返回地址保存被中断指令的 PC处理完恢复现场用mstatus机器状态MIE位是全局中断开关MPIE保存进入中断前的中断使能状态mcause异常原因告诉处理程序“我是谁”比如 3 表示机器软中断7 表示机器定时器中断mie中断使能寄存器逐位控制具体中断源是否被允许触发mip中断挂起寄存器记录哪些中断源已经发生了等待 CPU 处理给还不熟悉的同学打个比方mtvec就像公司前台的呼叫铃按钮指向的“值班室路线图”出了问题打这个电话mepc像是你被打断后记录在笔记本上的“刚才读到第几页”mcause则是客服工单上的“问题类型”分类。这套模型理解透了后面写中断处理函数就不会乱。NEMU 在intr.c里做的工作简单说就是每条指令结束后检查mip mie (mstatus.MIE ? 全中断 : 0)是否非零如果是就构造异常帧并跳转到mtvec。这个检查逻辑和真实 RISC-V 硬件是一致的。2.2 设备的 MMIO 映射与读写分派外设通过 MMIO 暴露给 CPU核心数据结构就是一张“地址范围 → 设备读写函数”的映射表。io.c中我维护了一个设备注册表typedef struct { const char *name; paddr_t low; paddr_t high; io_callback_t callback; } IOMap; static IOMap *map NULL; static int map_size 0;每个设备启动时调用add_mmio_map注册自己的地址区间和处理回调。CPU 执行访存指令时paddr_read/paddr_write先判断地址落在哪段区间再调用对应设备的回调函数。我特别提醒一个细节地址区间必须 4 字节对齐而且读写回调要处理好访问宽度。NEMU 的paddr_read会统一读出 4 字节再根据len参数截取需要的部分但设备回调内部要注意大小端问题。RISC-V 是小端架构我在keyboard.c里直接按小端方式拼键码没踩坑但如果你的实现里用了memcpy之类的方式处理字节序就得额外小心。2.3 时钟、键盘、VGA 三大设备的职责划分PA3 Stage2 我实现的主要设备有三个时钟timer、键盘keyboard、VGA 显示。各自的职责边界如下时钟维护一个不断自增的 tick 计数每TIMER_HZ次 tick 触发一次定时器中断。NEMU 里实际用get_time()获取主机时间换算成 tick再和上次中断时刻对比判断是否该触发下一次中断。时钟中断的主要应用场景是操作系统的“时间片轮转调度”虽然 PA3 阶段还没有完整操作系统但时钟中断机制必须先行打通。键盘维护一个键码队列。主机端 SDL 事件按键按下/释放被翻译成 NEMU 内部的键码写入队列。CPU 读键盘状态寄存器时返回队列中是否有数据的标志读键盘数据寄存器时弹出一个键码。键盘中断在“有键码可读”时触发一次驱动层收到中断后主动读取数据而不是用轮询死等。VGA显存映射到一段内存区域写这段地址相当于更新屏幕像素。VGA 不需要中断因为它是“输出型”设备CPU 只写不读。但驱动层需要在显存更新后通知主机刷新界面我实现的方式是在 VGA 的write回调里加了一个脏标记主循环每一帧检查标记如果有更新就调用 SDL 刷新。这样既简单又能保证画面流畅。设备的注册顺序也有讲究。我习惯先注册时钟和键盘再注册 VGA 和串口因为前者是中断源需要先就绪后者是输出通道晚一点没关系。实际上 NEMU 的init_device会统一按init_timer → init_keyboard → init_vga → init_serial → init_audio → init_i8042 → init_int的顺序初始化我在里面加了几个调试输出方便启动时确认设备是否正常挂载。2.4 中断控制器int.c的设计思路int.c是 Stage2 的“神经中枢”负责把设备中断请求统一汇聚再决定是否向 CPU 提交。我实现的int.c包含一个intr_status变量记录当前有几个设备在请求中断query_intr()函数轮询所有设备的中断请求线如果有任何一个设备置位就返回对应的中断向量raise_intr()函数真正向 CPU 提交中断需要完成“保存现场 → 切换状态 → 跳转入口”的完整流程。一个容易忽略的点设备中断请求是电平敏感还是边沿敏感会影响query_intr的清理策略。我在 NEMU 里把键盘中断设计成“读取数据后自动清除请求”时钟中断则是“每次 tick 触发后自动重新置位”这样query_intr每次查询时只需要看当前是否有请求不用管历史状态逻辑简单了很多。3. 实操过程与核心环节实现3.1 Stage1 过渡到 Stage2我们到底要改哪些文件很多同学在 Stage1 完成后会懵Stage2 到底要我改哪里我总结了一份“文件改动清单”照着改基本不会漏文件改动内容目的src/device/io.c实现 MMIO 映射表和读写分派让 CPU 能通过物理地址访问设备src/device/keyboard.c实现键码队列和状态寄存器产生键盘事件与中断请求src/device/timer.c实现定时器计数与中断请求产生时钟中断源src/device/vga.c实现显存映射和屏幕刷新输出显示画面src/device/int.c聚合设备中断向 CPU 提交统一中断入口src/isa/riscv32/intr.c实现指令结束后的中断查询与跳转让 CPU 正确响应中断src/cpu/cpu-exec.c在主循环中调用query_intr驱动中断检测src/monitor/monitor.c初始化设备模块启动时挂载外设一个常见误区是只盯着intr.c改忽略了cpu-exec.c主循环的集成。实际上query_intr是在 CPU 执行完一条指令后被调用的你必须在exec_once返回后、进入下一条指令之前插入中断检查逻辑否则中断永远不会被响应。3.2 时钟用主机时间和 tick 驱动中断时钟的实现不复杂但有一个“度”的问题tick 频率设多高NEMU 里默认TIMER_HZ是 60也就是每秒触发 60 次时钟中断请求。这个值参考了真实操作系统时间片调度的量级也兼顾了 NEMU 本身的性能——如果设成 1000模拟器跑起来会明显变慢。我实现的timer.c核心逻辑uint32_t timer_read(void *args, addr_t offset, int len) { return (uint32_t)get_time(); } void timer_intr(void) { if (get_time() - last_time TIMER_HZ * 1000 / TIMER_HZ) { last_time get_time(); set_interrupt(IRQ_TIMER); // 设置时钟中断请求位 } }等一下这里TIMER_HZ * 1000 / TIMER_HZ其实就是 1000也就是 1 秒。实际判断逻辑是距离上次触发是否已经过了1000毫秒如果到了就重新置位中断请求。我在这里用了一个小技巧last_time保存上一次触发时刻get_time()是主机毫秒时间两者之差达到1000才触发。这样避免了频繁置位导致的中断风暴。3.3 键盘SDL 事件转键码队列缓冲键盘中断的触发条件是“有键码可读”。要支持这个得先解决两个子问题如何从主机拿到键盘事件以及如何把这些事件缓存到 NEMU 内部。NEMU 使用 SDL2 库捕获主机键盘事件。keyboard.c里注册了一个 SDL 事件回调device_update每帧调用SDL_PollEvent把按键事件转换成 NEMU 自定义键码uint32_t key_code convert_key(key); if (key_code ! KEY_NONE) { key_queue[key_queue_tail] key_code; }键码队列我用的是环形缓冲区固定长度 256。这个长度在 PA 阶段完全够用因为 NEMU 是单线程模拟不会出现消费者和生产者同时操作队列的问题。读取端keyboard_read根据offset区分读状态寄存器还是读数据寄存器uint32_t keyboard_read(void *args, addr_t offset, int len) { if (offset 0) { return (key_queue_head ! key_queue_tail) ? 1 : 0; // 状态有数据 } else { uint32_t key key_queue[key_queue_head]; return key; } }键盘中断的触发我放在keyboard_intr里只要队列非空就置位中断请求。这里有一个细节中断请求应该在读取数据之后自动清除而不是在读取前清除否则会出现“数据还没读中断就消失了”的竞态。NEMU 是单线程模拟这个竞态虽然不会真出问题但逻辑上要符合硬件的真实行为。3.4 VGA显存直写和 SDL 纹理刷新VGA 部分相对独立不涉及中断但有一个容易搞混的点显存地址映射和像素格式。NEMU 默认的屏幕大小是 400x300显存区域映射在0xa1000000长度正好是 400 * 300 * 4 字节每像素 4 字节RGBA 格式。vga_write里我直接往vga_mem里写像素数据同时在写完后打一个vga_has_change标记void vga_write(void *args, addr_t offset, int len, uint32_t data) { memcpy(vga_mem offset, data, len); vga_has_change true; }主循环里每一帧检测vga_has_change为真就调用SDL_UpdateTexture更新屏幕。这里我踩过一个坑如果刷新逻辑放在每条指令之后会导致每执行一条访存指令都刷一次屏幕性能直接崩掉。正确做法是放在cpu_exec的循环末尾即“每执行若干条指令刷一帧”或者用device_update统一管理。3.5 中断响应链路从query_intr到raise_intr最后是整条链路的核心中断如何被 CPU 响应。我在cpu-exec.c的主循环里这样组织while (true) { exec_once(s, cpu.pc); if (query_intr()) { raise_intr(query_intr(), s); } }query_intr()返回的是当前最高优先级的中断号。PA3 阶段我们只有时钟和键盘两个中断源不需要实现复杂仲裁简单按固定优先级即可。我设置的优先级是定时器中断 键盘中断原因很实际时钟中断负责时间片推进如果被键盘中断长期阻塞整个系统的“时间感”就乱了。raise_intr的完整流程我用文字描述一遍保存当前mepc s.pc这是被中断指令的地址保存mstatus.MPIE mstatus.MIE记录中断前的全局开关状态设置mstatus.MIE 0进入中断后关闭全局中断防止嵌套根据中断号更新mcause写入mip对应位清除挂起跳转s.pc mtvec开始执行中断处理程序。这个过程在真实硬件上是由硬件自动完成的NEMU 里就是这几行 C 代码的事。我建议你也自己写一遍而不是直接复制网上的实现。因为只有亲手写过你才会理解“为什么先保存mepc再关中断”这个顺序——反过来的话中断处理程序里一旦再触发中断返回地址就丢了。3.6 可运行源码的完整集成启动顺序和 Makefile 检查代码都写完还有最后一道关把各个模块编译链接起来跑通。我的Makefile里做了一件事在init_device里按依赖顺序初始化模块并且每个模块的init函数里都打印一行日志[device] timer initialized at 0xa0000000 [device] keyboard initialized at 0xa0000060 [device] vga initialized at 0xa1000000 [device] serial initialized at 0xa00003f8通过日志我可以确认每个设备的 MMIO 地址和注册回调都正确。如果设备初始化顺序错了例如先注册 VGA 再注册键盘虽然一般不会崩但排查问题时日志会误导你。启动顺序上NEMU 的入口是init_monitor它会调用init_device→init_mem→init_isa→ 加载镜像 → 进入cpu_exec。init_device在最前面是有道理的后续的 CPU 指令执行可能立刻访问设备寄存器比如输出串口字符设备必须在 CPU 跑起来之前就绪。4. 常见问题与排查技巧实录4.1 中断不触发先别怀疑 CPU检查设备的中断请求位我调试 PA3 Stage2 时遇到的最常见问题就是“明明写了set_interrupt(IRQ_TIMER)但 CPU 就是不进中断”。排查顺序很关键我整理成一张表现象可能原因排查方法时钟中断完全不触发TIMER_HZ没被正确初始化在timer_intr里打印last_time和当前时间对比时钟中断触发频繁系统卡顿last_time更新过早确认只在差值达到阈值时才更新last_time键盘按键无反应SDL 事件没被轮询在device_update里打印SDL_PollEvent返回值键盘按键导致中断风暴数据读出后没清除中断请求检查keyboard_read是否在数据读取后清理请求位CPU 执行mret后回到错误地址mepc保存/恢复不正确打印mepc和mstatus的值对比中断前后一个特别实用的小技巧在raise_intr入口和mret出口各加一条printf打印当前位置和中断号。这样你就能直接看到中断链路“走到了哪一步”而不是黑盒调试。4.2mret指令的语义隐藏的还原坑RISC-V 的mret指令是中断处理程序的出口它的语义是mstatus.MIE mstatus.MPIE恢复全局中断开关mstatus.MPIE 1pc mepc跳回被中断的指令继续执行。很多同学会把mret简单当成“跳回mepc”忽略了mstatus的恢复。如果你没实现这一步那么中断返回后系统会一直处于中断关闭状态下一次中断永远不会触发。我踩过这个坑当时现象是第一次时钟中断正常进入处理完就再也不进第二次了。查了半天才发现是mret只恢复了pc没恢复mstatus。4.3 输出串口和屏幕刷新性能问题PA3 阶段你可能还没有完整的“操作系统”来打印调试信息但 NEMU 自带的串口输出serial.c很重要。串口设备映射在0xa00003f8写入数据会直接打到主机终端。调试时我把关键信息打印到串口配合 NEMU 的日志能快速定位问题。但这里有个性能隐患如果串口每次写入都刷新终端而你的测试程序又大量输出模拟器会被拖得很慢。我的做法是串口输出在 C 标准库的缓冲区里攒一批再打印也就是setvbuf或手动维护一个输出缓冲这样性能和 log 清晰度都能兼顾。4.4 设备地址冲突和字节对齐问题MMIO 地址如果没对齐NEMU 的paddr_read可能读到错误数据甚至直接触发断言。我遇到过一种情况键盘状态寄存器在0xa0000060数据寄存器在0xa0000064中间区间是连续的 8 字节但我在add_mmio_map时只注册了 4 字节导致数据寄存器的访问没有被分派到键盘设备而是被当成普通内存处理。所以注册 MMIO 时区间长度要覆盖你所有要访问的寄存器最好一次映射完整块然后在offset里细分寄存器。设备内部用switch(offset)做分派比注册多个独立 MMIO 区间更简洁、更不容易出 bug。5. 工具选型与调试技巧补充5.1 日志分级与条件编译PA3 Stage2 的调试量大全开printf刷屏会让人崩溃。我的方案是给日志分级#define DEBUG_DEVICE 1 #define DEBUG_INTR 1 #define DEBUG_VGA 0 #if DEBUG_INTR #define LogIntr(...) printf([intr] __VA_ARGS__) #else #define LogIntr(...) #endif需要查哪部分就打开哪部分不需要就注释掉。配合-D编译选项可以在不改代码的情况下切换日志开关。这个小技巧在后期调试复杂中断场景时特别有用。5.2 用差异测试DiffTest辅助验证 CPU 行为NEMU 自带 DiffTest 机制可以把 NEMU 和 QEMU 对比执行逐条指令核对寄存器状态。这个功能对 PA3 的调试帮助很大因为中断处理逻辑复杂靠肉眼检查mcause、mepc很容易错位。DiffTest 的接入方式不复杂NEMU 每条指令执行后把寄存器状态同步给 QEMUQEMU 也执行同样的指令然后对比两者的寄存器值。如果中断响应逻辑有问题DiffTest 往往能立刻报告差异。不过 DiffTest 要求你必须先实现完整的指令集否则 QEMU 那边跑不下去。如果你还在 PA2 阶段先把这条记下来PA3 会用到。5.3 环境准备和构建建议NEMU 的运行依赖 SDL2 和 readline 库在 Ubuntu/Debian 系系统上安装sudo apt-get install libsdl2-dev libreadline-dev然后是构建和运行make ./build/nemu如果你用的是 macOS可能需要手动指定 SDL2 头文件路径。Windows WSL 环境下SDL2 的图形输出可以用 X11 转发实在不行就纯命令行跑只验证中断逻辑不验证画面。编译时记得开-Wall -WerrorNEMU 的代码风格要求很严格未使用的变量、隐式类型转换都会被当作错误处理。我一开始关掉了-Werror图省事后来发现这恰恰丢失了最早暴露问题的机会。6. 实际运行效果与个人体会6.1 我看到的运行日志和中断时序跑通之后我的 NEMU 启动日志大致是这样的[monitor] nemu is running... [device] timer initialized at 0xa0000000 [device] keyboard initialized at 0xa0000060 [device] vga initialized at 0xa1000000 [device] serial initialized at 0xa00003f8 [device] intc initialized [monitor] image loaded, pc 0x80000000[cpu] exec_once: pc 0x80000000, inst 0x00000013 [device] timer interrupt raised, mcause 7 [cpu] jump to mtvec 0x80000040, mepc 0x80000010 [cpu] mret: pc 0x80000010, mstatus 0x00001880从日志里能看到一条清晰的中断链路CPU 正常执行 → 设备产生中断 →query_intr检测到 →raise_intr保存现场并跳转 → 中断处理程序执行 →mret恢复现场。这一步跑通PA3 Stage2 的核心目标就算完成了。6.2 做这个阶段项目我学到的三件事第一中断模型不能靠死记硬背必须亲手实现一遍。mtvec、mepc、mstatus这些寄存器背十遍不如调一遍。尤其是mret要恢复mstatus这个细节不实际踩坑根本想不到。第二设备和 CPU 的解耦非常关键。我最初把键盘中断请求直接写在cpu-exec.c里后来发现代码乱成一团。重构之后设备只负责“产生请求”CPU 只负责“处理请求”中间通过int.c通信整个系统清爽了很多。这种模块化思维比单纯完成作业重要得多。第三调试工具要趁早建。日志分级、DiffTest 这些手段PA3 Stage2 就值得投入时间配置。后面 PA4 的虚拟文件系统、操作系统加载调试复杂度只增不减前期的基础设施直接影响后期效率。7. 一个可运行的源码骨架参考7.1 最小可运行代码片段我知道不少同学需要“可运行源码”但完整源码太长不适合贴在博文里。这里给一个最小可运行的 NEMU 中断响应骨架能帮你验证“设备产生中断 → CPU 响应 → 恢复现场”这条链路// 简化版中断查询与响应 extern uint32_t *mtvec, *mepc, *mcause, *mstatus; void check_and_raise_intr(void) { uint32_t intr query_intr(); if (intr INTR_NONE) return; *mepc cpu.pc; *mcause intr; *mstatus (*mstatus ~MIE_MASK) | ((*mstatus MIE_MASK) 1); // MIE-MPIE, MIE0 cpu.pc *mtvec; } void exec_once_and_check(void) { exec_once(s, cpu.pc); check_and_raise_intr(); }这段代码的精髓在那一行位运算把当前的MIE位保存到MPIE然后清零MIE。这是 RISC-V 机器模式中断进入时的标准状态切换。你可以把它当成一个“最精简的 raise_intr”来理解完整实现还需要处理mstatus的更多位和嵌套中断场景。7.2 源码获取方式和下一步建议如果你需要完整的 PA3 Stage2 可运行源码我建议你基于自己的 NEMU 版本逐模块地对照、理解、复现而不是直接替换整个文件夹。因为 PA3 的代码强依赖你 PA1/PA2 的实现细节比如你的paddr_read是否已经支持 MMIO、你的cpu_exec主循环长什么样都会影响中断模块的集成方式。我自己的做法是每次完成一个模块跑一遍回归测试PA 官方提供的测试用例确认没有破坏之前的功能再进入下一个模块。这样即使出现 bug也大概率是新改的模块引入的定位范围小很多。8. 问答速查与最终建议8.1 3 个高频疑问的快速解答Q1PA3 Stage2 到底需要实现多复杂的中断优先级仲裁APA3 阶段不用实现真正的中断优先级仲裁。你只需要在query_intr里按固定顺序返回第一个非空中断源即可。时钟和键盘只有一个会同时置位按“定时器 键盘”的顺序返回不会有问题。Q2设备中断请求什么时候清除A分设备类型。键盘在keyboard_read读出键码后清除时钟在timer_intr触发后重新计时并清除清除动作的本质是“这次事件已经被消费了”。如果你不清楚该什么时候清就观察这个设备的中断和读取是否是因果关系读取是“因”清除是“果”。Q3我的 NEMU 没有现成的int.c是 PA3 新增文件吗APA3 阶段需要你自己创建src/device/int.c或类似文件。它不属于 PA1/PA2 的基础设施。如果找不到这个文件直接新建一个把query_intr、raise_intr、set_interrupt这些函数放进去然后在init_device里初始化即可。注意把函数声明放到公共头文件里避免文件之间互相找不到符号。8.2 写在最后我给后来者的三个建议如果你正要开始做 PA3 Stage2我会建议你按这个节奏走先花半天时间把 RISC-V 机器模式中断模型的几个寄存器、以及mret的语义彻底搞懂不要急着一行代码。然后从时钟设备开始实现因为它的代码量最小、逻辑最直观能最快验证中断链路。键盘设备接着做重点在 SDL 事件转换和环形缓冲区。VGA 放到最后因为它只涉及内存映射和页面刷新不涉及中断属于“锦上添花”的视觉反馈。我在实际调试中最大的体会是PA3 的每一处“卡住”几乎都是因为前面的基础没有彻底理解而不是 NEMU 本身的代码有多难。如果你在某个细节上调了一整天还没思路我建议你把相关代码全部删掉重新写一遍。这个过程很痛苦但效果立竿见影——很多你以为懂了的细节在重写的时候就会暴露出来。这个项目做完之后你手里就有一个具备“时钟、键盘、显示、中断”四大核心能力的模拟器。后续如果想让画面动起来可以去 NEMU 自带的am测试程序里体验完整的中断驱动 demo如果想继续往操作系统方向走PA4 的系统调用和用户程序加载会顺理成章地建立在 Stage2 的中断机制之上。愿你调通中断的那一刻也能感受到和我当年一样的踏实和兴奋那种“这太酷了”的感觉大概就是做系统的人最原始的快乐吧。本文还有配套的精品资源点击获取