编程语言研究书库

剖析指令 — 一行汇编是怎么变成几个字节、几个字节又是怎么找回操作数的

剖析指令 — 一行汇编是怎么变成几个字节的

这一章讲什么: 前面我们写汇编、看汇编,但汇编器和 CPU 之间真正流动的, 是一串字节。本章回答三个问题:一行汇编怎么编码成几个字节? CPU 执行时怎么从这几个字节里找回「操作谁、在哪儿、跳多远」? 汇编器和链接器这两个工具,又是怎么把符号变成地址的? 学完你会拿到一把钥匙:手册里那些天书般的指令描述,你看得懂了。 主线例子: 解剖一条 mov eax, -48[rbp+4*rcx]——从它的 4 个字节里, 把基址、变址、比例、偏移量一样一样抠出来。

先说这一章在全书的位置。 第 2、3 章讲了位怎么表示数据,第 4–8 章讲了这些数据怎么在 门和电路里流动,第 10、11 章我们已经在写汇编、调汇编。本章是 Hardware 与 Software 两大部分的接缝:从这一章起,你写的每一行代码都能在字节层面给出解释——不再有任何 「编译器替我干的事」是黑箱。

学它干什么,书里给了两条理由1:一是调试——有些错误只有在机器码层面才看得清; 二是读手册——手册对每条指令的描述本身就建立在编码知识上(「这条指令有个 ModR/M 字节, R/M 字段这样取值……」),不懂编码,手册是天书;懂一点,手册是地图。 作者同时交代了边界:没有人需要背下所有指令的机器码,本章的目标是「能查、能读」, 不是「能默写」1。

1. 顶层全景:一条指令的零件清单

先看 x86-64 一条机器指令的通用布局(书里图 12-1)2:

┌─────────┬─────────┬─────────┬──────────┬───────────┐
│ 前缀字节 │ 操作码   │ ModR/M  │   SIB    │ 偏移量/   │
│ (可选)  │ 1~3 字节 │ (可选)  │ (可选)   │ 立即数    │
└─────────┴─────────┴─────────┴──────────┴───────────┘

图说:每条指令至少 1 个字节的操作码,其余零件按需出现。
     越靠右的字节越「可选」——简单指令只要 1 字节,复杂指令能串到十几字节。

x86-64 的指令长短不一(有的架构所有指令一样长,叫定长指令集;x86 这一路是变长的), 一条指令到底带哪些零件,由操作码和 ModR/M 字节共同决定2。

怎么亲眼看到机器码?汇编器加个选项就行:as -al 生成汇编清单(assembly listing), 每条汇编源码后面跟着它的十六进制机器码,左边还有它在函数内的偏移地址3。 本章的所有解剖,都是对着这样一张清单做的——你可以在自己机器上原样复现。

2. 操作码字节:指令的「动词」

操作码指定「干什么」。最省事的指令连操作数都不用写:

  • ret 的机器码是单字节 C3。它明面上没有操作数,其实有两个隐式操作数: rsp(从栈顶弹数据)和 rip(把弹出的值放进去)——因为这两个寄存器是铁定的, 不用在指令里再指定,一个字节就够了4。 回想第 11 章:ret 弹出的正是 call 压进去的那个返回地址。一压一弹, 在字节层面只是 C3 这一个字节在配合硬件里写死的电路动作。

  • push rbp / pop rbp 也只要一个字节,因为操作码里嵌着寄存器编号: push 的操作码是 01010rrr,pop 是 01011rrr,低 3 位 rrr 就是寄存器代码。 rbp 的代码是 101,所以 push rbp = 0101 0101 = 0x555。 3 位只能编 8 个寄存器(rax 到 rdi);r8–r15 怎么办?先按下,看第 4 节的 REX 前缀。 另一个细节:这 3 位编码也说明 push/pop 操作的是 64 位值——具体压几个字节, 不由指令决定,而由程序加载时操作系统怎么设置调用栈决定(本书环境就是 64 位)5。

用清单 12-1 做一次全面体检

书里提供了一个「什么都不是」的程序,九行核心指令,每行换一种花样, 就是为了让机器码的对比足够干净6:

行汇编机器码这一行在演示什么
8push rbp55操作码嵌寄存器编号
9mov rbp, rsp48 89 E564 位:REX(W=1)+ 0x89 + ModR/M
11mov eax, ecx89 C832 位,低编号寄存器
12mov edi, esi89 F732 位,高编号寄存器(编号进 3 位)
13mov ax, cx66 89 C816 位:多一个 0x66 前缀
14mov al, cl88 C88 位:换操作码 0x88
15mov edi, r8d44 89 C7新寄存器:REX(R=1)补第 4 位
16mov rax, rcx48 89 C864 位:REX(W=1)
18mov eax, 0B8 00 00 00 00立即数:行李跟在操作码后
20pop rbp5D同第 8 行,方向相反
21retC3单字节,隐式操作数

这张表本身就是本章的目录:右边四行的差别,正是接下来四节的主角。

