简介:《个人总结的8086机器语言解码示例笔记》是一份面向汇编器编写者与x86底层学习者的专题笔记,重点梳理8086机器指令从汇编语句到机器码的对应关系,帮助读者理解编码规则并用于手写或校验汇编器实现。内容围绕指令格式、通用与段寄存器编号、五种寻址模式、操作码与立即数展开,并说明字节、字、双字的操作数尺寸,固定编码指令及双操作数指令中目的/源组合、d与w位、位移量与立即数字节占用等细节;文中还以MOV的多类传送形式给出机器码示例,便于对照分析最长可达8字节的指令构成。压缩包内为1个docx文档,约1.94MB,适合按知识点查阅和做编码速查。已有127人学习,可作为8086指令解码、汇编器开发与底层调试的参考材料。
1. 手写机器码这件事,比想象中更考验耐心
第一次把mov word [bx+si+0x1BCD],0x1234手动编码成0C7 80 CD 1B 34 12的人,大多会经历同一个阶段:以为看懂指令格式就够了,真动手写才发现 ModR/M 字节、d 位、w 位、位移量长度、立即数字节顺序,任何一环错一点,整条指令在调试器里就会变成完全不同的东西。这份 8086 机器语言解码笔记围绕自写汇编器时会遇到的编码规则展开,覆盖 MOV、PUSH、POP、XCHG、IN、OUT、XLAT、LEA、LDS、LES、ADD 等指令的机器码级示例。它更适合正在手写 encoder 的读者,不适合只想背汇编语法的人。
2. 指令结构拆解:6 字节指令背后的字段分布
8086 指令长度并不固定,笔记里提到了最长可达 8 字节的形式,例如F0 3E 81 80 CD AB 34 12,包含总线锁定前缀、段超越前缀、操作码、ModR/M、位移量和立即数。写汇编器时如果先假设指令长度固定,后面几乎一定要返工。
2.1 前缀、操作码、ModR/M、位移量与立即数
指令可以按“前缀 + 操作码 + 寻址字节 + 位移 + 立即数”的顺序理解。笔记中的例子F0 3E 81 80 CD AB 34 12对应lock add word [ds:bx+si-0x5433],0x1234。其中F0是 LOCK 前缀,3E是 DS 段超越前缀,81是 ADD 的操作码,80是 ModR/M 字节,CD AB是 16 位位移量,34 12是按小端序存放的立即数。
这种拆分方式适合直接映射到汇编器的编码流程:先判断是否需要前缀,再生成操作码,接着生成 ModR/M 和 SIB(8086 没有 SIB,只有 ModR/M 与位移),最后追加立即数。每一步都可以单独写函数,也方便做单元测试。
2.2 长度判断规则
笔记强调:当 disp 是字时,位移量占 2 字节;立即数为 16 位时占 2 字节,否则占 1 字节。这个规则看起来简单,但实现时容易在“根据操作数大小决定 w 位”和“根据 w 位决定立即数长度”之间绕晕。稳妥的做法是先生成 ModR/M 和位移,再根据操作数类型决定立即数长度,最后统一拼装字节流。
3. 寄存器编号与 ModR/M 编码:汇编器 encoder 的核心
8086 的寄存器编号和 ModR/M 字节是机器码编码里最容易写错的部分。笔记把通用寄存器、段寄存器、8 位寄存器的编号和 ModR/M 的 mod、reg、r/m 字段拆开列出,适合直接转成表驱动实现。
3.1 16 位寄存器与 8 位寄存器编号
16 位通用寄存器 AX、CX、DX、BX、SP、BP、SI、DI 按顺序编号 0 到 7;8 位寄存器 AL、CL、DL、BL、AH、CH、DH、BH 也按类似顺序编号。段寄存器 ES、CS、SS、DS 的编号分别是 0、1、2、3。写 encoder 时可以把这些编号放进数组或字典,通过寄存器名查编号。
REG16 = { "ax": 0, "cx": 1, "dx": 2, "bx": 3, "sp": 4, "bp": 5, "si": 6, "di": 7, } REG8 = { "al": 0, "cl": 1, "dl": 2, "bl": 3, "ah": 4, "ch": 5, "dh": 6, "bh": 7, } SEG = {"es": 0, "cs": 1, "ss": 2, "ds": 3} # 编码时根据操作数类型选择寄存器表 def reg_code(name, width): table = REG16 if width == 16 else REG8 return table[name.lower()]这个表结构简单,但要注意 8 位寄存器高半部分 AH、CH、DH、BH 的编号是 4 到 7,和 16 位寄存器编号不是同一套语义。写错会导致mov ah, 0x12被编码成别的寄存器。
3.2 ModR/M 字节拆分
ModR/M 字节由 mod(2 位)、reg(3 位)、r/m(3 位)组成。笔记中大量使用二进制字面量展示字段,比如10_000_000b表示 mod=2、reg=0、r/m=0。对于mov ax, [bx+si+0x1234],reg 字段表示目的寄存器 AX,r/m=0 表示[bx+si],mod=2 表示后面跟 16 位位移。
def modrm(mod, reg, rm): return ((mod & 0x3) << 6) | ((reg & 0x7) << 3) | (rm & 0x7) # mov ax, [bx+si+0x1234] # mod=2, reg=0(AX), rm=0(bx+si) b = modrm(2, 0, 0) # 0x80 # 后面再追加位移 0x34, 0x12实现时要特别注意 mod=0 且 r/m=6 表示直接地址,不是[bp]。8086 的寻址模式里[bp]必须用 mod=1、位移 0 表示。这个特殊规则在很多简易汇编器里会被漏掉,导致mov ax, [bp]编码错误。
3.3 d 位与 w 位
双操作数指令里,d 位表示 reg 字段是目的还是源,w 位表示字节操作还是字操作。笔记里用000000_1_1b这种形式展示 ADD 指令的编码,其中倒数第二位是 d,最后一位是 w。写 encoder 时可以把 d 和 w 作为参数传入,避免手写拼接。
def opcode_with_dw(base, d, w): return base | ((d & 1) << 1) | (w & 1) # add ax, [bx+si+0x1234] # 操作码 000000,d=1,w=1 -> 0x03 op = opcode_with_dw(0x00, 1, 1)这种写法比直接写0x03更直观,也方便批量生成指令。
4. 典型指令编码:MOV、PUSH、POP 与 XCHG
笔记里 MOV 的示例最多,也最能体现 8086 编码的多样性。MOV 至少有短指令格式、长指令格式、段寄存器专用格式、特定地址内存格式等多种形式,写汇编器时需要按操作数组合分支处理。
4.1 MOV 的短格式与长格式
mov bx, 0x1234可以用短指令格式1011_1_011b, 0x34, 0x12,也就是BB 34 12;而mov word ax, 0x1234用长指令格式0C7 11_000_000b 0x34 0x12,也就是C7 C0 34 12。两种格式都能表达常数到寄存器的传送,但短格式只适用于 BX 等部分寄存器,长格式通用性更强。
def mov_reg_imm16(reg_name, imm): code = REG16[reg_name] # 短格式:1011 1 rrr,只适用于 AX/CX/DX/BX if code <= 3: return bytes([0xB8 | code, imm & 0xFF, (imm >> 8) & 0xFF]) # 长格式:C7 /0 return bytes([0xC7, modrm(3, 0, code), imm & 0xFF, (imm >> 8) & 0xFF])短格式的判断条件不是所有 16 位寄存器都能用,SP、BP、SI、DI 不能用B8+reg这种短格式。笔记里mov bx, 0x1234的示例对应BB,说明 BX 编号 3 加上0xB8得到0xBB。
4.2 段寄存器传送
段寄存器传送使用8E和8C操作码。mov es, ax编码为8E C0,mov ax, es编码为8C C0。笔记里列出了mov cs, ax的编码,但 8086 实际上不允许直接向 CS 传送数据,写汇编器时应对 CS 做特殊处理或直接报错。
def mov_seg_reg(dst, src): if dst == "cs": raise ValueError("cannot mov to cs on 8086") return bytes([0x8E, modrm(3, SEG[dst], REG16[src])])这类限制在笔记里有示例但没有展开说明,实现时需要结合 8086 的硬件约束补上。
4.3 PUSH 与 POP
PUSH 和 POP 的操作数总是 16 位。PUSH 有内存形式FF /6、通用寄存器长格式FF /6和短格式50+reg,段寄存器形式06、0E、16、1E。POP 对应8F /0、58+reg和段寄存器形式07、17、1F。
def push_reg(name): code = REG16[name] # 短格式 01010 rrr return bytes([0x50 | code]) def push_mem(mod, rm, disp=0): # FF /6 return bytes([0xFF, modrm(mod, 6, rm)]) + encode_disp(disp)写 PUSH 时要注意短格式只适用于 16 位通用寄存器,内存操作数必须走FF /6。POP 的内存形式是8F /0,不是/6,这两个字段很容易记混。
4.4 XCHG 与短格式互换
xchg ax, dx可以用短格式10010_010b,也就是92;xchg cx, dx用通用格式87 D1。短格式只适用于 AX 与其它寄存器交换,写 encoder 时可以优先尝试短格式,失败再回退到通用格式。
def xchg_reg_reg(a, b): if a == "ax" and b != "ax": return bytes([0x90 | REG16[b]]) if b == "ax" and a != "ax": return bytes([0x90 | REG16[a]]) return bytes([0x87, modrm(3, REG16[a], REG16[b])])xchg ax, ax编码为90,也就是 NOP。这个细节在优化或生成指令时有用。
5. 避坑与排查:编码器调试时最容易翻车的几处
5.1 位移量长度判断错误
现象:mov ax, [bx+si+0x12]被编码成 4 字节而不是 5 字节,反汇编结果偏移。原因是 mod 字段选择了 16 位位移,但实际位移可以用 8 位表示。8086 的 mod=1 表示 8 位位移,mod=2 表示 16 位位移。解决方法是根据位移范围选择 mod:位移为 0 且 r/m 不是 6 时用 mod=0;位移在 -128 到 127 时用 mod=1;否则用 mod=2。
5.2 立即数字节序写反
现象:mov ax, 0x1234编码成B8 12 34,执行后 AX 变成0x3412。原因是 8086 是小端序,低字节在前。解决方法是统一用imm & 0xFF和(imm >> 8) & 0xFF生成字节,不要直接按十六进制字符串拆分。
5.3 忘记处理[bp]的特殊编码
现象:mov ax, [bp]编码成8B 06,实际被解释成mov ax, [0x0006]。原因是 mod=0、r/m=6 表示直接地址,不是[bp]。解决方法是遇到[bp]时强制使用 mod=1、位移 0,编码为8B 46 00。
5.4 段寄存器方向写反
现象:mov es, ax被编码成8C C0,实际执行成mov ax, es。原因是8E是“到段寄存器”,8C是“从段寄存器”。解决方法是把方向作为独立参数,不要靠操作码隐式判断。
5.5 汇编器版本不识别分组二进制常量
笔记里提到nasm-0.98不识别11_000_000b这种分组二进制常量,需要用nasm 2.10.04或更新版本。写测试用例时如果遇到语法报错,先检查汇编器版本,不要急着改编码逻辑。
6. 二次开发:把笔记变成可测试的 encoder
这份笔记的价值不在“背编码”,而在“把编码规则变成可验证的代码”。我一般会先写一个最小的 encoder 框架,把寄存器表、ModR/M 生成、位移编码、立即数编码拆成独立函数,再用笔记里的示例做对照测试。
def encode_mov(dst, src): # 只处理寄存器到寄存器、立即数到寄存器两种简单情况 if dst in REG16 and src in REG16: return bytes([0x8B, modrm(3, REG16[dst], REG16[src])]) if dst in REG16 and isinstance(src, int): code = REG16[dst] if code <= 3: return bytes([0xB8 | code, src & 0xFF, (src >> 8) & 0xFF]) return bytes([0xC7, modrm(3, 0, code), src & 0xFF, (src >> 8) & 0xFF]) raise NotImplementedError验证方法很直接:把笔记里的每条db示例写成一个测试用例,编码结果和预期字节流逐字节比较。比如mov word [bx+si+0x1BCD],0x1234预期是C7 80 CD 1B 34 12,push ax预期是50,pop es预期是07。测试跑通之后,再把内存寻址、段超越前缀、LOCK 前缀逐步加进去。
改造时建议先支持 8086 的 16 位寻址模式,不要一上来就做 32 位。8086 没有 SIB 字节,ModR/M 的 r/m 字段直接对应[bx+si]、[bx+di]、[bp+si]、[bp+di]、[si]、[di]、[bp]、[bx]这几种组合,先把这张表写死反而更稳。等 16 位 encoder 稳定后,再考虑扩展到 32 位寻址和更多指令。
从那以后我每次写指令编码器,都会先把笔记里的示例整理成字节级测试集,再动 encoder 代码。没有这层对照,调试器里看到的错误指令根本不知道该从哪里查。希望这份拆解能帮到正在写 8086 汇编器或机器码解码工具的你。
本文还有配套的精品资源,点击获取