1.3 机器级程序:x86-64 汇编
一个 for 循环在 CPU 眼里长什么样?函数调用的参数放在哪、返回值怎么传?为什么 lea 能同时做加法和乘法?反汇编里那些 %rdi、8(%rbp) 到底指什么?
读懂 GCC 生成的 x86-64 汇编;画出栈帧并算出每个参数的位置;解释数组/结构体/联合体的地址计算;用 objdump、gdb 定位一条指令的运行时状态;对照 ARM64 的寄存器与调用约定。
C 是「可移植的汇编」这一说法只对了一半。C 标准规定的是抽象机器的行为,编译器把它翻译成目标 ISA 的指令;同一段 C 在 x86-64、ARM64、RISC-V 上生成的指令差别很大,但调用约定、栈布局、数据布局这些概念是相通的。学会一种 ISA 的读法,换架构只需要换寄存器名和指令表。
这一章以 x86-64(System V ABI,Linux 使用)为主。选择它是因为绝大多数服务器、桌面 Linux 都在这个架构上,反汇编和调试资料最多。ARM64 只讲与正文相关的部分:寄存器、参数传递、bl/ret、ldp/stp、条件分支。Y86-64 是第 1.4 章的教学 ISA,这里不混入。
读汇编的目标不是手写汇编,而是三件事:验证编译器是否按你的意图优化;在崩溃现场从寄存器与栈还原程序状态;理解高级语言构造的代价。三者都需要你能把一条指令拆成「操作数从哪来、结果到哪去、标志位怎么变」。
1.3.1 寄存器、数据格式与调用约定
x86-64 有 16 个通用寄存器(64 位),每个都有不同宽度的别名:
| 64 位 | 32 位 | 16 位 | 8 位 | 用途(System V) | 谁保存 |
|---|---|---|---|---|---|
%rax | %eax | %ax | %al | 返回值;也是累加器 | 调用者 |
%rbx | %ebx | %bx | %bl | 被调用者保存的通用寄存器 | 被调用者 |
%rcx | %ecx | %cx | %cl | 第 4 个参数;移位量 | 调用者 |
%rdx | %edx | %dx | %dl | 第 3 个参数;除法高位 | 调用者 |
%rsi | %esi | %si | %sil | 第 2 个参数 | 调用者 |
%rdi | %edi | %di | %dil | 第 1 个参数 | 调用者 |
%rbp | %ebp | %bp | %bpl | 帧指针(可选) | 被调用者 |
%rsp | %esp | %sp | %spl | 栈指针 | 被调用者 |
%r8–%r9 | %r8d–%r9d | %r8w–%r9w | %r8b–%r9b | 第 5、6 个参数 | 调用者 |
%r10–%r11 | %r10d–%r11d | … | … | 临时寄存器 | 调用者 |
%r12–%r15 | %r12d–%r15d | … | … | 通用 | 被调用者 |
「谁保存」是调用约定(calling convention)的核心:调用者保存(caller-saved)的寄存器,被调用函数可以随意改写,调用者若还需要原值,必须在 call 前自己压栈;被调用者保存(callee-saved)的寄存器,被调用函数若使用,必须在入口保存、出口恢复。%rax 是调用者保存,所以调用完函数后不能假设它还是原来的值;%rbx 是被调用者保存,函数返回后它一定和调用前一样。
这套寄存器是历史叠加的结果:8086 的 16 位 ax/bx/cx/dx、80386 扩展的 eax、AMD64 扩展的 rax 与 r8–r15。32 位与 64 位不是「同一份代码换个开关」:调用约定、指针宽度、系统调用号、结构体对齐都不同;gcc -m32 生成的是 i386 ABI,-m64 才是 System V AMD64 ABI。读老汇编时先看 .code32/.code64 与 ELF class,不要默认是 64 位。
数据格式用后缀区分宽度:
| C 类型 | 汇编后缀 | 字节 | 典型指令 |
|---|---|---|---|
char | b | 1 | movb、addb |
short | w | 2 | movw、addw |
int | l | 4 | movl、addl |
long、指针 | q | 8 | movq、addq |
float | s | 4 | movss、addss |
double | l | 8 | movsd、addsd |
注意 int 的后缀是 l(long word,历史遗留),double 也是 l,靠指令类别(movsd vs movl)区分。浮点参数走 %xmm0–%xmm7,不占用通用寄存器;返回值在 %xmm0。
Linux、BSD、macOS 在 x86-64 上使用的调用约定。整数/指针参数依次放 %rdi、%rsi、%rdx、%rcx、%r8、%r9,第 7 个起从右向左压栈;浮点参数用 %xmm0–%xmm7;返回值在 %rax(或 %rax:%rdx、%xmm0)。栈在 call 前必须 16 字节对齐;%rbx、%rbp、%r12–%r15 由被调用者保存。
1.3.2 操作数与寻址模式
AT&T 语法的操作数顺序是「源, 目的」,寄存器带 %,立即数带 $。movq $8, %rax 把 8 放进 %rax;movq %rax, %rbx 把 %rax 的值复制到 %rbx。Intel 语法反过来(mov rax, 8),objdump -M intel 可以切换。
内存操作数的通用形式是
其中 Scale 只能是 1、2、4、8。这个形式几乎覆盖了所有数组/结构体访问:
| 写法 | 地址 | 典型来源 |
|---|---|---|
(%rdi) | M[rdi] | *p |
8(%rdi) | M[rdi+8] | p->next(偏移 8) |
(%rdi,%rsi,8) | M[rdi + 8*rsi] | a[i],long a[] |
4(%rdi,%rsi,4) | M[rdi + 4*rsi + 4] | a[i+1],int a[] |
(%rdi,%rsi,1) | M[rdi+rsi] | 字节数组 s[i] |
0x10(%rip) | M[rip+0x10] | 全局变量、字符串常量 |
%rip 相对寻址是位置无关代码(PIC)的基础:指令编码的是「目标 - 下一条指令」的 32 位偏移,加载到任何地址都能正确指向同一节内的数据。第 1.7 章讲 GOT/PLT 时会再见到它。
lea(load effective address)只做地址计算,不访问内存:
leaq 7(%rdi,%rsi,4), %rax # %rax = %rdi + 4*%rsi + 7
leaq (%rdi,%rdi,2), %rax # %rax = 3*%rdi
leaq (%rdi,%rdi,4), %rax # %rax = 5*%rdi
leaq (%rdi,%rdi,8), %rax # %rax = 9*%rdi
编译器常用 lea 做「乘以 3/5/9 再加常数」的快速运算,因为一条 lea 就能完成,且不设置标志位、不占 ALU 的乘法端口。lea 的操作数看起来像内存地址,但 CPU 只算地址、不读内存;这是它和 mov 的关键区别。
Scale 只允许 1/2/4/8,正好对应 C 的 char/short/int/long(以及指针)。硬件把「基址 + 变址 × 元素大小」做成一条指令的地址生成器,编译器就不需要先算 i*8 再相加。这是 ISA 为高级语言优化的经典例子:指令集里固化了常见数据布局的假设。
1.3.3 数据传送与算术逻辑
最常用的指令族:
| 指令 | 效果 | 说明 |
|---|---|---|
mov src, dst | dst = src | 不设标志位;源和目的不能同时为内存 |
movzXX src, dst | 零扩展传送 | movzbl 把字节零扩展到 32 位 |
movsXX src, dst | 符号扩展传送 | movsbq 把字节符号扩展到 64 位 |
lea addr, dst | dst = addr | 只算地址 |
add src, dst | dst += src | 设 CF/ZF/SF/OF |
sub src, dst | dst -= src | 设标志位 |
imul src, dst | dst *= src | 有符号乘法,设 CF/OF |
and/or/xor src, dst | 位运算 | 设 SF/ZF,CF=OF=0 |
inc/dec dst | 加/减 1 | 不改变 CF,其余标志位变 |
neg dst | 取负 | 设标志位 |
not dst | 按位取反 | 不设标志位 |
sal/sar/shl/shr k, dst | 移位 | sar 算术右移,shr 逻辑右移 |
几个容易踩的细节:
movl %eax, %eax会把%rax的高 32 位清零。x86-64 规定:任何写 32 位寄存器的操作,都会把对应 64 位寄存器的高 32 位清零。所以movl $1, %eax等价于movq $1, %rax,但编码更短。写 8/16 位寄存器不会清零高位。add的源可以是寄存器/内存/立即数,目的可以是寄存器或内存;但两个操作数不能同时是内存。addq (%rdi), %rax与addq %rax, (%rdi)都合法;后者是读-改-写。imul有两种形式:双操作数imul src, dst只保留低 64 位;单操作数imulq src把 128 位结果放到%rdx:%rax。C 的long乘法通常用双操作数,溢出被丢弃。inc/dec不改 CF,所以循环里用inc计数、又依赖 CF 做多精度运算时会出错。编译器通常用add $1而不是inc,部分原因就在这里。
一段真实的编译结果:
long arith(long x, long y, long z)
{
long t1 = x + y;
long t2 = z * 48;
long t3 = t1 & 0xFFFF;
return t2 + t3;
}
GCC 13 -O2 生成:
arith:
leaq (%rdi,%rsi), %rax # t1 = x + y
salq $4, %rdx # z << 4 = z * 16
leaq (%rdx,%rdx,2), %rdx # z*16 + z*32 = z*48
andl $65535, %eax # t3 = t1 & 0xFFFF(32 位结果零扩展)
addq %rdx, %rax
ret
z * 48 没有用 imul,而是拆成 <<4 和 lea:移位 1 周期,lea 1 周期,乘法 3 周期。编译器在常数乘法上做了强度削减(strength reduction)。andl 而不是 andq,是因为掩码只有低 16 位,32 位运算更短,而且结果零扩展正好符合 long 的语义。
1.3.4 条件码、比较与跳转
CPU 用四个条件码描述最近一次算术/逻辑运算的结果:
| 标志 | 名称 | 含义 |
|---|---|---|
CF | 进位 | 无符号溢出/借位 |
ZF | 零 | 结果为 0 |
SF | 符号 | 结果为负(最高位为 1) |
OF | 溢出 | 有符号溢出 |
cmp a, b 计算 b - a,只设标志位、不写结果;test a, b 计算 b & a,也只设标志位。注意 AT&T 的顺序:cmpq %rsi, %rdi 算的是 %rdi - %rsi,即「比较 rdi 和 rsi」时,条件码对应的是 rdi ? rsi。
条件跳转与条件传送:
| 指令 | 条件 | C 语义(cmp b, a 后) |
|---|---|---|
je / jz | ZF=1 | a == b |
jne / jnz | ZF=0 | a != b |
js | SF=1 | a < 0 |
jg | ZF=0 && SF=OF | a > b(有符号) |
jge | SF=OF | a >= b |
jl | SF!=OF | a < b |
jle | ZF=1 || SF!=OF | a <= b |
ja | CF=0 && ZF=0 | a > b(无符号) |
jb | CF=1 | a < b(无符号) |
有符号与无符号用不同指令,是因为同一组标志位有两种解释。0xFFFFFFFF 与 0 比较:按有符号是 -1 < 0,按无符号是 4294967295 > 0。编译器根据 C 类型选择 jl 还是 jb。
set 指令把条件码写成一个字节(0 或 1),cmov 根据条件码选择源操作数:
cmpq %rsi, %rdi # 比较 x, y
setl %al # al = (x < y)
movzbl %al, %eax # 零扩展到 32 位
cmov 避免了分支,但源操作数必须已经算好。如果源是一个可能越界的指针解引用,编译器不能无条件计算它,只能用分支。这就是「条件传送不一定更快」的原因:它把控制依赖变成数据依赖,可能多做无用功。
- 「
cmp会修改操作数」——不会,它只设标志位。sub才修改目的操作数。 - 「
test和cmp一样」——test做按位与,常用于test %rax,%rax判断是否为零;cmp做减法,用于大小比较。 - 「
jg和ja可以互换」——不能。前者有符号,后者无符号;混用会让负数比较出错。 - 「
lea会设置标志位」——不会。lea只算地址,标志位保持不变;这也是编译器喜欢用lea做算术的原因之一。
1.3.5 if、switch、循环的汇编形态
先看条件分支。absdiff 用 cmov 实现无分支:
long absdiff(long x, long y)
{
long r;
if (x < y)
r = y - x;
else
r = x - y;
return r;
}
absdiff:
movq %rdi, %rax
subq %rsi, %rax # rax = x - y
movq %rsi, %rdx
subq %rdi, %rdx # rdx = y - x
cmpq %rsi, %rdi # x - y
cmovl %rdx, %rax # if (x < y) rax = rdx
ret
两个分支都被计算,最后用 cmovl 选一个。对这么小的表达式,cmov 比跳转好:没有分支预测失败,指令流水线不会断。但若 y - x 可能触发异常(比如指针解引用),无条件计算就不安全,编译器会退回分支。
switch 在 case 密集时编译成跳转表:
int classify(int x)
{
switch (x) {
case 0: return 10;
case 1: return 11;
case 2: return 12;
case 3: return 13;
default: return -1;
}
}
classify:
cmpl $3, %edi
ja .L2 # 无符号大于 3:越界,走 default
movl %edi, %edi
jmp *.L4(,%rdi,8) # 跳转表,每项 8 字节
.L4:
.quad .L8 # case 0
.quad .L7 # case 1
.quad .L6 # case 2
.quad .L5 # case 3
跳转表把「比较 N 次」变成「一次边界检查 + 一次间接跳转」,复杂度 O(1)。代价是表占空间,且间接跳转可能被 Spectre 类攻击利用(内核因此有 array_index_nospec 等缓解)。case 稀疏时(比如 0、100、1000),编译器退回一串比较,不会生成巨大的表。
循环有三种常见形态:
long sum(long *a, long n)
{
long s = 0;
for (long i = 0; i < n; i++)
s += a[i];
return s;
}
sum:
testq %rsi, %rsi
jle .L4 # n <= 0 直接返回 0
leaq (%rdi,%rsi,8), %rdx # 结束地址 = a + 8n
xorl %eax, %eax
.L3:
addq (%rdi), %rax # s += *a
addq $8, %rdi # a++
cmpq %rdx, %rdi
jne .L3
ret
.L4:
xorl %eax, %eax
ret
编译器把「下标 i」优化成了「指针 a」:i < n 变成 a < a+8n,a[i] 变成 (%rdi)。这叫归纳变量优化(induction variable optimization)。while、do-while、for 在汇编层的差别只在「先判断还是先执行」,优化后往往趋同。
-O0 会把每个局部变量放在栈上,每次赋值都写回内存,循环边界每次重算。它不是「忠实的 C」,而是「方便调试的 C」。读优化后的汇编才有性能意义;读 -O0 汇编适合确认变量地址和栈帧布局。两者要分开用。
1.3.6 栈帧、call/ret 与参数传递
栈从高地址向低地址增长。call 做两件事:把返回地址(下一条指令的地址)压栈,跳到目标;ret 从栈顶弹出返回地址,跳回去。被调用函数通常用 %rbp 建立帧指针,形成一条链,调试器靠它回溯调用栈。
高地址
┌──────────────────────┐
│ 第 7 个及以后的参数 │ 16(%rbp), 24(%rbp), ...
├──────────────────────┤
│ 返回地址 │ 8(%rbp)
├──────────────────────┤
│ 保存的 %rbp │ 0(%rbp) ← %rbp 指向这里
├──────────────────────┤
│ 保存的 %rbx/%r12 │ -8(%rbp), -16(%rbp)
├──────────────────────┤
│ 局部变量 │ -24(%rbp), ...
├──────────────────────┤
│ 临时空间/对齐填充 │ ← %rsp
└──────────────────────┘
低地址
读这张图要抓住三点:
%rbp指向「保存的旧%rbp」,所以8(%rbp)是返回地址,16(%rbp)是调用者压入的第 7 个参数。- 参数 1–6 不在栈上,在寄存器里;被调用函数若需要它们的地址(比如
&x),才在序言里把它们写到栈上。 %rsp在函数执行期间可以移动(sub/add、push/pop、alloca),所以局部变量用%rbp相对寻址更稳定;-fomit-frame-pointer(-O2默认)省掉%rbp,改用%rsp相对寻址,调试时回溯会依赖 CFI(.eh_frame)信息。
一个带 7 个参数的调用:
long callee(long a, long b, long c, long d, long e, long f, long g);
long caller(void)
{
return callee(1, 2, 3, 4, 5, 6, 7);
}
调用点附近:
caller:
subq $8, %rsp # 对齐:进入 caller 时 rsp%16==8,sub 8 后为 0
movq $7, (%rsp) # 第 7 个参数放在栈顶(不能先 sub 8 再 push)
movl $6, %r9d
movl $5, %r8d
movl $4, %ecx
movl $3, %edx
movl $2, %esi
movl $1, %edi
call callee
addq $8, %rsp # 清理参数 + 对齐
ret
subq $8 是为了对齐:进入 caller 时 %rsp % 16 == 8(call 压了返回地址),subq $8 后变成 0;第 7 个参数放在 (%rsp),call 再压 8 字节,进入 callee 时 %rsp % 16 == 8。ABI 要求「call 执行前 %rsp % 16 == 0」,这里正好满足。不能先 sub 8 再 push:那会在 call 前变成 8,callee 入口变成 0,SSE/AVX 的 movaps 会直接段错误——这是新手汇编最常见的崩溃之一。
被调用函数的序言/尾声:
callee:
pushq %rbp
movq %rsp, %rbp
pushq %rbx # 保存被调用者保存的寄存器
subq $24, %rsp # 局部变量空间,保持 16 对齐
...
addq $24, %rsp
popq %rbx
popq %rbp
ret
pushq %rbp; movq %rsp,%rbp 是标准的帧指针建立;leave 指令等价于 movq %rbp,%rsp; popq %rbp。叶子函数(不再调用其他函数)可以使用红区(red zone):%rsp 以下 128 字节不被打扰,可以直接用 -8(%rsp) 存局部变量,不必调整栈指针。信号处理函数必须避开红区,否则会破坏被中断函数的临时数据。
一次函数调用在栈上占用的区域,包含:传入的栈参数、返回地址、保存的 %rbp、被调用者保存的寄存器、局部变量、临时空间与对齐填充。%rbp 指向帧基址,%rsp 指向栈顶。帧的大小在编译期确定(除非用 alloca/变长数组),由序言分配、尾声释放。
1.3.7 数组、结构体与联合体的布局
C 的数组是连续存放的,元素大小固定。对 T A[N],元素 A[i] 的地址是
二维数组 T A[R][C] 按行主序(row-major)存放,A[i][j] 的地址是
例:int A[4][5],sizeof(int)=4,&A[2][3] = A + (2*5+3)*4 = A + 52。行主序意味着「按行遍历」对缓存友好:同一行的元素在内存中相邻。第 1.6 章会量化这个差别。
int A[4][5] 的内存布局(每个格子 4 字节) A[0][0] A[0][1] A[0][2] A[0][3] A[0][4] A[1][0] A[1][1] A[1][2] A[1][3] A[1][4] A[2][0] A[2][1] A[2][2] A[2][3] A[2][4] A[3][0] A[3][1] A[3][2] A[3][3] A[3][4] 地址递增 →
结构体的成员按声明顺序存放,但每个成员要满足对齐要求,因此可能有填充(1.2.6 已算过)。联合体(union)的所有成员共享同一块内存,大小是最大成员的大小,对齐是最大对齐:
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
struct Point { int x, y; };
struct Rect { struct Point tl, br; };
union Value { int32_t i; float f; unsigned char bytes[4]; };
int main(void)
{
struct Rect r = { {1, 2}, {3, 4} };
int grid[4][5] = {0};
union Value v;
printf("sizeof(Point)=%zu sizeof(Rect)=%zu\n",
sizeof(struct Point), sizeof(struct Rect));
printf("grid[2][3] offset = %zu bytes\n",
(size_t)((char *)&grid[2][3] - (char *)grid));
printf("Rect.br offset = %zu\n", offsetof(struct Rect, br));
printf("sizeof(Value)=%zu\n", sizeof(union Value));
v.f = 1.0f;
printf("1.0f bytes: %02x %02x %02x %02x\n",
v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3]);
return 0;
}
输出会确认:Point 8 字节、Rect 16 字节;grid[2][3] 偏移 52;Rect.br 偏移 8;Value 4 字节;1.0f 的字节是 00 00 80 3f(小端)。
结构体指针的访问会被编译器翻译成「基址 + 偏移」:
# struct Rect *p; p->br.x
movl 8(%rdi), %eax # br 在偏移 8,x 是 int,用 32 位 movl
若结构体很大,编译器可能先用 lea 算出成员地址,再做读写;若成员是数组,还会叠加索引的 Scale。所有高级语言的「对象访问」,在机器层都是这种基址+偏移的算术。
C99 的柔性数组成员 struct { size_t n; int data[]; } 把 data 放在结构体末尾,sizeof 不含它;分配时 malloc(sizeof(struct S) + n*sizeof(int))。这是内核与网络协议里常见的「头部+载荷」布局。它比「指针指向另一块内存」少一次间接访问,但要求一次性分配、不能按值复制。
变长数组与 alloca:运行时调整 rsp
C99 的 VLA(int a[n];)在运行时按 n 调整栈指针:
void f(int n) {
int a[n]; /* 运行时 rsp -= n*4(+ 对齐) */
a[0] = 1;
}
编译器生成「取 n、乘元素大小、向上对齐、减 rsp」的序列;sizeof(a) 在 VLA 上是运行时值。VLA 不能初始化、不能用于结构体成员;C11 把 VLA 改为可选,C++ 不支持。alloca 类似但更危险:它在当前函数栈上分配,函数返回即失效,不能在循环里无界调用。
1.3.8 用 objdump 与 gdb 读机器码
objdump 是静态视角,gdb 是动态视角。两者配合,能从「指令」走到「运行时状态」。
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -g sum.c -o sum
objdump -d --no-show-raw-insn sum | sed -n '/<sum>:/,+12p'
objdump -d -M intel sum | sed -n '/<sum>:/,+12p' # Intel 语法
readelf -s sum | grep -E ' sum$| main$' # 符号表
objdump 输出里,左边是地址,中间是助记符,# 后是编译器加的注释(比如 # 8 <sum+0x8>)。-g 编译时还会带调试信息,objdump -S 能把源码与汇编交错显示。
gdb 的常用命令:
(gdb) disassemble /m sum # 带源码的反汇编
(gdb) break sum
(gdb) run
(gdb) info registers rdi rsi rax # 看参数与返回值
(gdb) x/4gx $rsp # 以 8 字节为单位看栈
(gdb) x/8i $rip # 看当前指令附近
(gdb) stepi # 单步一条机器指令
(gdb) finish # 运行到当前函数返回
读崩溃现场的顺序:先看 %rip 落在哪条指令,再看该指令的操作数(寄存器/内存),然后看栈上的返回地址链。若 %rip 是 0 或非法地址,通常是函数指针被覆盖、栈溢出写坏了返回地址,或者 ret 弹出了错误的值。bt(backtrace)依赖帧指针或 CFI;优化过的代码没有帧指针时,bt 仍能工作,但可能显示 ??。
- 「
objdump默认是 Intel 语法」——默认是 AT&T(源, 目的,寄存器带%)。看 Intel 语法要加-M intel,此时顺序是「目的, 源」。 - 「
call和jmp一样」——call会压返回地址,jmp不会。ret依赖call压入的地址;手工jmp进函数再ret会跳到未知位置。 - 「栈帧大小固定等于局部变量之和」——还要加上保存的寄存器、对齐填充、临时空间;编译器可能把多个小变量合并到一个 8 字节槽。
- 「
%rbp总是帧指针」——-O2默认省略帧指针,%rbp可能被当作普通寄存器使用。不要在生产代码里依赖%rbp回溯。
1.3.9 ARM64 对照:寄存器、调用与寻址
ARM64(AArch64)是 RISC 风格:指令定长 4 字节,load/store 架构(算术指令不能直接操作内存),31 个 64 位通用寄存器。与 x86-64 的对应关系:
| 概念 | x86-64 | ARM64 |
|---|---|---|
| 通用寄存器 | 16 个(%rax…%r15) | 31 个(x0–x30)+ sp/xzr |
| 32 位别名 | %eax 等 | w0–w30 |
| 参数 1–6/8 | %rdi、%rsi、%rdx、%rcx、%r8、%r9 | x0–x7 |
| 返回值 | %rax | x0(第二返回值 x1) |
| 栈指针 | %rsp | sp(16 字节对齐) |
| 帧指针 | %rbp | x29 |
| 返回地址 | 压栈 | x30(Link Register) |
| 调用 | call / ret | bl / ret |
| 被调用者保存 | %rbx、%rbp、%r12–%r15 | x19–x28、x29;x30 是 link register,不是 callee-saved |
| 条件执行 | cmov | csel、csinc 等 |
| 访存 | 指令可直接带内存操作数 | 只能 ldr/str,先算地址 |
ARM64 的 bl(branch with link)把返回地址放进 x30,然后跳转;ret 默认跳到 x30。若函数还要调用别的函数,必须先把 x30 保存到栈上,否则返回地址被覆盖。典型的序言/尾声:
// long callee(long a, long b)
callee:
stp x29, x30, [sp, #-16]! // 压栈并更新 sp:sp -= 16
mov x29, sp
add x0, x0, x1 // 返回值 = a + b
ldp x29, x30, [sp], #16 // 恢复并 sp += 16
ret
stp/ldp 是一次存/取两个寄存器,[sp, #-16]! 的 ! 表示「写回基址」(pre-index),[sp], #16 是 post-index。这两条指令在函数序言/尾声里非常常见,因为一条指令就完成「分配栈 + 保存两个寄存器」。
一个循环的 ARM64 版本:
// long sum(const long *a, long n)
sum:
mov x2, #0 // s = 0
cbz x1, .L2 // n == 0 则返回
.L1:
ldr x3, [x0], #8 // x3 = *a; a += 8
add x2, x2, x3 // s += x3
subs x1, x1, #1 // n -= 1,设标志位
b.ne .L1 // n != 0 继续
.L2:
mov x0, x2
ret
对照 x86-64 版本,差别在:ARM64 的 ldr 带 post-index 自动递增指针;循环计数用 subs + b.ne,不需要单独的 cmp;没有 lea,地址计算用 add/adrp。语义完全一样,指令数相近。
条件选择用 csel:
cmp x0, x1
csel x0, x1, x0, lt // if (x0 < x1) x0 = x1 else x0 = x0
csel 对应 x86 的 cmov,同样要求两个源都已计算。ARM64 还有 cbnz/cbz(比较并跳转)、tbz/tbnz(测位并跳转),可以把「比较 + 分支」合成一条。
x86-64 的优势是生态与单核性能,ARM64 的优势是能效与核心密度。云厂商的 ARM 实例(Graviton、Ampere)在吞吐型负载上性价比高;选择架构时先看依赖的软件是否有 ARM64 构建、性能关键路径是否依赖 x86 特有指令(AVX-512 等)。汇编层的差异对 C 代码通常透明,除非你写了内联汇编或依赖未定义行为。
1.3.10 机器码:从汇编到字节
这一节回答:objdump 左边那一串十六进制是什么?为什么 movq %rax,(%rdi) 是 3 个字节,而 movq $1,%rax 要 7 个?读懂编码后,崩溃地址附近的字节、JIT 生成的代码、以及「为什么这条指令比那条短」都不再是黑箱。
x86-64 的指令是变长的(1–15 字节),通用布局如下(CSAPP 图 3-2 的简化):
┌──────────┬──────────┬────────┬────────┬────────┬──────────┬─────────┐ │ 前缀(可选) │ REX(可选) │ opcode │ ModRM │ SIB │ disp │ imm │ │ 0-4 字节 │ 1 字节 │ 1-3 字节│ 1 字节 │ 1 字节 │ 1/2/4/8 │ 1/2/4/8 │ └──────────┴──────────┴────────┴────────┴────────┴──────────┴─────────┘
REX 前缀的位布局是 0100WRXB:
| 位 | 名称 | 作用 |
|---|---|---|
| W | 宽度 | 1 表示 64 位操作数(movq 而非 movl) |
| R | 扩展 reg | 把 ModRM 的 reg 字段从 3 位扩到 4 位(访问 %r8–%r15) |
| X | 扩展 index | 把 SIB 的 index 字段扩到 4 位 |
| B | 扩展 base/rm | 把 ModRM 的 rm 或 opcode 内嵌寄存器扩到 4 位 |
ModRM 一个字节拆成三段:mod(2 位,寻址方式)、reg(3 位,寄存器或 opcode 扩展)、rm(3 位,寄存器或内存基址)。mod=11 表示两边都是寄存器;mod=00/01/10 表示 rm 是内存,位移分别是 0、1 字节、4 字节。rm=100 时后面再跟一个 SIB 字节;rm=101 且 mod=00 表示 RIP 相对寻址。
SIB 拆成 scale(2 位:1/2/4/8)、index(3 位)、base(3 位)。index=100 表示没有变址(因为 %rsp 不能当变址寄存器)。
六个可手算的例子
| 汇编(AT&T) | 字节 | 拆解 |
|---|---|---|
movq %rax, (%rdi) | 48 89 07 | REX.W;89 是 mov r64→r/m64;ModRM 00 000 111 = [rdi] |
movq (%rdi), %rax | 48 8b 07 | 8b 方向相反:r/m64→r64 |
addq $8, %rdi | 48 83 c7 08 | 83 /0 ib;ModRM 11 000 111;imm8=8 |
leaq 8(%rdi,%rsi,4), %rax | 48 8d 44 b7 08 | ModRM 01 000 100(disp8+SIB);SIB 10 110 111(×4, rsi, rdi) |
movl $1, %eax | b8 01 00 00 00 | b8+rd 的 imm32;写 32 位寄存器自动清零高 32 位 |
jmp *%rax | ff e0 | ff /4;ModRM 11 100 000 |
字节序在这里有两层:指令各字段按编码顺序排列,而立即数与位移按小端存放。movl $1,%eax 的 01 00 00 00 就是小端的 1。反汇编器先按 opcode 表确定长度,再按 ModRM/SIB 决定后续字节,任何一步错位都会把后面几条指令解成乱码——这是分析被截断的代码时常见的现象。
为什么短指令重要
xorl %eax, %eax(31 c0,2 字节)比 movl $0, %eax(b8 00 00 00 00,5 字节)短,而且不产生对 %eax 旧值的依赖(寄存器重命名会切断依赖链)。编译器因此普遍用 xor 清零。类似地:
movq $1, %rax是48 c7 c0 01 00 00 00(7 字节,imm32 符号扩展);若常量超出 32 位有符号范围,必须用48 b8+ 8 字节立即数(10 字节)。leaq 1(%rdi), %rax是 4 字节,movq %rdi,%rax; addq $1,%rax是 8 字节;前者还少一次写寄存器。- 条件传送
cmov通常 4 字节,条件跳转jcc rel8是 2 字节、jcc rel32是 6 字节;分支预测失败时前者更贵,代码尺寸上后者更贵。
指令长度直接影响取指带宽与 I-cache 命中率。热循环里把一条 7 字节指令换成 3 字节,可能让循环整体跨过 32 字节取指边界,收益要用 perf stat 的 i-cache 相关计数验证,不能凭直觉。
常见 opcode 速查
| opcode | 指令族 | 方向/说明 |
|---|---|---|
88 / 89 | mov r8/r64 → r/m | 89 是 32/64 位,88 是 8 位 |
8a / 8b | mov r/m → r8/r64 | 与 88/89 方向相反 |
01 / 03 | add r → r/m / add r/m → r | 其余算术同理:29/2b sub、21/23 and、31/33 xor |
39 / 3b | cmp r/m, r / cmp r, r/m | 只设标志位 |
85 | test r/m, r | 常配合 %rax,%rax 判零 |
8d | lea | 只算地址 |
83 | 组 1 的 imm8 形式 | /0 add /1 or /4 and /5 sub /7 cmp |
c7 | mov imm32 → r/m | /0,立即数符号扩展到 64 位 |
e8 | call rel32 | 相对调用,链接器填偏移 |
c3 | ret | 从栈顶取返回地址 |
0f 05 | syscall | 系统调用,参数在 rdi/rsi/rdx/r10/r8/r9 |
0f 1f | nop 多字节形式 | 对齐填充 |
syscall 的参数约定值得单独记:它用 %r10 而不是 %rcx 传第 4 个参数,因为 syscall 指令本身会把返回地址放进 %rcx、把标志位放进 %r11。用户态函数调用(SysV ABI)与系统调用(kernel ABI)是两套寄存器约定,混用会得到莫名其妙的 ENOSYS 或错误参数。
x86 的译码器必须先判断指令边界,再译码;一个 15 字节的指令可能横跨两个 16 字节取指块。现代 Intel/AMD 用「译码缓存 + uop 缓存」绕开重复译码,但一次性冷代码仍受译码带宽限制。这也是为什么 RISC 架构(ARM64 定长 4 字节)在同样工艺下前端更简单——代价是代码密度通常更低。
1.3.11 参数与返回值的完整规则
1.3.1 给了「六个整数参数在 rdi…r9」的简化版。真实规则要处理浮点、结构体、超过六个参数、大结构体返回。这一节把 System V AMD64 ABI 的判定流程走一遍,每个结论都配编译结果。
整数、浮点与栈
| 参数类别 | 前几个放寄存器 | 超出后 |
|---|---|---|
| 整数/指针 | %rdi、%rsi、%rdx、%rcx、%r8、%r9 | 从右向左压栈,8(%rsp) 是第 7 个 |
float/double | %xmm0–%xmm7 | 压栈,每个占 8 字节 |
long double | 栈上(16 字节对齐) | 压栈;返回值才用 st0 |
| 混合参数 | 整数与浮点各自计数,互不占位 | 各自超出部分压栈 |
「各自计数」是第一个反直觉点:f(int a, double x, int b, double y, ..., int g) 的第 7 个整数参数仍然走栈,即使前面只用了两个整数寄存器。
long many(long a, long b, long c, long d, long e, long f, long g, long h)
{
return a + b + c + d + e + f + g + h;
}
many:
addq %rdi, %rsi # b += a
leaq (%rdx,%rcx), %rax # c + d
addq %rsi, %rax
addq %r8, %rax
addq %r9, %rax
addq 8(%rsp), %rax # g:第 7 个参数
addq 16(%rsp), %rax # h:第 8 个参数
ret
注意 8(%rsp) 而不是 0(%rsp):call 已经把 8 字节返回地址压在栈顶,第一个栈参数在返回地址之上。这也解释了为什么 -O0 的函数入口先 push %rbp,之后栈参数变成 16(%rbp)。
结构体按值传递
结构体不是「一律压栈」,而是按大小与字段类型分类(ABI 的 classification):
- 大小超过 16 字节 →
MEMORY,在栈上传一份副本。 - 大小 ≤ 16 字节 → 按 8 字节切成一个或两个「八字节」,每个八字节分类为
INTEGER(含指针、整数)或SSE(含 float/double)。 - 含非对齐字段、或字段跨越八字节边界导致无法归类 →
MEMORY。 - 混合分类(一个 INTEGER + 一个 SSE)按各自类别分别传。
| 结构体 | 大小 | 分类 | 参数位置 | 返回位置 |
|---|---|---|---|---|
struct P { long x, y; } | 16 | INTEGER, INTEGER | %rdi、%rsi | %rax、%rdx |
struct Q { double a, b; } | 16 | SSE, SSE | %xmm0、%xmm1 | %xmm0、%xmm1 |
struct R { long x; double y; } | 16 | INTEGER, SSE | %rdi、%xmm0 | %rax、%xmm0 |
struct S { char c[24]; } | 24 | MEMORY | 栈上副本 | 隐藏指针(%rdi) |
struct T { char c; } | 1 | INTEGER | %dil 的低 8 位 | %al |
编译结果直接印证:
sp: # long sp(struct P p)
leaq (%rdi,%rsi), %rax
ret
sq: # double sq(struct Q q)
mulsd %xmm1, %xmm0
ret
sr: # double sr(struct R r)
cvtsi2sd %rdi, %xmm1
addsd %xmm1, %xmm0
ret
ss: # long ss(struct S s)
movsbq 8(%rsp), %rax # 24 字节结构体在栈上,首字节在 8(%rsp)
ret
rp: # struct P rp(long a, long b)
movq %rsi, %rdx
movq %rdi, %rax
ret
mk: # struct S mk(void)
movq %rdi, %rax # 隐藏指针在 rdi,返回值再传回 rax
xorps %xmm0, %xmm0
movups %xmm0, (%rdi)
movq $0, 16(%rdi)
ret
mk 展示了 MEMORY 类返回的调用约定:调用者分配空间,把地址作为隐藏的第 0 个参数放进 %rdi;被调用者写入后,把同一地址放进 %rax 返回。C 代码里看不到这个指针,但反汇编里它改变了整个参数序列——大结构体返回的函数,第一个「可见」参数实际在 %rsi。
返回值与可变参数
| 返回类型 | 位置 |
|---|---|
| 整数/指针 ≤ 8 字节 | %rax |
| 16 字节整数结构体 | %rax:%rdx |
float/double | %xmm0 |
| 两个 SSE 八字节 | %xmm0:%xmm1 |
long double | %st0(x87) |
| 大于 16 字节 | 调用者提供的隐藏指针 |
可变参数函数(printf 一类)多一条规则:调用者把使用的向量寄存器个数放进 %al。编译器生成 movb $2, %al 再 call printf;被调用者的 va_start 靠它决定要不要保存 %xmm0–%xmm7。手写调用可变参数函数时忘了设 %al,浮点参数会读到垃圾值。
与 syscall、Windows 的差异
| 约定 | 整数参数 | 第 4 个 | 栈对齐 | 备注 |
|---|---|---|---|---|
| SysV AMD64(Linux 用户态) | rdi, rsi, rdx, rcx, r8, r9 | %rcx | call 前 16 字节 | 本文默认 |
| Linux syscall(x86-64) | rdi, rsi, rdx, r10, r8, r9 | %r10 | 内核负责 | syscall 破坏 rcx/r11 |
| Windows x64 | rcx, rdx, r8, r9 | %r9 | 16 字节 + 32 字节影子空间 | 调用者预留 shadow space |
| AArch64(Linux) | x0–x7 | x3 | 16 字节 | 前 8 个整数/指针 |
- 「结构体一律压栈」——≤16 字节的结构体会被拆进寄存器;只有
MEMORY类才压栈。 - 「浮点参数也占整数寄存器」——两套寄存器独立计数,
f(int, double, int)的第二个int仍在%esi。 - 「返回值总在
%rax」——浮点在%xmm0,大结构体走隐藏指针,long double在 x87 栈。 - 「第 7 个参数在
0(%rsp)」——返回地址占了栈顶 8 字节,第一个栈参数在8(%rsp)。
浮点参数、返回值与指令
x86-64 的浮点用 SSE/AVX 寄存器,不走通用寄存器:
| 用途 | 寄存器 | 说明 |
|---|---|---|
| 浮点参数 | %xmm0–%xmm7 | 与整数参数分开计数;超出走栈 |
| 浮点返回值 | %xmm0 | float/double 都是 |
变参 %al | %al | 调用变参函数时,%al 存「用了多少个向量寄存器」 |
| 标量指令 | movss/movsd、addss/addsd、mulss/mulsd | ss=单精度,sd=双精度 |
| 转换 | cvtss2sd、cvtsd2ss、cvttss2si、cvtsi2sd | 截断用 cvtt(向零) |
| 比较 | ucomiss/ucomisd | 设 ZF/PF/CF;NaN 时 PF=1,要用 jp 先判 |
/* fp.c — 浮点参数/返回值;cc -O2 -S fp.c */
double scale(double x, float k) { return x * (double)k; }
# x86-64 SysV:x=xmm0, k=xmm1(float 只用低 32 位)
scale:
cvtss2sd %xmm1, %xmm1 # float -> double
mulsd %xmm1, %xmm0 # x * k
ret # 返回 xmm0
float参数在%xmm里也占 128 位寄存器,但只写低 32 位;高位是垃圾,不要假设清零。- 比较要用
ucomisd+jp:NaN 与任何值比较都「无序」,直接je/jne会误判。 cvtsd2si默认按当前舍入模式;要截断用cvttsd2si。C 的(int)d是向零截断。- 变参函数(
printf)必须把%al设为向量寄存器个数,否则 callee 保存 XMM 时会读错;编译器自动做,手写汇编要自己设。
1.3.12 栈帧细节:对齐、红区与帧指针
call 压入 8 字节返回地址,所以函数入口处 %rsp % 16 == 8。ABI 要求「call 指令执行前 %rsp % 16 == 0」,这样入口处减 8 之后,任何 16 字节对齐的 SSE 访问(movaps)都不会崩。编译器因此在序言里凑对齐:
func:
pushq %rbp # rsp: 8 -> 0 (mod 16)
movq %rsp, %rbp
subq $32, %rsp # 32 是 16 的倍数,保持对齐
...
leave # movq %rbp,%rsp; popq %rbp
ret
如果手写汇编时 subq $8, %rsp 之后调用一个使用 movaps 的函数,就会在运行时收到 SIGSEGV,而错误现场看起来毫无道理——这是手写汇编最常见的崩溃之一。
红区(red zone)是 SysV 的另一个特性:叶子函数可以使用 %rsp 以下 128 字节而不调整栈指针,信号处理程序不会覆盖它(内核在递送信号时会跳过这 128 字节)。所以 -O2 的叶子函数常出现 movq %rax, -8(%rsp) 这样的写法。但红区只对叶子函数安全:一旦 call 别的函数,返回地址和 callee 的栈帧就会压进这块区域。
%rbp 作为帧指针在 -O1 以上默认被省略(-fomit-frame-pointer),寄存器紧张时多出一个通用寄存器;代价是调试器无法沿 %rbp 链回溯。用 perf 做调用图采样时需要 -fno-omit-frame-pointer,或者依赖 ORC/unwind 信息。-fno-omit-frame-pointer 也是内核与许多发行版默认编译选项之一。
函数 f 有三个 long 局部变量(24 字节),调用 g。序言 push %rbp(rsp 从 8 变 0),sub $32,%rsp`(仍是 0 mod 16),局部变量占 `-8`、`-16`、`-24`,剩余 8 字节是填充。调用 `g` 前 `rsp % 16 == 0`,符合要求。若把 `sub $32 改成 sub $24,rsp % 16 == 8,g 里的 movaps 就会崩。
栈帧与缓冲区溢出:从越界写到 ROP
一个典型的栈帧(x86-64,向下增长):
高地址 ┌──────────────┐ │ 调用者的栈 │ ├──────────────┤ │ 返回地址 │ ← 溢出首先改写这里 ├──────────────┤ │ 保存的 rbp │ ├──────────────┤ │ 局部缓冲区 │ ← char buf[64] └──────────────┘ ← rsp 低地址
/* vuln.c — 典型栈溢出;不要在生产代码里写 */
#include <stdio.h>
#include <string.h>
void vulnerable(const char *s)
{
char buf[64];
strcpy(buf, s); /* 没有长度检查 */
puts(buf);
}
int main(int argc, char **argv)
{
if (argc > 1) vulnerable(argv[1]);
return 0;
}
输入 80 个字节就会覆盖保存的 rbp 与返回地址。攻击者可以把返回地址改成 system("/bin/sh")、execve gadget,或一段 ROP 链(pop rdi; ret → /bin/sh → system)。
现代防御是分层的:
| 防御 | 编译/运行开关 | 作用 |
|---|---|---|
| 栈 canary | -fstack-protector-strong | 返回前检查哨兵,溢出即 __stack_chk_fail |
| NX | -z noexecstack(默认) | 栈不可执行,挡住 shellcode |
| ASLR | /proc/sys/kernel/randomize_va_space=2 | 栈/库/堆地址随机化 |
| PIE | -fPIE -pie | 主程序基址也随机化 |
| RELRO | -Wl,-z,relro,-z,now | GOT 只读,挡住 GOT 覆盖 |
| FORTIFY | -D_FORTIFY_SOURCE=3 -O2 | 编译期/运行期检查 strcpy 等 |
| CET/ shadow stack | -fcf-protection=full + CPU 支持 | 硬件校验返回地址 |
安全写法:
char buf[64];
if (snprintf(buf, sizeof buf, "%s", s) >= (int)sizeof buf) {
/* 截断;按业务决定报错还是继续 */
}
gets 已在 C11 移除;strcpy/sprintf/strcat 在未知长度输入上都不安全。审计时先搜这三个函数,再搜 %n 与 printf(user_input)。
递归:栈帧的重复与深度上限
递归函数每次调用都建立新栈帧;栈大小固定(默认 8 MiB),深度受帧大小限制:
long fact(long n) { return n <= 1 ? 1 : n * fact(n - 1); }
fact 的帧只有返回地址、保存的 n、对齐填充,深度可达几十万;但如果每帧有 1 KiB 局部数组,8 MiB 只够 8000 层。尾递归在 C 中不保证被优化成循环(GCC -O2 有时会),不要把深递归当循环用;需要深递归时显式改迭代或增大栈(setrlimit(RLIMIT_STACK)/线程 attr)。
1.3.13 完整程序一:手写 x86-64 汇编函数库
这个程序包含四个叶子函数,覆盖循环、字节访问、内存交换和六参数加法。它们不调用任何其他函数,因此不需要栈帧,也不需要保存 callee-saved 寄存器——正好用来观察「最小合规函数」长什么样。
# frame.s — 汇编:cc -c frame.s -o frame.o
.text
.globl sum_to
.type sum_to, @function
sum_to: # long sum_to(long n)
xorl %eax, %eax
testq %rdi, %rdi
jle .Lsum_done
.Lsum_loop:
addq %rdi, %rax
decq %rdi
jne .Lsum_loop
.Lsum_done:
ret
.size sum_to, .-sum_to
.globl my_strlen
.type my_strlen, @function
my_strlen: # size_t my_strlen(const char *s)
xorl %eax, %eax
.Lstr_loop:
cmpb $0, (%rdi,%rax)
je .Lstr_done
incq %rax
jmp .Lstr_loop
.Lstr_done:
ret
.size my_strlen, .-my_strlen
.globl swap_pair
.type swap_pair, @function
swap_pair: # void swap_pair(long *a, long *b)
movq (%rdi), %rax
movq (%rsi), %rdx
movq %rdx, (%rdi)
movq %rax, (%rsi)
ret
.size swap_pair, .-swap_pair
.globl add6
.type add6, @function
add6: # long add6(long a, ..., long f)
leaq (%rdi,%rsi), %rax
addq %rdx, %rax
addq %rcx, %rax
addq %r8, %rax
addq %r9, %rax
ret
.size add6, .-add6
.section .note.GNU-stack,"",@progbits
C 侧只写原型,链接时把 frame.o 一起交给编译器:
/* use_frame.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 \
* use_frame.c frame.s -o use_frame
*/
#include <stdio.h>
#include <stddef.h>
long sum_to(long n);
size_t my_strlen(const char *s);
void swap_pair(long *a, long *b);
long add6(long a, long b, long c, long d, long e, long f);
int main(void)
{
printf("sum_to(10)=%ld\n", sum_to(10));
printf("strlen(\"hello\")=%zu\n", my_strlen("hello"));
long a = 1, b = 2;
swap_pair(&a, &b);
printf("swap: a=%ld b=%ld\n", a, b);
printf("add6=%ld\n", add6(1, 2, 3, 4, 5, 6));
return 0;
}
预期输出:
sum_to(10)=55
strlen("hello")=5
swap: a=2 b=1
add6=21
关键点:
sum_to用%rdi同时做计数器和加数,循环体只有 3 条指令;jle是有符号比较,正确处理n <= 0。my_strlen用(%rdi,%rax)做「基址+变址」寻址,等价于s[i];cmpb $0只比较一个字节。swap_pair先读后写,两个指针指向同一对象时结果仍是原值(交换自身),不需要额外判断。add6全部用lea/add,没有一条mov;lea在这里做纯加法,不访问内存。
这个文件能直接用 cc -c 汇编,是因为 GNU as 与 GCC 使用同一套 AT&T 语法。clang -target x86_64-unknown-linux-gnu -c frame.s 也能汇编,便于在非 x86 机器上做语法检查(本机为 arm64,就是这样验证的)。
1.3.14 完整程序二:C 侧的 ABI 验证程序
这个程序不写汇编,只用 C 验证 1.3.11 的分类规则:打印结构体大小、用 offsetof 检查布局、把结构体按值传参并观察结果。它回答一个实际问题:我的结构体到底走寄存器还是栈?
/* abi_demo.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 abi_demo.c */
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
struct P { long x, y; }; /* 16 B: INTEGER, INTEGER */
struct Q { double a, b; }; /* 16 B: SSE, SSE */
struct R { long x; double y; }; /* 16 B: INTEGER, SSE */
struct S { char c[24]; }; /* 24 B: MEMORY */
struct T { char c; }; /* 1 B: INTEGER */
static long sp(struct P p) { return p.x + p.y; }
static double sq(struct Q q) { return q.a * q.b; }
static double sr(struct R r) { return (double)r.x + r.y; }
static long ss(struct S s) { return s.c[0]; }
static long st(struct T t) { return t.c; }
int main(void)
{
printf("sizeof P=%zu Q=%zu R=%zu S=%zu T=%zu\n",
sizeof(struct P), sizeof(struct Q), sizeof(struct R),
sizeof(struct S), sizeof(struct T));
printf("offsets R: x=%zu y=%zu\n",
offsetof(struct R, x), offsetof(struct R, y));
struct P p = {20, 22};
struct Q q = {1.5, 4.0};
struct R r = {3, 0.5};
struct S s = {{'A'}};
struct T t = {'z'};
printf("sp=%ld sq=%.1f sr=%.1f ss=%ld st=%ld\n",
sp(p), sq(q), sr(r), ss(s), st(t));
return 0;
}
预期输出:
sizeof P=16 Q=16 R=16 S=24 T=1
offsets R: x=0 y=8
sp=42 sq=6.0 sr=3.5 ss=65 st=122
把 -O2 的目标文件反汇编,可以看到调用点如何传参:
$ cc -O2 -c abi_demo.c -o abi_demo.o
$ objdump -dr -M intel abi_demo.o | sed -n '/<main>:/,+40p'
在 sp(p) 的调用点会看到 mov rdi, [rsp+...] 与 mov rsi, [rsp+...](两个 INTEGER 八字节);sq(q) 是 movsd xmm0/movsd xmm1;sr(r) 是 mov rdi + movsd xmm0;ss(s) 则是把 24 字节复制到栈上、只留地址。若把 struct S 缩小到 16 字节,调用点立刻变成两条 mov,一个字节都不用压栈——这就是分类规则的实际影响。
手写汇编、FFI(Python ctypes、Rust extern "C")、JIT、内核入口、以及阅读崩溃现场都依赖它。一个典型 bug:Rust 用 #[repr(C)] 定义的结构体与 C 侧字段顺序一致,但忘了 #[repr(C)] 时 Rust 可以重排字段,ABI 立刻不匹配。规则本身不复杂,难的是「每一层都以为自己是对的」。
1.3.15 完整程序三:ARM64 对照实现
同样三个函数用 AArch64 写一遍,差异立刻显现:参数在 x0–x7,返回地址在 x30(不是栈),条件分支用 cbz/b.ne,没有标志位寄存器可供 set。
// a64.s — 汇编:clang --target=aarch64-linux-gnu -c a64.s -o a64.o
.text
.globl a64_sum_to
a64_sum_to: // long a64_sum_to(long n): x0
mov x1, x0
mov x0, #0
cbz x1, 2f
1: add x0, x0, x1
subs x1, x1, #1
b.ne 1b
2: ret
.globl a64_strlen
a64_strlen: // size_t a64_strlen(const char *s): x0
mov x1, x0
mov x0, #0
3: ldrb w2, [x1, x0]
cbz w2, 4f
add x0, x0, #1
b 3b
4: ret
.globl a64_add6
a64_add6: // long a64_add6(long a..f): x0..x5
add x0, x0, x1
add x0, x0, x2
add x0, x0, x3
add x0, x0, x4
add x0, x0, x5
ret
.section .note.GNU-stack,"",@progbits
汇编后用 llvm-objdump -d 可以看到每条指令固定 4 字节:aa0003e1 是 mov x1,x0,8b010000 是 add x0,x0,x1,d65f03c0 是 ret。没有变长编码,也没有「指令边界」问题;代价是简单指令也占 4 字节,代码密度通常低于 x86。
x86-64 与 AArch64 对照表
| 项目 | x86-64(SysV) | AArch64(AAPCS64) |
|---|---|---|
| 通用寄存器 | 16 个(rax…r15) | 31 个(x0–x30)+ 零寄存器 xzr |
| 整数参数 | rdi, rsi, rdx, rcx, r8, r9 | x0–x7 |
| 浮点参数 | xmm0–xmm7 | v0–v7(低 64 位即 d0–d7) |
| 返回值 | rax / xmm0 | x0 / v0 |
| 返回地址 | 压栈(call) | x30(LR),bl 写入 |
| 调用/返回 | call / ret | bl / ret(br x30) |
| 栈指针 | rsp(通用寄存器) | sp(专用,必须 16 字节对齐) |
| 帧指针 | rbp | x29 |
| 条件 | RFLAGS(CF/ZF/SF/OF) | NZCV,条件后缀 eq/ne/lt/ge |
| 内存操作数 | disp(base,index,scale) | [base, #imm]、[base, xm, lsl #3] |
| 指令长度 | 1–15 字节变长 | 固定 4 字节 |
| SIMD | SSE/AVX(xmm/ymm) | NEON(v0–v31,128 位) |
| 系统调用 | syscall,编号在 rax | svc #0,编号在 x8 |
| 红区 | 有(128 字节) | 无 |
| 特殊寄存器 | — | x16/x17(PLT 暂存)、x18(平台相关:Darwin/Windows 保留,Linux 可用) |
两个最容易踩的差异:
- 返回地址不在栈上。AArch64 的
bl把返回地址写进x30,非叶子函数必须先stp x29, x30, [sp, #-16]!保存,返回前ldp恢复。忘了保存x30,第一次调用子函数就会覆盖返回地址,表现为ret跳到错误的地址。 - 没有红区。AArch64 的函数不能使用
sp以下未分配的空间;sub sp, sp, #16必须先做,哪怕只用 8 字节。
两套 ISA 的调用约定在「前 6/8 个整数参数用寄存器、返回值在固定寄存器、栈 16 字节对齐」上是同构的。学会一套后,换架构主要是换寄存器名与指令助记符,ABI 的思维方式不变。
1.3.16 实验一:用 gdb 逐条执行并观察栈帧
用 1.3.11 的 many 做一个 8 参数函数,观察第 7、8 个参数如何从栈上取:
/* many.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -g -O0 -o many many.c */
#include <stdio.h>
long many(long a, long b, long c, long d, long e, long f, long g, long h)
{
long s = a + b + c + d + e + f + g + h;
return s;
}
int main(void)
{
printf("%ld\n", many(1, 2, 3, 4, 5, 6, 7, 8));
return 0;
}
$ cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -g -O0 -o many many.c
$ gdb -q ./many
(gdb) break many
(gdb) run
(gdb) info registers rdi rsi rdx rcx r8 r9 rsp rbp
(gdb) x/3gx $rsp # [0]=返回地址, [1]=g=7, [2]=h=8
(gdb) p $rdi # 1
(gdb) p *(long*)($rsp+8) # 7
(gdb) p *(long*)($rsp+16) # 8
(gdb) bt # 调用栈:main -> many
(gdb) info frame # 保存的 rbp、返回地址、参数位置
(gdb) stepi # 单步一条机器指令
(gdb) x/6i $pc # 看接下来的 6 条指令
(gdb) disassemble /r many # 源码 + 指令 + 机器码
(gdb) finish # 跑完 many,回到 main
(gdb) p $rax # 36
要观察的关键点:
x/3gx $rsp的第一项是返回地址(指向main里call many的下一条指令),第二、三项才是g、h。若在函数序言push %rbp之后看,偏移全部加 8,变成16(%rbp)、24(%rbp)。info frame会打印rip、saved rbp、called by frame at ...,是理解栈链最快的方式。disassemble /r把机器码与汇编并排,可用来核对 1.3.10 的编码表:48 89 07应该出现在任何mov %rax,(%rdi)的位置。
再看寄存器被调用者保存规则:
(gdb) break many
(gdb) run
(gdb) set $rbx = 0xdeadbeef
(gdb) finish
(gdb) p/x $rbx # 仍是 0xdeadbeef:many 没有碰 rbx
把同样的实验做在 -O2 版本上,many 可能被内联进 main,断点根本停不下来。这不是 gdb 的问题,是优化把函数边界消掉了;调试优化代码要用 break many.c:5 或 rbreak,并接受变量「被优化掉」的提示。
1.3.17 实验二:objdump 与 readelf 看机器码、重定位
先看函数级反汇编与重定位:
$ cc -O1 -c abi.c -o abi.o
$ objdump -dr abi.o | sed -n '/<callv>:/,+8p'
0000000000000090 <callv>:
90: f2 0f 10 05 00 00 00 00 movsd 0x0(%rip),%xmm0
93: R_X86_64_PC32 .LCPI7_0-0x4
98: f2 0f 10 0d 00 00 00 00 movsd 0x0(%rip),%xmm1
9b: R_X86_64_PC32 .LCPI7_1-0x4
a0: b0 02 mov $0x2,%al
a2: e9 00 00 00 00 jmp a7 <callv+0x17>
a3: R_X86_64_PLT32 vsum-0x4
三件事在这段输出里同时出现:
movsd 0x0(%rip),%xmm0的位移是 0,因为常量地址要等链接器填;旁边的R_X86_64_PC32就是「32 位 PC 相对重定位」。mov $0x2,%al是可变参数规则:两个浮点参数占用两个向量寄存器,%al=2。jmp vsum是尾调用优化(callv直接返回vsum的结果),但重定位类型仍是R_X86_64_PLT32——通过 PLT 调用外部符号。
readelf 从文件结构角度看同一件事:
$ readelf -r abi.o
Relocation section '.rela.text' at offset 0x... contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000093 000500000002 R_X86_64_PC32 0000000000000000 .LCPI7_0 - 4
0000000000a3 000a00000004 R_X86_64_PLT32 0000000000000000 vsum - 4
$ readelf -s abi.o | grep -E 'sp|sq|callv'
12: 0000000000000000 11 FUNC GLOBAL DEFAULT 1 sp
-r 看重定位表,-s 看符号表。FUNC GLOBAL DEFAULT 表示 sp 是全局函数、默认可见性;若用 static,绑定会变成 LOCAL。链接器的工作就是把这些「符号引用 + 重定位项」解析成最终地址,详见 1.7。
最后核对指令长度与对齐:
$ size abi.o
text data bss dec hex filename
183 0 0 183 b7 abi.o
$ objdump -h abi.o | grep -E '\.text|\.rodata'
2 .text 000000a7 ...
4 .rodata.cst8 00000010 ...
.text 是代码,.rodata.cst8 是只读常量(两个 double 常量共 16 字节);具体数字随编译器版本与选项变化,看量级即可。把 -O2 换成 -Os 或 -O3,.text 大小会变,但语义不变;比较不同编译选项的 size 输出,是观察「优化换来了什么」的最便宜手段。
1.3.18 三架构 × 双语言:同一个函数的六种实现
这一节回答:同一段逻辑在 x86-64、AArch64、RISC-V 上分别长什么样?用 C 写和用 Rust 写,落到机器码后差多少?下面的代码按「架构」和「语言」分组,放在可折叠面板里:点击标题展开对应版本,代码块右上角可直接复制。所有面板是并列关系,不是「依次阅读」——你可以只展开自己关心的那一格。
先约定被实现的函数,语义与 1.3.13 的 sum_to 一致:
long sum_to(long n) /* 1 + 2 + ... + n,n <= 0 时返回 0 */
{
long s = 0;
for (long i = 1; i <= n; i++)
s += i;
return s;
}
三种汇编
x86-64(System V,AT&T 语法,GCC 13 可汇编)
.text
.globl sum_to
.type sum_to, @function
sum_to: # long sum_to(long n): rdi
xorl %eax, %eax # s = 0
movq $1, %rcx # i = 1
cmpq %rcx, %rdi
jl .Ldone # n < 1
.Lloop:
addq %rcx, %rax # s += i
incq %rcx # i++
cmpq %rcx, %rdi
jge .Lloop # n >= i
.Ldone:
ret
.size sum_to, .-sum_to
x86 的循环体是 4 条指令(add、inc、cmp、jge),比较用「减法设标志位 + 条件跳转」。inc 不改 CF,但这里不依赖 CF。
AArch64(AAPCS64,GCC/Clang 可汇编)
.text
.globl a64_sum_to
a64_sum_to: // long a64_sum_to(long n): x0
mov x1, x0 // x1 = n
mov x0, #0 // s = 0
mov x2, #1 // i = 1
cmp x1, x2
b.lt 2f // n < 1
1: add x0, x0, x2 // s += i
add x2, x2, #1 // i++
cmp x1, x2
b.ge 1b // n >= i
2: ret
循环体同样是 4 条指令,但比较与分支是两条独立指令(cmp + b.ge),条件码 NZCV 由 cmp 设置。AArch64 也可以写成 subs x3, x1, x2; b.ge,把比较与减法合并。
RISC-V 64(RV64GC,lp64d,GNU as 语法)
.text
.globl sum_to
sum_to: # long sum_to(long n): a0
mv a1, a0 # a1 = n
li a0, 0 # s = 0
li t0, 1 # i = 1
blt a1, t0, .Ldone # n < 1
.Lloop:
add a0, a0, t0 # s += i
addi t0, t0, 1 # i++
bge a1, t0, .Lloop # n >= i
.Ldone:
ret
RISC-V 没有条件码寄存器:blt/bge 直接比较两个寄存器。循环体只有 3 条指令(add、addi、bge),比 x86/AArch64 少一条,因为不需要单独的「比较」指令。x0 恒为 0,mv、li、ret、bge 都是伪指令,汇编器会展开成 addi/jalr 等真实指令。
两种语言
C17(GCC/Clang,`-O2`)
/* sum_to.c — cc -std=c17 -O2 -S sum_to.c 查看汇编 */
long sum_to(long n)
{
long s = 0;
for (long i = 1; i <= n; i++)
s += i;
return s;
}
C 版本在三种架构上都能编译,编译器生成的代码通常比手写版本更紧凑:GCC 会把循环优化成「倒计数」或「等差数列求和」的形式,具体取决于是否开启 -O2 与是否有符号溢出风险。
Rust 2021(rustc 1.75+,`-O`)
#![allow(unused)]
fn main() {
// sum_to.rs — rustc -O --emit asm sum_to.rs 查看汇编
#[no_mangle]
pub extern "C" fn sum_to(n: i64) -> i64 {
let mut s: i64 = 0;
let mut i: i64 = 1;
while i <= n {
s += i;
i += 1;
}
s
}
}
#[no_mangle] 关闭名称修饰,extern "C" 使用 C 的调用约定,生成的符号与 C 版本完全相同,可以直接被 C 链接。Rust 的 i64 加法在 debug 构建溢出会 panic,在 release 构建默认回绕;需要明确语义时用 wrapping_add/checked_add(1.2.18 展开)。
六种实现的对照
| 维度 | x86-64 | AArch64 | RV64GC |
|---|---|---|---|
| 指令长度 | 1–15 字节 | 固定 4 字节 | 固定 4 字节(C 扩展可 2 字节) |
| 通用寄存器 | 16 | 31 + xzr | 32(x0 恒 0) |
| 整数参数 | rdi, rsi, rdx, rcx, r8, r9 | x0–x7 | a0–a7 |
| 返回值 | rax | x0 | a0 |
| 返回地址 | 栈 | x30(LR) | ra |
| 条件机制 | RFLAGS(CF/ZF/SF/OF) | NZCV(可选设置) | 无标志,比较即分支 |
| 循环体指令数 | 4 | 4 | 3 |
| 典型译码 | 变长,需定界 | 定长,简单 | 定长,简单 |
| 系统调用 | syscall(rax 编号) | svc #0(x8 编号) | ecall(a7 编号) |
| C 编译器 | GCC/Clang | GCC/Clang | GCC/Clang |
「循环体 3 条 vs 4 条」的差别不是性能结论,只是编码风格:x86 的 cmp+jcc 与 RISC-V 的 bge 都是一条分支宏操作,实际执行时都会被译码成「比较 + 分支」的微操作。真正影响性能的是流水线深度、发射宽度、缓存与分支预测,而不是源码里的指令条数。
Rust 与 C 共存的工程接口
Rust 要进入一个 C 项目,边界只有三种:
| 方向 | 写法 | 注意 |
|---|---|---|
| Rust 导出给 C | #[no_mangle] pub extern "C" fn f(...) | 参数/返回值必须是 FFI-safe 类型;#[repr(C)] 结构体 |
| C 导出给 Rust | extern "C" { fn f(...); } | 调用是 unsafe;头文件与签名要手工对齐,或用 bindgen |
| 双向回调 | extern "C" fn 函数指针 | 回调里不能 panic 穿越 FFI 边界 |
一个最小可运行的混合工程:
/* main.c — cc -O2 main.c librmath.a -lpthread -ldl -lm -o demo */
#include <stdio.h>
extern long rust_sum(long n); /* 由 Rust 提供 */
long c_mul2(long x) { return 2 * x; } /* 由 C 提供,Rust 调用 */
int main(void)
{
printf("rust_sum(10)=%ld\n", rust_sum(10));
return 0;
}
#![allow(unused)]
fn main() {
// math.rs — rustc --crate-type staticlib -O math.rs -o librmath.a
#[no_mangle]
pub extern "C" fn rust_sum(n: i64) -> i64 {
(1..=n).sum()
}
extern "C" {
fn c_mul2(x: i64) -> i64;
}
#[no_mangle]
pub extern "C" fn rust_use_c(x: i64) -> i64 {
unsafe { c_mul2(x) + 1 }
}
}
链接命令里的 -lpthread -ldl -lm 是 Rust 标准库在 Linux 上的依赖;--crate-type staticlib 会把 Rust 运行时一起打包进 .a,C 侧不需要知道 Rust 的存在。rust_sum(10) 返回 55,与 C 版本一致。
不是因为「更安全」这句口号,而是因为它把一些 C 里靠约定维持的东西变成了语言规则:整数溢出的行为在 debug/release 下不同但有定义;数组越界一定 panic(除非 get_unchecked);repr(Rust) 的结构体允许编译器重排字段,跨 FFI 必须写 #[repr(C)];跨 FFI 的 panic 会 abort,而不是悄悄展开。这些规则与 1.2、1.3 讲的 ABI/表示是同一套知识,只是换了一个执行者。
- 「Rust 结构体直接传给 C」——默认
repr(Rust)不保证字段顺序,必须#[repr(C)];嵌套类型也要逐层repr(C)。 - 「
as转换和 C 的强制转换一样」——Rust 的as在整数间截断、浮点转整数饱和(NaN → 0),没有 UB;C 的越界转换是 UB。两者结果可能不同。 - 「release 下溢出会 panic」——release 默认
overflow-checks = false,回绕;要 panic 得在Cargo.toml里显式开启。 - 「Rust 函数不会崩」——
unwrap、数组越界、除零都会 panic;panic 穿越extern "C"边界会 abort 整个进程。 - 「
#[no_mangle]就能被 C 调用」——还要extern "C"指定调用约定,且参数类型必须是 FFI-safe(不能用String/Vec直接传)。
1.3.19 十二条汇编陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 调用库函数后局部变量变成垃圾 | 用了 callee-saved 寄存器(rbx/rbp/r12–r15)却没保存 | 入口 push,出口 pop,或用 caller-saved(rax/rcx/rdx/rsi/rdi/r8–r11) |
| 2 | 调用 printf 时 SIGSEGV,崩在 movaps | call 前 %rsp 不是 16 的倍数 | 调整 sub 的字节数为 16 的倍数 |
| 3 | 可变参数函数读到垃圾浮点值 | 没设置 %al(向量寄存器个数) | movb $n, %al 再调用 |
| 4 | call 之后参数寄存器里的值变了 | 参数寄存器是 caller-saved | 调用前存到栈或 callee-saved 寄存器 |
| 5 | mov (%rax), (%rbx) 汇编报错 | x86 不允许内存到内存 | 经一个寄存器中转 |
| 6 | 写 %eax 后 %rax 高 32 位变 0,误以为出错 | x86-64 规定 32 位写清零高 32 位 | 这是特性;需要保留高位就用 64 位指令 |
| 7 | 负数扩展后值不对 | 用了 movzbl(零扩展)而非 movsbl | 按源类型选 movz/movs |
| 8 | 乘法后 %rdx 被踩 | 单操作数 imulq 把 128 位积写进 rdx:rax | 用双操作数 imul,或先保存 %rdx |
| 9 | 返回大结构体的函数调用后栈错乱 | 忘了隐藏指针占 %rdi,后续参数整体后移 | 按 ABI 把隐藏指针算作第 0 个参数 |
| 10 | AArch64 函数 ret 跳飞 | 非叶子函数没保存 x30 | stp x29,x30,[sp,#-16]! / ldp 恢复 |
| 11 | AArch64 访问栈下 128 字节崩溃 | 没有红区,sp 以下未分配不可用 | 先 sub sp, sp, #16 |
| 12 | 内联汇编「偶尔」算错 | 没写 "memory" clobber 或 volatile,被优化器重排/删除 | 明确输入输出约束与 clobber,必要时 asm volatile |
补充三条与工具链相关的边界:
.note.GNU-stack:手写.s若不含这个节,链接器会警告「executable stack」,并让整个进程栈可执行(安全风险)。现代 GCC/Clang 的 C 代码自动带这个标记,手写汇编要自己加。-fomit-frame-pointer:-O1以上默认开启,栈回溯依赖 CFI/ORC 信息;perf record -g拿不到调用图时先检查这个选项。- 内联汇编的
"+r"与"=r":=表示只写,+表示读写。把读写约束写成只写,编译器可能复用同一个寄存器承载两个值,产生难以复现的错误。
- 「手写汇编一定比 C 快」——现代编译器在寄存器分配、指令选择、向量化上通常优于手写;手写汇编的价值在于访问 C 无法表达的指令(
cpuid、特殊屏障)或精确控制 ABI。 - 「
lea比add快」——在多数微架构上两者同延迟;lea的优势是不改标志位、能一次做「基址+变址×比例」,不是绝对更快。 - 「
-O0的汇编能代表程序行为」——-O0把变量放在栈上、禁止内联,与发布版本的代码差异巨大;用它验证语义可以,用它评估性能会得出完全错误的结论。
1.3.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出 6 个参数寄存器与返回值寄存器 | 不查表 | 1.3.1 |
手算 D(Rb,Ri,S) 的地址 | 能解释 lea 与 mov 的区别 | 1.3.2 |
解释 cmp/test 设置哪些标志 | 能区分有符号/无符号跳转 | 1.3.4 |
| 画出含 7 个参数的栈帧 | 标出 8(%rbp)、16(%rbp) | 1.3.6 |
| 算二维数组元素偏移 | 能写出行主序公式 | 1.3.7 |
用 objdump/gdb 读一条指令 | 能说出操作数与结果 | 1.3.8 |
写出 ARM64 的 bl/ret 与 stp/ldp | 能对照 x86-64 | 1.3.9 |
Q1 leaq 8(%rdi,%rsi,4), %rax 计算什么?如果 %rdi=1000、%rsi=3,结果是多少?
Q2 cmpq %rsi, %rdi 之后 jl 成立,C 表达式是什么?若把 jl 换成 jb,语义变成什么?
Q3 一个函数有 8 个 long 参数,第 8 个参数在栈的什么位置?%rsp 在进入函数时指向哪里?
Q4 struct { char c; int i; double d; } 在 x86-64 上的大小与成员偏移是多少?ARM64 上是否相同?
答案
A1 %rax = %rdi + 4*%rsi + 8 = 1000 + 12 + 8 = 1020。lea 只算地址,不读内存。
A2 jl 是有符号小于,C 表达式是 rdi < rsi(因为 cmp %rsi,%rdi 算 rdi-rsi)。换成 jb 后变成无符号比较 (unsigned)rdi < (unsigned)rsi,负数会被当成很大的正数。
A3 第 7、8 个参数都在栈上:进入被调用函数时,%rsp 指向返回地址,8(%rsp) 是第 7 个,16(%rsp) 是第 8 个。若函数建立帧指针,则 16(%rbp)、24(%rbp)。
A4 偏移 c=0、i=4(填充 3 字节)、d=8,总大小 16(double 对齐 8,尾部无填充)。ARM64 的 AAPCS64 规则相同,大小与偏移一致;差别在参数寄存器数量(8 个)和指令,不在数据布局。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 3 章 程序的机器级表示 3.1–3.5 | 1.3.1–1.3.4 | 完整:寄存器、操作数、算术、条件码 |
| C(CSAPP) | 第 3 章 3.6 控制 | 1.3.5 | 完整:if/switch/循环的汇编形态 |
| C(CSAPP) | 第 3 章 3.7 过程 | 1.3.6 | 完整:栈帧、参数传递、调用约定 |
| C(CSAPP) | 第 3 章 3.8–3.9 数组/结构/联合 | 1.3.7 | 完整:行主序、对齐、偏移计算 |
| C(CSAPP) | 第 3 章 3.10 控制与数据结合/缓冲区溢出 | 1.3.12.1 | 完整:栈帧、越界、ROP、canary/NX/ASLR |
| C(CSAPP) | 第 3 章 3.11 浮点代码 | 1.3.11.1 | 完整:xmm 参数/返回、cvt、ucomis、NaN |
| R(奔跑吧) | 第 1 章 处理器体系结构 | 1.3.1、1.3.9 | 背景:ARM64 寄存器与流水线,详见 1.4 |
| K(Kerrisk) | 第 2 章 基本概念 | 1.3.6 | 背景:进程栈与调用栈 |
| L(李治军) | 第 4 章 线程切换与调度 | 1.3.6 | 背景:上下文切换保存的寄存器集合 |
下一站:一条指令如何在流水线里执行?数据冒险为什么要转发或暂停?Y86-64 怎样把 ISA 变成硬件,现代 CPU 又为什么需要乱序执行?进入 1.4 处理器体系结构。