简介《PA2(上)_173071301781》是一份面向计算机组成原理、系统能力训练与底层编程学习者的指令系统实现专题资料围绕实验项目中的取指、译码、执行全流程展开兼顾寄存器结构、初始化更新、框架代码阅读和基本指令实现适合正在完成课程实验或希望理解机器执行机制的学生对照学习。压缩包仅含1个PDF文件大小约1.46MB内容覆盖实验概述、框架代码组织、指令细节处理以及打印变量和栈帧等调试任务并对ADD、SUB、MOV、NOP等指令的差异、译码模板选择、全局寄存器访问、符号扩展与条件跳转等实现要点作出梳理。已有1045人学习可作为排查框架阅读难点、补充实验记录和建立从指令格式到执行函数完整链路的参考资料也便于读者结合自身实验进度查漏补缺。1. 拿到一份陌生框架指令系统实验真正卡人的地方不在指令本身第一次打开这套指令系统实验的代码目录多数人的反应是指令表能背代码读不动。取指、译码、执行在教材里三句话讲完落到框架里却是几十个宏定义、几个共用同一块内存的 union 位域、一张符号表和一层模板类交织在一起。这份资源是实验上半部分的完整记录覆盖指令系统阅读、框架代码拆解、基本指令实现以及打印变量和打印栈帧两个调试工具。它适合已经学过机器级表示、准备动手写第一条 movq 的人也适合写完了但一跑就崩、不知道从哪查的人。难点从来不是指令语义而是把陌生代码里的函数引用关系和寄存器更新时机摸清楚。2. 取指、译码、执行三段拆解把一条机器码落到字节和寄存器上在动手写任何一条指令之前得先建立一张地址到字节、字节到字段、字段到动作的映射图。这张图不建立起来写出来的代码只能靠试。这条链路拆成三段看最清楚取指负责把字节搬到手里译码负责把字节解释成操作数执行负责让操作数产生副作用。三段之间不是完全串行的。取指阶段读到的指令字节里往往已经包含了后续译码需要的寄存器编号译码阶段读到的内存操作数又决定了执行阶段要访问哪个地址。框架把每段的边界切得很细好处是模板化程度高坏处是你必须知道当前在哪个阶段、手里有哪些信息是可靠的。2.1 取指一个字节里塞了 icode、ifun 和寄存器编号指令的第一个字节是分派依据。高 4 位是操作码低 4 位在算术指令里表示功能号在栈操作里表示寄存器编号。以常见的算术指令为例0x60 开头的字节低 4 位区分 addq、subq、andq、xorq0x70 开头的是跳转族低 4 位区分 jmp、jle、jl、je、jne、jge、jg 七种条件0x80 开头的是 call 和 ret。框架里的取指函数返回值是本次读入了几个字节。这个返回值很重要它决定了 PC 要往前推多少也决定了后续译码函数从哪里开始读。很多人第一版写错就是把返回值写成了固定值 1结果跳转指令执行完 PC 只前进了一个字节整个程序跑飞。// 从模拟内存读一个字节addr 是虚拟地址 static uint8_t read_byte(uint64_t addr) { if (addr MEM_SIZE) { // 越界直接报错别让它在后面变成玄学 panic(fetch out of bound: 0x%lx, addr); } return guest_mem[addr]; } // 取指返回本次消耗的字节数 static int fetch_instruction(uint64_t pc, uint8_t *icode, uint8_t *ifun) { uint8_t byte0 read_byte(pc); *icode (byte0 4) 0x0F; // 高四位操作码 *ifun byte0 0x0F; // 低四位功能号或寄存器编号 return 1; // 基础指令先按 1 字节算带立即数的在后面补 }read_byte里的越界检查不是防御性编程的洁癖而是排错刚需。内存越界在模拟器里不会触发段错误只会读到一块意料之外的零最后表现为某条指令结果不对排查成本极高。fetch_instruction只负责拆第一个字节带立即数和寄存器字节的指令要在调用方继续往下读这个分层是有意为之因为不同指令的后续字节长度差异很大。提示把当前指令的起始地址单独存一个变量别用全局 PC 代替。全局 PC 在一条指令执行完才更新执行中途读它拿到的是上一条指令的地址这个坑在跳转指令上会集中爆发。2.2 译码模板能替你省掉字节数判断但省不掉语义判断框架里有一层译码模板命名规则基本是操作数类型 指令名i 表示立即数r 表示寄存器m 表示内存。看到irmovq就知道是立即数到寄存器看到rmmovq就知道是寄存器到内存。模板内部会根据操作数类型自动决定读几个字节、读哪几个寄存器你只需要在指令实现里按模板名字调用。这层抽象省掉的是机械工作操作数长度、读取顺序、寄存器编号解析。它省不掉的是语义判断比如 subq 需要根据两个操作数的大小关系更新条件码pushq 需要先把栈指针减 8 再写内存。模板只告诉你操作数是谁不告诉你拿操作数干什么。// 译码结构体把一条指令的操作数一次性收齐 typedef struct { uint8_t reg_a; // 源寄存器编号0xF 表示无 uint8_t reg_b; // 目的寄存器编号 uint64_t imm; // 立即数或偏移量 uint64_t src; // 译码后取出的源操作数值 uint64_t dst; // 译码后取出的目的操作数值 int len; // 本条指令总字节数 } decoded_t; // 例irmovq 的译码流程 static void decode_irmovq(uint64_t pc, decoded_t *d) { d-reg_a 0xF; // 立即数形式源不是寄存器 d-reg_b read_byte(pc 1) 0x0F; // 第二个字节低四位是目的寄存器 d-imm read_u64(pc 2); // 后八个字节是立即数小端序 d-len 10; // 1 1 8 }decode_irmovq里len字段必须准确它同时被 PC 更新和汇编打印使用写错一处两处都错。read_u64要按小端序拼装不能直接对指针做强制类型转换否则换到大端机器上整条指令的立即数会反过来这是实验思考题里点名的坑。指令icode操作数字节总长度是否改条件码addq0x612是subq0x612是irmovq0x31 810否rmmovq0x41 810否pushq0xA12否jmp0x789否call0x889否这张表建议自己手抄一遍贴在显示器边上写实现的时候对照着看比反复翻手册快。2.3 执行先算结果、再写寄存器、最后更新 PC执行阶段最容易出错的不是算术本身而是副作用的顺序。一条指令可能同时修改寄存器、修改内存、修改条件码、修改 PC这四件事的先后顺序在框架里有隐含约定。常见做法是先算出结果再写回寄存器再写内存最后更新 PC。以 subq 为例它要读两个寄存器、做减法、把结果写回目的寄存器、根据结果更新 ZF/SF/OF。顺序错了会怎样如果先更新条件码再写回条件码本身没问题但如果把源寄存器和目的寄存器搞混subq %rax, %rbx算成rbx - rax还是rax - rbx结果和条件码会一起错。框架里的约定通常是目的寄存器在第二个操作数位置。// 条件码更新a - b 的结果 ris_sub 区分减法与加法 static void set_cc(uint64_t a, uint64_t b, uint64_t r, int is_sub) { ZF (r 0); SF (r 63) 1; if (is_sub) { // 减法溢出操作数符号不同且结果符号与被减数不同 OF ((a ^ b) (a ^ r)) 63 1; } else { // 加法溢出操作数符号相同且结果符号与操作数不同 OF ((a ^ r) (b ^ r)) 63 1; } }set_cc用位运算判断溢出比if分支写法短也不容易漏掉边界。a ^ b判断两个操作数符号是否不同a ^ r判断结果与被减数符号是否不同两者同时成立才是减法溢出。加法版本把b ^ r换进来逻辑对称。这个函数建议抽成独立头文件后面所有算术和逻辑指令都包含它避免每写一条指令抄一遍判断。3. 框架代码怎么读从宏定义追到寄存器位域陌生框架的阅读顺序决定了你在这上面耗几个小时还是几天。我的习惯是先找入口再找全局状态最后找宏。入口告诉你程序怎么跑起来全局状态告诉你数据存在哪宏告诉你字段怎么切。这三样找齐剩下的函数都是枝叶。框架里的文件组织大致是寄存器定义单独一个头文件指令执行函数集中在一个文件译码模板在另一个头文件输入输出接口再单独一个。指令实现按类别分文件夹放所有指令最终要注册到一张分派表里。这个结构不难难的是宏定义套宏定义展开之后看不出原貌。3.1 先定位四个关键点取指函数、执行函数、全局寄存器堆、分派表读代码的第一遍不要逐行看只找四个东西。第一个是取指函数它决定了指令字节从哪里来。第二个是指令执行函数它决定了每条指令的入口签名。第三个是全局寄存器堆变量它是所有寄存器读写的唯一通道。第四个是分派表它决定了 icode 到执行函数的映射关系。这四个点找到之后画一张简单的调用关系图入口函数调用取指取指返回 icode分派表根据 icode 找到执行函数执行函数内部调用译码模板译码模板读寄存器堆和内存。这张图不用画得好看能让你在调试时知道现在停在哪一层就够了。// 分派表的典型形态按 icode 索引到处理函数 typedef void (*handler_t)(uint64_t pc, decoded_t *d); static handler_t dispatch_table[16] { [0x0] do_halt, [0x1] do_nop, [0x2] do_rrmovq, [0x3] do_irmovq, [0x4] do_rmmovq, [0x5] do_mrmovq, [0x6] do_alu, // addq/subq/andq/xorq 共用一个入口靠 ifun 再分 [0x7] do_jmp, [0x8] do_call, [0x9] do_ret, [0xA] do_pushq, [0xB] do_popq, };这张表的好处是新增指令只需要在对应位置填函数指针不用改主循环。do_alu这种一个入口处理多条指令的写法很常见因为 addq、subq、andq、xorq 的字节结构完全一样只有 ifun 不同分开写反而重复。填表时注意没有实现的指令要指向一个明确的未实现处理函数让它打印 icode 后停机而不是留空指针等着崩溃。3.2 新增寄存器在 union 里加位域别和旧字段共享存储实验要求新增一个框架里没有的寄存器。做法不复杂但要理解 union 的内存布局。寄存器堆通常用一个 union 组织一部分按名字访问一部分按数组下标访问两套视图共享同一块内存。新增寄存器时如果直接加在第一个 struct 末尾会和数组视图的边界错位正确做法是单独开一个 struct用位域占位把前面的空间空出来。typedef union { struct { uint64_t rax, rcx, rdx, rbx; uint64_t rsp, rbp, rsi, rdi; uint64_t r8, r9, r10, r11, r12, r13, r14; } named; struct { uint64_t _pad[15]; // 前 15 个寄存器占位不参与访问 uint64_t new_reg : 64; // 新增寄存器与前面不共享存储 } ext; uint64_t raw[16]; // 数组视图调试时按编号打印 } reg_file_t;_pad[15]的作用是把偏移推到第 16 个槽位new_reg才落在他该在的位置。位宽写 64 是因为这个寄存器按 64 位使用如果实验只要求 8 位写: 8也能工作但要注意后面的字段会跟着前移。数组视图raw[16]是为了调试方便gdb里直接打印整个数组比逐个字段看快得多。注意新增字段后凡是用sizeof或硬编码偏移做批量读写的地方都要重新检查一遍。位域的对齐规则和编译器实现有关跨平台时不要假设它和uint64_t数组完全等价。3.3 符号表与字符串表变量名变成地址的完整链路打印变量和打印栈帧都依赖符号表。符号表里存的是符号结构数组每个结构里有名字偏移、地址、大小、类型字符串表里存的是一堆以零结尾的字符串名字偏移指向字符串表里的位置。两表配合才能把变量名翻译成内存地址加长度。遍历逻辑很直接对每个符号用名字偏移去字符串表取字符串和用户输入的变量名比较相等就取出地址和大小。这里有两个容易忽略的点。第一取名字必须用字符串表的基址加偏移不能直接把偏移当指针。第二符号表里可能有同名的弱符号和强符号要按类型过滤只取有分配空间的那个。// 按变量名查符号表找到返回 1并把地址和大小写回出参 static int lookup_symbol(const char *name, uint64_t *addr, uint64_t *size) { for (int i 0; i symtab_count; i) { const char *sym_name strtab symtab[i].st_name; // 基址加偏移 if (strcmp(sym_name, name) ! 0) continue; if (symtab[i].st_shndx SHN_UNDEF) continue; // 未定义符号跳过 if (symtab[i].st_size 0) continue; // 形参不占空间 *addr symtab[i].st_value; *size symtab[i].st_size; return 1; } return 0; }st_shndx SHN_UNDEF过滤的是引用但未定义的符号st_size 0过滤的是形参和弱符号。这两条过滤不做打印变量时会取到一个地址为零或长度为零的符号输出一堆空值看起来像变量没赋值实际是取错了符号。调试时可以先把整张符号表打印一遍确认目标变量确实在里面、大小正确再往下查取值逻辑。4. 手写一批指令movq、pushq、jmp 的实现与边界指令实现的工作量集中在三类数据搬移、栈操作、控制转移。这三类覆盖了实验要求的绝大部分指令也覆盖了最容易翻车的边界。写之前先把每类指令的读什么、算什么、写什么、改什么四件事列清楚比对着手册边看边写效率高。框架已经把译码模板准备好了实现一条指令通常只有十几行。真正花时间的是确认边界立即数有没有符号扩展、栈指针是先减还是后减、跳转偏移是相对当前指令还是相对下一条指令。这三件事每件都能让程序在某个特定输入下悄悄出错。4.1 数据搬移与算术写回目的寄存器条件码只跟算术走movq 族有四种形态立即数到寄存器、寄存器到寄存器、寄存器到内存、内存到寄存器。它们的共同点是都不修改条件码区别只在操作数来源和去向。写的时候把四种形态分别实现别想着用一个函数通吃通吃版本里的分支判断会让调试信息变得很难读。算术指令 addq、subq、andq、xorq 结构一致读两个寄存器、做运算、写回第二个寄存器、更新条件码。andq 和 xorq 的溢出标志固定置零因为按位运算不产生算术溢出这一点手册里写了但实现时容易顺手抄成 addq 的版本。// rrmovq寄存器到寄存器不改条件码 static void do_rrmovq(uint64_t pc, decoded_t *d) { uint64_t val regFile.raw[d-reg_a]; regFile.raw[d-reg_b] val; update_pc(pc d-len); } // subq减法并更新条件码结果写回第二个操作数 static void do_subq(uint64_t pc, decoded_t *d) { uint64_t a regFile.raw[d-reg_a]; // 被减数在前 uint64_t b regFile.raw[d-reg_b]; uint64_t r b - a; // 目的寄存器减源寄存器 regFile.raw[d-reg_b] r; set_cc(b, a, r, 1); // 注意传参顺序与被减数一致 update_pc(pc d-len); }do_subq里的传参顺序是最容易错的地方。set_cc(b, a, r, 1)的第一个参数必须是被减数第二个是减数顺序反了溢出判断整个失效。update_pc用pc d-len而不是全局 PC 加一这是为了兼容不同长度的指令。写完一条算术指令后立刻用一条正数减负数、一条负数减正数的用例验证 OF别等到最后一起测。4.2 栈操作先动指针再写内存pop 的顺序反过来pushq 的语义是把寄存器值压入栈栈向低地址增长。顺序必须是先把栈指针减 8再把值写到新的栈指针位置。反过来写会先覆盖掉栈顶已有的数据如果栈顶正好是返回地址程序直接跑飞而且崩溃点离出错点很远。popq 的顺序相反先读栈指针位置的值再把栈指针加 8。读和加的先后同样不能反。// pushq先把 rsp 减 8再写内存 static void do_pushq(uint64_t pc, decoded_t *d) { uint64_t val regFile.named.rsp; // 待压入的值 regFile.named.rsp - 8; // 先动指针 write_u64(regFile.named.rsp, val); // 再写内存 update_pc(pc d-len); } // popq先读内存再动指针 static void do_popq(uint64_t pc, decoded_t *d) { uint64_t val read_u64(regFile.named.rsp); regFile.named.rsp 8; regFile.raw[d-reg_b] val; update_pc(pc d-len); }pushq里待压入的寄存器编号来自指令字节的低四位不是固定值。第一版实现时我直接写死成 rsp结果只有压栈 rsp 的时候是对的压其他寄存器全是错的。write_u64和read_u64要按小端序实现和取指阶段的读法保持一致否则栈上数据的字节序会和程序预期相反。4.3 跳转偏移量要做有符号单字节或双字节扩展跳转指令的偏移量是相对地址相对于当前指令还是下一条指令手册里有明确说明。实现时先把目标地址算对再考虑条件判断。无条件跳转 jmp 最简单条件跳转要在算地址之前先查条件码。偏移量的符号扩展是必踩的坑。单字节偏移取值范围是 -128 到 127如果不做符号扩展负数偏移会被当成 128 到 255 的正数向后跳变成向前跳循环直接死掉。C 语言里把int8_t赋给int64_t会自动符号扩展但如果你用uint8_t接中间值再转扩展就丢了。// jmp读取 8 字节偏移相对当前指令跳转 static void do_jmp(uint64_t pc, decoded_t *d) { int64_t off (int64_t)read_u64(pc 1); // 偏移量按有符号处理 update_pc(pc d-len off); // 相对当前指令 } // 条件跳转先查条件码条件不成立就顺序执行 static void do_jne(uint64_t pc, decoded_t *d) { int64_t off (int64_t)read_u64(pc 1); if (!ZF) { update_pc(pc d-len off); } else { update_pc(pc d-len); } }这里update_pc传的是绝对地址不是增量。如果框架的update_pc语义是设置 PC传绝对地址没问题如果是PC 加上参数就要改成传off。写之前先看一眼框架里这个函数的定义别凭函数名猜语义。条件跳转的 7 种条件对应 ZF、SF、OF 的组合建议列一张真值表贴在代码旁边写完逐个对照。5. 避坑与排查:五条用调试器换来的记录这一章的五条都是从程序能编译、跑起来结果不对的状态里爬出来的。共同特征是崩溃点离出错点很远靠读代码找不到只能靠断点和内存打印定位。每条按现象、原因、解决三段写照着对照能省掉不少时间。5.1 跳转目标总是偏一条指令PC 取了上一轮的旧值现象循环执行次数比预期少一次或多一次断点打在跳转指令上看到的目标地址总是差一条指令的长度。原因跳转计算里用了全局 PC 变量而这个变量在一条指令执行完之后才更新。执行到跳转指令时全局 PC 里存的还是上一条指令的起始地址拿它加偏移自然错位。这个变量看起来是当前 PC实际语义是上一条指令的 PC。解决在执行函数入口把当前指令地址作为参数传进去所有地址计算只用这个参数不碰全局变量。改完之后跳转立刻正确。推而广之凡是执行过程中需要知道自己在哪的地方都不能依赖执行完才更新的状态。5.2 向后跳变成向前跳单字节偏移没做符号扩展现象一个从后往前的循环跑成了死循环或者跳转到了完全无关的地址反汇编看偏移量字节是 0xF6 之类的值。原因偏移量用uint8_t读出来再直接加到地址上。0xF6 被当成 246实际应该是 -10。C 语言的整型提升规则在这里不帮忙因为中间变量是无符号的。解决读偏移量的函数返回int8_t或int64_t赋值时显式转换。如果框架的读字节函数返回uint8_t在调用处强转成int8_t再扩展。改完后用手算一条负偏移的指令验证别只看正偏移的用例。5.3 压栈把返回地址覆盖先写内存后动指针现象call 指令执行后被调用函数还没返回就崩了栈上返回地址的位置是一堆奇怪的值。原因pushq 实现成了先写内存再减栈指针。写的时候栈指针还指向原来的栈顶正好把上一次 call 压入的返回地址覆盖掉。崩溃发生在 ret 指令上离 pushq 隔了很远。解决严格按先减指针、再写内存的顺序改。改完后用一条 call 加一条 ret 的最小用例验证断点打在 ret 上看读出的返回地址是否等于 call 的下一条指令地址。5.4 新增寄存器改了值旧寄存器跟着变union 里字段重叠现象往新增寄存器写一个值打印出来发现另一个不相关的寄存器也变了或者数组视图里的某个下标值跟着变。原因新增字段直接加在 union 的第一个 struct 末尾和数组视图的槽位重叠。位域的对齐规则让它的实际偏移和预期不一致两个视图指向了同一块内存。解决单独开一个 struct用占位数组把偏移推到目标位置再声明新增字段。改完后写一个小程序给每个寄存器赋不同值按名字和按下标各打印一遍确认两套视图一致。5.5 汇编打印和执行对不上断点打在了译码后的地址现象汇编输出显示某条指令在地址 0x100断点打在 0x100 却停在了另一条指令上或者根本没停。原因汇编打印用的是译码后的指令长度累加出来的地址执行用的是另一套 PC 更新逻辑两边对同一条指令的长度认定不一致。常见于带立即数的指令打印按 9 字节算执行按 10 字节算。解决把指令长度做成唯一来源译码结构体里的len字段同时供打印和 PC 更新使用任何一方不得自己算。改完后打印完整汇编和执行时的 PC 序列逐条对比长度不一致的位置就是问题点。6. 打印变量与栈帧把黑匣子打开的两个工具调试器能看寄存器但看不懂这个地址对应哪个变量。打印变量和打印栈帧这两个功能本质是把符号表信息和运行时状态接起来让输出从0x7ffd1234变成变量 x 42位于函数 foo。实现打印变量只需要一条链路用户输入变量名遍历符号表用名字偏移去字符串表取字符串比对命中后取出地址和大小按类型读内存并格式化。类型判断看符号的类型字段整型按字节数读成对应宽度指针加前缀。找不到变量时明确打印未找到别静默返回否则会误以为变量值为空。// 打印变量查符号表 - 读内存 - 按大小格式化 void print_var(const char *name) { uint64_t addr, size; if (!lookup_symbol(name, addr, size)) { printf(variable %s not found\n, name); return; } if (size 4) { printf(%s %d\n, name, read_u32(addr)); } else if (size 8) { printf(%s %ld\n, name, (int64_t)read_u64(addr)); } else { printf(%s at 0x%lx, size %lu\n, name, addr, size); } }打印栈帧的链路更长一点先根据当前 PC 在符号表里找覆盖该地址的函数符号取函数名再从栈帧指针指向的位置读出上一层的帧指针和返回地址循环往上走。每一层都打印函数名和返回地址直到帧指针为零或超出栈范围。检查项正常表现异常时的排查方向当前函数名与断点所在函数一致符号表地址范围是否包含 PC返回地址落在可执行段内帧指针偏移是否用了 8 字节上一层帧指针单调向高地址增长是否把栈指针当成了帧指针回溯层数有限且能到入口函数是否缺少零值终止条件两个工具都实现之后验证方法很直接写一个带局部变量和两层函数调用的测试程序在里层函数打断点打印变量应该看到正确值打印栈帧应该看到三层函数名。对不上就回到符号表把整张表打印出来逐条核对地址和名字。从那以后我每次改完条件码更新或者栈指针操作都会先跑一遍打印变量加打印栈帧的最小用例确认状态没被改坏再往下写新指令。这个习惯救过我好几次尤其是在连续加了几条指令之后肉眼根本看不出哪一步把寄存器写脏了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?