简介这份个人整理的8086机器语言解码示例笔记面向正在编写8086汇编器、需要对照机器码与汇编指令的开发者与学习者重点解决指令编码规则难以逐一核对的痛点。内容从机器语言基本概念、指令格式、寄存器与寻址模式入手逐项梳理操作码、立即数及字节、字、双字等要素并延伸到固定编码指令与双操作数指令的编码方式MOV指令部分以十六进制字节序列逐条标注便于按图索骥。资源包共1个docx文件约1.94MB篇幅集中、结构清晰适合作为手边查阅的编码速查笔记。已有127人学习说明其对汇编器实现与底层调试具有一定参考价值。读者可获得指令字段拆解、操作特征字节中d与w位的含义、MOD与R/M组合关系以及长指令前缀、偏移常量与立即数排列顺序等实用信息并据此反推目标代码减少试错成本。1. 从一条 6 字节的 db 说起这份笔记节省了多少翻手册的时间手里有个自己写的汇编器要调试卡在mov word [bxsi0x1BCD],0x1234这一行手册里 MOD、REG、R/M 三张表来回翻位移到底该给几个字节、立即数放前面还是后面改一次重编一次一晚上过去只挪动了两个字节。这份《8086机器语言解码示例笔记》解决的就是这类最原始的问题它把 MOV、ADD、PUSH、POP、XCHG、IN、OUT、LEA、LDS 这些指令的机器码全部用db 0c7h,80h,0xcd,0x1b,0x34,0x12这种形式逐字节写出来并在右边用注释对回汇编语句。适合两类人正在写 8086 汇编器或反汇编器、需要一份可对照的编码样本的开发者以及想搞明白 16 位实模式指令到底怎么拼出来、不想只停在“会写汇编”这一层的同学。它是一份手工解码的对照表并非教程。2. 环境与最小验证环NASM 2.10.04 加反汇编比对这份笔记全部是db硬编码好处是不依赖任何语法糖坏处是写错了汇编器不会拦你——它只当你写了一个普通的数据字节。所以第一步要做的不是急于抄指令而是先搭一个能立刻看到字节变成什么指令的验证环。整个环只有三件事选对 NASM 版本、写对段声明、编完立刻反汇编回来看。2.1 先把版本钉死nasm-0.98 是个陷阱笔记里大量出现11_000_000b这种用下划线分组的二进制常量读起来非常直观能一眼看出哪三位是 REG、哪三位是 R/M。但老版本 NASM 不认这种写法会直接抛解析错误。作者在笔记里专门标了一句“勿使用 nasm-0.98”这就是用版本差异换来的经验。# 确认版本2.10 以上才稳定支持下划线分组的二进制常量 nasm -v # 期望输出类似NASM version 2.10.04 compiled on Jun 7 2021 # 如果系统源里是老版本别硬扛装个新的或用包管理器指定版本 # 校验方式下面的最小样例能汇编通过就说明版本没问题这段命令的含义很直接nasm -v打印版本号只要主版本 ≥ 2.10 就基本安全。参数没什么可调的重点在于养成习惯——换机器、换容器、换 CI 镜像之后都重新跑一次别默认环境和你本地一样。我在容器里复现这类老汇编项目时第一条命令永远是查版本先确认环境再谈代码。2.2 最小骨架[cpu 8086] 与 [BITS 16] 的搭配NASM 默认按当前平台生成代码直接写db虽然不影响字节输出但一旦混进真实指令位数就会跑偏。笔记的骨架只有两行声明却决定了后面所有字节的解释方式。[cpu 8086] ; 限定指令集为 8086禁止 NASM 自作主张用 186 的编码 [BITS 16] ; 生成 16 位代码操作数默认按字处理 ; 这里开始混排真实指令和 db 硬编码可以放在一起对照 mov word [bxsi0x1BCD], 0x1234 db 0c7h, 80h, 0xcd, 0x1b, 0x34, 0x12 ; 上一条指令的手写等价形式 mov ax, 0x1234 db 0c7h, 11_000_000b, 0x34, 0x12 ; 长格式C7 /0 mod11 reg000 mov al, 0x34 db 0b0h, 0x34 ; 短格式B0r db 0c6h, 11_000_000b, 0x34 ; C6 /0字节操作等价 mov al,0x34[cpu 8086]的作用是把可用指令集锁死在 8086NASM 不会偷偷用 80186 才有的更短编码这样你手写的字节和它自动生成的字节才能对得上。[BITS 16]决定立即数和位移的默认宽度是 16 位。把真实指令和db等价形式并排放是这个骨架最有价值的地方编一次两条路径的字节必须完全一致这就相当于给自己写了一个即时断言。注意mov ax,0x1234这里 NASM 默认会选短格式B8 34 12所以想验证长格式C7 C0 34 12时得把真指令那行注释掉否则长度对不上。2.3 编译、反汇编、比对一条指令走完闭环写完a.asm之后用-f bin直接输出纯二进制再用ndisasm反汇编回来看这是验证手写编码最省事的方式。它不需要链接器、不需要操作系统生成的 bin 就是一段可以被任何 16 位环境加载的原始机器码。# 生成纯二进制-f bin 表示不生成目标文件格式只出机器码 nasm -f bin -o a.bin a.asm # 反汇编-b 16 指定 16 位模式和 BITS 16 对应 ndisasm -b 16 a.bin # 想看原始字节就用 xxd逐字节对照笔记里的 db 序列 xxd -g 1 a.bin-f bin是关键参数它让 NASM 跳过所有格式包装直接输出指令字节-b 16必须和[BITS 16]保持一致否则ndisasm会按 32 位解释同一条C7 80 ...出来的助记符完全不同。反汇编输出里左边是偏移、中间是字节、右边是助记符把它和你在笔记注释里写的助记符一条条对对不上的那行就是错的。这一步不要省我见过太多人改完 db 直接看源码注释结果注释写的是mov、字节实际编成了add。提示反汇编比对时把ndisasm的输出重定向到文件和源码并排打开一条一条划线比在终端里滚屏靠谱得多。3. 指令格式拆解MOD/REG/R/M 三个字段如何决定一切8086 的指令长度并不固定最短 1 字节如aaa编码0x37最长可到 8 字节依次是总线锁定前缀、段超越前缀、操作码、操作特征字节、位移量、立即数。真正让初学者卡住的并非这些字段的数量而是那个 1 字节的操作特征字节——MOD2 位、REG3 位、R/M3 位它同时决定了“操作数在哪儿”和“要不要跟位移”。3.1 操作特征字节MOD/REG/R/M 的位分配把这个字节拆开看高位两比特是 MOD中间三比特是 REG低三比特是 R/M。REG 的含义随指令里的 d 位变化d1 时它是目的寄存器d0 时它是源寄存器。笔记里反复出现的11_000_001b、10_000_000b就是按这个分组写的前缀11是 MOD中间三位是 REG最后三位是 R/M。; 以 mov ax,cx 为例操作码 8B10001011d1 表示 REG 是目的 ; 字节序列8B C1 - 10001011 11000001 ; 操作码 MOD11 REG000(AX) R/M001(CX) db 8bh, 11_000_001b ; mov ax,cx ; 反向一条mov cx,axREG001(CX) R/M000(AX) db 8bh, 11_001_000b ; mov cx,ax ; 字节操作把操作码换成 8A其余字段不变 db 8ah, 11_000_001b ; mov al,cl上面三条的关键差异只在操作码和 REG/R/M 的取值上。8B和8A的区别是 w 位操作码最低位为 1 还是 0分别对应字操作和字节操作MOD11 是固定写法表示两个操作数都是寄存器所以后面不再跟位移字节。参数调整的方式很机械换寄存器就改 REG 和 R/M 的三位编号换位宽就改操作码最低位换方向就把 d 位翻转8B与89的关系。这种“改三个位就能换一条指令”的规律正是编写表驱动汇编器的基础。3.2 MOD 与 R/M 的组合表位移到底占几个字节MOD 决定位移的有无和宽度R/M 决定基址加变址的组合方式这两者合起来才能算出一条指令的总长度。笔记里mov word [bxsi0x1BCD],0x1234之所以是 6 字节就是因为 MOD10 强制带 2 字节位移加上 1 字节操作码、1 字节特征字节、2 字节立即数。MODR/M有效地址位移字节00000[BXSI]000001[BXDI]000010[BPSI]000011[BPDI]000100[SI]000101[DI]000110直接地址 disp16200111[BX]001任意上表地址 disp8110任意上表地址 disp16211任意寄存器无内存访问0这张表有两个反直觉的地方新手最常在这里出错。第一MOD00 且 R/M110 的情况实际是直接地址并不属于[BP][BP]需要 MOD01 且位移为 0 才能表示所以它后面强制跟 2 字节位移。第二MOD11 时 R/M 不再表示内存寻址而是直接当作寄存器编号用和 REG 字段地位相同。写编码器时如果按“R/M 一定是内存”去处理遇到寄存器操作数就会多吐两个字节出来。3.3 寄存器编号与 8 字节最长指令的字段顺序REG 和 R/M 里的三位编号在不同上下文下指向不同的寄存器组这三张表必须能随口背出来否则看到11_000_001b会不知道是哪两个寄存器。编号00000101001110010111011116 位AXCXDXBXSPBPSIDI8 位ALCLDLBLAHCHDHBH段寄存器ESCSSSDS————值得注意的是 8 位寄存器的编号顺序和 16 位不一样100在 16 位里是 SP在 8 位里却是 AH。这条差异是 x86 历史遗留写解码器时如果用同一张表映射两种位宽mov ah,0x34会被解成mov sp的字节操作得到的结果会完全不对。把所有字段按顺序拼起来就能解释笔记里那条 8 字节极限指令; F0 3E 81 80 CD AB 34 12 ; F0 - LOCK 前缀 ; 3E - DS 段超越前缀 ; 81 - ADD r/m16, imm16 ; 80 - MOD10 REG000(/0) R/M000 - [BXSI]disp16 ; CD AB - disp16 0xABCD -0x5433补码 ; 34 12 - imm16 0x1234 db 0f0h, 03eh, 081h, 080h, 0cdh, 0abh, 034h, 012h字段顺序是硬性规定前缀在操作码之前位移在特征字节之后立即数永远在最后。想把lock写成81 F0 3E ...是无效的CPU 会当前缀不存在、然后按完全不同的指令解释。0xABCD作为 disp16 时是补码表示的负数和注释里的-0x5433等价这个换算在手写mov [bxsi-0x5433]这类负位移时天天要用。4. 双操作数指令对照MOV 与 ADD 的九种编码形态MOV 和 ADD 几乎覆盖了双操作数指令的全部编码套路寄存器到寄存器、内存到寄存器、寄存器到内存、立即数到寄存器、立即数到内存、段寄存器专用、直接地址专用。把这九种形态对齐之后PUSH、POP、XCHG 这些只需要换个操作码或换个扩展位剩下的字段布局完全复用。这一章按“寄存器互传 → 立即数长短格式 → 段寄存器与内存方向”的顺序走每一步都给出可编译的字节和反汇编结果。4.1 寄存器互传8A/8B 与 88/89 的 d 位方向寄存器之间的 MOV 用 MOD11不带位移长度固定 2 字节是验证 d 位和 w 位最干净的例子。操作码的低两位分别是 d 和 wd 决定 REG 是源还是目的w 决定字节还是字。; 88 /r - MOV r/m8, r8 (d0REG 是源) ; 89 /r - MOV r/m16, r16 (d0REG 是源) ; 8A /r - MOV r8, r/m8 (d1REG 是目的) ; 8B /r - MOV r16, r/m16 (d1REG 是目的) db 8bh, 11_000_001b ; mov ax,cx REGAX 目的, R/MCX 源 db 89h, 11_001_000b ; mov cx,ax REGAX 源, R/MCX 目的 db 8ah, 11_000_001b ; mov al,cl db 88h, 11_001_000b ; mov cl,al ; 内存方向MOD10 带 2 字节位移 db 8bh, 10_000_000b, 34h, 12h ; mov ax,[bxsi0x1234] db 89h, 10_000_000b, 34h, 12h ; mov [bxsi0x1234],ax这六条放在一起看规律就出来了8B和89是同一组区别只在 d 位8B和8A也是同一组区别只在 w 位。真正要小心的是最后两条内存指令它们字节数一样、操作码只差 1但语义正好相反。之前有人在调试时把方向写反代码能跑但数据流反了查了大半天才发现是89写成了8B。反汇编输出里mov ax,[...]和mov [...],ax的区别极其显眼所以第 2 章那个反汇编比对环节在这里就能直接发挥作用。4.2 立即数C6/C7 长格式与 B0~BF 短格式的取舍立即数到寄存器有两条合法路径语义完全相同但长度不同长格式是C7 /0加 MOD11短格式是B8r只占 1 字节操作码。笔记里两种都给了这个对照很有价值——它解释了为什么反汇编同一个mov ax,0x1234有时看到 2 字节、有时看到 4 字节。; 长格式C7 /0REG 字段固定为 000/0 扩展操作数靠 R/M 指定 db 0c7h, 11_000_000b, 034h, 012h ; mov ax,0x1234 - C7 C0 34 12 ; 短格式B8r操作码低三位直接是寄存器编号 db 0b8h, 034h, 012h ; mov ax,0x1234 - B8 34 12 ; 字节版本的短格式是 B0rw 位退化为操作码的最低位 db 0b0h, 034h ; mov al,0x34 - B0 34 ; 立即数到内存只能走长格式且 w1 时立即数固定 2 字节 db 0c7h, 10_000_000b, 034h, 012h ; mov word [bxsi],0x1234长格式的 REG 字段在这里不是寄存器而是“操作码扩展位”写成000表示/0这个具体操作汇编器源码里常见C7 /0的记法就是这么来的。短格式的B8到BF一共 8 个操作码正好对应 8 个 16 位寄存器所以它不需要特征字节直接跟立即数就行。实际编码时优先用短格式能省 2 字节但如果你在写解码器两种形式都必须能识别因为其他工具链不一定做同样的优化。4.3 段寄存器与内存方向8C/8E 与 LEA/LDS/LES段寄存器有独立的操作码8E /r是MOV Sreg, r/m168C /r是MOV r/m16, SregREG 字段在这里直接就是段寄存器编号00ES、01CS、10SS、11DS。; 装载段寄存器8EREG 字段 段寄存器编号 db 8eh, 11_000_000b ; mov es,ax db 8eh, 11_011_000b ; mov ds,ax ; 从内存装载段寄存器MOD00 R/M100 是 [SI] db 8eh, 00_000_100b ; mov es,[si] ; 读出段寄存器8C db 8ch, 11_011_000b ; mov ax,ds db 8ch, 10_000_000b, 34h, 12h ; mov [bxsi0x1234],es ; LEA / LDS / LES同样是“把内存地址或内容取到寄存器”操作码不同 db 8dh, 10_001_000b, 34h, 12h ; lea cx,[bxsi0x1234] db 0c5h, 10_001_000b, 34h, 12h ; lds cx,[bxsi0x1234] db 0c4h, 10_001_000b, 34h, 12h ; les cx,[bxsi0x1234]LEA、LDS、LES这三条共用一个字段布局甚至LEA和MOV长得几乎一样8D和8B差别只在语义LEA取的是有效地址本身不访问内存。也正因为长得像手工编码时最容易把8D敲成8B结果程序不报错、算出来的值莫名其妙。注意8E的 REG 字段里001CS是保留的mov cs,ax这类写法在真正的 CPU 上无效。想改 CS 只能靠远跳转或retf笔记里作为编码练习列出来没问题但别当成可直接执行的代码搬进项目。5. 避坑与排查手写 db 的五个翻车现场手写字节最难受的地方是它几乎不报错db后面跟什么都是合法的数据NASM 不会有任何意见错误全部推迟到运行或反汇编时才会显现。这一章把最常遇到的五类问题按“现象 → 原因 → 解决”列出来都是我踩过或者见别人踩过的。5.1 编码层面的三个翻车点现象一反汇编出来是add/sub而不是预期的mov。原因d 位写反。88/89是 d0REG 为源8A/8B是 d1REG 为目的两条指令字节只差一位视觉上很难分辨。解决写db之前先在注释里明确标出d1, REG目的编完立刻用ndisasm -b 16反查一遍助记符和操作数顺序对不上就是方向错了。现象二指令比预期多一字节或少一字节。原因MOD 选错。位移绝对值小于 128 时可以用 MOD01disp8省一个字节位移等于 0 时可以用 MOD00 完全省掉位移但如果位移恰好为 0 而 R/M110MOD00 会变成直接地址反而多 2 字节。解决编码函数里先判断disp 0 rm ! 6再判断-128 disp 127最后才落到 disp16顺序不能乱。现象三立即数写成了12 34跑出来的值是 0x3412。原因8086 是小端多字节常量必须低字节在前。mov ax,0x1234的机器码是B8 34 12不是B8 12 34。解决凡是手写多字节字段一律用little字节序输出或者手写时先在纸上把高低位倒过来写一遍x86上这条规则没有例外。5.2 工具与执行层面的两个坑现象四NASM 报parser: instruction expected或常量解析失败。原因用了 0.98 之类的老版本不识别11_000_000b这种下划线分组的二进制常量甚至会把它拆成非法 token。解决升级到 2.10 以上或者退而求其次写成11000000b不带下划线。笔记里专门标注了版本要求这类问题在换机器、换 CI 镜像时最容易复现值得单独加一条环境检查。现象五代码在模拟器里跑飞或者lock前缀被忽略。原因前缀顺序放错或者对一个非内存操作数加了lock。前缀必须紧贴操作码之前顺序是段超越在前、lock在后F0 3E 81 ...而lock只能配合读-改-写类内存指令使用。解决按前缀 → 操作码 → 特征字节 → 位移 → 立即数的固定顺序拼接前缀部分单独抽成一个函数处理不要和操作码混在一行db里手写。提示把每个db行拆成“字段列表 → 拼接 → 输出”三步来做比直接写一串十六进制更容易定位问题出错时能一眼看出是哪个字段算错了。6. 二次开发把散落的 db 变成表驱动编码器笔记的形态是“一条指令一组db”人读方便程序用不了。真正要拿它做二次开发第一步是把这些散落的行抽成结构化数据每条记录包含助记符、操作码、d/w 位规则、是否需要特征字节、位移和立即数的宽度。抽完之后你手上就有了一个最小可用的 8086 编码表反过来也就是解码表的雏形。6.1 把散落的 db 变成表结构我一般会先把笔记里的形态归成几类再按类写编码函数而不是一条指令写一个分支。MOV 就三族寄存器/内存互传88/89/8A/8B、立即数长格式C6/C7、立即数短格式B0~BF。ADD 复用第一族的布局只换操作码和/0扩展位。# 把 mod/reg/rm 三段拼成操作特征字节 def modrm(mod, reg, rm): assert 0 mod 4 and 0 reg 8 and 0 rm 8 return (mod 6) | (reg 3) | rm # 位移的三种形态0、disp8、disp16顺序不能反 def encode_disp(mod, rm, disp): if mod 0b00: return b if rm ! 0b110 else (disp 0xFFFF).to_bytes(2, little) if mod 0b01: return disp.to_bytes(1, little, signedTrue) return (disp 0xFFFF).to_bytes(2, little) # 对应笔记里的 mov word [bxsi0x1BCD],0x1234 def mov_rm16_imm16(mod, rm, disp, imm): out bytearray([0xC7, modrm(mod, 0, rm)]) # /0 扩展位固定为 000 out encode_disp(mod, rm, disp) out (imm 0xFFFF).to_bytes(2, little) return bytes(out) print(mov_rm16_imm16(0b10, 0b000, 0x1BCD, 0x1234).hex( )) # 输出c7 80 cd 1b 34 12和笔记里的 db 序列逐字节一致modrm函数就是笔记里那些11_000_001b的代码化版本三个参数分别对应 MOD、REG、R/M位移和立即数统一按小端输出。encode_disp里那个rm ! 0b110的判断对应的就是 3.2 节表里“MOD00 且 R/M110 是直接地址”的特殊情况这一行如果漏掉所有涉及[disp16]的指令都会短两个字节。6.2 编码器的自校验用反汇编当断言表写完之后最容易出的问题是“表本身是错的”所以必须有一个不依赖人工核对的校验手段。最省事的做法是把ndisasm当成断言工具编码器输出字节 → 写进临时文件 → 反汇编 → 把助记符和操作数与输入比对。# 把编码器输出的十六进制字节转成 bin再反汇编回来比对 printf \xc7\x80\xcd\x1b\x34\x12 check.bin ndisasm -b 16 check.bin # 期望00000000 C780CD1B34 mov word [bxsi0x1bcd],0x1234这一步的意义在于把“我理解的编码规则”和“工具链理解的编码规则”强制对齐。两边不一致时要么是你的表错了要么是你漏了某个特殊情况比如段超越前缀、/0扩展位无论哪种都是必须当场解决的。批量化的做法是维护一个asm源码加db对照的测试集每次改编码器就跑一遍全量比对只要有一条助记符对不上就停下。我现在的习惯是每加一簇新指令先手写三条笔记里的db样例当基准再从编码器生成同样的三条去比对三条都过才允许继续加。这样即使用表驱动重构把原来的分支逻辑全推翻了也能随时退回。从那以后我每次动 8086 相关的编码表都强制先跑一遍ndisasm比对再提交省下来的排查时间远比写测试的时间多。希望帮到你。本文还有配套的精品资源点击获取