剖析指令 — 一行汇编是怎么变成几个字节的¶
这一章讲什么: 前面我们写汇编、看汇编,但汇编器和 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. 汇编器与链接器:两遍扫描填空白¶
为什么需要两遍:前向引用是硬骨头¶
先看一眼为什么"一遍扫不过去"。汇编器的输入是顺序的文本,但汇编语言里的引用 经常指向后面才出现的东西:
jne forward 的偏移量要写在指令字节里,可 forward 的值得等扫到它才知道。
要么先记着回头填,要么先把全文扫一遍建表——前者是单趟汇编器,
后者就是书里讲的两趟汇编器。两条路都通,选哪条是实现自由;
「欠账登记 + 事后填账」这个本质,两条路都跑不掉23。
汇编器:符号不认识,就先留空¶
汇编器把汇编翻译成机器码,一行对一行,本来一遍就够。麻烦在前向引用:
jne forward 出现时,forward 标签还没读到,偏移量算不出来。
经典解法是两趟汇编器23:
- 第一趟:从头到尾扫一遍,维护一个位置计数器,记下每个标签等于「相对模块开头的多少字节」,
存进局部符号表(
.equ定义的符号直接进表)。注意一个细节:每个 .text 节、 .data 节各建一张局部符号表——符号的值是「相对本节的偏移」,不是相对整个文件23; - 第二趟:正式翻译。符号在表里就直接填值;不在表里的(别的文件定义的), 填 0 占位,同时把「哪个符号、在哪个位置被引用」记进目标文件,留给链接器24。 翻译本身靠一张内置的操作码表——汇编语言与机器码一一对应,这就是它叫 「汇编」的原因:不是编译,是查表翻译24。
汇编器两趟走一遍微缩例子¶
- 第一趟:位置计数器从 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 为例:
书里所有"❶❷❸"的编号,都是从这样一张清单里来的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. 可带走的¶
- 指令 = 前缀(可选)+ 操作码 + ModR/M(可选)+ SIB(可选)+ 偏移量/立即数;x86-64 变长;
as -al生成汇编清单,机器码逐行可见;- ModR/M 三字段:Mod 定寻址方式,Reg/Opcode 和 R/M 各指一个寄存器(3 位);
- 默认 32 位;16 位加
0x66前缀,8 位换操作码,64 位加 REX(W=1); - 第 9–16 号寄存器(r8–r15)的编号第 4 位藏在 REX 前缀的 R/X/B 位;
- 立即数是行李,跟在操作码后,小端序存储;写 0 也占满规定的字节数;
- 内存寻址的完全体:
基址 + 变址 × 比例(1/2/4/8) + 偏移量;R/M=100 表示「后面有 SIB」; - 跳转偏移量相对下一条指令算,负偏移按补码符号扩展;
- 条件跳转助记符比编码多(30+ 对 16),同码多名,挑最达意的用;
- 一条指令最多一个内存操作数,「内存→内存」必须经寄存器中转;
- 汇编器两趟:一趟建符号表,一趟翻译填值;链接器同理,粒度是目标文件;
- 外部符号留给 PLT/GOT,运行时由动态加载器填地址;
- 符号表 + 占位符 + 回填,是汇编器、链接器、动态加载器三代工具的共同骨架。
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(搜「外部符号」) |
-
出处:「第12 章 剖析指令」第 62–77 段(
text/18-ch12.txt:64,搜「字节数是可变的」;text/18-ch12.txt:74,搜「操作码」)。指令布局见第 69–72 段(text/18-ch12.txt:72,搜「一般布局」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 20–27 段(
text/18-ch12.txt:20,搜「汇编清单」;text/18-ch12.txt:25,搜「gstabs」)。清单各列含义见第 58–60 段(text/18-ch12.txt:58,搜「第一列」)。 ↩↩ -
出处:「第12 章 剖析指令」第 83–85 段(
text/18-ch12.txt:83,搜「隐式操作数」;text/18-ch12.txt:52,搜「C3」)。 ↩ -
出处:「第12 章 剖析指令」第 86–90 段(
text/18-ch12.txt:86,搜「01010rrr」;text/18-ch12.txt:40,搜「55」)。 ↩↩ -
出处:「第12 章 剖析指令」第 95–103 段(
text/18-ch12.txt:97,搜「ModR/M 字节用于扩展」;text/18-ch12.txt:98,搜「4 种可能的寻址模式」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 140–145 段(
text/18-ch12.txt:142,搜「11 001 000」;text/18-ch12.txt:145,搜「源寄存器 esi」)。 ↩ -
出处:「第12 章 剖析指令」第 119–121 段(
text/18-ch12.txt:120,搜「0x66 前缀字节」)与第 146–149 段(text/18-ch12.txt:149,搜「0x88」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 154–167 段(
text/18-ch12.txt:156,搜「REX 前缀字节」;text/18-ch12.txt:158,搜「0100」)。 ↩↩ -
出处:「第12 章 剖析指令」第 164–167 段(
text/18-ch12.txt:166,搜「01001000」)。 ↩ -
出处:「第12 章 剖析指令」第 168–173 段(
text/18-ch12.txt:169,搜「01000100」;text/18-ch12.txt:170,搜「源寄存器字段的高位」)。 ↩ -
出处:「第12 章 剖析指令」第 220–222 段(
text/18-ch12.txt:221,搜「小端序」;text/18-ch12.txt:222,搜「按字节存储」)。 ↩ -
出处:「第12 章 剖析指令」第 250–261 段(
text/18-ch12.txt:252,搜「8B 04 25」;text/18-ch12.txt:258,搜「占位符」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 262–268 段(
text/18-ch12.txt:262,搜「并非位置无关代码」;text/18-ch12.txt:538,搜「寄存器间接寻址模式」)。 ↩ -
出处:「第12 章 剖析指令」第 293–308 段(
text/18-ch12.txt:294,搜「-48[rbp]」;text/18-ch12.txt:307,搜「0x00007fffffffdf30」)。 ↩ -
出处:「第12 章 剖析指令」第 309–323 段(
text/18-ch12.txt:305,搜「第 14 行的下一条指令」;text/18-ch12.txt:315,搜「01 000 101」)。 ↩ -
出处:「第12 章 剖析指令」第 324–329 段(
text/18-ch12.txt:324,搜「如果你开始觉得复杂了,别」;text/18-ch12.txt:327,搜「numberOfItems」)。 ↩ -
出处:「第12 章 剖析指令」第 331–355 段(
text/18-ch12.txt:333,搜「变址寄存器」;text/18-ch12.txt:349,搜「00 001 101」;text/18-ch12.txt:355,搜「比例因子为 4」)。 ↩ -
出处:「第12 章 剖析指令」第 335–337 段(
text/18-ch12.txt:335,搜「大小必须相同」;text/18-ch12.txt:337,搜「全部 64 位」)。 ↩ -
出处:「第12 章 剖析指令」第 362–376 段(
text/18-ch12.txt:362,搜「短跳转」;text/18-ch12.txt:367,搜「长跳转」;text/18-ch12.txt:369,搜「双重否定」)。 ↩↩ -
出处:「第12 章 剖析指令」第 413–424 段(
text/18-ch12.txt:421,搜「偏移量字节包含 0x06」;text/18-ch12.txt:422,搜「0x0009」)。 ↩↩ -
出处:「第12 章 剖析指令」第 425–430 段(
text/18-ch12.txt:426,搜「0xf5 是负数」;text/18-ch12.txt:422,搜「0x0009」)。 ↩↩ -
出处:「第12 章 剖析指令」第 380–384 段(
text/18-ch12.txt:381,搜「紧跟在导致出现待检查条件的操作之后」)。 ↩ -
出处:「第12 章 剖析指令」第 450–460 段(
text/18-ch12.txt:456,搜「两趟汇编器」;text/18-ch12.txt:462,搜「第一趟处理」)。 ↩↩↩↩↩ -
出处:「第12 章 剖析指令」第 478–498 段(
text/18-ch12.txt:483,搜「第二趟处理」;text/18-ch12.txt:492,搜「Let value = 0」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 510–518 段(
text/18-ch12.txt:512,搜「基本单元是一段机器码」;text/18-ch12.txt:516,搜「全局符号表」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 519–526 段(
text/18-ch12.txt:519,搜「外部符号」;text/18-ch12.txt:525,搜「将其地址插入程序的 GOT」)。 ↩ -
出处:「第12 章 剖析指令」第 12–17 段(
text/18-ch12.txt:12,搜「主要目标」;text/18-ch12.txt:12,搜「记不住每条指令」;text/18-ch12.txt:14,搜「学习指令编码的另一个原因」)。 ↩↩↩ -
出处:「第12 章 剖析指令」第 503–506 段(
text/18-ch12.txt:503,搜「以上是对汇编过程的简要概」)与第 527–529 段(text/18-ch12.txt:528,搜「Linkers & Loaders」)。 ↩↩ -
出处:「第12 章 剖析指令」第 54–57 段(
text/18-ch12.txt:54,搜「什么都不做」)与第 29–52 段(清单 12-1 本体,text/18-ch12.txt:40,搜「save caller's frame pointer」)。 ↩↩ -
出处:「第12 章 剖析指令」第 185–232 段(清单 12-2 本体,
text/18-ch12.txt:197,搜「8-bit immediate」;text/18-ch12.txt:231,搜「设置 main 的返回值」)。 ↩↩ -
出处:「第12 章 剖析指令」第 66–68 段(
text/18-ch12.txt:66,搜「寄存器、内存或 I/O 端口」;text/18-ch12.txt:67,搜「寻址模式」)。 ↩ -
出处:「第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 位寄存器的代码」)。 ↩↩ -
出处:「第12 章 剖析指令」第 413–420 段(
text/18-ch12.txt:417,搜「另一个助记符 jnz」;text/18-ch12.txt:419,搜「最能够清楚地传达你意图」)。 ↩↩ -
出处:「第12 章 剖析指令」第 240–242 段(
text/18-ch12.txt:240,搜「只允许一个操作数」)。 ↩ -
出处:「第12 章 剖析指令」第 136–139 段(
text/18-ch12.txt:137,搜「最高位为 0」;text/18-ch12.txt:138,搜「其余 3 位完全可以放在 ModR/M 字节内」)。 ↩ -
出处:「第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,搜「带有索引的寄存器间接寻址」)。 ↩