PA1模拟器与sdb调试器:指令级模拟的观察窗口

发布时间:2026/11/3 11:13:03
PA1模拟器与sdb调试器:指令级模拟的观察窗口

简介:面向计算机系统课程学习者与底层软件开发者的一份 NEMU PA1 专题说明文档,围绕简单调试器 sdb 的构建与 NEMU 模拟器基础设施展开,适合在操作系统、体系结构或系统能力实践项目中对照查阅。压缩包仅含 1 个 PDF 文件,约 74KB,篇幅精炼,便于随课程讲义或实验环境一起存档与检索。文档先从项目定位切入,梳理单步执行、打印寄存器、扫描内存、表达式求值等 PA1 常见任务,再解释 CPU 模拟、内存字节数组建模、设备模拟与调试器实现之间的关系,并延伸到计算机系统教学、体系结构研究、嵌入式与操作系统开发测试等应用场景。已有 799 人学习,可作为理解 NEMU 运行机制、搭建 sdb 功能框架和规划后续实验的参考材料。

1. 从一台不存在的电脑说起:PA1 到底在做什么

很多人第一次看到 PA1,是在计算机系统课的实践清单里:要求写一个调试器,而它调试的对象,是一台并不在桌上的电脑。这台电脑是软件模仿出来的,业内叫模拟器;那个调试器名字很朴素,叫简易调试器(sdb,simple debugger)。PA1 做的事,就是给这台软件电脑装上初始的控制接口——能单步走、能看寄存器、能翻内存、能算一个简单表达式。听起来像玩具,可它解决的是真问题:程序跑偏的时候,总得有个地方能停下来看一眼。下面把它是什么、为什么长成这样、跟相近概念怎么区分,一层层说清。

2. 为什么要用软件假装一台电脑:模拟器的三层骨架

先回答一个容易被跳过的问题:明明有真机器,为什么还要在软件里再造一个?原因很朴素——真机器不让你看。你在真 CPU 上跑一条指令,寄存器怎么变、内存哪个字节被写、下一条指令从哪来,这些事发生得太快,也没有窗口给你看。软件假扮的电脑则相反,它的每一步都是几行代码,你想在哪停就在哪停,想打印什么就打印什么。代价是慢,可能慢几十上百倍,但换来的是完全可见。

这种“用程序解释另一段程序”的做法,在计算机系统里有个统称:模拟。被模拟的那台电脑叫客户机,跑模拟器的这台叫宿主机。模拟器自己是个普通程序,它维护一份“客户机状态”——若干寄存器、一大块内存、一个程序计数器——然后一条一条地把客户程序的指令读出来、算出来、把状态改掉。整件事没有魔法,就是把“硬件做的事”用变量和循环重写一遍。

一份最小可用的模拟器,骨架只有三层:CPU、内存、设备。下面拆开看,顺便说清每层为什么必须存在。

2.1 CPU 模拟:把一条指令拆成四个动作

CPU 在模拟器里不是一个芯片,而是一个结构体加一个循环。结构体存状态,循环负责推进。最核心的状态只有两样:一组通用寄存器,和一个程序计数器(PC)。PC 存的是“下一条要执行的指令在哪个地址”,它是整个模拟器的指针,指着哪,程序就走到哪。

一条指令的执行被拆成四个动作,顺序固定:取指、译码、执行、更新。取指是拿 PC 当地址去内存里读几个字节;译码是从这几个字节里切出操作码和操作数;执行是按操作码做加减、访存或者跳转;更新是把结果写回寄存器,并把 PC 挪到下一条。打个比方,这像厨师照菜谱做菜:PC 是手指着的那一行,取指是把那行字看清楚,译码是弄明白“盐少许”到底是多少,执行是动手,更新是把手指移到下一行。

