# 第 2 章 程序如何真正运行


> 从 `.c` 到内存中奔跑的指令流，中间每一步都有产物、都有坑，也都是面试追问的落点。

## 学习目标

读完本章，你应当能够：

1. 复述「预处理 → 编译 → 汇编 → 链接 → 加载」五阶段的输入、输出与典型报错，并说明链接错误与编译错误的本质区别。
2. 区分 ELF 的段（segment）与节（section），用 `readelf` 的三个视图读出同一文件的三种信息。
3. 推导 PLT/GOT 在首次调用与后续调用中的指令流差异，解释延迟绑定为什么能省启动时间。
4. 用 `LD_PRELOAD` 在不改源码的前提下替换一个 libc 函数，并说明它被忽略的所有条件。
5. 画出 x86-64 虚拟地址空间布局图（含增长方向），推导任意结构体的 `sizeof`，并解释 ASLR 能防住什么、防不住什么。
6. 分解一次函数调用的指令开销，判断内联在什么情况下反而更慢。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 寄存器、cache line、主存延迟量级 | 见第 1 章 1.1 节 |
| 分页、页表、缺页中断的完整机制 | 见第 8 章 8.2 节（本章只用到「加载后地址空间被分成 4 KB 页」这一结论） |
| 用户态/内核态与 `execve`；进程与 COW | 见第 9 章 9.3 节、第 4 章 4.2 节 |

## 2.1 从源码到进程：五阶段链路

面试里最常被问的一句是「`gcc main.c` 到底做了什么」。标准答案需要能说清**五个阶段的产物形态**，因为面试官接下来一定会挑一个阶段追问细节。

| 阶段 | 工具（gcc 内部） | 输入 | 输出 | 该阶段典型错误 |
|---|---|---|---|---|
| 预处理 | `cpp` | `.c` + `#include` 的头 | 纯 C 的 `.i` | 头文件找不到；宏展开成非预期文本后才报语法错 |
| 编译 | `cc1` | `.i` | 汇编 `.s` | 语法/类型错误、未声明标识符 |
| 汇编 | `as` | `.s` | 可重定位目标文件 `.o`（ELF relocatable） | 汇编语法错、指令不支持 |
| 链接 | `ld`（gcc 驱动 `collect2`） | 多个 `.o` + `.a`/`.so` | 可执行文件（ELF executable）或 `.so` | `undefined reference`、`multiple definition` |
| 加载 | 内核 + `ld.so` | 可执行文件 | 进程的虚拟地址空间 | `No such file`、`cannot open shared object` |

链接与编译错误的**本质区别**：编译错误是「这个文件内部自相矛盾」，链接错误是「文件之间对不上账」。所以编译错误的修复范围通常在本文件，链接错误必须跨文件核对符号的定义与可见性。别忘了：**链接器不检查逻辑，它只检查符号表能不能配平**。

```c
/* 证明预处理只是文本替换：出错发生在 .i 阶段，而不是 .s 阶段 */
#define SQUARE(x) ((x) * (x))
int main(void) {
    /* 宏没有类型也没有求值顺序，SQUARE(i++) 展开成 ((i++) * (i++))，i 被加两次 */
    /* 这正是 C++ 引入 inline 函数的核心动机之一：保留类型检查与单次求值 */
    int i = 3;
    return SQUARE(i);
}
```

`gcc -E`、`-S`、`-c` 可以分别停在三个阶段；`readelf -h/-l/-S/-d` 则分别看 ELF 头、段视图、节视图与动态段，这组开关是观察整条链路最省事的入口。

```text
   源码到运行：每一步的产物在哪一层

  main.c ──[cpp]──▶ main.i ──[cc1]──▶ main.s ──[as]──▶ main.o
                                                  │
   libc.so / crt1.o / crti.o ─────────────────────┤
                                                  ▼
                                          [ld 链接]──▶ a.out  (ELF executable)
                                                          │
                            内核 execve 读 ELF 头 ─────────┤
                                                          ▼
                            ld.so 装载依赖 ──▶ 重定位 ──▶ _start ──▶ __libc_start_main ──▶ main
```

`.o` 是**可重定位目标文件**：地址栏全是待填占位符，所以链接期只做两件事——按名字把符号配平、把占位符换成最终地址。

## 2.2 ELF 文件结构：段与节的双视图

同一个 ELF 文件，**链接器按「节（section）」看，加载器按「段（segment）」看**。两者的分工是：

- **节（section）**：给链接器用的逻辑分区，粒度细，带名字（`.text`、`.data`、`.debug_info`），用于重定位、符号解析、调试。节头表（section header table）在运行时**根本不需要**。
- **段（segment）**：给内核/加载器用的物理分区，粒度粗，由程序头表（program header table）描述，属性只有三种：`R`、`W`、`X`。内核按段一次性 `mmap` 出对应保护位。
- **段是节的「打包结果」**：多个权限相同的节被合并成一个 `PT_LOAD` 段。

