简介:面向编写8086汇编器、需要吃透机器指令编码细节的开发者,这份个人整理的笔记以示例驱动梳理8086机器语言解码要点。内容从指令格式、寄存器与寻址模式入手,覆盖操作码、立即数、字节/字/双字、固定编码指令及双操作数指令编码,并对MOV指令的多类编码展开说明;节选中还给出NASM版本提示、十六进制字节序列与汇编语句对照,便于把助记符与机器码逐字节对应起来,理解最长指令、前缀、MOD/REG/R/M等字段的组合关系,以及目的与源操作数的编码约束。资源包为1个docx文档,约1.94MB,轻量集中,适合作为查阅型笔记。已有127人学习,可供汇编器编写、逆向分析或8086指令编码复习时对照参考。
1. 一条指令占满 8 个字节:8086 机器语言解码到底在拼什么
汇编器写到最后,难的从来不是查表,而是同一批位在不同上下文里有不同含义。8086 机器语言解码的入口是一条看起来最长的指令:F0 3E 81 80 CD AB 34 12,八个字节,依次拆开是总线锁定前缀、段超越前缀、操作码、寻址模式字节、16 位位移量、16 位立即数。每一段的长度都不固定,而是由它前面某一位决定:位移是 1 字节还是 2 字节,看寻址模式字节的高两位;立即数是 1 字节还是 2 字节,看操作码里的 w 位。这就是 8086 编码的核心矛盾:6 位操作码不够用,于是把「做什么」和「操作谁」拆进两个字节分别表达,代价是长度可变。搞懂这条链路,写汇编器、看机器码 dump 都不再是玄学。
2. mod/reg/r/m:三个字段怎么把「谁、跟谁、怎么找」一次说完
2.1 操作特征字节的三段划分
8086 的双操作数指令几乎都会多带一个字节,Intel 叫它 ModR/M(操作特征字节)。它的 8 位被切成三段,没有一位是填充:
| 位域 | 位数 | 位置 | 作用 |
|---|---|---|---|
| mod | 2 | bit7~6 | 决定 r/m 是寄存器还是内存;内存时还决定位移量长度 |
| reg | 3 | bit5~3 | 一个通用寄存器编号,或段寄存器编号,或被借去当扩展操作码 |
| r/m | 3 | bit2~0 | 第二个操作数:寄存器编号,或内存寻址方式 |
写成解码代码只有三行:
/* 从取指缓冲里取出一个 ModR/M 字节,拆成三段 */ uint8_t modrm = fetch8(); /* 紧跟操作码之后的那个字节 */ uint8_t mod = (modrm >> 6) & 0x03; /* 高 2 位:寻址大类 */ uint8_t reg = (modrm >> 3) & 0x07; /* 中 3 位:寄存器 / 扩展操作码 */ uint8_t rm = modrm & 0x07; /* 低 3 位:第二操作数 */fetch8()表示从当前指令流指针处读一个字节并把指针加一,mod、reg、rm全部靠移位和掩码取出,不做任何查表。这里值得注意的机制是:三段加起来正好 8 位,硬件解码器可以把这 8 根线直接接到三组选择器上,不需要额外的对齐或拼接逻辑。8086 只有约 2.9 万个晶体管,这种「一个字节三个用途」的密度设计不是巧合。
mod只有四个取值,含义却是分层的:00、01、10都表示内存操作数,区别只在后面跟几个字节的位移;11表示两个操作数都是寄存器,此时r/m直接当寄存器编号用。
2.2 w 位怎么让同一组 3 位编号管两种寄存器宽度
reg和r/m都只有 3 位,最多表示 8 个寄存器。但 8086 有 8 个 16 位通用寄存器和 8 个 8 位通用寄存器,正好各 8 个。解决办法是操作码里的 w 位:w=1 表示字操作,w=0 表示字节操作,同一组 3 位编号在两种模式下映射到不同的寄存器。
| 编号 | w=1(16 位) | w=0(8 位) |
|---|---|---|
| 000 | AX | AL |
| 001 | CX | CL |
| 010 | DX | DL |
| 011 | BX | BL |
| 100 | SP | AH |
| 101 | BP | CH |
| 110 | SI | DH |
| 111 | DI | BH |
这张表解释了一个新手常觉得奇怪的现象:8 位寄存器的高半部分编号是100~111,而不是从000另起一套。因为 8086 没有 SPL/BPL/SIL/DIL,100~111这四个位置空着,正好塞进 AH、CH、DH、BH,跟 AX、CX、DX、BX 的低半部分编号对齐成一对。
于是mov ax, cx和mov al, cl的差别只有操作码最低位:
db 8Bh, 11_000_001b ; 10001011 11000001 -> mov ax, cx w=1 reg=000(AX) rm=001(CX) db 8Ah, 11_000_001b ; 10001010 11000001 -> mov al, cl w=0 reg=000(AL) rm=001(CL)两个字节的第二字节完全相同,只有第一字节最低位从 1 变成 0,操作数宽度就整体切换了。这种「一个位控制全局宽度」的做法在后续 x86 里一直保留,只是宽度从 1 位变成了 REX.W 那样的独立前缀。
段寄存器是另一套编号,因为段寄存器只有 4 个,且不受 w 位影响:00是 ES,01是 CS,10是 SS,11是 DS。它只在8C、8E、06、0E、16、1E、07、17、1F这几种指令里出现在reg字段上。
2.3 mod=00 且 r/m=110 的特例:为什么 [BP] 不能用最短形式写
mod和r/m组合起来一共 32 种情况:mod=11的 8 种是寄存器,mod=00/01/10的 24 种是内存。这 24 种按理说应该正好对应 8 种基址组合乘以 3 种位移长度。问题出在mod=00这一行:本来 8 个r/m值应该分别对应[BX+SI]、[BX+DI]、[BP+SI]、[BP+DI]、[SI]、[DI]、[BX],还差一个位置。
8086 的设计是把mod=00, r/m=110这个组合挖出来,规定它表示「后面跟 2 字节位移,当作直接地址」,而不是[BP]。
; 直觉上以为 00_000_110 是 [bp],实际上不是 db 8Bh, 00_000_110b, 00h, 00h ; mov ax, [0x0000] —— 06 后面两个字节是绝对地址 db 8Bh, 01_000_110b, 00h ; mov ax, [bp+0] —— 想要 [bp] 只能这么写第一条占 4 个字节,mod=00本该没有位移,但因为r/m=110这个特例,解码器要额外吞掉两个字节当disp16;第二条占 3 个字节,mod=01表示「有 1 字节位移」,位移填 0,等效于[BP]。这也是为什么用汇编器写mov ax, [bp]时,你看到的目标码永远是8B 46 00而不是更短的8B 06。
顺带一提,mod=00, r/m=101也是特例,它表示[DI],没有位移,跟[BP+DI]共享101这个值,区别只在 mod 是01/10还是00。这两处特例是 8086 编码里最容易写错的分支,掉进去过一次之后,基本就不会忘了。
3. MOV 的九个编码家族:同一条助记符为什么被拆成九套模板
3.1 短格式与长格式:B0~BF 和 C6/C7 为什么并存
mov是 8086 里编码家族最多的指令,按操作数类型可以拆成九类,但底层其实只有两套模板在反复用。
第一套是「操作码里带寄存器号」的短格式:B0+r到B7+r是立即数送 8 位寄存器,B8+r到BF+r是立即数送 16 位寄存器。这里的r就是 3 位寄存器编号,直接塞进操作码最低 3 位,所以整条指令只有 2 或 3 个字节,不需要 ModR/M。
第二套是「借 ModR/M 的 reg 字段当扩展操作码」的长格式:C6 /0和C7 /0。斜杠后面的 0 表示reg字段必须是000,这个位置不再表示寄存器,而是表示「这是 C6/C7 组的第 0 号操作」。剩下 7 个位置001~111留给其他指令用。
; 短格式:操作码自带寄存器号,2 字节 db 1011_0_011b, 0x34 ; B3 34 -> mov bl, 0x34 db 1011_1_011b, 0x34, 0x12 ; BB 34 12 -> mov bx, 0x1234 ; 长格式:多一个 ModR/M,3 或 4 字节 db 0C6h, 11_000_000b, 0x34 ; C6 C0 34 -> mov al, 0x34 db 0C7h, 11_000_000b, 0x34, 0x12 ; C7 C0 34 12 -> mov ax, 0x1234注意mov al, 0x34有两条合法编码:B0 34(2 字节)和C6 C0 34(3 字节)。汇编器永远选前者,但反汇编器两条都得认。这就是 CISC 可变长编码的第一个真实代价:同一条语义有多种字节表示,机器码不具备唯一性。
那为什么还要留C6/C7这套更长的模板?因为短格式的r字段只能指向寄存器,没法指向内存。而mov word [bx+si+0x1BCD], 0x1234这种「立即数直接进内存」的需求,只能靠 ModR/M 的r/m来承载目标地址。
db 0C7h, 10_000_000b, 0CDh, 1Bh, 34h, 12h ; C7 80 CD 1B 34 12 -> mov word [bx+si+0x1BCD], 0x1234 ; mod=10 表示 2 字节位移,reg=000 表示 /0,r/m=000 表示 [BX+SI] ; 位移 = CD 1B = 0x1BCD,立即数 = 34 12 = 0x1234整条指令 6 个字节:1 字节操作码、1 字节 ModR/M、2 字节位移、2 字节立即数。短格式省下来的这个 ModR/M 字节,在目标变成内存时是省不掉的。
3.2 段寄存器走的是另一套模板:8C/8E 与 reg 字段的复用
段寄存器不参与 w 位那套编号,所以它们的传送指令自成一组。8E /r是「内存或通用寄存器 → 段寄存器」,8C /r是「段寄存器 → 内存或通用寄存器」,这两个操作码的最低两位固定是10和00,w 位在这里没有意义。
; 8E: mov Sreg, r/m16,reg 字段填段寄存器号 db 10001110b, 11_0_00_000b ; 8E C0 -> mov es, ax db 10001110b, 11_0_11_000b ; 8E D8 -> mov ds, ax db 10001110b, 00_0_00_100b ; 8E 04 -> mov es, [si] ; 8C: mov r/m16, Sreg db 10001100b, 11_0_00_000b ; 8C C0 -> mov ax, es db 10001100b, 10_0_00_000b, 34h, 12h ; 8C 80 34 12 -> mov [bx+si+0x1234], es这里最值得记住的是方向:8E的段寄存器在reg字段(永远是源),8C的段寄存器也在reg字段(永远是目标)。两者的区别由操作码本身决定,不靠 d 位。这与MOV通用寄存器之间的88/89/8A/8B四兄弟不同——那四条是靠bit0传宽度、bit1传方向,信息密度更高,但理解成本也更高。
3.3 固定编码与隐含操作数:AAA、XLAT、LAHF 怎么省掉 ModR/M
有些指令的操作数完全固定,硬件根本不需要再取字节去判断,于是整条指令压成 1 个字节。
db 37h ; aaa —— 隐含 AL/AH,无操作数 db 0D7h ; xlat —— 隐含 [ds:bx+al] -> al db 9Fh ; lahf —— 隐含 flags 低字节 -> AH db 9Eh ; sahf —— 隐含 AH -> flags 低字节 db 9Ch ; pushf db 9Dh ; popfxlat是最典型的例子:它同时用 AL 当索引、用 BX 当基址、用 DS 当段,结果又写回 AL。整个语义一个字节说完,解码器只要看到D7就知道该干什么。用 C 写出来的模拟逻辑大概是这样:
case 0xD7: /* xlat: AL = DS:[BX + AL] */ al = mem_read(segment_base(ds) + bx + al); /* al 既是输入也是输出 */ break;segment_base()负责把 16 位段值左移 4 位再加上偏移,这是 8086 实模式的地址换算方式。这类单字节指令的存在,反过来解释了为什么 8086 的单字节操作码空间那么紧张——最高频、最简单的操作被优先塞进 1 字节,剩下的才需要 ModR/M 去描述。
4. 位移量、立即数与前缀:长度不固定的字节流怎么切
4.1 小端:0x1234 为什么落成 34 12
8086 是多字节小端:低位字节放在低地址。这条规则在笔记里体现得非常密集——所有 16 位的位移和立即数,写出来都是「低字节在前」。
db 0C7h, 11_000_000b, 34h, 12h ; C7 C0 34 12 -> mov word ax, 0x1234 db 0A1h, 34h, 12h ; A1 34 12 -> mov ax, [0x1234] db 0B8h + 3, 34h, 12h ; BB 34 12 -> mov bx, 0x1234三条指令里0x1234全部写成34 12,没有任何例外。常见的解释是:8086 内部数据总线是 16 位,从偶地址读一个字可以在一个总线周期内完成,低字节放在低地址可以让地址运算和取指共用同一套加法器,不必做字节交换。不管是哪种解释,对写解码器的人来说结论只有一个:读出 2 字节之后必须low | (high << 8)拼回去,写反了就会得到0x3412。
4.2 位移量长度由 mod 决定,而不是由数值大小决定
这一点是新手最容易踩的隐坑:位移量占几个字节,跟位移数值大不大毫无关系,只跟mod有关。
| mod | 位移长度 | 含义 |
|---|---|---|
| 00 | 0 字节(r/m=110 时 2 字节) | 无位移,或直接地址 |
| 01 | 1 字节 | disp8,解码时符号扩展到 16 位 |
| 10 | 2 字节 | disp16 |
这意味着[BX+SI+5]有两种合法写法,长的那个完全符合规范:
db 8Bh, 10_000_000b, 05h, 00h ; 8B 80 05 00 -> mov ax, [bx+si+5] 用了 2 字节位移 db 8Bh, 01_000_000b, 05h ; 8B 40 05 -> mov ax, [bx+si+5] 汇编器实际输出两条指令语义完全相同,长度差 1 字节。汇编器总是选短的,但解码器必须两种都认——反汇编时如果只按「位移小就用 1 字节」去猜,遇到别人手写的db数据就会整段错位。
mod=01的disp8是有符号的,解码时要先做符号扩展再参与地址计算。写[bx+si-5]时汇编器输出的是FB(即 -5 的补码),解码端如果当成无符号数251去加,地址就跑到完全不相干的地方去了。
4.3 立即数长度由 w 位和操作码共同决定
同一组操作码下,立即数的宽度由 w 位决定:C6配imm8,C7配imm16;80 /r配imm8,81 /r配imm16。而B0~B7和B8~BF这两段,宽度直接编在操作码里,不靠 w 位。
; 80 组和 81 组:同一个扩展操作码,两种立即数宽度 db 100000_0_0b, 11_000_000b, 05h ; 80 C0 05 -> add al, 5 db 100000_0_1b, 11_000_000b, 05h, 00h ; 81 C0 05 00 -> add ax, 580 C0 05是 3 字节,81 C0 05 00是 4 字节,差别只有操作码最低位和后面那个补 0 的字节。很多手写机器码的人习惯把add ax, 5写成80 C0 05,结果执行时变成add al, 5,高位被静默吃掉——这类错误不会报异常,只会让数值算错,排查起来相当费劲。
4.4 前缀:8 字节上限是这么凑出来的
回到第 1 章那条最长的指令:
; F0 3E 81 80 CD AB 34 12 ; F0 lock 前缀,总线锁定 ; 3E 段超越前缀,指定 DS ; 81 操作码,ADD r/m16, imm16 ; 80 ModR/M:mod=10 reg=000(/0) r/m=000([BX+SI]) ; CD AB disp16 = 0xABCD,作为有符号数是 -0x5433 ; 34 12 imm16 = 0x1234加起来1+1+1+1+2+2 = 8字节,正好是 8086 单条指令的长度上限。这里有个容易被忽略的细节:[BX+SI]的默认段本来就是 DS,3E这个段超越前缀在语义上是冗余的。也就是说,这条「最长指令」其实是靠一个可有可无的前缀凑满 8 字节的。真正有意义的组合最多到 7 字节。
前缀必须放在操作码之前,多条前缀可以叠加(虽然通常没有意义),解码器的处理方式是在读操作码之前先循环吞掉所有前缀字节,每读到一个26/2E/36/3E就更新一次当前段寄存器,读到F0就置一个锁定标志。这个循环不能省,否则遇到带前缀的指令会直接把它当操作码解码,后面整段字节流全部错位。
5. 避坑:8086 编码笔记里最容易翻车的几处
5.1 工具链层面:NASM 版本与二进制常量写法
现象:明明抄的是11_000_000b,汇编时报语法错误,或者被当成十进制数算出了别的值。
原因:带下划线分组的二进制常量不是所有 NASM 版本都支持。老版本(比如 0.98 系列)不认这种写法,只认连续的11000000b。
解决:用nasm -v先确认版本,2.10 以上再放心写分组形式;或者干脆统一写成十六进制,11_000_000b就是0xC0,可读性差一点但不会出错。
$ nasm -v NASM version 2.10.04现象:本地生成的目标码是对的,换台机器跑出来多出几个字节,或者出现了 32 位才有的编码。
原因:源文件头没写[cpu 8086]和[BITS 16],NASM 会按当前默认的 CPU 和位宽生成代码,push、mov这类指令的编码会悄悄变长。
解决:每个.asm文件头两行固定写死,别依赖命令行参数:
[cpu 8086] [BITS 16]写机器码笔记时尤其要这样,因为你验证的不是「汇编器能编出来」,而是「8086 会怎么解码」,两者差一个字节就对不上了。
5.2 编码层面:[bp] 与 disp16 的边界
现象:手写8B 06想表达mov ax, [bp],运行结果读到了内存地址 0 处的内容。
原因:mod=00, r/m=110是「disp16 直接地址」的特例,06后面的两个字节会被当成绝对地址,而不是[BP]。
解决:[BP]必须走mod=01加一个 0 位移,写成8B 46 00;同理[BP+SI]、[BP+DI]在无位移时也得用mod=01补零。
现象:mov ax, [bx+si-5]手写成8B 80 FB FF,能跑但比预期长一个字节,而且和汇编器输出对不上。
原因:位移值小的时候,mod=01的disp8就够了,汇编器输出的是8B 40 FB。
解决:手写机器码时按「先定 mod,再定位移长度」的顺序推,而不是按数值大小估。disp8的范围是 -128~127,超出才需要mod=10。
5.3 语义层面:段寄存器与非法组合
现象:db 8Eh, 11_0_01_000b在 8086 上确实能跑成mov cs, ax,但同样的字节放到后续 CPU 上直接触发异常。
原因:8E /1这个编码在 8086 上没有被禁止,80286 之后把它定义为非法,因为往 CS 里随便塞值会破坏取指流。
解决:自己写汇编器时,8E组的reg=001应该直接在语义检查阶段报错,不要因为「8086 手册里有」就放行。编码合法不等于语义合法,这两层检查要分开做。
现象:mov word [bx+si+0x1BCD], 0x1234和mov word ax, 0x1234都用了C7,但前者 6 字节、后者 4 字节,手写时长度算错导致后续字节错位。
原因:C7只是模板,真实长度由 ModR/M 里的 mod 和 r/m 决定,寄存器目的话mod=11没有位移,立即数照旧占 2 字节。
解决:把长度计算写成公式而不是背表:1(操作码) + 1(ModR/M) + disp_len(mod) + imm_len(w),每次手写前先代一遍。
6. 设计取舍:固定 6 位操作码撑不住的时候,8086 借了什么
把前面几章串起来看,8086 的编码方案其实做了一个相当激进的取舍:把「指令语义」和「寻址方式」彻底正交拆开。指令语义压进 6 位操作码加 d/w 两个标志位,寻址方式整体外包给 ModR/M 字节。好处是硬件解码器极小,同一条指令能覆盖寄存器、内存、立即数各种组合而不增加操作码;代价是长度可变,而且同一条语义有多种字节表示。
这个取舍最直接的后果是操作码空间很快见底。8086 的解法是「借用 reg 字段当扩展操作码」:80到83这四个操作码,每个都用 reg 字段的 8 个值扩展成 8 条指令,000是 ADD、001是 OR、010是 ADC、011是 SBB、100是 AND、101是 SUB、110是 XOR、111是 CMP。也就是说80这一个字节实际承载了 8 条指令,代价是必须再跟一个 ModR/M 字节,最短也是 2 字节。
; 同一个操作码 000000_0_1b,靠 reg 字段区分八条运算指令 db 000000_0_1b, 11_000_000b, 05h, 00h ; 01 C0 05 00 -> add ax, 5 db 000000_0_1b, 11_001_000b, 05h, 00h ; 09 C8 05 00 -> or ax, 5 db 000000_0_1b, 11_101_000b, 05h, 00h ; 29 E8 05 00 -> sub ax, 5另一个能看出空间紧张程度的证据是80和82在 8086 上完全等价——82就是80的别名,编码器可以随便用哪个,解码器两个都得认。这个冗余在后续 CPU 里被收掉了,82变成未定义指令。一种编码方案把别名都留着用,说明它在设计时就已经算到空间快用完了。后来的0F两字节操作码前缀,本质上是把这个「借位」思路推到极致:整个字节都拿去做扩展。
要验证自己的理解对不对,最省事的办法不是背表,而是写个几十行的反汇编器,拿笔记里的db数据跑一遍,再和 NASM 汇编出来的结果对拍。对不上的地方,基本都落在三处:mod=00, r/m=110的特例、位移量的符号扩展、前缀的循环吞噬。我一般会拿一张表把「助记符 / 字节序列 / 是否可短格式」三列并排写,遇到不一致就回去查 ModR/M 的位。
| 验证项 | 期望结果 | 常见偏差 |
|---|---|---|
mov ax, 0x1234 | B8 34 12(3 字节) | 误用C7 C0 34 12(4 字节) |
mov ax, [bp] | 8B 46 00 | 误写8B 06 00 00 |
add ax, 5 | 05 05 00或81 C0 05 00 | 误写80 C0 05变成字节操作 |
push ax | 50(1 字节) | 误用FF F0(2 字节) |
这份笔记的价值也正在这里:它把 MOV、PUSH、POP、XCHG、IN、OUT、XLAT、LEA、LDS、LES、LAHF、SAHF、PUSHF、POPF、ADD 这些指令逐条拆到二进制位,每条都给出db形式的等价写法和对应的助记符。对写汇编器或反汇编器的人来说,这就是一份可以直接对拍的参照。需要的话,去把这份资源取下来,拿它的db行当测试用例跑一遍,比看十页手册都快。
从那以后我每写一条db注释,都强制用汇编器反汇编回来看一眼,确认字节、助记符、操作数三者闭环,才敢往下写。希望帮到你。
本文还有配套的精品资源,点击获取