// 取指-译码-执行的主循环,省略了绝大多数指令 void cpu_exec(uint64_t n) { while (n-- > 0) { // n 是步数上限,防止死循环把宿主拖死 uint32_t inst = mem_read(cpu.pc, 4); // 取指:一次读 4 字节 uint32_t opcode = inst & 0x7f; // 译码:先切出低 7 位操作码 switch (opcode) { case 0x13: /* 立即数运算:rd = rs1 + imm,立即数需要符号扩展 */ break; case 0x33: /* 寄存器运算:rd = rs1 op rs2 */ break; case 0x6f: /* 跳转:直接改 PC,不再走顺序自增 */ break; default: panic("unsupported opcode 0x%02x at pc=0x%08x", opcode, cpu.pc); } } }

这段代码有三个值得注意的地方。n是执行条数,不是时间,模拟器没有“时钟”,只有“第几条指令”,这一点后面讲单步时会反复用到。0x7f这个掩码不是随便写的,它对应指令格式里操作码占的位宽,掩码写错,译码就会把加法认成跳转,症状是程序莫名其妙跑到一片空白内存上。panic是模拟器自己的报错出口,遇到不认识的指令就当场停下并打印现场,比硬扛着往下跑有用得多——这个习惯在后面的实践里能省掉大量时间。

还有两个细节,初学最容易忽略。一是立即数的符号扩展:指令里塞得下的立即数位宽有限,负数必须把高位补成 1,否则-4会变成一个大正数。二是零号寄存器:很多指令集规定 0 号寄存器恒为 0,写进去也读回 0,模拟时要在写回那一步专门拦一下,不然一段依赖它的代码会悄悄算错。

2.2 内存模拟:一整块字节数组就是整条街的门牌

内存的模拟比 CPU 还简单:申请一大块连续字节,用一个数组表示,地址就是下标。要读地址0x80000000处的 4 个字节,就去数组对应位置取四个元素拼起来。为什么用连续数组而不用链表或哈希表?因为内存访问的最大特点是随机——这一秒读栈顶,下一秒跳到代码段,只有下标寻址能做到一步到位。

#define MEM_SIZE (128 * 1024 * 1024) static uint8_t pmem[MEM_SIZE]; // 一整块连续字节,地址即下标 uint32_t mem_read(uint32_t addr, int len) { if (addr + len > MEM_SIZE) { // 越界必须当场炸掉,不能静默返回 0 panic("out of bound: read at 0x%08x, len=%d", addr, len); } uint32_t ret = 0; for (int i = 0; i < len; i++) { ret |= (uint32_t)pmem[addr + i] << (8 * i); // 小端:低地址放低位字节 } return ret; }

这里的关键词是小端。同样 4 个字节,放在内存里谁在前谁在后,不同体系结构约定不同。小端的规则是“低地址存低位字节”,所以拼装时第 0 个字节要左移 0 位,第 1 个左移 8 位。这个顺序写反,读出来的数会变成字节倒序的怪值,比如0x12345678读成0x78563412,症状是打印寄存器时数字看着“眼熟但不对”,很容易误以为是指令译码错了,实际是字节序翻车。

还有两件事要提前定好。一是地址空间布局:代码从哪开始、栈顶在哪、设备寄存器映射到哪一段,通常用几个宏固定下来,写死在头文件里,别散落在各处。二是越界检查:数组就那么大,客户程序一旦算错地址,读写就会跑到数组外面,C 语言不会拦你,结果是宿主程序莫名其妙崩溃,或者更糟——悄悄改坏了别的变量。所以每个读写函数开头都要有一段边界判断,宁可当场报错,也别留下一个查不出来的隐患。

提示:内存模拟的边界检查要按“起始地址 + 长度”一起判,只判起始地址会漏掉跨边界的那几个字节。

2.3 设备模拟与观察窗口:能看见比能跑更重要

第三层是设备,PA1 阶段它常被标成可选,但值得先理解它的思路。真实机器上,程序要输出一个字符,得往某个固定地址写数据,那个地址背后连着串口芯片。模拟器照抄这个约定:把设备寄存器映射进地址空间,客户程序往那个地址写字节,模拟器收到之后转成宿主机的输出。这种做法叫内存映射 I/O,好处是访存指令不用为设备开特例,读写内存和读写设备长得一模一样。