| 常用节 | 内容 | 是否占文件空间 | 权限 | 归入的典型段 |
|---|---|---|---|---|
| `.text` | 机器指令 | 是 | R+X | 代码段 |
| `.rodata` | 字符串字面量、`const` 全局量 | 是 | R | 代码段（同页只读） |
| `.data` | 已初始化的非零全局/静态变量 | 是 | R+W | 数据段 |
| `.bss` | 未初始化或初始化为零的全局/静态变量 | **否**（只记大小） | R+W | 数据段（内核给匿名零页） |
| `.dynsym` / `.dynstr` | 动态符号表与名字串，供 `ld.so` 用 | 是 | R | 动态段 |
| `.rela.plt` / `.rela.dyn` / `.plt` / `.got` | 重定位表、跳转桩、全局偏移表 | 是 | R（`.got` 运行时需可写） | 代码段/数据段 |

`.bss` 不占文件空间是**必须记住的算例级结论**：一个 100 MB 的零初始化全局数组让可执行文件几乎不增长，但进程一启动 RSS 就会涨——内核对 `.bss` 做的是「映射一段匿名零页」，写哪一页才分配哪一页的物理内存。

```bash
readelf -h ./a.out        # ELF 头：类型(EXEC/DYN)、入口地址、程序头偏移
readelf -l ./a.out        # 段视图：哪些节被合并、虚拟地址与权限位
readelf -S ./a.out        # 节视图：每个节的地址、偏移、大小、对齐
readelf -s ./a.out | grep main   # 符号定义与绑定类型（T 代码/D 数据/B bss/U 未定义）
readelf -d ./a.out        # 动态段：NEEDED 依赖库、RPATH/RUNPATH、SONAME
```

## 2.3 静态链接与动态链接的取舍

两种链接方式回答的问题不同：静态链接在**链接期**就把符号地址钉死；动态链接只把「需要哪个库、哪个符号」记进 `.dynamic` 段，地址留到**加载期甚至首次调用时**才填。注意表中「多进程内存占用」一行：**共享的只是只读代码段与 `.rodata` 的物理页**，`.data`/`.bss` 每个进程仍各一份（写时复制，见第 4 章 4.2 节），所以动态链接省的是物理内存而不是虚拟内存。

| 维度 | 静态链接（`.a`） | 动态链接（`.so`） |
|---|---|---|
| 符号地址确定时机 | 链接期（绝对或相对地址写死） | 加载期 + 首次调用（重定位/延迟绑定） |
| 可执行文件体积 | 大（每个用到的函数都复制进来） | 小（只留桩与符号名） |
| 多进程内存占用 | N 个进程 = N 份代码页 | 只读代码段**物理页共享**，N 个进程 1 份 |
| 库升级与部署 | 单文件无依赖地狱，但升级必须重新链接 | 需 `LD_LIBRARY_PATH`/`ldconfig`/镜像配合，但 ABI 兼容时换库即生效（libc 漏洞修复不必重编译） |
| 启动速度 | 快（无解析、无重定位） | 慢（`ld.so` 解析依赖 + 重定位），可被延迟绑定 + `prelink`/`-z now` 抵消 |
| 运行时调用开销 | 0（直接 `call`） | 首次调用贵；后续多一次 `jmp [GOT]` 间接跳转 |

### 2.3.1 PLT/GOT 与延迟绑定

位置无关代码（position-independent code, PIC）的核心矛盾是：**代码要能映射到任意基址就不能含绝对地址，但访问外部函数/全局变量又必须知道地址**。解法是「代码不存地址，而是去一张表中查地址」——那张表就是全局偏移表（global offset table, GOT），PLT 则是「查表 + 跳转」的跳转桩。

```text
  首次调用 printf 的完整指令流（延迟绑定，lazy binding）

  调用方 main                     .plt（只读，可共享）            .got.plt（可写，每进程一份）
  ─────────────────────────       ──────────────────────         ──────────────────────────
  call printf@plt ───────────▶  printf@plt:                     [0] .dynamic 地址
                                    jmp  *GOT[printf] ──┐        [1] link_map 指针
                                                        │        [2] _dl_runtime_resolve
                                                        │        [3] 0x...4006  ◀─ 回填后
                                                        ▼        [4] loader 桩地址（初始值）
                                 GOT[printf] 初始指向 plt+6（不是 printf！）
                                                        │
                                    push $index; jmp .plt[0] ──▶ _dl_runtime_resolve
                                                                   │ 查 .dynsym/.dynstr
                                                                   ▼
                                                            回填 GOT[printf] = 真实地址
                                                            然后跳到真实 printf

  第二次调用 printf：
  call printf@plt ──▶ jmp *GOT[printf] ──▶ 直接进 libc（只多一次间接跳转）

  与之相对：-z now / -Wl,-z,now（full RELRO）在加载时把所有 GOT 一次填好，
  jmp *GOT 第一次就命中真实地址，代价是启动多一次全量符号解析。
```

延迟绑定为什么值得：libc 导出上千个符号，一个程序平均只用几十个。若加载期全部解析，每次启动都要做上千次符号查找与重定位；延迟绑定把绝大部分成本推给了「永远不执行的代码路径」。

