☰
NEMU PA1手写调试器全解析:从模拟器原理到sdb实现
2026/10/14 2:06:08 网站建设 项目流程

简介:这份PDF资源面向计算机系统课程学习者、NEMU项目初学者及体系结构方向研究者,系统梳理NEMU PA1项目的定位、核心原理与典型应用场景。内容覆盖简单调试器(sdb)的功能设计,包括单步执行、寄存器查看、内存扫描与表达式求值等关键能力,并深入讲解CPU模拟、内存模拟及可选设备模拟的底层机制,有助于快速建立对NEMU基础设施的整体认知。资源为单个PDF文档,压缩包仅74KB,便于离线阅读或随堂参考。文档采用“概述—原理—应用”的结构展开,兼顾教学与科研视角,既可作为计算机系统课程实验的辅助材料,也能为后续NEMU进阶开发或相关研究提供基础铺垫。目前已有796人学习下载,适合正在完成PA1阶段任务、希望理解调试器实现原理的读者参考。

1. NEMU PA1 到底是什么:一个亲手写的调试器能拆掉多少“黑匣子”

NEMU PA1 这个项目,说穿了就一件事:给一台还没完全造好的模拟器,亲手写一个调试器。模拟器本身是个只会循环执行指令的“黑匣子”——跑起来之后,程序计数器走到哪、内存里发生了什么,你全都看不见;一旦出错,只能靠 printf 加重启去猜。PA1 通过实现一个叫 sdb 的简单调试器,把单步执行、打印寄存器、扫描内存、表达式求值几件事逐个打通,让你第一次能扒开模拟器内部看状态。适合正在学计算机系统、折腾模拟器或为后续实验打基础的人。教学验证、系统研究、底层软件调试都能用上。这篇拆解把原理、实现路径和踩坑点一次讲透,照着做能把环境完整跑起来。

2. 模拟原理再拆一层:CPU 状态、内存数组与加载路径如何支撑 sdb

sdb 能工作的前提,是模拟器把 CPU 和内存的状态暴露成了普通的数据结构。NEMU 的 CPU 模拟不是用真 CPU,也不是逐周期翻译硬件的时序逻辑,而是用一个结构体记录关键状态,在“取指 - 译码 - 执行”的主循环里不断更新。理解了这条主链,你就明白调试器凭什么能读到寄存器、凭什么能停在指定指令上。

2.1 CPU 模拟循环:取指、执行、更新 PC——状态如何在结构体里留痕

PA1 阶段的 CPU 模拟,核心目标是“状态机完整”。指令集往往是半成品,很多指令只是占位实现,但寄存器和程序计数器的行为必须是完备的。常见实现是:一个结构体保存所有通用寄存器值,pc 单独占一个字段;主循环每次从 pc 指向的地址读指令,按操作码分发到对应处理函数,执行完更新寄存器和 pc。调试器看到的“寄存器状态”,本质就是读这个结构体。

