资讯动态

8086机器码编码实战:从MOV指令拆解到汇编器避坑指南

发布时间:2026/10/9 22:37:14 来源:尧图企业网站定制
简介这份笔记面向编写8086汇编器、需要理解机器指令编码细节的开发者系统整理了8086机器语言解码的核心知识。内容涵盖指令格式、寄存器编号、寻址模式、操作码、立即数以及字节、字、双字等基本概念并重点剖析固定编码指令与双操作数指令的编码规则配合MOV指令的多种编码示例逐条给出对应的机器码字节便于对照查阅。资源为1个docx文档压缩包约1.94MB以文字与代码片段为主适合作为手边速查手册。笔记中特别标注了nasm版本兼容问题提醒勿使用nasm-0.98并给出最长8字节指令的构成说明如总线锁定前缀、段超越前缀、操作码、寻址模式、偏移常量与立即数的组合。目前已有127人学习适合汇编器开发者、底层系统学习者参考帮助快速定位指令编码规律减少试错成本。1. 手写机器码这件事为什么还有人认真做前阵子帮一个做嵌入式教学工具的朋友看问题他那边有个模拟器跑得好好的换了一批测试用例就崩了。排查到最后发现是汇编器把mov word [bxsi0x1BCD],0x1234编成了 5 个字节而正确的 8086 编码应该是 6 个字节——少了一个位移量字节。这种问题在写汇编器、反汇编器、或者做指令级模拟器的时候特别常见因为 8086 的指令编码规则不是简单的查表而是由操作码、寻址模式、寄存器编号、位移量、立即数这几层拼出来的。你少考虑一个 bit编出来的机器码就是错的而且往往要到运行阶段才暴露。这份个人总结的 8086 机器语言解码示例笔记就是冲着这个问题来的。它不是一本完整的指令手册而是把 MOV、PUSH、POP、XCHG、IN、OUT、XLAT、LEA、LDS、LES、LAHF、SAHF、PUSHF、POPF、ADD 这些常用指令的编码规则用db伪指令逐字节拆开给你看。每一条汇编指令对应一组十六进制字节旁边标注了二进制分组告诉你哪个 bit 是 d、哪个 bit 是 w、哪个 bit 是 mod、哪个 bit 是 reg、哪个 bit 是 r/m。适合正在写 8086 汇编器、反汇编器、或者做 x86 指令级仿真的人参考也适合想搞清楚机器码到底怎么拼出来的底层爱好者。2. 从一条 MOV 指令拆开看编码字段与二进制分组2.1 操作特征字节的五个字段8086 的双操作数指令编码的核心是操作特征字节opcode byte。以 MOV 为例它的通用格式是1000 10 d w这 8 个 bit 里高 6 位100010是 MOV 的操作码标识剩下两个 bit 分别是 d 和 w。d 决定方向w 决定操作数宽度。但光有这两个 bit 还不够后面还要跟一个 mod-reg-r/m 字节把源和目的的具体位置定下来。mod-reg-r/m 字节的格式是mod(2位) reg(3位) r/m(3位)mod 决定操作数是寄存器还是内存如果是内存还要看有没有位移量reg 指定一个寄存器r/m 指定另一个操作数可能是寄存器也可能是内存。这三个字段组合起来才能唯一确定一条指令的操作数。笔记里给了一个很直观的例子; mov word [bxsi0x1BCD],0x1234 ; 编码0c7h, 80h, 0xcd, 0x1b, 0x34, 0x12拆开看0c7h是操作码二进制11000111高 6 位110001对应 MOV 到内存/寄存器的立即数传送形式80h是 mod-reg-r/m 字节二进制10000000mod10表示 16 位位移量reg000表示操作码扩展这里配合0c7h表示立即数传送r/m000表示[bxsi]寻址后面0xcd, 0x1b是 16 位位移量0x1BCD小端序存放最后0x34, 0x12是 16 位立即数0x1234同样小端序。这里有个容易翻车的点位移量和立即数都是小端序低位字节在前。很多人第一次写汇编器的时候会按大端序排结果编出来的指令在模拟器里跑飞。2.2 用 NASM 验证编码环境与版本选择笔记里用 NASM 做验证版本是 2.10.04。这里有一个血泪经验不要用 nasm-0.98 版本它不识别分组的二进制常量比如11_000_000b这种写法会直接报错。2.10 之后的版本才支持这种带下划线分隔的二进制字面量写编码笔记的时候用这种写法可读性高很多。验证环境很简单一个 Linux 或者 WSL 都行# 查看 NASM 版本确认不是 0.98 nasm -v # 写一个测试文件 cat a.asm EOF [cpu 8086] [BITS 16] ; mov word [bxsi0x1BCD],0x1234 ; db 0c7h,80h,0xcd,0x1b,0x34,0x12 mov word [bxsi0x1BCD],0x1234 ; mov word ax,0x1234 ; db 0c7h,11_000_000b,0x34,0x12 mov word ax,0x1234 ; mov al,0x34 ; db 0xB0,0x34 mov al,0x34 ; db 0c6h,11_000_000b,0x34 等价于 mov al,0x34 db 0c6h,11_000_000b,0x34 EOF # 汇编成纯二进制 nasm -f bin a.asm -o a.bin # 用 xxd 看机器码 xxd -g 1 a.bin[cpu 8086]告诉 NASM 只生成 8086 支持的指令[BITS 16]指定 16 位模式。-f bin输出纯二进制不带任何格式头方便直接对比字节。xxd -g 1按单字节分组显示和笔记里的db列表一一对应。跑完之后你会看到a.bin的内容和笔记里标注的字节完全一致。这一步很关键你手写的编码规则对不对用 NASM 编一遍再对比比对着手册空想靠谱得多。2.3 寄存器编号与寻址模式的对应关系笔记里反复出现11_000_000b、10_000_000b、00_0_01_101b这种写法其实就是在手动拼 mod-reg-r/m 字节。要读懂这些得先把寄存器编号和寻址模式的表记住。16 位通用寄存器编号reg 字段 3 位编号寄存器000AX001CX010DX011BX100SP101BP110SI111DI8 位通用寄存器编号编号寄存器000AL001CL010DL011BL100AH101CH110DH111BH段寄存器编号编号段寄存器00ES01CS10SS11DSr/m 字段在 mod00、01、10时表示内存寻址方式mod11时表示寄存器。内存寻址的 r/m 组合r/mmod00mod01mod10000[BXSI][BXSIdisp8][BXSIdisp16]001[BXDI][BXDIdisp8][BXDIdisp16]010[BPSI][BPSIdisp8][BPSIdisp16]011[BPDI][BPDIdisp8][BPDIdisp16]100[SI][SIdisp8][SIdisp16]101[DI][DIdisp8][DIdisp16]110disp16[BPdisp8][BPdisp16]111[BX][BXdisp8][BXdisp16]这张表是写汇编器时最常查的。笔记里mov ax,[bxsi0x1234]的编码是1000_10_1_1b, 10_000_000b, 34h, 12h其中10_000_000b就是 mod10、reg000AX、r/m000[BXSI]后面跟 16 位位移量0x1234。对照表格一看就明白。3. 逐类指令编码实战MOV、PUSH、POP 与算术指令3.1 MOV 的九种编码形式笔记把 MOV 拆成了九类这个分类方式很实用因为 MOV 是 8086 里编码形式最多的指令之一。逐类看第一类通用寄存器之间的互传。mov ax,cx编码为8bh,11_000_001b。8bh是操作码10001011d1、w111_000_001b是 mod-reg-r/mmod11表示寄存器reg000AX 是目的r/m001CX 是源。第二类8 位通用寄存器互传。mov al,cl编码为8ah,11_000_001b。操作码变成10001010w0表示字节操作。第三类内存与通用寄存器互传。mov ax,[bxsi0x1234]编码为1000_10_1_1b,10_000_000b,34h,12h。这里操作码是10001011d1表示 reg 是目的mod10表示 16 位位移r/m000表示 [BXSI]。第四类常数到通用寄存器的长指令格式。mov word ax,0x1234编码为0c7h,11_000_000b,0x34,0x12。0c7h是操作码mod-reg-r/m 字节11_000_000b中 reg000是操作码扩展r/m000表示 AX。第五类常数到内存。mov word [bxsi0x1BCD],0x1234编码为0c7h,80h,0xcd,0x1b,0x34,0x12。注意这里 mod10、r/m000后面跟 16 位位移和 16 位立即数总共 6 个字节。第六类常数到通用寄存器的短指令格式。mov bx,0x1234编码为1011_1_011b,0x34,0x12。1011是短格式操作码前缀后面跟 w 位和 3 位寄存器编号总共 3 个字节。mov bl,0x34则是1011_0_011b,0x342 个字节。第七类特定地址内存到 AX/AL。mov ax,[1234h]编码为1010000_1b,0x34,0x12mov al,[1234h]编码为1010000_0b,0x34,0x12。这种形式专门用于直接地址寻址操作码里已经隐含了 AX/AL。第八类AX/AL 到特定地址内存。mov [1234h],ax编码为1010001_1b,0x34,0x12mov [1234h],al编码为1010001_0b,0x34,0x12。第九类通用寄存器与段寄存器互传。mov es,ax编码为10001110b,11_0_00_000bmov ax,es编码为10001100b,11_0_00_000b。段寄存器传送的操作码是固定的8eh到段寄存器和8ch从段寄存器mod-reg-r/m 字节里 reg 字段的 3 位中低 2 位是段寄存器编号第 3 位固定为 0。写汇编器的时候MOV 这九类要分别处理不能指望一个通用函数全包了。笔记里把每一类的db编码都列出来实际上就是给你一份可对照的测试用例。3.2 PUSH 与 POP 的编码差异PUSH 和 POP 的操作数总是字16 位这一点和 MOV 不同不需要 w 位来区分宽度。PUSH 的编码形式; 源为内存单元 ; push word [bxsi0x1234] db 0ffh,10_000_000b,34h,12h ; 源为通用寄存器 16 位长指令 ; push ax db 0ffh,11_110_000b ; 源为通用寄存器 16 位短指令1 字节 ; push ax db 01010_000b ; push di db 01010_111b ; 源为段寄存器 ; push cs db 000_01_110bPUSH 的短指令格式是01010加 3 位寄存器编号总共 1 个字节。长指令格式用0ffh操作码mod-reg-r/m 字节里 reg 字段固定为110。段寄存器的 PUSH 更特殊操作码直接是000_01_110b其中01表示 CS。POP 的编码; 目的为内存单元或通用寄存器 ; pop word [bxsi1234h] db 8fh,10_000_000b,34h,12h ; pop ax db 8fh,11_000_000b ; 目的为通用寄存器短指令 ; pop ax db 01011_000b ; 目的为段寄存器 ; pop es db 000_00_111b ; pop ss db 000_10_111b ; pop ds db 000_11_111bPOP 的短指令前缀是01011段寄存器 POP 的操作码里00、10、11分别对应 ES、SS、DS。注意 POP CS 在 8086 上是不存在的笔记里也没有列这是一个硬件限制。写反汇编器的时候PUSH/POP 的短指令和长指令要分别识别。短指令只有 1 个字节长指令有 2 到 4 个字节解码逻辑完全不同。3.3 XCHG、IN、OUT 与固定编码指令XCHG 的编码分两种; 通用寄存器或内存与寄存器交换8/16 位 ; xchg cx,[bxsi1234h] db 1000011_1b,10_001_000b,34h,12h ; xchg cl,[bxsi1234h] db 1000011_0b,10_001_000b,34h,12h ; xchg cx,dx db 1000011_1b,11_001_010b ; xchg cl,dl db 1000011_0b,11_001_010b ; 通用寄存器与 AX 交换16 位 ; xchg ax,dx db 10010_010b ; xchg ax,cx db 10010_001bXCHG 和 AX 交换的短指令格式是10010加 3 位寄存器编号1 个字节。这个和 PUSH/POP 的短指令类似都是操作码里直接编码寄存器。IN 和 OUT 的编码; 特定端口读入数据到 AX/AL ; in ax,20h db 1110010_1b,20h ; in al,20h db 1110010_0b,20h ; DX 所表示的端口读入数据到 AX/AL ; in ax,dx db 1110110_1b ; in al,dx db 1110110_0b ; AX/AL 数据到特定端口 ; out 20h,ax db 1110011_1b,20h ; out 20h,al db 1110011_0b,20h ; AX/AL 数据到 DX 所表示的端口 ; out dx,ax db 1110111_1b ; out dx,al db 1110111_0bIN/OUT 的端口地址如果是 8 位常量直接跟在操作码后面如果是 DX 间接寻址操作码里已经隐含了 DX不需要额外字节。固定编码的 1 字节指令; aaa db 37h ; xlat db 0d7h ; lahf db 9fh ; sahf db 9eh ; pushf db 9ch ; popf db 9dh这些指令没有操作数操作码就是全部编码。写反汇编器的时候这些可以直接查表。3.4 ADD 与 LEA/LDS/LES 的编码ADD 的编码和 MOV 类似也有 d 和 w 位; add ax,[bxsi0x1234] db 000000_1_1b,10_000_000b,34h,12h ; add al,[bxsi0x1234] db 000000_1_0b,10_000_000b,34h,12h ; add ax,cx db 000000_1_1b,11_000_001b ; add [bxsi0x1234],ax db 000000_0_1b,10_000_000b,34h,12hADD 的操作码高 6 位是000000d1时 reg 是目的d0时 mod-reg-r/m 是目的。这个规则和 MOV 一致实际上 8086 的算术逻辑指令大多遵循这个模式。LEA、LDS、LES 的编码; lea cx,[bxsi0x1234] db 10001101b,10_001_000b,34h,12h ; lds cx,[bxsi0x1234] db 11000101b,10_001_000b,34h,12h ; les cx,[bxsi0x1234] db 11000100b,10_001_000b,34h,12hLEA 把有效地址送到寄存器LDS/LES 从内存取双字偏移 段基址分别送到通用寄存器和 DS/ES。这三个指令的 mod-reg-r/m 字节格式相同区别只在操作码。4. 避坑与排查编码验证中的五个常见问题4.1 位移量和立即数的小端序搞反现象手写的编码在模拟器里跑寄存器值不对或者内存访问地址偏移了 256 的倍数。原因8086 是小端序16 位位移量和立即数的低位字节在前。比如0x1234在内存里是34 12不是12 34。很多人按大端序写编出来的指令就错了。解决所有多字节数值一律低位在前。写汇编器的时候输出函数里统一做字节序转换不要在每个指令处理里手动排。4.2 NASM 版本不兼容二进制分组常量现象用11_000_000b这种写法NASM 报语法错误。原因nasm-0.98 及更早版本不支持带下划线分隔的二进制字面量。笔记里明确标注了这一点。解决升级到 NASM 2.10 或更高版本。如果环境限制不能升级把11_000_000b改成11000000b去掉下划线。4.3 mod00 且 r/m110 的特殊情况现象mov ax,[0x1234]这种直接地址寻址按 mod00、r/m110 编码后反汇编器解析成了[bp]。原因mod00、r/m110 在 8086 里是特殊情况表示 16 位直接地址而不是[BP]。[BP]寻址必须用 mod01 或 mod10 加位移量 0 来表示。解决编码和反汇编时都要单独处理这个组合。写汇编器的时候直接地址寻址走1010000系列操作码不要走 mod-reg-r/m 的通用路径。4.4 段超越前缀的位置现象带段超越前缀的指令反汇编时前缀被当成了独立指令。原因段超越前缀如CS:、DS:是 1 个字节放在指令最前面。如果反汇编器没有先检查前缀就会把前缀字节当成操作码。解决解码循环里先读一个字节判断是不是前缀26h、2eh、36h、3eh、64h、65h、66h、67h、f0h、f2h、f3h如果是就记录下来继续读下一个字节直到读到真正的操作码。4.5 最长指令 8 字节的边界处理现象缓冲区按 6 字节分配遇到带锁定前缀和段超越前缀的指令时越界。原因笔记里提到最长的指令可达 8 个字节包含总线锁定前缀、段超越前缀、操作码、寻址模式、偏移常量、立即数。比如F03E8180CDAB3412就是lock add word [ds:bxsi-0x5433],0x1234。解决解码缓冲区至少分配 16 字节不要按“典型指令长度”来分配。写模拟器的时候指令长度计算要覆盖所有前缀组合。5. 用 Python 写一个最小解码器验证笔记笔记里的编码规则最终要落到代码里才算真正理解。我一般会写一个最小的 Python 解码器把db列表喂进去看能不能还原出汇编指令。这样比对着笔记空想有效得多。# 8086 最小解码器验证 MOV 的 mod-reg-r/m 解码 # 只处理 MOV 的通用格式用于对照笔记中的编码 REG16 [AX, CX, DX, BX, SP, BP, SI, DI] REG8 [AL, CL, DL, BL, AH, CH, DH, BH] SEG [ES, CS, SS, DS] def decode_modrm(mod, reg, rm, w): 解码 mod-reg-r/m 字节返回 (目的, 源) 的字符串描述 reg_name (REG16 if w else REG8)[reg] if mod 0b11: # 寄存器到寄存器 rm_name (REG16 if w else REG8)[rm] return reg_name, rm_name else: # 内存寻址这里只处理 mod10 的 16 位位移情况 base [BXSI, BXDI, BPSI, BPDI, SI, DI, BP, BX][rm] return reg_name, f[{base}disp16] def decode_mov(opcode, modrm, dispNone, immNone): 解码 MOV 指令的通用格式 d (opcode 1) 1 w opcode 1 mod (modrm 6) 0b11 reg (modrm 3) 0b111 rm modrm 0b111 dst, src decode_modrm(mod, reg, rm, w) if d 1: # reg 是目的 return fmov {dst}, {src} else: # mod-reg-r/m 是目的 return fmov {src}, {dst} # 测试笔记中的编码 # mov ax,cx - 8bh, 11_000_001b print(decode_mov(0x8b, 0b11000001)) # 输出: mov AX, CX # mov al,cl - 8ah, 11_000_001b print(decode_mov(0x8a, 0b11000001)) # 输出: mov AL, CL # mov ax,[bxsi0x1234] - 1000_10_1_1b, 10_000_000b print(decode_mov(0b10001011, 0b10000000)) # 输出: mov AX, [BXSIdisp16]这个解码器只覆盖了 MOV 的通用格式但已经能验证笔记里大部分编码规则。decode_modrm函数里 mod11走寄存器路径mod10走内存路径和笔记里的表格一一对应。decode_mov里 d 位决定方向w 位决定寄存器组这两个 bit 的处理是 MOV 解码的核心。跑一遍输出和笔记里的汇编指令对比如果一致就说明编码规则理解对了。不一致的地方回去查 mod-reg-r/m 的字段拆分十有八九是某个 bit 取反了或者寄存器编号查错了。写汇编器的时候编码方向反过来先解析汇编文本确定 d、w、mod、reg、r/m 的值再拼成字节。但验证逻辑是一样的——用解码器交叉验证编码器比单方向写代码可靠得多。从那以后我每次写指令编码相关的代码都会先用手写的db列表跑一遍解码器确认字节流能还原出预期指令再往编码器里加逻辑。这个习惯帮我省了很多调试时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