> [!WARNING]
> 典型错误回答：「PLT 是程序的跳转表，GOT 存的是函数地址」。这只说对一半。必须点出**初始状态下 `GOT` 存的是 PLT 内部地址而不是真实函数地址**，以及 `_dl_runtime_resolve` 的**回填（写 `GOT`）**动作——这也正是 `GOT` 必须可写、从而成为攻击面（GOT 覆写劫持）的原因。

## 2.4 动态链接器 ld.so 与 LD_PRELOAD

动态链接器（`PT_INTERP` 段指定的 `/lib64/ld-linux-x86-64.so.2`）在内核映完可执行文件之后、`main` 之前运行：读 `.dynamic` 收集 `NEEDED` 依赖并递归装载、按重定位表填 `GOT`、调用各库的 `init_array`，最后把控制权交给 `_start`。注意入口点是 `_start` 而非 `main`——`_start` 备好 `argc/argv/envp` 后调用 `__libc_start_main`，由后者再调 `main`，并在 `main` 返回后走 `exit`。

`LD_PRELOAD` 的机制非常朴素：它把指定的共享库插到**符号搜索顺序的最前面**，于是同名符号被「劫持」——因为动态链接器在解析符号时，谁先出现用谁。

| 搜索顺序（从先到后） | 来源 | 可控性 |
|---|---|---|
| 1 | `LD_PRELOAD` 指定的库 | 环境变量，仅**非 setuid** 程序生效 |
| 2 | 可执行文件自身的 `DT_RPATH`（旧机制，默认先于 `LD_LIBRARY_PATH`） | 链接时 `-rpath` 写进 ELF |
| 3 | `LD_LIBRARY_PATH` | 环境变量，仅非 setuid 生效 |
| 4 | `DT_RUNPATH`（新机制，默认**后于** `LD_LIBRARY_PATH`） | 链接时 `-Wl,-rpath` |
| 5 | `/etc/ld.so.cache`（由 `ldconfig` 生成） | 系统库主路径 |
| 6 | `/lib`、`/usr/lib`（及架构目录） | 兜底 |

```c
/* 拦截 libc 的 malloc：不改一行业务代码就把分配记进日志，是内存排查的常用手段 */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stddef.h>

/* 用 RTLD_NEXT 取"下一个"同名符号，否则 dlsym("malloc") 会查到我们自己，造成无限递归 */
static void *(*real_malloc)(size_t) = NULL;

void *malloc(size_t size) {
    if (real_malloc == NULL) {
        real_malloc = dlsym(RTLD_NEXT, "malloc");  /* 首次调用才解析，避免 ld.so 重入死锁 */
    }
    /* 这里只允许用 write 这类不分配内存的接口：printf 自身会 malloc 而重入本函数 */
    return real_malloc(size);
}
```

编译与使用：`gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl`，然后 `LD_PRELOAD=./mymalloc.so ./app`。

编译期还有一批「同类不同层」的手段，面试常用来做区分题：

| 手段 | 生效阶段 | 能否拦截直接调用 | 典型用途 |
|---|---|---|---|
| `LD_PRELOAD` | 加载/运行时 | 是（通过 PLT 的调用） | 函数打桩、故障注入、内存检测 |
| `--wrap`（`-Wl,--wrap=malloc`） | 链接期 | 是，且改写符号引用 | 单元测试替换依赖 |
| 宏 / `#define` | 预处理期 | 只对本文件 | 调试开关 |
| `ptrace` / eBPF uprobe | 运行时插桩 | 可拦不可静态解析的调用 | 无侵入观测 |

> [!TIP]
> 记忆锚点：`LD_PRELOAD` 能生效的前提是**调用走 PLT**。若函数被内联、或用 `-fno-plt`/`-static` 链接、或是程序内部的 `static` 函数、或直接发 `syscall()` 绕过 libc 包装，它都拦不住。

## 2.5 加载后的虚拟地址空间布局与 ASLR

```text
  x86-64 Linux 进程虚拟地址空间（高地址在上；箭头为增长方向）

  0xFFFFFFFFFFFFFFFF  ┌───────────────────────────────┐
                      │ 内核空间（用户不可访问）        │
  0x00007FFFFFFFFFFF  ├───────────────────────────────┤
                      │ 栈 stack                      │  ← 向下增长
                      │   [环境变量/argv][局部变量]    │
                      │   ↓ rsp 每压栈一次递减 8 字节  │
                      ├───────────────────────────────┤  ◀── 栈与 mmap 之间是巨大的空闲"护栏"
                      │   （大段未映射空洞）           │      任何越界访问都直接 SIGSEGV
                      ├───────────────────────────────┤
                      │ mmap 区（共享库、大块 malloc、  │  ← 向下增长（自高地址向低地址分配）
                      │ 线程栈、mmap 文件映射）         │
                      ├───────────────────────────────┤
                      │   （空洞）                     │
                      ├───────────────────────────────┤
                      │ 堆 heap                       │  ← 向上增长（brk 抬高 program break）
                      │   ↑ 小块 malloc（< 128 KB）     │
                      ├───────────────────────────────┤
                      │ .bss   未初始化全局量          │  读写
                      │ .data  已初始化全局量          │  读写
                      │ .rodata 常量与字面量           │  只读
                      │ .text  机器码                  │  只读+可执行
  0x0000000000400000  └───────────────────────────────┘  （非 PIE 的传统固定基址）
                      │ 低地址：NULL 页（mmap_min_addr 保留，用于拦空指针解引用） │
                    0 └───────────────────────────────┘
```