设备这层在 PA1 里做得浅,但它解释了一个更本质的问题:模拟器为什么必须有“观察窗口”。一台不给任何输出的模拟器就是个黑匣子,客户程序跑完,你只知道“结束了”,不知道中间发生了什么。调试器就是那个窗口——它不是模拟器的装饰品,而是让模拟器从“能跑”变成“能查”的那部分。这也是 PA1 把重点放在调试器而不是指令数量上的原因:指令可以慢慢补,观察能力不先建起来,后面每加一条指令都是盲改。

3. 简易调试器 sdb 的四个基本功:让执行过程变得可问可查

调试器在概念上是个很朴素的程序:一个循环,读你敲的一行字,按第一个词找到对应动作,把剩下的参数交过去,做完再回来等下一行。它和模拟器的关系是“套在外面”——调试器解析你的命令,命令最终落到模拟器的状态上:读寄存器、读内存、让 CPU 往前走几条。理解这一层,后面四个功能就不会觉得是四件不相干的事,它们其实是同一套状态的四扇窗。

3.1 命令循环与解析:为什么用一张表而不是一串 if

第一版调试器最容易写成一大串if-else:是si就单步,是info就打印,是x就扫内存。命令少的时候没问题,加到第六七个就开始难受:每个分支里都要自己切参数,帮助信息散落在各处,加一条命令要改三处地方。更稳的写法是表驱动——把命令名、处理函数、帮助文本塞进一张表,主循环只做两件事:找到表项、调用它。

