NEMU模拟器调试器实战:单步、寄存器、内存与监视点

发布时间:2026/11/2 13:48:37
NEMU模拟器调试器实战:单步、寄存器、内存与监视点

简介:《PA1-简易调试器1》是一份面向计算机系统实验学习者的PDF实验指导,围绕NEMU简化版x86全系统模拟器,讲解如何构建具备单步执行、寄存器打印、内存扫描、表达式求值与监视点等能力的简易调试器。内容按三个阶段组织,并涉及实验前的git分支整理、评分构成与实验报告要求,适合需要完成PA1或补强调试与虚拟化认知的学生对照实践。压缩包共1个PDF文件,大小约1.53MB,便于直接阅读和打印。已有2183人学习下载。读者可据此理解调试器功能拆解、NEMU模块关系与阶段任务重点,并借助评分说明安排代码实现与报告撰写,减少在环境准备和任务理解上的偏差。

1. 从一条 printf 说起

调一个只有几十行的 mov 用户程序,为了确认某条指令执行完esp到底落在哪,只能在 C 代码里到处塞 printf,改一次重编一次,一个上午就没了。这份 PA1-简易调试器资料解决的就是这个场景:它在 NEMU 这个 x86-32 全系统模拟器的 monitor 模块上加一套接近 GDB 的命令——si单步、info r看寄存器、x扫内存、p求表达式、w下监视点,全部代码约 400 行,预计 30 小时。适合两类人:正在啃模拟器/虚拟机框架、需要给自制系统补调试能力的开发者,以及想动手写一个命令行调试器前端的人。它不动 CPU 执行部分,只在“观察”这一侧做文章,因此拿来做二次开发的起点也不重。

2. NEMU 框架拆解:一条指令从 restart() 到 cpu_exec() 的完整链路

2.1 四个模块的边界:monitor 站在虚拟机器外面

NEMU 被切成四块:monitor、CPU、存储管理、设备。后面三块合起来是一台虚拟计算机,用户程序跑在里面;monitor 在这台机器之外,负责和宿主系统打交道(读写文件、读 ELF、打印日志),也负责调试。调试器为什么非要单独放在外面?因为用户程序在模拟世界里跑,它自己的状态对外部 GDB 来说几乎不可见——你没法用 GDB 直接在 NEMU 里跑的用户程序上打断点,但 NEMU 自己随时能读到cpu.eip,这就是差距。

这个边界想清楚,后面写代码就不会乱放:命令解析、表达式求值、监视点全在nemu/src/monitor/debug/下;寄存器的定义在nemu/include/cpu/reg.h,实现和自测在nemu/src/cpu/reg.c;真正执行指令的exec()在nemu/src/cpu/exec/exec.c,PA1 阶段基本不需要动它。

我一般会先画一张归属图再动手,否则很容易把printf塞进exec.c里,等 PA2 一改指令译码就全得推倒重来。

2.2 启动链路:init_monitor → restart → ui_mainloop → cpu_exec

框架的入口很固定,你可以顺着下面这条线读一遍,读的时候对照源码,别脱离代码空看文档。

/* nemu/src/monitor/monitor.c 结构示意 */ int init_monitor(int argc, char *argv[]) { init_log(); /* 打开 log.txt,之后所有 Log() 的输出去这里 */ init_isa(); /* 体系结构初始化,内部会调用 reg_test() 验证寄存器实现 */ init_sdb(); /* 读 ELF 的符号表和字符串表,为以后的函数名解析做准备 */ init_regex(); /* 把表达式规则表编译成正则,编译失败会 assertion fail */ init_wp_pool(); /* 初始化监视点结构池,阶段 3 才用得上 */ return 0; } void restart(void) { init_ramdisk(); /* 把命令行传进来的用户程序从真实磁盘读进 ramdisk */ /* 把 entry 读到固定地址 0x100000,并把它作为 %eip 的初值 */ cpu.eip = 0x100000; init_dram(); /* DRAM 模拟,PA1 不用关心细节 */ }