几个必须能算的细节：**栈默认 8 MB**（`ulimit -s` 默认 8192 KB），溢出护栏外即 `SIGSEGV`；**`malloc` 分界线约 128 KB**（glibc `M_MMAP_THRESHOLD`），小于它走 `brk`/堆管理、大于它直接 `mmap`，所以大块释放能立刻归还内核而小块释放通常留在堆里。**三者方向相反**是为了每段只需一个单向指针（`rsp`、`mmap_base`、`program break`）就能管理，并天然把冲突概率降到最低。

地址空间布局随机化（address space layout randomization, ASLR）随机化的对象不止栈：

| 随机化对象 | 随机粒度 | 关闭后现象 |
|---|---|---|
| 栈基址 | 页（4 KB）对齐，随机位数约 22 位 | 每次运行 `&local` 完全相同 |
| mmap 基址（含共享库） | 页对齐，约 28 位随机 | `libc` 基址固定 |
| 可执行文件自身 | 仅 PIE（`-pie -fPIE`）生效，否则固定在 `0x400000` | 代码地址稳定可预测 |

随机化位数决定绕过成本：`2^28 ≈ 2.7 × 10⁸` 的猜测空间配合「猜错就崩溃」的约束，使盲打不现实；但一旦通过信息泄露拿到一个模块基址，同模块内所有偏移都是固定的——这就是「ASLR 只能提高门槛、不能修复漏洞」的准确表述。两个高频答崩点：把 ASLR 说成「随机化了地址空间的内容」（它只随机化各段基址），以及把它与「栈不可执行（NX）」混为一谈（前者防「跳到已知地址」，后者防「跳到栈上自己写的代码」，现代利用需同时绕过两者，ROP 就是典型组合技）。

## 2.6 栈帧与调用约定

x86-64 System V ABI（Linux、macOS、BSD 使用；Windows x64 是另一套）规定参数传递与寄存器保存责任：

| 用途 | 寄存器 | 保存责任 |
|---|---|---|
| 第 1~6 个整型/指针参数 | `rdi`、`rsi`、`rdx`、`rcx`、`r8`、`r9` | 调用者保存（caller-saved） |
| 第 7 个及以后参数 | 通过栈传递，从右向左压栈 | 调用者 |
| 浮点参数 | `xmm0`~`xmm7` | 调用者保存 |
| 返回值 | `rax`（整型）、`rdx:rax`（128 位）、`xmm0`（浮点） | — |
| 栈指针 / 帧指针 | `rsp` / `rbp` | `rsp` 由 ABI 强制维护 |
| 被调用者必须原样恢复 | `rbp`、`rbx`、`r12`~`r15` | 被调用者保存（callee-saved） |

> [!WARNING]
> 反直觉处：`rbx`/`r12`~`r15` 想用就必须先 `push` 再 `pop`，而「保存责任」决定了哪些寄存器可以放心在函数内改，这也是手写汇编最容易出错的地方。

```text
  单个栈帧的内存布局（地址从高到低排列，x86-64，红区 red zone 为 128 字节）

  高地址
        ┌──────────────────────────────┐
        │ 调用者的栈帧 ...              │
        ├──────────────────────────────┤
        │ 第 7 个及以后的参数           │  调用者负责压入
        ├──────────────────────────────┤
   rbp+8│ 返回地址 return address       │  ← call 指令自动压入，= 下一条指令地址
   rbp+0│ 调用者的 rbp（saved rbp）     │  ← push rbp 压入
        ├──────────────────────────────┤  ◀── rbp 指向这里，帧内一切用 rbp-偏移 寻址
   rbp-8│ 局部变量 / 被调用者保存寄存器 │
   rbp-16│ ...                          │
        │                              │  ← sub rsp, N 一次性预留
        ├──────────────────────────────┤  ◀── rsp
        │ 红区（128 字节，叶子函数可直接 │  信号处理器的栈帧会覆盖红区，
        │ 使用而无需调整 rsp）           │  这是 volatile 变量必须落栈的原因之一
  低地址 └──────────────────────────────┘

  函数序言：push rbp; mov rbp, rsp; sub rsp, N
  函数尾声：leave（= mov rsp, rbp; pop rbp）; ret
```

**缓冲区溢出的经典利用路径**就写在这张图里：局部数组在 `rbp-16` 这类低地址处，向**高地址方向**（越界写）连续覆盖时，先越过其他局部变量，再覆盖 `saved rbp`，最后命中 `rbp+8` 的**返回地址**。于是 `ret` 弹出的是攻击者写入的地址，控制流被劫持。

三道防线分别打断这条链的不同环节：

