简介:个人总结的8086机器语言解码示例笔记,面向正在编写8086汇编器的开发者、研究x86指令编码的进阶学习者,逐项梳理了8086机器语言的基本概念、指令格式、寄存器编号、寻址模式、操作码、立即数、字节/字/双字、固定编码指令及双操作数指令编码等核心知识,帮助读者建立从汇编指令到机器码的完整映射思路。资源包共1个docx文档,压缩包大小1.94MB,排版以清单加示例为主,便于随查随用。已有127人浏览学习,是一份经过实践沉淀的个人总结。笔记结合大量真实编码示例,如mov word [bx+si+0x1BCD],0x1234对应db 0c7h,80h,0xcd,0x1b,0x34,0x12,细致对比MOV指令的寄存器互传、内存互传、立即数传送以及短/长格式,并说明操作码与寻址模式位域的组合规则;同时标注出最长指令可长达8字节,提醒nasm-0.98版本不支持分组二进制常量。文档还专门整理了固定编码的AAA等1字节指令,以及双操作数指令编码中d/w位含义、目的与源不可同时为内存单元的约束、disp与立即数按字或字节分配的具体规则,能够帮助汇编器开发者快速查表、校对编码位,显著降低手工解码出错率。
1. 8086机器语言解码:一份写给汇编器开发者的十六进制对照手册
大多数开发者接触 8086 是从汇编语法入手的,可真到写汇编器、反汇编器或者指令级模拟器的时候,面对的却是一堆裸的十六进制字节。这份「8086机器语言解码示例笔记」最实在的地方在于:作者把所有常用指令的机器码用 nasm 的 db 伪指令直接铺在汇编语句旁边,等于把「汇编 → 字节」的翻译过程摊开给你看。适合正在写 8086 汇编器、调试器、模拟器的开发者;也适合那些只想搞清楚mov word [bx+si+0x1BCD],0x1234这行汇编在内存里到底长什么样的人。读完这篇,你至少能把 MOV、PUSH、POP、XCHG、ADD 这几组高频指令从操作数反推出字节序列,也能看懂一份编码表为什么是那个样子。
2. 指令编码体系拆解:从 8 字节最长指令到 ModRM 三字段
2.1 一条指令的字节构成:前缀、操作码、寻址模式、位移量与立即数
8086 指令最长可以到 8 个字节,笔记开头就抛出了一个非常极端的例子,把整条指令的前缀、操作码、寻址模式字节、位移量和立即数一次性串了起来:
F0 3E 81 80 CD AB 34 12 lock add word [ds:bx+si-0x5433],0x1234这 8 个字节逐字节拆开看,各自的角色是这样的:
F0是总线锁定前缀lock,告诉 CPU 在执行这条读改写指令期间锁住总线,典型用在多处理器环境下对共享内存的原子操作。3E是段超越前缀,把默认的 DS 段换成 DS……严格说默认就是 DS,这里写出来更多是演示前缀可以叠加。换成2E就是 CS、36是 SS、26是 ES。81是算术运算组指令的操作码,它不带 d 位和 w 位一起用,81 /0表示 ADD r/m16, imm16。80是 ModRM 字节,展开成二进制是10_000_000b,MOD=10 表示后面跟 16 位位移量,REG=000 表示扩展操作码 /0(ADD),R/M=000 表示[BX+SI]这个基址加变址组合。CD AB是 16 位位移量,小端存储,实际值是0xABCD,也就是-0x5433的补码,正好对上汇编里的-0x5433。34 12是 16 位立即数0x1234,同样小端存放。
这个例子把指令格式的各个字段全占满了:前缀 + 操作码 + ModRM + disp16 + imm16。我一般会拿它当自测用例,凡是手写的编码器能把这 8 个字节原样吐出来,指令格式这一关就算过了。注意,add word [ds:bx+si-0x5433],0x1234里的word修饰符决定了 w=1,所以操作码落在81而不是80,这是初学者最容易忽略的点。
2.2 双操作数指令编码:d 位决定方向、w 位决定宽度
双操作数指令是 8086 编码里最绕的部分。笔记里总结的规则可以浓缩成一张表:
| 编码位 | 值 | 含义 |
|---|---|---|
| d=1 | ModRM 的 REG 字段表示目的寄存器 | 源由 MOD 和 R/M 组合决定 |
| d=0 | REG 字段表示源寄存器 | 目的由 MOD 和 R/M 组合决定 |
| w=1 | 字操作 | 操作数为 16 位 |
| w=0 | 字节操作 | 操作数为 8 位 |
再加上一条硬性约束:目的和源不可以同时为内存单元。所以mov [bx],[si]这样的指令在 8086 里根本不存在,编码器遇到这种情况要么报错,要么就得拆成两条指令。d=1和d=0单独看不难,难的是组合起来之后谁能占 REG 字段、谁只能占 R/M 字段。比如 LEA 指令的 REG 字段是目的寄存器,但它的源操作数是内存地址而不是寄存器,跟 MOV 的编码逻辑正好相反,写汇编器的时候这里特别容易翻车。
ModRM 字节本身是三个字段的合体,一个字节里塞了 MOD(2 位)、REG(3 位)、R/M(3 位):
- MOD=00:R/M 走内存寻址,无位移量(R/M=110 是例外,表示直接地址 disp16)。
- MOD=01:R/M 走内存寻址,带 8 位位移量。
- MOD=10:R/M 走内存寻址,带 16 位位移量。
- MOD=11:R/M 不再表示内存,而是第二个寄存器编号。
也就是说,寄存器间传送时 MOD 必然是 11,R/M 字段当作寄存器编号用,此时指令只需要操作码 + ModRM 两个字节,比如mov ax,cx的机器码就是8B C1。
2.3 寄存器编号映射与寻址模式组合
笔记里把三类寄存器编号都列全了:16 位通用寄存器、8 位通用寄存器、段寄存器。这是手写编码器必备的查表素材:
| 编号 | 16 位寄存器 | 8 位寄存器 | 段寄存器 |
|---|---|---|---|
| 000 | AX | AL | ES |
| 001 | CX | CL | CS |
| 010 | DX | DL | SS |
| 011 | BX | BL | DS |
| 100 | SP | AH | — |
| 101 | BP | CH | — |
| 110 | SI | DH | — |
| 111 | DI | BH | — |
段寄存器只占 REG 字段里的 2 位,因此跟段寄存器相关的 MOV 指令里 ModRM 的 REG 字段实际上只有两位有效,另外一位要么固定为 0,要么是扩展操作码的一部分。比如mov es,ax的第二个字节11_0_00_000b,拆开看是 MOD=11、REG=00(ES)、R/M=000(AX),中间的0是填充位。
内存寻址的 R/M 组合也要背熟,MOD 不等于 11 时:
- 000 =
[BX+SI],001 =[BX+DI] - 010 =
[BP+SI],011 =[BP+DI] - 100 =
[SI],101 =[DI] - 110 =
[disp16](MOD=00)或[BP+disp](MOD=01/10) - 111 =
[BX]
[BP]的默认段是 SS,这是 8086 里少数几个默认段不是 DS 的寻址组合,写模拟器时如果想省事只查 DS 表,这里就会埋雷。
3. MOV 指令解码实战:九种编码形态与 db 字节对照
3.1 寄存器互传和内存访问的 ModRM 编码
MOV 是 8086 里编码形态最丰富的一条指令,笔记里一口气列了十几种情况。先从最基础的寄存器互传看起:
db 8bh,11_000_001b ; mov ax,cx db 8ah,11_000_001b ; mov al,cl第一条的8B是 MOV r16, r/m16 的操作码,它隐含 d=1、w=1,也就是 REG 字段代表目的寄存器。第二个字节11_000_001b展开是 MOD=11、REG=000(AX)、R/M=001(CX),所以整条指令的意思是mov ax,cx。把 w 位改成 0,操作码变成8A,REG=000 就代表 AL,R/M=001 代表 CL,于是得到mov al,cl。这里能看出一个规律:w 位同时影响操作码和寄存器组的宽度,REG 和 R/M 的编号不变,变的只是寄存器集合。
寄存器与内存互传时,ModRM 的 MOD 字段变成内存寻址模式:
db 1000_10_1_1b,10_000_000b,34h,12h ; mov ax,[bx+si+0x1234] db 1000_10_0_1b,10_000_000b,34h,12h ; mov [bx+si+0x1234],ax1000_10_1_1b就是8B,d=1 表示 REG 是目的,AX 是目的,源是[BX+SI+0x1234];第二个字节10_000_000b的 MOD=10 表示 16 位位移量,R/M=000 表示[BX+SI],REG=000 表示 AX。所以这条是mov ax,[bx+si+0x1234]。下面那条操作码换成1000_10_0_1b即89,d=0 表示 REG 是源,AX 变成源,目的由 R/M 决定,即mov [bx+si+0x1234],ax。注意两条指令都带了 16 位位移量0x1234,在 ModRM 之后按小端补两个字节:34h,12h。
3.2 常数传送的短格式与长格式:C7、C6 与 B8、B0 的取舍
常数传送到寄存器,8086 给了两套编码,笔记把两套都列出来了:
mov word ax,0x1234 ; db 0c7h,11_000_000b,0x34,0x12 mov bx,0x1234 ; db 1011_1_011b,0x34,0x12 mov al,0x34 ; db 0xB0,0x34 db 0c6h,11_000_000b,0x34 ; mov al,0x34 的长格式第一行的C7 /0 iw是常数到 r/m16 的长格式:操作码C7,ModRM 的 REG 字段固定为 000,这个 000 不是 AX,而是扩展操作码 /0,表示这条 C7 就是 MOV。MOD=11、R/M=000 表示目标是 AX,后面小端跟34 12。所以C7 C0 34 12等价于80 C0 34 12里的C0不是随便写的,它要求 REG 三位必须是 0。这里有个新手常踩的坑:想当然把 REG=000 理解成 AX,等遇到mov word [bx],0x1234这种内存目标时,ModRM 里的 REG 还是 000,才意识到它是扩展操作码而不是寄存器。
第二行1011_1_011b是短格式操作码B8+reg,低三位是寄存器编号,011 是 BX,所以BB 34 12就是mov bx,0x1234。同样地,8 位版本是1011_0_011b即B3,对应mov bl,0x34。短格式的好处是省一个 ModRM 字节,所以汇编器一般会优先选短格式,只有当目标是内存操作数时才用 C7/C6 长格式。
第四行C6 /0 ib是常数到 r/m8 的长格式,C6 C0 34通过 MOD=11、R/M=000 把目标指向 AL。对比B0 34和C6 C0 34两个编码,能明显感受到 8086 为了压缩指令长度做的冗余设计:同一个语义,寄存器目标走单字节操作码,内存目标走双字节操作码加 ModRM。
3.3 段寄存器传送与特定地址传送的特殊编码
段寄存器不属于通用寄存器编号体系,它的 MOV 单独占了两组操作码:
db 10001110b, 11_0_00_000b ; mov es,ax db 10001110b, 00_0_00_100b ; mov es,[si] db 10001100b, 11_0_00_000b ; mov ax,es db 10001100b, 10_0_00_000b,34h,12h ; mov [bx+si+1234h],es8E组负责把通用寄存器或内存内容送到段寄存器,8C组负责把段寄存器送到通用寄存器或内存。两条指令的 ModRM 里 REG 字段只取两位,00=ES、01=CS、10=SS、11=DS。所以8E C0是mov es,ax,8C C0是mov ax,es。这里特别容易出错的是方向:虽然表面上看 8E 和 8C 互为镜像,但 d 位规则在这里不适用,REG 到底是源还是目的完全由操作码决定,不能照搬通用寄存器的 d 位逻辑。
特定地址传送走的是 8086 里另一类极短格式,操作码本身隐含了 AX/AL 和地址:
db 1010000_1b,0x34,0x12 ; mov ax,[1234h] db 1010000_0b,0x34,0x12 ; mov al,[1234h] db 1010001_1b,0x34,0x12 ; mov [1234h],ax db 1010001_0b,0x34,0x12 ; mov [1234h],alA0~A3四个操作码,靠最低位的 w 区分 AX 还是 AL,靠 0/1 区分读还是写。它们不需要 ModRM,操作码后面直接跟 16 位绝对地址的小端字节。写汇编器时,凡是目标操作数是[memptr]这种直接地址且数据寄存器是 AX/AL 的 MOV,都可以优先匹配这组编码,能省一个 ModRM 字节。
4. 堆栈、交换、端口与特殊传送指令:长格式和短格式的编码策略
4.1 PUSH/POP:操作数总是字,短格式低 3 位是寄存器编号
PUSH 和 POP 的操作数尺寸永远是 16 位,不存在 w 位选择的问题。笔记把它们的编码分成了内存/长格式、寄存器短格式、段寄存器三组:
db 0ffh,11_110_000b ; push ax 的长格式 db 01010_000b ; push ax 的短格式 db 01010_111b ; push di 的短格式 db 000_01_110b ; push cs db 8fh,10_000_000b,34h,12h ; pop word [bx+si+1234h] db 8fh,11_000_000b ; pop ax db 01011_000b ; pop ax 的短格式 db 000_00_111b ; pop esFF /6是 push r/m16 的长格式,ModRM 的 REG 字段固定为 110,即扩展操作码 /6。FF F0的 MOD=11、R/M=000,目标就是 AX。短格式50+reg更干脆,01010_000b是 0x50,低 3 位 000 对应 AX;01010_111b是 0x57,对应 DI。段寄存器的 PUSH 是单字节固定编码,0E就是 push cs。
POP 的套路一样,只是长格式操作码变成了8F /0,REG 固定为 000,短格式变成58+reg,段寄存器里没有 pop cs——把 CS 弹出会让处理器的代码段指针完全失控,所以 8086 故意不给 pop cs 留编码空间。写汇编器的时候,遇到 pop cs 应当直接报「指令不存在」,而不是试图编码。
4.2 XCHG:交换指令的对称性与 AX 短格式
XCHG 比较有意思的地方是它的对称性:交换操作不分方向,REG 和 R/M 谁是源谁是目的其实无所谓,编码结果一样。
db 1000011_1b,10_001_000b,34h,12h ; xchg cx,[bx+si+1234h] db 1000011_0b,10_001_000b,34h,12h ; xchg cl,[bx+si+1234h] db 1000011_1b,11_001_010b ; xchg cx,dx db 10010_010b ; xchg ax,dx 的短格式1000011_1b是 0x87(XCHG r/m16, r16),1000011_0b是 0x86(XCHG r/m8, r8)。第二个字节10_001_000b拆开是 MOD=10、REG=001(CX)、R/M=000(BX+SI),对应xchg cx,[bx+si+1234h]。xchg cx,dx的编码是87 CA:MOD=11、REG=001(CX)、R/M=010(DX),因为交换对称,87 D1(REG=DX、R/M=CX)反汇编出来还是同一句话。
短格式10010_010b是 0x92,属于90+reg这组,专门用于 AX 与通用寄存器的交换。xchg ax,cx是 0x91,xchg ax,ax就是 0x90,也就是著名的 NOP。写反汇编器时遇到 0x90,既可以解成 xchg ax,ax 也可以解成 nop,两种习惯在资料里都能见到,但语义完全等价。
4.3 IN/OUT、LEA、LDS/LES 与固定编码指令
端口指令 IN/OUT 只有两种形式:立即数端口和 DX 间接端口,而且数据寄存器被锁死在 AL/AX 上,所以编码非常规整:
db 1110010_1b,20h ; in ax,20h db 1110010_0b,20h ; in al,20h db 1110110_1b ; in ax,dx db 1110110_0b ; in al,dx db 1110011_1b,20h ; out 20h,ax db 1110011_0b,20h ; out 20h,al db 1110111_1b ; out dx,ax db 1110111_0b ; out dx,al1110010_1b是 0xE5,末尾的 1 表示 AX;0xE4对应 AL。换成 DX 间接端口时,操作码变成1110110_1b即 0xED 和1110110_0b即 0xEC,而且不再跟立即数字节。OUT 方向的操作码是把 IN 的倒数第二位翻过来:E7/E6/EF/EE。这组指令没有任何 ModRM,写编码器时是最简单的一类。
LEA、LDS、LES 共享同一个 ModRM 结构,但语义差别很大:
db 10001101b,10_001_000b,34h,12h ; lea cx,[bx+si+0x1234] db 11000101b,10_001_000b,34h,12h ; lds cx,[bx+si+0x1234] db 11000100b,10_001_000b,34h,12h ; les cx,[bx+si+0x1234]8D是 LEA,REG 字段是目的寄存器 CX,R/M 是内存地址[BX+SI+0x1234],指令把有效地址算出来送进 CX,不访问内存。C5是 LDS,C4是 LES,它们的 REG 字段同样是目的通用寄存器,但会从 R/M 指向的内存连续读两个字:第一个字进 REG 指定的寄存器,第二个字进 DS 或 ES。这里最容易写错的是:LDS/LES 的第二个字固定进段寄存器,汇编器如果没检查 REG 是不是合法 16 位通用寄存器,就会编出畸形指令。
固定编码指令不需要操作数,编码器只需要留一张操作码表:
| 指令 | 操作码 | 隐含语义 |
|---|---|---|
| AAA | 37 | AX 十进制调整 |
| XLAT | D7 | AL = [DS:BX+AL] |
| LAHF | 9F | AH = FLAGS低字节 |
| SAHF | 9E | FLAGS 低字节 = AH |
| PUSHF | 9C | 压入 FLAGS 字 |
| POPF | 9D | 弹出到 FLAGS |
这些指令组装器时只需要按助记符查表,不需要任何操作数字段的处理逻辑。
5. 避坑记录:nasm 版本、二进制常量与指令编码边界
5.1 分组二进制常量:nasm 0.98 不识别 11_000_000b
现象:用nasm -v看到版本是 2.10.04 时一切正常,但换到 nasm 0.98,同样的源码直接报语法错误,定位在db 11_000_000b这行。
原因:nasm 从 2.0 开始支持数字中插入下划线分组,11_000_000b是二进制字面量的可视化写法,等价于0xC0。0.98 是 2007 年之前的版本,它的词法分析器不认下划线,把11_000_000b拆成了非法 token。
解决:升级到 nasm 2.10 以上,或者干脆在笔记里统一用十六进制0xC0代替分组二进制。我个人建议代码注释里保留分组写法便于人读,但db指令里全部写十六进制,省得换环境就翻车。
5.2 字节序:立即数和位移量必须小端存放
现象:把mov word [bx+si+0x1BCD],0x1234编码成C7 80 1B CD 12 34,反汇编出来变成mov word [bx+si+0xCD1B],0x3412,数据全反了。
原因:8086 内存按小端存储,16 位数值低字节在前。0x1BCD必须先写CD再写1B,0x1234必须先写34再写12。所以正确编码是C7 80 CD 1B 34 12,跟笔记里完全一致。
解决:写编码器时准备两个固定函数:write_disp16(imm)和write_imm16(imm),内部强制先输出低 8 位再输出高 8 位,从根上杜绝手写字节顺序的错乱。
5.3 指令方向位 d 与 word 修饰符的配合
现象:编码mov ax,cx时按直觉先写出操作码89,第二个字节填C1,结果反汇编成mov cx,ax——方向反了。
原因:89是 MOV r/m16, r16,d=0,REG 字段代表源寄存器,R/M 代表目的;8B才是 d=1,REG 代表目的。cat写反了操作码,等于把源和目的对调了。同样,笔记里mov word ax,0x1234这种写法在 nasm 语法下并不合法——word修饰符只能用在内存操作数上,寄存器本身宽度自明,nasm 会报错。但作者用这行注释表达的是:立即数送到 AX 时,也可以走C7长格式,此时 ModRM 的 REG 字段固定为 000。
解决:统一约定编码 MOV 时按「第一个操作数是目标」的直觉,目标在 REG 侧就选 d=1(8B),目标在 R/M 侧就选 d=0(89)。给mov ax,cx这类寄存器互传单独写测试用例,用 ndisasm 反汇编验证输出,别靠肉眼核对。
5.4 R/M=110 在 MOD=00 时的地址模式特例
现象:编码mov ax,[bp]时,查表发现 BP 的 R/M 编号是 101,于是写出 MOD=00、R/M=101,结果反汇编得到mov ax,[di],完全不是预期。
原因:8086 的 ModRM 里,MOD=00 且 R/M=110 表示的是 16 位直接地址[disp16],而不是[BP]。BP 的寻址必须出现在 MOD=01 或 MOD=10 里,也就是说[BP]最小也得编成[BP+disp8],disp8 填 0。
解决:笔记里没有专门列这个特例,但写汇编器时必须单独处理:MOD=00 时 R/M=110 直接走直接地址分支,不再走寄存器基址逻辑。[BP]统一降级为 MOD=01、R/M=110、disp8=0,多花一个字节,但保证反汇编结果正确。
5.5 固定编码指令没有操作数,别往后面补字节
现象:写db 0x9f实现 LAHF,反汇编出来是lahf没问题,但有的汇编器却把它识别成带操作数的指令,导致后续字节错位。
原因:LAHF、SAHF、PUSHF、POPF、XLAT 这组指令是固定单字节操作码,不存在 ModRM,也不存在扩展操作数。把9F当普通操作码去匹配「操作码 + 寻址模式」两字节模板,就会串读后面的代码字节。
解决:在编码器里为固定指令单独建一个分支,匹配到助记符后直接输出单字节,不做任何后续解析。这个分支顺序还要放在通用双字节模板之前,否则9F会被通用逻辑吞掉。
6. 二次开发:把解码笔记落成汇编器编码表
读完这份笔记,最顺理成章的动作就是把它转成自己汇编器里的编码表。我一般分三步走:先把每条db序列提取成「助记符 + 操作数 + 字节」三列的结构化数据,再按操作数类型分组,最后针对每一组写编码函数。
以 MOV 为例,从笔记里能归出五类:寄存器到寄存器走8B /r,内存到寄存器走8B /r加位移量,寄存器到内存走89 /r,立即数到寄存器优先走B8+reg,立即数到内存走C7 /0。对应 C 语言风格的编码函数,骨架长这样:
uint16_t encode_mov(operand_t *dst, operand_t *src) { uint8_t opcode, modrm; if (dst->type == OP_REG && src->type == OP_REG) { /* 16 位寄存器互传:8B /r,REG 是目的 */ opcode = 0x8B; modrm = 0xC0 | (dst->reg << 3) | src->reg; return (opcode << 8) | modrm; } if (dst->type == OP_REG && src->type == OP_MEM) { /* mov ax,[bx+si+disp16] */ opcode = 0x8B; modrm = 0x80 | (dst->reg << 3) | src->rm; /* 后面还要追加 disp16 的小端写入,这里省略 */ } if (dst->type == OP_MEM && src->type == OP_IMM) { /* mov word [bx+si+0x1BCD],0x1234 -> C7 /0 */ opcode = 0xC7; modrm = 0x80 | (0 << 3) | dst->rm; /* 追加 disp16 和 imm16 */ } /* 其余分支按表补齐 */ }核心思路是「操作数类型决定操作码组,寄存器编号决定 ModRM 的 REG/R/M 字段」。每实现一组指令,就用 nasm 汇编同一句源码,再用ndisasm反汇编输出对比:
nasm -f bin a.asm -o a.bin ndisasm -b 16 a.bin如果笔记里写的 db 字节和 nasm 生成的一致,这组编码就是可靠的;不一致就回头查是 d 位方向写反、ModRM 的 REG 填成寄存器编号还是位移量大小端写错。从那以后,我每次新增指令都强制走一遍「先查笔记、再写 db 验证、最后落编码函数」的流程,这套笔记相当于一个最小的验证台,比对着 Intel 手册啃快得多。希望帮到你。
本文还有配套的精品资源,点击获取