简介:这是一份面向 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 可运行源码。如果你正在做南京大学的计算机系统基础课程 PA(Programming 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 又分多个 Stage,Stage1 通常是建立异常入口和基础的中断流程,Stage2 则是在此之上实现真正的设备与输入输出,让用户程序能通过中断与外部世界交互。
PA3 Stage2 的核心目标,总结成一句话:在模拟器里实现时钟、键盘、VGA 显示等设备的中断驱动模型,并打通“设备产生事件 → CPU 响应中断 → 操作系统/用户程序处理 → 恢复现场”的完整链路。这不仅仅是一个功能点,更是后续实现操作系统、文件系统等更高层内容的基础。
1.2 为什么选择“中断驱动 + 设备映射”这套方案
NEMU 的硬件模型设计得很经典,很多部分参考了真实 RISC-V 机器的做法,但做了大幅度简化。PA3 Stage2 的关键建模点有两个:设备模型和中断机制。
设备模型上,NEMU 采用“内存映射 I/O(MMIO)”的思路。键盘、时钟、显示控制器这些外设,不单独划地址空间,而是映射到一段物理地址范围内。CPU 访问某一段地址,实际上就是和对应设备通信。比如键盘的键码缓冲区映射在0xa0000000附近,VGA 显存映射在0xa1000000附近。这样的好处是:CPU 不需要额外的 I/O 指令,读写内存的指令天然就能访问设备,软件层面的驱动写起来非常直观。
中断机制上,RISC-V 架构本身提供了清晰的异常与中断模型。机器模式下,mtvec寄存器保存异常入口地址,mstatus中的MIE位是全局中断开关,mie寄存器控制具体中断源的使能,mip寄存器记录中断挂起状态。当外部设备触发中断时,mip对应位置位,CPU 在指令边界检查到中断条件满足后,跳转到mtvec指向的入口,同时把当前 PC、状态等压栈,再切换到机器模式处理。
我选择的是“轮询 + 中断”混合的思路:设备本身的状态变化(比如按键按下、时钟 tick)先记录在设备内部,通过mip通知 CPU;CPU 在每条指令执行结束后检查中断条件,如果满足就进入中断处理流程。这样既保留了中断机制的核心语义,又不需要在 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 = 1;pc = 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 每条指令执行后,把寄存器状态同步给 QEMU,QEMU 也执行同样的指令,然后对比两者的寄存器值。如果中断响应逻辑有问题,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 -Werror,NEMU 的代码风格要求很严格,未使用的变量、隐式类型转换都会被当作错误处理。我一开始关掉了-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, MIE=0 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 个高频疑问的快速解答
Q1:PA3 Stage2 到底需要实现多复杂的中断优先级仲裁?
A:PA3 阶段不用实现真正的中断优先级仲裁。你只需要在query_intr里按固定顺序返回第一个非空中断源即可。时钟和键盘只有一个会同时置位,按“定时器 > 键盘”的顺序返回不会有问题。
Q2:设备中断请求什么时候清除?
A:分设备类型。键盘在keyboard_read读出键码后清除,时钟在timer_intr触发后重新计时并清除,清除动作的本质是“这次事件已经被消费了”。如果你不清楚该什么时候清,就观察这个设备的中断和读取是否是因果关系,读取是“因”,清除是“果”。
Q3:我的 NEMU 没有现成的int.c,是 PA3 新增文件吗?
A:PA3 阶段需要你自己创建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 的中断机制之上。愿你调通中断的那一刻,也能感受到和我当年一样的踏实和兴奋,那种“这太酷了”的感觉,大概就是做系统的人最原始的快乐吧。
本文还有配套的精品资源,点击获取