| 防线 | 打断哪一步 | 被绕过的条件 |
|---|---|---|
| 栈保护 `-fstack-protector-strong`（`canary`） | 在返回地址前放哨兵值，覆盖它即在 `ret` 前 `__stack_chk_fail` 终止 | 泄露 canary，或利用不经过该函数的写原语 |
| NX / `-z noexecstack` | `ret` 跳过去后栈不可执行，注入的 shellcode 变数据 | 转向已有的可执行片段（ROP/JOP） |
| ASLR / PIE | 使「跳到哪」不可预测 | 信息泄露 + 相对偏移固定 |
| 影子栈（`-fsanitize=shadow-call-stack`、Intel CET） | 返回地址另存一份，`ret` 时校验 | 目前最难绕过的一类 |

## 2.7 大小端：判定、网络字节序与踩坑

端序（endianness）只影响**多字节标量的字节排列**，单字节与字节数组不受影响。判定方法只需一次类型双关：

```c
/* 用 union 而非指针强转：union 的成员共享同一块存储，是标准允许的类型双关（type punning） */
#include <stdio.h>
int main(void) {
    union { unsigned int i; unsigned char c[4]; } u;
    u.i = 0x01020304u;   /* 0x01 是最高有效字节，看它落在低地址还是高地址 */
    printf("%s\n", u.c[0] == 0x04 ? "little-endian" : "big-endian");
    return 0;
}
```

x86 与 ARM 小端（ARM 可切换为双端 bi-endian），PowerPC 历史上常用大端，网络协议统一规定大端。**为什么网络字节序 = 大端**？两个理由：一是 RFC 1700 等规范成型时，多数参与制定标准的机器（如 PDP-10、早期 MIPS 工作站）是大端，规定大端能避免多数设备做转换；二是大端在**抓包与规范书写上更可读**——字段按字节顺序读出来就是数值本身，写规范和读实现都不易出错（RFC 793 中 TCP 头的多字节字段均明确按网络字节序）。这是「规范选择」而非「工程必然」：它**不改变正确性，只改变转换发生在谁那里**。

| 踩坑场景 | 现象 | 正解 |
|---|---|---|
| socket 收发端口/长度 | 小端机器上端口号变成 0x1F90 → 0x901F | `htons`/`htonl`/`ntohs`/`ntohl` 成对使用 |
| 直接 `write` 结构体或手搓 `memcpy` | 跨架构读回后字段全错，x86 通过而 ARM 大端失败 | 逐字段序列化并统一转大端，或用 `ntohl` 后再解析 |
| 位域 / 哈希按整数读 | 位序与端序相关且布局未标准化；不同架构摘要不同 | 位域改显式掩码与移位；摘要按规范声明的字序实现 |
| Java `DataOutputStream` 与 C 互操作 | Java 规定大端，C 侧默认小端 | 两侧显式转换，别依赖默认 |

> [!TIP]
> 一句话记忆：**网络是大端，x86 是小端，Java 的 `DataInput/OutputStream` 也是大端（JLS 未见规定，但 `java.io` 文档明确写出），所以「Java 写、C 读」是最常见的转换漏点。**

## 2.8 结构体内存对齐与填充

对齐规则只有两条，但足以推出所有 `sizeof`：**成员偏移**必须是 `min(成员自身对齐, 编译指示对齐)` 的整数倍，不满足则在成员**前面**插入填充；**结构体总大小**必须是「最大成员对齐」的整数倍，不满足则在**尾部**插入填充（保证数组场景下每个元素都对齐）。

```c
#include <stdio.h>
struct A { char a; int b; char c; };                 /* 1 + 3填充 + 4 + 1 + 3尾部填充 = 12 */
struct B { char c; char a; int b; };                 /* 调整顺序后只需 4 字节填充 = 8 */
struct C { double d; char c; int i; };               /* 8 + 1 + 3填充 + 4 = 16（尾部已对齐） */
struct D { char a; long b; char c; } __attribute__((packed));  /* 强制紧凑 = 10 */
struct E { char a; long b; char c; };                /* 1 + 7填充 + 8 + 1 + 7尾部填充 = 24 */
int main(void) {
    /* 静态断言在编译期拦住"以为 sizeof 等于字段和"的误改，比运行时打印更早暴露问题 */
    _Static_assert(sizeof(struct A) == 12, "A");
    _Static_assert(sizeof(struct B) == 8,  "B");
    _Static_assert(sizeof(struct E) == 24, "E");
    printf("%zu %zu %zu %zu\n", sizeof(struct A), sizeof(struct B),
           sizeof(struct C), sizeof(struct D));
    return 0;
}
```

`struct A` 与 `struct B` 字段完全相同、只换了顺序，体积从 12 降到 8——**成员排序就是零成本的优化**，把大对齐类型排在前面、同类型聚在一起通常就能消掉大部分填充。

| 类型（x86-64 System V） | 大小 | 对齐 |
|---|---|---|
| `char` / `_Bool` / `short` | 1 / 1 / 2 | 1 / 1 / 2 |
| `int` / `float` | 4 | 4 |
| `long` / `double` / 指针 | 8 | 8 |
| `long double` / `__int128` | 16 | 16 |