typedef struct { uint64_t gpr[32]; // 通用寄存器,RAX, RCX, RDX ... vaddr_t pc; // 程序计数器 } CPU_state;

有了状态结构体,执行循环就有写头了。我一般会写成一个函数:

void cpu_exec(uint64_t n) { for (uint64_t i = 0; i < n && nemu_state.state == NEMU_RUNNING; i++) { cpu.pc = exec_inst(cpu.pc); // 取指、译码、执行,返回新的PC } }

这里有两个细节值得说。第一个是循环条件里的nemu_state.state == NEMU_RUNNING:客户程序执行到 trap 指令时,模拟器会把状态从 RUNNING 改成 END,此时循环立刻停住。如果不带这个判断,程序都执行完了,循环还会一头撞进空内存继续取指,后面第 5 章的 si 卡死问题就是这么来的。第二个细节是返回值:exec_inst 返回新 pc,而不是去改全局变量——这样每次执行一条指令前后,pc 都保留在结构体里,调试器打印“当前在哪”就是一行 printf 的事。

寄存器数组的下标约定要跟头文件保持一致,不要自己在 sdb 里再写一套映射。x86-64 的通用寄存器在 NEMU 里通常按 rax、rcx、rdx、rbx、rsp、rbp、rsi、rdi、r8~r15 的顺序排,下标 0 是 rax,4 是 rsp。后面写 info r 命令时,直接遍历这个数组,打印名用头文件里现成的 reg_names 字符串表。PA1 阶段不需要考虑异常和中断,PC 的更新方式只有“顺序执行”和“跳转指令改 PC”两种,这让调试器在实现上省了很大一类麻烦。

2.2 内存模拟:字节数组、读写接口与地址换算逻辑

NEMU 把内存实现为一个大字节数组,这是模拟器最直白也最可靠的做法。所谓内存模拟,本质上就是维护一段连续缓冲区,外加一层地址校验。常见设计如下:

static uint8_t pmem[CONFIG_MSIZE]; // 客户程序物理内存

注意这里有个地址换算:客户程序的地址空间从 CONFIG_MBASE 开始布局,而数组下标从 0 开始,所以物理地址 addr 对应 pmem[addr - CONFIG_MBASE]。所有内存访问都走统一的读写接口,PA1 阶段一般没有分页,也不需要开 MMU,vaddr 和 paddr 往往指向同一个函数。

uint32_t vaddr_read(vaddr_t addr, int len) { if (in_pmem(addr)) { return pmem_read(addr - CONFIG_MBASE, len); } panic("address = 0x%08" PRIx32 " is out of bound", addr); }

这段代码里,in_pmem 是做边界检查的关键。x 命令扫内存时如果地址算过头,第一时间就在这里触发 panic,而不是让你读到一堆乱七八糟的“幻影数据”。这点对调试器体验影响很大:宁可报错,也不要返回垃圾值。

提示:PA1 阶段虚拟地址与物理地址一致,但如果你的框架里同时存在 vaddr_read 和 paddr_read 两套接口,先确认它们是否被映射到同一个函数,否则后面调 x 命令时会发现读出来的内容对不上。

字节序也值得提前说一句。x86 是小端,pmem_read 在拼多字节结果时,低地址放低字节。如果你在 sdb 里自己拼数字,务必按小端读;很多“扫描内存结果完全看不懂”的翻车,其实是拼字节时顺序写反了。

2.3 客户程序加载:镜像文件与复位入口

调试器要调试的是“客户程序”,所以模拟器启动时必须先把客户程序放进内存。常见流程是:读入一个编译链接好的镜像文件(通常是 .bin 或通过工具转出来的纯二进制),把文件内容拷贝到 pmem 的对应位置,然后把 CPU 复位到入口地址,寄存器全部清零。做完这两件事,模拟器停在第一条指令之前,sdb 才有机会接管。

复位后的状态必须是确定的。pc 是多少、rsp 指向哪、标志寄存器什么值,都要有一条清晰的初始化路径。我在实际做的时候,习惯把复位函数单独拎出来,每次启动前调用一遍,这样调试器打印出来的初始寄存器值不会“每次都不一样”。客户程序如果依赖栈,入口附近一般会有初始化栈的指令,你单步走几步就能看到 rsp 从一个固定值开始变化——这是验证 CPU 模拟是否正常的最早信号。

另外,设备模拟属于可选扩展,PA1 阶段基本不掺和。键盘、屏幕这些外设通常留到后面的阶段再接,调试器这层不用关心设备,它只操作 CPU 状态和内存。把这两样吃透,sdb 的地基就算立住了。

3. sdb 实现四件套:单步、寄存器、内存扫描与表达式求值的取舍

sdb 不只是一个“能交互的命令行程序”,它更像一个放大镜:把模拟器内部的状态一点一点露给你看。四件套里,前三个都很直白,真正的分水岭在表达式求值。我建议按“先跑通简单命令,再啃求值器”的顺序动手,避免一上来就被解析问题绊住。

3.1 单步执行:si 命令背后的一次 cpu_exec 调用

si 命令的语义是执行 N 条指令。实现上,命令处理函数只需要做一件事:把参数解析成整数,再交给 cpu_exec。常见做法是做一个表驱动的命令分发器,命令字符串、处理函数指针、帮助文本放一张表里,查表分发。sdb 里注册一条命令的成本被压到很低,后面想加命令也方便。

static int cmd_si(char *args) { int n = 1; if (args != NULL && *args != '\0') { char *end; n = strtol(args, &end, 10); if (*end != '\0' || n <= 0) { printf("Usage: si [N]\n"); return 0; } } cpu_exec(n); return 0; }

这段代码有三个参数细节。strtol 的第三个参数 10 表示按十进制解析,end 指针用来检测字符串后面是不是还有垃圾字符——如果用户输入了si 3abc,*end 不是 '\0',正确做法是报错而不是悄悄忽略。n <= 0 的情况直接拒绝,避免si 0这种输入让你误以为调试器卡死。最后调用 cpu_exec(n),所有执行逻辑都封装在里面,si 命令本身保持得很薄。

执行完 si 后,sdb 会重新进入提示符等待输入。如果发现执行了若干条指令后没有任何输出,不要先去查 si,先确认 cpu_exec 里有没有打印每条指令的 pc;如果连 pc 都没打,你的调试器就是个盲人,单步执行的意义直接减半。一般建议在 cpu_exec 循环里把当前 pc 和取到的指令编码打出来,方便和真实执行的指令流对照。

3.2 打印寄存器:从 CPU_state 到格式化输出

info r 命令要做的事:遍历 CPU_state 里的寄存器数组,按固定格式打出来。PA1 阶段的寄存器宽度是一致的 64 位,打印时统一用十六进制、位数补满,这样高低位变化一眼就能看出来。

static void display_regs(void) { for (int i = 0; i < 32; i++) { printf("%-4s = 0x%016" PRIx64 "\n", reg_names[i], cpu.gpr[i]); } printf("pc = 0x%016" PRIx64 "\n", cpu.pc); }

格式控制符%-4s保证寄存器名左对齐、占 4 个字符,%016PRIx64 保证十六进制输出补足 16 位。这里的 reg_names 就是 2.1 节说的那个映射表,建议直接复用,不要自己重新拼一份,否则下标错一个,后面排起错来就是典型的“看着对但结果玄学”问题。

常用寄存器映射如下,64 位模式下低 32 位名字就是把 r 换成 e;r8~r15 的低 32 位是 r8d~r15d。

下标64 位名低 32 位名
0raxeax
3rbxebx
4rspesp
5rbpebp
6rsiesi
7rdiedi
8r8r8d

打印 pc 也建议走同一套格式,这样你在实验报告里粘贴输出时,寄存器列是对齐的,老师一眼能看到 pc 跳变。

3.3 扫描内存:x 命令的地址递增与单位换算

x 命令的格式通常是x /n u addr,n 是单元个数,u 是单位长度,b/h/w/g 分别对应 1/2/4/8 字节,addr 是起始地址。这个命令最容易翻车的地方不在解析,而在地址递增:很多人把循环步长写成了“地址 + 1”,结果x /4w打出来的 4 个值其实是 4 个字节,而不是 4 个字。

static int cmd_x(char *args) { int n; char unit; vaddr_t addr; sscanf(args, "%d %c %lx", &n, &unit, &addr); int sz = (unit == 'b') ? 1 : (unit == 'h') ? 2 : (unit == 'w') ? 4 : 8; for (int i = 0; i < n; i++) { printf("0x%08lx:", addr + i * sz); for (int j = 0; j < sz; j++) { printf(" %02x", vaddr_read(addr + i * sz + j, 1)); } putchar('\n'); } return 0; }

外层循环按“单元个数”走,内层按“字节”读,这样每个单元内部会展开成原始字节。sz 是单位换算的核心,w 是 4,g 是 8,h 是 2。用 vaddr_read 按单字节读,天然避开了字节序拼装问题,输出结果和 xxd 这种十六进制工具对照时不容易花眼。

这里要给个提醒:镜像文件加载进内存后,入口地址附近的内容就是指令编码。你用x /8w 0x80000000扫出来的数据,应该能和镜像二进制文件的对应位置逐字节对上;对不上,问题大概率出在镜像加载偏移,而不是 x 命令本身。

3.4 表达式求值:tokenize 与递归下降的取舍

表达式求值是 PA1 里最重的一块,很多人卡在这里几天。需求很简单:用户输入(1 + 2) * 3 + $rax + 0x10这种式子,sdb 能算出数值。实现分两步:先把字符串切成一串 token,再按优先级递归求值。token 类型至少要覆盖:十六进制数、十进制数、寄存器引用($ 开头)、运算符 + - * / 和括号。

typedef struct { TokenType type; // TOKEN_NUM / TOKEN_REG / TOKEN_OP / TOKEN_LPAREN ... uint64_t val; // 数值或寄存器下标 char str[32]; // 原始字符串,排错时用 } Token;

我一般用递归下降,代码量小,报错位置也自然。核心思路是:在 token 串里找出优先级最低的那个运算符,把表达式切成左右两半,分别递归求值,再用这个运算符把两半合起来。找最低优先级运算符时必须跳过成对括号,否则括号内部的加减号会被当成最外层的运算符,优先级就全乱了。

static int eval(int l, int r) { if (l > r) { printf("empty expression\n"); return -1; } int op = find_lowest_prio_op(l, r); if (op == -1) { return parse_number(l, r); } int lhs = eval(l, op - 1); int rhs = eval(op + 1, r); switch (tokens[op].type) { case TOKEN_ADD: return lhs + rhs; case TOKEN_SUB: return lhs - rhs; case TOKEN_MUL: return lhs * rhs; default: assert(0); } }

parse_number 里要区分三种情况:$开头去查寄存器,0x开头按十六进制,否则按十进制。如果你还想支持负数、按位与这类扩展,优先级表里再加一行就行。不要一上来就写完整的编译原理式解析器,PA1 的表达能力有限,递归下降足够用,后面做条件断点要复用的时候也容易改。

这里有个取舍要说明:有些人习惯把表达式先转成后缀表达式再算,在只有加减乘除括号时完全走得通,但调试器场景里用户输入很随意,递归下降能让你在出错时直接指出“第几个 token 有问题”,排错体验更好。PA1 做完了,这个求值器后面还会被反复复用到,选一个自己能维护的方案比选一个看起来高端的方案重要得多。

4. 完整跑通 PA1:环境构建、目录结构与逐项验证清单

原理和实现都清楚了,接下来是把工程跑起来。PA1 是典型的“代码量不大、但环境坑不少”的实验,我先说目录,再说启动,最后给一张验证清单——照着逐项过,比漫无目的地试命令高效得多。

4.1 工程目录:哪些文件在 sdb 实现里真正动手

NEMU 的源码习惯上按功能拆目录,PA1 阶段你真正要动的通常只有一小部分。一个大致的目录划分如下:

目录职责PA1 里你大概率要动的文件
include/公共类型、宏、外部变量声明common.h、cpu.h
src/monitor/命令分发、sdb 主体、UIsdb.c、monitor.c
src/cpu/执行循环与 CPU 状态cpu-exec.c
src/memory/物理内存读写paddr.c
src/isa/寄存器定义、指令解析reg.c、inst.c

动手顺序建议是:先读 include 里的头文件,弄清有哪些全局函数和全局变量;再看 src/monitor 里命令是怎么注册的,照着已有模板加自己的命令;最后才是写命令处理函数本身。如果拿到的是学校实验框架,注意 Makefile 里有没有额外的宏定义——有些框架会预留“是否启用参考模拟器”这类开关,直接决定你一些调试功能能不能跑。

这里有个常见误区:一上来就改 src/isa/ 里的指令实现。PA1 的重点是 sdb,指令集只要“能跑通测试程序”就行,不需要你去逐条抠指令语义。把时间花在指令集上,属于典型的用力用错地方。

4.2 构建与启动:从 make 到弹出 sdb 提示符

环境准备一般就三件事:Linux 环境、make、一个能用的编译器。PA1 不依赖图形界面,纯命令行就能做完。

cd nemu make # 或 make -j$(nproc) 并行编译 ./build/nemu ./bin/cpu-tests.bin # 带一个客户程序镜像启动

有些框架提供 menuconfig 之类的配置界面,用来选 ISA 和内存大小;如果没有,直接看 config.h 里的宏定义。这里有个关键点:ISA 选择必须和实验文档一致,你后面打 info r 看到的寄存器名,完全取决于 ISA 类型。选成 x86 就按 64 位寄存器打印,选成别的就按对应架构的寄存器表来。

启动后如果一切正常,会看到一个提示符,通常是(nemu)。输入 help 能看到已注册的命令列表,输入 q 退出,输入 si 1 试试单步。到了这一步,说明构建和最基础的运行链路已经通了。

提示:启动时如果提示找不到镜像文件,先确认路径和文件权限,不要直接怀疑代码。镜像没加载进来表现为“程序跑起来一片空白”,这种情况十有八九是路径问题,属于环境类玄学里最常见的一种。

编译报错时先看第一行错误,PA1 最常见的错误是漏了某个头文件里的宏或类型定义;用 make -j 并行编译时如果报错信息被淹没,建议先串行 make 一遍,把完整错误链拉出来再改。

4.3 逐项验证:一张清单确认四件套没白写

跑通提示符之后,逐条验证四件套。下面这张表是验证动作和期望结果,建议按顺序过:

命令输入期望结果
sisi 3打印 3 条指令的 pc 及指令编码,pc 连续变化
info rinfo r打印 32 个通用寄存器 + pc,寄存器值非全零
xx /8w 0x80000000读到 8 个字,内容与镜像二进制能对上
pp (1 + 2) * 3输出 9
pp $pc + 8输出数值,和 info r 里的 pc 手动加 8 对照一致

验证的思路是“交叉比对”。info r 打出的寄存器值,和单步执行前的初始值比;x 扫出的字节,用 xxd 打开镜像文件对应区域比;p $pc + 8的结果,拿 info r 的 pc 手算验证。这样即使没有官方标准答案,也能确信自己没写歪。

还有一个容易被忽略的点:命令解析失败时应否友好报错。比如x /4x缺地址、p 1+表达式不完整,sdb 应该打印用法或错误提示,然后重新进入提示符,而不是崩掉整个模拟器。把“能抗住错误输入”当作验收标准之一,实验报告也好看很多。

5. PA1 避坑指南:五类高频翻车现场与排查顺序

实战里踩过的坑,按出现频率排。每一条都写成“现象 → 原因 → 解决”,照着排查能省下大把时间。

5.1 负号被当成减号:单目运算符解析陷阱

现象:输入p -10 + 2,期望是 -8,结果算出 -12 或直接报错;输入p 1--1提示语法错误。

原因:tokenizer 把所有-都当成二元减号,递归求值时左侧缺操作数,或者当成“1 减负 1”时运算顺序不对。这类问题只在特定输入下触发,前期测试全是正数时根本发现不了。

解决:在 tokenize 阶段区分单目负号和二元减号。判断规则是:如果-出现在表达式开头,或者紧跟在运算符、左括号后面,它就是单目负号;其余情况是二元减号。实际判断时记得先跳过空格,找到前一个非空字符再决定。

if (s[i] == '-' && (i == 0 || s[i-1] == '(' || strchr("+-*/", s[i-1]))) { // 标记为单目负号,求值时对右操作数取反 }

单目负号的求值可以在递归函数里单独处理:遇到负号 token 时,只对右半部分递归求值,再取负;不要把它丢进“加减乘除”的二元分支里。修完这一点,p -1 + 2和p 1 - -2这类输入就都能正常算了。

5.2 x 命令地址没乘单位:扫描出的内存全是错位数据

现象:x /4w 0x80000000打印的数据看起来每个只有 8 位,而且相邻两个值之间只差 1 个字节的位移。

原因:地址步长写成了addr + 1,而不是addr + i * sz。w 是 4 字节单位,循环该按 4 字节步进,写成 1 字节后,4 个“字”实际是连续 4 个字节被拆成了 4 个“假字”。

解决:按 3.3 节的方式,外层循环用addr + i * sz作为每个单元的起始地址,内层按字节展开。修好后再用 xxd 与镜像文件对照,如果还是对不上,再查单位映射表(b/h/w/g 对应 1/2/4/8)有没有写错。

5.3 si 一直停不下来:忘了判断模拟器运行状态

现象:执行si 1000后,提示符迟迟不回来,屏幕上还在持续打印指令;或者程序明明已经执行完 trap,输出却还在继续。

原因:cpu_exec 的循环条件里漏了nemu_state.state == NEMU_RUNNING。trap 指令已经把状态改成了 END,但循环无视它,继续从空内存取指。空内存读出来的 0 可能被执行成一条看似合法的指令,实际上早就跑飞了。

解决:把执行循环改成for (...; i < n && nemu_state.state == NEMU_RUNNING; i++)。另外确认 trap 处理后真的修改了全局状态,而不是只改了某个局部变量。修完后用si 1000000做一次压力测试,能正常停下并回到提示符就算过。

5.4 寄存器名被解析成数值:tokenizer 少了查表环节

现象:p $rax + 4报错,或者把$rax当成了某种十六进制数解析,得出一个完全没意义的大数。

原因:表达式求值的 tokenize 阶段没有单独处理$开头的 token,或者处理顺序放在了十六进制判断之后,导致0x和$两种前缀互相干扰。

解决:在 tokenize 里首先识别$开头的一段,去寄存器映射表查名字,查到了就存成“寄存器引用”类型的 token,查不到就报错。寄存器映射表直接用头文件里现成的 reg_names,不要自己重新写一份。这种错位一旦发生,表现极其接近“运行结果看不懂”,属于典型的需要靠查表逻辑一次性规避的问题。

5.5 链接报错:跨文件访问变量时缺 extern 声明

现象:sdb.c 里访问 cpu.gpr、nemu_state,编译阶段没报错,链接阶段报 undefined reference。

原因:变量定义在别的 .c 文件里,sdb.c 没有对应的声明。新手常把“定义变量”和“在头文件里声明 extern”混为一谈,以为加了 include 头文件就万事大吉,但头文件里如果没有 extern,链接器照样找不到符号。

解决:在公共头文件(如 common.h)里统一声明:

extern CPU_state cpu; extern NEMU_state nemu_state;

然后在某个 .c 文件里定义一次。不要在头文件里直接写CPU_state cpu;,否则多个 .c 包含同一个头文件会触发重复定义。修好后重新 make,看到链接通过再往下做。

6. 把 sdb 再往前推一步:表达式求值器的两个进阶用法

PA1 做完,表达式求值器看起来只用来算算术,但它真正的价值是“能把内存地址算出来”。我建议你做完四件套后,强行给自己加两个小功能。

技巧 1:地址即表达式。把 x 命令的地址参数从“只接受十六进制数字”改成“接受完整表达式”。这样你能直接输x /8w $rsp或x /4w $pc+8,省去先 p 算地址再手打数字的重复劳动。实现不复杂:把原来调用 strtol 的地方换成调用表达式求值函数,返回值当地址用。此时表达式求值器就从一个“计算器”变成了“地址发生器”,调试体验完全不一样。

技巧 2:用表达式做状态一致性检查。比如在执行若干条指令以后,验证寄存器是否符合预期:

static void check_runtime_state(void) { uint64_t expect = eval("($rsp - 8) & 0xffff"); if (expect != (cpu.gpr[4] & 0xffff)) { printf("stack pointer mismatch at %s\n", __func__); } }

cpu.gpr[4] 对应 rsp,取低 16 位与表达式结果做对比。这段代码的价值在于:你可以把“表达式 + 寄存器比对”当作一组快速断言,跑完一段执行流就检查一次,比肉眼盯着寄存器表靠谱得多。如果你手头正好有这份 PA1 的资料,建议把它当作实验笔记来读:先跑通环境,再对照实现部分逐行看,踩坑部分留着排错时查。

验证方法也顺手说一下:做完两个技巧后,用一个已知行为的客户程序做回归。程序启动后 rsp 指向固定位置,你用p $rsp确认和文档一致;再把 x 命令扫出的前几条指令与原始二进制对照,确认指令字节没有错位。那种“看起来正常但指令顺序不对”的 bug,用这个对照法基本一次就能抓出来。

PA1 最容易被低估的一点是:表达式求值器在后面会被反复复用,条件断点、内存监视点,底层都是它。从那以后,我做模拟器类项目都强迫自己先把调试器和表达式求值打通,再碰指令集。调试器不是辅助工具,它就是理解这台机器最快的那把钥匙。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询