3. ModR/M 字节:操作数的「宾语和状语」

为什么需要第二个字节

mov 能在寄存器之间、内存和寄存器之间搬运数据,组合太多,一个字节编不下。 所以多数 mov 指令在操作码后面加一个 ModR/M 字节,三个字段分工如下7:

字段位数管什么
Mod2寻址方式(4 种之一)
Reg/Opcode3一个寄存器,或补充操作码位
R/M3另一个寄存器,或配合 Mod 说明寻址细节

操作数可能出现的位置有三种:寄存器、内存、I/O 端口。 I/O 端口的寻址更复杂,书把它留到后面章节;本章先解决前两种8。

寄存器的 3 位代码表

ModR/M 里那 3 位寄存器代码的排法,表 12-1 给了前 8 个寄存器(注意 8 位列的 特殊之处:ah/ch/dh/bh 是历史上遗留下来的「高位寄存器」)9:

代码8 位16 位32 位
000alaxeax
001clcxecx
010dldxedx
011blbxebx
100ahspesp
101chbpebp
110dhsiesi
111bhdiedi

同一个代码,凭什么分清 al、ax、eax 三种大小?靠第 3 节那两条规则: 默认 32 位;16 位加 0x66 前缀;8 位换操作码。大小不从 ModR/M 里读, 从操作码和前缀里读——这是 x86 编码里最容易反直觉的一条10。

主线例子第一站:拆 89 C8

清单 12-1 第 11 行 mov eax, ecx 的机器码是 89 C8。

  • 操作码 0x89 说:「这是 32 位的 mov,方向是源 → 目标」;
  • ModR/M 字节 C8 = 11 001 000:Mod=11 表示「寄存器到寄存器」; Reg/Opcode=001 是源(ecx),R/M=000 是目标(eax)11。

对照第 12 行 mov edi, esi = 89 F7 = 11 110 111: 操作码相同,只是 ModR/M 换成了 esi→edi。同一操作码 + 不同 ModR/M = 不同的一对寄存器。

三种操作数大小,两套办法

默认操作数大小是 32 位。要用 16 位寄存器,汇编器在操作码前插一个 0x66 前缀字节 (第 13 行 mov ax, cx = 66 89 C8);8 位和 32 位则用不同操作码 (第 14 行 mov al, cl 用 0x88,不是 0x89)10。

4. REX 前缀:给 64 位和 r8–r15 补位

表 12-2 把 16 个 64 位寄存器的代码列全了(rax=0000、rcx=0001、……、r15=1111), 都是 4 位——ModR/M 里只有 3 位的位置。第 4 位藏在哪?藏在REX 前缀字节里12:

  0100 W R X B
       │ │ │ └─ B:R/M 字段寄存器编号的高位
       │ │ └─── X:SIB 变址寄存器编号的高位
       │ └───── R:Reg/Opcode 字段寄存器编号的高位
       └─────── W:1 = 64 位操作数;0 = 其他大小

对照两组就明白了:

  • 第 11 行 mov eax, ecx(32 位)= 89 C8,没有前缀; 第 16 行 mov rax, rcx(64 位)= 48 89 C8——只多了 48 = 0100 1000,W=1 是唯一的区别13;
  • 第 15 行 mov edi, r8d = 44 89 C7:W=0(只搬 32 位),但 R=1, 这个 1 拼到 Reg/Opcode 的 3 位前面,凑出第 4 位,得到 1000 = r814。

一句话:REX 前缀 = 「这条指令用了第 9 到 16 号寄存器,或操作数是 64 位」的声明。 那平时为什么几乎看不见它?因为指令只用前 8 个寄存器的 32 位部分时, 代码的最高位是 0,不需要第 4 位,自然不需要前缀——32 位时代的老代码, 在 64 位 CPU 上原样能跑,靠的就是这个「最高位为 0 就省略」的兼容设计15。

主线例子补一站:48 89 E5 其实早见过了

回头看清单 12-1 第 9 行,mov rbp, rsp(64 位)= 48 89 E5。 现在你能把它拆到底:48 是 REX(W=1,64 位),89 是 mov 操作码, E5 = 11 100 101:Mod=11 寄存器间移动,源=rsp(100),目标=rbp(101)。 第 10、11 章里你抄了几十遍的函数序言,机器码层面只有三个字节。 往后每次再看到 push rbp; mov rbp, rsp,脑子里应该自动响起这串字节。

5. 立即寻址:常量是指令的「行李」

立即寻址(immediate)就是数据直接跟在操作码后面,作为指令的一部分。 清单 12-2 把四种大小的「搬常量进寄存器」并排摆开,一眼看清规律16:

汇编机器码读法
mov al, 0xabB0 AB操作码 B0(嵌 al 的编号)+ 1 字节行李
mov ax, 0xabcd66 B8 CD AB0x66 宣告 16 位;行李 2 字节
mov eax, 0xabcdef12B8 12 EF CD AB行李 4 字节,小端序
mov rax, 0xabcdef1248 B8 12 EF CD AB 00 00 00 00REX(W=1);常量不足 64 位,汇编器补前导 0 凑满 8 字节
mov rax, 0xabcdef012345678948 B8 89 67 45 23 01 EF CD AB真正的 64 位行李

