简介围绕“PA1-简易调试器1”的PDF实验指导材料面向正在学习计算机系统、汇编与调试技术的学生及自学者帮助其在NEMU简化版x86模拟器上完成简易调试器实践。压缩包仅含1个PDF文件大小约1.53MB内容聚焦实验流程、阶段任务与评分说明便于按页查阅和打印。文档覆盖单步执行、打印寄存器状态、扫描内存、表达式求值及监视点等核心功能并提示实验前用git commit、git checkout整理分支同时交代代码与报告占比、各阶段分值及约30小时、400行代码的实践规模。已有2183人学习适合用作课程实验对照手册、调试器功能拆解参考和NEMU入门阅读材料。1. 从一条 c 命令说起简易调试器在 NEMU 里的坐标PA1 的名字叫「简易调试器」乍一听像要手搓一个 GDB真动手才发现要写的只有约 400 行 C 代码30 小时里大半时间耗在读框架代码和跟玄学 bug 较劲上。整条链路其实能压成一句话你在 monitor 里敲下ccpu_exec就开始一条条执行用户程序而si、info r、x、p、w这些命令本质都是让 monitor 站在「虚拟计算机」外面偷看一眼。评分表也直白代码 70%、报告 30%代码里阶段 1 占一半阶段 2 占三成阶段 3 只占一成——先把寄存器结构体和三条基础命令做扎实远比一上来就啃表达式求值划算。这份笔记按这条主线铺开框架地图、模块要点、易漏点最后落到一张提交前的自测表。2. 框架地图monitor、CPU、存储与设备四模块的走读顺序框架代码有 6000 多行不算空行第一次打开很容易被目录树劝退。合理的做法是先看结构再挑和当前进度相关的部分读无关模块碰都别碰——那些留给后面几个阶段的代码看了只会制造不必要的恐惧。2.1 四模块分工谁在虚拟计算机里谁在外面NEMU 由四块构成monitor、CPU、存储管理、设备。后三者拼出一台虚拟计算机用户程序就跑在这台机器上monitor 在这台机器之外负责两件事——和宿主系统打交道读文件、写日志以及充当调试器。这个「内外之分」值得多想一层。CPU 里的寄存器、内存里的指令对 NEMU 自己来说就是几个 C 变量和数组随时可读可写但对宿主机上的 GDB 来说用户程序的执行状态藏在 NEMU 进程内部想设个断点、看个变量都费劲。所以 monitor 里的调试器不是可有可无的装饰它是唯一能低成本窥视虚拟世界内部的窗口。缺了它后面几个阶段调 NEMU 会非常痛苦。理解方式可以类比三层嵌套Hello World 跑在 GNU/Linux 上超级玛丽跑在 NES 模拟器上而模拟器本身只是 GNU/Linux 的一个普通用户进程到了 NEMU用户程序跑在模拟出的 x86 硬件上那上面还压着一个微型操作系统。对用户程序而言它分不清脚下是真实硬件还是模拟出来的这就是虚拟化最朴素的魔术。2.2 启动链路从 init_monitor 到 %eip 0x100000开工前先做一件事别跳过# 在工程目录下先留一个空提交作为基线再切出实验分支 git commit --allow-empty -am before starting PA1 git checkout -b PA1这两条命令的作用是给「改挂了想回滚」留一条后路同时保证后续提交落在正确的分支上。血的教训是直接在主分支上改最后提交记录混成一团成绩受影响。启动流程顺着读一遍就清楚了。init_monitor先做一堆与 monitor 相关的初始化打开日志文件、读入 ELF 的符号表和字符串表、编译正则表达式、初始化监视点结构池。这些细节当下几乎一个都看不懂属于正常的——它们服务于后面才用到的功能。之后代码会对寄存器结构体做一次自测通过后调用restart()它模拟「计算机启动」初始化 ramdisk把待运行的可执行文件从真实磁盘读进内存开头那段区间读入入口代码 entry放到固定位置0x100000把%eip的初值设成0x100000初始化 DRAM 的模拟当前不必深究。真实机器启动时控制权先交给 BIOSBIOS 再从磁盘把有意义的程序读进内存。NEMU 把这个过程简化掉了monitor 直接把 entry 塞到固定地址然后让%eip指向那里。于是内存布局大致是物理地址区间用途PA1 是否使用从 0 开始的一段ramdisk存放可执行文件暂不使用0x100000 起entry用户程序本体是模拟执行从这里开始等到下一个阶段entry 会换成一个操作系统微内核由它从 ramdisk 里读程序并加载到正确位置那时这张表才会有实质变化。2.3 执行循环ui_mainloop、cpu_exec 与 0xd6 这条退出指令restart()结束后进入ui_mainloop()这是一个读命令、解析命令、执行命令的循环风格和 GDB 很像。键入c之后才真正进入cpu_exec()——一个不断取指、译码、执行的死循环每轮调一次exec()让 CPU 执行一条指令执行过的指令会写进log.txt。循环不会永远转下去退出条件有两个达到指定的循环次数或者用户程序执行了nemu_trap指令。这条指令机器码是0xd6x86-32 指令集里根本不存在它是专门为「指示程序结束」而加进去的私货。后面还会借它实现一些程序自身完成不了、必须请 NEMU 帮忙的功能。配套的思考题问cmd_c()调cpu_exec()时为什么传-1答案是-1表示不设上限一直跑到nemu_trap为止。谁来指示程序结束就是那条0xd6。另一个思考题问opcode_table是什么类型的数组看一眼定义就明白——它是函数指针数组共 256 项用操作码的单字节值做下标/* nemu/src/cpu/exec/exec.c 的骨架函数指针数组按 opcode 索引 */ static void (*opcode_table[256])(void) { [0x89] mov_r2rm, /* mov 的一个变体框架已实现 */ [0xd6] nemu_trap, /* 特殊指令结束用户程序 */ /* 其余槽位默认为 invinvalid表示尚未实现或 x86-32 中不存在 */ };数组元素类型是函数指针所以每个槽位存的是「执行该指令的函数地址」而不是数据。随着实验推进inv会一个个被真实函数替换掉。读代码时养成一个习惯先看这个数组的初始化方式就知道框架替你实现了多少。2.4 调试三宏与内存访问接口Log、Assert、panic、swaddr_readnemu/include/debug.h里有三个宏整个实验都靠它们定位问题/* 三个宏的典型用法输出会自动带上文件名、行号与函数名 */ Log(eip 0x%08x, inst 0x%02x, cpu.eip, inst); /* printf 的升级版 */ Assert(nr_token MAX_TOKEN, token 数超限: %d, nr_token); /* 条件为假则带信息失败 */ if (expr_len MAX_STR) { panic(表达式缓冲区装不下长度 %d, expr_len); /* 无条件 assertion fail */ }Log()比printf()多的那层前缀看着不起眼调试信息一多就是救命稻草——几十行输出里能一眼定位到哪个文件的哪一行打的。Assert()在断言失败前允许附带一段自定义信息比裸assert好用。panic()相当于无条件失败用于「走到这里一定是逻辑错了」的分支。访问模拟内存统一走swaddr_read()和swaddr_write()这两个接口。参数里出现的 swaddr、lnaddr、hwaddr 分别对应虚拟地址、线性地址、物理地址这几个概念要到后续阶段才真正用上。现在保持接口一致是为了将来不用回头改一大堆调用点——这种「先按最终形态留接口」的设计读代码时值得留意。3. 寄存器与三条基础命令union 三级视图到 x N EXPR 的完整链路阶段 1 的三个功能单步、打印寄存器、扫描内存加起来占代码分的 50%但真正的难点只有一个寄存器结构体。结构体写错后面所有命令的输出都不可信。3.1 三层视图8 个通用寄存器的位宽嵌套关系i386 的通用寄存器不是八个独立的东西而是层层嵌套的。以 EAX 为例它的低 16 位叫 AXAX 又拆成高字节 AH 和低字节 AL。八个寄存器都是这个结构32 位16 位高 8 位低 8 位EAXAXAHALEBXBXBHBLECXCXCHCLEDXDXDHDLESPSP——EBPBP——ESISI——EDIDI——注意后四个只有 16 位视图没有独立的高字节命名。这个表格建议直接背下来写info r的输出格式时用得上。物理上它们共享同一块存储写 EAX 会覆盖 AX、AH、AL反之亦然。这种「一份内存多种解读」的需求C 语言里对应的工具就是 union。3.2 匿名 struct 加 union一次写对 CPU_state 的关键实现思路是把「按位宽解读」和「按名字访问」两个需求叠在一起/* nemu/include/cpu/reg.h 的实现要点示意 */ typedef struct { union { union { uint32_t _32; /* 32 位视图 */ uint16_t _16; /* 16 位视图低 16 位 */ uint8_t _8[2]; /* 8 位视图_8[0] 是低字节(AL)_8[1] 是高字节(AH) */ } gpr[8]; /* 数组形式方便按下标循环打印 */ struct { /* 匿名 struct成员直接提升到外层可写 r.eax */ uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; }; }; uint32_t eip; /* 程序计数器独立于通用寄存器 */ } CPU_state;关键点有三处。第一最内层 union 让_32、_16、_8三个成员共享同一段内存写其中一个另外两个自动跟着变。第二gpr[8]数组保证可以按下标批量遍历info r里就不用写八遍printf。第三匿名 struct 把eax等名字直接暴露在外层访问时写r.eax而不是r.gpr[0]._32可读性差很多。寄存器顺序不是随便排的。eax, ecx, edx, ebx, esp, ebp, esi, edi这个次序对应 x86 的寄存器编码将来按指令里的字段取寄存器时能直接用下标省掉一张映射表。eip单独放在 union 外面因为它是程序计数器不参与通用寄存器的位宽嵌套。它的职责是记录「下一条要执行的指令在哪」restart()把它设成0x100000每执行完一条指令就要按指令长度自增。3.3 reg_test 报错时怎么定位nemu/src/cpu/reg.c里的reg_test()会生成随机数据做交叉验证先往 32 位视图写一个值再检查 16 位和 8 位视图是否符合预期反过来也测一遍。写对了它一声不吭然后 NEMU 打出命令提示符写错了就直接触发 assertion fail。定位方法很土但有效在reg_test()失败的那一行前后加Log()把被测寄存器的 32 位、16 位、两个 8 位值一次性打出来和手算结果对照。最常见的错因是_8[2]的下标理解反了以为_8[1]是 AL——x86 是小端低字节在低地址_8[0]才是 AL。这个坑下面还会单独说。如果 PA0 阶段运行 NEMU 一直报 assertion fail原因就是框架里CPU_state是空的等着你填。填完之后再跑看到提示符出现、输入c能正常结束、log.txt里有指令记录阶段 1 的第一关才算过。3.4 命令解析与 si / info r从 readline 到 printfNEMU 用 readline 库读命令好处是自带行编辑功能上下方向键能翻历史记录shell 也是这么干的。读到一整行字符串之后需要解析出命令名和参数比如si 10要拆成si和10。框架里用了strtok()两条路子都行/* 路子一strtok 逐段切分注意它会修改原字符串 */ char *cmd strtok(str, ); /* 第一次传入原串返回首个字段 */ if (cmd NULL) return 0; char *arg1 strtok(NULL, ); /* 之后统一传 NULL继续切同一串 */ /* 路子二sscanf 按格式直接取参数个数固定的命令更省事 */ unsigned int n; if (sscanf(str, x %u, n) 1) { /* 只吃下 N剩下的 EXPR 部分从原串里再取一次 */ }strtok()有两个必须知道的特性它会就地写入\0来切分所以不能传字符串字面量分隔符参数是「字符集合」而不是「完整分隔串」因此连续多个空格会被当成一个分隔符跳过正好省掉自己处理空格的麻烦。命令解析失败时返回 0、打印提示信息即可不必 panic。si [N]的实现极其简单不写 N 时默认 1然后调cpu_exec(N)。真正要留意的是输出格式和暂停时机别在循环里多执行一条。info r就是把八个通用寄存器加eip全部printf出来格式没有硬性规定参照 GDB 的info registers排版即可。建议用%08x固定宽度十六进制对齐后肉眼比对效率高很多。3.5 扫描内存 x N EXPR小端、步长与 objdump 对拍x N EXPR的语义是先算出 EXPR 的值当起始地址然后以十六进制形式连续输出 N 个 4 字节。拆解成三步解析出 N 和 EXPR。阶段 1 可以偷懒规定 EXPR 只能是十六进制字面量例如x 10 0x100000表达式求值留到阶段 2 再补循环 N 次每次读 4 字节地址步长是 4 而不是 1用%08x输出保证每行宽度一致。这里有两个细节容易翻车。其一swaddr_read()返回的是uint32_t如果只写%x数值0x0000abcd会打成abcd和 objdump 的输出对不齐。其二x86 是小端内存里0x100000处的四个字节按低地址到高地址读出来再拼成一个 32 位数这个顺序和 objdump 显示的顺序是一致的不需要再手动翻转——但前提是你的swaddr_read实现没有多此一举。验证方式很直接打印0x100000附近的内存和obj/testcase/mov.txt里的反汇编结果并排贴出来逐行核对。这一步别跳过扫描内存要是有 bug后面调别的功能时会以为是自己新写的代码错了然后花几天几夜查一个根本不存在的错误——这种「调试工具的 bug 伪装成被调试代码的 bug」的惨剧代价会随实验推进指数级放大。4. 表达式求值与监视点正则词法分析、BNF 递归求值与 w/d 命令阶段 2 是表达式求值占代码分 30%是整个 PA1 里最像「正经算法题」的部分。它被拆成两步先把字符串切成 token再对 token 序列递归求值。4.1 token 与规则表正则元字符、转义与 NOTYPE「词法分析」听着高端说白了就是识别出表达式里有独立含义的子串这些子串正式名称叫 token。像5 4 * 3 / 2 - 1要切出5、、4、*等九个 token更复杂的表达式还会出现十六进制整数0xc0100000、小括号、寄存器$eax、指针解引用*、变量名等。手写状态机也能做但没必要。框架用正则表达式来匹配每条规则是「正则 token 类型」的二元组。框架已经给好了和空格串的规则剩下要补的是十进制整数、-、*、/、左右括号。动手前有两个 C 语言层面的坑。第一正则写在 C 字符串里反斜杠要写两遍想匹配数字得写\\d或者[0-9]写成\d会被编译器当成转义序列处理掉。第二正则里的元字符.、*、、(、)、[、]、|若要当普通字符匹配必须加反斜杠转义比如匹配左括号要写\\(。空格串的 token 类型是NOTYPE意思是识别出来之后直接丢弃不进入 token 数组。NOTYPE这个设计很关键它让「有空格的表达式」和「没有空格的表达式」走同一条求值逻辑。token 类型正则示例是否记录进数组十进制整数[0-9]是且要记录字面串运算符 - * /\\-\\*/是只记类型左右括号\\(\\)是只记类型空格串[ ]否NOTYPE丢弃规则如果编译不通过NEMU 会直接 assertion fail报错点就在正则语法上回头检查转义即可。4.2 make_token 的推进逻辑与 Token 结构体make_token()的逻辑直白用一个position变量指示当前处理到哪按顺序尝试各条规则匹配当前位置的字符串某条规则匹配成功、且匹配出的子串起点正好等于position就成功识别出一个 token。Token结构体要存两样东西/* token 的信息载体类型 可选的原始字面串 */ typedef struct { int type; /* token 类型不同的类型编码成不同整数即可 */ char str[32]; /* 只有整数类 token 需要记录原始字面串 */ } Token; static Token tokens[32]; /* 按顺序存放识别结果 */ static int nr_token; /* 已识别出的 token 数目 */type对运算符和括号足够了但对整数不够——只知道「这是个整数」不知道它具体是多少求值时没法用。所以整数必须把原始子串抄进str求值时再用strtol()转成数值。str的长度是有限的写之前要检查是否会溢出。这里的正确答案不是精心设计一套截断逻辑而是assert(0)。理由很实际表达式求值的核心功能和「超长 token 的优雅处理」根本不耦合后者做不做都不影响前者正确性。在 30 小时、400 行的规模下把精力花在核心路径上才是对的这就是 KISS 法则在实验里的具体落地。4.3 BNF、括号匹配与主运算符的寻找方向token 序列拿到手之后用递归求值。理论依据是表达式的归纳定义即巴克斯范式BNF一个表达式要么是一个整数要么是被括号包住的表达式要么是两个表达式用运算符连接。长表达式由短表达式构成那就先算短的再算长的——分治法递归是实现它的自然选择。求值函数用两个整数p、q指示子表达式的起止下标框架大致长这样/* 递归求值的骨架先判括号再找主运算符最后分治 */ uint32_t eval(int p, int q) { if (p q) { /* 空表达式走不到这里出错即断言 */ assert(0); } else if (p q) { /* 只剩一个 token必然是整数转成数值返回 */ return strtol(tokens[p].str, NULL, 0); } else if (check_parentheses(p, q) true) { /* 整个子表达式被一对匹配的括号包住剥掉再求值 */ return eval(p 1, q - 1); } else { int op find_dominant_op(p, q); /* 找最后参与运算的运算符 */ uint32_t val1 eval(p, op - 1); uint32_t val2 eval(op 1, q); switch (tokens[op].type) { /* 用两个子结果做最后一次运算 */ case : return val1 val2; case -: return val1 - val2; case *: return val1 * val2; case /: return val1 / val2; } } assert(0); }两个辅助函数是全体翻车的重灾区。check_parentheses(p, q)不能只看tokens[p]是不是左括号、tokens[q]是不是右括号还得确认这对括号真的互相匹配——(1) (2)的两端也是括号但整个表达式并不被一对括号包住。做法是从 p 扫到 q 维护计数器中途计数归零就说明不匹配。find_dominant_op(p, q)要在跳过括号内部内容的前提下找优先级最低的运算符同优先级时因为加减乘除都是左结合必须取最右边那个。于是扫描方向是从右往左。写成从左往右1 - 2 - 3会先算1 - 2得-1再算-1 - 3得-4——等等那是对的。真正出错的是扫描方向反了并且取了第一个运算符的情况1 - 2 - 3会被切成1和2 - 3得到1 - (-1) 2。测试用例里必须放一条左结合的减法或除法否则这个 bug 藏得很深。4.4 监视点 w / d结构池、基准值与检查时机阶段 3 的监视点只占代码分 10%但它是最能体现「调试器」三个字的功能当某个表达式的值发生变化时暂停程序执行。w EXPR的行为分三步先求一次表达式的值把它作为基准存进一个监视点结构把结构挂进池子之后每执行完一条指令就把池子里所有监视点重算一遍和基准比较不一致就暂停并打印新旧值。d N删除序号为 N 的监视点。结构池用固定长度的数组就够了不必上链表/* 监视点编号、表达式原文、上次的值、启用标记 */ typedef struct watchpoint { int NO; /* 展示给用户的序号 */ struct watchpoint *next; /* 若用链表则留此指针数组实现可去掉 */ char expr[128]; /* 表达式原文检查时重新求值 */ uint32_t old_value; /* 基准值初始化为创建时的求值结果 */ } WP;三个细节决定成败。第一基准值必须在创建时就算出来并保存不能留 0——否则第一次检查就会误报。第二检查时机是每条指令执行之后不是之前漏了这一步w $eax永远不触发。第三删除后序号要维护好最简单的方式是数组空洞标记为未启用新监视点复用最小空位避免序号和下标错位。info w命令负责把当前所有监视点打印出来包含序号、表达式原文和当前值。它能省掉大量猜测——程序暂停时到底哪个监视点被触发了看一眼列表就清楚。5. 易漏点排查五个最容易翻车的地方5.1 寄存器字节序AH 和 AL 到底谁在低地址易漏点uint8_t _8[2]里_8[0]是低字节 AL_8[1]才是高字节 AH。很多人按「先高后低」的直觉写反。为什么漏平时写0x12345678这种常量高低位顺序完全不可见只有当reg_test()挂掉或者info r输出和预期差一个字节时才会暴露。而且这个错在小数值上测不出来——0x00000041无论怎么错AL 和 AH 都是41和00。怎么补往eax里写0x12345678然后分别打印 AH 和 AL必须得到0x56和0x78。更彻底的办法是打印r.eax、r.ax、r.al、r.ah的地址看清楚al的地址最低。5.2 主运算符方向1-2-3 算成 2 的经典翻车易漏点find_dominant_op必须从右往左找同优先级的运算符因为加减乘除都是左结合。为什么漏测试用例通常写1 2 3加法和减法在这个场景下碰巧没有分歧从左从右结果都是 6。只有换成1 - 2 - 3或8 / 4 / 2才会出现差异而这两个恰好是新手最容易漏测的。怎么补建立一张最小测试表至少包含12*3得 7、(12)*3得 9、1-2-3得 -4、8/4/2得 1、(12)*(34)得 21。跑通这五条优先级、结合性、括号匹配三件事基本都覆盖到了。5.3 token 缓冲区与空格串NOTYPE 不能进数组易漏点空格串的 token 类型是NOTYPE识别成功后要直接丢弃不能塞进tokens数组整数 token 的字面串抄进str之前要判长度。为什么漏拿12这种没有空格的表达式测试NOTYPE分支根本走不到拿12测试缓冲区永远不会溢出。于是两个分支都成了「未测试代码」而调试公理第二条说得很清楚未测试代码永远是错的。怎么补专门测1 2多个空格、12前导空格、12尾随空格三种情况结果都应该是 3。缓冲区那条按 KISS 法则直接assert(0)不写「聪明」的截断逻辑。5.4 监视点的检查时机与基准值易漏点监视点要在每条指令执行后检查且基准值必须在创建时求出来存好。为什么漏w命令敲下去之后界面上什么都没发生很容易以为功能已经正常了只有当程序跑飞、该停没停时才回头查而那时已经分不清是监视点的问题还是新写的指令实现的问题。怎么补用一个确定会修改$eax的小程序做验证先w $eax再si观察是否在eax变化的那一条之后立刻暂停并且打印出的新旧值都能和log.txt对上。基准值留 0 的情况第一次检查就会误报反而容易发现真正难查的是「检查位置放在exec()之前」。5.5 开工前的 git 分支与 log.txt 膨胀易漏点开工第一件事是在工程目录下留一个空提交、切出新分支之后再动代码。为什么漏这条要求写在实验说明里但不在代码里容易被当成客套话跳过。等到改崩了想回滚才发现主分支上已经堆了一堆半成品提交等到提交时分支记录混在一起影响成绩。怎么补运行完开头那两条 git 命令后用git branch确认当前停在实验分支上log.txt每跑完一轮就清一次或改名归档否则几万行输出翻起来比不写日志还慢。6. 提交前复习清单一张自测表把五个模块过一遍写到这里把散在各章的东西收成一张可以照着跑的表。复习时不用重读代码逐项打勾就行。自测项通过标准不通过先查哪寄存器结构体reg_test()不触发 assertion fail且eax/ax/ah/al联动正确_8[2]的下标方向单步执行si 10后eip前进量等于 10 条指令长度之和cpu_exec的循环次数与退出条件打印寄存器八个通用寄存器加eip全部输出宽度对齐是否漏了eip是否用了%08x扫描内存x 10 0x100000与mov.txt前十行逐字节一致步长是否为 4是否补零到八位表达式求值12*37、(12)*39、1-2-3-4、8/4/21主运算符扫描方向、括号匹配判断词法分析多空格、前导空格、尾随空格的表达式都能正确求值NOTYPE是否被误存进数组监视点w $eax后单步eax变化时立即暂停并打印新旧值检查时机是否在exec()之后环境与提交当前在实验分支log.txt未失控膨胀是否漏了开工前的两条 git 命令打勾的顺序也有讲究先寄存器、再单步和打印、然后扫描内存、最后表达式和监视点。后一项依赖前一项的输出前面虚了后面全是白测。这里补一个最省时间的技巧——把调试器当被测对象来测。每实现一个功能立刻用它去查一个自己已经知道答案的东西x 4 0x100000去对照 objdumpp 12*3去对照手算w $eax去对照log.txt。答对再往下走答错就停在这里改。一个功能做完不做验证等到几个功能叠起来一起调试定位成本会翻好几倍。血泪经验是这样的某次扫描内存的地址步长写成了 1输出看起来「有点像」正确结果就没细核对后来拿它去验证指令实现发现内存内容和预期对不上整整两天在怀疑取指逻辑最后把x换成objdump一比对才发现问题出在调试器自己身上。从那以后我每实现一个命令都强制先跑一条已知答案的用例通过之后才敢拿它去调别的模块。工具本身没被验证过它给出的每个结论都不可信。希望这份清单能帮你少走那两天弯路帮到你。本文还有配套的精品资源点击获取