typedef struct { const char *name; int (*handler)(char *args); // 返回 0 表示继续,非 0 表示退出调试器 const char *help; } cmd_t; static cmd_t table[] = { {"si", cmd_si, "si [N] 单步执行 N 条指令,缺省 1"}, {"info", cmd_info, "info r 打印全部寄存器"}, {"x", cmd_x, "x N ADDR 从 ADDR 起打印 N 个 4 字节字"}, {"p", cmd_p, "p EXPR 求值一个表达式"}, {"help", cmd_help, "help 列出所有命令"}, {"q", cmd_quit, "q 退出"}, {NULL, NULL, NULL} // 哨兵:遍历到 NULL 就停 };

这张表解决的不只是代码整洁。第一,帮助信息只有一份,help命令直接遍历打印,永远不会和实际行为对不上。第二,新增命令是纯增量,不动主循环,也就不会碰坏已有功能。第三,哨兵NULL让遍历有确定的终点,不用另外维护一个计数器。解析部分通常按空格切词:第一个词查表,后面的整段字符串原样交给处理函数,让每个命令自己决定怎么理解参数——info只认r,x要认“个数 + 地址”,p要认一整条表达式,它们的参数规则本来就不同,硬塞进一个统一的解析器反而别扭。

3.2 单步与断点:把连续的时间切成格

程序在真机器上是连续跑的,调试器做的事是把它切成一片一片,每片之间停下来让你看。实现上极其简单:模拟器的主循环本来就能执行指定条数,单步就是“执行 1 条然后返回”,si 10就是“执行 10 条然后返回”。没有暂停信号,没有线程挂起,就是循环少转几圈。

断点是同一件事的另一种用法:不是走一步停一次,而是一路跑,跑到某个地址停。做法是在执行前对 PC 做一次判断——如果 PC 等于断点地址,就停下来并打印现场。这里有个容易忽略的细节:停下来的时候,那条断点指令还没执行,PC 正指着它。所以“停在哪”和“执行到哪”差一条指令,描述行为时要说清是哪一种,否则复现问题时会对不上。

维度单步 si断点
停止条件执行完固定条数PC 命中预设地址
典型用途观察每一步状态变化跳过已知正确的大段代码
失败表现步数太多,看得眼花断点地址设错,永远不停
常见误解以为“一步”是一个 C 语句以为停下时该指令已执行

单步真正的价值在于“对照”。你心里有一个预期:执行完这条加法,某个寄存器应该从 3 变 7。单步一次,打印一次,对上了就继续,对不上就找到了出错的最小现场。这比一次性跑完再看最终结果强太多——最终结果错了,你只知道错了,不知道从哪条开始错的。

3.3 打印寄存器与扫描内存:读仪表盘和翻字节

info r干的事是把 CPU 结构体里的 32 个通用寄存器和 PC 按固定格式打出来。看着像最简单的功能,其实有两个讲究。格式要固定:宽度对齐、十六进制带前缀、每行几个,固定下来之后你扫一眼就能发现哪个值不对;如果每次输出格式都变,人眼比对会累到放弃。内容要有派生信息:光打 PC 没用,最好同时把它指向的那条指令的原始字节也打出来,一眼就能对上“停在哪条指令”。

x N ADDR是内存扫描,从指定地址连续打印 N 个 4 字节字,通常一行打四个,左边标地址。这个命令在排查“数据到底写没写进去”时最管用:你以为写到了 A 处,实际写到了 B 处,扫一段内存就能看出来。它和info r的关系是互补的——寄存器告诉你“算出来的值”,内存告诉你“存下去的值”,两个对不上,问题多半出在地址计算或字节序上。

注意:打印长度要和读写长度一致。用 4 字节读、按 1 字节打,看到的会是错位的字节,白折腾半天。

3.4 表达式求值:让调试器听懂一句人话

前三件事都是“固定格式查询”,第四个功能不一样:p $pc + 4这种输入,长度不定、有优先级、还能嵌套括号,靠几个if拼不出来。标准解法是递归下降——给每一档优先级写一个函数,高优先级函数被低优先级函数调用,括号一出现就递归回最外层。

// expr := term (('+'|'-') term)* // term := factor (('*'|'/') factor)* // factor := number | '(' expr ')' | '$' reg static uint32_t eval_expr(const char **p); static uint32_t eval_factor(const char **p) { while (**p == ' ') (*p)++; // 跳过空格,别让空格改变语义 if (**p == '(') { (*p)++; uint32_t v = eval_expr(p); (*p)++; return v; } if (**p == '$') { (*p)++; return reg_read(parse_reg(p)); } // $pc、$0 之类 return parse_number(p); // 十进制或 0x 开头的十六进制 }

这段代码的思路比代码本身重要。优先级的实现方式是“函数层次”:eval_expr只认加减,遇到乘除就交给eval_term,eval_term遇到括号又交回eval_expr。乘除天然比加减先算,因为它在更里层。整个求值过程不建语法树、不做两遍扫描,边走边算,读到哪算到哪。

参数说明也要留个心:表达式里的数字默认十进制还是十六进制,0x前缀要不要支持,寄存器名写$pc还是$eip,这些约定必须在帮助里写清。调试器最怕的不是功能少,是行为不一致——同一条命令这次这么理解、下次那么理解,用的人就会失去信任,宁可回去加打印语句。

4. 从“能跑”到“跑对”:PA1 里几个必须提前想清楚的设计取舍

同样能跑通,不同实现之间的差距往往不在功能多少,而在几个当时没在意、后来天天踩的点上。这一章说三个取舍,它们都属于“现在多想十分钟,后面少调三小时”的类型。

4.1 指令周期与状态更新:PC 到底什么时候动

新手最常见的实现顺序是:先cpu.pc += 4,再去执行指令。这么写在纯顺序执行的程序上看不出问题,一遇到跳转就翻车:跳转指令本来要把 PC 改成目标地址,可你前面已经加过 4 了,最后算出来的地址整体偏了 4 字节,程序跳到一个“差一点”的地方,症状是执行几条后撞上非法指令。

正确的心智模型是“PC 指向当前正在执行的指令”,执行完再决定下一条去哪。顺序指令是“当前 + 4”,跳转指令是“目标地址”,两者在同一步里二选一,不能先加再改。这件事的教训不止于 PC:凡是“状态更新”和“副作用”混在一起的地方,都要先想清楚谁先谁后。寄存器写回也是同理——如果一条指令同时读源寄存器和写目的寄存器,而源和目的是同一个,写早了就会把要读的旧值覆盖掉,算出来的结果看着像“差了一个数”,非常难查。

4.2 边界与失败可见:报错要趁早,信息要够用

模拟器里所有“按理说不会发生”的情况,都值得用一句断言拦住:不认识的指令、越界的地址、非法的寄存器编号、除数为零。这些检查在正常路径上一次都不触发,看起来是浪费时间,可一旦触发,它把“宿主程序在某处莫名崩溃”变成“第 1234 条指令访问了 0x90000000,超出内存范围”。这两种失败的信息量差了几个数量级。

血泪经验通常长这样:越界访问没有检查,写坏了数组旁边的另一个变量,那个变量恰好是循环计数器,于是循环次数变得莫名其妙,最后表现成“程序跑了一半停了”。你从现象往回推,要穿过好几层假象才能找到真凶。加上检查之后,同样的问题会在第一现场直接喊出来。

// 失败也要有格式:地址、长度、操作类型,一个都不能少 static void check_bound(uint32_t addr, int len, const char *op) { if (addr + len > MEM_SIZE || addr + len < addr) { // 第二个条件防溢出回绕 panic("%s out of bound: addr=0x%08x len=%d", op, addr, len); } }

注意第二个条件addr + len < addr:两个无符号数相加可能溢出回绕,回绕之后反而“看起来没越界”,这种边界情况在真实排查里确实会出现,多写一个判断成本极低。

4.3 可扩展性:为什么命令要用表、内存要用宏

PA1 只是起点,后面还要加指令、加设备、加更多调试命令。所以这一阶段的选择要考虑“第二次修改好不好改”。命令用表驱动,加命令是加一行;内存大小、起始地址、栈顶位置用宏统一定义,改布局是改一处;寄存器的读写包成函数,将来加 CSR 或者浮点寄存器时不用改调用方。

反过来,几个看似省事的做法会留下后患:把内存大小写死在十几个地方,改一次要全局搜索;把打印格式散落在各个命令里,输出风格不统一;把“单步一条”写成一个独立的特殊路径,而不是复用主循环的步数参数。这些都不影响第一次跑通,但会让第二次、第三次修改越来越贵。判断标准很简单:加一个新功能,需要改几个已有文件?如果一个,说明结构还行;如果三四个,说明该重构了。

5. 易混概念清单:模拟器、虚拟机、调试器、解释器差在哪

这一章专门处理“好像都差不多”的那几组词。它们混起来不是因为难,而是因为日常说话时被当成同义词用,一旦落到具体实现上,边界其实很清楚。

5.1 模拟器不是虚拟机

易混点:两者都“在一台机器上跑另一套环境”。为什么混:都给人一种“电脑里的电脑”的观感,界面表现也像。怎么区分:看它模拟的是“指令”还是“硬件资源”。模拟器逐条解释客户程序的指令,每条指令都要自己算一遍,客户程序的指令集可以和宿主机完全不同;虚拟机更多是把真实硬件的资源切一块出来给客户系统直接用,客户系统的指令直接跑在物理 CPU 上,中间靠一层调度隔离。一个明显的体感差别是速度:指令级模拟通常慢一到两个数量级,虚拟化则接近原生。做 PA1 时你写的那套东西属于前者,别指望它跑得快,它的价值是可见和可控。

5.2 调试器不是模拟器的一部分

易混点:sdb 和模拟器写在同一个项目里,容易以为是一回事。为什么混:两者共享同一份状态,调试器读的寄存器就是模拟器的寄存器。怎么区分:它们是三层关系——客户程序在最里层,模拟器在中间负责执行,调试器在最外层负责观察和控制。模拟器不知道调试器存在,它只提供“执行 n 条”和“读写状态”这两类能力;调试器不执行指令,它只调用模拟器。分清这层,你就能理解为什么“单步”实现起来只是“让模拟器走一条再返回”,而不是给模拟器加一个暂停开关。

5.3 单步不是断点

易混点:都让程序停下来,都被叫“调试手段”。为什么混:使用场景重叠,都是“停下来看看”。怎么区分:单步是“我控制节奏”,断点是“程序自己撞线”。单步的停止条件是执行条数,断点的停止条件是地址命中。数量级也不一样:查一个循环里的问题,单步可能要走几百次,断点一次就到。实际排查时两者是配合关系——先用断点跳到可疑区域,再用单步细看几条指令。

5.4 寄存器不是内存

易混点:都能存数据,都能用x之类的方式打印出来。为什么混:调试时两者都在同一屏输出,格式也相似。怎么区分:位置和速度不同,寄存器在 CPU 内部,容量极小、访问极快,指令里用编号直接指定;内存在 CPU 外面,容量大、访问慢,要靠地址去寻址。数量上也差得远:寄存器几十个,内存按字节计。排查时的用法也不同——寄存器看“当前算到哪一步”,内存看“数据最终落在哪”。数值对不上时,先确认你看的是哪一个,别拿寄存器里的地址当内存里的内容比。

5.5 解析指令不是编译程序

易混点:都要“读懂一段指令/代码”。为什么混:“解析”和“编译”两个词在日常语境里都指“弄明白”。怎么区分:编译是把一段程序整体翻成另一种形式,产出物可以反复执行;模拟器里的译码是每条指令执行时临时切一下位,切完就扔,下条指令重新来。前者是一次性投入、多次受益,后者是每次都要付。这也解释了为什么模拟器慢:它没有把“翻译”的结果存下来复用。想加速,常见的思路正是把“每次译码”改成“译一次存起来”,那就往编译的方向走了。

6. 什么时候值得动手,什么时候别碰:一份边界判断

把上面几章合起来看,PA1 的定位其实很清楚:它是一块“观察能力”的地基,不是性能工程,也不是完整的系统实现。所以判断要不要投入,标准不是“难不难”,而是“你要不要这双眼睛”。

适合动手的情况有三种。一是你正在学计算机系统,需要把“指令、寄存器、地址”这些词从纸面概念变成手上能改的变量——自己写一遍mem_read,字节序这件事就再也不会记错。二是你要在底层软件上排查问题,需要一个可控、可改、能看到每一步的测试台,真机给不了这种透明度。三是你打算继续往后做更完整的模拟器,PA1 的命令框架、内存布局、失败检查都是后面要复用的东西,这时候省下的时间会在下一阶段还回来。

不适合的情况也有三种。你只是想让一段普通业务代码跑起来,那用现成的调试器和打印语句更快;你需要的是接近真实硬件的性能数据,指令级模拟的时序和真机差得远,拿它的耗时当性能指标会得出错误结论;你只想看结论不想看过程,那这套东西的价值基本为零,因为它的全部意义就在过程里。

场景是否值得投入原因
学习计算机系统基础概念值得每个抽象都能落到可改的代码上
验证底层软件的行为值得可停、可看、可改地址
后续要继续扩展模拟器值得命令表与内存布局可直接复用
调试普通应用逻辑不必现成调试器成本更低
需要真实性能数字不必解释执行的耗时不能代表硬件
只想拿最终结果不必观察能力用不上

如果决定动手,验收不必贪多,能过这张最小清单就说明地基稳了:si走一条后 PC 变化符合预期;info r能稳定打印全部寄存器;x能在已知地址读到刚写进去的值;p 1+2*3得 7 而不是 9;访问越界地址时程序当场报错并打印地址,而不是默默继续。

我自己的习惯是:每加一个新功能,先想好“它出错时该打印什么”,再写实现。早些年图省事,越界不检查、译码不报错,结果一个问题查一整天,最后发现是四个字节的字节序反了,那种后悔药没处买。从那以后我每次加内存读写都强制走一遍边界判断,宁可多写两行 panic,也不让程序静默地错下去。希望帮到你。

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