news 2026/10/27 2:13:15

8086机器码解码实战:MOV指令编码与NASM反汇编对照

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
8086机器码解码实战:MOV指令编码与NASM反汇编对照

简介:这份笔记围绕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 字节决定指令族
4modrm1 字节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,展开就是四个:

操作码二进制含义
881000_1000MOV r/m8, r8
891000_1001MOV r/m16, r16
8A1000_1010MOV r8, r/m8
8B1000_1011MOV 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,0x34

C7后面跟的 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],es

modrm 里的那个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+2

IN/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当普通单字节操作码处理了,后面所有双字节指令全部错位,查了半个下午才定位到是一个字节的判断写反了。从那以后我每加一族指令,都强制走一遍「汇编 → 反汇编 → 逐字节比对」的闭环,宁可慢,也别攒着。希望这份拆解能帮你少绕两圈。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:12:10

2026年工贸一体服装供应链管理系统丨职业装工装供应链的交付数据结构:量体版本、人员编号与批次拆解

职业装工装供应链与零售品类的差别&#xff0c;表面上是订单形态不同&#xff0c;落在系统里则是数据结构不同&#xff1a;零售以款为主键、以号型为规格维度&#xff0c;职业装以人为主键、以个体尺寸为规格维度。主键一旦选错&#xff0c;后面所有环节的查询、追溯与结算都会…

作者头像 李华
网站建设 2026/10/27 2:11:53

MySQL到Doris秒级同步:Flink CDC全量增量与位点对齐

简介&#xff1a;面向具备一定 Flink 基础、希望打通实时同步链路的研发人员&#xff0c;这份文档围绕 Flink CDC 3.0 讲解从概念到落地的完整流程。内容先厘清 CDC 的变更捕获思想&#xff0c;对比基于查询与基于 Binlog 两种方式的执行模式、覆盖范围、延迟和数据库压力差异&…

作者头像 李华
网站建设 2026/10/27 2:11:39

Kettle 9.3 部署与实战:从启动失败到云原生ETL全流程

简介&#xff1a;本资源为Kettle&#xff08;Pentaho Data Integration&#xff09;开源ETL工具最新9.3版本的国内便捷获取方案&#xff0c;面向数据工程师、ETL开发人员及大数据初学者&#xff0c;解决官方下载受限、海外访问慢等实际部署难题。压缩包内含1个14KB的docx文档&a…

作者头像 李华
网站建设 2026/10/27 2:11:38

XGBoost入门:从梯度提升原理到Python实战

简介&#xff1a;本资源是Jason Brownlee撰写的英文原版经典教程《XGBoost with Python》PDF电子书&#xff0c;面向具备Python与机器学习基础的开发者、数据分析师及竞赛选手&#xff0c;系统解决梯度提升建模中原理理解难、XGBoost实操门槛高、参数调优无从下手等核心问题。全…

作者头像 李华
网站建设 2026/10/27 2:11:19

UiPath下载安装与社区版配置:许可证、Profile与包源避坑

简介&#xff1a;这是一份面向办公自动化初学者与职场新人的 UiPath 下载入门指南&#xff0c;围绕 RPA 工具的获取渠道与安装流程展开&#xff0c;适合想接触 RPA 技能、却不知从何处下手的零基础读者参考。资源包共 1 个 docx 文件&#xff0c;约 235KB&#xff0c;以文字步骤…

作者头像 李华
网站建设 2026/10/27 2:09:30

ETL数据整合实战:从抽取清洗到加载的完整指南

简介&#xff1a;这份文档面向 BI 项目中的数据仓库建设者、ETL 开发人员与数据工程初学者&#xff0c;系统讲解 ETL 设计中数据抽取、清洗转换与加载三个环节的落地思路&#xff0c;帮助读者理清从业务系统到 ODS、再到 DW 的完整链路。资源包共 1 个 docx 文件&#xff0c;约…

作者头像 李华