简介:这份 PDF 面向正在学习计算机系统、操作系统或 NEMU 实验的读者,聚焦 PA1 阶段如何构建简易调试器 sdb,并理解 NEMU 模拟器的基础设施。内容围绕单步执行、打印寄存器、扫描内存和表达式求值等调试功能展开,同时梳理 CPU 模拟、内存模拟与设备模拟的实现思路,帮助读者把指令执行、状态更新和内存访问等抽象概念落到可操作的实验场景中。压缩包内共 1 个 PDF 文件,约 74KB,篇幅轻量,适合课前预习、实验对照与要点复盘。该资料已有 799 人学习,可用于课程教学、体系结构研究及底层软件测试等场景,为后续 NEMU 开发和调试排错提供概念框架与功能清单。
1. 从一个只会打印提示符的 sdb 说起
第一眼我把《NEMU PA1 的概述、原理及应用》归到软件/插件那一类随手可删的资料里,直到翻到中间讲命令分派的那几页,才发现它把「谁负责推进 PC、谁负责在指令之间停下来」这条线拎得很清楚。PA1 真正卡人的地方不是行数,而是搞不懂模拟器的哪一块该由调试器去动。NEMU 负责按指令节奏往下走,sdb 负责在每个指令边界上停住、把寄存器和内存翻出来看。这份 PDF 把单步、打印寄存器、扫描内存、表达式求值四件事拆成可复现的步骤,适合正在啃计算机系统课程、想自己把调试器从空壳补到能用的人。
2. 执行模型先立住:一条指令在模拟器里怎么走完
动手写 sdb 之前必须先建立模型,否则命令写得再多也只是在黑匣子外面猜。NEMU 这份资料最值钱的部分,是它把 CPU、内存、设备三块的职责切得很干净:CPU 只持有寄存器集合与 PC,内存就是一个按字节寻址的连续数组,设备则挂在访存通路上等地址命中才被唤醒。理解这个划分之后,sdb 的每条命令都能对应到「读哪块状态、走哪条通路」,不用再靠打印大法去试。
2.1 取指、译码、执行三步各自改了什么状态
模拟器的外层是一个循环,循环体里只有三件事。取指阶段用当前 PC 去内存里取一段字节,这一步只读不写;译码阶段把这段字节翻译成「操作码 + 若干个操作数」,操作数可能来自寄存器,也可能来自内存地址,这一步同样不应该改变架构状态;只有执行阶段才真正写回寄存器、写内存,并且推进 PC。把「状态推进」集中到执行阶段,是后面所有调试功能能成立的前提:只要 PC 只在执行末尾被改,那么在两次执行之间查看的状态就一定是自洽的。
/* 主循环骨架: 取指 -> 译码 -> 执行, 状态推进只在最后一步 */ void cpu_exec(uint64_t n) { while (n-- > 0) { /* 1. 取指: PC 指向的字节序列, 不做任何副作用 */ uint8_t *inst = (uint8_t *)guest_to_host(cpu.pc); /* 2. 译码: 得到目的操作数与两个源操作数的索引 */ Decode dst, src1, src2; decode_exec(inst, &dst, &src1, &src2); /* 3. 执行: 写回寄存器/内存, 并在末尾推进 cpu.pc */ exec_once(dst, src1, src2); } }这段骨架里n是剩余步数,传-1(无符号最大值)表示不设上限、一路跑到结束。要注意while (n-- > 0)的写法:如果写成while (--n > 0),传 1 进去会一条指令都不执行,这是我在第一次对si行为时就踩过的经典翻车点。decode_exec的签名在不同版本里会略有差异,有的把操作数直接解到内部索引里,有的返回一个结构体,按框架给的接口改就行。PA1 阶段cpu_exec往往还是空壳,此时si能打印出来的 PC 只是框架预设值,不要误以为自己写错了——这一点资料里讲得比较含糊,值得单独确认。
2.2 内存、寄存器和 PC 的三层映射
客机内存通常从某个固定基地址开始,映射到宿主进程里的一块大数组。所有访存都应该走统一入口,而不是直接拿下标去索引数组,因为入口函数承担了越界检查和大小端拼装两件事。越界在真实机器上会触发异常,在模拟器里如果不拦,就是直接读飞、结果随机。
/* 客机物理地址转宿主指针, 基地址由配置决定 */ static uint8_t *guest_to_host(paddr_t paddr) { return pmem + paddr - CONFIG_MBASE; } /* 读访存: len 取 1/2/4, 按小端序拼成一个字 */ word_t paddr_read(paddr_t addr, int len) { /* 1. 越界检查放在最前面, 越界返回 0 而不是 assert */ if (!in_pmem(addr) || addr + len > CONFIG_MBASE + CONFIG_MSIZE) { printf("访问越界: 0x%08x\n", addr); return 0; } /* 2. 低地址字节放在低位, 与 ISA 的内存序保持一致 */ word_t ret = 0; for (int i = 0; i < len; i++) { ret |= (word_t)guest_to_host(addr)[i] << (8 * i); } return ret; }CONFIG_MBASE和CONFIG_MSIZE来自配置文件,改它们等于改客机内存的起始地址和容量,sdb 里x命令的合法范围也随之变化。in_pmem只判断起点是否落在区间内,长度检查必须另外做,否则addr + 4跨界的情况照样能读到相邻区域。上面这段里越界返回 0 是调试期的便宜做法,正式实现应该把它变成一次可观察的访存异常,让上层能区分「读到 0」和「访问非法」。
2.3 sdb 与执行器之间的接口约定
调试器不直接改 PC,也不自己写循环跑指令,所有「让程序往下走」的动作都收敛到一个出口。这条约定听起来像洁癖,实际上是后面加断点、加监视点时不用改两处逻辑的唯一办法。
| sdb 动作 | 读写的对象 | 应该走的路径 | 常见误用 |
|---|---|---|---|
| c | PC 连续推进 | 执行器无上限运行 | 自己写 while 循环绕开统一出口 |
| si N | PC 推进 N 条 | 执行器限步运行 | 参数不校验,默认值写错 |
| info r | 通用寄存器 | 寄存器名表 + 值读取 | 另手写一份名字,和 ISA 表不一致 |
| x N EXPR | 内存 | 虚拟地址读接口 | 直接索引宿主数组 |
| p EXPR | 寄存器与立即数 | 表达式求值 + 寄存器解引用 | 把寄存器名连前缀一起匹配 |
| b EXPR | 断点池 | 命中检查放在执行之前 | 命中检查放在执行之后 |
表里「虚拟地址读」这一列容易被忽略。PA1 阶段通常虚拟地址等于物理地址,两条通路看起来一样,但一旦后面引入地址翻译,直接读数组的代码会静默失效,还不报错。
3. sdb 命令框架:把一行输入拆成 token 再分派
命令解析这一块看起来最没技术含量,实际上是最容易写成意大利面的地方。我见过太多版本是一个巨大的 if-else 链,加一条命令要改三处,help的输出还得手动同步。资料里给的做法是命令表加统一函数签名,这个结构值得照抄。
3.1 命令表:一张表决定 help 里能显示什么
/* 所有命令共用一个签名: 参数原样传进来, 返回值暂时不用 */ typedef struct { const char *name; const char *description; int (*handler)(char *args); } cmd_t; static cmd_t cmd_table[] = { {"help", "打印所有命令", cmd_help}, {"c", "继续执行直到结束或断点", cmd_c}, {"si", "单步执行 N 条, 默认 1", cmd_si}, {"info", "info r 打印寄存器", cmd_info}, {"x", "x N EXPR 扫描内存", cmd_x}, {"p", "p EXPR 求值", cmd_p}, {"w", "w EXPR 设置监视点", cmd_w}, {"q", "退出", cmd_q}, };表的存在让help变成一次遍历,加命令只加一行,描述和实现对得上。件数用sizeof(cmd_table) / sizeof(cmd_table[0])算,不要手写常量,删一行就会错位。handler统一收char *,好处是分派处不用关心每条命令各自的参数形态,坏处是每条命令内部都要自己做参数校验,这个代价可以接受。
3.2 si 的参数边界:默认值和非法输入都要管
/* 单步: 不带参数走 1 条, 带了就必须是正整数 */ static int cmd_si(char *args) { uint64_t step = 1; if (args != NULL) { char *end = NULL; step = strtoull(args, &end, 10); /* 尾部还有非数字字符, 说明是 "3abc" 这类输入, 直接拒绝 */ if (*end != '\0' || step == 0) { puts("用法: si [N], N 为正整数"); return 0; } } cpu_exec(step); return 0; }用strtoull而不是atoi,是因为atoi对非法输入静默返回 0,si abc会变成「执行 0 条」,表现和「没反应」一模一样,排查时极难定位。end指针判断是这条命令的关键:只有*end == '\0'才算整个串都是数字。步数上限也值得加一道,防止手滑打出si 999999999之后终端卡住几十秒。
3.3 打印寄存器和扫描内存:两条完全不同的取值路径
info r走的是寄存器表,x走的是内存通路,两者唯一的共同点是都要做输出格式化。寄存器打印建议按固定宽度对齐,名字用%-8s,值用0x%08x,这样多次打印之间可以直接肉眼 diff。名字表只保留一份,和 ISA 里的字符串转换函数共用,别在打印处再抄一遍。
/* 扫描内存: 先求值起始地址, 再按 4 字节一个字输出 */ static int cmd_x(char *args) { char *n_str = strtok(args, " "); char *expr_str = strtok(NULL, ""); if (n_str == NULL || expr_str == NULL) { puts("用法: x N EXPR"); return 0; } int n = atoi(n_str); if (n <= 0) { puts("N 必须是正整数"); return 0; } bool ok = true; word_t addr = expr(expr_str, &ok); /* 起始地址也走表达式求值 */ if (!ok) { puts("表达式非法"); return 0; } for (int i = 0; i < n; i++) { word_t cur = addr + i * 4; /* 走虚拟地址读, 为将来的地址翻译留出口 */ printf("0x%08x: 0x%08x\n", cur, vaddr_read(cur, 4)); } return 0; }strtok(NULL, "")的第二个参数是空串,作用是「取剩下全部内容作为第二个 token」,因为表达式里可能带空格,不能按空格再切。n是字数不是字节数,一次打印 4 字节,所以地址步长是 4。起始地址允许写成表达式而不是死数字,这条设计在实际调试里省事很多,比如直接x 8 0x100000+0x40跳过一段头部。
3.4 断点池的命中判断放在哪一行
#define MAX_BP 32 static struct { word_t addr; bool used; } bps[MAX_BP]; /* 单条指令执行之前调用, 命中就停下来 */ static bool bp_hit(word_t pc) { for (int i = 0; i < MAX_BP; i++) { if (bps[i].used && bps[i].addr == pc) return true; } return false; }命中判断必须放在指令执行之前。放到执行之后,PC 已经变了,断点地址永远对不上,表现就是「断点设了但一次都不停」。used标志用来支持删除,没有它就只能靠地址等于 0 来判断空位,而 0 本身是合法地址。断点数量和地址空间的关系也要想清楚:数量上限写死 32 够教学用,超过就要考虑用哈希表,但在 PA1 这个规模下线性扫完全没问题。
4. 表达式求值:递归下降为什么比一堆 if-else 省心
p和x两条命令都依赖表达式求值,这块写不好,前面所有命令都会跟着废。我见过用一长串if手动判优先级的版本,加一个运算符就要重排整段逻辑。递归下降的价值在于「函数的嵌套层次直接等于优先级」,加运算符只是在对应层加一个分支。
4.1 词法拆分:token 数组比原地切割好用
typedef enum { TK_NUM, TK_LP, TK_RP, TK_OP, TK_EOF } tk_t; static struct { tk_t type; word_t val; char op; } tokens[256]; static int tok_cnt, tok_pos; static void tokenize(const char *s) { tok_cnt = tok_pos = 0; while (*s) { if (*s == ' ' || *s == '\t') { s++; continue; } if (isdigit((unsigned char)*s)) { char *end = NULL; /* 基址传 0, 让 0x 前缀生效, 同时支持十进制 */ tokens[tok_cnt].val = strtoul(s, &end, 0); tokens[tok_cnt++].type = TK_NUM; s = end; /* 关键: 指针必须前进, 否则死循环 */ continue; } if (*s == '(') { tokens[tok_cnt++].type = TK_LP; s++; continue; } if (*s == ')') { tokens[tok_cnt++].type = TK_RP; s++; continue; } tokens[tok_cnt].type = TK_OP; tokens[tok_cnt++].op = *s++; } tokens[tok_cnt].type = TK_EOF; }先把输入切成 token 数组,解析阶段就只需要维护一个下标,不用在字符指针上反复前后挪。strtoul的基址传 0 是让它自动识别0x前缀,不传 0 就只能解十进制。s = end这一行是整个函数里最容易漏的,漏了之后数字分支的指针原地不动,while (*s)变成死循环,现象是输入p 123之后终端直接挂住。
4.2 三层函数对应三级优先级
/* expr := term (('+'|'-') term)* * term := factor (('*'|'/') factor)* * factor := NUM | '(' expr ')' | '-' factor */ static word_t parse_expr(bool *ok); static word_t parse_term(bool *ok); static word_t parse_factor(bool *ok) { if (!*ok) return 0; if (tokens[tok_pos].type == TK_EOF) { *ok = false; return 0; } if (tokens[tok_pos].type == TK_NUM) return tokens[tok_pos++].val; if (tokens[tok_pos].type == TK_OP && tokens[tok_pos].op == '-') { tok_pos++; return 0 - parse_factor(ok); /* 一元负号, 递归吃掉后面的因子 */ } if (tokens[tok_pos].type == TK_LP) { tok_pos++; word_t v = parse_expr(ok); if (tokens[tok_pos].type != TK_RP) { *ok = false; return 0; } tok_pos++; return v; } *ok = false; return 0; }parse_term里循环处理*和/,parse_expr里循环处理+和-,调用方向是 expr 调 term、term 调 factor,层次即优先级。每个函数入口都要先判TK_EOF,这是递归能停下来的保证,缺了它左括号输入p (1+2会一直往下递归到栈溢出。
4.3 失败路径:除零、越界和没吃干净尾巴
除零要在parse_term里显式判断并置*ok = false,不要交给宿主语言去触发陷阱。溢出在 32 位无符号语义下通常不做检查,但至少要知道它是静默回绕的。最容易忽略的是收尾检查:解析完一个完整表达式后,tok_pos必须停在TK_EOF上,否则p 1+2abc会被解析成 3 而没有报错,错误被吞掉比报错难查得多。
static word_t expr(char *e, bool *ok) { if (e == NULL) { *ok = false; return 0; } tokenize(e); word_t v = parse_expr(ok); if (tokens[tok_pos].type != TK_EOF) *ok = false; /* 尾巴没吃干净 */ return v; }5. 常见问题排查:五个让 sdb 当场失灵的现场
下面这几条都是我在补调试器时真撞过的,现象看着像是「框架有 bug」,追下去基本都是自己某一行写偏了。按「现象 → 原因 → 解决」记一遍,比对着代码干瞪眼快得多。
5.1 si 执行一次后 PC 看起来没变,或者跳了两条
现象:连按三次si,打印出来的 PC 有时加 4,有时加 8,偶尔一次不变。
原因:步数循环写成了先自减后判断,传 1 进去一条都不执行;或者命中检查放在了执行之后,导致当前指令被重复算一次。
解决:循环统一写while (n-- > 0);把断点命中判断挪到执行之前;si结束后在命令里单独打印 PC,不要在执行器内部再插一行打印,两处输出会互相干扰。
5.2 p 打出来的寄存器和 info r 对不上
现象:info r显示某个寄存器是 0x00000001,p $寄存器名却打出 0 或一个很大的数。
原因:寄存器名转值的时候没有跳过前缀符号,匹配直接失败;或者拿到的是寄存器地址而不是解引用后的值。
解决:字符串匹配前先确认前缀字符,匹配成功后返回解引用结果;寄存器名表只保留一份,info打印和p求值共用它,两套表迟早会分叉。
5.3 x 扫到某个地址就整个退出
现象:x 4 0x100000只打印了一行,进程直接结束,没有任何提示。
原因:访存函数用断言处理越界,断言一旦触发就是终止进程;或者访问了尚未映射的地址区间。
解决:越界不要用断言,返回一个可识别的值并打印一行提示;扫描前先把「起始地址 + 长度」整体做一次范围校验,别只校验起点。
5.4 表达式求值卡在某个输入上不返回
现象:输入p (1+2或p 1+之后终端挂住,只能中断。
原因:解析函数在遇到 EOF 时没有返回路径,继续往后取 token;或者词法阶段数字分支的指针没有推进。
解决:每个解析函数入口先判 EOF 并立即置失败返回;词法阶段数字分支务必把字符串指针指到strtoul返回的结束位置。
5.5 断点设上了却永远不命中
现象:设完断点再继续执行,程序一路跑完也没停。
原因:断点比对用的是虚拟地址,设置时填的是物理地址;或者c走了另一条执行循环,压根没经过命中判断。
解决:断点和 PC 用同一套地址空间;c和si共用同一个限步/不限步的入口,让命中判断在全篇只有一处。
6. 进阶验证:拿已知答案把 sdb 自己验一遍
调试器写完不等于写对,最省事的验法不是继续加功能,而是拿一批「答案已知」的输入去打它。我的习惯是先固定一张自检表,每次改完解析器或访存就整表跑一遍,跑完再动别的。
| 验证项 | 输入 | 期望输出 |
|---|---|---|
| 优先级 | p (1+2)*3/p 1+2*3 | 9 / 7 |
| 十六进制 | p 0x10+1 | 0x11 |
| 一元负号 | p 5*-2 | 0xfffffffe |
| 寄存器一致 | p $eip与info r里的 eip | 完全相等 |
| 尾巴检查 | p 1+2abc | 报非法,不返回 3 |
| 越界不崩 | x 1 0xfffffff0 | 打印越界提示后继续等输入 |
| 断点 | 在已知地址设b再c | 停在该地址 |
这张表里最容易被跳过的是「一元负号」和「尾巴检查」,前者暴露的是优先级层次没接对,后者暴露的是收尾判断缺失。另一个高阶技巧是把「未知量」也塞进表达式:在 PA1 里 PC 和寄存器本身就是变量,如果求值器能识别寄存器名,那p $eip+4这种写法可以直接算出下一个地址,配合x命令就能把一段指令序列手工反推出来,比反复si快得多。
越界那条尤其值得单独跑。很多人第一版访存根本没做范围检查,测试用例都在合法地址上,一切正常,直到某次表达式算出一个奇怪地址,进程静默退出,回头查半天以为是解析器的问题。从那以后我每次改完访存路径,都会强制走一遍「越界 → 不崩 → 有提示」这一条,哪怕只改了一个常量。希望帮到你。
本文还有配套的精品资源,点击获取