简介:这份笔记面向编写8086汇编器、需要理解机器指令编码细节的开发者,系统整理了8086机器语言解码的核心知识。内容涵盖指令格式、寄存器编号、寻址模式、操作码、立即数以及字节/字/双字等基本概念,并重点剖析固定编码指令与双操作数指令的编码规则。笔记以MOV指令为主线,逐条给出汇编语句与对应机器码字节的对照,如mov word [bx+si+0x1BCD],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到05,OR占08到0D,ADC占10到15,以此类推,每条指令占 8 个编码位置。这不是巧合,而是 Intel 当年刻意设计的规律:操作码的低 3 位用来区分寻址形式,高 5 位用来区分具体是哪条指令。
具体来说,00到05这 6 个编码里,00是ADD r/m8, r8,01是ADD r/m16, r16,02是ADD r8, r/m8,03是ADD r16, r/m16,04是ADD AL, imm8,05是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/m=110 时表示 16 位直接地址)、01表示带 8 位位移、10表示带 16 位位移、11表示寄存器直接寻址。这里有个经典坑:当 mod=00 且 r/m=110 时,并不是「无位移的内存寻址」,而是「16 位直接地址」,后面跟两个字节的绝对地址。这个特例在写解码器时如果漏掉,遇到MOV AX, [0x1234]这类指令就会解析错位,后面的字节全部乱套。
r/m 字段在 mod≠11 时表示的是内存寻址方式,8086 支持 8 种基址加变址组合,比如[BX+SI]、[BX+DI]、[BP+SI]、[BP+DI]、[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字段在mod=11时共用 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 三个字段翻译成人类可读的操作数描述。注意mod=00, rm=110的特例单独处理了,这就是前面强调的那个坑。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[offset+1] 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[offset+1] 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[offset+1] | (code[offset+2] << 8) return (f"{mnemonic} AX, 0x{imm:04X}", 3) else: imm = code[offset+1] 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[offset+1] | (code[offset+2] << 8) return (f"MOV {REG16[reg]}, 0x{imm:04X}", 3) if op == 0xB0 or op == 0xB1 or op == 0xB2 or op == 0xB3: reg = op - 0xB0 imm = code[offset+1] return (f"MOV {REG8[reg]}, 0x{imm:02X}", 2) return (f"DB 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:offset+size]) 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, r16,ModR/M 字节D8拆开是 mod=11、reg=011(BX)、rm=000(AX),所以是ADD AX, BX。89 1E 00 20这条:操作码89是MOV r/m16, r16,ModR/M 字节1E拆开是 mod=00、reg=011(BX)、rm=110,触发直接地址特例,后面跟00 20小端拼成0x2000,所以是MOV [0x2000], BX。跑通这几条,说明解码器的骨架是对的。
4. 解码过程中最容易翻车的几个地方
这块内容我踩过的坑不少,有些是规则本身的反直觉设计,有些是写代码时的思维惯性。下面按「现象 → 原因 → 解决」整理几条。
4.1 现象:解码到某条指令后,后面全部错位
原因:指令长度算错了。最常见的是漏算位移量字节,或者把mod=00, rm=110的直接地址特例当成了无位移内存寻址,少算了两个字节。另一个高频原因是段超越前缀(0x26、0x2E、0x36、0x3E)没有单独处理,被当成了操作码。
解决:写一个instruction_length函数,把长度计算和助记符生成分开,长度算对了再生成文本。段超越前缀单独判断,遇到就记录并跳过,继续解码后面的操作码。
4.2 现象:8 位位移量算出来的地址不对
原因:8 位位移量是带符号数,直接当无符号数用会导致负偏移变成正的大数。比如[BP-4]的位移字节是0xFC,无符号解释是 252,符号解释才是 -4。
解决:解码位移量时做符号扩展,Python 里可以用disp if disp < 128 else disp - 256,C 里直接强转int8_t。
4.3 现象:MOV [BX+SI], AL这类指令的寻址方式解析反了
原因:RM_TABLE的索引顺序搞错了。r/m 字段的值 0 到 7 对应的组合是固定的,但很多人会凭感觉写成[BX+SI]、[BX+DI]、[SI]、[DI]这种顺序,实际上[BP+SI]和[BP+DI]排在中间。
解决:直接照抄指令手册里的表格,不要凭记忆写。我一般会把表格打印出来贴在显示器边上,写代码时逐行核对。
4.4 现象:ADD AL, imm8和ADD AX, imm16混淆
原因:操作码04和05只差最低位,04是 8 位立即数,05是 16 位立即数。如果判断条件写成了op & 1而不是精确匹配,就会把04也当成 16 位处理,多读一个字节。
解决:算术逻辑指令组的立即数形式只有form=100和form=101两种,分别对应 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 往往不是崩溃,而是静默地输出错误结果,等你发现时已经不知道是哪条指令开始错的了。希望帮到你。
本文还有配套的精品资源,点击获取