**为什么对齐能提升性能**：CPU 访存通路按「字/行」组织。一次对齐的 8 字节访问恰好落在一个 cache line 内，只需一次总线事务；跨行访问要拆成两次并触发两次 cache 查找；某些架构（SPARC、部分 ARM 模式）对未对齐访问直接抛异常（`SIGBUS`），由内核软件模拟完成，代价是几十到上百周期。此外原子指令（`lock cmpxchg`、`std::atomic`）**要求对齐**，未对齐的原子操作在 x86 上不再是原子的。

`__attribute__((packed))` 的代价必须说清：

| 收益 | 代价 |
|---|---|
| 序列化协议/硬件寄存器布局能一字节不差地对齐规范 | 成员可能未对齐：x86 上生成额外指令拆访问，ARM 上可能 `SIGBUS` |
| 显著省内存（网络包头、内核结构） | **不能对该结构成员取地址交给需要对齐的代码**（`&s.b` 传给 `long*` 是 UB） |
| 减少一次字段搬运的 `memcpy`，但编译器无法再用向量指令批量访问 |

> [!WARNING]
> 常见错误回答：「`packed` 只是让结构体更紧凑」。真正的坑是**它把「未对齐访问」这件 UB 事显式引入代码**：在 ARM 上取 `packed` 成员的地址再解引用可能直接 `SIGBUS`。回答时必须带上代价。

## 2.9 函数调用开销的分解与内联的取舍

一次函数调用的开销可以拆成可列举的几项，这也是「为什么热点函数要内联」的完整论证：

| 开销项 | 具体内容 | 量级 |
|---|---|---|
| 传参 | 前 6 个整型参数 `mov` 进寄存器；超过 6 个要写栈 | 数个周期 |
| 控制转移 | `call` 压返回地址 + 跳转；返回时 `ret`（现代 CPU 有返回地址栈预测） | 1~3 周期 |
| 寄存器保存/恢复 | 用到的 callee-saved 寄存器 `push`/`pop` | 每对约 1 周期 |
| 栈帧建立 | `push rbp; mov rbp,rsp; sub rsp,N`（`-O2` 常省略 `rbp`，用 `rsp` 直接寻址） | 2~3 周期 |
| 尾部的 `leave`/`ret` | 与上面对称 | 2~3 周期 |
| 间接调用（虚函数/函数指针） | 无法静态解析目标，依赖间接分支预测（BTB） | 预测命中时约 1 周期，**预测失败约 5 ns** |
| 优化被阻断 | 调用是编译器的优化屏障：常量无法跨调用传播、无法做死代码消除 | 往往是**真正的大头** |

所以「内联的收益」很少来自省掉的 `call`/`ret`（合计约 1% 量级的收益），而是来自**跨函数优化被打开**：常量折叠、把参数变成编译期已知、把返回值的分支一并优化掉、为调用点特化生成无分支代码。典型例子是 `qsort` 的比较函数——函数指针导致每次比较都间接调用且无法内联，这正是在性能敏感路径上改用「特化排序 + 模板/泛型」的动力。

内联的代价同样要能说清：代码膨胀会挤占指令缓存（L1i 典型 32 KB），并在指令 TLB 上产生压力；同时放大编译时间与调试难度（栈回溯断链、`gdb` 里看不到内联前的帧）。现代编译器的内联阈值是启发式的（函数体大小、调用点热度、`-O2`/`-O3`、`inline` 关键字只是提示），并非「写了 `inline` 就一定内联」。

```c
/* 三个控制内联的手段：always_inline 是强制、noinline 是禁制、hot 影响代码布局 */
static inline __attribute__((always_inline)) int add_one(int x) { return x + 1; }

/* 大函数强制内联会挤掉 icache，这里显式禁止，交给编译器按调用点热度决定 */
__attribute__((noinline)) void heavy_path(void) { /* 复杂逻辑 */ }

/* 标记热路径让链接器把该函数排在一起，提升 icache 与 iTLB 命中率 */
__attribute__((hot)) int fast_path(int x) { return add_one(x); }
```

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 五阶段链路与双视图 | 产物依次是 `.i`/`.s`/`.o`/ELF/地址空间；节给链接器、段给加载器（只有 R/W/X） | 把链接错误当编译错误找本地文件 |
| `.bss` | 不占文件空间，加载时映射匿名零页，物理页按需分配；`packed` 则相反地引入未对齐访问 | 只答"未初始化的变量"、或以为 `packed` 无代价 |
| PLT/GOT | GOT 初始存 PLT 桩地址，`_dl_runtime_resolve` 首次调用后回填真实地址 | 答"GOT 存函数地址" |
| `LD_PRELOAD` | 插到符号搜索最前，靠 PLT 劫持；setuid 程序忽略 | 忘了"内联/静态链接/直接 syscall 拦不住" |
| 地址空间与 ASLR | 栈与 mmap 向下、堆向上；ASLR 只随机各段**基址** | 说"随机化了内容"或"栈向高地址增长" |
| 调用约定 | 6 个整型参数走寄存器，`rbx`/`r12`~`r15` 由被调用者保存 | 记混 caller/callee-saved |
| 栈溢出 | 越界写命中 `rbp+8` 的返回地址；canary/NX/ASLR/影子栈各打断一环 | 把 ASLR 当"修复漏洞" |
| 对齐与 `packed` | 偏移按 `min(成员对齐, 编译指示)` 对齐，总大小按最大对齐补齐 | 以为 `sizeof` 等于字段之和 |
| 内联收益 | 主要来自跨函数优化，不是省 `call`/`ret`；代价是 icache 压力 | 答"省了函数调用所以快一倍" |