restart()做的事,本质上是在模拟“上电”:真实机器上电先跑 BIOS,再从磁盘把程序搬进内存;这里简化成直接把一个有意义的 entry 放到0x100000,然后让%eip指向它。所以你在x 10 0x100000里看到的那段字节,就是即将被执行的入口代码。

restart()结束后进入ui_mainloop()(在nemu/src/monitor/debug/ui.c),用 readline 读命令。敲c才会进cpu_exec():

/* nemu/src/monitor/cpu-exec.c 的执行循环,check_watchpoints 是阶段 3 加的 */ void cpu_exec(uint32_t n) { for (; n > 0; n --) { exec_once(); /* 取指、译码、执行一条指令 */ check_watchpoints(); /* 每条指令之后重新求值监视点表达式 */ if (nemu_state != RUNNING) break; /* nemu_trap、监视点命中或出错时退出 */ } }

参数n是本次要跑的指令条数:cmd_c()里传的是-1,转成uint32_t就是0xFFFFFFFF,实际含义是“一直跑到有人喊停”。谁来喊停?用户程序执行到nemu_trap指令(机器码0xd6)。这条指令 x86-32 里并不存在,是人为塞进去的结束标记——这正好回答了“程序跑到 main 返回处就结束”那个疑问:结束不是语言规定的,是运行时约定出来的。监视点命中时,也是靠把nemu_state改成非 RUNNING 让它跳出循环。

2.3 最小阅读清单和第一次跑通

框架代码六千多行,PA1 阶段真正要读的没那么多。我列了一份自己用的清单:

文件作用什么时候必须看
nemu/src/monitor/monitor.c初始化与restart()阶段 1 开始前
nemu/src/monitor/debug/ui.creadline 主循环与命令表写任何命令之前
nemu/src/monitor/cpu-exec.ccpu_exec()循环实现si、接入监视点时
nemu/src/cpu/reg.creg_test()自测改完寄存器结构体后
nemu/src/monitor/debug/expr.c词法分析与求值骨架阶段 2
nemu/include/debug.hLog()、Assert()、panic()调试任何阶段
obj/testcase/mov.txtmov 程序的 objdump 结果验证x命令时

Log()值得单独说一句:它比printf多输出文件名、行号和函数名,调试信息一多,靠这个能直接定位到打印点。panic()则是无条件失败的 assert,写“这里我还没想好”的分支时用它最省事。

跑通的顺序建议老老实实来一遍:

# 先把分支理清楚,这一步不做,后面提交记录会一塌糊涂 git commit --allow-empty -am "before starting PA1" git checkout -b PA1 make # 编译,注意看有没有寄存器自测的 assertion fail ./nemu # 进来应该能看到命令提示符

进去以后敲c,如果reg_test()没通过,程序会在打印提示符之前就挂掉。通过了会看到 mov 用户程序跑完的输出;再敲q退出,打开log.txt就能看到刚才执行的每一条指令。这一步是整个 PA1 的基线,基线不稳,后面所有命令的调试都是在流沙上盖楼。

3. 从寄存器结构体到三条命令:si / info r / x 的落地写法

3.1 CPU_state:匿名 struct 套 union,把 EAX/AX/AH/AL 叠在同一块内存

框架里CPU_state是空的,reg_test()一跑就 assertion fail,任务就是把这块补上。难点不在写代码,在于想明白 i386 那套寄存器在物理上并不独立:EAX 的低 16 位是 AX,AX 又拆成 AH 和 AL。你不能声明九个独立变量,得让它们共享同一段内存。

/* nemu/include/cpu/reg.h */ typedef struct { union { union { union { uint32_t _32; /* 32 位整体视角 */ uint16_t _16; /* 低 16 位 */ uint8_t _8[2]; /* _8[0] 是 AL,_8[1] 是 AH(小端) */ } gpr[8]; /* 数组视角:方便循环遍历、统一打印 */ struct { /* 命名视角:p $eax、info r 直接点出来 */ uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; }; }; }; uint32_t eip; /* 程序计数器,先单独放着 */ } CPU_state;

