s += a[i] 在 C 里是一行,到了 CPU 那里是什么?CPU 不认识变量、数组和 for 循环,它只会从内存取一条指令、执行、再取下一条。把这一层看清楚,很多现象就有了解释:为什么函数调用有开销、为什么递归太深会崩、为什么一个 strcpy 能让攻击者接管程序,以及为什么 CPU 能”同时”执行好几条指令。
前置:信息的表示、C++ 内存与所有权
汇编都由 Apple clang 21 真实生成:x86-64 用 clang -O1 -S -target x86_64-apple-macos(交叉生成,只看不跑),ARM64 是本机(Apple M3)的输出。为了易读,去掉了汇编器伪指令。
程序员眼里的机器:寄存器、内存、PC
从机器的视角,一个正在运行的程序的全部状态只有几样东西:
- PC(program counter,x86 叫
%rip):下一条要执行的指令的地址。 - 寄存器:CPU 内部少量的高速存储格子。x86-64 有 16 个 64 位通用寄存器(
%rax、%rdi、%rsp……),ARM64 有 31 个(x0–x30)。 - 条件码:最近一次算术运算的结果是否为零、是否为负、是否溢出,供条件跳转使用。
- 内存:一个巨大的字节数组,代码、全局变量、栈、堆都在里面。
C 里的变量、类型、结构体,到这一层全部消失了。long 和指针都只是 8 字节的数,数组只是一个起始地址,结构体只是起始地址加固定偏移。类型的唯一残留是指令的选择:比较有符号数和无符号数用的跳转指令不同,读 4 字节和 8 字节用的指令后缀不同。
为什么要有寄存器,而不是所有运算直接在内存上做?因为访问内存要几十到几百个周期,而读寄存器不到一个周期。这个速度鸿沟是 内存层次与缓存 的出发点。编译器的头号任务之一,就是把频繁使用的变量放进寄存器。
这套”程序员能看到的状态 + 每条指令怎么改变它”的规定叫指令集架构(ISA),x86-64 和 ARM64 是两种不同的 ISA。ISA 是软硬件之间的契约:同一份 x86-64 机器码能在十几年间各代 Intel 和 AMD 处理器上运行,尽管它们内部的电路天差地别。Apple 从 Intel 迁移到自研芯片时,必须靠 Rosetta 2 把 x86-64 机器码翻译成 ARM64,正因为换掉的是这份契约本身。
C 代码到可执行文件要经过预处理、编译(生成汇编)、汇编(生成机器码的目标文件)、链接四步,链接器怎么把多个目标文件拼起来、符号怎么解析,归 编译链接与 Python 扩展。这一篇只看”编译”这一步的产物。
一个循环的汇编
拿一个最普通的函数开始:
long sum(long *a, long n) {
long s = 0;
for (long i = 0; i < n; i++) s += a[i];
return s;
}x86-64 汇编(AT&T 语法,op 源, 目的):
_sum:
testq %rsi, %rsi
jle LBB0_1
xorl %ecx, %ecx
xorl %eax, %eax
LBB0_4:
addq (%rdi,%rcx,8), %rax
incq %rcx
cmpq %rcx, %rsi
jne LBB0_4
retq
LBB0_1:
xorl %eax, %eax
retq逐行翻译回 C:
- 参数在哪?System V 调用约定规定前 6 个整数参数依次放在
%rdi, %rsi, %rdx, %rcx, %r8, %r9,返回值放在%rax。所以a在%rdi,n在%rsi。 testq %rsi, %rsi把n和自己做按位与,只为设置条件码;jle在结果 ≤ 0 时跳走。这是循环的”先检查一次”,编译器把for改写成了”if 不满足就跳过,否则进入 do-while”的形状,这样循环体内只需要一次判断。xorl %ecx, %ecx是把i清零的惯用法:异或自己得 0,指令编码比mov $0更短。写 32 位的%ecx会自动把%rcx的高 32 位清零。addq (%rdi,%rcx,8), %rax是整个循环的核心:从地址%rdi + %rcx*8取 8 字节,加到%rax(即s)上。a[i]的地址计算a + i*sizeof(long)被硬件的寻址模式一条指令包办了。cmpq计算n - i设置条件码,jne不相等就跳回循环头。for、while、if在机器层面都只是”比较 + 条件跳转”。
这些文本最终会被汇编成字节。用 objdump -d 看目标文件:
0: 48 85 f6 testq %rsi, %rsi
3: 7e 18 jle 0x1d <_sum+0x1d>
...
10: 48 03 04 cf addq (%rdi,%rcx,8), %rax
14: 48 ff c1 incq %rcx
17: 48 39 ce cmpq %rcx, %rsi
1a: 75 f4 jne 0x10 <_sum+0x10>
1c: c3 retqx86 的指令是变长的(这里 1 到 4 字节),75 f4 里的 f4 是补码 −12,意思是”从下一条指令的地址往回跳 12 字节”,0x1c - 12 = 0x10,正好是循环头。跳转目标用相对偏移编码,所以代码被加载到任何地址都能运行。
同一个函数在 ARM64(本机)上:
_sum:
cmp x1, #1
b.lt LBB0_4
mov x8, #0
LBB0_2:
ldr x9, [x0], #8
add x8, x9, x8
subs x1, x1, #1
b.ne LBB0_2
mov x0, x8
ret两种风格的差别一目了然。ARM 是 load/store 架构:算术指令只能操作寄存器,读内存必须单独用 ldr。ldr x9, [x0], #8 读出 *a 后顺手让 x0 += 8(后变址寻址),编译器干脆把 i 优化掉了,改成指针前进、n 倒数到 0。ARM 指令定长 4 字节,解码简单;x86 允许 addq 直接带内存操作数,指令更紧凑但解码复杂。这就是 RISC 和 CISC 的经典对比。现代 x86 处理器内部也会把复杂指令拆成类 RISC 的微操作(μop),所以两者的差距在实现层面已经很小。
x86-64 指令 movq 16(%rdi,%rsi,8), %rax 对应下面哪一行 C 代码?(a 在 %rdi,i 在 %rsi,a 是 long*)
寻址模式 D(B,I,S) 表示地址 B + I*S + D,这里是 a + i*8 + 16,即 a + (i+2)*8,也就是 a[i+2] 的地址;movq 从该地址读 8 字节。clang 对 long get(long *a, long i) { return a[i + 2]; } 生成的正是这一条。
加 16 应该作用在地址上而非值上;比例因子 8 乘的是 i;返回地址需要 leaq(只算地址不访存),而 movq 会真的读内存。
结构体:名字变成偏移
数组下标变成了”基址 + 下标 × 大小”,结构体字段则变成”基址 + 固定偏移”。字段名在编译后完全消失:
struct A { char c; long x; char d; };
long getx(struct A *p) { return p->x; }_getx:
movq 8(%rdi), %rax
retqp->x 就是”从 p + 8 读 8 字节”。为什么偏移是 8 而不是紧跟在 c 后面的 1?因为对齐(alignment):x86-64 和 ARM64 的 ABI 都要求 long 放在 8 的倍数地址上。硬件按对齐的块访问内存和 cache,一个跨越两个块的 8 字节读要拆成两次访问,有的架构甚至直接报错。编译器于是在字段之间插入填充字节:
sizeof(A)=24 offsetof x=8 d=16
sizeof(B)=16 offsetof c=8 d=9struct A 里 c 后面空了 7 字节,d 后面又空了 7 字节(结构体总大小也要是最大对齐的倍数,这样数组里每个元素都对齐)。把字段按从大到小重排成 struct B { long x; char c; char d; },同样的数据只要 16 字节。几百万个对象时,这就是三分之一的内存和 cache 空间。
条件:分支还是条件传送
if 一般编译成条件跳转,但不总是。看这个:
long absdiff(long x, long y) { return x < y ? y - x : x - y; }_absdiff:
movq %rdi, %rax
subq %rsi, %rax
subq %rdi, %rsi
cmovgeq %rsi, %rax
retq这里没有跳转。编译器把两个分支的结果都算出来(%rax = x - y,%rsi = y - x),最后用条件传送 cmovge 根据比较结果挑一个。多算一次减法反而更快,原因要到 CPU 的流水线里找:跳转方向在比较完成之前是未知的,CPU 只能猜;猜错了要把已经开始执行的指令全部作废,代价是十几个周期。当两个分支都很便宜、且条件难以预测时,干脆不跳是更好的选择。这一点在最后一节会再出现。
函数调用:栈与调用约定
循环和条件都只是跳转。函数调用难在三件事:跳过去之后怎么回来?参数和返回值怎么传?被调用的函数会不会把我寄存器里的东西弄乱?
答案是一块叫栈(stack)的内存加上一套双方都遵守的调用约定(calling convention, ABI)。栈从高地址向低地址增长,%rsp(ARM 是 sp)指向栈顶。每次函数调用在栈上占一段,叫栈帧(stack frame),里面放返回地址、保存的寄存器和局部变量。
看一个调用了别的函数的例子:
long g(long);
long f(long x) { return g(x) + x; }x86-64:
_f:
pushq %rbx
movq %rdi, %rbx
callq _g
addq %rbx, %rax
popq %rbx
retqcallq _g 做两件事:把下一条指令的地址(返回地址)压栈,然后跳到 _g。g 最后执行 retq,从栈顶弹出返回地址跳回来。返回地址放在栈上而不是固定位置,所以递归和嵌套调用都自然成立。
有意思的是 %rbx。f 在调用 g 之后还要用 x,但 x 在 %rdi 里,而 %rdi 是调用者保存(caller-saved)寄存器:g 可以随便改它。所以 f 把 x 挪到 %rbx,因为调用约定规定 %rbx 是被调用者保存(callee-saved)寄存器:谁要用它,谁负责先存、用完恢复。g 若用 %rbx 必须自己保存恢复,所以 x 在调用后还在;而 f 自己也是被别人调用的,所以它开头 pushq %rbx、结尾 popq %rbx,把调用者的 %rbx 原样还回去。
这套分工的意义是:不需要每次调用都保存所有寄存器。临时值放 caller-saved 寄存器(跨调用就丢,但不用保存);需要跨调用存活的值放 callee-saved 寄存器(只在真用到时付保存成本)。
ARM64 本机版本:
_f:
stp x20, x19, [sp, #-32]!
stp x29, x30, [sp, #16]
mov x19, x0
bl _g
add x0, x0, x19
ldp x29, x30, [sp, #16]
ldp x20, x19, [sp], #32
ret思路一样,细节不同:ARM 的 bl(branch with link)不压栈,而是把返回地址放进链接寄存器 x30。只有当前函数还要调用别人时,才需要把 x30 存到栈上(叶子函数完全不碰栈,sum 就是这样)。x19 是 callee-saved,扮演 x86 里 %rbx 的角色;x29 是帧指针。参数和返回值都走 x0。
调用约定也解释了”函数调用有开销”:每次调用要保存恢复寄存器、可能要调整栈、跳转还会打断流水线。所以编译器热衷于内联(inlining),而短小的 C++ 访问器和模板代码在 -O2 下几乎没有调用开销。
关于调用约定中 caller-saved 和 callee-saved 寄存器,下面哪些说法正确?
callee-saved 的含义是"被调用者若要用,必须先存后还",所以 f 用 %rbx 保存 x 时要 push/pop;叶子函数只用临时寄存器,调用者早已默认它们会被改写,什么都不用存。
硬件的 call 只压返回地址(ARM 的 bl 甚至连栈都不碰);callee-saved 寄存器可以修改,只要返回前恢复原值。
递归与栈帧
每次调用都在栈上开一段新帧,这一点可以直接观察:
__attribute__((noinline)) long depth(long n) {
long local = n; // 每层栈帧里都有一个自己的 local
if (n == 0) return 0;
if (n % 2 == 0 || n == 3) printf("n=%ld &local=%p\n", n, (void*)&local);
return depth(n - 1) + local;
}
// main 里调用 depth(4)n=4 &local=0x16d4f27a8
n=3 &local=0x16d4f2768
n=2 &local=0x16d4f2728
sum=10每深一层,local 的地址就减少 0x40 = 64 字节,这就是这个函数(-O0 下)一个栈帧的大小。递归之所以能正确工作,是因为每层的 n 和 local 都住在各自的帧里,互不覆盖,这正是 函数与环境模型 里”每次调用创建新的帧”在硬件上的实现。
栈的大小是有限的(macOS 主线程默认 8 MB)。递归一百万层、每层 64 字节就要 64 MB,越过栈底的保护页后触发段错误,这就是 stack overflow。Python 有 sys.setrecursionlimit 的默认上限 1000,部分原因就是解释器自身的 C 栈会随着 Python 递归一起增长。
还有一个推论:函数返回后,它的栈帧就被”回收”了,下一次调用会覆盖同一块地址。返回局部变量的指针之所以是悬垂指针,就是因为它指向的帧已经不属于任何人。
缓冲区溢出:栈上的数据与控制混在一起
栈帧的布局有一个危险的特点:局部数组和返回地址挨在一起,而且数组从低地址往高地址写,返回地址恰好在数组的”上方”。看看编译器给这个函数分配的栈:
void greet(const char *name) {
char buf[8];
strcpy(buf, name); // 不检查长度:越界写会覆盖栈上 buf 之后的内容
printf("hi %s\n", buf);
}关掉栈保护后的 x86-64 汇编(节选):
_greet:
pushq %rbx
subq $16, %rsp
movq %rdi, %rsi
leaq 8(%rsp), %rbx
movq %rbx, %rdi
callq _strcpy
...
addq $16, %rsp
popq %rbx
retqbuf 位于 %rsp + 8,往上 8 字节是保存的 %rbx,再往上就是 call 压进来的返回地址。strcpy 一直拷到 '\0' 为止,不管 buf 有多大。传一个 32 字节的名字,它就会依次覆盖保存的寄存器和返回地址。retq 时 CPU 从栈上弹出的已经是攻击者写进去的值,程序跳到哪里由输入决定。早期的经典攻击就是把一段机器码(shellcode)放进 buf,再把返回地址改成 buf 的地址。1988 年的 Morris 蠕虫就利用了 fingerd 里的 gets。
本机实际运行(ARM64 的布局细节不同,但原理一样):
$ ./bo Bob # 默认编译
hi Bob
$ ./bo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA # 默认编译:栈保护生效
退出码 134(SIGABRT)
$ ./bo2 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA # -fno-stack-protector
退出码 138(SIGBUS)两个崩溃的意义不同。默认编译时,clang 在 buf 和返回地址之间放了一个随机值,叫金丝雀(stack canary),函数返回前检查它是否被改过,被改了就调用 __stack_chk_fail 主动 abort。关掉保护后,栈上保存的返回地址被一串 0x41('A')覆盖,函数返回时 CPU 跳到这个非法地址,触发总线错误。如果攻击者精心构造输入,这里就不是崩溃,而是执行任意代码。
现代系统叠加了好几层防御,每一层针对攻击的一个前提:
| 防御 | 破坏的攻击前提 |
|---|---|
| 栈金丝雀 | 能悄悄覆盖返回地址而不被发现 |
| 不可执行栈(NX / W^X) | 栈上写入的 shellcode 能被执行 |
| 地址空间随机化(ASLR) | 攻击者知道要跳转的目标地址 |
| 指针认证(ARM PAC) | 返回地址能被伪造(Apple Silicon 系统库默认启用) |
NX 堵住之后,攻击者转向 ROP(return-oriented programming):不注入代码,而是把已有代码里以 ret 结尾的小片段串起来。所以根本解法仍是不写越界代码:用 strncpy/snprintf、带边界检查的容器,或者干脆换成内存安全的语言。至于栈、堆、代码段在地址空间里怎么排布、为什么会有不可执行页,归 虚拟内存。
栈金丝雀(stack canary)能防住下面哪种情况?
金丝雀放在局部变量和返回地址之间,连续越界写要碰到返回地址必须先踩过它,返回前检查就能发现。
堆上的函数指针不在栈帧里,不受影响;越界读不修改金丝雀,检查无从发现,反而可能把金丝雀本身泄露出去;能跳过金丝雀精确写入的漏洞(如任意地址写)也防不住,所以它只是多层防御中的一层。
CPU 内部:从取指到流水线
前面一直把 CPU 当成”一条一条执行指令”的黑盒。打开它,最基本的结构是:
- ALU(算术逻辑单元):一块组合逻辑电路,输入两个数和一个操作码,输出结果和条件码。加法器由一串全加器级联而成,全加器又由与、或、异或门组成。Crash Course 前十集正是从晶体管一路搭到 ALU。
- 寄存器文件:一组可读写的存储单元,每个周期能读出两个、写入一个。
- 控制单元:解码指令,决定这个周期 ALU 做什么运算、从哪读、往哪写。
- 时钟:所有状态在时钟边沿统一更新。3 GHz 意味着每个周期约 0.33 ns。
最简单的实现是单周期:一个时钟周期内完成一条指令的全部工作。CSAPP 第 4 章把一条指令的执行拆成固定的阶段:取指(fetch)、译码(decode)、执行(execute)、访存(memory)、写回(write back)、更新 PC。单周期设计的问题是时钟周期必须长到能走完所有阶段,而大部分时间里大部分电路都闲着。
自然的修正是流水线(pipelining),像洗衣房:第一批衣服进烘干机时,第二批就可以进洗衣机。把指令执行切成 5 级,每级之间加寄存器隔开,每个周期每一级处理一条不同的指令。单条指令的延迟没有变短(甚至因为级间寄存器略长了一点),但吞吐量提高到接近每周期一条。
流水线在两种情况下会坏掉,统称冒险(hazard):
- 数据冒险:后一条指令需要前一条的结果,而前一条还没写回。比如
sum循环里的addq ... %rax每次都依赖上一次的%rax。解法是转发(forwarding):结果一出 ALU 就直接接到下一条的输入,不必等写回;实在来不及(比如要等内存读出的值)就插入气泡暂停一个周期。 - 控制冒险:遇到条件跳转时,下一条该取哪里要等比较完成才知道。CPU 的做法是分支预测:先猜一个方向继续取指,猜错了就作废已取的指令。现代预测器对循环这种规律分支的准确率超过 95%,但对随机数据上的
if可能只有 50%,每次猜错丢十几个周期。上一节的cmov就是编译器绕开这个代价的办法。
现代 CPU 在流水线之上又加了超标量(每周期发射多条指令)和乱序执行(指令不按程序顺序,只要操作数就绪就执行,最后按顺序提交)。Apple M 系列的性能核每周期能解码约 8 条指令。这意味着性能的瓶颈往往不是”指令有多少条”,而是依赖链有多长:sum 里每次加法都要等上一次,就算 CPU 有多个加法器也用不上;把累加拆成 4 个独立的部分和,就能让它们并行。在本机用 -O2 对一个 4096 元素的 double 数组反复求和 10 万遍:
for (...) s += a[i]; // 一条依赖链
for (...) { s0 += a[i]; s1 += a[i+1]; s2 += a[i+2]; s3 += a[i+3]; } // 四条独立链1 accumulator: 0.54 s (s=419328000000)
4 accumulators: 0.14 s (s=419328000000)指令数几乎一样,时间差了将近 4 倍。编译器不会自动替你做这件事,因为浮点加法不满足结合律(见 信息的表示),重排会改变结果;这里两种算法结果恰好相同,是因为所有部分和都是能精确表示的数。这也是 SIMD 向量指令和 GPU 的出发点,后者见 GPU 与 Kernel。至于取数据本身的等待,往往比所有这些计算加起来都长,那是下一篇的主题。
对一个长数组做 if (a[i] > 128) s += a[i];,数组排序后运行明显变快(数据和总运算量都不变)。最主要的原因是?
未排序时条件真假随机,预测器大约一半猜错,每次猜错都要作废已进入流水线的指令;排序后只在分界处猜错一次。
两种情况都是顺序扫描同一个数组,访存模式相同,cache 行为没有区别;加法的速度和数值大小无关;编译器在编译时看不到运行时数据是否有序,生成的代码完全一样。
记住这三件事
- 到了机器层,变量、类型、循环都消失了,只剩寄存器、内存地址和”比较 + 跳转”;数组下标就是寻址模式里的
基址 + 下标 × 元素大小。 - 函数调用靠栈帧和调用约定:返回地址在栈上(或 ARM 的链接寄存器),寄存器分成 caller-saved 和 callee-saved 两类,只在必要时保存。
- 栈上数据和返回地址相邻,越界写就能劫持控制流;流水线让吞吐接近每周期多条指令,但会被数据依赖和猜错的分支打断。
延伸阅读
- Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective, 第 3 章 Machine-Level Representation of Programs、第 4 章 Processor Architecture
- CMU 15-213 讲义与 Bomb Lab / Attack Lab:https://www.cs.cmu.edu/~213/
- Compiler Explorer(在线看任意编译器的汇编输出):https://godbolt.org/
- System V AMD64 ABI:https://gitlab.com/x86-psABIs/x86-64-ABI
- Arm, Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64)
- Crash Course Computer Science #5–#9(ALU、寄存器、CPU、指令与流水线)