简介:这份笔记围绕8086机器语言解码展开,面向正在编写8086汇编器、需要核对指令编码的开发者,也可供学习16位x86指令格式的读者参考。内容从操作码、寻址模式、操作数三类基本结构讲起,梳理通用寄存器与段寄存器编号、立即数、字节/字/双字等概念,并给出固定编码指令和双操作数指令的编码规律。重点以MOV指令为例,逐条列出寄存器互传、内存与寄存器互传、常数传送、段寄存器传送等形式的机器码,如0C7h、8Bh、8Ah、B0h等,并附NASM 2.10.04下可验证的汇编片段,提醒避免使用不识别分组二进制常量的旧版本。压缩包仅含1个docx文件,约1.94MB,篇幅集中、便于检索。已有127人学习,适合作为汇编器开发时的编码对照与排错参考。
1. 一行 6 个字节的 MOV,值得单独拆一遍
mov word [bx+si+0x1BCD],0x1234在汇编器里是一行,落到机器里是C7 80 CD 1B 34 12。六个字节:一个操作码、一个同时塞了寻址模式和寄存器编号的字节、两字节位移、两字节立即数。有人把这套东西一条条手工拆开,用 NASM 的db伪指令把机器码直接写出来,再用注释对回汇编语句,攒成了这份 8086 机器语言解码示例笔记。
它不教你怎么写汇编程序,教的是反过来读:拿到一串十六进制,说得出这是什么指令、操作数在哪个寄存器、位移占几个字节。写汇编器、反汇编器、指令级调试工具,或者带人做 x86 编码实验的,看它比翻手册快——手册给你规则,它给你四十多条已经拆好的实例,还顺手记了工具链的版本坑。下面按笔记自己的组织顺序走:先看编码模型,再看怎么跑起来,最后看怎么改成自己的查表工具。
2. 笔记的结构与编码模型:一条指令最多 8 个字节
这份材料的形式很朴素,一个a.asm,每段是几行db,后面跟一行注释写明等价的汇编语句。看起来像草稿,但它把 8086 的编码规则拆成三层:字节布局、字段语义、字段取值。这三层捋直了,后面所有指令族都是同一套模板的变形。
2.1 用 db 伪指令写机器码:让笔记本身可以被汇编
笔记里所有指令都写成db加十六进制字节或者带下划线的二进制字面量,而不是直接写mov ax,0x1234。这个选择是有讲究的:直接写汇编语句,汇编器替你编码,你永远看不到中间过程;写成db,字节是你亲手排的,nasm只负责把它们原样倒进目标文件,任何一个字段写错,反汇编出来立刻对不上。
[cpu 8086] [BITS 16] ; mov word [bx+si+0x1BCD],0x1234 ; db 0c7h,80h,0xcd,0x1b,0x34,0x12 mov word ax,0x1234 db 0c7h,11_000_000b,0x34,0x12 mov al,0x34 db 0c6h,11_000_000b,0x34几行代码里有两种写法混排:一种直接写汇编、让汇编器编码(注释掉的那行和最后两行),一种是db手工排字节。混排的目的是对照——同一段汇编,一条走汇编器的路径,一条走手工的路径,汇编成二进制后ndisasm反出来必须一致,不一致就说明你对字段的理解有偏差。
[cpu 8086]和[BITS 16]这两行不能省。前者把指令集限制在 8086,防止汇编器接受 386 之后的编码;后者决定立即数和位移的宽度,16 位模式下mov ax,0x1234编成B8 34 12,32 位模式下同一个助记符就是另一样子。带下划线的二进制常量11_000_000b是笔记里最常用的写法,下划线只是分隔符,汇编器会忽略它,作用是把 8 位的操作特征字节按mod | reg | r/m切成11_000_000三段,肉眼能直接对上是哪一段。
2.2 字节布局:前缀、操作码、modrm、位移、立即数
笔记里给了一句总结:最长的指令可达 8 个字节,包含总线锁定前缀、段超越前缀、操作码、寻址模式、偏移常量、立即数。例子是F0 3E 81 80 CD AB 34 12,lock add word [ds:bx+si-0x5433],0x1234。这 8 个字节的排布顺序就是 8086 的通用指令骨架:
| 顺序 | 组成部分 | 长度 | 说明 |
|---|---|---|---|
| 1 | 总线锁定前缀 | 1 字节 | F0,仅 LOCK 类指令 |
| 2 | 段超越前缀 | 1 字节 | 26/2E/36/3E对应 ES/CS/SS/DS |
| 3 | 操作码 | 1~2 字节 | 决定指令族 |
| 4 | modrm | 1 字节 | MOD(2) REG(3) R/M(3) |
| 5 | 位移 | 0/1/2 字节 | 由 MOD 字段决定 |
| 6 | 立即数 | 0/1/2 字节 | 由操作码和 w 位决定 |
这个表是整套解码逻辑的地基。写解码器的时候,你其实是在按顺序消费这个字节流:先循环读前缀(遇到F0/26/2E/36/3E/64/65/66/67就继续往后走),再读操作码,然后查表判断这条指令有没有 modrm,有就解析 modrm,再按 MOD 字段算位移长度,最后按操作码表算立即数长度。每一步的偏移量都是前一步算出来的,不是固定的。
F0 3E 81 80 CD AB 34 12这个例子最值得琢磨的地方在位移。81是 ADD 的立即数到 r/m16 编码,/0是 ADD。modrm 是80,拆成10 000 000:MOD=10,意思是操作数是内存且带 16 位位移;REG=000,作为扩展操作码告诉 CPU 这是 ADD;R/M=000,指向[BX+SI]。接下来两字节CD AB是小端序的0xABCD,按 16 位有符号数解释就是-0x5433。最后两字节34 12才是立即数0x1234。位移是负数这一点在解码时经常被忽略——8086 的 disp16 是带符号的,反汇编输出里应该显示为负偏移,直接当无符号数打出来就会出现+0xABCD这种奇怪的地址。
2.3 MOD / REG / R/M 三张表与 w、d 两个位
操作特征字节是整份笔记的核心,几乎所有双操作数指令都要过这一关。MOD 两位决定操作数形态:00是内存、无位移(但r/m=110是特例,直接寻址 disp16);01是内存、带 1 字节位移;10是内存、带 2 字节位移;11是寄存器,此时 R/M 字段不再是寻址方式而是一个寄存器编号。
REG 三位在大多数双操作数指令里是寄存器编号,取值 000~111 对应AX/CX/DX/BX/SP/BP/SI/DI;在 8 位操作时同一套编号对应AL/CL/DL/BL/AH/CH/DH/BH。笔记里专门列了 16 位通用寄存器编号、8 位通用寄存器编号、段寄存器编号三张表,这个分法是必要的,因为同一个数字011在 16 位语境下是 BX,在 8 位语境下是 BL,在段寄存器语境下是 DS。
段寄存器只有 4 个,所以只占两位,在 modrm 里只用到 REG 的低两位:00=ES、01=CS、10=SS、11=DS。这也是为什么8E和8C这两条指令的 REG 字段高位看起来总是 0——笔记里写的11_0_00_000b拆开就是mod=11、0是保留位、00是 ES、000是 AX。
剩下两个位贯穿所有算术和传送指令:
w(word)位:w=1表示字操作(16 位),w=0表示字节操作(8 位)。它通常编码在操作码的最低位。d(direction)位:d=1时 REG 字段表示目的寄存器,源由 MOD 和 R/M 组合确定;d=0时反过来,REG 是源,MOD/R/M 是目的。
d位是理解 MOV 和 ADD 全部变体的钥匙。00和01这一对操作码只差最低位,00是ADD r/m8, r8(d=0),01是ADD r/m16, r16;02和03又是另一对,02是ADD r8, r/m8(d=1),03是ADD r16, r/m16。同一个助记符 ADD,四条不同的操作码,区别只在d和w两位上。笔记里把 ADD 的四种组合全列了出来,db 000000_1_1b,11_000_001b ;add ax,cx和db 000000_0_1b,10_000_000b,34h,12h ;add [bx+si+0x1234],ax正好是d位取 1 和取 0 的对照。
3. 把笔记跑起来:NASM 汇编与 ndisasm 反汇编对照
光看是记不住的,这份笔记的价值在于它能被真正汇编出来,然后反汇编回去,形成闭环。闭环跑通一次,你对 modrm 的理解就稳了;跑不通,错的字节会自己跳出来指认你。
3.1 工具链版本:先确认你的 NASM 认不认分组二进制常量
笔记里留了一条血泪经验:NASM version 2.10.04,并且明确写了「勿使用 nasm-0.98 版本,其不识别分组的二进制常量,如11_000_000b」。这个坑踩过的人不少——你从旧系统里翻出一个十几年前的nasm,喂进去直接报语法错误,报错行指着11_000_000b,而你会以为是下划线写法本身有问题,改成11000000b之后又能过,于是误判成「下划线不能用」。
# 先看版本 nasm -v # NASM version 2.10.04 compiled on Jun 7 2021版本低于 2.0 的 NASM 不支持数字中的下划线分隔符。这条限制对整份笔记是致命的,因为作者用下划线把 8 位字节切成mod_reg_rm三段,切没了就看不出字段边界。所以第一步永远是nasm -v,确认主版本号至少是 2。
除了 NASM 本身,还需要ndisasm,它一般跟 NASM 一起装。两条命令都用-b 16指定 16 位模式,跟源码里的[BITS 16]对应上;忘了这个参数,ndisasm默认按 16 位反汇编(老版本)或者按你给的参数走,新版一定要显式指定。
3.2 汇编成纯二进制,再反汇编回来
汇编这一步要注意输出格式。默认的bin格式就是从偏移 0 开始铺字节,没有 ELF/COFF 那些头部,ndisasm读进来不会有偏移错位。命令串下来是这样:
# 汇编成纯二进制,-f bin 表示不生成任何容器格式 nasm -f bin a.asm -o a.bin # 反汇编,-b 16 指定 16 位,-o 0x0 指定起始偏移 ndisasm -b 16 -o 0x0 a.bin-f bin这个参数容易被忽略。用默认的elf或者obj格式,目标文件前面会插进节头和符号表,ndisasm从头读会把头部当成指令,输出一堆db 0x7f、db 0x45之类的垃圾,然后你会误以为自己的编码写错了。
跑完应该看到类似这样的输出:
00000000 C780CD1B3412 mov word [bx+si+0x1bcd],0x1234 00000006 C7C03412 mov ax,0x1234 0000000A B034 mov al,0x34 0000000C C6C034 mov al,0x34注意C7 80 CD 1B 34 12被反汇编成带0x1bcd正偏移的形式,因为CD 1B最高位是 0,按有符号解释就是正数。想看负数位移的效果,得自己写一段disp16高位置 1 的编码,比如把CD AB放进 modrm 是10 xxx xxx的指令里。
3.3 写个小脚本做批量对照:注释和反汇编结果自动比对
一段一段手工看几十条输出,眼睛会花,而且很容易放过一两条对不上的。比较省事的做法是让脚本把源码里的注释抽出来,跟ndisasm的输出对齐比较。
import re import subprocess # 从 a.asm 里抽出注释中的汇编语句,注释格式是 "; mov ax,cx" with open("a.asm", encoding="utf-8") as f: src = f.read() expect = [m.group(1).strip() for m in re.finditer(r";\s{2,}([a-z].*)$", src, re.M)] # 反汇编成文本,去掉地址和机器码列,只保留助记符部分 out = subprocess.run(["ndisasm", "-b", "16", "-o", "0x0", "a.bin"], capture_output=True, text=True).stdout actual = [line.split(None, 2)[2].strip() for line in out.strip().splitlines()] # 逐行比对,不一致的打印出来人工复核 for i, (e, a) in enumerate(zip(expect, actual)): flag = "OK " if e == a else "DIFF" print(f"{flag} {i:03d} expect={e:<40} actual={a}")这段脚本的逻辑很直白:用正则把注释里;之后、至少两个空格之后的汇编语句抓出来当期望值,用ndisasm的输出当实际值,逐行zip比对。参数上有两点要调:正则里的\s{2,}是为了跳过那些把;当纯注释用的行;split(None, 2)按空白切三刀,前两刀是地址和机器码,第三刀往后才是指令文本。
差异行会集中在几个地方——地址计算偏移、助记符写法不同(mov word [bx+si+0x1bcd]里的word有的版本会省略)、以及笔记本身把非法编码也写进去了。最后这类差异恰恰是最有价值的,说明你的编码在语法上成立,但在 CPU 上跑不了。
4. MOV 全家族拆解:一条指令的九种编码路径
MOV 是 8086 里变体最多的指令,笔记用了接近一半的篇幅把它拆开。拆完 MOV,其他指令族基本都是套模板。按编码来源分,MOV 可以归成三条通道:通用寄存器之间的 reg/rm 通道、立即数通道、段寄存器专用通道。
4.1 reg/rm 通道:88/89/8A/8B 与 d 位的两种方向
这一族的操作码是1000 10dw,展开就是四个:
| 操作码 | 二进制 | 含义 |
|---|---|---|
| 88 | 1000_1000 | MOV r/m8, r8 |
| 89 | 1000_1001 | MOV r/m16, r16 |
| 8A | 1000_1010 | MOV r8, r/m8 |
| 8B | 1000_1011 | MOV r16, r/m16 |
最低位是w,决定这是字节还是字操作;次低位是d,决定 REG 字段是源还是目的。笔记里的写法很直接:
; 寄存器到寄存器,8B /r,REG 是目的 AX,R/M 是源 CX db 8bh, 11_000_001b ; mov ax,cx ; 同一条指令的 8 位版本,8A /r db 8ah, 11_000_001b ; mov al,cl ; 内存到寄存器,89 /r,modrm=10 000 000 表示 [BX+SI]+disp16 db 1000_10_1_1b, 10_000_000b, 34h, 12h ; mov ax,[bx+si+0x1234] ; 寄存器到内存,8B /r db 1000_10_0_1b, 10_000_000b, 34h, 12h ; mov [bx+si+0x1234],ax第一行的8B拆成1000_10_1_1:前缀100010是 MOV 家族的标志,d=1、w=1。modrm 是11_000_001:MOD=11表示双方都是寄存器,REG=000对应 AX,R/M=001对应 CX。因为d=1,REG 是目的,所以是mov ax,cx。如果把d位改成 0,同一条1000_10_0_1就是89,方向反过来变成mov cx,ax,modrm 一个字节都不用改。
第二行只把w位改成 0,操作码变成8A,寄存器编号表跟着切到 8 位那套,000/001从AX/CX变成AL/CL。
第三行的 modrm 是10_000_000:MOD=10说明操作数是内存并带 16 位位移,REG=000是 AX,R/M=000是[BX+SI]。紧跟的两字节34 12是小端序的位移0x1234。第四行操作码换成89,方向反转为写内存。
这里有个必须记住的例外:MOD=00且R/M=110时,含义不是[BP],而是直接寻址disp16,后面固定跟 2 字节位移。这一条在写解码器时如果不特判,就会把mov ax,[0x1234]解成mov ax,[bp]并且错位两个字节,后面的指令全部跟着乱。
4.2 立即数通道:C7 长格式、B0-BF 短格式、A0-A3 直接地址
立即数到寄存器的传送有三条完全不同的路,长度和适用面都不一样,笔记把这三条都写了出来。
第一条是通用的C7 /0(字)和C6 /0(字节)。这里的 REG 字段不是寄存器编号,而是扩展操作码,/0表示只接受 REG=000 这一种形式:
; C7 /0 iw,modrm=11_000_000,REG 是扩展操作码 000 db 0c7h, 11_000_000b, 0x34, 0x12 ; mov word ax,0x1234 ; C6 /0 ib,字节版本 db 0c6h, 11_000_000b, 0x34 ; mov al,0x34C7后面跟的 modrm 是11_000_000:MOD=11表示操作数是寄存器,REG=000作为扩展操作码告诉 CPU 这是「/0」这个变体,R/M=000才是真正的目标寄存器 AX。写成mov word ax,0x1234时word不能省,因为C7的字/字节由操作码本身区分而不是靠 modrm,汇编器需要这个提示。
第二条是短格式B0-B7(8 位)和B8-BF(16 位),寄存器编号直接编进操作码低三位,不需要 modrm,所以整条指令只有 2 或 3 个字节:
; 1011_1_011,最高位组 1011,w=1,低三位 011=BX db 1011_1_011b, 0x34, 0x12 ; mov bx,0x1234 ; 1011_0_011,w=0,低三位 011=BL db 1011_0_011b, 0x34 ; mov bl,0x34这条路径比C7短一个字节,是汇编器在优化时优先选的形式。写解码器的时候,B0-BF这段区间要单独处理,因为它没有 modrm,不能走通用的双操作数解析流程。
第三条是A0-A3,专门给累加器用的直接地址传送,操作数是一个 16 位的绝对地址:
; A1,字读入,后面跟 disp16 db 1010_000_1b, 0x34, 0x12 ; mov ax,[1234h] ; A0,字节读入 db 1010_000_0b, 0x34, 0x12 ; mov al,[1234h] ; A3,字写出 db 1010_001_1b, 0x34, 0x12 ; mov [1234h],ax ; A2,字节写出 db 1010_001_0b, 0x34, 0x12 ; mov [1234h],al这一组的编码规律很好记:最低位是w,次低位是方向(0读入、1写出)。A0/A1是内存读到 AL/AX,A2/A3是 AL/AX 写到内存。它们的存在纯粹是为了省一个字节——用8B走直接寻址要 4 字节(8B 06 34 12),A1只要 3 字节。现代汇编器一般不会主动选这条,但读老代码的时候会遇到。
4.3 段寄存器通道:8C / 8E 和那几个写得出跑不了的组合
段寄存器的传送是独立的一套操作码,因为段寄存器只有 4 个,编号只要 2 位,塞不进 REG 的 3 位编号体系。8E是「r/m16 → 段寄存器」,8C是「段寄存器 → r/m16」。
; 8E /r,modrm=11_0_00_000,REG 高位置 0,低两位 00=ES,R/M=000=AX db 10001110b, 11_0_00_000b ; mov es,ax ; 8C /r,modrm=11_0_00_000,REG 低两位 00=ES db 10001100b, 11_0_00_000b ; mov ax,es ; 8C 写内存,modrm=10_0_00_000,disp16=0x1234 db 10001100b, 10_0_00_000b, 34h, 12h ; mov [bx+si+1234h],esmodrm 里的那个0是保留位,写解码器时应该校验它,非 0 的编码在真实 CPU 上是未定义的。笔记里还列了mov cs,ax和mov [di],cs这类组合——它们在编码表上存在(8E /1),但 CS 作为 MOV 的目的寄存器在 8086 上是不允许的,286 之后会直接抛异常。这是编码合法性和指令合法性分离的典型例子,后面避坑那章还要专门说。
另一个常见误区是「段寄存器之间可以直接互传」。mov ds,es这种写法没有编码,8086 不提供段到段的通道,必须借一个通用寄存器中转:mov ax,es再mov ds,ax。写解码器的时候如果遇到8E的 modrm 是11_0_xx_yyy且yyy又指向段寄存器,那说明字节流有问题。
4.4 其余指令族:PUSH/POP/XCHG/IN/OUT 与固定编码
MOV 拆完,剩下的指令基本都能归到几类模板里,笔记里每种给了两三行,足够看出规律。
PUSH 有三条路。50-57是短格式,操作码低三位就是寄存器编号,一条字节;FF /6是通用格式,能压内存也能压寄存器;段寄存器用06/0E/16/1E,每个段一个固定操作码。
db 0ffh, 11_110_000b ; push ax FF /6,REG=110 是扩展操作码 db 01010_000b ; push ax 50,短格式,省一个字节 db 01010_111b ; push di 57 db 000_01_110b ; push cs 0x0E,段寄存器固定编码POP 的结构对称:58-5F短格式、8F /0通用格式、07/17/1F给 ES/SS/DS。注意这里只有三个段寄存器有 POP 编码,POP CS在 8086 上不存在(0F这个字节后来被拿去当双字节操作码的转义前缀了),这一条不对称是很多人写指令表时漏掉的。
XCHG 有两套。86/87是带 modrm 的通用格式,w位区分字节和字;90-97是跟 AX 交换的短格式,90就是xchg ax,ax,也就是 NOP。
db 1000011_1b, 10_001_000b, 34h, 12h ; xchg cx,[bx+si+1234h] 87 /r db 1000011_0b, 10_001_000b, 34h, 12h ; xchg cl,[bx+si+1234h] 86 /r db 10010_010b ; xchg ax,dx 92 = 90+2IN/OUT 这一族没有 modrm,端口号要么是固定的 8 位立即数,要么是 DX。四个操作码刚好构成一张 2×2 表:E4/E5是立即数端口读入 AL/AX,EC/ED是 DX 端口读入,E6/E7是立即数端口写出,EE/EF是 DX 端口写出。最低位统一是w。
db 1110010_1b, 20h ; in ax,20h E5 db 1110010_0b, 20h ; in al,20h E4 db 1110110_1b ; in ax,dx ED db 1110111_0b ; out dx,al EE固定编码的那一批最省事,一个字节一条:37是 AAA、D7是 XLAT、9F是 LAHF、9E是 SAHF、9C是 PUSHF、9D是 POPF。这一类的共同点是没有操作数字段,全部操作数都是隐含的(累加器、标志寄存器、[BX+AL]),所以解码器遇到它们直接查表返回助记符就行,不用再往后读字节。
LEA/LDS/LES 是三个需要特别注意的指令。它们的 modrm 结构和 MOV 长得一样,但REG字段的含义不同:LEA 里 REG 是目的寄存器,LDS/LES 里 REG 是目的寄存器而内存里取出来的是「偏移+段」两个字。
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]LEA 的MOD字段不能是11,因为它要的是有效地址而不是寄存器内容。8D C0这种编码在语法上能写出来,但语义上没有意义,写解码器时应该判非法。LDS/LES 读的是 4 个字节(2 字节偏移 + 2 字节段),但指令里只写了 2 字节位移,剩下 2 字节是运行时从内存里取的,不在指令流里——这个区别如果不清楚,会误以为指令后面还跟着数据。
5. 避坑与排查:解码笔记整理时最容易翻车的五处
这些坑不是理论上的可能性,是逐字节对照时真会卡住人的地方。按「现象 → 原因 → 解决」写,遇到对应的现象可以直接对号入座。
5.1 现象:汇编通过,ndisasm 反出来却是另一条指令
最常见的原因是 modrm 里MOD=00且R/M=110的特例没处理。你写db 10001011b, 00_000_110b, 34h, 12h期望得到mov ax,[0x1234],ndisasm却给出mov ax,[bp+0x1234]之类的结果,而且后续指令全部错位两字节。
原因在于MOD=00时的R/M=110不表示[BP],而是「直接寻址,后跟 disp16」。这是 8086 为了兼容早期设计留的一个特例,[BP]这种寻址必须写成MOD=01加一个 0 位移。
解决办法是在解析 modrm 的分支里把这一条单独拎出来:先判断MOD==0 && RM==6,成立就按 disp16 处理并多消费 2 字节,不成立再走[BX+SI]那套表。写测试用例的时候把8B 06 34 12和8B 46 00放一起,前者是mov ax,[0x1234],后者是mov ax,[bp+0],长度差两个字节但语义不同。
5.2 现象:二进制常量写错一位,整段输出往后偏移
db 1000_10_1_1b手写成db 1000_10_0_1b,只差一个字符,但操作码从8B变成89,方向整个反过来。更隐蔽的是把11_000_001b写成11_000_101b,CX变成BP,指令还能反汇编出来,只是寄存器不对,肉眼扫一遍很难发现。
原因是带下划线的二进制字面量虽然可读,但每一位都是手工敲的,没有校验。MOD、REG、R/M三段之间没有分隔符之外的提示,三位一组很容易数错。
解决办法是别信手动数位。一是在注释里同时写出十六进制和二进制两种形式,; 8B C8 mov ax,cx这样一眼能对;二是用第 3 章那个比对脚本,让机器去发现差异;三是写完一段就跑一次ndisasm,不要攒几十条再一起验,错误会互相掩盖。我自己的习惯是每加五条db就汇编一次,成本很低。
5.3 现象:编码表上有的组合,实机跑不了
mov cs,ax、pop cs、mov cs,[di]这几条,编码层面都能写出来,你的解码器也会正常输出助记符,但拿到 8086 上执行结果不可预期,286 之后直接触发异常。
原因是 8086 的编码空间里确实存在这些操作码位置,但指令集规范禁止把 CS 当 MOV 的目的、也禁止 POP CS。编码合法性和架构合法性是两件事,笔记记录的是前者。
解决办法是在解码器里维护一张「编码存在但非法」的补充表,命中就输出db 0x8E加注释而不是助记符。反汇编器(比如ndisasm)对这类编码的处理也不统一,有的会照常给出mov cs,ax,不要拿它的输出当权威。
5.4 现象:位移是负数,反汇编出来却是个巨大的正数
F0 3E 81 80 CD AB 34 12这条,CD AB按无符号读是0xABCD,按有符号读是-0x5433。有些反汇编器会输出[bx+si+0xabcd],看起来很怪但语法没错。
原因是 disp16 是有符号数,符号扩展后参与地址计算。地址本身是 16 位回绕的,所以+0xABCD和-0x5433算出来的有效地址一样,只是显示形式不同。
解决办法是看你的工具链想给谁看。给人读的输出按有符号格式化,[bx+si-0x5433]更贴近源码写法;给机器用的中间表示保留原始 16 位值,符号在计算有效地址那一步再处理,别提前转换。
5.5 现象:换个 NASM 版本,整份文件报语法错
从旧环境里翻出nasm-0.98,运行时第一行就报错,错误指向11_000_000b。改成11000000b能过,但可读性全没了。
原因是 2.0 之前的 NASM 不支持数字里的下划线分隔符,这个特性是后来加的。笔记里专门标注了用2.10.04,就是为了避开这个。
解决办法只有一条:升级工具链,别迁就旧版本改写法。nasm -v作为第一步检查,写进脚本或者 Makefile 里当门禁,版本号低于 2 就直接退出。顺带一提,[BITS 16]在老版本里也不是所有情况都认,同样的道理。
6. 二次开发:把散落的 db 收成一张可查的编码表
这份笔记最大的问题是形态——它是给人看的,不是给程序用的。四十多条db加注释,你想让解码器用上,得先把它变成结构化数据。我的做法是分两步:先把每条编码抽成记录,再写一个查表解码器,最后用随机指令回环测试验证。
第一步,把笔记里的行转成记录。字段不用多,五个够用:操作码、是否有 modrm、指令族、操作数描述、原始字节长度。
# 每条记录对应笔记里的一行 db # opcode: 操作码;has_modrm: 是否带操作特征字节;mnemonic: 助记符模板 OPCODES = { 0x88: {"has_modrm": True, "mnemonic": "mov r/m8, r8", "len": 2}, 0x89: {"has_modrm": True, "mnemonic": "mov r/m16, r16", "len": 2}, 0x8A: {"has_modrm": True, "mnemonic": "mov r8, r/m8", "len": 2}, 0x8B: {"has_modrm": True, "mnemonic": "mov r16, r/m16", "len": 2}, 0xB0: {"has_modrm": False, "mnemonic": "mov r8, imm8", "len": 2}, 0xB8: {"has_modrm": False, "mnemonic": "mov r16, imm16", "len": 3}, 0xA0: {"has_modrm": False, "mnemonic": "mov al, [disp16]","len": 3}, 0xC7: {"has_modrm": True, "mnemonic": "mov r/m16, imm16","len": 4}, }0xB0和0xB8这两条要展开成 8 条记录(B0-B7、B8-BF),因为寄存器编号编在操作码低三位里,查表时要用opcode & 0x07取寄存器。C7的len字段写 4 是保守值,实际长度取决于 modrm 的MOD位,真正解码时按情况算。
第二步,写解码主循环。顺序就是第 2 章那个字节布局表:吃前缀、读操作码、判断有没有 modrm、算位移长度、算立即数长度。
def decode(stream, offset=0): # 1) 循环消费前缀,F0/26/2E/36/3E 各占 1 字节 prefix = [] while stream[offset] in (0xF0, 0x26, 0x2E, 0x36, 0x3E): prefix.append(stream[offset]) offset += 1 # 2) 读操作码,这里简化处理,不做两字节操作码 op = stream[offset] offset += 1 info = OPCODES.get(op) if info is None: return {"prefix": prefix, "opcode": op, "error": "unknown"} # 3) 有 modrm 就解析,MOD=00 且 R/M=110 要特判为 disp16 disp_len = 0 if info["has_modrm"]: modrm = stream[offset] offset += 1 mod = modrm >> 6 rm = modrm & 0x07 if mod == 0 and rm == 6: disp_len = 2 elif mod == 1: disp_len = 1 elif mod == 2: disp_len = 2 # mod == 3 表示寄存器,无位移 # 4) 位移和立即数按长度跳过,具体值在需要时再解析 offset += disp_len return {"prefix": prefix, "opcode": op, "disp_len": disp_len, "next": offset, "mnemonic": info["mnemonic"]}三个参数值得说一下。stream是字节序列,可以直接传bytes;offset是相对当前指令的起始位置,返回值的next是下一条指令的起点,调用方靠它推进。prefix单独收集而不是直接丢弃,是因为段超越前缀会影响操作数描述,[ds:bx+si]和[es:bx+si]在反汇编输出里必须体现出来。
第三步,验证。手工构造测试用例太慢,直接从笔记里那四十多条db反推更快:把每条注释里的汇编语句喂给nasm编成二进制,再喂给自己的解码器,比对操作码和长度。跑一遍下来,凡是对不上的,要么是我的表漏了某种变体(比如8E /1这种扩展操作码),要么是笔记本身记的就是非法组合。这两种情况都得人工判断,不能全自动通过。
一个具体的技巧是别急着上完整指令集。先把 MOV 的九条路径跑通,再补 PUSH/POP 这一族,最后处理0F这种后来被占用的字节。每补一族就跑一次回归,比一口气写完再调要省事得多。我在这上面吃过一次亏:一开始想覆盖全表,结果0F当普通单字节操作码处理了,后面所有双字节指令全部错位,查了半个下午才定位到是一个字节的判断写反了。从那以后我每加一族指令,都强制走一遍「汇编 → 反汇编 → 逐字节比对」的闭环,宁可慢,也别攒着。希望这份拆解能帮你少绕两圈。
本文还有配套的精品资源,点击获取