跳转至

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

行 汇编 机器码 这一行在演示什么
8 push rbp 55 操作码嵌寄存器编号
9 mov rbp, rsp 48 89 E5 64 位:REX(W=1)+ 0x89 + ModR/M
11 mov eax, ecx 89 C8 32 位,低编号寄存器
12 mov edi, esi 89 F7 32 位,高编号寄存器(编号进 3 位)
13 mov ax, cx 66 89 C8 16 位:多一个 0x66 前缀
14 mov al, cl 88 C8 8 位:换操作码 0x88
15 mov edi, r8d 44 89 C7 新寄存器:REX(R=1)补第 4 位
16 mov rax, rcx 48 89 C8 64 位:REX(W=1)
18 mov eax, 0 B8 00 00 00 00 立即数:行李跟在操作码后
20 pop rbp 5D 同第 8 行,方向相反
21 ret C3 单字节,隐式操作数

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

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

为什么需要第二个字节

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

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

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

寄存器的 3 位代码表

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

代码 8 位 16 位 32 位
000 al ax eax
001 cl cx ecx
010 dl dx edx
011 bl bx ebx
100 ah sp esp
101 ch bp ebp
110 dh si esi
111 bh di edi

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

主线例子第一站:拆 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)6。

对照第 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)7。

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

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

  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 是唯一的区别9;
  • 第 15 行 mov edi, r8d = 44 89 C7:W=0(只搬 32 位),但 R=1, 这个 1 拼到 Reg/Opcode 的 3 位前面,凑出第 4 位,得到 1000 = r810。

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

主线例子补一站: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 把四种大小的「搬常量进寄存器」并排摆开,一眼看清规律30:

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

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

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

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

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

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

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

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

主线例子第二站:拆 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 字节装进 eax14。

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

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

主线例子第三站:拆 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 章数组实现的机器级底座17。

细节两条,都值得记:

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

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

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

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

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

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

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

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

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

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

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

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 标签20。
  • 74 F5:0x74 = je。偏移量 0xF5 是负数(补码),符号扩展后加到 rip: 0x0014 + (−0x0B) = 0x0009 = back 标签21。

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

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

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

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

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

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

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

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

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

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

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

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

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 专讲链接器),并再次声明:本章只是「粗略的概念」28。

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

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

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

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

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

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

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

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

9. 作者的判断与证据

本章的教法和它的代价

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

一个值得亲手做的验证

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

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

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

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

10. 边界与局限

  • ModR/M 的全部情况本书故意不讲,只给了读懂手册的最小集——遇到生僻组合,查手册是必然。
  • 只解剖了 mov 与条件跳转两类。算术指令、乘除法的编码本书不展开(第 16 章用「会用」的标准讲)。
  • 汇编器/链接器的算法是概念级概述:真实的 gas 与 ld 处理了重定位类型、弱符号、段合并等大量细节,别拿这两段伪代码去实现。
  • SIB 字节有两个「特殊情况」通道(R/M=100 触发 SIB;SIB 的 0x25 形式承载直接地址),它们是 x86 编码史上的补丁摞补丁——能用,但不优雅,知道即可12。
  • 变长指令是 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 反而不能编码——手册里这个坑用注脚标着,本章未展开32。

判断(我们的,不是书里的): 全章几乎没说一句「这很难」,但 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(搜「外部符号」)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  32. 出处:「第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 位寄存器的代码」)。 ↩↩

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

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

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

  36. 出处:「第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,搜「带有索引的寄存器间接寻址」)。 ↩