简介:一份面向计算机系统结构实验的PDF文档,专门讲解指令系统的实现过程,适合正在完成PA类指令实验、需要理清取指—译码—执行主线的本科生或自学者。文档从指令系统基础切入,逐步展开框架代码解析,覆盖fetchInstruction、decode、寄存器读写、译码模板以及ADD/SUB、MOV/NOP、栈操作、标志位更新等指令细节,并延伸到打印变量与打印栈帧的实现思路。资源为单个PDF文件,大小1.46MB,已有1044人学习;内容围绕实验中的关键难点组织,包括寄存器组织、函数调用、栈帧变化与指令模板的对应关系,能够帮助读者快速定位框架函数、理解操作数读取方式,减少陌生代码的阅读成本。整份资料结构清晰、重点突出,适合作为实验前的预习笔记与实验中的排错参考,也可用于快速上手同类指令系统的框架代码。
1. PA2 指令系统:先把取指、译码、执行三个动作打通
PA2 指令系统是整条实验链里第一个需要你亲手把框架代码读懂、把 opcode 表格填满的环节。我一开始以为只写几个执行函数就行,结果卡在宏定义和三四个文件的引用关系上三天没动。这份资源真正有用的地方是:它把取指、译码、执行的调用链拆开,把 ADD、SUB、PUSH、CALL 每类指令的模板选择和 flags 更新时间点讲清楚,还带上了打印变量和打印栈帧的实现思路。做完之后 opcode 表不再是黑匣子,0x00 到 0xff 每一项都能对应到print_asm的汇编输出上。适合正在 PA2 卡壳、想补框架阅读方法、或者要对照符号表和栈帧打印做扩展的人。下面按我当时实际动手的顺序展开。
2. 框架代码怎么读:先找 location.h 的宏,再理 exec_wrapper 的分发
PA2(上) 的大部分工作量不在“写指令”,而在“读懂框架”。这个实验的框架代码不是从头给你一个干净的 CPU,而是把一个指令系统的骨架摆好,留了一堆宏和函数让你填,所以第一件事不是写代码,是把宏展开关系摸清楚。
2.1 取指、译码、执行的调用链先画出来
常见的做法是每次 CPU 执行一条指令时,都会走cpu_exec→exec_wrapper→ 查表 → 译码 → 执行这条路。我建议拿到框架先别读细节,直接在注释里把这条链写出来:
static inline void exec_wrapper(void) { uint8_t opcode = instr_fetch(&cpu.eip, 1); // 取指:读 1 字节并让 eip 顺序前进 seq_eip = cpu.eip; // 备份顺序下一条地址,跳转时会用到 OpcodeEntry *entry = &table[opcode]; // 以 opcode 为下标查指令表 entry->decode(); // 译码:把操作数填进 id_dest / id_src entry->execute(); // 执行:EHelper 宏展开成具体操作 print_asm(entry->name, id_dest, id_src); // 打印汇编,方便后续单步对拍 }逻辑说明:instr_fetch读一个字节后,cpu.eip已经不是当前指令地址了,而是指向下一条顺序指令。seq_eip就是把这个地址备份下来。普通指令执行完,cpu.eip就停留在seq_eip,框架自然进入下一条;跳转类指令必须直接改cpu.eip,否则会继续往下顺序执行。
参数说明:opcode是uint8_t,所以table最多 256 项;instr_fetch的第二个参数是读取长度,本次实验的指令都是一个字节的 opcode,所以传 1 就够。entry->name是给print_asm用的字符串,不是执行逻辑的一部分,但它在验证阶段特别有用。
2.2 location.h 里的三类宏:make_EHelper、make_DHelper、make_instr_helper
框架代码里最关键的文件基本都叫location.h或者instruction.h,里面封装了三类宏。我建议按“执行函数、译码模板、表项绑定”三组来记忆:
| 宏 | 作用 | 我一般怎么用 |
|---|---|---|
make_EHelper(name) | 定义一个指令执行函数 | 在里面写 RTL 操作,如rtl_add、rtl_push |
make_DHelper(template) | 定义一种译码模板 | 按操作数形式选模板,如寄存器加立即数 |
make_instr_helper(template, ehelper) | 把模板和执行函数绑成一个表项 | 在指令表中填一行,opcode 查表后自动配对 |
比如我要实现一条“寄存器 + 1 字节立即数”的算术指令,常见的做法是在decode.c里已经有类似rB_I1这样的模板,名字的意思是“一个寄存器和 1 字节立即数”。我只需要在location.h里把这条指令绑定到rB_I1上,译码的细节就不用自己写了。模板一旦确定,print_asm打印出来的汇编格式也随之确定,这就是为什么有些指令不需要单独写print_asm。
有一点要注意:不同版本的框架里模板命名不完全一样,不要死记模板名。我一般先去decode.c里看make_DHelper的宏列表,确认每个模板读多少字节、是否做符号扩展,再回来填location.h。
2.3 seq_eip 与 cpu.eip:一个管顺序,一个管跳转
这个点我在前面代码里已经埋了伏笔,但值得单独拿出来说,因为它是 PA2 所有跳转指令出 bug 的根源。cpu.eip是真正的指令指针,硬件靠它决定下一个取指位置;seq_eip是“当前指令按顺序执行完后的下一条地址”。
普通指令执行完,框架不会额外去动cpu.eip,它就停在seq_eip;但 JMP、JE、JNE、CALL、RET 这些指令,必须自己把cpu.eip改成目标地址或者栈上弹出的地址。框架里那个“顺序推进”的动作不会帮你做跳转,你如果在跳转指令里用了seq_eip来赋值,等于告诉 CPU “不要跳”。这个坑后面避坑章我会再展开。
3. 从 ADD 到条件跳转:指令模板选型与 flags 更新细节
指令实现不是一条条孤立写的,而是先看它属于哪一类操作数格式,再决定用哪个模板。实现清单可以先列成一张表,然后逐个补执行逻辑。
3.1 先看指令清单:哪些共用模板,哪些要单独写
| 指令 | 典型模板 | 注意点 |
|---|---|---|
| ADD / SUB | rB_I1 / rB_I2 | 要更新 OF/CF/SF/ZF/PF,写回目标 |
| CMP | 同 SUB 的模板 | 只更新 flags,不写回目标 |
| MOV | rB / rB_I1 | 不更新 flags |
| PUSH / POP | 寄存器或内存操作数 | 先改 esp 再读写栈顶 |
| JMP / JE / JNE | 1 字节相对偏移 | 改 cpu.eip,偏移做有符号扩展 |
| CALL / RET | 2 字节立即数 / 无操作数 | 返回地址压栈存 seq_eip |
| SETB / SETNE | 1 字节寄存器或内存 | 条件成立写 1,否则写 0 |
从这张表能看出来,模板选型决定了译码的字节数。同一个 opcode 段里,一条指令可能按 ModRM 的 reg 位分成八个分支,比如算术组里 ADD、OR、ADC、SBB、AND、SUB、XOR、CMP 就是共享同一批模板、由 reg 区分具体运算。所以实现时先确认这个区段有几个分支,再决定是复用一个 EHelper 还是拆开写。
3.2 flags 更新公式:OF/CF/SF/ZF/PF 一次算全
算术指令最容易漏的就是 flags。我一开始只更新了 ZF 和 SF,导致条件跳转完全不按预期走。后来把 flags 更新统一抽成一个函数,所有加减类指令都调它:
static inline void update_flags(uint32_t dest, uint32_t src, uint32_t result, bool is_sub) { uint32_t sign = (dest ^ src ^ result); cpu.eflags.CF = is_sub ? (dest < src) : (result < dest); cpu.eflags.OF = ((~(dest ^ src) & (dest ^ result)) >> 31) & 1; cpu.eflags.ZF = (result == 0); cpu.eflags.SF = result >> 31; uint8_t low = result & 0xff; int ones = __builtin_popcount(low); cpu.eflags.PF = (ones % 2 == 0); }逻辑说明:所有 flags 的输入是“操作前的目标值 dest、操作数 src、操作后的结果 result”。CF 在减法里是借位标志,判断条件是 dest 是否小于 src;在加法里是进位标志,判断条件是 result 是否小于 dest。OF 符号溢出公式在所有补码加减法里都通用,只对最高位这一位有效,所以结果是右移 31 位再与 1。
参数说明:is_sub决定 CF 的判据;如果不传它,CMP 和 SUB 会复用同一套逻辑,但减法语义会错。ZF、SF、PF 是纯结果相关,跟加减无关。PF 统计的是低 8 位里 1 的个数,个数为偶数时置 1,注意不是整个 32 位数里 1 的个数。
3.3 CMP 与 SUB 的差异:算完了但不写回
CMP 的操作和 SUB 完全相同,唯一的差别是结果不进目标寄存器。所以代码结构上可以把 SUB 的 EHelper 拆成“算 result”和“写回”两步,CMP 只做前一步:
make_EHelper(sub) { uint32_t dest = id_dest->val; uint32_t src = id_src->val; uint32_t result = dest - src; update_flags(dest, src, result, true); operand_write(id_dest, &result); // SUB 写回目标 } make_EHelper(cmp) { uint32_t dest = id_dest->val; uint32_t src = id_src->val; uint32_t result = dest - src; update_flags(dest, src, result, true); // 没有 operand_write }这里的 id_dest 和 id_src 是译码阶段填好的操作数结构,id_dest->val已经是寄存器或内存里取出的实际值,而operand_write(id_dest, &result)会根据操作数类型决定写寄存器还是写内存。CMP 不调用写回,这就是它对 flags 有影响但不动数据的原因。
3.4 条件跳转和 SETcc:ZF/CF 如何决定下一步
JE 和 JNE 只看 ZF,SETB 只看 CF。跳转类指令的核心是“偏移量有符号扩展”和“写 cpu.eip 而不是 seq_eip”:
make_EHelper(je) { if (cpu.eflags.ZF == 1) { cpu.eip += (int8_t)id_src->val; // 偏移量必须按有符号扩展 } // ZF 为 0 时不改 cpu.eip,自然顺序执行 } make_EHelper(setne) { uint32_t val = (cpu.eflags.ZF == 0) ? 1 : 0; operand_write(id_dest, &val); }SETcc 这一类指令和跳转原理几乎一样,只是把条件结果写到 1 字节目标里。实验里如果没用到部分条件,把对应的 CF/ZF/OF 置零即可,不需要额外实现整套逻辑。需要特别注意的是(int8_t)id_src->val这个强制转换:如果偏移量读出来是 0xFE,直接当无符号数 254 去加,跳到天边去了;转成 int8_t 之后就变成 -2,往回到正确位置。
4. 从 PUSH 到打印栈帧:栈上数据链与符号表匹配
栈相关的指令是 PA2(上) 第二个坎。PUSH/POP 本身不难,难的是 CALL/RET 的返回地址链,以及打印栈帧时要靠返回地址反查函数名。这条链一旦理顺,打印变量和栈帧就只是符号表遍历的问题。
4.1 PUSH/POP:入栈先减栈顶,出栈先读再增
x86 的栈向低地址增长,所以入栈的第一步一定是esp -= 4,然后才把值写入esp指向的内存。顺序反了会把数据写到旧栈顶上面,覆盖掉返回地址。常见实现如下:
make_EHelper(push) { cpu.esp -= 4; vaddr_write(cpu.esp, 4, id_dest->val); } make_EHelper(pop) { uint32_t val = vaddr_read(cpu.esp, 4); cpu.esp += 4; operand_write(id_dest, &val); }vaddr_write的三个参数分别是地址、长度、数据。PUSH 的id_dest->val来自译码阶段,如果是PUSH eax,它就是 eax 的值。POP 则是从栈顶读 4 字节再写回目标寄存器。注意operand_write在这里会自动按目标宽度写,比如pop al只写低 8 位。
4.2 CALL/RET:返回地址压的是 seq_eip,不是 cpu.eip
CALL 要先把下一条指令地址压栈,然后跳转。这里最容易写错的就是把cpu.eip压进去。因为instr_fetch之后cpu.eip已经指向顺序下一条了,理论上它和seq_eip相等,但 CALL 还需要读 2 字节立即数,读完后cpu.eip已经越过立即数区域,如果这时候把cpu.eip压栈,RET 返回时会落在立即数中间。
make_EHelper(call) { uint32_t ret_addr = seq_eip; // 指向 CALL 下一条真正的指令 cpu.esp -= 4; vaddr_write(cpu.esp, 4, ret_addr); cpu.eip = id_dest->val; // 跳到目标地址 } make_EHelper(ret) { uint32_t ret_addr = vaddr_read(cpu.esp, 4); cpu.esp += 4; cpu.eip = ret_addr; }这就是为什么我在第 2 章反复强调seq_eip。CALL 里压栈的返回地址,是译码完成时按顺序算出的下一条指令地址,不是某个寄存器当前值。RET 反向操作,从栈顶弹出后就地修改cpu.eip,这一改,主循环下一次取指就直接到调用者的下一条指令了。
4.3 打印变量:遍历 ELF 符号表,拿 st_name 去字符串表匹配
实验要求支持打印变量,本质是在 ELF 的符号表里找名字匹配的符号,然后按符号地址读内存。ELF 里st_name是一个偏移量,真正名字在字符串表里,偏移量加在字符串表基地址上才是可读字符串。常见做法如下:
void print_var(char *var_name) { int sym_num = elf_get_num_symbols(); bool found = false; for (int i = 0; i < sym_num; i++) { Elf32_Sym *sym = elf_get_symbol(i); char *name = (char *)(symtab_base + sym->st_name); if (strcmp(name, var_name) == 0 && ELF32_ST_TYPE(sym->st_info) == STT_OBJECT) { uint32_t addr = sym->st_value; uint32_t val = vaddr_read(addr, sym->st_size); printf("%s = %u\n", var_name, val); found = true; break; } } if (!found) printf("No such variable\n"); }逻辑说明:符号类型STT_OBJECT过滤掉函数符号,避免把函数名当变量打印。st_size是变量字节数,vaddr_read(addr, st_size)统一读入,实验里通常是 4 字节的 int,值直接打印即可。
参数说明:不同框架封装了不同接口,有的叫elf_get_symbol,有的直接暴露symtab和strtab两个数组,核心字段都是一样的。注意形参不会出现在全局符号表里,因为形参没有分配静态空间,它只活在栈上,靠 ebp 偏移访问,这正是思考题里“消失的符号”的答案。
4.4 打印栈帧:用返回地址反查函数名,沿 ebp 链回退
打印栈帧(backtrace)的关键是理解调用者栈帧布局:ebp指向当前帧底部,[ebp]存调用者的 ebp,[ebp+4]存返回地址。所以回退是循环读取,而不是递归:
void print_backtrace(void) { uint32_t ebp_val = cpu.ebp; for (int depth = 0; ebp_val != 0 && depth < 32; depth++) { uint32_t ret_addr = vaddr_read(ebp_val + 4, 4); char *name = find_func(ret_addr); printf(" #%d 0x%08x in %s\n", depth, ret_addr, name); ebp_val = vaddr_read(ebp_val, 4); // 跳到调用者的 ebp } }find_func的实现思路是遍历符号表,找满足addr >= st_value && addr < st_value + st_size的STT_FUNC符号,返回它的名字。左闭右开很关键,避免返回地址恰好落在相邻函数开头时归属错。这个循环我加了深度上限 32,防止某个测试程序栈帧链被破坏后无限回退刷屏。
5. 避坑排查:五个我在 PA2(上) 翻过的车
5.1 寄存器访问宽度不一致,结果算对但寄存器没变
现象:ADD 指令执行后,用info reg查看 eax,值还是旧的,但单步调试里 result 明明算对了。
原因:我直接给cpu.eax赋值,而模板在译码阶段是按id_dest->width读操作数的。如果你的目标操作数是 1 字节,模板读的是al映射的位置,你写 32 位寄存器等于跨宽度写,低 8 位被框架后续按位域读回时覆盖了。
解决:统一走operand_write(id_dest, &result),它会根据id_dest->width自动选reg_b、reg_w、reg_l宏写对应宽度。实在要手动写寄存器,也必须先看模板里操作数的宽度,再选对应的访问宏。
5.2 跳转偏移量没做有符号扩展,往回跳直接跳到高位地址
现象:JMP 往前跳的时候,eip 一下变成 0xFFFF 开头的地址,程序直接跑到未定义区域触发 invalid opcode。
原因:译码读出来的偏移是uint8_t,0xFE 被当成 254 处理,而不是 -2。JMP 的偏移在 x86 里是相对当前指令的带符号数,负偏移表示往回跳。
解决:cpu.eip += (int8_t)id_src->val;先把 8 位无符号转成有符号 char,再做加法时自动符号扩展成 32 位。这个强制转换是我踩过最值的坑,也是思考题大端机器读立即数的前置概念。
5.3 跳转指令里用了 seq_eip,导致条件跳转永远顺序执行
现象:JE 条件满足时没有跳转,程序照常往下跑;单步看寄存器,ZF 确实是 1,eip 就是不动。
原因:我在 EHelper 里写的是cpu.eip = seq_eip;,等于把跳转目标覆盖成顺序下一条地址。seq_eip这个变量是框架在每次执行前备份的顺序地址,不是真正的“目标地址”。
解决:所有跳转类指令统一改cpu.eip,不要碰seq_eip。JMP、JE、JNE、CALL、RET 一律是cpu.eip = 目标值或者cpu.eip += 偏移,框架主循环自然会从那继续取指。
5.4 flags 更新拿错了操作数,条件判断反着来
现象:CMP 后 JE 跳转结果和汇编语义完全相反;SUB 后 ZF 总是 0,哪怕两个寄存器相等。
原因:我在算 ZF 时用了执行后的 dest 值,而 dest 已经被改掉了,比如dest -= src之后才去调 update_flags,传进去的 dest 是结果而不是操作前原值。CF 的加法判断result < dest也依赖“操作前 dest”,一旦 dest 被覆盖,整个 flags 全错。
解决:执行函数开头先把id_dest->val存到局部变量,算完结果后再调update_flags(原dest, src, result, is_sub)。这个顺序不能省,我后来把所有算术指令都改成“先取原值 → 算结果 → 更新 flags → 写回”四步模板,再没出过这类问题。
5.5 大端机器读立即数,两字节顺序反了
现象:把vaddr_read(addr, 2)改成逐字节读立即数后,0x1234 被读成 0x3412,程序行为全部错乱。
原因:小端机器上低字节在低地址,我逐字节循环读到第一个字节后直接imm |= b,把它放到了高位;第二个字节又放到了低位,等于按大端重组了。
解决:要么直接用框架封装好的vaddr_read(addr, 2),它内部已经按小端拼好;要么自己拼时写成imm |= (uint32_t)b << (8 * i),i 表示第几个字节。如果你要模拟大端机器,反过来从高位字节开始循环即可,这也是思考题里“遍历读入再反过来重组”的实操版本。
6. 验证方法:单步对拍、断点卡变量、跑回归测试
6.1 单步对拍:让 NEMU 的 eip 和 objdump 输出逐行对上
每次实现完一组指令,我一定是先进 NEMU 单步跑一个短程序,然后对照反汇编检查 eip。流程是先用交叉编译器编一个测试程序,比如一个简单的int main() { return 0; },然后查看它的汇编:
riscv32-linux-gnu-objdump -d /path/to/test > /tmp/test.asm # 版本可能不同,按你的工具链来 ./nemu --batch # 或者 make debug,具体入口看 Makefile接着在 NEMU 里用si单步,每执行一步就用info r看 eip 落在哪个地址,再和/tmp/test.asm里该地址的指令对照。如果 NEMU 的print_asm输出和 objdump 不一致,基本就是译码模板选错或返回字节数不对。这一步能快速暴露 5.3 这类 seq_eip 问题,因为 eip 序列一对上,跳转是否生效立刻可见。
6.2 打印变量和栈帧的回归验证
打印变量是很容易做回归的:先用p打印一个局部变量,再反汇编看它的栈偏移,手动算一遍值对不对。打印栈帧就更有意思,我习惯写一个三层嵌套函数,在里面手动设断点,然后bt,看栈帧深度是不是三层、每层函数名是否和源码一致。
从那以后,我每次接触新框架都强制先画调用链图再动手写指令,哪怕只是在注释里把 exec_wrapper、table、decode 的关系列一遍。这个习惯帮我少走了很多弯路,也希望你在这份资源上能少踩几个我踩过的坑。希望帮到你。
本文还有配套的精品资源,点击获取