Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 类型汇编后缀字节典型指令
charb1movb、addb
shortw2movw、addw
intl4movl、addl
long、指针q8movq、addq
floats4movss、addss
doublel8movsd、addsd

注意 int 的后缀是 l(long word,历史遗留),double 也是 l,靠指令类别(movsd vs movl)区分。浮点参数走 %xmm0–%xmm7,不占用通用寄存器;返回值在 %xmm0。

定义 1.3.1 · System V AMD64 ABI

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 可以切换。

内存操作数的通用形式是

D(Base,Index,Scale)=M[D+Base+Index×Scale], D(\text{Base}, \text{Index}, \text{Scale}) = M[D + \text{Base} + \text{Index} \times \text{Scale}],

其中 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

Scale 只允许 1/2/4/8,正好对应 C 的 char/short/int/long(以及指针)。硬件把「基址 + 变址 × 元素大小」做成一条指令的地址生成器,编译器就不需要先算 i*8 再相加。这是 ISA 为高级语言优化的经典例子:指令集里固化了常见数据布局的假设。

1.3.3 数据传送与算术逻辑

最常用的指令族:

指令效果说明
mov src, dstdst = src不设标志位;源和目的不能同时为内存
movzXX src, dst零扩展传送movzbl 把字节零扩展到 32 位
movsXX src, dst符号扩展传送movsbq 把字节符号扩展到 64 位
lea addr, dstdst = addr只算地址
add src, dstdst += src设 CF/ZF/SF/OF
sub src, dstdst -= src设标志位
imul src, dstdst *= 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 / jzZF=1a == b
jne / jnzZF=0a != b
jsSF=1a < 0
jgZF=0 && SF=OFa > b(有符号)
jgeSF=OFa >= b
jlSF!=OFa < b
jleZF=1 || SF!=OFa <= b
jaCF=0 && ZF=0a > b(无符号)
jbCF=1a < 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 避免了分支,但源操作数必须已经算好。如果源是一个可能越界的指针解引用,编译器不能无条件计算它,只能用分支。这就是「条件传送不一定更快」的原因:它把控制依赖变成数据依赖,可能多做无用功。