这里有一个全书反复用、必须刻进脑子的细节——小端序: mov eax, 0xabcdef12 的机器码是 B8 12 EF CD AB, 数据按字节倒着存:低字节 0x12 在前,高字节 0xab 在尾。 书里特意提醒:指令本身按字节顺序存,但常量数据以小端序存, 读机器码时别拿阅读顺序去套17。第 2 章存储格式的账,在这里结。

另一个容易忽略的点:最末一行 mov eax, 0 的机器码是 B8 00000000—— 写 0 也是 4 字节行李,汇编器不会替你省。从本书第一个程序起你就在用这句 给 main 安排返回值,现在你知道它的 5 个字节是怎么来的了16。

主线例子插曲:为什么书里的代码总在序言结语里打转

注意清单 12-1 和 12-2 里都有那几行 push rbp; mov rbp, rsp 和 mov rsp, rbp; pop rbp; ret。作者明说:这个程序「什么都不做」, 放它们进去,是给你一个能在 gdb 里跑、能对照第 11 章栈帧知识的完整骨架6。 教学上的小心思:每次新知识的对比组,其余部分都保持你已经会的东西不动。

6. 内存寻址:从「直接点名」到「基址 + 变址 × 比例 + 偏移量」

直接内存寻址为什么被我们抛弃了

汇编里写 mov eax, x,机器码是 8B 04 25 加 4 字节地址。 这个地址位先是 0——汇编器留下的占位符:它把「这里要填 x 的地址」记进目标文件, 链接器再找标签、算地址、填进来18。 补一个书里带的细节:CPU 执行时会用前导 0 把这个 32 位地址扩展成 64 位再访问18。 问题在第 11 章说过了:填死的地址让代码位置相关。 所以本书的代码全用 x[rip] 或寄存器寻址——下面三种才是主战场19。

主线例子第二站:拆 8B 45 D0,算出那个真实地址

清单 12-3 第 14 行 mov eax, -48[rbp] 的机器码是 8B 45 D0。 对照布局逐字节读:

8B        操作码:32 位 mov,内存 → 寄存器
45        ModR/M = 01 000 101:Mod=01「基址 + 8 位偏移量」;
          Reg/Opcode=000(eax),R/M=101(rbp)
D0        8 位偏移量 = 0xD0 = −48 的补码

CPU 执行时:把 0xD0 符号扩展成 64 位(−48),加上 rbp。 书里给的实例:rbp = 0x00007fffffffdf60,有效地址 = 0x00007fffffffdf60 + 0xffffffffffffffd0 = 0x00007fffffffdf30, 把这个地址处的 4 字节装进 eax20。

和第 13 行 mov eax, [rbp](8B 45 00)对比:区别只在最后那个偏移量字节, 0 表示没有偏移——设计者懒得起一个单独的「无偏移」操作码,统一走「偏移量可为 0」这一条路21。

这就是第 11 章「局部变量 = 帧指针 + 负偏移」的机器码真相: -9[rbp] 里的 −9,就是指令末尾这样的一个偏移量字节(或由 .equ 起名替换的同一个数)22。

主线例子第三站:拆 8B 44 8D D0,数组的寻址公式现形

第 16 行 mov eax, -48[rbp+4*rcx] = 8B 44 8D D0,多出来的 SIB 字节:

8B        操作码:同上
44        ModR/M = 01 000 100:R/M=100 是又一个「特殊情况」暗号——「后面还有 SIB 字节」
8D        SIB = 10 001 101:比例=10(×4,比例可以是 1/2/4/8),
          变址=001(rcx),基址=101(rbp)
D0        偏移量 −48

有效地址公式:rbp + rcx × 4 − 48。第 15 行是它的特例(比例 1,所以 8B 4D D0 = rbp + rcx − 48), 适合逐字节扫数组;第 16 行的 ×4 适合 int 数组(每个元素 4 字节)—— 索引加 1,地址自动跳 4,这正是第 17 章数组实现的机器级底座23。

细节两条,都值得记:

  • 变址寄存器和基址寄存器必须同样宽(都用 64 位), 哪怕索引值 8 位就装得下——因为有效地址用的是变址寄存器的全部 64 位24;
  • x86-64 有个硬规矩:一条指令最多只允许一个操作数是内存位置。 想干「内存搬到内存」的活,必须经一个寄存器中转25。 这不是编码偷懒,是设计取舍:两个内存操作数会让指令长度和访存次数都失控。

寻址模式全家桶:一张表收拢全章

至此本章的寻址模式齐了,按「CPU 怎么找到操作数」排成一张总表, 右边是每种模式的机器码特征26:

