简介:围绕NEMU PA1(简易调试器sdb与NEMU模拟器基础设施)展开的PDF学习资料,面向计算机系统课程学习者、参与PA实验的学生以及需要理解模拟器内部机制的研发人员。内容从概述、原理与应用三部分梳理CPU模拟、内存模拟、设备模拟及调试器实现等关键概念,并说明单步执行、打印寄存器、扫描内存、表达式求值等功能在后续开发中的作用。压缩包仅含1个PDF文件,大小约74KB,篇幅精简,适合作为实验前的概念预习或课程复习提纲。已有799人学习,读者可借助它快速建立PA1的整体知识框架,理解调试器与模拟器之间的协作关系,并为后续NEMU开发与底层软件调试提供参考。
1. 调试器不是额外的工具,它就是模拟器伸出来的手
大多数人对这一关的印象,是把几行命令拼起来——si、info r、x、p,跑通就算完。但结论反直觉:PA1 的本质并不是写工具,它要做的是把客户程序从一个只能整个跑完的黑匣子,改造成可以随时读、随时改、随时停的对象。没装调试器之前,模拟器跑起来只给你一个退出码,中间状态全部不可见;装上之后,你要能在任意时刻精确回答“pc 停在哪儿、32 个通用寄存器分别是什么、地址 X 处那 4 个字节是什么、3+$pc*2等于多少”。这套“状态可被外部观察并干预”的能力,是后面异常、中断、分页、系统调用所有阶段的地基。先把状态结构想清楚的人,后面一路顺;先想着把命令拼起来的人,八成要在字符串解析和地址换算里反复翻车。
2. 用软件模拟一台 CPU:状态、取指与内存模型
2.1 一份 CPU 状态就是整台机器唯一的真相
模拟器和真实处理器的第一个对应关系,是“状态”这个概念。真实 CPU 里有一组寄存器和一根 pc(程序计数器,也就是下一条要取指的地址),它们在任何时刻的取值,完整决定了这台机器接下来会走哪条路。NEMU 把这个东西照搬进内存里,本质上就是一个结构体。
// 位宽由 ISA 决定, word_t 一般等于通用寄存器的宽度 typedef word_t rtlreg_t; typedef struct { rtlreg_t gpr[32]; // 通用寄存器堆, 下标就是寄存器号 vaddr_t pc; // 程序计数器, 始终指向"下一条待取指"的地址 } CPU_state; static CPU_state cpu; // 全局唯一的状态源 static uint8_t pmem[CONFIG_MSIZE]; // 物理内存, 按字节编址这里最容易被忽略的设计点是“为什么敢用全局变量”。真实机器上,寄存器和内存本来就只有一份,没有第二个实例;把它做成全局对象,等于在代码结构上如实映射硬件结构。好处是调试器那边读状态不需要任何参数传递,isa_reg_display()直接遍历cpu.gpr就行。代价是可重入性和多实例能力没有了,你没法在一台 host 上同时跑两个隔离的客户机——这是教学模拟器明确愿意接受的取舍,把复杂度花在指令语义上,而不是花在实例管理上。
第二个点是 pc 的语义。代码里把 pc 定义成“下一条待取指的地址”,而不是“当前正在执行的指令地址”。这个约定看着只是差 4 个字节,但它会一路影响到后面所有跳转类指令的实现方式:分支指令只需要算出一个目标地址写回 pc,不需要再考虑“要不要补一条指令”的问题。真实处理器在执行某条指令时,pc 已经指向它后面那条了,NEMU 在这里做的是同一个简化。
2.2 取指、译码、执行:一个 switch 撑起指令分派
CPU 的主循环极其朴素:从 pc 指向的地方读一条指令,把这条指令编码拆成“干什么”和“对谁干”,然后执行它、更新状态。
static void exec_once(void) { // 1. 取指: 以 pc 为地址, 从内存读出 4 字节指令 uint32_t inst = vaddr_read(cpu.pc, 4); // 2. 译码: 教学 ISA 的 opcode 固定在低 7 位 uint32_t opcode = inst & 0x7f; // 3. 先按顺序把 pc 推进一步, 再执行 // 跳转类指令只要覆盖 pc 的值, 就自然实现了"跳" cpu.pc += 4; // 4. 执行: 按 opcode 分派到具体语义 switch (opcode) { case 0x13: { // addi rd, rs1, imm uint32_t rd = BITS(inst, 7, 11); uint32_t rs1 = BITS(inst, 15, 19); int32_t imm = SEXT(BITS(inst, 20, 31), 12); cpu.gpr[rd] = cpu.gpr[rs1] + imm; break; } case 0x33: { // 寄存器-寄存器运算, 具体功能由 funct3/funct7 决定 uint32_t rd = BITS(inst, 7, 11); uint32_t rs1 = BITS(inst, 15, 19); uint32_t rs2 = BITS(inst, 20, 24); uint32_t f3 = BITS(inst, 12, 14); uint32_t f7 = BITS(inst, 25, 31); switch (f3) { case 0x0: cpu.gpr[rd] = (f7 == 0x20) ? cpu.gpr[rs1] - cpu.gpr[rs2] : cpu.gpr[rs1] + cpu.gpr[rs2]; break; case 0x7: cpu.gpr[rd] = cpu.gpr[rs1] & cpu.gpr[rs2]; break; // 其余功能位略 } break; } default: // 非法指令: 不要默默跳过, 这是抓 bug 的最好时机 panic("invalid opcode 0x%08x at pc = 0x%08x", opcode, cpu.pc - 4); } }代码里有三个关键决策值得说清楚。
第一,cpu.pc += 4放在执行之前。这一步看起来只是顺序问题,实际上它让“跳转”和“顺序执行”统一成了同一种写法:顺序执行就是没人改 pc,跳转就是有人把它改掉。如果放在执行之后,分支指令就得额外告诉框架“别推进了”,会多出一层控制流。这是典型的“用一个不变量换掉一堆分支”的写法。
第二,BITS和SEXT这两个宏把“抽位段”和“符号扩展”拆成了两步。指令里的立即数在编码时是被打散放在不同位置的,抽出来之后必须按它原本的位宽做符号扩展,才能当成有符号数参与运算。新手最常见的错就是抽完直接当无符号用,addi x1, x0, -1会得到一个巨大的正数,然后后面所有依赖它的地址计算全都偏掉。
第三,为什么用switch而不是函数指针表。函数指针表在指令几百条、需要插件化的场景里是正解,但在教学 ISA 三四十条指令的规模下,它带来的间接层反而让“指令 → 语义”的对应关系变模糊。switch在编译期有机会变成跳转表,性能不吃亏,可读性还和官方的指令手册一一对得上。反过来,如果哪天要扩到几百条指令,switch会膨胀成几千行,那时候才该换成 decode 表加函数指针——这是规模驱动的取舍,不是风格问题。
注意:
panic里打印的 pc 要减 4,因为进入default时 pc 已经被推进过了。这个细节错了,你会拿着一个“指向下一条非法指令”的地址去排查上一条合法指令,白花几个小时。
2.3 内存模型:一个数组、一次地址换算、一种填充策略
内存模拟是整个 NEMU 里最“不像硬件”的部分,因为它太简单了:一个按字节编址的数组,加上一次地址换算。
// guest 地址到 host 指针的换算: 前提是地址已经落在物理内存区间内 static inline uint8_t *guest_to_host(paddr_t paddr) { return pmem + paddr - CONFIG_MBASE; } // 往物理内存写一个字节, 越界就当成一次访存异常 static void paddr_write(paddr_t addr, int len, word_t data) { if (addr < CONFIG_MBASE || addr + len > CONFIG_MBASE + CONFIG_MSIZE) { out_of_bound(addr); // 交给异常处理框架, 而不是让 host 进程段错误 return; } memcpy(guest_to_host(addr), &data, len); }CONFIG_MBASE这个基址的存在,是因为客户机的物理地址不一定从 0 开始。教学平台通常把内存映射在某个固定偏移上(比如0x80000000),这样加载进来的镜像地址天然就落在合法区间里。换算里减掉基址,就等于把“客户机视角的地址”翻译成“数组下标”。新手常犯的错是忘了这一步,直接用 guest 地址去索引数组,跑起来第一次访存就崩在 host 上——而崩在 host 上是最难查的,因为你看到的是模拟器的崩溃,不是客户程序的错误。
越界检查放在paddr_write里,而不是让memcpy自己崩掉,意义不只是“报错好看一点”。越界访存在真实机器上是一次异常,它应该被客户程序感知到(后面阶段会用它实现缺页、段错误),而不是把模拟器整个干掉。把“客户机的错误”和“模拟器的错误”分开,是模拟器设计里的一条基本纪律。
最后是填充策略。镜像加载进pmem之后,剩下的那片内存用什么填?常见三种选择:全填 0、全填固定非法值(比如0xd6)、或者填随机值。全填 0 的问题是太“友好”了——如果客户程序不小心跳到了一个没初始化的地方,它可能会接着执行一条全 0 编码的合法指令,然后一路跑下去,你根本不知道从哪开始错的。填一个非法指令编码,第一次取指就panic,定位成本立刻降到最低。这属于典型的“宁可早点崩,也别晚点错”。
3. sdb 的骨架:命令分派、表达式求值与“停下来”的能力
3.1 命令循环:字符串怎么变成函数调用
调试器的外壳是一个读一行、切词、比对的循环。看起来平淡,但它是整个 sdb 里最容易埋坑的地方。
void sdb_mainloop(void) { for (char *str; (str = rl_gets()) != NULL; ) { char *cmd = strtok(str, " "); // 取出命令名, strtok 会改写原字符串 if (cmd == NULL) continue; if (strcmp(cmd, "c") == 0) { cpu_exec((uint64_t)-1); // 一直执行到客户程序结束 } else if (strcmp(cmd, "si") == 0) { char *arg = strtok(NULL, " "); int n = (arg == NULL) ? 1 : atoi(arg); // si 缺省走 1 步 cpu_exec(n); } else if (strcmp(cmd, "info") == 0) { char *sub = strtok(NULL, " "); if (sub != NULL && strcmp(sub, "r") == 0) isa_reg_display(); } else if (strcmp(cmd, "x") == 0) { // x N EXPR: 从表达式的值开始, 连续读 N 个字 char *n_str = strtok(NULL, " "); char *e_str = strtok(NULL, " "); if (n_str == NULL || e_str == NULL) { puts("usage: x N EXPR"); continue; } word_t addr = eval_expr(e_str); for (int i = 0; i < atoi(n_str); i++) printf("0x%08x: 0x%08x\n", (uint32_t)(addr + 4 * i), vaddr_read(addr + 4 * i, 4)); } else if (strcmp(cmd, "p") == 0) { char *e_str = strtok(NULL, " "); printf("%u\n", eval_expr(e_str)); } else if (strcmp(cmd, "w") == 0) { char *e_str = strtok(NULL, " "); add_watchpoint(e_str); } else if (strcmp(cmd, "d") == 0) { char *n_str = strtok(NULL, " "); del_watchpoint(atoi(n_str)); } else { puts("unknown command"); } } }这段代码里藏着一个结构性冲突:strtok把空格当分隔符,可表达式本身是有可能带空格的(p 3 + $pc)。一旦用户在表达式里加空格,strtok(NULL, " ")只拿到3,后面的+和$pc就被丢掉了,求值结果莫名其妙。常见的解法有两个:一是干脆约定“表达式内不得含空格”,在文档和错误提示里写死;二是只切出第一个参数,剩下的整段原样交给表达式解析器。第二种更健壮,代价是x N EXPR里 N 和表达式的边界要靠“N 能不能整体解析成十进制数”来判断。
另一个点是strcmp链的规模假设。命令只有十来条的时候,链式查找最直观,加一条命令就加一个分支。真扩到几十条,就该换成“名字 → 处理函数”的表,否则主循环会变成一坨几百行的else if。这里同样不是风格问题,是规模问题。
3.2 表达式求值:找主运算符就是递归下降的全部
表达式求值是 PA1 里最像“编译原理”的部分,但它的实现可以很轻:先做词法切分,再用递归下降求值。递归下降的核心,是每一层都去找“当前表达式里优先级最低的那个运算符”,把它当作树根。
// 判断 token[p..q] 的最外层是否恰好被一对括号完整包住 static bool check_parentheses(int p, int q, bool *ok); // 从右向左扫, 找出优先级最低的二元运算符 // 相同优先级取最右一个, 这样 1-2-3 才会被解析成 (1-2)-3 static int find_dominant_op(int p, int q, bool *ok); // 求值入口: 返回 token[p..q] 这段表达式的值 static word_t eval(int p, int q, bool *ok) { if (p > q) { *ok = false; return 0; } // 空表达式 if (p == q) return parse_atom(p, ok); // 单个数字或寄存器 if (check_parentheses(p, q, ok)) // 整体被一层括号包住 return eval(p + 1, q - 1, ok); int op = find_dominant_op(p, q, ok); if (op < 0) { *ok = false; return 0; } // 找不到合法运算符 word_t lhs = eval(p, op - 1, ok); word_t rhs = eval(op + 1, q, ok); return apply_op(op, lhs, rhs, ok); // 算术/移位/关系/逻辑 }把这几行拆开看,每一个判断都对应一类具体的坑。
check_parentheses必须在找运算符之前做,而且不能只比较首尾是不是括号。(1+2)整体被包住,应该剥掉外层再求值;但(1+2)*(3+4)首尾也是括号,整体却不被一对括号包住,如果只比首尾就剥,剥完变成1+2)*(3+4,结果自然错。正确做法是用一个计数器从前扫到后,一旦余额在中间某处回到 0 且还没到末尾,就说明整体没被包住。
find_dominant_op从右往左扫,是为了保证左结合。1-2-3里有两个优先级相同的-,从右往左先遇到最右边那个,把它当树根,左边1-2再递归,最后得到(1-2)-3。如果从左往右扫,会得到1-(2-3),结果差 2。优先级表本身很直白:||最低,然后&&、比较运算、按位或/与/异或、加减、乘除、移位,一层层往上。
parse_atom要同时认数字和寄存器。寄存器写成$pc、$rax这种带美元前缀的形式,词法阶段把$和后面的标识符粘成一个 token,再交给isa_reg_str2val去查表。pc单独处理,因为它不在gpr数组里,是结构体的另一个字段。这里最容易踩的坑是大小写和前缀:如果只认小写,p $PC就报错;如果忘了剥$,查表永远失败。
最后是一元负号。-1里的-不是二元运算符,但find_dominant_op只认优先级,它会老老实实把这个-找出来,然后对左边那半段空表达式求值,直接越界。判断规则其实很简单:-出现在表达式开头,或者左边紧邻另一个运算符、左括号,就当一元处理,不参与主运算符的竞争。
3.3 单步、继续与监视点:把“停下来”做成原语
si的实现朴素到没什么可讲:循环调用exec_once,走 N 次。真正有意思的是监视点,因为它第一次让 sdb 具备了“自动停下来”的能力。
typedef struct watchpoint { int NO; // 编号, 用户用 d N 删除 struct watchpoint *next; char expr[128]; // 原始表达式字符串 word_t old_val; // 上一次求值结果, 用于比较 } WP; static WP *head = NULL; // 正在使用的监视点 static WP *free_ = NULL; // 已删除、等待复用的监视点 // 每执行完一条指令调用一次, 返回 true 表示需要停下来 bool check_watchpoints(void) { for (WP *wp = head; wp != NULL; wp = wp->next) { bool ok = true; word_t new_val = eval_expr(wp->expr, &ok); if (!ok) continue; // 表达式暂时非法就跳过 if (new_val != wp->old_val) { printf("Watchpoint %d: %s\n", wp->NO, wp->expr); printf("Old value = %u\nNew value = %u\n", wp->old_val, new_val); wp->old_val = new_val; // 必须立刻更新, 否则会连环触发 return true; } } return false; }两个链表的写法是有讲究的。head存活的监视点,free_存被d掉的节点。删除时把节点从head摘下来挂到free_,新增时优先从free_取——这样既避免了频繁malloc/free,也天然实现了“编号可以复用”,用户不用关心编号是不是连续。当然代价是编号可能重复出现,需要额外的逻辑保证当前存活的编号不撞车。
监视点里存“表达式字符串”而不是解析后的语法树,是一个明确的时间换空间取舍。每次检查都要重新做一遍词法和语法分析,对教学模拟器每秒几百万条指令的规模来说完全可接受;好处是实现极简,加一个监视点就是把字符串拷贝进去、求一次初值。如果换成 AST,删除、复用、序列化都要多写一堆代码,收益在 PA1 这个阶段几乎为零。
提示:
wp->old_val = new_val这一行不能省。省掉之后,同一个监视点在下一条指令执行完后仍然满足“新旧不等”,会再一次触发,表现出来就是“程序停在原地不动,一直在报同一个监视点”。
4. 避坑与排查:sdb 写完跑起来之后的那几个翻车现场
4.1 表达式与命令解析的坑
坑一:x N EXPR的 N 和表达式边界搞混。现象是x 4 0x80000000正常,x $pc+16报参数错误,或者表达式里带空格(x 4 3 + $pc)时结果全错。 原因是strtok把空格当分隔符,表达式被切碎;同时代码默认第一个参数一定是 N,没考虑“用户省略 N”的写法。 解决办法是先把第一个 token 取出来,尝试整体解析成十进制数:成功就当作 N,失败就把它和后面的剩余字符串拼回一个表达式、N 取默认值 1。最省事的做法是在帮助信息里明确写“表达式内不要带空格”,把边界说清楚。
坑二:一元负号被当成减号。现象是p -1直接报“表达式非法”,或者更糟——返回一个随机数。 原因是find_dominant_op只看优先级,在位置 0 找到了-,然后把左半段空表达式丢给eval,越界访问。 解决办法是在扫描时跳过“前面没有合法左操作数”的-和+:如果它位于表达式开头,或者左边紧邻(或另一个运算符,就把它归为一元,不参与主运算符竞选。
坑三:括号检查只比首尾,(1+2)*(3+4)被误剥。现象是p (1+2)*(3+4)返回 3,正确答案应该是 21。 原因是check_parentheses只判断了首尾是不是括号,没检查这对括号是否真的配成一对。 解决办法是用计数器从p扫到q:遇到(加一、遇到)减一,如果中途余额回到 0 而位置还没到q,就说明整体没被包住,直接返回 false。
4.2 内存与寄存器访问的坑
坑四:x命令直接拿数组下标当 guest 地址。现象是打印出来的地址和预期差一大截,越界访问时 host 进程直接段错误,什么信息都没有。 原因是没有经过guest_to_host换算,也没有走越界检查。 解决办法是统一从vaddr_read(addr + 4 * i, 4)读,把所有地址换算和边界判断集中到一个函数里。排查这类问题时,第一件事是打印CONFIG_MBASE和实际算出来的数组下标,对不上就一定是换算错了。
坑五:端序搞反或者非对齐读。现象是x 1 0x80000000读出来的值和用反汇编看到的差了 0x1000000 的整数倍;偶尔还会在某些平台上直接崩。 原因是用错了端序手工拼字节,或者把memcpy的目标指针强转成了一个没对齐的uint32_t *。 解决办法是写死一个host_read函数,内部按 ISA 声明的端序逐字节拼,业务代码里不许再出现手写的位运算。端序这种东西没有“差不多对”,错一个字节就是全错,所以必须收敛到单一入口。
这五类问题的共同点是:它们都不会在编译期报错,只会在运行时以“结果莫名其妙”的形式出现。所以排查顺序建议固定成三步——先确认命令有没有被正确切分(打印出解析后的参数),再确认地址有没有被正确换算(打印出 guest 地址和数组下标),最后才去看指令语义。顺序颠倒的话,你会花大量时间在一个其实没问题的求值函数上。
5. 取舍的边界:这份 sdb 为什么甘愿“不优雅”
把上面的实现摊开看,会发现它做了好几个在工程上会被批评的决定:状态用全局变量、表达式每次重新解析、监视点存字符串而不是语法树、命令分派用strcmp链。单拎出来每一条都有一堆更好的替代方案,但放在一起看,它们是同一套价值观的产物——把复杂度留给指令语义,把简单留给观测逻辑。
这套取舍的合理性,取决于一个前提:客户程序的行为本身足够复杂,而调试器只需要“看清楚”。如果调试器自己也变得复杂(支持嵌套表达式、支持脚本、支持条件断点组合),那它就会变成第二个需要被调试的系统,PA1 的重点就跑了。反过来,如果哪天真的要拿 sdb 去调一个真实的操作系统内核,这套设计立刻会撑不住——全局状态让你没法同时观察两个进程,每次重新解析表达式在单步几万次时会明显卡顿。知道它什么时候会撑不住,比知道它现在能跑更重要。
验证这份 sdb 是不是真的对,我一般用三个手段,按成本从低到高排:
| 手段 | 做法 | 能抓到什么 |
|---|---|---|
| 手算比对 | 写一段已知指令序列,逐条手算每个寄存器应有的值,再用si一条条对 | 指令语义、pc 推进、符号扩展 |
| 交叉验证 | 用x读出的指令字节,和反汇编工具输出的十六进制逐字节比对 | 取指地址、端序、内存换算 |
| 边界测试 | x越界、p除零、w写非法表达式、d删不存在的编号 | 错误处理路径有没有把 host 带崩 |
三个手段里,第三个最容易被跳过,也最容易在后期咬人。因为前两个只验证“正常路径对不对”,而模拟器真正的价值恰恰在于“出错的时候能不能给出可读的信息”。一个越界访问直接把 host 打崩的 sdb,和没有 sdb 的区别其实不大。
从那以后我每次写完一个观测类工具,都会强制走一遍“故意让它出错”的流程——先喂非法输入,再喂边界输入,最后才喂正常输入。因为这个顺序反过来的话,正常路径跑通带来的满足感,会让你不自觉地跳过那些真正会翻车的地方。希望帮到你。
本文还有配套的精品资源,点击获取