外层那层 union 是给 PA2、PA3 留的口子,等后面加段寄存器、控制寄存器的时候,直接往里塞新的匿名 struct 就行,不用改已经写好的访问代码。

数组顺序必须是 EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDI。这个顺序不是按字母排的,是按 i386 的编码顺序来的,opcode里的寄存器编号直接当下标用。写成 EAX、EBX、ECX、EDX 是最常见的翻车点,reg_test()随机写 32 位再去读 16 位和 8 位,顺序错一位就露馅。

匿名 struct 和 union 是 C11 的特性,gcc 默认就支持。如果你的环境报错,先看编译选项里是不是被钉死到了更老的标准。

3.2 命令从键盘到函数:readline + cmd_table + strtok

ui_mainloop()用readline()读一行,比gets()多了行编辑和历史记录——上下方向键能翻之前敲过的命令,调表达式的时候很救命。读到字符串以后,靠一张命令表分发:

/* nemu/src/monitor/debug/ui.c 里的命令表,PA1 需要补后五条 */ static struct { char *name; char *description; int (*handler)(char *); } cmd_table[] = { { "help", "打印所有命令的帮助", cmd_help }, { "c", "继续运行被暂停的程序", cmd_c }, { "q", "退出 NEMU", cmd_q }, { "si", "单步执行 N 条指令后暂停", cmd_si }, { "info", "info r 看寄存器 / info w 看监视点", cmd_info }, { "x", "扫描内存", cmd_x }, { "p", "求表达式的值", cmd_p }, { "w", "设置监视点", cmd_w }, { "d", "删除监视点", cmd_d }, };

分发的逻辑是:用strtok()切出第一个 token,在表里按名字匹配,命中就把去掉命令名之后的剩余字符串丢给 handler。这带来一个必须记住的约定——handler里的args已经是剩余部分,想取第一个参数得继续strtok(NULL, " "),而不是从头切。cmd_help()里给了现成的strtok用法,照着抄一遍比看手册快。

参数解析不一定非用strtok。sscanf(args, "%d", &n)处理si 10这种“一个整数”的场景更干净,sscanf(args, "%s %s", ...)也能一次拿到两个参数。两种混着用完全没问题,我自己习惯是按参数个数选:一个参数用sscanf,参数个数不定(比如表达式)才上strtok。

另外,命令名匹配别用strcmp(name, "si")逐个写 if-else,表驱动的好处是加命令只加一行,不用改分发逻辑。

3.3 si 与 info r:单步和寄存器打印

si是最短的一条命令,因为执行逻辑框架里全给了,你只需要把参数译成次数再转手调用:

static int cmd_si(char *args) { int n = 1; /* 不传参数时默认单步一条 */ if (args != NULL) { sscanf(args, "%d", &n); /* 解析 "si 10" 里的 10 */ } cpu_exec(n); /* 执行完自动回到 ui_mainloop */ return 0; }

参数n直接决定cpu_exec()跑几条,返回 0 表示命令正常结束。这里有个容易忽略的点:si 0会一条都不跑,如果测试脚本里用0表示“无限”,结果就是原地打转,别问我怎么知道的。

info r要把九个值按固定宽度打印出来,靠一张名字表对齐:

static const char *reg_name[] = { "$eax", "$ecx", "$edx", "$ebx", "$esp", "$ebp", "$esi", "$edi", "$eip" }; static int cmd_info(char *args) { char *sub = args ? strtok(args, " ") : NULL; if (sub == NULL) { printf("用法: info r | info w\n"); return 0; } if (strcmp(sub, "r") == 0) { int i; for (i = 0; i < 8; i ++) printf("%-8s0x%08x\n", reg_name[i], cpu.gpr[i]._32); printf("%-8s0x%08x\n", reg_name[8], cpu.eip); } else if (strcmp(sub, "w") == 0) { show_watchpoints(); /* 阶段 3 再接 */ } return 0; }

%-8s让名字左对齐占八列,%08x补足八位十六进制,两者一起保证输出是整齐的一列,肉眼扫差异会快很多。框架对输出格式没有硬性要求,但既然要拿它调试,格式对齐这点投入是划算的。