寻址模式写法示例操作数在哪机器码里的标志
立即寻址mov eax, 0xabcdef12指令自己的行李里操作码 + 小端序数据
寄存器寻址mov eax, ecxMod=11,两个寄存器字段ModR/M 字节
直接内存寻址mov eax, x链接器填死的地址占位符 + 4 字节地址(本书不用)
寄存器间接mov eax, [rbp]rbp 指着的内存Mod=01,偏移量=0
基址 + 偏移mov eax, -48[rbp]rbp − 488 位偏移量字段,补码
基址 + 变址 + 偏移mov eax, -48[rbp+rcx]rbp + rcx − 48加 SIB 字节(比例 1)
基址 + 变址 × 比例 + 偏移mov eax, -48[rbp+4*rcx]rbp + rcx×4 − 48SIB 比例字段 = 4

这张表就是「CPU 如何获知操作数位置」这个问题在 x86-64 上的全部答案—— 本章开头立的问题,到这里闭环。往后在手册里看到任何一个陌生寻址写法, 都能在这张表里对号入座,再回查对应字段的编码。

7. 跳转指令:距离怎么编进字节里

条件跳转约 30 个助记符,但短跳转的编码里只有 4 个条件位——也就是说 16 种条件组合,30 多个名字。同一个机器码常有多个助记符, 比如「ZF=0 时跳」既叫 jne(不相等)也叫 jnz(非零)——书里建议: 用那个最能表达你意图的27。跳转的距离有两档:

类型距离范围说明
短跳转−128 ~ +127 字节偏移量 1 字节,多数程序够用
近跳转±2 GB16 或 32 位偏移量;再远只能借无条件 jmp

要跳得比近跳转还远?用「条件跳过无条件」的双重否定结构:jne skip; jmp FarAway; skip:28。

主线例子第四站:拆 75 06 和 74 F5

清单 12-4 是一个「原地打转」的小程序:xor 设置标志位,jne 向前跳, 再 xor 一次,je 跳回来。把两条跳转的机器码拆开,「距离是相对下一条指令算的」 这件事就演活了2930:

0004  48 31 D8      xor  rax, rbx      # 按位异或,结果设置 ZF
0007  75 06         jne  forward       # ZF=0 就跳
0009  4D 89 C8      mov  r8, r9        # (会被跳过的两行)
000c  48 89 CB      mov  rbx, rcx
000f  48 31 D8      xor  rax, rbx      # forward:
0012  74 F5         je   back          # ZF=1 就跳
0014  B8 00 00 00 00 mov eax, 0
  • 75 06:0x75 = jne。执行时 rip 已指向下一条指令 0x0009;ZF=0 就把 0x09 + 0x06 = 0x000f,正好落在 forward 标签29。
  • 74 F5:0x74 = je。偏移量 0xF5 是负数(补码),符号扩展后加到 rip: 0x0014 + (−0x0B) = 0x0009 = back 标签30。

还有个细节呼应第 11 章:jne/je 检查的是 ZF,而 ZF 是 xor 的结果设置的—— 两个值相等时异或为 0,ZF=1。「用异或判断相等」正是栈金丝雀检查用的同一招27。

书里还有一句实用警告:条件跳转必须紧跟产生条件的指令(比如 xor、cmp), 中间隔了别的运算,状态标志就被改写了31。

8. 汇编器与链接器:两遍扫描填空白

为什么需要两遍:前向引用是硬骨头

先看一眼为什么"一遍扫不过去"。汇编器的输入是顺序的文本,但汇编语言里的引用 经常指向后面才出现的东西:

main:
    jne  forward     # forward 在这行还没定义!
    …
forward:
    ret

jne forward 的偏移量要写在指令字节里,可 forward 的值得等扫到它才知道。 要么先记着回头填,要么先把全文扫一遍建表——前者是单趟汇编器, 后者就是书里讲的两趟汇编器。两条路都通,选哪条是实现自由; 「欠账登记 + 事后填账」这个本质,两条路都跑不掉32。

汇编器:符号不认识,就先留空

汇编器把汇编翻译成机器码,一行对一行,本来一遍就够。麻烦在前向引用: jne forward 出现时,forward 标签还没读到,偏移量算不出来。 经典解法是两趟汇编器32:

  • 第一趟:从头到尾扫一遍,维护一个位置计数器,记下每个标签等于「相对模块开头的多少字节」, 存进局部符号表(.equ 定义的符号直接进表)。注意一个细节:每个 .text 节、 .data 节各建一张局部符号表——符号的值是「相对本节的偏移」,不是相对整个文件32;
  • 第二趟:正式翻译。符号在表里就直接填值;不在表里的(别的文件定义的), 填 0 占位,同时把「哪个符号、在哪个位置被引用」记进目标文件,留给链接器33。 翻译本身靠一张内置的操作码表——汇编语言与机器码一一对应,这就是它叫 「汇编」的原因:不是编译,是查表翻译33。

汇编器两趟走一遍微缩例子

拿一个三行的玩具文件走一遍,算法就落地了3233:

main:
    jne  forward        # 前向引用!此刻 forward 还没出现
