简介:《nemu pa1的概述、原理及应用.pdf》面向计算机系统课程学习者、模拟器开发入门者与底层软件调试爱好者,围绕NEMU PA1实践项目梳理从概念到应用的完整脉络。文档以构建简单调试器sdb为主线,讲解单步执行、打印寄存器、扫描内存与表达式求值等基础功能的实现思路,并剖析NEMU通过CPU模拟、内存模拟、设备模拟来承载客户程序运行的核心机制。内容还延伸到调试器在计算机系统教学、体系结构研究、嵌入式与操作系统开发测试等场景中的实际价值,适合用作课程实验前的预习材料或项目开发时的概念参考。资源包共1个PDF文件,约74KB,轻量便于随取随阅;已有798人学习,可作为理解NEMU基础设施与后续实验开发的入门读本。
1. 从一条si命令说起:NEMU PA1 到底在练什么
第一次把 NEMU PA1 的骨架跑起来,终端里敲下si,屏幕回一行 PC 值,再敲info r吐出 32 个寄存器,x 8 0x80000000把内存前 32 字节按小端拼成 8 个 32 位字。那一刻才反应过来,调试器里那些理所当然的功能,是有人在模拟器里一行行手工码出来的。
NEMU PA1 干的就是这件事:在一个能执行客户程序的模拟器上,从零搭一个 Simple Debugger(sdb),把单步执行、寄存器查看、内存扫描、表达式求值这几件事做出来。它把 CPU、内存、取指这些概念落成可以被你打断、被你观测的软件对象,而不是课本上的示意图。适合系统课做实验的同学,也适合想搞清楚「调试器凭什么能调试别人」的底层开发者,或者需要一个可脚本化测试台的嵌入式工程师。把 sdb 跑通一遍,比读十篇讲寄存器的文章实在得多。
2. 架构拆解:CPU、内存、monitor 三层怎么串起来
2.1 三类模拟对象与 monitor 的依赖方向
NEMU 这个名字听着像「一个模拟器」,拆开其实是三块半。CPU 模拟在src/cpu/下,cpu-exec.c管取指、译码、执行和 PC 推进,reg.c管寄存器组,src/isa/<isa>/下每个指令集一套译码表和语义实现。内存模拟在src/memory/paddr.c,用宿主进程里的一块大数组pmem冒充客户机物理内存,CONFIG_MBASE是客户机物理地址的起点,CONFIG_MSIZE是这块内存的大小。设备模拟旁挂在src/device/,键盘、串口、定时器都在这里,PA1 阶段基本用不上,但框架已经预留好了。剩下那半块是monitor,也就是读一行命令、查表派发给处理函数的外壳,sdb 是它的一部分。
依赖方向很干净:monitor/sdb→cpu→memory。sdb 想知道当前 PC,读的是cpu.pc;想看某个地址存了什么,得先经过地址映射再落到pmem上。反过来不成立,memory层不会反过来调 sdb。
这个单向依赖决定了 PA1 的推进顺序。先做info r,因为它只要遍历一个结构体;再做x,因为它要过一遍地址映射;最后做表达式求值,因为它要把一整串字符串解析成数字或者寄存器值,再喂给前面两步。顺序乱了会很痛苦——不少人一上来就写表达式求值,写完发现没有x和info r做验证,只能盲猜自己算得对不对。
2.2 目录结构与 Kconfig:先认清改哪个文件
动手之前先把目录摸清楚,省得每次改代码都在find里翻。
nemu/ ├── configs/ # 各 ISA 的默认配置 ├── include/ # 公共头文件、类型定义 ├── src/ │ ├── cpu/ │ │ ├── cpu-exec.c # 执行引擎:exec_once / cpu_exec │ │ ├── decode.c # 指令译码 │ │ └── reg.c # 寄存器名表与打印 │ ├── device/ # 设备模拟(PA1 用不到) │ ├── isa/ │ │ └── riscv32/ # 指令集相关:译码、寄存器结构、执行语义 │ ├── memory/ │ │ └── paddr.c # 物理内存:pmem、guest_to_host、读写 │ ├── monitor/ │ │ ├── monitor.c # 启动、镜像加载、批处理入口 │ │ └── sdb/ │ │ ├── sdb.c # 命令主循环与命令表 │ │ └── expr.c # 表达式求值 │ └── utils/ # 状态机、日志、计时等工具 ├── Kconfig # 配置项定义 └── Makefile构建系统是 Kconfig + Makefile 的组合。make menuconfig进的是 ncurses 界面,选指令集、内存大小、是否开 trace;确认后生成build/generated/autoconf.h,源码里用CONFIG_XXX宏引用。默认配置在configs/里,可以理解成一份预设的.config。
# 选指令集和内存大小,第一次进来建议直接回车确认默认值 make menuconfig # 全量构建,产物在 build/ 下 make # 跑一个镜像:-b 是批处理模式,-l 把指令 trace 落到文件 ./build/riscv32-nemu-interpreter -b -l build/trace.log --image=build/add.bin--image指定的镜像会被加载到CONFIG_MBASE起始的物理地址上。-b的意义在后面做回归测试时会体现出来——它跳过交互式命令行,直接从标准输入读命令序列,跑完自动退出。-l落下来的 trace 文件里每一行是一次exec_once的记录,PC、指令机器码、寄存器变化都在,出问题的时候这是第一手材料。
一个容易忽略的点:menuconfig改完配置后,增量make不一定能意识到autoconf.h变了。autoconf.h被打包进include/config.h之类的公共头,理论上所有依赖它的.o都会重编;但如果你的环境里 ccache 或者某些文件的依赖没写全,就会变成「配置改了、行为没变」的玄学现场。碰到这种情况别再猜,make clean && make走一遍,成本也就几十秒。
2.3 一条指令的生命周期:exec_once 里的四个状态
理解了目录,再看执行引擎。这是整个模拟器的核心,也是后面si能单步的前提。
// src/cpu/cpu-exec.c static void exec_once(Decode *s, vaddr_t pc) { cpu.pc = pc; // 1. 对齐到本次起点 s->pc = pc; // 记下本次指令地址 s->isa.inst.val = inst_fetch(&s->snpc, 4); // 2. 取指,snpc = pc + 4 isa_exec_once(s); // 3. 译码并执行,算出 dnpc cpu.pc = s->dnpc; // 4. PC 落到下一条指令 }四个状态分别是:s->pc是「这条指令在哪」,s->snpc是「静态下一条」,s->dnpc是「动态下一条」,cpu.pc是「执行完之后真正该去哪」。为什么要分snpc和dnpc?因为跳转类指令会让dnpc偏离snpc,分支预测、jalr这类指令只有把两者拆开,才写得清楚谁负责推进、谁负责改写。
isa_exec_once(s)这层做了三件事。先看低 7 位(比如 RISC-V 里是 opcode),去译码表里定位到具体的指令类型;再把inst.val按各个字段位宽拆成rd、rs1、rs2、imm;最后调对应的执行函数,写回rd,同时把s->dnpc设成跳转目标或者s->snpc + 4。整套映射用「函数指针数组 + 位域结构体」实现,新增一条指令基本就是加一行表项和一个执行函数。
// src/cpu/cpu-exec.c void cpu_exec(uint64_t n) { // 前置检查:如果已经在结束态,直接返回,避免重复跑 switch (nemu_state.state) { case NEMU_END: case NEMU_ABORT: printf("Program execution has ended. Exit now...\n"); return; default: nemu_state.state = NEMU_RUNNING; } for (; n > 0; n--) { exec_once(&s, cpu.pc); // 执行一条 g_nr_guest_inst++; // 客户机指令计数,后续性能对比要用 trace_and_difftest(&s, cpu.pc); // trace 落盘 / 对比参考实现 if (nemu_state.state != NEMU_RUNNING) break; // 遇到 ebreak / halt 就停 } }cpu_exec(n)的参数n是「最多执行几条客户机指令」。si命令传 1,c命令传UINT64_MAX,批处理模式跑完整镜像也是走这个入口。循环里三个动作值得注意:exec_once是真正的推进;g_nr_guest_inst累加计数器,后面做 IPC 统计、性能对比都靠它;trace_and_difftest是个钩子,里面按需把这次执行记录下来、或者拿参考实现的结果做比对。nemu_state.state是个四态状态机(RUNNING/END/ABORT/QUIT),ebreak之类的特殊指令会把它改掉,循环据此提前退出。
sdb的命令处理函数最终都落到cpu_exec上,所以si N、c这类命令的实现其实只有一行——解析参数,调用cpu_exec(n),然后打印一下当前 PC。真正复杂的地方在内存和表达式,那两章展开。
3. 内存与寄存器:先让「看得见」这件事成立
3.1 pmem 与 guest_to_host:客户机地址怎么落到宿主数组
客户机眼里的物理地址从CONFIG_MBASE(常见值0x80000000)开始,长度CONFIG_MSIZE(常见值 128MB)。宿主这边只有一块连续的字节数组pmem,所以每次访问都要做一次平移。
// src/memory/paddr.c static uint8_t pmem[CONFIG_MSIZE] PG_ALIGN = {}; // 客户机物理地址 -> 宿主数组指针 static inline uint8_t* guest_to_host(paddr_t paddr) { return pmem + paddr - CONFIG_MBASE; } // 宿主数组指针 -> 客户机物理地址(difftest 里会用到) static inline paddr_t host_to_guest(uint8_t *haddr) { return haddr - pmem + CONFIG_MBASE; }PG_ALIGN是个对齐修饰宏,扩展开来通常是__attribute__((aligned(4096)))。加它是为了让pmem的起始地址落在页边界上,后续如果要做内存映射或者 DMA 模拟,物理地址和宿主地址的对齐关系不会错位。
paddr_t是客户机物理地址类型,word_t是机器字(RISC-V 32 下就是uint32_t)。这两个类型在整个项目里贯穿始终,别混用int。
有了地址映射,读写就好写了。核心是host_read/host_write:
// src/memory/paddr.c static inline word_t host_read(void *addr, int len) { switch (len) { case 1: return *(uint8_t *)addr; case 2: return *(uint16_t *)addr; case 4: return *(uint32_t *)addr; default: Assert(0, "host_read: 非法长度 %d", len); } } static word_t paddr_read(paddr_t addr, int len) { // 越界检查一定要有,否则 x 命令扫飞了就是段错误 if (likely(in_pmem(addr, len))) { return host_read(guest_to_host(addr), len); } // MMIO 区域走设备回调,PA1 阶段基本用不到 return mmio_read(addr, len); }len只接受 1、2、4 是刻意的限制,模拟器里指令宽度和访存宽度都在这三个值里,写死反而省掉一类边界错误。in_pmem(addr, len)判断的是[addr, addr + len)整段是否都落在[CONFIG_MBASE, CONFIG_MBASE + CONFIG_MSIZE)里,一定要整段判,只判起点会漏掉跨边界访问。
| 参数 / 宏 | 典型值 | 作用 |
|---|---|---|
CONFIG_MBASE | 0x80000000 | 客户机物理内存起点 |
CONFIG_MSIZE | 0x8000000(128MB) | 客户机物理内存大小 |
PG_ALIGN | __attribute__((aligned(4096))) | 强制pmem页对齐 |
paddr_t | uint32_t | 客户机物理地址类型 |
word_t | uint32_t | 机器字,随 ISA 变化 |
这里没有处理大端序。RISC-V 默认小端,host_read直接按宿主字节序读就和客户机一致;如果换成大端的 MIPS 或 PowerPC,host_read里得加一次字节翻转,这一步漏了,x命令打出来的全是反的。
3.2 info r:寄存器名映射与 x0 恒零
寄存器这块最简单,也最容易在小地方翻车。
// src/isa/riscv32/reg.c static const char *regs[] = { "$0", "ra", "sp", "gp", "tp", "t0", "t1", "t2", "s0", "s1", "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "t3", "t4", "t5", "t6", }; void isa_reg_display(void) { for (int i = 0; i < 32; i++) { printf("%-4s(x%-2d) = 0x%08x\t%u\n", regs[i], i, cpu.gpr[i], cpu.gpr[i]); } }regs数组的顺序必须严格对齐 ABI 编号。数组元素数和寄存器数不一致,或者中间漏一个,都会导致info r输出的名字和实际值错位——这种错位最难受的地方在于它不崩,只是安静地把错误的数字贴到错误的名字上。
x0恒零这件事,两种做法都行。一种是在执行层强制cpu.gpr[0] = 0,每次写回前判一下;另一种是在读的时候统一走宏R(i),里面判断i == 0返回 0。我更偏向后一种,写回路径上只需要在R(0)的位置断言一下就行,不用在每条指令的执行函数里都想着它。
// src/isa/riscv32/include/isa-def.h typedef struct { word_t gpr[32]; // 32 个通用寄存器 vaddr_t pc; // 程序计数器 } riscv32_CPU_state; #define R(i) (cpu.gpr[i]) // 读寄存器统一走这里把寄存器访问收敛到一个宏上,后面接表达式求值的时候只要复用R(i)就行,不用在两处分别处理x0。
3.3 x N EXPR:按表达式扫内存并把字节拼成字
x命令的语法是x N EXPR,意思是「从地址 EXPR 开始,连续打印 N 个 4 字节字」。难点在于EXPR是一个表达式而不是裸数字,所以得先有表达式求值——这也是为什么我在第 2 章建议先做info r和x,再做p。
// src/monitor/sdb/sdb.c static int cmd_x(char *args) { if (args == NULL) { printf("Usage: x N EXPR\n"); return 0; } // 切出 N char *n_str = strtok(args, " "); if (n_str == NULL) return 0; int n = atoi(n_str); if (n <= 0) { printf("x: N 必须是正整数\n"); return 0; } // 剩下的整段留给表达式,注意别用 strtok 继续切,否则空格被吃掉 char *expr = n_str + strlen(n_str) + 1; if (expr[0] == '\0') { printf("x: 缺少地址表达式\n"); return 0; } bool ok = true; word_t addr = expr_eval(expr, &ok); if (!ok) { printf("x: 表达式求值失败: %s\n", expr); return 0; } for (int i = 0; i < n; i++) { word_t cur = addr + i * 4; printf("0x%08x: 0x%08x\n", cur, paddr_read(cur, 4)); } return 0; }两个细节值得单独说。第一个是expr指针的位置:n_str + strlen(n_str) + 1跳过了N和它后面那个空格,直接指向表达式的第一个字符。当args只有N一个词的时候,这个位置可能已经越过了字符串结尾,所以后面必须再判一次expr[0] == '\0'。这是 PA1 里x命令最常见的崩溃来源之一。
第二个是为什么不继续用strtok把表达式切出来。因为表达式里本身可能有空格(x 4 $pc + 8这种写法),strtok会把它切碎。正确的做法
本文还有配套的精品资源,点击获取