3.4 x N EXPR:先简化成十六进制地址,再对齐输出

阶段 1 还没做表达式求值,所以x先只认十六进制数,等阶段 2 完成再把第二个参数换成expr_eval():

static int cmd_x(char *args) { char *n_str = args ? strtok(args, " ") : NULL; char *a_str = n_str ? strtok(NULL, " ") : NULL; if (n_str == NULL || a_str == NULL) { printf("用法: x N EXPR\n"); return 0; } int n = atoi(n_str); /* 要扫描多少个 4 字节 */ swaddr_t addr = (swaddr_t)strtol(a_str, NULL, 16); /* 按十六进制解析起始地址 */ int i; for (i = 0; i < n; i ++) { uint32_t val = swaddr_read(addr + i * 4, 4); /* 读 4 字节,接口按框架签名传 */ printf("0x%08x: 0x%08x\n", addr + i * 4, val); } return 0; }

n是行数不是字节数,每次读 4 字节、地址加 4,这是 GDB 的默认行为,也是题面里“连续的 N 个 4 字节”的意思。地址一律走swaddr_read(),别直接对内存数组取下标:现在它和物理地址是一回事,等 PA3 引入虚拟地址和线性地址,接口保持一致能省掉一轮返工。swaddr_read()的签名在不同框架版本里有(addr, len)和(addr, len, sreg)两种,照着手里那份传。

写完之后一定要验证。用x 20 0x100000打印入口附近的内存,再打开obj/testcase/mov.txt逐字节对。对不上就说明解析或读取位置有问题,这时候只是两条命令,定位成本还很低;等到后面指令越实现越多再发现,就得在几百行里捞针了。

4. 表达式求值与监视点:正则切词、递归求值、watchpoint 池

4.1 词法分析:规则表编译成正则,按位置顺序吃 token

p $eax + 0xc / 4这种输入,空格数量不固定,混着十进制、十六进制、寄存器、括号、解引用,手写状态机不是不行,是没必要。框架的做法是用正则表达式识别 token,规则是“正则 + 类型”的二元组:

/* nemu/src/monitor/debug/expr.c 的规则表,顺序即优先级 */ static struct rule { char *regex; int token_type; } rules[] = { {" +", NOTYPE}, /* 空格串,识别出来直接丢弃 */ {"\\+", '+'}, /* 正则里 + 是元字符,必须转义 */ {"-", '-'}, {"\\*", '*'}, {"/", '/'}, {"\\(", '('}, {"\\)", ')'}, {"0[xX][0-9a-fA-F]+", NUM}, /* 十六进制必须排在十进制前面 */ {"[0-9]+", NUM}, {"\\$[a-zA-Z]+", REG}, };

make_token()的工作方式很直接:用position记当前处理到的位置,按表顺序对e + position做regexec,命中且pmatch.rm_so == 0(匹配在开头)才算数,然后把子串长度加上去继续。全表都不命中就返回 false,说明词法分析失败。

这里有个隐形炸弹:规则表是有顺序的。十六进制那条如果排在[0-9]+后面,0xc0100000会被切成0和一堆垃圾,报错信息还特别难懂。另外str成员是定长数组,memcpy之前必须判断长度,超了就用assert(0)先挡住——按 KISS 法则,这个边界情况不影响核心功能,先用断言暴露出来,比写一段“得体”但没测过的处理代码安全得多。

4.2 递归求值:BNF 落到 eval(p, q) 上

token 切完以后就变成了一个数组,用p、q两个下标圈子表达式。递归的骨架是现成的:

static uint32_t eval(int p, int q) { if (p > q) { return 0; /* 空表达式,按约定返回 0 */ } else if (p == q) { return check_num(tokens[p].str); /* 单个 token:数字取值,寄存器读 cpu */ } else if (check_parentheses(p, q)) { return eval(p + 1, q - 1); /* 最外层被一对完整括号包住,剥掉 */ } else { int op = dominant_op(p, q); /* 找主运算符:括号外、优先级最低 */ uint32_t v1 = eval(p, op - 1); uint32_t v2 = eval(op + 1, q); switch (tokens[op].type) { case '+': return v1 + v2; case '-': return v1 - v2; case '*': return v1 * v2; case '/': if (v2 == 0) { printf("除零\n"); assert(0); } return v1 / v2; } } }