forward:
    ret
  • 第一趟:位置计数器从 0 开始。main 记为 0;jne forward 占 2 字节, 计数器到 2;forward 记为 2;ret 占 1 字节,到 3。局部符号表:{main:0, forward:2}。
  • 第二趟:翻译 jne forward 时查表:forward = 2,当前指令在 0、长 2 字节, 下一条在 2,偏移量 = 2 − 2 = 0,填进去。ret 照表翻译为 C3。 若引用的符号查不到(比如换成别的文件里的标签),这里填 0 占位, 并把「符号名 + 位置 1」写进目标文件——这就是链接器接手的那个「欠条」。

也有单趟汇编器:维护一张「前向引用清单」,标签一出现就回头把欠的值补上。 书里顺带给了两本深入读物(Tanenbaum 的 Structured Computer Organization 第 7 章讲汇编, Levine 的 Linkers & Loaders 专讲链接器),并再次声明:本章只是「粗略的概念」34。

链接器:把占位符换成真地址

链接器干的是同一件事的放大版——基本单元从「一行汇编」变成「一个目标文件」。 一个典型程序由许多目标文件组成,每个里又有不止一个 .text 节和 .data 节。 汇编器交给它的材料是:每个目标文件里各段的大小,加上全局符号清单和 「谁在哪引用了谁」的使用位置35。

也是两遍:第一趟读所有目标文件,建全局符号表(每个全局符号距程序开头的相对位置); 第二趟把所有机器码按序拼进可执行文件,引用处全部填上相对位置值35。 它还顺手完成第 11 章的伏笔:对库函数这类外部符号,不解析,转交 PLT/GOT, 运行时才由操作系统的动态加载器填真地址36。

最后一遍捋全章的因果链,为什么是这个顺序:

汇编源码 --[汇编器:两趟,符号表+占位符]--> 目标文件(.o)
目标文件 --[链接器:两趟,全局符号表+填地址]--> 可执行文件
可执行文件 --[OS 加载,PLT/GOT 欠着外部符号]--> 运行
运行 --[动态加载器首次调用时填 GOT]--> 外部函数有了真地址

图说:每一站都在做同一件事——「把现在还不知道的数,先记账,后填上」。
     符号 → 地址,贯穿了编译、链接、运行三个时代。

判断(我们的,不是书里的): 「两遍扫描」是全章最该带走的模式—— 汇编器、链接器,乃至第 11 章的 PLT 首次调用,本质都是同一个策略: 第一遍先记下「哪里欠着」,等欠的东西能算出来时,再回来填。 这个模式在编译器(先声明后使用)、链接器、甚至数据库里反复出现。 如果错,会错在: 单趟汇编器确实存在(书里也提了:维护前向引用清单、回头再填), 所以「两遍」是实现手段而非本质;本质只是「欠账登记 + 事后填账」。

判断(我们的,不是书里的): 本章信息密度极高,但作者始终只走到「够用」就停: ModR/M 不列全表、汇编器算法明说是伪代码、手一伸就指向手册。 这个分寸本身值得学:机器码细节是无底洞,「能查、能读、知道去哪查」 才是对程序员正确的目标深度——这也是本书全书的共同风格。 如果错,会错在: 对做逆向、写调试器、搞 JIT 的人,「够用」远远不够, 那条职业路径需要的就是把无底洞走穿——但那是另一本书的任务,不是这本。

9. 作者的判断与证据

本章的教法和它的代价

书里对 ModR/M 的处理方式值得单独说:只给格式、不给全表,明说"所有可能的情况, 你可以在手册中找到"7。这是有意的取舍——ModR/M 全表有几十行,背下来没有意义, 学会读字段格式 + 知道去哪查表,才是手册时代的能力。配套地,作者给的都是 "最小可复现"的清单:每个清单十几行,序言结语固定,变量只有一处。 这种"控制变量"式的代码清单,是学习任何指令编码(或任何机器协议)的正确姿势: 一次只变一个零件,差异自己说话。

一个值得亲手做的验证

本章所有结论都能用一条命令验证。以清单 12-1 为例:

$ as --gstabs -al register.s        # 生成汇编清单
$ cat register.lst                  # 对照每一行的机器码

书里所有"❶❷❸"的编号,都是从这样一张清单里来的3。建议挑一行没讲过的 (比如 mov rsp, rbp = 48 89 EC),自己动手拆一遍——拆完这行, "操作码 + ModR/M + REX" 的三件套就从"听懂了"变成"会用了"。

说法性质
ModR/M、SIB、REX 的字段划分与编码书外事实,引自 Intel/AMD 手册体系,书里给的是教学化子集712
「x86-64 变长、其他架构定长」事实陈述,一句带过2
操作数大小不进 ModR/M、靠前缀和操作码区分编码事实,由清单 12-1 逐行对比支撑10
跳转距离两档(短 −128~+127 / 近 32 位)与双否定补长法编码事实 + 惯用法,清单 12-4 可验28
两趟汇编器/链接器的算法标准教科书内容,书里明说是「高度简化」的概述3235
「这些细节有助于调试、帮你读手册」作者的动机说明,属观点,但和本章内容自洽1
深入细节的两本书推荐(Tanenbaum、Levine《Linkers & Loaders》)作者推荐,我们未复核内容34

