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                           	retq

x86 的指令是变长的(这里 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*)

结构体:名字变成偏移

数组下标变成了”基址 + 下标 × 大小”,结构体字段则变成”基址 + 固定偏移”。字段名在编译后完全消失:

struct A { char c; long x; char d; };
long getx(struct A *p) { return p->x; }
_getx:
	movq	8(%rdi), %rax
	retq

p->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=9

struct 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
	retq

callq _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 寄存器,下面哪些说法正确?

递归与栈帧

每次调用都在栈上开一段新帧,这一点可以直接观察:

__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
	retq

buf 位于 %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];,数组排序后运行明显变快(数据和总运算量都不变)。最主要的原因是?

记住这三件事

  1. 到了机器层,变量、类型、循环都消失了,只剩寄存器、内存地址和”比较 + 跳转”;数组下标就是寻址模式里的 基址 + 下标 × 元素大小。
  2. 函数调用靠栈帧和调用约定:返回地址在栈上(或 ARM 的链接寄存器),寄存器分成 caller-saved 和 callee-saved 两类,只在必要时保存。
  3. 栈上数据和返回地址相邻,越界写就能劫持控制流;流水线让吞吐接近每周期多条指令,但会被数据依赖和猜错的分支打断。

延伸阅读

  • 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、指令与流水线)