关于条件码的四个误解
  1. 「cmp 会修改操作数」——不会,它只设标志位。sub 才修改目的操作数。
  2. 「test 和 cmp 一样」——test 做按位与,常用于 test %rax,%rax 判断是否为零;cmp 做减法,用于大小比较。
  3. 「jg 和 ja 可以互换」——不能。前者有符号,后者无符号;混用会让负数比较出错。
  4. 「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` 的汇编那么长

-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
        └──────────────────────┘
低地址

读这张图要抓住三点:

  1. %rbp 指向「保存的旧 %rbp」,所以 8(%rbp) 是返回地址,16(%rbp) 是调用者压入的第 7 个参数。
  2. 参数 1–6 不在栈上,在寄存器里;被调用函数若需要它们的地址(比如 &x),才在序言里把它们写到栈上。
  3. %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) 存局部变量,不必调整栈指针。信号处理函数必须避开红区,否则会破坏被中断函数的临时数据。

定义 1.3.2 · 栈帧(stack frame)

一次函数调用在栈上占用的区域,包含:传入的栈参数、返回地址、保存的 %rbp、被调用者保存的寄存器、局部变量、临时空间与对齐填充。%rbp 指向帧基址,%rsp 指向栈顶。帧的大小在编译期确定(除非用 alloca/变长数组),由序言分配、尾声释放。

1.3.7 数组、结构体与联合体的布局

C 的数组是连续存放的,元素大小固定。对 T A[N],元素 A[i] 的地址是

&A[i]=A+i×sizeof(T). &amp;amp;A[i] = A + i \times \text{sizeof}(T).

二维数组 T A[R][C] 按行主序(row-major)存放,A[i][j] 的地址是

&A[i][j]=A+(i×C+j)×sizeof(T). &amp;amp;A[i][j] = A + (i \times C + j) \times \text{sizeof}(T).

例: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 仍能工作,但可能显示 ??。

关于汇编阅读的四个误解
  1. 「objdump 默认是 Intel 语法」——默认是 AT&T(源, 目的,寄存器带 %)。看 Intel 语法要加 -M intel,此时顺序是「目的, 源」。
  2. 「call 和 jmp 一样」——call 会压返回地址,jmp 不会。ret 依赖 call 压入的地址;手工 jmp 进函数再 ret 会跳到未知位置。
  3. 「栈帧大小固定等于局部变量之和」——还要加上保存的寄存器、对齐填充、临时空间;编译器可能把多个小变量合并到一个 8 字节槽。
  4. 「%rbp 总是帧指针」——-O2 默认省略帧指针,%rbp 可能被当作普通寄存器使用。不要在生产代码里依赖 %rbp 回溯。

1.3.9 ARM64 对照:寄存器、调用与寻址

ARM64(AArch64)是 RISC 风格:指令定长 4 字节,load/store 架构(算术指令不能直接操作内存),31 个 64 位通用寄存器。与 x86-64 的对应关系:

概念x86-64ARM64
通用寄存器16 个(%rax…%r15)31 个(x0–x30)+ sp/xzr
32 位别名%eax 等w0–w30
参数 1–6/8%rdi、%rsi、%rdx、%rcx、%r8、%r9x0–x7
返回值%raxx0(第二返回值 x1)
栈指针%rspsp(16 字节对齐)
帧指针%rbpx29
返回地址压栈x30(Link Register)
调用call / retbl / ret
被调用者保存%rbx、%rbp、%r12–%r15x19–x28、x29;x30 是 link register,不是 callee-saved
条件执行cmovcsel、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 仍是主流

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 07REX.W;89 是 mov r64→r/m64;ModRM 00 000 111 = [rdi]
movq (%rdi), %rax48 8b 078b 方向相反:r/m64→r64
addq $8, %rdi48 83 c7 0883 /0 ib;ModRM 11 000 111;imm8=8
leaq 8(%rdi,%rsi,4), %rax48 8d 44 b7 08ModRM 01 000 100(disp8+SIB);SIB 10 110 111(×4, rsi, rdi)
movl $1, %eaxb8 01 00 00 00b8+rd 的 imm32;写 32 位寄存器自动清零高 32 位
jmp *%raxff e0ff /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 / 89mov r8/r64 → r/m89 是 32/64 位,88 是 8 位
8a / 8bmov r/m → r8/r64与 88/89 方向相反
01 / 03add r → r/m / add r/m → r其余算术同理:29/2b sub、21/23 and、31/33 xor
39 / 3bcmp r/m, r / cmp r, r/m只设标志位
85test r/m, r常配合 %rax,%rax 判零
8dlea只算地址
83组 1 的 imm8 形式/0 add /1 or /4 and /5 sub /7 cmp
c7mov imm32 → r/m/0,立即数符号扩展到 64 位
e8call rel32相对调用,链接器填偏移
c3ret从栈顶取返回地址
0f 05syscall系统调用,参数在 rdi/rsi/rdx/r10/r8/r9
0f 1fnop 多字节形式对齐填充

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):

  1. 大小超过 16 字节 → MEMORY,在栈上传一份副本。
  2. 大小 ≤ 16 字节 → 按 8 字节切成一个或两个「八字节」,每个八字节分类为 INTEGER(含指针、整数)或 SSE(含 float/double)。
  3. 含非对齐字段、或字段跨越八字节边界导致无法归类 → MEMORY。
  4. 混合分类(一个 INTEGER + 一个 SSE)按各自类别分别传。
结构体大小分类参数位置返回位置
struct P { long x, y; }16INTEGER, INTEGER%rdi、%rsi%rax、%rdx
struct Q { double a, b; }16SSE, SSE%xmm0、%xmm1%xmm0、%xmm1
struct R { long x; double y; }16INTEGER, SSE%rdi、%xmm0%rax、%xmm0
struct S { char c[24]; }24MEMORY栈上副本隐藏指针(%rdi)
struct T { char c; }1INTEGER%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%rcxcall 前 16 字节本文默认
Linux syscall(x86-64)rdi, rsi, rdx, r10, r8, r9%r10内核负责syscall 破坏 rcx/r11
Windows x64rcx, rdx, r8, r9%r916 字节 + 32 字节影子空间调用者预留 shadow space
AArch64(Linux)x0–x7x316 字节前 8 个整数/指针
关于参数传递的四个误解
  1. 「结构体一律压栈」——≤16 字节的结构体会被拆进寄存器;只有 MEMORY 类才压栈。
  2. 「浮点参数也占整数寄存器」——两套寄存器独立计数,f(int, double, int) 的第二个 int 仍在 %esi。
  3. 「返回值总在 %rax」——浮点在 %xmm0,大结构体走隐藏指针,long double 在 x87 栈。
  4. 「第 7 个参数在 0(%rsp)」——返回地址占了栈顶 8 字节,第一个栈参数在 8(%rsp)。

浮点参数、返回值与指令

x86-64 的浮点用 SSE/AVX 寄存器,不走通用寄存器:

用途寄存器说明
浮点参数%xmm0–%xmm7与整数参数分开计数;超出走栈
浮点返回值%xmm0float/double 都是
变参 %al%al调用变参函数时,%al 存「用了多少个向量寄存器」
标量指令movss/movsd、addss/addsd、mulss/mulsdss=单精度,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
浮点的四个坑
  1. float 参数在 %xmm 里也占 128 位寄存器,但只写低 32 位;高位是垃圾,不要假设清零。
  2. 比较要用 ucomisd + jp:NaN 与任何值比较都「无序」,直接 je/jne 会误判。
  3. cvtsd2si 默认按当前舍入模式;要截断用 cvttsd2si。C 的 (int)d 是向零截断。
  4. 变参函数(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,nowGOT 只读,挡住 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,一个字节都不用压栈——这就是分类规则的实际影响。

为什么 ABI 值得记

手写汇编、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, r9x0–x7
浮点参数xmm0–xmm7v0–v7(低 64 位即 d0–d7)
返回值rax / xmm0x0 / v0
返回地址压栈(call)x30(LR),bl 写入
调用/返回call / retbl / ret(br x30)
栈指针rsp(通用寄存器)sp(专用,必须 16 字节对齐)
帧指针rbpx29
条件RFLAGS(CF/ZF/SF/OF)NZCV,条件后缀 eq/ne/lt/ge
内存操作数disp(base,index,scale)[base, #imm]、[base, xm, lsl #3]
指令长度1–15 字节变长固定 4 字节
SIMDSSE/AVX(xmm/ymm)NEON(v0–v31,128 位)
系统调用syscall,编号在 raxsvc #0,编号在 x8
红区有(128 字节)无
特殊寄存器—x16/x17(PLT 暂存)、x18(平台相关:Darwin/Windows 保留,Linux 可用)

两个最容易踩的差异:

  1. 返回地址不在栈上。AArch64 的 bl 把返回地址写进 x30,非叶子函数必须先 stp x29, x30, [sp, #-16]! 保存,返回前 ldp 恢复。忘了保存 x30,第一次调用子函数就会覆盖返回地址,表现为 ret 跳到错误的地址。
  2. 没有红区。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

三件事在这段输出里同时出现:

  1. movsd 0x0(%rip),%xmm0 的位移是 0,因为常量地址要等链接器填;旁边的 R_X86_64_PC32 就是「32 位 PC 相对重定位」。
  2. mov $0x2,%al 是可变参数规则:两个浮点参数占用两个向量寄存器,%al=2。
  3. 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-64AArch64RV64GC
指令长度1–15 字节固定 4 字节固定 4 字节(C 扩展可 2 字节)
通用寄存器1631 + xzr32(x0 恒 0)
整数参数rdi, rsi, rdx, rcx, r8, r9x0–x7a0–a7
返回值raxx0a0
返回地址栈x30(LR)ra
条件机制RFLAGS(CF/ZF/SF/OF)NZCV(可选设置)无标志,比较即分支
循环体指令数443
典型译码变长,需定界定长,简单定长,简单
系统调用syscall(rax 编号)svc #0(x8 编号)ecall(a7 编号)
C 编译器GCC/ClangGCC/ClangGCC/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 导出给 Rustextern "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 版本一致。

为什么 Rust 版本值得在系统课里出现

不是因为「更安全」这句口号,而是因为它把一些 C 里靠约定维持的东西变成了语言规则:整数溢出的行为在 debug/release 下不同但有定义;数组越界一定 panic(除非 get_unchecked);repr(Rust) 的结构体允许编译器重排字段,跨 FFI 必须写 #[repr(C)];跨 FFI 的 panic 会 abort,而不是悄悄展开。这些规则与 1.2、1.3 讲的 ABI/表示是同一套知识,只是换了一个执行者。

C 与 Rust 混编的五个坑
  1. 「Rust 结构体直接传给 C」——默认 repr(Rust) 不保证字段顺序,必须 #[repr(C)];嵌套类型也要逐层 repr(C)。
  2. 「as 转换和 C 的强制转换一样」——Rust 的 as 在整数间截断、浮点转整数饱和(NaN → 0),没有 UB;C 的越界转换是 UB。两者结果可能不同。
  3. 「release 下溢出会 panic」——release 默认 overflow-checks = false,回绕;要 panic 得在 Cargo.toml 里显式开启。
  4. 「Rust 函数不会崩」——unwrap、数组越界、除零都会 panic;panic 穿越 extern "C" 边界会 abort 整个进程。
  5. 「#[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,崩在 movapscall 前 %rsp 不是 16 的倍数调整 sub 的字节数为 16 的倍数
3可变参数函数读到垃圾浮点值没设置 %al(向量寄存器个数)movb $n, %al 再调用
4call 之后参数寄存器里的值变了参数寄存器是 caller-saved调用前存到栈或 callee-saved 寄存器
5mov (%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 个参数
10AArch64 函数 ret 跳飞非叶子函数没保存 x30stp x29,x30,[sp,#-16]! / ldp 恢复
11AArch64 访问栈下 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":= 表示只写,+ 表示读写。把读写约束写成只写,编译器可能复用同一个寄存器承载两个值,产生难以复现的错误。
三个关于「汇编很快」的误解
  1. 「手写汇编一定比 C 快」——现代编译器在寄存器分配、指令选择、向量化上通常优于手写;手写汇编的价值在于访问 C 无法表达的指令(cpuid、特殊屏障)或精确控制 ABI。
  2. 「lea 比 add 快」——在多数微架构上两者同延迟;lea 的优势是不改标志位、能一次做「基址+变址×比例」,不是绝对更快。
  3. 「-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-641.3.9
即时自测 · 1.3

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.51.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 处理器体系结构。