10. 边界与局限

  • ModR/M 的全部情况本书故意不讲,只给了读懂手册的最小集——遇到生僻组合,查手册是必然。
  • 只解剖了 mov 与条件跳转两类。算术指令、乘除法的编码本书不展开(第 16 章用「会用」的标准讲)。
  • 汇编器/链接器的算法是概念级概述:真实的 gas 与 ld 处理了重定位类型、弱符号、段合并等大量细节,别拿这两段伪代码去实现。
  • SIB 字节有两个「特殊情况」通道(R/M=100 触发 SIB;SIB 的 0x25 形式承载直接地址),它们是 x86 编码史上的补丁摞补丁——能用,但不优雅,知道即可18。
  • 变长指令是 x86 的历史包袱(书里以一句「其他一些架构对每条指令使用相同的字节数」轻轻带过)。 补充(不在书里): ARM、RISC-V 都是定长指令,译码电路因此简单得多——这是理解「x86 为什么耗电」的常见切入点。
  • 8 位寄存器那半张表(al/cl/…/ah/ch/…)是历史遗产。 8 位编号 100–111 分给 ah/ch/dh/bh 而不是 r8b–r15b,是因为这些编码定型时 r8–r15 还不存在;想用新寄存器的 8 位部分, 必须配 REX 前缀,而 REX 存在时 ah–bh 反而不能编码——手册里这个坑用注脚标着,本章未展开9。

判断(我们的,不是书里的): 全章几乎没说一句「这很难」,但 x86 的编码其实是 业界公认的「复杂到无法穷举测试」——Intel 的手册厚达几千页。 作者的勇气不在讲全,而在挑了一条读者必走的路径:五个字节零件、两条前缀, 就把「查手册」从不可能变成可行。教复杂体系的第一课不该是全景图,而该是一张 标好了退路的局部地图——本章就是这种教法的样板。 如果错,会错在: 有些读者需要的就是全景(比如要手写反汇编器的人), 对那批人,局部地图会培养出「只会查不会通」的习惯——但那不是本书读者的画像。

11. 可带走的

  1. 指令 = 前缀(可选)+ 操作码 + ModR/M(可选)+ SIB(可选)+ 偏移量/立即数;x86-64 变长;
  2. as -al 生成汇编清单,机器码逐行可见;
  3. ModR/M 三字段:Mod 定寻址方式,Reg/Opcode 和 R/M 各指一个寄存器(3 位);
  4. 默认 32 位;16 位加 0x66 前缀,8 位换操作码,64 位加 REX(W=1);
  5. 第 9–16 号寄存器(r8–r15)的编号第 4 位藏在 REX 前缀的 R/X/B 位;
  6. 立即数是行李,跟在操作码后,小端序存储;写 0 也占满规定的字节数;
  7. 内存寻址的完全体:基址 + 变址 × 比例(1/2/4/8) + 偏移量;R/M=100 表示「后面有 SIB」;
  8. 跳转偏移量相对下一条指令算,负偏移按补码符号扩展;
  9. 条件跳转助记符比编码多(30+ 对 16),同码多名,挑最达意的用;
  10. 一条指令最多一个内存操作数,「内存→内存」必须经寄存器中转;
  11. 汇编器两趟:一趟建符号表,一趟翻译填值;链接器同理,粒度是目标文件;
  12. 外部符号留给 PLT/GOT,运行时由动态加载器填地址;
  13. 符号表 + 占位符 + 回填,是汇编器、链接器、动态加载器三代工具的共同骨架。

12. 原文地图

