ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

8086机器码解码实战:从ModRM到汇编指令的底层笔记

8086机器码解码实战:从ModRM到汇编指令的底层笔记 简介完整扎实的8086机器语言解码示例笔记由作者jocks整理面向编写8086汇编器与反汇编器的开发者也适合希望深入理解x86指令硬编码规则的底层程序员。文档从指令格式、寄存器、五种寻址模式等基础讲起逐步覆盖操作码、立即数、字节/字/双字等概念并用大量nasm与db伪指令对照的示例讲解MOV及双操作数指令编码结构清晰、便于查阅。资源包内含1个docx文档压缩包约1.94MB轻量易用内容以具体编码示例驱动例如mov word ax,0x1234对应c7h 11000000b 34h 12h直观展示寄存器、内存、立即数如何组合成机器码同时梳理了固定编码指令、双操作数MOD/RM组合规则以及最长8字节指令含锁前缀和段超越前缀等细节。已有127人学习/下载笔记还特别提示nasm版本对分组二进制常量的兼容性适合需要动手实现汇编器、排查指令编码问题的读者按需查阅。1. 8086机器语言解码为什么我建议你亲手做一份示例笔记刚接触逆向或者底层开发的人十有八九都会在 8086 指令编码上栽跟头看着B8 34 12这种字节序列查表能对上MOV AX, 1234H但换个操作数、换个寻址方式立刻就懵。原因很简单机器码解码不是背表而是吃透 ModRM、操作码扩展、段覆盖前缀这几层结构。我做了几年嵌入式固件分析和老平台代码维护最大的体会是与其反复查手册不如按自己的思路整理一份「8086 机器语言解码示例笔记」把每个指令类别的字节结构和解码逻辑写清楚。这份笔记不需要多高深但必须是手写、逐个指令验证过的——它能让你从「能查表」变成「能心算预测机器码」这份能力在调试引导程序、分析老旧 BIOS 片段、甚至做最小编译器后端时都直接起作用。这篇文章就顺着我的笔记组织方式把从 ModRM 基础到指令解码实操的完整路径讲给你。2. 解码前必须建立的三个底层认知ModRM、操作数与默认段规则2.1 ModRM 字节决定了解码的走向先把它的位布局刻进脑子8086 机器码里出现频率最高的修饰字节就是 ModRM紧跟操作码之后也可能不存在结构是MOD(位7-6) REG(位5-3) RM(位2-0)。REG 字段直接对应寄存器编号000是 AX001是 CX010是 DX011是 BX100是 SP101是 BP110是 SI111是 DI这个映射在解码任何指令时都用得到。MOD 等于11时RM 字段按寄存器方式解释MOD 是00/01/10时RM 进入内存寻址模式MD 为00且 RM 为110时代表直接地址16 位偏移量紧随其后。这几种组合就是解码的核心分叉点。我笔记里写的第一条规则拿到一个操作码先不要急着查指令表先把跟在后面的 ModRM 拆开再去判断 REG 字段代表的是寄存器操作数还是操作码扩展位。因为 8086 里有大量指令如组的80/81/83以及FE/FF把 REG 当作扩展操作码轮用拆错位整条指令就废了。举例说明解码流程比如收到8B 47 02。第一步识别8B属于MOV r16, r/m16那么47就是 ModRMMOD01、REG000(AX)、RM111(DI)。MOD01代表内存寻址加 8 位偏移量有效地址是[DI]disp8偏移量取下一个字节02。逐层拆解后得到MOV AX, [DI02H]。这个例子放进笔记里覆盖了「ModRM 拆位 偏移量补全 结果写回」三件事每个抽象概念都落到具体的十六进制字节上。2.2 8086 的默认段寄存器规则解码时顺带标注段前缀解码时不考虑段寄存器后面做内存访问分析会直接翻车。8086 的默认段规则相当直观取指令走 CS栈操作走 SS数据访问默认 DS但 BP 作为基址时默认段切到 SS。规则可以用一个简单表格整理有效地址成分默认段寄存器备注BX 偏移DS最常见的数据段访问SI 偏移DS常用于字符串处理DI 偏移DS与 SI 配合时也归 DSBP 偏移SS栈帧访问直接地址[disp16]DS直接寻址走数据段取指令IPCS两字节以上指令时自动增长解码时如果遇到3EDS 段覆盖、26ES、2ECS、36(SS)、64FS仅 386先把覆盖记到指令人旁边再做后续解码。我一般会在笔记里用括号标注比如MOV AX, ES:[DI02H]这里26就是段覆盖前缀解码结果要保留这个前缀语义否则遇到跨段访问时生成的反汇编代码和实际内存地址对不上。2.3 操作数宽度由指令本身决定B0与B8系指令的宽度判据8086 部分指令用最低位区分 8 位和 16 位操作数B0是MOV AL, imm8B8是MOV AX, imm16。解码时判断宽度的办法是操作码最低位为0时操作数是 8 位为1时操作数是 16 位。但80/81/83组指令不是这个规律它们用 ModRM 的REG字段区分扩展操作码用W位区分宽度——80是立即数与 8 位寄存器/内存运算81是 16 位立即数与 16 位操作数运算83则用 8 位符号扩展立即数参与 16 位运算。这是我笔记里最核心的一张表。我习惯把指令按操作码分组记录00-03(ADD)、08-0B(OR)、20-23(AND)、28-2B(SUB)、30-33(XOR)、38-3B(CMP) 这些二元算术指令有个规律——偶数操作码是内存/寄存器到寄存器方向奇数操作码是寄存器到内存方向比如01是ADD r/m16, r1603是ADD r16, r/m16。解码时先认领方向再观察 W 位基本不会出错。注意ModRM 的 REG 字段在部分指令如80/81/83中不是寄存器而是扩展操作码解码前必须先确认指令的操作码位。判断错了后面整条指令都会跟着错。3. 动手搭一套最小解码环境字节流逐条拆解的实操笔记3.1 用 Python 脚本实现单条指令解码的最小框架笔记里最重要的是把「人读字节流」自动化成「脚本解码」。我写过一个非常小的 Python 片段作用是把十六进制字符串解析成字节数组再根据自己的解析逻辑输出指令描述。这个脚本的作用不是替代完整反汇编器而是验证你对每条指令编码规则的理解对不对。def parse_hex_stream(hex_str: str) - list: 把 B8 34 12 样式的十六进制字符串转成字节数组 cleaned hex_str.replace(0x, ).replace(,, ).split() return [int(b, 16) for b in cleaned] # 示例B8 34 12 stream parse_hex_stream(B8 34 12) print(stream) # 输出 [184, 52, 18]对应 0xB8, 0x34, 0x12这样做的逻辑是先去掉分隔符和前缀把每个十六进制片段映射成整数得到的列表就是字节流。后面写解码器时stream[0]就是操作码stream[1]是 ModRMstream[2:]是偏移量或立即数。参数方面输入格式要统一我习惯用空格分隔的完整十六进制序列如果要支持连续内存转储比如二进制文件读出来的一串字节得自己加长度截断参数。拿到字节数组后下一个问题是「怎么知道第一条指令在哪里结束」。这就需要用到刚才说的 ModRM 规则操作码决定是否含 ModRM如果含 ModRMMOD00且 RM110时有 16 位直接地址MOD01有 8 位偏移MOD10有 16 位偏移。我的脚本里用这段逻辑决定跳过多少字节def length_from_modrm(modrm: int, opcode: int) - int: 返回 ModRM 之后还要消费几个字节的偏移/立即数 mod (modrm 6) 0x03 rm modrm 0x07 if mod 0b00 and rm 0b110: return 2 # 直接地址 [disp16] elif mod 0b01: return 1 # 8 位偏移 elif mod 0b10: return 2 # 16 位偏移 else: return 0 # 寄存器操作数无内存偏移这个函数把 ModRM 的内存寻址分支全部覆盖了MOD11时直接返回 0寄存器模式不需要额外偏移。配合操作码表就能写出「给定一条指令的起始地址自动算长度并跳到下一条」的解码器骨架。这对分析一段连续的机器码特别有用因为反汇编最怕的就是长度算错导致整段错位。3.2 用 NASM 生成对照样本作为笔记的「参考答案」手写解码器没有对照样本就是自嗨我建议用汇编器反向生成测试用例。用 NASM 写一段汇编编译后拿机器码对照自己的解码结果这样笔记里的每个示例都能验证。; test_decode.asm BITS 16 org 0x0100 mov ax, 0x1234 ; B8 34 12 mov byte [bx], 0x55 ; C6 07 55 mov ax, [bxsi4] ; 8B 40 04? 不对正确的是 8B 40 04 add word [bp6], 0x10 ; 83 46 06 10上面注释中我故意留了一个错在写笔记时我会先把汇编文件编译成二进制nasm -f bin test_decode.asm -o test_decode.bin xxd test_decode.binxxd输出的每一行对应 16 个字节左侧十六进制、右侧 ASCII。这样你就能直观看到MOV AX, 0x1234对应的机器码确实是B8 34 12——注意立即数在 8086 里是小端序存储0x1234在内存里是34 12这也是新手最容易漏掉的一环。我笔记里专门有一页记录这个坑操作码本身是最高有效位在前的顺序但立即数与偏移量的字节序一律小端。用xxd验证过一遍之后解码时字节顺序就不容易搞反。3.3 建立自己的指令分组笔记表按操作码块组织而不是按指令名我用下来最顺手的笔记组织方式是按操作码块走不是按MOV、ADD这种指令名来排。原因是解码器实际执行时是拿操作码查表的按指令名排笔记遇到新操作码还得二次索引。我笔记本第一页是一幅操作码地图操作码范围指令族解码要点00-0FADD / OR / ADC / SBB偶数操作码目标为 r/m奇数为寄存器10-1FADC / SBB / AND 等注意1F是POP DS不是算术指令20-3FAND / SUB / XOR / CMP指令方向靠操作码奇偶判断40-5FINC/DEC 单字节寄存器指令高 3 位是寄存器编号不需 ModRM70-7F条件跳转Jccib 偏移量是 8 位相对偏移80-8F立即数组 / 位移组REG 字段是扩展操作码B0-BFMOV r, imm操作码低 3 位是寄存器编号C0-DF移位 / 串操作 / 远跳转括号内还要细分E0-EF循环与端口指令E8是CALL rel16偏移量相对下一条指令F0-FF锁前缀 / 重复前缀 / 单字节 INC/DECF2/F3是重复前缀FF组按 REG 扩展位细分解码时先确认操作码落在哪个块然后马上知道该块有没有 ModRM、REG 是寄存器还是扩展操作码。我实际经验是40-5F和B0-BF区块最容易——没有 ModRM直接读操作码低位80-8F和F0-FF区块最需要警惕因为 REG 字段是扩展码解码步骤要多两步。笔记里我会用一个表单独列FF组的五种扩展操作码/0INC r/m/1DEC r/m/2CALL r/m/3CALLF m16:16/4JMP r/m/5JMPF m16:16/6PUSH r/m。看不懂这些扩展码一个FF 30都能让你愣半天。4. 从80到FF扩展操作码组的解码规则与实例拆解4.180/81/83立即数组ModRM 的 REG 字段在这里是「操作码扩展」这组指令是 8086 机器码解码里最容易混淆的80/81/83都是「立即数与 r/m 操作数做算术运算」差别只在宽度和立即数长度。80对应 8 位操作数、8 位立即数81对应 16 位操作数、16 位立即数83对应 16 位操作数但立即数只有 8 位并做符号扩展。REG 字段的意义在这里完全变掉000是 ADD、001是 OR、010是 ADC、011是 SBB、100是 AND、101是 SUB、110是 XOR、111是 CMP。拿83 46 06 10举例83组然后46拆开MOD01、REG110、RM110。REG110说明是 XORRM110表示[BPdisp8]偏移量是06最后10是 8 位立即数。整条指令解码为XOR word [BP06H], 0x10。如果这里把 REG 解析成寄存器110是 SI就会得出完全荒谬的结果。所以我的解码流程里凡是碰到操作码是80/81/83第一步就是读 REG 当作子操作码用。脚本上我只是加一个分支判断OPCODE_EXT {0x80: {0: ADD, 1: OR, 2: ADC, 3: SBB, 4: AND, 5: SUB, 6: XOR, 7: CMP}, 0x81: {0: ADD, 1: OR, 2: ADC, 3: SBB, 4: AND, 5: SUB, 6: XOR, 7: CMP}, 0x83: {0: ADD, 1: OR, 2: ADC, 3: SBB, 4: AND, 5: SUB, 6: XOR, 7: CMP}} def decode_80_group(opcode, modrm): reg (modrm 3) 0x07 mnemonic OPCODE_EXT[opcode][reg] return mnemonic, reg # 测试: 0x83, ModRM0x46 print(decode_80_group(0x83, 0x46)) # (XOR, 6)参数说明与检查逻辑OPCODE_EXT是扩展操作码映射表每个操作码块内都有同一个 REG 字段到指令助记符的映射decode_80_group只做一件事——把 REG 字段取出来查映射表。这个分支放对位置之后解码C6MOV 立即数到 r/m8、C7MOV 立即数到 r/m16时也能复用。REG 字段的/0到/7记法在 Intel 手册里很常见用映射表实现起来特别自然。4.2FE和FF组单字节操作码后的 REG 扩展逻辑FE和FF组只做两件事FE组操作数是 8 位FF组操作数是 16 位。REG 字段作为扩展操作码指示具体动作FE /0是 INCFE /1是 DECFF /0是 INCFF /1是 DECFF /2是 CALLFF /4是 JMPFF /6是 PUSH。这组指令没有立即数所以解码时先读 ModRM判别 REG 字段后立即确定助记符再利用 ModRM 的 MOD/RM 字段确定操作数类型。FF 30就是最典型的例子FF说明 16 位操作数组ModRM30 MOD00、REG110、RM000。REG110在FF组里是 PUSHRM000且 MOD00表示[BXSI]是内存操作数。解码结果PUSH word [BXSI]。如果不理解扩展编码FF 30会让你猜不到这是个压栈指令。我笔记里标了一行提示凡是解码到一个FF别再想它是寄存器操作它必然是 r/m 组指令下一步就是读 ModRM 的 REG 字段。一个容易忽略的失败点是FF /3和FF /5CALLF/JMPF是远调用/远跳转操作数是m16:16段地址:偏移量解码时需要多读 4 个字节。如果在写解码器时只处理了FF /2(CALL) 和FF /4(JMP)遇到远调用会读漏参数字节导致接下来的字节流全部错位。这是我在笔记里第 3 条「血泪经验」扩展操作码的变长操作数不只是位移量还包括指针类型带来的额外字数量。4.3 条件跳转与CALL相对偏移量的符号扩展细节70-7F范围是Jcc rel8每条指令一个操作码无 ModRM紧跟一个 8 位有符号偏移。74 03是JE 3意思是如果 ZF1从下一条指令的地址加上03跳转。注意偏移量相对的是「当前指令的下一条指令」不是当前指令自身。解码时我建议显式记录target next_ip offset这个在手工解码和二进制定位时都很关键。E8是CALL rel16偏移量是 16 位有符号数同样相对下一条指令。常见的解码翻车点是把相对偏移当成绝对地址——很多静态分析工具输出的「目标地址」其实已经帮你算过了但对笔记作者来说必须保留两个值原始偏移量和算出来的目标地址两者分开记录后面做重定位分析时才能还原代码的真实位置。另一个坑是 16 位偏移量的符号扩展问题如果偏移量是负的比如FFFE目标是往低地址方向跳转计算时要用有符号加法否则目标地址会超过 64KB 段范围。相对偏移的公式我直接写进笔记里目标地址 (当前指令地址 指令总长度) rel 其中 rel 是有符号数8位时范围 -128~12716位时范围 -32768~327675. 避坑手册8086 机器码解码中常见的 5 个翻车现场5.1 操作码只说了一半忘了看 W 位现象从某段固件里提了一串字节80 C3 55按MOV BL, 55H记录结果与预期逻辑对不上。原因80 C3 55中80是立即数组指令C3拆开后 MOD11、REG000、RM011。REG000是 ADD 而非 MOVRM011对应 BL因此这条指令是ADD BL, 55H。我把 REG 扩展码当寄存器用方向错了一半。解决碰到80/81/83时强制查扩展操作码映射表REG 值000-111对应 ADD/OR/ADC/SBB/AND/SUB/XOR/CMP与指令名无关时先不要猜。5.2 小端序在偏移量/立即数上反复踩雷现象B8 34 12被记录为MOV AX, 3412H实际上反汇编结果是MOV AX, 1234H。原因8086 立即数按小端存储34 12在内存里对应 0x1234不经转换直接拼接就会读成 0x3412。解决解码时按小端序读取立即数和偏移量写成代码时不要逐字节拼字符串正确做法是imm bytes[1] | (bytes[2] 8)在笔记示例中标记为「取字节时要反向读」。5.3IP相对偏移算错CALL的偏移是相对下一条指令现象E8 01 00被算成跳到「当前地址 1」结果跳到了指令中间。原因E8的 rel16 相对的是CALL指令之后的下一条指令如果CALL本身占 3 字节那么target addr 3 0x0001而不是addr 1。解决解码时统一先算next_ip 当前地址 指令长度然后target next_ip rel。这个公式对所有相对跳转与相对调用都成立不要为跳转类指令写特殊逻辑。5.4 段覆盖前缀被丢弃解码结果与真实访问地址不符现象字节流26 8B 47 02被直接解成MOV AX, [DI02H]但实际访问的是ES:[DI02H]。原因26是 ES 段覆盖前缀如果不把它纳入语义范围解码输出的内存地址就落在默认段DS 或 SS中与真实执行环境完全不一致。解决解码脚本遇到段覆盖前缀时把它们存到指令上下文里输出助记符时强制带上ES:、CS:这类段前缀。手工笔记里遇到前缀就画一个括号标明覆盖段。5.5LOCK/REP前缀与指令边界混淆现象解码F3 A4时把F3当成操作码结果写成了「未识别指令」。原因F3是REP前缀真正操作码是后面的A4MOVSB。前缀的存在不改变操作码的语义只给指令附加上循环或锁存行为。解决写解码器时先做前缀剥除凡是F0(LOCK)、F2(REPNE)、F3(REP/REPE)、段覆盖前缀26/2E/36/3E都先吃掉并记录再进入真正的操作码解码。在笔记里要把前缀放在助记符前面如REP MOVSB。6. 一个能小步验证的进阶技巧手写 8086 到机器码的「编码反查」最后一章想给你一个验证笔记正确性的高效手段不依赖完整反汇编器用你自己写的代码把「汇编指令」翻译回机器码然后再用 NASM 验证。这个步骤和正文的解码是互逆的两个方向都对上你的笔记者才真正立得住。具体做法是这样你在笔记里挑 20 条指令每条手工解码形成「机器码 → 汇编」的对照记录然后再手工从汇编反推一遍机器码最后 NASM 输出做三方对照。我一般给每条指令编码时都做一次「手编码」练习比如MOV AX, [BXSI4]操作码是8BModRM 中 MOD01、REG000(AX)、RM000(BXSI)组合出来是40立即数位移04结果机器码8B 40 04。随后用nasm -f bin编译验证。这里最值得注意的技巧是REG 与 RM 字段的寄存器编码不是随便记的它们和操作码低 3 位如B8reg用的是同一个编号体系一次把000-111映射到 AX/CX/DX/BX/SP/BP/SI/DI 记牢既可以解码 ModRM也能解码单字节寄存器 MOV。我自己的习惯是维护一张双向映射表左边是寄存器名右边是 3 位编号然后在各种指令组下面反复对照练习。遇到40-47(INC r16) 这类单字节寄存器指令低 3 位就是寄存器号遇到B0-B7(MOV r8, imm8) 同样。练顺手之后你在看byte dump时就不需要逐条查表先扫操作码区块、再留意 ModRM 的 REG 与 MOD 位指令的大致语义就浮出来了。验证工具链上我保留了一个 40 行以内的 Python 脚本实现「输入一列字节长度、输出当前指令助记符和长度」再配合 NASM 做逆向验证整个过程比打开反汇编器点按钮要可靠得多。因为反汇编器自动帮你看掉了所有细节你根本不知道自己哪一步理解是错的手工互逆的编码解码把每个细节都暴露出来。还有一个经验遇到拿不准的字节序列先把对应指令手册那一页的伪代码逐行读一遍再回到自己的笔记对照。遇到0F开头的指令286 引入的扩展操作码就先标注「需要查 0F 表」不在 8086 笔记里硬解——这样做能让笔记边界清晰不把后续架构的特性混进来。我踩过的最大跟头就是把 386 的0F B6(MOVZX) 错记到 8086 笔记里后面拿着笔记去分析引导扇区时浪费了大半天。最后给你一个实操建议给自己限定时间从 BIOS 区域或引导扇区里随机挑 20 个字节手写解码加机器码互逆输出两版答案再用 NASM 对照。这个过程比读十篇教程都有用。我自己当初这样做了一星期之后再也没在 ModRM 上栽过跟头也希望这个笨办法能帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进