☰
NEMU PA1简易调试器实现:寄存器、表达式求值与监视点
2026/11/2 4:53:43 网站建设 项目流程

简介:围绕“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 里敲下c,cpu_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 的模拟(当前不必深究)。

真实机器启动时控制权先交给 BIOS,BIOS 再从磁盘把有意义的程序读进内存。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指令。这条指令机器码是0xd6,x86-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, /* 特殊指令:结束用户程序 */ /* 其余槽位默认为 inv(invalid),表示尚未实现或 x86-32 中不存在 */ };

数组元素类型是函数指针,所以每个槽位存的是「执行该指令的函数地址」,而不是数据。随着实验推进,inv会一个个被真实函数替换掉。读代码时养成一个习惯:先看这个数组的初始化方式,就知道框架替你实现了多少。

2.4 调试三宏与内存访问接口:Log、Assert、panic、swaddr_read

nemu/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 位叫 AX,AX 又拆成高字节 AH 和低字节 AL。八个寄存器都是这个结构:

32 位16 位高 8 位低 8 位
EAXAXAHAL
EBXBXBHBL
ECXCXCHCL
EDXDXDHDL
ESPSP——
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 到 printf

NEMU 用 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 字节。拆解成三步:

  1. 解析出 N 和 EXPR。阶段 1 可以偷懒,规定 EXPR 只能是十六进制字面量,例如x 10 0x100000,表达式求值留到阶段 2 再补;
  2. 循环 N 次,每次读 4 字节,地址步长是 4 而不是 1;
  3. 用%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才会出现差异,而这两个恰好是新手最容易漏测的。

怎么补:建立一张最小测试表,至少包含1+2*3(得 7)、(1+2)*3(得 9)、1-2-3(得 -4)、8/4/2(得 1)、(1+2)*(3+4)(得 21)。跑通这五条,优先级、结合性、括号匹配三件事基本都覆盖到了。

5.3 token 缓冲区与空格串:NOTYPE 不能进数组

易漏点:空格串的 token 类型是NOTYPE,识别成功后要直接丢弃,不能塞进tokens数组;整数 token 的字面串抄进str之前要判长度。

为什么漏:拿1+2这种没有空格的表达式测试,NOTYPE分支根本走不到;拿1+2测试缓冲区,永远不会溢出。于是两个分支都成了「未测试代码」,而调试公理第二条说得很清楚:未测试代码永远是错的。

怎么补:专门测1 + 2(多个空格)、1+2(前导空格)、1+2(尾随空格)三种情况,结果都应该是 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,是否补零到八位
表达式求值1+2*3=7、(1+2)*3=9、1-2-3=-4、8/4/2=1主运算符扫描方向、括号匹配判断
词法分析多空格、前导空格、尾随空格的表达式都能正确求值NOTYPE是否被误存进数组
监视点w $eax后单步,eax变化时立即暂停并打印新旧值检查时机是否在exec()之后
环境与提交当前在实验分支,log.txt未失控膨胀是否漏了开工前的两条 git 命令

打勾的顺序也有讲究:先寄存器、再单步和打印、然后扫描内存、最后表达式和监视点。后一项依赖前一项的输出,前面虚了,后面全是白测。

这里补一个最省时间的技巧——把调试器当被测对象来测。每实现一个功能,立刻用它去查一个自己已经知道答案的东西:x 4 0x100000去对照 objdump,p 1+2*3去对照手算,w $eax去对照log.txt。答对再往下走,答错就停在这里改。一个功能做完不做验证,等到几个功能叠起来一起调试,定位成本会翻好几倍。

血泪经验是这样的:某次扫描内存的地址步长写成了 1,输出看起来「有点像」正确结果,就没细核对;后来拿它去验证指令实现,发现内存内容和预期对不上,整整两天在怀疑取指逻辑,最后把x换成objdump一比对才发现问题出在调试器自己身上。从那以后我每实现一个命令,都强制先跑一条已知答案的用例,通过之后才敢拿它去调别的模块。工具本身没被验证过,它给出的每个结论都不可信。

希望这份清单能帮你少走那两天弯路,帮到你。

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

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

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

立即咨询