主题原书章原文位置
汇编清单、机器码查看第12 章 剖析指令text/18-ch12.txt:20(搜「汇编清单」) · text/18-ch12.txt:25(搜「gstabs」)
指令字节布局第12 章 剖析指令text/18-ch12.txt:64(搜「字节数是可变的」) · text/18-ch12.txt:74(搜「操作码」)
push/pop 操作码嵌寄存器编号第12 章 剖析指令text/18-ch12.txt:86(搜「01010rrr」)
ModR/M 字节第12 章 剖析指令text/18-ch12.txt:97(搜「ModR/M 字节用于扩展」) · text/18-ch12.txt:106(搜「寄存器的代码」)
0x66 前缀、8 位操作码第12 章 剖析指令text/18-ch12.txt:119(搜「默认的操作数大小」) · text/18-ch12.txt:149(搜「0x88」)
REX 前缀第12 章 剖析指令text/18-ch12.txt:154(搜「REX 前缀字节」) · text/18-ch12.txt:164(搜「W 位的用法」)
立即寻址、小端序第12 章 剖析指令text/18-ch12.txt:178(搜「立即寻址模式」) · text/18-ch12.txt:221(搜「小端序」)
直接内存寻址与占位符第12 章 剖析指令text/18-ch12.txt:252(搜「8B 04 25」) · text/18-ch12.txt:258(搜「占位符」)
−48[rbp] 与真实地址计算第12 章 剖析指令text/18-ch12.txt:294(搜「-48[rbp]」) · text/18-ch12.txt:307(搜「0x00007fffffffdf30」)
偏移量符号扩展、Mod=01第12 章 剖析指令text/18-ch12.txt:318(搜「符号扩展」)
变址寄存器、SIB第12 章 剖析指令text/18-ch12.txt:333(搜「变址寄存器」) · text/18-ch12.txt:343(搜「SIB 字节」)
短跳转、双否定长跳转第12 章 剖析指令text/18-ch12.txt:362(搜「短跳转」) · text/18-ch12.txt:367(搜「长跳转」)
jne 0x06、je 0xF5第12 章 剖析指令text/18-ch12.txt:421(搜「偏移量字节包含 0x06」) · text/18-ch12.txt:426(搜「0xf5 是负数」)
两趟汇编器第12 章 剖析指令text/18-ch12.txt:456(搜「两趟汇编器」) · text/18-ch12.txt:462(搜「第一趟处理」)
链接器与 GOT/PLT第12 章 剖析指令text/18-ch12.txt:512(搜「基本单元是一段机器码」) · text/18-ch12.txt:519(搜「外部符号」)