## 自测题

**Q1**（计算）在 x86-64 上，`struct S { char a; double b; char c; int d; };` 的 `sizeof(struct S)` 是多少？把成员重排后最小能到多少？
<details><summary>答案与解析</summary>

原顺序：`a` 在 0；`b` 对齐 8，故从偏移 8 开始（1~7 是填充）；`c` 在 16；`d` 对齐 4，从 20 开始，共到 24；最大对齐是 8 且 24 已对齐，**`sizeof = 24`**。重排为 `{ double b; int d; char a; char c; }` 后：`b` 在 0、`d` 在 8、`a` 在 12、`c` 在 13，共 14 字节，向上补到 8 的倍数，**最小 16**。

错在哪：忘记尾部填充，或以为「把 `c` 挪到 `d` 后面就够」。面试官会追问 32 位 x86 上 `double` 对齐是 4（System V i386 ABI），以及「要 `memcpy` 到网络的报文头时你怎么设计」——答案是按对齐声明字段 + 显式序列化，而不是用 `packed` 硬压。
</details>

**Q2**（推导）延迟绑定到底省了什么？请说明 `printf` 第一次调用与第二次调用在指令层面差多少，以及 `-z now` 换来什么？
<details><summary>答案与解析</summary>

第一次：`call printf@plt` → `jmp *GOT[printf]` 落到 PLT 内部桩 → `push $index; jmp .plt[0]` → `_dl_runtime_resolve` 在 `.dynsym`/`.dynstr` 里按名字查找、算出真实地址、**回填 `GOT[printf]`**、再跳进 libc。第二次：`call printf@plt` → `jmp *GOT[printf]` 直接进 libc，只多一次间接跳转。省的是**加载期的符号解析总量**：libc 导出上千符号而程序通常只用几十个，未使用符号的解析成本被彻底消除。

`-z now`（配合 `-z relro` 即 full RELRO）在加载时一次解析全部符号并填好 GOT，再把 GOT 页设为只读：收益是启动后无运行时解析、且免疫 GOT 覆写劫持；代价是启动变慢，对长跑服务通常划算。错在哪：只答「第一次慢第二次快」而不说回填动作，或以为 `-z now` 会「禁用动态链接」。追问方向：「为什么 GOT 必须可写？」——延迟绑定要回填，这正是它成为攻击面的原因。
</details>

**Q3**（设计）线上服务只能改环境变量、不能改代码，你要统计某个进程里所有 `malloc` 的调用次数和大小分布。怎么做，有哪些失效情形？
<details><summary>答案与解析</summary>

写一个 `malloc` 拦截库：内部用 `dlsym(RTLD_NEXT, "malloc")` 取真实实现并转发，再用 `LD_PRELOAD` 挂上去。记录必须用异步信号安全的 `write` 或线程本地缓冲，否则 `printf` 自身会再触发 `malloc` 造成递归。失效情形（答出这些才是加分项）：调用被内联或用 `-fno-plt` 编译；目标静态链接（`-static`）或被 `-Bsymbolic` 绑定；程序直接发 `syscall(SYS_brk/mmap)` 绕过 libc；目标是 setuid/setgid 进程（`ld.so` 忽略 `LD_PRELOAD` 以防提权）；多线程下必须用线程本地状态，否则要引入锁与竞态。
</details>

**Q4**（判断）「`.bss` 段不占文件空间，所以一个 100 MB 的零初始化全局数组几乎不增加可执行文件大小，进程启动后也不占用物理内存。」这句话对吗？
<details><summary>答案与解析</summary>

前半句对，后半句错。`.bss` 在文件里只记录「大小」这一项，所以 ELF 文件大小几乎不变（避免用零填充文件正是 ELF 的设计取舍）。但进程启动后内核给 `.bss` 映射一段匿名零页：**虚拟地址空间立刻按 100 MB 记账，物理内存按需分配**，只有真正写到某页才分配该页物理页（只读零页被所有进程共享）。所以「RSS 不涨」只在从不访问这段内存时成立；一旦 `memset` 全写，100 MB 实打实被占用。

错在哪：混淆「映射」与「分配物理页」，或以为 `.bss` 会懒加载到「完全不占虚拟地址空间」。追问方向：「`.bss` 与 `malloc` 大块内存谁更快？」——都靠按需分页，`.bss` 省掉一次 `mmap` 与 `brk` 管理，但同样有首次写入的缺页中断（见第 8 章 8.3 节）。
</details>

**Q5**（推导）一个热点小函数被强制内联后，程序反而变慢。请给出至少三条可能的机制解释。
<details><summary>答案与解析</summary>

