☰
8086机器码解码避坑:NASM报错、C6/C7与ModR/M复算
2026/10/29 8:54:34 网站建设 项目流程

简介:面向编写8086汇编器、需要核对机器码生成逻辑的开发者,这份笔记以个人总结形式梳理8086机器语言解码的关键知识点。内容从指令格式、16位通用寄存器与段寄存器编号、五种寻址模式讲到操作码、立即数、字节/字/双字及固定编码指令,并重点展开双操作数指令与MOV指令的编码规则,配合NASM环境下的字节序列示例说明操作特征字节、MOD/R/M、位移量和立即数字段的组合关系。压缩包仅含1个docx文件,约1.94MB,便于集中查阅和打印批注。已有127人学习,适合作为汇编器实现、机器码手工验证和指令编码查漏补缺的参考材料。

1. 一行db报错背后:8086 机器语言解码最容易翻车的地方

做 8086 机器语言解码的人,手里大概都有一份「助记符 ↔ 字节」的对照笔记。我这份笔记里出现频率最高的一行是db 0c7h,11_000_000b,0x34,0x12——前面是操作码,中间是按 mod、reg、rm 三段切开的操作特征字节,后面跟两个立即数字节。写成这样可读性是够了,可它在老版本汇编器上一个字节都编不过去;更麻烦的是另一种情况:汇编器一声不吭,你把c7手滑写成c6,反汇编回去就变成mov al,0x34,寄存器值全乱,查半天才绕回这一行。这类「字节错一位、现象全变样」的故障,做解码器、写汇编器、手搓 bootloader 的人几乎都撞过。下面这份排错记录,就从这些字节开始,把报错、根因和验证手法一条条摊开。

2.nasm -v之后照样报错:分组二进制常量与 16 位输出的三个开关

笔记里的每条指令都配了db形式的机器码,本意是拿汇编器当「编码校验器」用:写一行db,汇编一遍,再反汇编回来,看看和助记符对不对得上。这套流程很省事,但前提是工具链得先能编过。我最早在一个老环境上跑,第一步就卡住了——不是指令写错,而是那串看起来人畜无害的11_000_000b。

2.1 报错现场:老版本 nasm 把11_000_000b当成未定义符号

先说我当时的完整复现路径,这个坑任何人三分钟就能撞上。新建一个a.asm,内容照着笔记抄:

[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

然后nasm -v看一眼版本,输出是NASM version 2.10.04 compiled on Jun 7 2021,再执行汇编。同一份文件,在不同机器上得到两种完全不同的结果:新版本一声不响地编过去了,老环境上却停在第二行db上,报的错大意是解析器不认识11_000_000b,把它当成一个没定义过的符号,顺带还会扯出「instruction expected」之类的连锁报错。这种报错最迷惑人的地方在于,它指向的行号是对的,但提示词完全没提「二进制」两个字,你很容易怀疑是自己少写了一个逗号或者括号不配对。

根因很干脆:用下划线给二进制常量分组的写法,是相对新的词法特性。老版本的词法分析器扫到11_000_000b时,不认为下划线是数字的一部分,于是把它切成几段,剩下的残片拿去符号表里找,找不到就判定为未定义符号。换句话说,这不是你指令写错了,是编译器版本不认这种常量字面量。确认方法就一条命令,先看版本再看报错,别急着改代码:

# 先确认版本,低于 2.07 的基本不用往下试了 nasm -v # 单独的常量行测试,把问题从指令编码里隔离出来 echo 'db 11_000_000b' > t.asm && nasm -f bin t.asm -o t.bin

第一条命令输出的是版本号和编译日期,只要看到 2.10 及以上,分组常量基本可用;第二条命令是把「常量能否被解析」和「指令编码是否正确」这两件事拆开,如果单独一行db都报错,那就跟 mod/reg/rm 一点关系都没有,纯属工具链问题,别在指令上浪费时间。这也是我后来养成的习惯:任何一次「汇编不过」,先用最小用例把变量砍到只剩一个。

2.2 版本之外的两个开关:[BITS 16]、[cpu 8086]与-f bin

版本对上之后,很多人会以为万事大吉,结果发现字节编出来了,跑起来还是不对。这一步的坑不在常量,在三个开关上,缺一个都会让你拿到「看起来对、其实不对」的输出。

第一个是[BITS 16]。不写这一行,汇编器按默认位宽处理,遇到mov ax, 0x1234这类指令时可能给你塞进操作数尺寸前缀,机器码从B8 34 12变成66 B8 34 12,长度直接多一个字节。你拿笔记里的六字节去比对,怎么都对不上,还会以为是 mod 字段算错了。第二个是[cpu 8086],它的作用是限制指令集范围,让汇编器拒绝 80186 以后才有的写法,比如push 0x1234、shl ax, 4这种带立即数的形式。没有这道闸门,你不小心写了新指令,汇编器照样编过去,等到真机或者模拟器上执行,行为就变得莫名其妙。第三个是-f bin,不指定输出格式时汇编器可能给你 ELF 或者 COFF 头,前面几十个字节全是文件头,你xxd一看偏移全错,还以为是自己编码错了位。

一份能拿来做解码校验的最小骨架,我一般固定写成这样:

[CPU 8086] ; 限定指令集,拒绝 80186+ 才有的编码 [BITS 16] ; 强制 16 位,避免 0x66 操作数尺寸前缀 org 0x7C00 ; 裸二进制起点,仅影响标号地址,不影响本条指令字节 mov word [bx+si+0x1BCD], 0x1234 ; C7 80 CD 1B 34 12 mov word ax, 0x1234 ; C7 C0 34 12 mov al, 0x34 ; B0 34

[CPU 8086]和[BITS 16]都是伪指令,不产生任何机器码,只影响汇编器怎么理解后面的源码;org只改变标号的绝对地址,对这几条不含跳转的指令没有任何字节影响。配套的汇编和反汇编命令是这两条:

# -f bin 输出裸二进制,没有文件头,偏移从 0 开始 nasm -f bin a.asm -o a.bin # -b 16 指定 16 位反汇编,忘了这个参数会按 32 位解读 ndisasm -b 16 a.bin

-f bin让输出文件的第一字节就是第一条指令的第一个字节,xxd a.bin看到的偏移和笔记里的字节序能一一对上;ndisasm -b 16里的-b是位的缩写,8086 的代码段必须写 16,写 32 会把C7 80 CD 1B解析成另一条指令,然后你在那里怀疑人生——这个坑我在第 5 章还会再提一次。

2.3 不能升级时的等价改写:把分组常量换成十六进制

有些环境锁死在老版本上,升级要走审批,这时候最省事的做法不是硬扛,而是把常量换成十六进制等价写法。分组二进制本来就是为了人眼好读,换成两位十六进制并不损失信息,只是要小心别手算错。

最容易用错、也是笔记里出现频率最高的几个值,我列成对照:11_000_000b是0xC0(mod=11、reg=000、rm=000),10_000_000b是0x80(mod=10、reg=000、rm=000),10_001_000b是0x88(mod=10、reg=001、rm=000),11_000_001b是0xC1,11_110_000b是0xF0,11_001_010b是0xCA。手算这些很容易翻车,尤其0x88和0xC1这种,眼花一下就写反了。

老环境里我通常是先跑一遍批量替换脚本,再人工抽查几行,脚本很简单:

import re def bin_to_hex(m): raw = m.group(1).replace('_', '') # 去掉分组下划线 val = int(raw, 2) if val > 0xFF: # db 只能放一个字节 raise ValueError(f'{m.group(0)} 超过 8 位,不能放进 db') return f'0x{val:02X}' src = open('a.asm', encoding='utf-8').read() # 只匹配以 b 结尾、且全是 0/1 和下划线的字面量,避免误伤标号 out = re.sub(r'\b([01][01_]*[01])b\b', bin_to_hex, src) open('a_hex.asm', 'w', encoding='utf-8').write(out)

正则里的\b(...)b\b是关键,它保证只命中「二进制数字串 + 后缀 b」这种形态,不会把0x34、寄存器名或者标号误替换掉;raw.replace('_','')先去掉分组下划线,int(raw, 2)按二进制解析,最后统一输出两位大写的十六进制。中间那个val > 0xFF的判断不是为了好看,db只能容纳一个字节,遇到10_000_000_0b这种超范围的值,汇编器会直接报「byte data exceeds bounds」,而人往往要到报错才知道自己数错了位数。批量替换之后再nasm -f bin编一遍、ndisasm -b 16反汇编一遍,两边字节对上,这次改写才算收工。

3.c7还是c6:MOV 双操作数编码的 mod/reg/rm 逐位复算

工具链的问题解决完,真正难的部分才开始:字节编出来了,但编错了。MOV 是笔记里条目最多的一条指令,也是最容易写错 opcode 的一条。c6和c7只差一位,一个是mov r/m8, imm8,一个是mov r/m16, imm16,写错之后汇编器不会报任何错,反汇编器会给你一条「看着像那么回事」的指令,程序跑起来才发现低字节被覆盖了。

3.1 操作特征字节拆三段:mod、reg、rm 各管什么

MOV 的双操作数编码一共三部分:一个字节的 opcode(88~8B、C6、C7、A0~A3之类),一个字节的操作特征字节(也就是常说的 mod/reg/rm 或者 ModR/M),再加上位移量和立即数。第二字节是核心,共 8 位,从高到低切成 2 位、3 位、3 位。

  • mod(第 7、6 位):决定操作数在不在内存里。00表示内存但无位移(有一个致命特例),01表示内存带 8 位位移,10表示内存带 16 位位移,11表示寄存器。记住一句话:mod不是「有没有位移」,而是「rm 指的是什么」。
  • reg(第 5、4、3 位):作用随d位反转。d=1时它是目的寄存器编号,d=0时它是源寄存器编号;对C6、C7、FF、8F这一类只有单操作数的编码,它不表示寄存器,而是表示「扩展操作码」,比如C7 /0的/0、FF /6的/6。
  • rm(第 2、1、0 位):mod=11时是寄存器编号,否则是内存寻址方式编号。16 位下000是[bx+si]、001是[bx+di]、010是[bp+si]、011是[bp+di]、100是[si]、101是[di]、110是[bp]或直接地址、111是[bx]。

寄存器编号有两套,别混用:16 位是AX=0, CX=1, DX=2, BX=3, SP=4, BP=5, SI=6, DI=7,8 位是AL=0, CL=1, DL=2, BL=3, AH=4, CH=5, DH=6, BH=7,段寄存器是ES=0, CS=1, SS=2, DS=3。同一个编号001,在 16 位上下文里是CX,在 8 位上下文里是CL,在段寄存器上下文里是CS——这套编号互换是手工解码时最常见的错误来源。

3.2 d 位和 w 位:目的操作数在哪、按字节还是按字

88~8B这组 opcode 的最后一位是d位,后两位才是 opcode 本身,所以判断方向的时候要先做减法。88和89是MOV r/m, r(d=0,目的在 mod/rm 里),8A和8B是MOV r, r/m(d=1,目的在 reg 里);每一对的低位是w位,0按字节、1按字。

拿笔记里这几行对照一下就很清楚:

; 通用寄存器之间:8B 的 d=1,reg 是目的 db 10001011b, 11_000_001b ; 8B C1 -> mov ax, cx db 10001010b, 11_000_001b ; 8A C1 -> mov al, cl ; 内存与寄存器:同样是 8B/89,靠 d 位区分谁在内存 db 1000_10_1_1b, 10_000_000b, 34h, 12h ; 8B 80 34 12 -> mov ax, [bx+si+0x1234] db 1000_10_0_1b, 10_000_000b, 34h, 12h ; 89 80 34 12 -> mov [bx+si+0x1234], ax ; 常数到寄存器:短格式 B8+reg,w 位在 opcode 最低位 db 1011_1_011b, 0x34, 0x12 ; BB 34 12 -> mov bx, 0x1234 db 1011_0_011b, 0x34 ; B3 34 -> mov bl, 0x34

第一组两条只差 opcode 的最低位,8B是字操作,8A是字节操作,操作特征字节完全相同;第二组两条 opcode 只差d位那一个比特,8B表示「读进寄存器」,89表示「写回内存」,而操作特征字节80一模一样。这就解释了一个很常见的现象:方向写反时,指令不会报错,反汇编器照样给你一条合法指令,只是源和目的换了位置,你在调试器里看到值没写进去,很难立刻想到是 opcode 少加了一位。

短格式B8~BF值得单独说,它把寄存器编号直接编进 opcode 的低三位,B0~B7是 8 位、B8~BF是 16 位,后面紧跟立即数,位移量为零。这种形式比C7 /0短,mov ax, 0x1234用B8只要三个字节,用C7 C0要四个字节。笔记里两种都记了,实际写汇编器的时候,这是个可以优化的点:立即数到寄存器优先走短格式。

3.3 把mov word [bx+si+0x1BCD],0x1234六个字节算一遍

这条是笔记里的头号例子,也是最值得逐位复算的一条,因为它同时用到了w=1、d=0、长位移和 16 位立即数。

第一步定 opcode。目的是内存,源是立即数,属于「常数到内存」这一类,只能走C6(字节)或C7(字)。这里明确是word,所以 opcode 是C7,二进制11000111,最低位w=1。

第二步定操作特征字节。mod是10,因为位移0x1BCD是 16 位;reg是000,这是扩展操作码/0,跟寄存器编号无关;rm是000,对应[bx+si]。拼起来就是10 000 000,即0x80。

第三步排字节。位移量按小端序写,0x1BCD拆成低字节CD、高字节1B;立即数0x1234同样小端,低字节34、高字节12。最终六字节是:

; 逐字段拆解:C7 /0 + mod=10 rm=000 + disp16=0x1BCD + imm16=0x1234 db 0c7h ; 11000111 -> MOV r/m16, imm16,w=1 db 10_000_000b ; mod=10, reg=000(/0), rm=000([bx+si]) dw 0x1BCD ; 位移量,小端输出 CD 1B dw 0x1234 ; 立即数,小端输出 34 12 ; 等价的单行写法 ; db 0c7h,80h,0cdh,01bh,034h,012h

用dw而不是手写两个db,是因为dw会自动按小端序输出,少一次手算就能少一次大端小端写反的事故;0cdh和01bh这种带前导零、后缀 h 的写法,是十六进制字面量的另一种形式,和0xCD完全等价,两种混用在 NASM 里不会报错,但在一份笔记里最好统一成一种,我后来全改成了0x前缀。

顺便把另外两条容易混的放在一起对比。mov word ax, 0x1234走短格式B8,三字节B8 34 12;如果按C7形式写,就是C7 C0 34 12,C0是11 000 000,mod=11表示 rm 是寄存器,rm=000是AX。而mov al, 0x34是B0 34,B0的低三位000就是AL;如果你手滑写成C6 C0 34,也能编过,反汇编出来同样是mov al, 0x34,但长度多一字节——这种「功能对、长度错」的差异,只有靠反汇编逐字节比对才抓得住。

3.4 反汇编回读:用 ndisasm 验证而不是靠眼睛

手工算完不要直接用,一定要反汇编回读。人眼对十六进制的校验能力非常差,尤其是0x80和0x88、0xC0和0xC1这两对,我翻了不止一次。回读的命令只有两行:

# 汇编成裸二进制 nasm -f bin a.asm -o a.bin # 按 16 位反汇编,与笔记逐行比对 ndisasm -b 16 a.bin | head -n 20 xxd -g 1 a.bin | head -n 5 # 同时看原始字节,防止反汇编器掩盖对齐问题

ndisasm的输出每行有三列:偏移、原始字节、反汇编出的助记符。要比对的是第二列和第三列——第二列如果和你笔记里的字节序列一致,说明编码对了;不一致但第三列助记符对得上,说明你笔记里的字节写错了,机器码本身没问题。xxd -g 1是按一字节一组显示,主要用来确认文件里没有多余的前缀和填充,head只是防止一次刷屏几千行。

提示:ndisasm默认按 32 位解,-b 16一定不能省。忘了这个参数,C7 80 CD 1B会被当成完全不同的指令,你会误判成一堆莫名其妙的编码错误。

4. 段寄存器与栈指令:mov es,ax用8E、push ax用50的两个字节

MOV 的通用寄存器部分理顺之后,段寄存器和栈指令是第二类高频出错点。它们的特点是编码短、方向位行为特殊、还带着一组「短格式 vs 长格式」的选择,选错了不影响功能,影响长度;方向位写反了,影响功能但不报错。

4.1 段寄存器传送:8E/8C的方向位必须写 0

段寄存器的 MOV 只有两个 opcode:8E是MOV Sreg, r/m16,8C是MOV r/m16, Sreg。它们同样是d位编码,8E的d=1(目的在 reg 字段)、8C的d=0(源在 reg 字段),reg字段的 3 位在这里不表示通用寄存器,而是段寄存器编号。

关键点在操作特征字节:mod=11时reg是段寄存器号,但中间那一位方向位在操作特征字节里没有位置——它已经在 opcode 的最低位了,所以操作特征字节的reg段必须老老实实填段寄存器编号,不能像通用寄存器那样想着「反正方向在 opcode 里,随便填」。笔记里的写法把这一点表达得很清楚:

; 通用寄存器 -> 段寄存器,8E /reg,reg 就是段寄存器编号 db 10001110b, 11_0_00_000b ; 8E C0 -> mov es, ax db 10001110b, 11_0_10_000b ; 8E D0 -> mov ss, ax db 10001110b, 11_0_11_000b ; 8E D8 -> mov ds, ax ; 段寄存器 -> 通用寄存器,8C /reg db 10001100b, 11_0_00_000b ; 8C C0 -> mov ax, es db 10001100b, 11_0_11_000b ; 8C D8 -> mov ax, ds ; 段寄存器与内存之间:mod 不再是 11,rm 给出寻址方式 db 10001100b, 10_0_00_000b, 34h, 12h ; 8C 80 34 12 -> mov [bx+si+0x1234], es db 10001110b, 00_0_11_100b ; 8E 1C -> mov ds, [si]

看第一组三条,reg从000变到010、011,对应ES、SS、DS,rm一直是000(AX)。第二组把8E换成8C,方向就反过来了,reg里放的仍然是被传送的段寄存器。第三组把mod改成非11,rm变成内存寻址方式,reg依然是段寄存器号——这就是「方向位在 opcode、段寄存器号在 reg」这套设计的具体表现。

注意:笔记里记了mov cs,ax对应8E C8,但这条编码在 8086 上行为不保证,不要在实际代码里依赖它。段寄存器往CS写,常规做法是走retf或者jmp far,不要用 MOV。

4.2 PUSH/POP:短格式0x50+reg与长格式FF /6、8F /0的选择

栈指令的编码有两套并存,这是 8086 解码里很典型的「同一功能多种编码」。通用寄存器的PUSH有短格式01010 rrr(0x50~0x57),一字节搞定;长格式走FF /6,需要操作特征字节,还要根据mod、rm决定是否带位移;内存源的PUSH只能用长格式。POP对应的是01011 rrr(0x58~0x5F)和8F /0。

; PUSH 短格式:0x50 + 寄存器编号,一字节 db 01010_000b ; 50 -> push ax db 01010_111b ; 57 -> push di ; PUSH 长格式:FF /6,操作特征字节的 reg 固定为 110 db 0ffh, 11_110_000b ; FF F0 -> push ax(冗余但合法,比 50 多一字节) db 0ffh, 10_110_000b, 34h, 12h ; FF B0 34 12 -> push word [bx+si+0x1234] ; POP 短格式与长格式 db 01011_000b ; 58 -> pop ax db 8fh, 11_000_000b ; 8F C0 -> pop ax(冗余写法) db 8fh, 10_000_000b, 34h, 12h ; 8F 80 34 12 -> pop word [bx+si+0x1234]

FF /6里的/6就是操作特征字节的reg段填110,它和寄存器编号毫无关系,纯粹是扩展操作码;8F /0同理,reg填000。写汇编器时最容易出的错是把这两个/digit当成「目的寄存器编号」去处理,结果push ax的reg填了000,编出来是FF C0,那是inc ax的编码,行为完全跑偏,但反汇编器会老老实实告诉你这是inc ax,你要是只看助记符不看字节,就发现不了。

段寄存器的PUSH/POP又是另一套固定编码,PUSH CS是0x0E,PUSH SS是0x16,PUSH DS是0x1E,PUSH ES是0x06;POP侧ES是0x07、SS是0x17、DS是0x1F,POP CS在 8086 上是0x0F,但这个编码后来被用于其他用途,写汇编器时建议直接禁用。这些值没有规律,只能背或者查表,我一般是把它们单独放一个常量表,不要指望现场推导。

4.3 XCHG 与 AX:0x90+reg的短格式,以及0x90就是 NOP

XCHG有两个形态:通用格式86(字节)/87(字)配操作特征字节,功能和 MOV 类似但 opcode 独立;以及与AX交换的短格式10010 rrr(0x90~0x97),一字节。

; 通用格式:87 是字、86 是字节,操作特征字节结构与 MOV 一致 db 1000011_1b, 10_001_000b, 34h, 12h ; 87 88 34 12 -> xchg cx, [bx+si+0x1234] db 1000011_0b, 11_001_010b ; 86 CA -> xchg cl, dl ; 与 AX 交换的短格式:0x90 + 寄存器编号 db 10010_001b ; 91 -> xchg ax, cx db 10010_010b ; 92 -> xchg ax, dx db 10010_000b ; 90 -> xchg ax, ax,也就是 NOP

这里有个很实用的细节:0x90在 8086 上就是xchg ax,ax,语义上什么都没做,所以被拿来当NOP用。写反汇编器的时候,如果先匹配了xchg ax,ax规则,就永远输出不出nop,用户会觉得很怪;反过来如果先匹配nop,xchg ax,ax这种写法就永远显示不出来。我的处理是优先输出nop,同时在十六进制注释里保留xchg ax,ax的说明,两边都不丢。

0x90~0x97这组还有一个隐含约束:短格式只对AX成立,xchg bx,cx没有短格式,必须走87加操作特征字节。这一点和PUSH/POP不同,PUSH的短格式覆盖全部八个通用寄存器,XCHG的短格式只覆盖「与 AX 交换」。写编码器时如果按PUSH的思路去推XCHG,就会生成不存在的短编码。

5. 避坑清单:手写 8086 机器码最容易翻车的四个细节

前面几章讲的是「按流程推」,这一章讲的是「按经验躲」。下面四条都是我自己或者身边人真实翻过的车,现象、原因、解决一条条列清楚,写汇编器的时候可以当检查表用。

5.1 mod=00 且 rm=110 不是[bp],是 disp16 直接地址

现象:想把mov ax, [bp]编成「无位移」的形式,按 16 位寻址表查出[bp]对应rm=110,于是写出mod=00, rm=110,操作特征字节0x06,得到8B 06。反汇编回来却是mov ax, [0x1234]之类的东西,后面的两个字节被当成了地址,整条指令长度不对,后面全部错位。

原因:这是 16 位寻址里唯一的特例。mod=00且rm=110的组合不表示[bp],而是表示「后面跟一个 16 位位移量,直接作为偏移地址」。真正的[bp]必须走mod=01,并且带一个 8 位位移0x00。

解决:[bp]一律写成mod=01, rm=110, disp8=00,即8B 46 00。汇编器一般会自动做这个替换,但手写字节时必须自己补。判断口诀是:mod=00时看到rm=110,无条件认为后面有两个字节的地址,不要当成[bp]。

; 错误写法:mod=00 rm=110 会被解释成直接地址 ; db 10001011b, 00_000_110b -> 8B 06,含义是 mov ax,[disp16] ; 正确写法:mod=01 + disp8=0 db 10001011b, 01_000_110b, 0x00 ; 8B 46 00 -> mov ax, [bp]

5.2 位移量与立即数占几个字节:跟着mod和w走,不要凭印象

现象:mov ax, [bx+si+0x1234]写成8B 80 34 12是对的,但同类指令换成mov ax, [bx+si+0x12]时,很多人还是照抄两个字节位移,写成8B 80 12 00或者8B 80 12,前者长度多一字节、后者直接少了半条指令,从这条往后全部错位。

原因:位移量的宽度由mod决定而不是由「地址看起来多长」决定。mod=01固定跟 1 字节位移,mod=10固定跟 2 字节位移,mod=00除了rm=110特例之外不跟位移。立即数的宽度则由w位和具体指令形态决定,`C6

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询