Footnotes

  1. 出处:「第12 章 剖析指令」第 12–17 段(text/18-ch12.txt:12,搜「主要目标」;text/18-ch12.txt:12,搜「记不住每条指令」;text/18-ch12.txt:14,搜「学习指令编码的另一个原因」)。 ↩ ↩2 ↩3

  2. 出处:「第12 章 剖析指令」第 62–77 段(text/18-ch12.txt:64,搜「字节数是可变的」;text/18-ch12.txt:74,搜「操作码」)。指令布局见第 69–72 段(text/18-ch12.txt:72,搜「一般布局」)。 ↩ ↩2 ↩3

  3. 出处:「第12 章 剖析指令」第 20–27 段(text/18-ch12.txt:20,搜「汇编清单」;text/18-ch12.txt:25,搜「gstabs」)。清单各列含义见第 58–60 段(text/18-ch12.txt:58,搜「第一列」)。 ↩ ↩2

  4. 出处:「第12 章 剖析指令」第 83–85 段(text/18-ch12.txt:83,搜「隐式操作数」;text/18-ch12.txt:52,搜「C3」)。 ↩

  5. 出处:「第12 章 剖析指令」第 86–90 段(text/18-ch12.txt:86,搜「01010rrr」;text/18-ch12.txt:40,搜「55」)。 ↩ ↩2

  6. 出处:「第12 章 剖析指令」第 54–57 段(text/18-ch12.txt:54,搜「什么都不做」)与第 29–52 段(清单 12-1 本体,text/18-ch12.txt:40,搜「save caller's frame pointer」)。 ↩ ↩2

  7. 出处:「第12 章 剖析指令」第 95–103 段(text/18-ch12.txt:97,搜「ModR/M 字节用于扩展」;text/18-ch12.txt:98,搜「4 种可能的寻址模式」)。 ↩ ↩2 ↩3

  8. 出处:「第12 章 剖析指令」第 66–68 段(text/18-ch12.txt:66,搜「寄存器、内存或 I/O 端口」;text/18-ch12.txt:67,搜「寻址模式」)。 ↩

  9. 出处:「第12 章 剖析指令」第 104–116 段(text/18-ch12.txt:106,搜「寄存器的代码」;text/18-ch12.txt:113,搜「ah」)。64 位代码表(表 12-2)见第 124–134 段(text/18-ch12.txt:124,搜「64 位寄存器的代码」)。 ↩ ↩2

  10. 出处:「第12 章 剖析指令」第 119–121 段(text/18-ch12.txt:120,搜「0x66 前缀字节」)与第 146–149 段(text/18-ch12.txt:149,搜「0x88」)。 ↩ ↩2 ↩3

  11. 出处:「第12 章 剖析指令」第 140–145 段(text/18-ch12.txt:142,搜「11 001 000」;text/18-ch12.txt:145,搜「源寄存器 esi」)。 ↩

  12. 出处:「第12 章 剖析指令」第 154–167 段(text/18-ch12.txt:156,搜「REX 前缀字节」;text/18-ch12.txt:158,搜「0100」)。 ↩ ↩2

  13. 出处:「第12 章 剖析指令」第 164–167 段(text/18-ch12.txt:166,搜「01001000」)。 ↩

  14. 出处:「第12 章 剖析指令」第 168–173 段(text/18-ch12.txt:169,搜「01000100」;text/18-ch12.txt:170,搜「源寄存器字段的高位」)。 ↩

  15. 出处:「第12 章 剖析指令」第 136–139 段(text/18-ch12.txt:137,搜「最高位为 0」;text/18-ch12.txt:138,搜「其余 3 位完全可以放在 ModR/M 字节内」)。 ↩

  16. 出处:「第12 章 剖析指令」第 185–232 段(清单 12-2 本体,text/18-ch12.txt:197,搜「8-bit immediate」;text/18-ch12.txt:231,搜「设置 main 的返回值」)。 ↩ ↩2

  17. 出处:「第12 章 剖析指令」第 220–222 段(text/18-ch12.txt:221,搜「小端序」;text/18-ch12.txt:222,搜「按字节存储」)。 ↩

  18. 出处:「第12 章 剖析指令」第 250–261 段(text/18-ch12.txt:252,搜「8B 04 25」;text/18-ch12.txt:258,搜「占位符」)。 ↩ ↩2 ↩3

  19. 出处:「第12 章 剖析指令」第 262–268 段(text/18-ch12.txt:262,搜「并非位置无关代码」;text/18-ch12.txt:538,搜「寄存器间接寻址模式」)。 ↩

  20. 出处:「第12 章 剖析指令」第 293–308 段(text/18-ch12.txt:294,搜「-48[rbp]」;text/18-ch12.txt:307,搜「0x00007fffffffdf30」)。 ↩

  21. 出处:「第12 章 剖析指令」第 309–323 段(text/18-ch12.txt:305,搜「第 14 行的下一条指令」;text/18-ch12.txt:315,搜「01 000 101」)。 ↩

  22. 出处:「第12 章 剖析指令」第 324–329 段(text/18-ch12.txt:324,搜「如果你开始觉得复杂了,别」;text/18-ch12.txt:327,搜「numberOfItems」)。 ↩

  23. 出处:「第12 章 剖析指令」第 331–355 段(text/18-ch12.txt:333,搜「变址寄存器」;text/18-ch12.txt:349,搜「00 001 101」;text/18-ch12.txt:355,搜「比例因子为 4」)。 ↩

  24. 出处:「第12 章 剖析指令」第 335–337 段(text/18-ch12.txt:335,搜「大小必须相同」;text/18-ch12.txt:337,搜「全部 64 位」)。 ↩

  25. 出处:「第12 章 剖析指令」第 240–242 段(text/18-ch12.txt:240,搜「只允许一个操作数」)。 ↩

  26. 出处:「第12 章 剖析指令」第 178–233 段(立即寻址,text/18-ch12.txt:178,搜「立即寻址模式」)、第 243–268 段(直接内存寻址,text/18-ch12.txt:243,搜「直接内存寻址」)、第 270–329 段(带偏移的寄存器间接,text/18-ch12.txt:270,搜「带有偏移量的寄存器间接寻址」)、第 331–355 段(带索引的寄存器间接,text/18-ch12.txt:331,搜「带有索引的寄存器间接寻址」)。 ↩

  27. 出处:「第12 章 剖析指令」第 413–420 段(text/18-ch12.txt:417,搜「另一个助记符 jnz」;text/18-ch12.txt:419,搜「最能够清楚地传达你意图」)。 ↩ ↩2

  28. 出处:「第12 章 剖析指令」第 362–376 段(text/18-ch12.txt:362,搜「短跳转」;text/18-ch12.txt:367,搜「长跳转」;text/18-ch12.txt:369,搜「双重否定」)。 ↩ ↩2

  29. 出处:「第12 章 剖析指令」第 413–424 段(text/18-ch12.txt:421,搜「偏移量字节包含 0x06」;text/18-ch12.txt:422,搜「0x0009」)。 ↩ ↩2

  30. 出处:「第12 章 剖析指令」第 425–430 段(text/18-ch12.txt:426,搜「0xf5 是负数」;text/18-ch12.txt:422,搜「0x0009」)。 ↩ ↩2

  31. 出处:「第12 章 剖析指令」第 380–384 段(text/18-ch12.txt:381,搜「紧跟在导致出现待检查条件的操作之后」)。 ↩

  32. 出处:「第12 章 剖析指令」第 450–460 段(text/18-ch12.txt:456,搜「两趟汇编器」;text/18-ch12.txt:462,搜「第一趟处理」)。 ↩ ↩2 ↩3 ↩4 ↩5

  33. 出处:「第12 章 剖析指令」第 478–498 段(text/18-ch12.txt:483,搜「第二趟处理」;text/18-ch12.txt:492,搜「Let value = 0」)。 ↩ ↩2 ↩3

  34. 出处:「第12 章 剖析指令」第 503–506 段(text/18-ch12.txt:503,搜「以上是对汇编过程的简要概」)与第 527–529 段(text/18-ch12.txt:528,搜「Linkers & Loaders」)。 ↩ ↩2

  35. 出处:「第12 章 剖析指令」第 510–518 段(text/18-ch12.txt:512,搜「基本单元是一段机器码」;text/18-ch12.txt:516,搜「全局符号表」)。 ↩ ↩2 ↩3

  36. 出处:「第12 章 剖析指令」第 519–526 段(text/18-ch12.txt:519,搜「外部符号」;text/18-ch12.txt:525,搜「将其地址插入程序的 GOT」)。 ↩

On this page