首页 / 资讯中心 / 文章详情

从取指译码到栈帧回溯:指令系统模拟器实验避坑指南

从取指译码到栈帧回溯:指令系统模拟器实验避坑指南 ★ FEATURED ARTICLE
简介面向计算机组成与系统结构课程实验的指令系统实现详解PDF适合正在完成PA2上机、需要理解取指—译码—执行全流程及框架代码组织的学习者。内容围绕寄存器初始化与更新、基本指令实现和指令细节处理展开对取指阶段读入字节数、译码阶段模板匹配、执行阶段完成算术运算、移动、空操作、条件跳转与压栈等操作给出过程性说明并涉及立即数/有符号扩展、标志位更新以及打印变量、打印栈帧等调试环节。资源包共1个PDF文件约1.46MB篇幅集中便于按实验阶段查阅。已有1047人学习可作为梳理宏定义与函数引用关系、理解译码模板选择、定位指令实现差异和整理排错思路的参考帮助读者把抽象的机器执行过程落到可操作的代码实现中。1. 三条指令的模拟器跑起来那晚我才算真进了指令系统的门第一次让那个只能执行三条指令的模拟器跑通我盯着屏幕上打印出来的eip 0x100014愣了半分钟。取指、译码、执行这三个词我在课本上背了不下一百遍可真正让它们在一个自己写出来的循环里转起来是另一回事。这篇要讲的是我在一个指令系统实现实验里走过的一条完整路径怎么从一份几万行的陌生框架代码里找到入口怎么把 mov、add、push、jmp、call 这些基本指令一条条落到代码里怎么让调试器能打印变量和栈帧以及那些看着无害、实际能让人返工一整天的小细节。你如果正卡在取指译码执行中的某一步或者拿到框架代码不知道该从哪个文件看起接下来的内容应该能帮你省掉两天摸索。2. 陌生框架先别急着写把取指、译码、执行三条链路摸清楚我拿到框架代码的第一反应是慌因为打开目录看到的是几十个头文件、一堆宏、若干层嵌套的模板。后来发现读这类代码有一条相对固定的顺序先找程序从哪进入、循环体在哪、每条指令执行前后各发生了什么再回头看宏定义展开成什么样最后才去关心寄存器、译码模板、指令实现这些模块各自负责什么。顺序反了就会陷入每个文件都看懂了一点但不知道它们在什么时候被调用的状态。2.1 从入口倒推一条指令在框架里到底走了几步我一般会先找主函数再顺着往下看那个反复调用的执行函数。它在框架里的典型形态是一个exec_once进入时先把当前的eip存一份快照然后取指拿到操作码交给译码器解析出操作数最后查表调用对应的执行函数执行结束后统一推进eip、更新指令计数。这个顺序看着简单但里面藏着两个关键约定后面写跳转指令时会直接撞上。第一个约定是eip到底由谁推进。有的实现是取指时就把eip加掉有的是执行完统一加。我在读代码时先拿一条最短的指令比如 NOP单步跑了一遍在取指前后各打一次eip立刻就能确认是哪一种。第二个约定是快照的存在框架为了让反汇编日志和单步调试能对齐会把进入本条指令时的eip单独存一份供打印和异常处理用。这份快照在整条指令执行过程中是不变的而跳转类指令要改的是实时的eip。我当初没注意这点把jmp的偏移加到了快照上程序日志里显示跳转成功实际执行流一动不动这个坑后面会单独讲。// 按我自己实验时的框架改写函数名做了简化 static void exec_once() { cpu.eip_snapshot cpu.eip; // 快照只在打印/异常时用别拿来算跳转 uint8_t op mem_read(cpu.eip, 1); // 取指先只读一个字节 Decode *d decode(op); // 译码填操作数类型、宽度、反汇编文本 d-exec(d); // 执行真正改寄存器/内存的地方 cpu.inst_count; // 计数推进供后续调试对比 }读到这里先别急着往下翻建议把这条链路在纸上画成三行取指读几个字节、译码填哪些字段、执行改哪些状态。后面每实现一条新指令都可以拿这三行对照判断自己写的东西有没有漏掉某一环。2.2 宏定义摊平了看opcode 到字段的拆分规则框架里最劝退的是宏。它们大多在做一件事把指令字节拆成字段。这个实验接触到的都是单字节操作码没有两字节、三字节的扩展编码所以拆分规则相对简单——高位若干位是主操作码低位若干位是寄存器编号或者寄存器/内存指示位。宏只是让这件事写起来短一点。#define OPCODE(inst) ((inst) 2) // 高 6 位主操作码 #define REG_FIELD(i) (((i) 0) 0x3) // 低 2 位参与运算的寄存器编号 #define RM_FIELD(i) ((i) 0x7) // 低 3 位寄存器/内存操作数编号这段是我按实验里的编码习惯写的示意具体位移量要对着自己框架里的定义核对。理解方式比记住数字重要给定一条指令字节你只要知道它被切成几段、每段是什么含义剩下的事情就是查表。我当时的做法是把所有宏在编译预处理后的展开结果打出来看了一遍gcc -E就够把十几个看着莫名其妙的宏还原成位运算之后再看代码就顺了。这一步花了我将近两个小时但后面省下的时间远不止两小时。提示先在一条已知指令上验证宏的拆分结果再去套别的指令。拿 add 的机器码手工拆一遍和你用宏算出来的字段对不上说明你套错了宏或者记错了位移。2.3 四个模块的职责边界寄存器、译码模板、执行函数、内存接口框架里真正需要你动的模块其实就四块把边界认清楚写指令就是填空题。寄存器模块负责定义所有寄存器的存储方式和访问方式。我在里面新增了一个标志位寄存器做法是照着已有寄存器的写法加了一个 32 位整型成员和对应的读写入口。这里有个取舍已有的通用寄存器用位域组织几个挤在一个字节里看着省空间但我新增的那个如果也硬塞进位域会影响同一字节里其他寄存器的读取接线的人一不小心就读到被污染的值。考虑再三我让它单独占一个 32 位成员直接用下标访问牺牲一点优雅换掉一类隐蔽 bug。后来回看这个决定是对的——实验里没人在意那四个字节。译码模板模块是框架给的最大便利。模板按操作数类型命名比如立即数寄存器寄存器内存单字节立即数各自对应一个模板。选定模板之后读几个字节、读成什么类型、反汇编怎么打印框架都替你处理了。模板的名字要跟指令匹配因为模板一旦确定反汇编输出的形态也跟着确定。我当时吃过一次亏给一条只带单字节偏移的跳转指令套了双字的模板程序跑起来反汇编显示正常实际偏移读成了四个字节里的一堆垃圾。执行函数模块里放的是所有指令的实现以及一批公用的小工具比如更新标志位、读写操作数、做符号扩展。公用工具写一次就够我后来把更新各类标志位的函数都收在一个头文件里哪条指令需要就包含进来直接传结果值进去。内存与输入接口模块提供按长度读写的入口本质就是对内存按字节或按字访问再按目标类型变化。写指令时读写内存不要自己拼指针统一走这个接口后面调试打印内存内容的时候才不会出现我写进去的和读出来的不是一块地方这种灵异现象。3. 基本指令的落地从译码模板到标志位更新的完整路径知道链路之后写指令就变成一件很机械的事查这条指令的操作码落在哪个区间决定它该套哪个模板把伪代码里的运算翻成 C 语言最后确认它该更新哪些状态。这一章按指令类别拆开讲每一类都给出我实际写过的代码形状和几个必须注意的细节。3.1 译码模板怎么选一张单字节操作码的对照表模板选择的核心判断是这条指令后面跟的是立即数、寄存器还是寄存器/内存二选一。下面这张表是我在实验过程中整理出来的常用对照覆盖了这次要实现的绝大部分指令。表里的模板一列写的是操作数形态具体模板名对着自己框架里的命名套不同框架叫法不一样。操作码指令形态操作数模板立即数宽度0x88 / 0x89mov 目的, 源寄存器/内存, 寄存器无0xB8 寄存器号mov 寄存器, 立即数寄存器, 立即数4 字节0x01add 目的, 源寄存器/内存, 寄存器无0x29sub 目的, 源寄存器/内存, 寄存器无0x39cmp 左, 右寄存器/内存, 寄存器无0x85test 左, 右寄存器/内存, 寄存器无0x50 寄存器号push 寄存器单寄存器无0x58 寄存器号pop 寄存器单寄存器无0xE9jmp 相对偏移立即数32 位4 字节0xE8call 相对偏移立即数32 位4 字节0xC3ret无操作数无两类带寄存器编号的指令要特别注意0xB8、0x50、0x58 这种操作码本身就编码了寄存器号后面不再跟寄存器字段。我刚写 push 的时候顺手套了双操作数模板结果它多读了四个字节把下一条指令吃掉了程序从下一条开始全部错位。判断办法很简单这条操作码加寄存器号之后后面还有没有字节没有就是单操作数形态。3.2 mov、add、sub结果写回与标志位联动的写法mov 是最先应该写的因为它没有副作用跑通了就说明取指、译码、写回这条最小链路是通的。它的实现只有三步取目的操作数的地址和值取源操作数的值把源值写到目的地址。注意目的可能是寄存器也可能是内存框架的模板已经把这件事抽象成取地址和取当前值两个动作照着调就行。add 和 sub 多了一步写回之后要更新标志位。这里的顺序不能乱必须先把运算结果算出来再拿结果更新标志位最后写回内存或寄存器。我一开始把写回放在更新标志位之前有个别指令在写回时顺手改了目的寄存器的值导致后面算进位时读到的是新值进位标志时对时错。表现是某些用例通过、某些不通过排查了很久。// add 的核心结构按我实验时的写法简化 static void do_add(Decode *d) { uint32_t dst_addr operand_addr(d, 0); // 目的操作数的地址寄存器号或内存地址 uint32_t lhs operand_read(d, 0); // 目的当前值 uint32_t rhs operand_read(d, 1); // 源值 uint32_t res lhs rhs; update_flags_add(lhs, rhs, res); // 先用结果更新标志位 operand_write(d, 0, res); // 最后写回 }sub 的形状一模一样只是运算换成减法标志位用减法的一套规则借位是减法的进位符号位看结果的最高位。cmp 更简单算完之后什么都不写回只更新标志位相当于sub 但不保存结果。我当初把 cmp 写成了sub 完也写回去程序运行结果看起来对但一进到有比较循环的用例里就死循环因为循环变量被意外改掉了。这个错误很典型看到比较两个字要条件反射地想起只改标志位不改操作数。3.3 push、pop、jmp、call、ret地址计算和符号扩展是两处硬骨头栈操作的关键是顺序。push 的顺序是先把栈指针减四再把寄存器值写进新的栈顶两者颠倒就会覆盖掉上一份数据。pop 反过来先从栈顶读值写进寄存器再把栈指针加四。听起来是废话但写的时候很容易顺手写反尤其是你用了一个先读参数再执行的模板读到的一半是旧栈顶的内容。寄存器编号的算法是操作码减去基址比如 push 系列基址是 0x50操作码 0x53 就是压入 3 号寄存器。这个减法别写死用一个宏或者常量表示基址后面补指令的时候不容易错。jmp 和 call 是整章最容易翻车的地方翻车点集中在两处一是前面说的快照问题二是偏移的符号扩展。单字节偏移是一个有符号数正向最多跳 127 字节反向最多跳 128 字节。我在读偏移时用了无符号字节负偏移变成了两百多的正数程序直接跳到内存的另一头报出来的错误跟跳转毫无关系我按那个错误查了半天才发现是这里。static void do_jmp(Decode *d) { int8_t rel (int8_t)operand_read(d, 0); // 单字节偏移必须按有符号读 cpu.eip cpu.eip rel; // 改实时 eip不是快照 } static void do_call(Decode *d) { int8_t rel (int8_t)operand_read(d, 0); cpu.esp - 4; // 压入返回地址 mem_write(cpu.esp, 4, cpu.eip); // 此刻 eip 已指向下一条指令正是返回地址 cpu.eip cpu.eip rel; // 再跳转 } static void do_ret(Decode *d) { uint32_t ret_addr mem_read(cpu.esp, 4); // 先取返回地址 cpu.esp 4; // 再抬栈 cpu.eip ret_addr; // 最后跳回去 }call 的顺序尤其要想清楚框架在取指时已经把eip推到了下一条指令的位置所以压栈的eip天然就是调用返回后该执行的那条指令地址不需要额外加偏移。ret 则必须严格按读、抬栈、跳三步走任何一步提前都会读到脏数据。这三个函数我写完各单步跑了十几遍就是为了把顺序刻进肌肉记忆里。3.4 标志位三条规则奇偶、零、符号各管一摊实验里要求更新三类标志位规则都很短但每条都有个容易忽略的细节。奇偶标志看的是结果低八位里 1 的个数偶数个 1 就置位。实现方式就是循环移位统计别去查表一个 while 循环足够。static void update_pf(uint32_t res) { uint8_t low res 0xFF; int ones 0; while (low) { ones (low 1); low 1; } // 统计低八位中 1 的个数 set_flag(FLAG_PF, (ones % 2) 0); // 偶数个 1 置 1 }零标志最简单结果等于零就置位。符号标志看结果的最高位这里是 32 位运算就看第 31 位。我在符号标志上踩过一个很浅的坑一开始用res 0判断对有符号数没问题但我手里的结果都是用无符号类型存的这个判断永远为假。改成(res 31) 1之后就对了。写标志位相关的代码时脑子里要时刻清楚这个值在内存里是位模式而不是数学意义上的数。还有两个标志位这次实验没有严格要求我的处理是简单置零保证不留下脏值影响后续比较。这种实验没要求但会留坑的地方宁可多写一行赋值也别指望它默认是干净的。4. 打印变量与栈帧符号表和 EBP 链怎么用起来指令跑通之后实验还要求让调试器支持两个功能按变量名打印变量的值以及在程序停在某个位置时打印出完整的函数调用栈。这两件事看起来玄乎本质都是读 ELF 文件里的符号表再加上一点地址推算。做完这两个功能我对程序运行时到底长什么样的理解明显上了一层。4.1 符号表怎么读变量名到内存地址的映射符号表的结构里最关键的三个字段是名字偏移、值、大小和类型信息。名字偏移是相对于字符串表的偏移量拿它去字符串表里一取就能拿到真正的符号名字值是符号的地址对函数和全局变量而言类型信息告诉你这个符号是函数还是数据对象。常见的做法是先把符号表整个遍历一遍把所有符号的名字和地址打印出来看。这一步很有必要因为你会发现有些符号值是 0有些符号大小是 0有些名字下面根本没有地址空间。看完这一遍再动手写查找逻辑就不会被各种边界情况打懵。// 按变量名查找地址遍历符号表用字符串表取名字对比 bool find_symbol(const char *target, uint32_t *addr, uint32_t *size) { for (int i 0; i symtab_count; i) { Elf32_Sym *sym symtab[i]; const char *name strtab sym-st_name; // st_name 是字符串表内的偏移 if (strcmp(name, target) ! 0) continue; if (ELF32_ST_TYPE(sym-st_info) ! STT_OBJECT) continue; // 只认数据对象 if (sym-st_value 0 || sym-st_size 0) continue; // 跳过无效符号 *addr sym-st_value; *size sym-st_size; return true; } return false; }这里的类型过滤不是可选项。我第一次写的时候只比名字结果查一个函数名也返回了成功然后按变量去读它的内存读到的地址是 0程序直接崩在原地。加了两道过滤类型必须是数据对象、地址和大小不能为零之后就干净了。取到地址之后还要根据变量类型决定读几个字节、按什么格式打印。实验里内置了识别常见变量类型的规则照着已有的分支加就行。我加了一个标志位变量用于判断查找是否真的成功没找到时打印明确的未找到而不是静默返回零值这个习惯后来救我很多次——调试时最怕的不是报错是悄悄给了一个错误答案。4.2 栈帧回溯从当前地址反查函数名和返回地址栈帧打印的原理比想象中简单当前执行地址落在哪个函数的地址区间内当前就在哪个函数里上一层的栈帧基址存在当前栈帧基址指向的位置当前基址加四的地方存着返回地址。顺着这条链往上走就能把调用栈一层层打出来。先写按地址找函数名。遍历符号表找出类型是函数、且地址区间包含目标地址的符号。注意函数的地址区间是起始地址加上大小不要只比较起始地址否则一个函数里的所有位置都会被算成相邻的那个函数。// 栈帧回溯底指针链 函数地址区间反查 void print_backtrace() { uint32_t pc cpu.eip; uint32_t ebp cpu.ebp; int depth 0; printf(#%d %s (0x%08x)\n, depth, func_name_of(pc), pc); while (ebp ! 0 depth 16) { // 加上深度上限防止脏数据导致死循环 uint32_t ret_addr mem_read(ebp 4, 4); // 返回地址在基址上方四个字节 if (ret_addr 0) break; printf(#%d %s (0x%08x)\n, depth, func_name_of(ret_addr), ret_addr); ebp mem_read(ebp, 4); // 上一层基址存在当前基址指向处 } }两个必须加的防御深度上限和基址为零的判断。我第一版没有这两个程序在某个用例上无限打印输出文件涨到几百兆才发现。原因是回溯到最外层之后那个基址位置存的是别的数据被当成了新基址一路读下去永远不会结束。还有一个边界要在做的时候就认清函数符号表里记录的是入口地址不是栈帧信息。所以从栈帧回溯出来的函数名本质上是根据返回地址猜的调用者中间有内联函数或者被优化掉的帧时打出来的结果会和源码层级对不上。实验里不加优化编译问题不明显但你得知道这个前提存在。5. 五个返工现场指令系统实验里最容易翻车的地方这一章我按现象、原因、解决的格式整理了五条实际踩过的坑。它们中的大多数都不是逻辑错误而是理解偏差——代码看起来完全符合课本上那句话但落到这个具体的框架里就不成立。如果你正在做同类实验这五条大概率能提前帮你避掉。5.1 跳转指令执行了但没跳快照和实时值混用现象给跳转类指令加上偏移之后反汇编日志里明明显示目标地址变了程序却继续顺着往下执行看起来像跳转被吞掉了。加打印之后发现偏移确实算对了只是下一次取指还是从老地方开始的。原因框架在每条指令执行前把当时的eip存了一份快照用于日志打印和异常处理。我写跳转时顺手用了这个快照去加偏移改的是一份副本实时的eip没动。更迷惑的是日志打印时用的也是快照所以打印出来的下一条地址看起来是对的实际执行流完全没变。解决把所有需要修改指令流向的地方单独挑出来统一改成写实时的eip快照只读不写。改完之后我在跳转函数里加了一行断言检查跳转后的地址是否落在已知代码段内越界立刻报错。这个断言后面又帮我抓到一次符号扩展写错导致的野跳。5.2 负偏移变成大正数单字节立即数忘了符号扩展现象一个向回跳的循环跳到一半之后程序跑飞报出来的错误是一堆和跳转毫无关系的读内存失败。用调试器看跳转前后的地址发现目标地址从低地址突然变成了高地址。原因偏移字段只有一个字节我按无符号字节读出来本该是负数的偏移比如 -20读成了 236加上去之后直接跳到代码段外面。单字节偏移必须按有符号解释这是指令编码本身的规定和你的程序语言无关。解决读单字节偏移时强制转成有符号字节类型再参与地址运算。为了保险我把它封装成一个函数所有跳转类指令统一调用不再各写各的。封装之后这类错误一次都没再犯因为想写错都没地方写。5.3 新增寄存器污染了邻近寄存器位域不是随便塞的现象新增了一个标志位寄存器在它的更新函数里写值之后另一个完全无关的寄存器值跟着变了导致后续比较结果时对时错像是玄学。原因已有的寄存器是按位域组织在同一个字节里的好几个寄存器共享一个字节的不同位。我照着这个风格把自己的新寄存器也塞进了同一个字节位宽和偏移没算对覆盖掉了相邻位。这类问题的恶心之处在于它不报错只在你恰好同时用到两个寄存器时才发作。解决把新增寄存器从位域里拿出来单独占一个完整的 32 位成员用数组下标访问。牺牲的只是几个字节的内存换来的是所有读写互不干扰。回头想实验里那点内存根本不值得为它冒这个风险凡是要么省空间要么省心二选一的场合先选省心。5.4 打印变量读到了非法地址符号表没做类型过滤现象调试器执行打印某变量时某些名字能打某些名字一打程序就崩。崩溃位置全都在读内存那一行。原因符号表里除了数据对象还有函数符号有些函数符号的值为零或者指向不可读的区域。我最初只比对名字没管符号类型查到一个同名函数就拿着零地址去读直接崩溃。还有一种情况是形参形参在编译期不分配空间符号表里根本不会有它的地址记录查不到是正常的。解决查找时加两道过滤类型必须是数据对象地址和大小不能为零查不到时明确报未找到符号而不是返回零值当成地址用。顺带把形参这种情况想明白了后面再有人问为什么打印形参打不出来我第一反应就是这句形参既不是强符号也不是弱符号编译期不占据空间自然不在符号表里留下一份带地址的记录。5.5 用户程序从入口函数一返回就崩返回地址变成了垃圾现象写了一个最简单的用户程序正常执行完最后一条指令然后整个模拟器卡死或者跳到一片空白区域执行乱码。原因程序入口不是主函数而是运行时启动的那一小段代码它负责准备好环境再去调用主函数。主函数返回之后控制权回到启动代码而启动代码后面已经没有指令了。这时候如果再来一条返回指令它从栈上弹出的是无效数据跳过去就是未知区域。解决理解清楚程序没有自然的退出路径这件事之后在实现返回类指令时不需要为这个场景做特殊处理但调试用户程序时心里要有数主函数之后必须显式调用退出不能让它自然返回。想通这一层我才算真正理解了入口和主函数的关系而不只是背下程序从某个指定入口开始执行这句话。6. 写完一条指令先手推一遍字节一个能省掉大半返工的笨办法如果只让我留下一件在指令系统实验里最管用的习惯那就是这个写任何一条新指令之前先拿纸把它的机器码手工拆一遍把操作码、寄存器字段、立即数宽度、符号位一个一个写出来再对照译码模板核对最后才动键盘。听起来很笨但它把调试时发现问题提前到了写代码前发现问题而这两者的代价差着十倍。具体怎么做从反汇编或调试器里抄一条真实存在的指令字节比如一条带单字节偏移的跳转写下来是几个字节、哪个字节是操作码、哪个字节是偏移然后把你准备用的模板在心里跑一遍看它读几个字节、读成什么类型、反汇编会打印成什么。三处能对上再开始写。我当初写 call 的时候就是靠这一步发现自己准备用双字节模板而实际编码里偏移只有一字节避免了一次必然发生的错位。指令写完之后还有第二道关单独验证。不要一上来就跑完整程序而是构造一个只包含这一条指令加一条退出指令的最小片段在取指前后各打一次eip、在执行前后各打一次目标寄存器和标志位对照手推的预期看。这个方法最大的价值是失败时的定位速度完整程序出错可能是十条指令里任意一条的问题最小片段出错问题就在你眼前这一条。我后面几条指令全是这么过的最长的一条也只花了二十分钟。回过头看这个实验真正难的从来不是指令本身的实现那部分把伪代码翻成 C 语言就完了。难的是读懂别人的框架、想清楚每个状态在什么时刻被谁改写、以及在没有报错的地方自己给自己造检查点。从那以后我每次接手一份陌生代码都会先强制走一遍入口、循环、执行、状态这四步先手推一个最小用例再开始改。这个习惯在后面的项目里救过我很多次也让我少了很多明明逻辑对但结果不对的抓狂时刻。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站