check_parentheses(p, q)不能只看首尾是不是左右括号,还要确认这对括号确实是一对——从头扫一遍计数,中途计数归零就说明首尾那对并不配对,(1+2)*(3+4)就是典型反例。

dominant_op()是整个求值的核心:跳过括号内部,找优先级最低的运算符;同优先级取最右边那个,因为 BNF 定义的是左结合,1-2-3必须拆成(1-2)-3。取最左会算出1-(2-3)=2,答案看着像对的,藏得极深,这一条我在后面避坑章还会提一次。

4.3 监视点:结构体池 + 每步重新求值比对

监视点的语义是“表达式值变了就暂停”。实现上是一个链表池:

typedef struct watchpoint { int NO; /* 监视点编号,d N 靠它定位 */ struct watchpoint *next; char expr[128]; /* 保存表达式原文,而不是求值结果 */ uint32_t value; /* 上一次求出来的值 */ } WP;

关键在expr存的是文本。为什么不能存w *0x2000求值出来的那个地址?因为w $eax也合法,而$eax每次变化时它指向的地址就变了,存地址等于把监视点钉死在一个过时的位置上。存文本、每次重新求值,才能覆盖寄存器表达式这类动态场景。

比对函数挂在cpu_exec()循环里,每执行完一条指令跑一遍:

void check_watchpoints(void) { WP *p = head; bool hit = false; while (p != NULL) { uint32_t now = expr_eval(p->expr); /* 复用 p 命令的求值入口 */ if (now != p->value) { printf("监视点 %d: %s\n旧值 = %u(0x%x)\n新值 = %u(0x%x)\n\n", p->NO, p->expr, p->value, p->value, now, now); p->value = now; /* 更新基准值,避免重复触发 */ hit = true; } p = p->next; } if (hit) nemu_state = STOP; /* 让 cpu_exec 的循环退出 */ }

expr_eval()就是cmd_p()里调的那个求值函数,抽出来一处实现两处用。更新p->value这一步不能漏,否则同一个变化会在接下来每条指令上都报一次,输出会炸掉。d N删除时记得同时从池里摘下来并还给空闲池,不还也没事,但连续设几十个容易耗尽。

4.4 自测清单:别等 PA2 才发现阶段 1 是错的

原文里那句警告很实在:扫描内存有 bug,你却拿它去调试新功能,最后几天几夜都在追一个不存在的 bug。所以每完成一块,先自测:

命令测试输入期望现象
sisi/si 10/si 0暂停在正确条数上,0不动
info r运行前后各一次$eip随执行前进,$esp有变化
xx 20 0x100000与obj/testcase/mov.txt逐字节一致
pp 1+2*3、p (1+2)*3、p 0x107、9、16
pp $eax、p $eax+1与info r对得上
ww $eip、w *0x100000单步时命中一次就停,不刷屏
dd 1后再info w列表里少一条

p 1+2*3和p (1+2)*3这一组必须都过,只测一个很可能把优先级写反了还浑然不觉。

5. 避坑与排查:最容易翻车的五处

5.1 环境、寄存器与命令解析

现象:做到一半发现提交记录里混进了别的分支的改动,回滚都找不到干净点。原因:开工前没按说明整理分支,git checkout -b PA1这一步省了,后面所有提交直接压在默认分支上。解决:先git commit --allow-empty -am "before starting PA1"打一个空提交当锚点,再切分支;每个阶段做完单独 commit 一次,出问题能立刻回到上一个可用点。这是少有的后悔药,别省。