一是**指令缓存压力**：内联把同一份指令复制到多个调用点，热点循环的代码体积超出 L1i（典型 32 KB）或 iTLB 覆盖范围，取指命中率下降，收益被抵消。二是**分支预测劣化**：被内联的函数原本有稳定的 `call`/`ret` 模式，由返回地址栈（RSB）廉价预测；内联后代码膨胀改变了基本块的位置与对齐，间接跳转与循环分支的预测可能变差。三是**寄存器压力上升**：函数体在多个上下文重复，活跃变量变多导致 spill（溢出到栈）与访存增多；内联进冷路径后，膨胀还会影响外层循环的展开决策。

错在哪：只答「内联会增加代码体积，所以有代价」而不说清代价通过哪条硬件机制转化为变慢。面试官会追问「你怎么验证」——在完整负载下用 `perf stat -e L1-icache-load-misses,branch-misses` 对比前后，并用 `-fno-inline-functions` 做对照，而不是用单点微基准外推。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| `gcc main.c` 这条命令背后依次发生了什么？每一步的产物是什么？ | 工具链全链路 | 答不完整或只说「编译成可执行文件」；加分点是能说出 `.o` 里已经没有「函数名调用」只有「符号引用 + 重定位项」 | 2.1 |
| 为什么 `.bss` 段不占可执行文件空间，但进程启动后内存却会涨？ | ELF 与加载 | 答「因为未初始化」就停了；必须讲到匿名零页映射 + 按需分页 + 只读零页共享 | 2.2 |
| 动态链接比静态链接快还是慢？ | 链接取舍 | 答「动态链接快因为共享库」混淆了体积与启动速度；正确说法是运行时省内存、启动更慢、后续调用多一次间接跳转 | 2.3 |
| 延迟绑定是怎么实现的？第一次调用 `printf` 时发生了什么？ | PLT/GOT | 答「GOT 里存函数地址」是最典型的错误，初始值其实是 PLT 桩地址，靠 `_dl_runtime_resolve` 回填 | 2.3.1 |
| 不改任何一行代码，怎么替换一个库函数？有哪些情况会失败？ | 动态链接器与插桩 | 只说 `LD_PRELOAD` 不提 `RTLD_NEXT`（会递归死循环）、不提 setuid 被忽略、不提内联与静态链接拦不住 | 2.4 |
| ASLR 是干什么的？它能防住栈溢出吗？ | 安全机制边界 | 答「随机化内存布局所以防住了」是错的（只能提高门槛）；要区分 ASLR 与 NX、canary 各自打断的环节，并说清「一个基址泄露即全失效」 | 2.5、2.6 |
| 函数参数是怎么传递的？哪些寄存器由被调用者负责保存？ | 调用约定 | 记不住 `rdi/rsi/rdx/rcx/r8/r9` 顺序，或把 `rbx`/`r12`~`r15` 说成调用者保存 | 2.6 |
| 栈溢出是怎么劫持控制流的？现在为什么很难利用了？ | 安全 + 栈帧 | 只说「覆盖返回地址」；加分点是给出 `rbp+8` 的位置推导，并按 canary/NX/ASLR/PIE 分别说明各自打断哪一步 | 2.6 |
| 网络字节序为什么规定为大端？ | 端序 | 答「大端更快」没有依据；正确角度是规范成型时的历史选择 + 大端在抓包与规范书写上的可读性，并强调转换发生在收发边界 | 2.7 |
| 这个结构体的 `sizeof` 是多少？为什么不是字段大小之和？ | 内存对齐 | 只背对齐规则不会算；要能现场列出每个成员的偏移、算出填充、并验证总大小是最大对齐的倍数 | 2.8 |
| `__attribute__((packed))` 有什么风险？ | 对齐与 UB | 答「更省内存」；必须补上未对齐访问的性能损失、ARM 上的 `SIGBUS`、以及取成员地址传出去是 UB | 2.8 |
| 内联函数为什么能提升性能？什么情况下反而更慢？ | 编译优化 | 答「省了函数调用开销」是把小头当大头（真正收益是跨函数优化）；必须能给出 icache 与寄存器压力导致的劣化 | 2.9 |

## 延伸阅读

- System V Application Binary Interface, AMD64 Architecture Processor Supplement（第 3 章「Low Level System Information」，参数传递与栈帧的规范原文）。
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification, Version 1.2（段与节的权威定义，`PT_LOAD`/`p_flags`）。
- `man 5 elf`、`man 8 ld.so`、`man 1 ldconfig`、`man 1 readelf`（Linux man-pages，符号搜索顺序与 `LD_PRELOAD` 的官方描述）。
- Ulrich Drepper, *How To Write Shared Libraries*（PIC、GOT/PLT、符号介入与重定位开销的经典长文）。
- John R. Levine, *Linkers and Loaders*, Morgan Kaufmann（第 5~6 章讲重定位与符号解析，第 8 章讲共享库）。
- Randal E. Bryant & David R. O'Hallaron, *Computer Systems: A Programmer's Perspective*（第 3 版第 7 章「Linking」、第 3 章「Machine-Level Representation of Programs」）；RFC 793（TCP 多字节字段均按网络字节序定义）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch02/  

