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

8086机器语言解码实战:手写指令解码器与反汇编入门

8086机器语言解码实战:手写指令解码器与反汇编入门 ★ FEATURED ARTICLE
简介这份笔记面向编写8086汇编器、需要理解机器指令编码细节的开发者系统整理了8086机器语言解码的核心知识。内容涵盖指令格式、寄存器编号、寻址模式、操作码、立即数以及字节/字/双字等基本概念并重点剖析固定编码指令与双操作数指令的编码规则。笔记以MOV指令为主线逐条给出汇编语句与对应机器码字节的对照如mov word [bxsi0x1BCD],0x1234对应0c7h、80h、0xcd、0x1b、0x34、0x12还涉及段寄存器传送、特定地址内存访问及最长8字节指令的构成分析。资源包为1个docx文档约1.94MB结构紧凑适合作为手边速查手册。目前已有127人学习可供汇编器实现者对照验证编码逻辑、排查字节序与寻址组合问题。1. 8086 机器语言解码从一串十六进制到一条能跑的指令很多人第一次看到B8 34 12这种十六进制串第一反应是「这不就是内存里的一堆字节吗」直到把它丢进反汇编器屏幕上跳出MOV AX, 0x1234才意识到这背后有一套极其规整的编码规则。8086 机器语言解码这件事说白了就是把这串字节还原成 CPU 真正执行的动作操作码在哪几位、寄存器编号藏在哪个 bit、立即数是大端还是小端、ModR/M 字节怎么拆出寻址方式。它解决的是「看得懂机器码」这个最底层的问题适合正在学汇编、写模拟器、做逆向入门或者调试裸机程序的人。我当年啃这块内容时最大的感受是指令表背得再熟不如自己手写一个解码器把每条指令的字节结构拆一遍那些「玄学」的编码规律才会变成肌肉记忆。2. 8086 指令编码的骨架操作码、ModR/M 与位移量怎么排布8086 的指令长度是 1 到 6 字节不等这个可变长度设计是后面所有解码逻辑的根源。一条指令通常由这几部分组成前缀可选、操作码1 到 2 字节、ModR/M 字节可选、SIB 字节8086 没有那是 32 位才引入的、位移量0/1/2 字节、立即数0/1/2 字节。解码的核心工作就是按顺序把这些字段切出来而切分规则完全依赖操作码本身。2.1 操作码的高位规律为什么 0x00 到 0x3F 长得那么像翻 8086 指令表会发现一个很有意思的现象ADD、OR、ADC、SBB、AND、SUB、XOR、CMP这八条算术逻辑指令它们的操作码是连续排布的。ADD占00到05OR占08到0DADC占10到15以此类推每条指令占 8 个编码位置。这不是巧合而是 Intel 当年刻意设计的规律操作码的低 3 位用来区分寻址形式高 5 位用来区分具体是哪条指令。具体来说00到05这 6 个编码里00是ADD r/m8, r801是ADD r/m16, r1602是ADD r8, r/m803是ADD r16, r/m1604是ADD AL, imm805是ADD AX, imm16。低 3 位里bit0 区分字节/字操作bit1 区分方向寄存器到内存还是反过来bit2 区分是不是立即数形式。理解了这个规律你就不需要死记硬背每条指令的编码而是能推导出来。2.2 ModR/M 字节一个字节里塞进寻址方式和寄存器编号ModR/M 字节是 8086 解码里最容易翻车的地方。它只有 8 个 bit却要同时表达三件事mod 字段bit7-6表示操作数是寄存器还是内存、如果是内存则有没有位移量reg 字段bit5-3表示寄存器操作数编号r/m 字段bit2-0表示另一个操作数可能是寄存器也可能是内存寻址方式。mod 字段有四种取值00表示内存寻址但无位移除了 r/m110 时表示 16 位直接地址、01表示带 8 位位移、10表示带 16 位位移、11表示寄存器直接寻址。这里有个经典坑当 mod00 且 r/m110 时并不是「无位移的内存寻址」而是「16 位直接地址」后面跟两个字节的绝对地址。这个特例在写解码器时如果漏掉遇到MOV AX, [0x1234]这类指令就会解析错位后面的字节全部乱套。r/m 字段在 mod≠11 时表示的是内存寻址方式8086 支持 8 种基址加变址组合比如[BXSI]、[BXDI]、[BPSI]、[BPDI]、[SI]、[DI]、[BP]mod≠00 时或直接地址、[BX]。这套组合表建议直接做成数组查表比写一堆 if-else 清晰得多。2.3 位移量和立即数小端序与符号扩展8086 是小端序位移量和立即数都是低字节在前。8 位位移量在解码后需要做符号扩展再参与地址计算因为它是带符号数范围是 -128 到 127。16 位立即数直接按小端拼装即可。这里有个容易忽略的点8 位立即数在 16 位指令里比如ADD AX, imm8这种形式虽然 8086 上不常见但 386 以后有需要符号扩展到 16 位而 8086 的ADD AL, imm8就是纯 8 位操作不涉及扩展。3. 手写一个最小解码器从字节流到助记符的完整流程光看规则容易飘直接上手写一个能跑的解码器才是正道。下面用 Python 写一个最小可用的 8086 解码器只覆盖最常见的十几条指令但结构是完整的你可以按同样的模式往里加指令。3.1 定义寄存器表和 ModR/M 查找表先把查表用的数据结构建好后面解码逻辑就是纯粹的查表和位运算。# 16位寄存器编号表reg字段和r/m字段在mod11时共用 REG16 [AX, CX, DX, BX, SP, BP, SI, DI] # 8位寄存器编号表 REG8 [AL, CL, DL, BL, AH, CH, DH, BH] # r/m字段在mod!11时的内存寻址方式按r/m值索引 # 每个元素是(基址, 变址)组合None表示无 RM_TABLE [ (BX, SI), (BX, DI), (BP, SI), (BP, DI), (SI, None), (DI, None), (BP, None), (BX, None), ] def decode_modrm(mod, rm, reg_field, is_word): 返回(操作数1描述, 操作数2描述)操作数1是r/m操作数2是reg reg_name (REG16 if is_word else REG8)[reg_field] if mod 0b11: rm_name (REG16 if is_word else REG8)[rm] return (rm_name, reg_name) # 内存寻址 base, index RM_TABLE[rm] if mod 0b00 and rm 0b110: # 特例16位直接地址 return ([disp16], reg_name) parts [p for p in (base, index) if p] expr .join(parts) if parts else ? if mod 0b01: expr disp8 elif mod 0b10: expr disp16 return (f[{expr}], reg_name)这段代码里RM_TABLE直接对应前面说的 8 种寻址组合decode_modrm函数把 mod、rm、reg 三个字段翻译成人类可读的操作数描述。注意mod00, rm110的特例单独处理了这就是前面强调的那个坑。is_word参数控制用 16 位还是 8 位寄存器表因为同一个 reg 编号在字节和字模式下对应不同寄存器。3.2 主解码循环按操作码分派主循环负责读字节、判断指令长度、调用对应的解码函数。这里只实现算术逻辑指令组和MOV的几种形式足够演示完整流程。def decode_one(code, offset): 从code[offset]开始解码一条指令返回(助记符, 消耗字节数) op code[offset] # 算术逻辑指令组ADD/OR/ADC/SBB/AND/SUB/XOR/CMP # 操作码 0x00-0x3F每8个一组 if op 0x40: mnemonic [ADD,OR,ADC,SBB,AND,SUB,XOR,CMP][op 3] form op 0x07 if form 0b000 or form 0b001: # r/m, r 形式 is_word form 1 modrm code[offset1] mod modrm 6 reg (modrm 3) 7 rm modrm 7 dst, src decode_modrm(mod, reg, rm, is_word) # 计算位移量字节数 extra 0 if mod 0b01: extra 1 elif mod 0b10: extra 2 elif mod 0b00 and rm 0b110: extra 2 return (f{mnemonic} {dst}, {src}, 2 extra) elif form 0b010 or form 0b011: # r, r/m 形式 is_word form 1 modrm code[offset1] mod modrm 6 reg (modrm 3) 7 rm modrm 7 src, dst decode_modrm(mod, reg, rm, is_word) extra 0 if mod 0b01: extra 1 elif mod 0b10: extra 2 elif mod 0b00 and rm 0b110: extra 2 return (f{mnemonic} {dst}, {src}, 2 extra) elif form 0b100 or form 0b101: # AL/AX, imm 形式 is_word form 1 if is_word: imm code[offset1] | (code[offset2] 8) return (f{mnemonic} AX, 0x{imm:04X}, 3) else: imm code[offset1] return (f{mnemonic} AL, 0x{imm:02X}, 2) # MOV 指令的几种常见形式 if op 0xB8 or op 0xB9 or op 0xBA or op 0xBB: reg op - 0xB8 imm code[offset1] | (code[offset2] 8) return (fMOV {REG16[reg]}, 0x{imm:04X}, 3) if op 0xB0 or op 0xB1 or op 0xB2 or op 0xB3: reg op - 0xB0 imm code[offset1] return (fMOV {REG8[reg]}, 0x{imm:02X}, 2) return (fDB 0x{op:02X}, 1)主循环里op 3直接算出是哪条算术逻辑指令op 0x07算出具体形式这就是前面说的编码规律的直接应用。MOV的0xB8到0xBF是「MOV r16, imm16」形式0xB0到0xB7是「MOV r8, imm8」形式寄存器编号就是操作码低 3 位。遇到不认识的字节就输出DB伪指令保证解码器不会崩。3.3 跑一遍验证用已知字节序列测试写完了得验证拿几个手算过的字节序列跑一下。def disassemble(code): offset 0 lines [] while offset len(code): text, size decode_one(code, offset) raw .join(f{b:02X} for b in code[offset:offsetsize]) lines.append(f{offset:04X}: {raw:12} {text}) offset size return \n.join(lines) # 测试序列 code bytes([ 0xB8, 0x34, 0x12, # MOV AX, 0x1234 0x05, 0x78, 0x56, # ADD AX, 0x5678 0x01, 0xD8, # ADD AX, BX 0x2B, 0xC1, # SUB AX, CX 0x89, 0x1E, 0x00, 0x20, # MOV [0x2000], BX ]) print(disassemble(code))预期输出应该是0000: B8 34 12 MOV AX, 0x1234 0003: 05 78 56 ADD AX, 0x5678 0006: 01 D8 ADD AX, BX 0008: 2B C1 SUB AX, CX 000A: 89 1E 00 20 MOV [0x2000], BX01 D8这条操作码01是ADD r/m16, r16ModR/M 字节D8拆开是 mod11、reg011BX、rm000AX所以是ADD AX, BX。89 1E 00 20这条操作码89是MOV r/m16, r16ModR/M 字节1E拆开是 mod00、reg011BX、rm110触发直接地址特例后面跟00 20小端拼成0x2000所以是MOV [0x2000], BX。跑通这几条说明解码器的骨架是对的。4. 解码过程中最容易翻车的几个地方这块内容我踩过的坑不少有些是规则本身的反直觉设计有些是写代码时的思维惯性。下面按「现象 → 原因 → 解决」整理几条。4.1 现象解码到某条指令后后面全部错位原因指令长度算错了。最常见的是漏算位移量字节或者把mod00, rm110的直接地址特例当成了无位移内存寻址少算了两个字节。另一个高频原因是段超越前缀0x26、0x2E、0x36、0x3E没有单独处理被当成了操作码。解决写一个instruction_length函数把长度计算和助记符生成分开长度算对了再生成文本。段超越前缀单独判断遇到就记录并跳过继续解码后面的操作码。4.2 现象8 位位移量算出来的地址不对原因8 位位移量是带符号数直接当无符号数用会导致负偏移变成正的大数。比如[BP-4]的位移字节是0xFC无符号解释是 252符号解释才是 -4。解决解码位移量时做符号扩展Python 里可以用disp if disp 128 else disp - 256C 里直接强转int8_t。4.3 现象MOV [BXSI], AL这类指令的寻址方式解析反了原因RM_TABLE的索引顺序搞错了。r/m 字段的值 0 到 7 对应的组合是固定的但很多人会凭感觉写成[BXSI]、[BXDI]、[SI]、[DI]这种顺序实际上[BPSI]和[BPDI]排在中间。解决直接照抄指令手册里的表格不要凭记忆写。我一般会把表格打印出来贴在显示器边上写代码时逐行核对。4.4 现象ADD AL, imm8和ADD AX, imm16混淆原因操作码04和05只差最低位04是 8 位立即数05是 16 位立即数。如果判断条件写成了op 1而不是精确匹配就会把04也当成 16 位处理多读一个字节。解决算术逻辑指令组的立即数形式只有form100和form101两种分别对应 8 位和 16 位用form 1判断是对的但前提是form已经通过op 0x07正确提取。检查一下位运算的优先级低于op 3要先算。4.5 现象解码器遇到不认识的指令直接崩溃原因没有做边界检查和默认分支。8086 指令表里有大量不常用指令最小解码器不可能全覆盖遇到未知操作码时如果直接索引数组就会越界。解决每个分派分支都要有 fallback未知操作码输出DB伪指令并至少消耗 1 个字节保证解码循环能继续。同时加一个offset len(code)的循环条件防止读越界。5. 从解码器到反汇编器几个让输出更可读的技巧解码器跑通之后输出的是「助记符 操作数」的文本但离真正好用的反汇编器还差一些细节。第一个技巧是符号化地址MOV [0x2000], BX里的0x2000如果是一个已知的变量地址可以替换成变量名输出MOV [var_a], BX可读性会好很多。实现方式是在解码器外面维护一个地址到符号的映射表生成文本时查表替换。第二个技巧是分支目标标注。遇到JMP、JZ、JNZ这类跳转指令时把目标地址算出来在输出里标注- 0x0010方便追踪控制流。8086 的条件跳转都是 8 位相对位移目标地址 当前指令地址 指令长度 符号扩展后的位移。这个计算和前面位移量的符号扩展是同一套逻辑可以直接复用。第三个技巧是数据与代码分离。反汇编器最容易犯的错误是把数据段当代码解码输出一堆无意义的指令。实际使用中我一般会先跑一遍线性扫描标记出所有跳转目标地址然后从入口点开始做递归下降解码只解码可达的地址范围其余部分标记为数据。这个策略在分析裸机固件时特别有用因为固件里往往混着代码表和字符串。最后一个技巧是关于输出的对齐。助记符长度不一操作数长度也不一如果直接左对齐输出会很难看。我习惯把助记符字段固定宽度操作数字段从固定列开始这样生成的列表在终端里看起来整齐扫读效率高很多。这些细节看起来不起眼但真正拿解码器去分析一段几百字节的代码时可读性直接决定你愿不愿意继续用下去。我自己的习惯是每加一条新指令的解码逻辑就立刻用两三个手工验证过的字节序列跑一遍确认输出和预期一致再继续。这个习惯帮我省下了大量回头排查的时间因为解码器的 bug 往往不是崩溃而是静默地输出错误结果等你发现时已经不知道是哪条指令开始错的了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站