现象:一启动就在reg_test()里 assertion fail。原因:gpr[8]的顺序写成了 EAX、EBX、ECX、EDX,或者_8[2]的高低字节记反了。解决:顺序严格按 EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDI;小端下_8[0]是 AL、_8[1]是 AH。改完直接重跑,reg_test()会随机造数据验证三种宽度的一致性,过了才算数。

现象:si和info r都正常,x 20 0x100000打出来一堆 0。原因:起始地址用atoi解析了,0x100000被当成 0;或者循环里地址忘了+ i * 4,反复读同一个位置。解决:地址一律strtol(str, NULL, 16);循环体里先用局部变量算出addr + i * 4再打印和读取,避免边读边改。

5.2 表达式求值与监视点

现象:p 0xc0100000报词法错误,或者只识别出一个0。原因:规则表里[0-9]+排在了十六进制规则前面,正则按顺序匹配,先命中的赢。解决:把范围更窄、更长匹配的规则往前放;写完拿0x10、0xdeadbeef、10三个输入各测一次。

现象:p 1-2-3结果是 2 而不是 -4,但p 1+2+3又看不出问题。原因:dominant_op()取的是最左边的同级运算符,把左结合算成了右结合。解决:同优先级取最右边那个;顺手check_parentheses()也补上配对计数,别只看首尾字符。

现象:w $eip设完以后程序一路跑到底,一次都没停。原因:要么只在设置时求了一次值,之后没有在cpu_exec()循环里比对;要么存了求值结果而不是表达式原文,$eip一变地址就失效了。解决:把check_watchpoints()挂在每条指令执行之后,结构体里存expr文本、每次重新求值,值变化后立刻更新基准再置停止标志。

6. 二次开发:把它改成一个能复用的小调试器内核

这套东西写完之后其实是个通用骨架:命令表 + 参数解析 + 表达式求值 + 断点式回调。想继续扩,改动点很集中。第一件事是把表达式求值从cmd_p()里抽成expr_eval(const char *),p命令、w监视点、以后的条件断点全部共用一份实现,逻辑只维护一处。第二件事是把命令表做成可注册的,help的输出直接遍历表生成,加命令只加一行结构体,不用同步改三处。

加一条新命令的模板大概长这样:

static int cmd_bt(char *args) { /* 打印栈帧链:从 $ebp 出发沿保存的 ebp 往上走,顺便取返回地址 */ uint32_t ebp = cpu.ebp; while (ebp != 0) { uint32_t next = swaddr_read(ebp, 4); /* 上一帧的 ebp */ uint32_t ret = swaddr_read(ebp + 4, 4); /* 返回地址 */ printf("#%08x in ??? ()\n", ret); if (next <= ebp) break; /* 防止坏链把内存读穿 */ ebp = next; } return 0; }

ebp是帧基址,[ebp]存上一帧的基址、[ebp+4]存返回地址,这是最朴素的栈回溯。循环里那句向上单调的检查是保命的:链一旦被写坏就会原地读同一块内存,卡死在命令里比崩溃更难查。

再往下的改造方向我按性价比排了一下:

想加的能力主要改动点怎么验证
单步 N 条后打印状态cmd_si里cpu_exec(n)后补一次寄存器输出和si 1+info r交替敲的结果对比
表达式支持==、&&规则表加 token,eval加分支和优先级p 1==1、p 0&&1
条件断点复用expr_eval,在cpu_exec循环里判对一个必然命中的条件验证恰好停一次
日志分级给Log()加级别参数,按环境变量过滤跑一遍 mov 程序看log.txt体积

验证方法上有个习惯我一直在用:每加一条命令,先在一个只有几条mov指令的程序上跑一遍,结果拿去和obj/testcase/mov.txt的 dump 对;对不上就补断言,把不变量写死在代码里,而不是靠肉眼盯输出。还有一点,别在exec.c里加打印——那个文件以后会被指令实现塞满,任何写进去的调试代码最终都会变成负担。

从那以后我每加一条调试命令,都先把它的测试输入写进一个 shell 脚本,跑一遍全绿才允许自己往下走;毕竟这类框架里最难查的从来不是写错的代码,而是“看起来跑通了”的代码。希望帮到你。

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