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.1 计算机系统漫游:从源文件到进程

本章回答

一行 hello.c 到屏幕上出现一行字,中间经过哪些程序、哪些文件、哪些硬件?为什么一次 printf 可能比一次加法慢几万倍?操作系统究竟替你藏起了什么?

读完你会

用 gcc -v、readelf、strace 把「编译—链接—加载—运行」拆开看;解释进程、虚拟内存、文件三个抽象各自解决什么问题;用 Amdahl 定律判断一处优化值不值得做。

一个 C 程序在磁盘上是一串字节,在 CPU 上是一串指令,在操作系统眼里是一个进程,在终端上是一行输出。这四个「是」之间没有魔法,只有一串可以观察的转换:预处理、编译、汇编、链接、加载、执行。每一步都有明确的输入、输出和负责它的程序。

这一章沿着 hello.c 走一遍全程。目的不是记住工具名,而是建立一张地图:后面讲汇编、链接、进程、虚拟内存、I/O 时,你能立刻把新知识钉在地图的某个位置。地图上最重要的不是细节,而是边界——哪一步由编译器负责,哪一步由内核负责,哪一步由硬件负责。

读法建议:手边开一个终端,把文中的命令跑一遍。所有结论都以 Linux 6.8、x86-64、GCC 13 为背景;ARM64 上的差异会在需要处点出。

1.1.1 源文件是文本,程序是字节

hello.c 只有 6 行:

#include <stdio.h>

int main(void)
{
    printf("hello, world\n");
    return 0;
}

对编辑器它是文本,对文件系统它是一串字节。od -An -tx1 -c hello.c 能看到每个字符的 ASCII 码:# 是 0x23,i 是 0x69,换行是 0x0a。文件里没有「函数」「循环」这些概念,只有字节;是上下文(C 语法)让同一串字节有了含义。

一个容易忽略的事实:源文件里出现的所有内容,包括注释、空白、#include 指令,都不是给 CPU 看的。CPU 只认识一种东西——按特定格式编码的指令与数据。从文本到那种格式,需要一整套翻译流程。

定义 1.1.1 · 编译系统(compilation system)

把 C 源文件翻译成可执行目标文件的程序集合,通常由预处理器(cpp)、编译器(cc1)、汇编器(as)和链接器(ld)四部分组成。GCC 驱动程序 gcc 按文件后缀和选项依次调用它们,本身不做翻译。

1.1.2 四个阶段:预处理、编译、汇编、链接

gcc hello.c -o hello 一条命令背后是四个阶段。用 gcc -v 可以看到它实际调用的子命令;用 -E、-S、-c 可以让流程停在任意一步。

阶段输入输出谁来做主要工作
预处理.c + 头文件.icc1 -E展开 #include、#define,处理条件编译,删注释
编译.i.scc1词法/语法/语义分析,生成中间表示,优化,生成汇编
汇编.s.oas把助记符编码成机器指令,生成可重定位目标文件
链接.o + 库可执行文件ld(经 collect2)符号解析、重定位、合并段
cc -E hello.c -o hello.i          # 预处理;hello.i 通常有几百到几千行
cc -S hello.c -o hello.s          # 编译;得到人类可读的汇编
cc -c hello.c -o hello.o          # 汇编;得到可重定位目标文件
cc hello.o -o hello               # 链接;此时才需要 printf 的实现

hello.i 会比 hello.c 大很多,因为 stdio.h 连带进来的声明有上千行。这些声明大部分不会生成代码,只告诉编译器「printf 长什么样」。声明与定义是两件事:头文件给声明,真正的实现藏在库文件里,链接阶段才被接上。

为什么要有汇编这一步

编译器完全可以跳过汇编文本直接生成机器码。保留 .s 是为了让「优化后的指令选择」和「指令编码」分成两个可检查的层次:-S 的输出能被人读,-c 的输出能被 objdump 反读。调试性能问题时,你通常在 .s 或反汇编上确认编译器到底生成了什么。

1.1.3 可执行文件里有什么

file hello 会告诉你它是一个 ELF 64-bit LSB executable,动态链接,入口地址若干。ELF(Executable and Linkable Format)是 Linux 上目标文件、可执行文件、共享库的统一容器格式。

用 readelf 从三个角度看同一个文件:

file hello
readelf -h hello | sed -n '1,14p'          # 文件头:类型、机器、入口
readelf -S hello | grep -E '\.(text|rodata|data|bss|symtab)'
readelf -l hello | sed -n '1,40p'          # 段(segment):加载器关心的视图

三个视图的差别值得记牢:

  • 节(section) 是链接器视图:.text 放指令,.rodata 放只读常量,.data 放已初始化的全局/静态变量,.bss 放未初始化(或初始化为 0)的全局/静态变量,.symtab 放符号表。
  • 段(segment) 是加载器视图:一个 LOAD 段往往包含多个节,按页对齐、按权限(R/W/X)合并。内核只按段建立映射,不关心节。
  • 入口地址 不是 main 的地址,而是 _start 的地址。_start 从栈上取出 argc/argv,调用 __libc_start_main,后者才调用 main。

.bss 有一个反直觉的性质:它在文件里不占空间,只记录「需要多少字节、从哪个地址开始」。加载器把这些页映射成零页,第一次写触发缺页,才分配物理内存。一个 static char buf[1 << 30]; 不会让可执行文件变成 1 GiB。

readelf -S 的典型输出(节选,地址与偏移因编译选项而异):

[Nr] Name              Type             Address           Off    Size   ES Flg Lk Inf Al
[ 1] .interp           PROGBITS         0000000000000318  000318 00001c 00   A  0   0  1
[ 4] .text             PROGBITS         0000000000001050  001050 0001a5 00  AX  0   0 16
[ 5] .rodata           PROGBITS         0000000000002000  002000 000014 00   A  0   0  4
[ 6] .data             PROGBITS         0000000000004000  003000 000010 00  WA  0   0  8
[ 7] .bss              NOBITS           0000000000004010  003010 000008 00  WA  0   0  1
[ 8] .symtab           SYMTAB           0000000000000000  003010 0001b0 18     9  12  8

看三列就够:Address 是链接后的虚拟地址,Off 是文件内偏移,Flg 是权限(A 可分配、W 可写、X 可执行)。.bss 的类型是 NOBITS,Off 与 Size 的关系说明它不占文件字节;.text 的 AX 说明它可执行但不可写——这正是 W^X(write xor execute)的落地方式。

反汇编 main 能看到高层语句落地后的样子:

objdump -d --no-show-raw-insn hello | sed -n '/<main>:/,+8p'

在 -O2 下,printf("hello, world\n") 通常被优化成 puts("hello, world"),因为编译器知道不需要格式化。这类替换是后面 1.5 的主题:你写的代码和 CPU 执行的代码不是一回事。

1.1.4 从 shell 到进程:fork、exec、wait

在终端输入 ./hello,shell 做的是三件事:

  1. fork():复制出一个子进程,子进程几乎拥有父进程的一份副本;
  2. execve("./hello", argv, envp):在子进程里装入 hello,替换掉原来的地址空间;
  3. waitpid():父进程睡眠,等子进程退出并回收。

内核在 execve 里做的工作,比「读文件到内存」复杂得多:

  • 打开 ELF,检查魔数、架构、ABI;
  • 按 PT_LOAD 段建立虚拟内存映射,设置 R/W/X 权限;.bss 映射到零页;
  • 把 argv、envp、辅助向量(auxv)复制到新栈的顶部;
  • 如果带 PT_INTERP,先映射动态链接器 ld-linux-x86-64.so.2,把控制权交给它;
  • 设置寄存器:%rsp 指向新栈,%rip 指向入口(动态链接时先到 ld.so);
  • 返回用户态,开始执行。
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (pid == 0) {
        /* 子进程:用 exec 替换自身映像;成功则不返回 */
        execl("/bin/echo", "echo", "child: exec ok", (char *)NULL);
        perror("execl");          /* 只有失败才会执行到这里 */
        _exit(127);
    }

    int status;
    if (waitpid(pid, &status, 0) == -1) {
        perror("waitpid");
        return EXIT_FAILURE;
    }
    if (WIFEXITED(status))
        printf("parent: child exited with %d\n", WEXITSTATUS(status));
    return EXIT_SUCCESS;
}

fork 的返回值在父子进程中不同:父进程拿到子进程 pid,子进程拿到 0,失败返回 -1。这个设计让一份代码自然分成两条路径。execl 成功后不会返回,所以 perror 那行只有失败时才可达;用 _exit 而不是 exit,是为了不重复刷新父进程继承来的 stdio 缓冲区。

exec 是一族函数,区别只在参数如何组织:

函数参数形式是否查 PATH是否传递 environ
execl / execle变长参数列表否execle 显式传
execv / execvechar *const argv[]否execve 显式传
execlp / execvp列表 / 数组是继承
execvpe数组是显式传

execlp("ls", "ls", "-l", (char *)NULL) 会沿 PATH 找 ls;execl("/bin/ls", ...) 不会。argv[0] 是程序自己看到的名字,可以与路径不同——这既是 busybox 用一份二进制模拟几百个命令的手法,也是恶意程序伪装进程名的入口。execve 是唯一的系统调用,其余都是 glibc 的包装。

`vfork` 与 `posix_spawn`

vfork() 不复制页表,父进程阻塞到子进程 exec 或 _exit,子进程甚至共享父进程的地址空间。它曾用于「fork 后立刻 exec」的场景,但限制多、容易出错。现代代码优先用 posix_spawn():glibc 在可用时用 clone(CLONE_VM|CLONE_VFORK) 实现,语义比裸 vfork 安全,适合需要频繁创建子进程的服务。

关于「运行程序」的四个误解
  1. 「exec 创建新进程」——不创建。execve 替换当前进程的地址空间、寄存器、信号处理方式,pid 不变。创建进程的是 fork/clone。
  2. 「fork 立刻复制整个地址空间」——现代 Linux 用写时复制(copy-on-write, COW),父子共享物理页,只有写时才复制。fork 的成本主要是页表复制,不是内存复制。
  3. 「程序退出码就是 main 的返回值」——main 的返回值经 exit 截断为 8 位(status & 0xff)。返回 256 和返回 0 在 shell 里看到的一样。
  4. 「./hello 是 shell 读文件并执行」——shell 不解析 ELF,它只调用 execve;解析和执行都是内核与动态链接器的事。

进程状态与上下文切换

hello 不会从第一行一直执行到退出。它要和终端、磁盘、其他进程竞争 CPU,内核必须能随时暂停它、稍后从同一点恢复。Linux 用一组状态描述这件事,最常遇到的是:

状态ps 显示含义谁把它唤醒
TASK_RUNNINGR正在 CPU 上,或在运行队列里等 CPU调度器
TASK_INTERRUPTIBLES可中断睡眠,等事件或信号事件、信号
TASK_UNINTERRUPTIBLED不可中断睡眠,通常等块设备 I/OI/O 完成
__TASK_STOPPEDT被 SIGSTOP/调试器暂停SIGCONT
EXIT_ZOMBIEZ已退出,等父进程 wait 回收父进程

一次「暂停—恢复」叫上下文切换(context switch)。内核把当前进程的通用寄存器、%rip、%rsp、浮点/SIMD 状态、页表基址(%cr3)保存到 task_struct 的 thread_struct 与内核栈,再装入下一个进程的状态。切换本身通常只要 1–5 µs,但副作用可能很大:更换页表会让 TLB 失效(PCID 可缓解),缓存工作集被污染,分支预测器要重新学习。

进程 A(R)          内核              进程 B(S → R)
   |                  |                    |
   |-- 时间片用完 --->|                    |
   |  保存 A 的上下文 |                    |
   |                  |-- 选中 B --------->|
   |                  |  恢复 B 的上下文   |
   |                  |                    |-- 继续执行
   |   (A 变成 S)   |                    |

这张时间线说明两件事:第一,切换由内核发起,用户态代码没有「我要让出 CPU」的普通指令(sched_yield 也是系统调用);第二,被换下的进程并没有丢进度,它的全部可见状态都在内存里。所谓「进程以为自己独占 CPU」,是靠这种保存/恢复制造出来的幻觉。

时间片有多长

Linux 的 CFS/EEVDF 调度器不设固定时间片,而是按权重分配 CPU 时间比例;典型交互负载的下一次调度点在几毫秒量级。时间片太短,切换开销占比升高;太长,交互延迟变差。第 1.13 章会把调度策略展开,这里只需记住:切换是有成本的,并发不等于免费。

1.1.5 进程眼中的内存

每个进程都以为自己独占一整块地址空间。x86-64 用户态可用 47 位(开启 5 级页表时 56 位)虚拟地址,典型布局从低到高:

0x0000000000400000  ┌──────────────┐
                    │ .text  .rodata│  代码与只读常量(r-x / r--)
0x0000000000401000  ├──────────────┤
                    │ .data  .bss  │  全局与静态变量(rw-)
0x0000000000404000  ├──────────────┤
                    │ heap ↓       │  malloc 向上生长
                    │              │
                    │ mmap 区域 ↑  │  共享库、文件映射、大块 malloc
0x00007ffff7a00000  ├──────────────┤
                    │ stack        │  局部变量、返回地址、argv/envp
0x00007ffffffff000  └──────────────┘

这张布局图解释了三类常见崩溃:访问 0x0 附近的空指针(低地址未映射)、栈溢出(向低地址越界,撞上保护页)、堆越界(写坏相邻 chunk 的元数据)。具体地址每次运行都可能不同,因为 ASLR(address space layout randomization)会随机化栈、堆、mmap 的基址;.text 的偏移不变,但 PIE 可执行文件的加载基址也会随机。

在运行中的进程里可以直接看到这张图:

cat /proc/self/maps | awk '{print $1, $2, $6}' | head
# 7f2a5c000000-7f2a5c021000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
# 7ffd3a1f8000-7ffd3a219000 rw-p [stack]

每行是「起始-结束 权限 偏移 设备 inode 路径」。没有路径的匿名映射通常是堆、mmap 分配的匿名页或 [heap]、[stack] 伪文件。权限位 r-xp 里的 p 表示私有映射(MAP_PRIVATE),s 表示共享。

从指令看 hello

把 main 反汇编出来(cc -O2,去掉字节列):

0000000000001149 <main>:
    1149: endbr64
    114d: sub    $0x8,%rsp
    1151: lea    0xeac(%rip),%rdi      # 2004 <_IO_stdin_used+0x4>
    1158: call   1050 <puts@plt>
    115d: xor    %eax,%eax
    115f: add    $0x8,%rsp
    1163: ret

逐行读:

  • endbr64 是 CET(Control-flow Enforcement Technology)的间接跳转着陆标记,防止 ROP 跳到函数中间;老 CPU 把它当 nop。
  • sub $0x8,%rsp 调整栈指针并保持 16 字节对齐。System V ABI 要求 call 之前 %rsp 是 16 的倍数,call 压入 8 字节返回地址后,被调函数入口是 %rsp % 16 == 8。
  • lea 0xeac(%rip),%rdi 把字符串地址放进第一个参数寄存器 %rdi。%rip 是下一条指令的地址,0x1158 + 0xeac = 0x2004,正好落在 .rodata。
  • call puts@plt 先把返回地址压栈,再跳到 PLT 桩;真正的 puts 在 libc.so.6 里,第一次调用由动态链接器解析。
  • xor %eax,%eax 把返回值设为 0,比 mov $0,%eax 少一个字节。
  • add $0x8,%rsp; ret 恢复栈并返回到 __libc_start_call_main。

这段不到 10 条指令的程序,已经暴露出后面几章的全部主题:寄存器与寻址(1.3)、栈帧与调用约定(1.3)、PLT 与动态链接(1.7)、指令级并行(1.4)。读汇编不是为了手写汇编,而是为了验证编译器是否按你的意图生成代码。

定义 1.1.2 · 进程(process)

进程是程序的一次执行实例。内核为它维护:独立的虚拟地址空间、一组寄存器上下文(尤其 %rip、%rsp)、打开文件描述符表、信号处理表、凭证(uid/gid)、调度状态与统计。程序是磁盘上的静态文件,进程是运行中的动态实体;同一个程序可以对应多个进程。

1.1.6 存储层次:一次访存的差距有多大

CPU 执行一条加法大约 1 个周期(约 0.3 ns);从 DRAM 取一个数约 80–100 ns;从 NVMe SSD 取 4 KiB 约 20–100 µs;一次跨机房网络往返约 0.5–1 ms。数量级差距是存储层次存在的理由:把最近用过的数据放在更近、更小、更快的介质上。

存储层次与局部性 从寄存器到磁盘/网络,容量增大、延迟上升;缓存利用时间与空间局部性。 存储层次:容量、延迟与局部性寄存器< 1 ns~KBL1 cache~1 ns32–64 KBL2 cache~4 ns256 KB–1 MBL3 cache~10–20 ns8–64 MBDRAM~80–100 nsGB–TBNVMe SSD~20–100 µsTBHDD / 网络~msTB / 无限越靠上:越快、越小、越贵越靠下:越慢、越大、越便宜局部性时间:刚访问的还会访问空间:相邻地址会被一起取缓存行通常 64 Bmiss 惩罚 >> 命中延迟工程含义:顺序访问 > 随机访问;结构体拆分(SoA)> 大结构体数组;循环分块提高复用
读图:左侧金字塔越往下越慢。注意数量级:L1 约 1 ns,DRAM 约 100 ns,NVMe 约几十 µs,网络约 ms——每下一层就是 10–1000 倍。右侧两条局部性是所有性能优化的基础:让下一次访问落在更上一层。

读图时抓住两条线:纵向是容量与延迟的数量级,横向是局部性。时间局部性让「刚读过的变量」留在 L1;空间局部性让「相邻的 64 字节」一起被取进缓存行。程序性能的很大一部分,取决于你是否让下一次访问落在这张图的上一层。

一个具体对比:遍历 int a[1024][1024],按行求和与按列求和的访存次数相同,但缓存未命中数相差 8 倍以上。按列访问时,相邻两次访问相隔 4 KiB,每次都可能换入一个新的缓存行;按行访问时,一个 64 字节行里 16 个 int 会被连续用完。第 1.6 章会把这个例子算清楚。

把常用数字记成表,做容量规划时先估数量级:

操作典型延迟折算成「1 个 CPU 周期 = 1 秒」
寄存器访问< 1 ns< 1 秒
L1 命中~1 ns~1 秒
L2 命中~4 ns~4 秒
L3 命中~15 ns~15 秒
DRAM 访问~90 ns~1.5 分钟
NVMe 4 KiB 读~50 µs~14 小时
HDD 随机 4 KiB 读~10 ms~116 天
同机房网络 RTT~0.5 ms~6 天
跨洲网络 RTT~150 ms~4.8 年

「1 周期 = 1 秒」的换算把 ns 放大成人类尺度,比例不变。它给出的直觉是:一次 DRAM 访问相对于一次加法,相当于一分半钟;一次网络往返相当于几天。 任何「多访问一次内存/网络无所谓」的假设,都经不起这张表的检验。

缓存为什么必须靠「猜」

缓存无法预知你下一次访问哪个地址,它只能利用「过去访问过的、附近的地址很可能再被访问」这一统计规律。所以缓存对顺序访问友好,对随机访问差;对紧凑数据友好,对指针追逐差。优化缓存命中率,本质是把访问模式改造成缓存能猜中的形状。

关于存储层次的三个误解
  1. 「缓存越大程序越快」——只在工作集大于当前缓存、且访问模式有局部性时成立。一个纯随机访问的大哈希表,扩大 L3 也可能毫无收益;反过来,把热数据压进 L2 往往比换更大 L3 更有效。
  2. 「SSD 没有寻道,所以随机读和顺序读一样快」——SSD 没有机械寻道,但仍有页/块粒度、FTL 映射、读放大和并发队列限制。4 KiB 随机读的 IOPS 远高于 HDD,但顺序大块读的吞吐通常仍是随机读的数倍。
  3. 「CPU 频率高就一定快」——访存密集型程序的瓶颈在内存延迟/带宽,不在主频;提高频率甚至可能让功耗上升、频率回落。判断瓶颈要看 perf stat 的 IPC 与 stall 原因,而不是只看 GHz。

1.1.7 操作系统的三个抽象

操作系统把硬件包装成三个抽象,程序只跟抽象打交道:

抽象内核对象解决什么问题典型接口
进程task_struct让每个程序以为自己独占 CPUfork、execve、wait
虚拟内存mm_struct、页表让每个进程以为自己独占内存,并提供隔离mmap、brk、缺页异常
文件struct file、inode把磁盘、终端、管道、socket 统一成字节流open、read、write、close

三个抽象的共同点是虚拟化:CPU 被时间分片,内存被地址翻译,I/O 被统一成文件描述符。抽象带来两个好处:程序不用知道硬件细节;内核可以在不改变接口的前提下换实现(比如从 HDD 换到 NVMe,read 的语义不变)。

程序通过系统调用(system call)使用这些抽象。系统调用是一次受控的特权级切换:用户态把调用号放进寄存器,执行 syscall 指令,CPU 跳到内核预设的入口,内核根据调用号分派。strace 把这次切换的两侧打印出来:

strace -f -e trace=execve,write,exit_group ./hello

输出里能看到 execve("./hello", ...)、write(1, "hello, world\n", 13)、exit_group(0)。注意 printf 最终变成了一次 write,而且是在缓冲区刷新时发生的;printf 本身只是把字节放进用户态缓冲区。

系统调用并不便宜

一次普通函数调用约 1–2 ns;一次系统调用要切换特权级、切换栈、保存/恢复部分上下文、执行内核代码,再返回用户态,通常要 100 ns–1 µs。开启了 KPTI(Kernel Page Table Isolation,缓解 Meltdown)之后,每次进出内核还要更换页表,成本进一步上升。

strace -c -T ./hello          # 按系统调用统计次数与耗时
perf stat -e cycles,instructions,branches,branch-misses,cache-misses ./hello

这解释了一个常见的优化方向:批量处理系统调用。read 一次 64 KiB 比 read 64 次 1 KiB 快,不只是因为磁盘,还因为少切换了 63 次内核。writev、io_uring、mmap 都是同一个思路的不同形态。

也有反向的例外:clock_gettime、gettimeofday、time、getcpu 通过 vDSO(virtual dynamic shared object)在用户态读取内核维护的数据,不发生系统调用。strace 看不到它们,perf 也只会看到用户态指令。判断一个函数是否真的进内核,不能只靠「它看起来像系统调用」。

`printf` 到屏幕的完整路径

printf → stdio 缓冲区 → write(1, ...) → 内核 vfs_write → 终端驱动(或 pipe/socket)→ 显示。若 stdout 是终端,stdio 默认行缓冲,遇到 \n 就 flush;若重定向到文件,默认全缓冲,可能攒到 4 KiB 才写。这解释了两个现象:printf 后崩溃可能丢最后一行;printf 加 \n 在终端上立刻可见,在文件里却不一定。

1.1.8 网络也是一台 I/O 设备

把 hello 换成浏览器访问服务器,链条变长但结构相同:客户端进程调用 write(sockfd, "GET / HTTP/1.1\r\n..."),数据穿过 TCP/IP 协议栈,被网卡 DMA 读出,经过交换机、路由器到达服务器;服务器网卡收到帧,触发中断,内核协议栈逐层解封装,把字节放进 socket 接收缓冲区,服务器进程 read 到请求。

从进程的角度,网络和磁盘没有本质区别:都是「打开一个 fd,读写字节」。差别在延迟、带宽、故障模式:磁盘读失败通常返回 EIO,网络读失败可能是超时、连接重置、部分写;网络的延迟比内存高 5–6 个数量级,所以网络程序必须处理「慢」和「断」。

数据在链路上是逐层封装的。以一次 HTTP GET 为例,用户数据每往下走一层就多一个首部:

应用层   ┌──────────────────────────────────────────┐
         │ HTTP 请求:"GET /index.html HTTP/1.1..." │
         ├──────────────────────────────────────────┤
运输层   │ TCP 首部 20 B │ HTTP 数据 ...            │
         ├───────────────┴──────────────────────────┤
网络层   │ IP 首部 20 B │ TCP 首部 │ 数据 ...       │
         ├──────────────┴───────────┴───────────────┤
链路层   │ 以太网首部 14 B │ IP 包 ... │ FCS 4 B    │
         └──────────────────────────────────────────┘

以太网 MTU 通常 1500 字节;减去 IP 20 字节、TCP 20 字节,TCP 的有效载荷上限 MSS 为 1460 字节。一个 1 MiB 的响应要切成约 719 个 TCP 段。每一层只关心自己的首部,收端再逐层剥掉——这叫封装与分用(encapsulation / demultiplexing)。第 1.11 章会把这套结构落到 socket API 上。

这个视角是第 1.11 章的基础:socket 是文件描述符的一种,read/write 的语义(短读、短写、EINTR、EAGAIN)完全复用。你在这里学到的 I/O 纪律,到网络编程时一条都不用改。

1.1.9 Amdahl 定律:优化哪一部分

假设一个程序里比例 pp 的部分能被加速 ss 倍,其余 1−p1-p 不变,则总加速比为

S=1(1−p)+ps. S = \frac{1}{(1-p) + \dfrac{p}{s}}.

令 s→∞s \to \infty,上限是 11−p\dfrac{1}{1-p}。这个上限比公式本身更有用:它告诉你「不管投入多少并行资源,最多能快多少」。

具体数字:若 p=0.6p = 0.6,用 4 核并行,S=1/(0.4+0.15)=1.82S = 1 / (0.4 + 0.15) = 1.82;即使核数无限,Smax⁡=2.5S_{\max} = 2.5。若 p=0.95p = 0.95,4 核得到 1/(0.05+0.2375)=3.481/(0.05+0.2375)=3.48,上限 20。串行部分决定了天花板,这就是为什么「加机器」经常不解决问题。

串行比例 1−p1-p4 核加速比16 核加速比无限核上限
40%1.822.292.50
10%3.086.4010.0
5%3.489.1420.0
1%3.8813.9100

最后一行说明:想让 16 核跑出 13.9 倍,串行部分必须压到 1% 以下。这也是并发编程里「锁的临界区要短」「I/O 要异步」的量化依据。

例 1.1.1 · 该优化哪一段

某服务单请求耗时 100 ms,其中 JSON 序列化 60 ms、数据库 30 ms、其余 10 ms。把序列化加速 3 倍,总时间变为 60/3+30+10=6060/3 + 30 + 10 = 60 ms,加速比 1.671.67。把数据库加速 3 倍,总时间 60+10+10=8060 + 10 + 10 = 80 ms,加速比 1.251.25。若两部分都加速 3 倍,总时间 20+10+10=4020 + 10 + 10 = 40 ms,加速比 2.52.5。先测量,再决定动哪一段——这就是 perf 存在的意义。

并发(concurrency)与并行(parallelism)不是同义词:并发是「多个任务在时间上重叠」,单核分时也能并发;并行是「多个任务在同一时刻真正同时执行」,需要多核或 SIMD。Amdahl 定律约束的是并行带来的收益;并发更多解决的是「等待 I/O 时别闲着」,它的收益模型是 Little 定律与吞吐量,不是单请求延迟。

并行的三个层次

同一段代码可以从三个层次榨取并行度,收益与成本依次上升:

层次手段典型加速主要障碍
指令级(ILP)流水线、多发射、乱序执行2–6 倍数据依赖、分支、寄存器压力
数据级(DLP)SSE/AVX、GPU4–16 倍(向量宽度)数据布局、尾部处理
线程/进程级(TLP)pthread、多进程、多机受 Amdahl 上限约束锁竞争、通信、负载不均

指令级并行由 CPU 自动完成,你只能通过「减少依赖链」帮它(1.5);数据级并行要求你把循环改成向量友好的形状;线程级并行要求你处理共享状态(1.12)。三者不是互斥的:一个高性能内核库通常同时用满三层。

测扩展性别靠猜。固定问题规模,逐步增加线程数,记录吞吐与加速比:

for t in 1 2 4 8; do
  OMP_NUM_THREADS=$t /usr/bin/time -f "$t threads: %e s" ./workload
done

如果 2 线程只有 1.3 倍、4 线程反而下降,先查三件事:临界区是否太长、是否存在伪共享(false sharing)、内存带宽是否已经饱和。Amdahl 定律解释「为什么有上限」,这三件事解释「为什么没到上限」。

1.1.10 一次完整的解剖:把工具串成证据链

前面各节是分开放的。真实排障时,你需要把工具串成一条链:现象 → 假设 → 证据 → 结论。假设有人交给你一个二进制 prog,说「它启动慢,而且偶尔输出丢失」,可以按下面的顺序取证。

解剖一个陌生程序的六步
  1. 看类型与依赖:file prog、ldd prog。确认架构、是否动态链接、依赖哪些库;ldd 对不可信二进制不要直接跑(它会触发加载器),可用 objdump -p prog | grep NEEDED。
  2. 看节与段:readelf -S prog、readelf -l prog。确认 .text 大小、是否有大 .bss、LOAD 段的权限是否异常(可写又可执行就是危险信号)。
  3. 看入口与启动路径:readelf -h prog 的 Entry point,objdump -d --start-address=...。动态链接的程序入口在 ld.so,真正的 main 由 __libc_start_main 调用。
  4. 看运行时系统调用:strace -f -T -tt -o trace.log ./prog。按耗时排序找慢调用;strace -c 先看总量。注意 strace 会放大开销,别用它测绝对性能。
  5. 看 CPU 与缓存:perf stat -e cycles,instructions,cache-references,cache-misses,branch-misses ./prog;perf record -g ./prog 后 perf report 找热点函数。
  6. 看内存与缺页:/usr/bin/time -v ./prog 的 Maximum resident set size、minor/major page faults;/proc/<pid>/smaps 看具体映射。

一个具体的「输出丢失」案例:程序用 printf 打日志,重定向到文件后最后几行不见了,终端上却正常。证据链是:

现象工具证据结论
终端正常、文件丢尾strace -e write文件模式下 write 次数少、最后没有 flushstdio 全缓冲
进程异常退出waitpid 状态WIFSIGNALED 为真没走到 exit,缓冲区没刷
加 \n 仍丢setvbuf 文档文件模式默认 _IOFBF换行不触发 flush

修复不是「多加几个 \n」,而是显式 fflush(fp),或者用 setvbuf(fp, NULL, _IOLBF, 0) 改成行缓冲,或者在退出路径上保证 exit 被调用。证据链的价值在于排除法:先确认数据是否到了内核(strace 看 write),再确认是否到了磁盘(fsync),最后才怀疑硬件。

1.1.11 完整时间线:从 execve 到 printf 返回

把前面几节串成一条可观测的时间线。下面是一次 ./hello 在 Linux 6.8/x86-64 上的典型路径(strace -f -tt -e trace=execve,openat,mmap,write,exit_group ./hello 的简化):

0.000000 execve("./hello", ["./hello"], 0x7ffd...) = 0
0.000120 openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
0.000180 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
0.000240 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f...
0.000300 mmap(NULL, 1900544, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f...
0.000360 mmap(0x7f..., 4096, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x...)
0.000420 write(1, "hello, world\n", 13) = 13
0.000480 exit_group(0) = ?

读这条时间线要抓五个动作:

  1. execve 之前:shell 已经 fork 出子进程,子进程调用 execve;内核验证 ELF、建立新地址空间、把 argc/argv/envp 放到新栈顶。
  2. 动态链接器:内核先跳到 ld-linux-x86-64.so.2 的入口;它读 /etc/ld.so.cache、加载 libc.so.6、做重定位、填 GOT,然后把控制权交给 _start。
  3. _start → __libc_start_main → main:_start 从栈上取 argc/argv,把 main、init、fini 传给 libc;libc 初始化 TLS、stdio、atexit 链,再调用 main。
  4. printf 不是一次 write:它先把 13 字节写进 stdio 缓冲区;遇到换行且 stdout 是终端(行缓冲)才 write(1, ...)。如果重定向到文件,缓冲区是 4 KiB/8 KiB,可能要等 exit 或缓冲区满才写。
  5. exit_group:return 0 从 main 返回,__libc_start_main 调 exit(0),运行 atexit/fclose,最后 exit_group 结束所有线程。_exit 不会做这些。
三个「看起来像」的误区
  • ./hello 不是「运行一个文件」:是 shell fork + execve,内核按 ELF 头建立映射;没有执行权限会 EACCES,格式不对会 ENOEXEC(shell 可能回退到 /bin/sh 解释)。
  • printf 的返回值不是「写成功」:它返回写入缓冲区的字节数;真正的 write 可能稍后才发生,失败也只能在 fflush/fclose 时看到。
  • main 不是入口:入口是 _start;main 只是 libc 调用的一个普通函数,可以被 LD_PRELOAD 替换。

1.1.12 _start 的栈布局:argc、argv、envp、auxv

内核在 execve 时把参数放到新栈顶,布局在 x86-64/ARM64/RISC-V 上语义相同、细节略异:

  高地址
  ┌──────────────┐
  │ 字符串区      │  argv[0]、argv[1]…、envp…
  ├──────────────┤
  │ AT_NULL      │  auxv 结束
  │ AT_ENTRY ... │  auxv(ELF 入口、页大小、UID、随机数…)
  ├──────────────┤
  │ NULL         │  envp 结束
  │ envp[n-1]    │
  │ ...          │
  ├──────────────┤
  │ NULL         │  argv 结束
  │ argv[argc-1] │
  │ ...          │
  │ argv[0]      │
  ├──────────────┤
  │ argc         │  ← 进入 _start 时 sp 指向这里
  └──────────────┘  ← sp
  低地址
# _start:argc 在 (%rsp),argv 在 8(%rsp)
.global _start
_start:
    mov    (%rsp), %rdi          # argc
    lea    8(%rsp), %rsi         # argv
    lea    16(%rsp,%rdi,8), %rdx # envp = argv + argc + 1
    call   main
    mov    %eax, %edi
    mov    $60, %eax             # SYS_exit
    syscall
// _start:sp 指向 argc
.global _start
_start:
    ldr    x0, [sp]              // argc
    add    x1, sp, #8            // argv
    add    x2, x1, x0, lsl #3    // envp = argv + argc*8 + 8
    add    x2, x2, #8
    bl     main
    mov    x8, #93               // SYS_exit
    svc    #0
# _start:sp 指向 argc
.global _start
_start:
    ld     a0, 0(sp)             # argc
    addi   a1, sp, 8             # argv
    slli   t0, a0, 3
    add    a2, a1, t0
    addi   a2, a2, 8             # envp
    call   main
    li     a7, 93                # SYS_exit
    ecall

用 gdb 在 _start 下断点,x/8gx $sp 就能看到 argc、argv[0] 指针、NULL、envp;info auxv 能读辅助向量。AT_ENTRY 是程序入口,AT_PHDR 是程序头表地址,AT_RANDOM 是 16 字节栈保护/ASLR 随机数。

1.1.13 静态链接、动态链接与 LD_PRELOAD

# 静态链接:把 libc 目标代码复制进可执行文件
cc -static hello.c -o hello-static
file hello-static
ldd hello-static            # 不是动态可执行文件

# 动态链接(默认)
cc hello.c -o hello-shared
ldd hello-shared
readelf -d hello-shared | grep -E 'NEEDED|RPATH|RUNPATH'

动态链接的三个可观测点:

# 1) 动态链接器实际加载了什么
LD_DEBUG=libs ./hello-shared 2>&1 | sed -n '1,30p'

# 2) 替换一个函数:LD_PRELOAD 的符号优先于 libc
cat > myprintf.c <<'EOF'
#include <stdio.h>
int printf(const char *fmt, ...) {
    return fprintf(stderr, "[preload] %s", fmt);
}
EOF
cc -shared -fPIC myprintf.c -o myprintf.so
LD_PRELOAD=./myprintf.so ./hello-shared

# 3) 运行时找不到库:LD_LIBRARY_PATH 与 rpath 的优先级
readelf -d ./hello-shared | grep -E 'RPATH|RUNPATH'
动态链接的五个坑
  1. ldd 会执行动态链接器,不要对不可信二进制用;readelf -d 更安全。
  2. RPATH 优先于 LD_LIBRARY_PATH,RUNPATH 相反;新代码用 RUNPATH(-Wl,-rpath 默认)。
  3. LD_PRELOAD 只能替换动态符号;静态链接或内联后的调用替换不了。
  4. -static 不是「更安全」:libc 的 NSS、locale、DNS 仍可能需要动态模块;静态二进制也更大、更新更麻烦。
  5. 符号版本:GLIBC_2.34 之类版本节点会让「在新系统编译、旧系统运行」失败;发布时对齐目标 glibc 或静态链接 musl。

1.1.14 工具链证据:让编译器自己说话

# 看驱动程序实际调用了哪些子命令(不执行)
cc -### hello.c -o hello 2>&1 | sed -n '1,20p'

# 保留中间产物
cc -save-temps hello.c -o hello
ls hello.i hello.s hello.o

# 汇编里带源码/优化注释
cc -O2 -fverbose-asm -S hello.c -o hello-O2.s
sed -n '1,60p' hello-O2.s

# 反汇编与重定位
objdump -d --no-show-raw-insn hello | sed -n '/<main>:/,/^$/p'
readelf -r hello.o | sed -n '1,30p'

-### 的输出里能看到 cc1、as、collect2 的完整参数;-save-temps 适合确认「宏展开后到底是什么」;-fverbose-asm 会把变量名/源码行作为注释插进汇编;readelf -r 告诉你哪些位置需要链接器填地址。

1.1.15 跨架构的 hello:同一件事,三种入口

/* hello.c — 三架构通用 */
#include <stdio.h>
int main(void) { printf("hello, world\n"); return 0; }
// hello.rs — 三架构通用;rustc -O hello.rs
fn main() { println!("hello, world"); }
# 交叉编译到三套架构
clang --target=x86_64-linux-gnu  -O2 hello.c -o hello-x86_64
clang --target=aarch64-linux-gnu -O2 hello.c -o hello-arm64
clang --target=riscv64-linux-gnu -O2 hello.c -o hello-riscv64

# 没有硬件时用 QEMU 用户态
qemu-aarch64 -L /usr/aarch64-linux-gnu ./hello-arm64
qemu-riscv64 -L /usr/riscv64-linux-gnu ./hello-riscv64

三份二进制的 readelf -h 会显示不同 Machine、不同入口地址;objdump -d 的 _start/main 指令完全不同,但 write(1, "hello, world\n", 13) 的语义完全一样。这就是「ISA 变、ABI 变、系统调用号变,但操作系统抽象不变」。

更细的三架构寄存器/系统调用对照见 1.14;汇编/调用约定见 1.15;RISC-V 裸机见 1.18。

1.1.16 一页速查与陷阱

问题证据结论
源文件怎么变成可执行?cc -###、-save-temps预处理→编译→汇编→链接
可执行文件里有什么?readelf -h/-S/-l/-rELF 头、节、段、重定位、符号
为什么 main 不是入口?readelf -h 的 Entry、gdb _start入口是 _start,libc 再调 main
printf 何时真正写?strace -e write行缓冲/全缓冲决定,未必立即
动态库怎么找到?ldd、LD_DEBUG=libs、readelf -dRPATH → LD_LIBRARY_PATH → RUNPATH → ld.so.cache → 默认目录
为什么静态链接仍依赖 NSS?ldd、strace openat名字服务/ locale 可能 dlopen
本章十个高频错误
  1. 把 main 当入口,忽略 _start/__libc_start_main。
  2. 以为 printf 一定立刻 write,用 _exit 导致缓冲区丢失。
  3. 把 .bss 当文件占用,惊讶于大数组不增大二进制。
  4. 用 ldd 检查不可信二进制(它会执行 ld.so)。
  5. 在旧系统运行新 glibc 编译的二进制,遇到 GLIBC_2.xx not found。
  6. 以为 -static 完全无动态依赖(NSS/locale 仍可能)。
  7. 用 LD_PRELOAD 替换内联/静态链接的函数,失败。
  8. 把 argc/argv 的栈布局记成「argv 在 argc 前面」。
  9. 忽略 AT_RANDOM/ASLR,对栈地址做硬编码。
  10. 用 strace 追高并发服务,把性能问题变成观测问题。

1.1.17 工具链的字节账与时间账

这一节回答:gcc hello.c -o hello 究竟启动了几个进程、每个阶段产出多少字节、时间花在哪一段?1.1.14 给了命令,这里把产物量化。

gcc -### 把驱动程序准备执行的命令逐条打印到 stderr,但不执行。在 Ubuntu 24.04 / GCC 13 上,输出骨架是:

COLLECT_GCC=cc
Target: x86_64-linux-gnu
 /usr/libexec/gcc/x86_64-linux-gnu/13/cc1 -quiet -imultiarch x86_64-linux-gnu \
   hello.c -dumpbase hello.c -mtune=generic -march=x86-64 -o /tmp/ccA1.s
 /usr/libexec/gcc/x86_64-linux-gnu/13/as -v --64 -o /tmp/ccB2.o /tmp/ccA1.s
 /usr/libexec/gcc/x86_64-linux-gnu/13/collect2 -plugin ... \
   -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -o hello \
   /usr/lib/x86_64-linux-gnu/Scrt1.o /usr/lib/x86_64-linux-gnu/crti.o \
   ... -lc ... /usr/lib/x86_64-linux-gnu/crtn.o

链接行里除了 hello.o 和 -lc,还夹着 Scrt1.o、crti.o、crtn.o——它们是 C 运行时的启动/收尾代码,_start 就在 Scrt1.o 里(PIE 版)。

各阶段产物的典型量级(hello.c,glibc 2.39,-O2):

产物规模主要成分谁生成
hello.c6 行 / 约 90 B源码你
hello.i700–1000 行 / 18–28 KiBstdio.h 及其传递包含的声明cc1 -E
hello.s30–60 行 / 1–2 KiB优化后的汇编cc1
hello.o1.3–2 KiB / 5–8 个节指令、只读数据、符号表、重定位as
hello(动态)15–17 KiBELF 头、代码、PLT、动态段ld/collect2
hello(静态)700–900 KiB整个 libc 中用到的部分ld -static

想知道时间花在哪,用 -ftime-report:

cc -O2 -ftime-report -c hello.c -o hello.o

它把 cc1 内部各 pass 的墙钟时间与内存打印到 stderr。典型分布是:解析(parsing)5–15%,优化与代码生成(opt and generate)40–60%,展开(expand)10–20%,调度 5–10%,汇编输出 5–10%。链接时间不在其中,-ftime-report 只覆盖编译。若 parsing 占了大头,先查头文件与模板;若 opt 占了大头,再考虑 -O1 或 -fno-* 选项,而不是盲目换机器。

实验 1.1.A · 四阶段字节账
set -e
cc -E hello.c -o hello.i
cc -S hello.c -o hello.s
cc -c hello.c -o hello.o
cc hello.o -o hello
wc -lc hello.c hello.i hello.s
ls -l hello.o hello
size hello.o hello
readelf -S hello.o | grep -E '\.(text|rodata|data|bss|rela)'

预期看到:hello.i 比 hello.c 大两三个数量级;hello.o 的 .text 只有几百字节;size 的 text 列包含 .text 与 .rodata,不等于「指令字节数」。若把 -O2 换成 -O0,.text 通常增大 20–50%,hello.s 行数翻倍。

工具链账本的五个坑
  1. “-### 已经编译了”——它只打印命令并退出;真正执行的是 -v 或不带 -### 的命令。
  2. “-save-temps 不会影响构建”——它在当前目录写 hello.i/.s/.o;并行构建同一目录会互相覆盖,改用 -save-temps=obj 把中间文件放到输出目录。
  3. “cc 就是 gcc”——cc 可能是 clang;clang 的 -### 输出结构与 GCC 不同,脚本不要解析它,解析 -### 只在人工排障时可靠。
  4. “-ftime-report 可以用来做基准”——它自身有测量开销,且不含链接;比较两个编译器版本时,用外部 time 与多次运行的中位数。

1.1.18 重定位:链接器改的是哪几个字节

这一节回答:.o 里的 call 目标为什么显示为 0?链接器到底往哪些偏移写了什么?

编译到 .o 时,编译器还不知道 puts 与字符串的最终地址,于是把字段填 0,并在 .rela.text 里留一条记录:

Relocation section '.rela.text' contains 2 entries:
    Offset             Info             Type            Sym. Value  Symbol's Name + Addend
0000000000000003  000400000002 R_X86_64_PC32      0000000000000000 msg - 4
0000000000000008  000500000004 R_X86_64_PLT32     0000000000000000 puts - 4

三列要会读:Offset 是要修改的字段在节内的偏移;Info 的高 32 位是符号表索引、低 32 位是重定位类型;Addend 是显式加数(x86-64 用 RELA,加数不放在被改的字段里)。i386 用 REL,加数直接存在字段中,链接器要先读旧值。

以 1.1.5 里那段 main 为例。lea 0xeac(%rip),%rdi 位于 0x1151,长度 7,下一条指令在 0x1158;字符串在 .rodata 的 0x2004。字段本身在 0x1154,链接器计算:

value=S+A−P=0x2004+(−4)−0x1154=0xeac. \text{value} = S + A - P = 0x2004 + (-4) - 0x1154 = 0xeac.

A = -4 不是笔误:x86 的 RIP 相对位移是「目标 − 下一条指令地址」,而 P 指向字段起点,字段起点比下一条指令早 4 字节,所以用 -4 补齐。call puts@plt 位于 0x1158,长度 5,下一条在 0x115d;设 puts@plt 在 0x1050,则

0x1050−4−0x1159=−0x10d=0xfffffef3, 0x1050 - 4 - 0x1159 = -0x10d = \texttt{0xfffffef3},

机器码是 e8 f3 fe ff ff。反汇编显示 call 1050 <puts@plt>,与计算一致。

不同架构的重定位类型与范围:

架构类型用途计算/范围
x86-64R_X86_64_PC32RIP 相对 32 位S+A−PS+A-P,±2 GiB
x86-64R_X86_64_PLT32调用 PLT 桩指向 PLT,±2 GiB
x86-64R_X86_64_GOTPCREL取 GOT 项地址-fPIC 后常见
ARM64R_AARCH64_ADR_PREL_PG_HI21ADRP 的页基址页对齐,±4 GiB
ARM64R_AARCH64_CALL26BL 调用±128 MiB
RISC-VR_RISCV_CALL_PLTAUIPC+JALR 对±2 GiB
RISC-VR_RISCV_BRANCH条件分支±4 KiB

超出范围时链接器报 relocation truncated to fit。这不是罕见错误:内核模块、巨大静态二进制、把代码链接到高地址都会触发。

实验 1.1.B · 看链接器改字节
cc -c hello.c -o hello.o
readelf -r hello.o
objdump -dr --no-show-raw-insn hello.o | sed -n '/<main>:/,/^$/p'
cc -fPIC -c hello.c -o hello-pic.o
readelf -r hello-pic.o | grep -E 'GOTPCREL|PLT32'
cc -no-pie hello.o -o hello-nopie
readelf -r hello-nopie | head
readelf -S hello-nopie | grep -E '\.(got|plt|rela)'

非 PIC 版本出现 PC32/PLT32;-fPIC 版本多出 GOTPCREL,因为外部符号要经过 GOT 间接取址;可执行文件里原来的 .rela.text 消失,只剩 .rela.dyn 中的动态重定位(PIE 下常见 R_X86_64_RELATIVE)。「链接后没有重定位」是错觉——静态重定位被链接器消化了,动态重定位还在。

重定位的五个坑
  1. “Addend 的 -4 是编译器 bug”——它是 RIP 相对寻址的定义决定的;不同指令长度对应不同的 P。
  2. “可执行文件没有重定位”——PIE/动态链接的可执行文件仍有 .rela.dyn;readelf -r 看得到。
  3. “-fPIC 只是编译选项,不影响链接”——它改变代码生成(GOT/PLT),也改变重定位类型与数量。
  4. “REL 与 RELA 可以混用”——x86-64 用 RELA、i386 用 REL;手写链接器/加载器时必须按架构处理加数。
  5. “重定位溢出只会发生在大程序”——把 .text 映射到远离数据的高地址、或链接脚本把段放太远,小程序也会溢出。

1.1.19 动态链接的两次绑定:PLT、GOT 与 LD_DEBUG

这一节回答:puts 的地址什么时候写进 GOT?为什么第一次调用比后续慢?LD_DEBUG 的哪一行能证明这一点?

动态链接的可执行文件里,call puts 并不直接跳到 libc,而是跳到 PLT 桩:

0000000000001050 <puts@plt>:
    1050: ff 25 b2 2f 00 00   jmp *0x2fb2(%rip)   # 4008 <puts@GOT>
    1056: 68 00 00 00 00      push $0x0
    105b: e9 a0 ff ff ff      jmp 1020 <plt0>   # 跳到 PLT0,不是 _init

jmp *0x2fb2(%rip) 从 GOT 项 0x4008 取地址。加载时 GOT 项被填成 0x1056(也就是 push 那条指令),所以第一次调用会落到 push $0x0:把重定位索引压栈,再跳到动态链接器的解析器。解析器找到 puts 的真实地址,回写 GOT,然后跳过去。第二次调用时,jmp 直接取到 libc 地址,不再经过解析器。

绑定模式何时解析启动成本首次调用GOT 是否可写
默认(lazy)首次调用低慢部分 RELRO 下可写
-Wl,-z,now加载时全部高快全 RELRO 下只读
LD_BIND_NOW=1加载时全部高快取决于链接选项
dlopen(..., RTLD_NOW)dlopen 时中快取决于库

动态链接器的搜索顺序(glibc):

顺序来源何时生效备注
1DT_RPATH没有 DT_RUNPATH 时已废弃
2LD_LIBRARY_PATH非 secure-execution开发调试常用
3DT_RUNPATH直接依赖不传递给依赖的依赖
4/etc/ld.so.cache默认ldconfig 维护
5/lib、/usr/lib 等默认架构相关子目录

$ORIGIN 在 RPATH/RUNPATH 里展开为可执行文件所在目录,适合自带库的应用:

mkdir -p app/bin && cc hello.c -Wl,-rpath,'$ORIGIN/../lib' -o app/bin/hello
readelf -d app/bin/hello | grep -E 'RPATH|RUNPATH'

LD_DEBUG 是这条链路的证词。libs 显示搜索与加载,bindings 显示符号绑定,statistics 显示重定位数量与启动耗时:

LD_DEBUG=libs ./hello 2>&1 | sed -n '1,12p'
LD_DEBUG=bindings ./hello 2>&1 | grep puts
LD_DEBUG=statistics ./hello 2>&1 | grep -E 'relocations|startup'

典型输出(pid 前缀因运行而异):

     21344:     find library=libc.so.6 [0]; searching
     21344:      search cache=/etc/ld.so.cache
     21344:       trying file=/lib/x86_64-linux-gnu/libc.so.6
     21344:     binding file ./hello [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `puts' [GLIBC_2.2.5]
     21344:     number of relocations: 121

bindings 那一行证明:puts 确实绑定到了 libc,且带版本节点 GLIBC_2.2.5。版本不匹配时,报错是 version 'GLIBC_2.34' not found,而不是「找不到符号」——两者根因不同。

实验 1.1.C · lazy 与 now 的启动账
cc hello.c -o hello-lazy
cc hello.c -Wl,-z,now -o hello-now
for b in hello-lazy hello-now; do
  echo "== $b"
  LD_DEBUG=statistics ./$b 2>&1 | grep -E 'relocations|total startup'
  /usr/bin/time -f '%e s' ./$b >/dev/null
done
LD_BIND_NOW=1 ./hello-lazy
readelf -l hello-now | grep GNU_RELRO

hello-now 的 number of relocations 通常更大(加载时解析全部),但首次调用没有解析抖动;hello-lazy 的启动更快,首次 puts 更慢。GNU_RELRO 段在 -z now 下覆盖 .got,readelf -l 能看到它。

动态链接的六个坑
  1. “LD_LIBRARY_PATH 优先于 RUNPATH”——顺序是 RPATH → LD_LIBRARY_PATH → RUNPATH;LD_LIBRARY_PATH 确实压过 RUNPATH,别记反。
  2. “setuid 程序也认 LD_PRELOAD”——secure-execution 模式忽略 LD_PRELOAD/LD_LIBRARY_PATH;但 RPATH/RUNPATH 仍可能被利用,提权程序要避免从可写目录加载库。
  3. “LD_PRELOAD 能替换任何函数”——只能替换动态符号;静态链接、内联、-Bsymbolic 绑定、版本不匹配都可能让它失效。
  4. “dlopen 的符号全局可见”——默认 RTLD_LOCAL;要让后续库看到,得用 RTLD_GLOBAL,但会引入符号冲突。
  5. “-z now 只有好处”——它把解析成本移到启动,并让 GOT 只读;对启动时间敏感的程序要实测。
  6. “ldd 是只读检查”——ldd 会执行动态链接器;对不可信二进制用 objdump -p | grep NEEDED 或 readelf -d。

1.1.20 _start 的现场:auxv 与 libc 接管

这一节回答:进入 _start 时栈上到底有什么?libc 如何在不读 ELF 文件的情况下找到程序头、入口和随机数?

1.1.12 给了布局图与三段 _start。用 gdb 在 _start 断下,能直接读到现场:

(gdb) break _start
(gdb) run
(gdb) x/6gx $rsp
0x7fffffffe4d0: 0x0000000000000002  0x00007fffffffe7a1
0x7fffffffe4e0: 0x00007fffffffe7b1  0x0000000000000000
0x7fffffffe4f0: 0x00007fffffffe7c9  0x00007fffffffe7d1
(gdb) info auxv
33 AT_SYSINFO_EHDR  0x7ffff7fc1000
25 AT_RANDOM        0x7fffffffe4b9
9  AT_ENTRY         0x555555555060
6  AT_PAGESZ        4096

依次是 argc=2、argv[0]、argv[1]、NULL、envp[0]、envp[1];再往后是 envp 的其余项,最后是 auxv 的键值对,以 AT_NULL 结束。info auxv 把辅助向量翻译成名字。

auxv 项值/含义谁用
AT_PHDR程序头表在内存中的地址ld.so 找 PT_DYNAMIC
AT_PHENT / AT_PHNUM每个程序头大小 / 个数ld.so 遍历
AT_ENTRY主可执行文件入口ld.so 跳回 _start
AT_PAGESZ页大小(常见 4096)libc/malloc
AT_RANDOM16 字节随机数地址栈保护 canary、ASLR
AT_SECURE是否 setuid/secure 模式libc 关闭不安全特性

为什么需要 AT_PHDR?ld.so 要读 PT_DYNAMIC 才知道依赖库、重定位表、符号表;内核已把程序头映射进内存,直接给地址比重新 open 解析更快,也不受文件被删除/替换的影响。AT_RANDOM 则是 16 字节真随机数:glibc 用它初始化栈保护 canary 和 pointer guard,没有它,ASLR 的强度会大打折扣。

用 getauxval 可以在 C 里读这些值:

#include <errno.h>
#include <stdio.h>
#include <sys/auxv.h>
#include <elf.h>

int main(void)
{
    errno = 0;
    unsigned long pagesz = getauxval(AT_PAGESZ);
    unsigned long entry  = getauxval(AT_ENTRY);
    unsigned char *rnd   = (unsigned char *)getauxval(AT_RANDOM);
    if (errno != 0 || rnd == NULL) {
        perror("getauxval");
        return 1;
    }
    printf("AT_PAGESZ=%lu AT_ENTRY=%#lx\n", pagesz, entry);
    printf("AT_RANDOM=");
    for (int i = 0; i < 16; i++)
        printf("%02x", rnd[i]);
    putchar('\n');
    return 0;
}

getauxval 在找不到项时返回 0 并置 errno=ENOENT;必须先清 errno 再判断,否则会读到上一次调用的残留值。

`_start` 与 auxv 的六个坑
  1. “argc 至少是 1”——execve 可以传空 argv,此时 argc=0、argv[0]=NULL;无条件 strlen(argv[0]) 会崩。
  2. “envp = argv + argc”——中间还有一个 NULL 终止符,正确写法是 argv + argc + 1。
  3. “AT_RANDOM 是字符串”——它是 16 字节二进制,按 %s 打印会越界。
  4. “手写 _start 可以直接 ret”——入口没有返回地址;必须 exit/exit_group,否则跳到未定义地址。
  5. “栈对齐无所谓”——x86-64 在 call 前必须 16 字节对齐;main 里的 movaps 会对未对齐地址触发 SIGSEGV。
  6. “getauxval 返回 0 就是值 0”——0 也可能是合法值;用 errno 区分「不存在」与「值为 0」。

1.1.21 存储层次的并发度:Little 定律与指针追逐

这一节回答:既然 DRAM 要 80 ns,处理器靠什么把带宽跑满?为什么「多开几个线程」有时没用,有时又必须开?

1.1.6 给了延迟表,但延迟只说明「一次访问等多久」,不说明「同时能等多少次」。把系统看成排队网络,Little 定律给出:

在途请求数=吞吐×延迟. \text{在途请求数} = \text{吞吐} \times \text{延迟}.

要让 25 GB/s 的 DRAM 跑满,在 80 ns 延迟下必须有 25×109×80×10−9=200025 \times 10^9 \times 80 \times 10^{-9} = 2000 字节在途,也就是约 31 条 64 B 的 cache line。CPU 的 miss 状态寄存器(MSHR)与乱序窗口就是用来维持这个并发度的;一旦超发,多出来的请求只会排队。

场景延迟吞吐/带宽在途量
L1 命中1.3 ns(4 拍 @3 GHz)~200 GB/s~260 B(4 条 line)
DRAM80 ns25 GB/s~2000 B(31 条 line)
NVMe 随机读20 µs500k IOPS10 个请求
1 GbE0.5 ms RTT125 MB/s~62.5 KB
10 GbE0.5 ms RTT1.25 GB/s~625 KB

这张表解释了两件事:第一,单线程顺序访存能靠预取器维持并发,随机访存只能靠乱序窗口,所以后者慢;第二,网络程序要「多开连接/异步 I/O」,本质是把带宽延迟积(BDP)填满,而不是 CPU 算不过来。

指针追逐(pointer chasing)是测真实访存延迟的标准实验:每次加载的地址依赖上一次结果,编译器无法重排,预取器也无法提前。

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

static double now_ns(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec;
}

int main(int argc, char **argv)
{
    size_t bytes = argc > 1 ? strtoul(argv[1], NULL, 0) : (1u << 20);
    size_t n = bytes / sizeof(size_t);
    if (n < 2) return 1;

    size_t *next  = malloc(n * sizeof *next);
    size_t *order = malloc(n * sizeof *order);
    if (!next || !order) return 1;

    for (size_t i = 0; i < n; i++) order[i] = i;
    for (size_t i = n - 1; i > 0; i--) {
        size_t j = (size_t)rand() % (i + 1);
        size_t t = order[i]; order[i] = order[j]; order[j] = t;
    }
    for (size_t i = 0; i < n; i++)
        next[order[i]] = order[(i + 1) % n];
    free(order);

    size_t idx = 0;
    size_t steps = n * 4;
    double t0 = now_ns();
    for (size_t i = 0; i < steps; i++)
        idx = next[idx];              /* 依赖链:下一次地址来自上一次加载 */
    double t1 = now_ns();
    printf("bytes=%zu steps=%zu %.2f ns/access idx=%zu\n",
           bytes, steps, (t1 - t0) / (double)steps, idx);
    free(next);
    return 0;
}
cc -O2 -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror chase.c -o chase
for s in 1048576 8388608 67108864 1073741824; do ./chase $s; done
工作集典型 ns/次主要瓶颈
1 MiB2–4L2/L3
8 MiB10–20L3
64 MiB80–100DRAM
1 GiB100–150DRAM + TLB miss

如果 64 MiB 的结果明显高于 100 ns,先查 NUMA 绑定与是否被其他进程干扰;如果 1 GiB 的结果与 64 MiB 相同,说明大页或 TLB 覆盖很好。不要用顺序遍历测延迟——预取器会把 DRAM 延迟藏起来。

存储层次测量的五个坑
  1. “volatile 能绕过 cache”——volatile 只阻止编译器优化,不改变 cache 行为;要测内存延迟用依赖链,不是加 volatile。
  2. “顺序访问和随机访问差不了多少”——顺序访问有预取与行填充,随机访问每次都可能 miss;差距是数量级。
  3. “大页一定更快”——大页减少 TLB miss,但会增加内部碎片与分配延迟;小工作集用大页可能更慢。
  4. “clock_gettime 没有开销”——vDSO 快路径也要几十纳秒;测纳秒级事件要循环多次摊薄,或用 rdtscp 并序列化。
  5. “容器里的 cache 是独占的”——L3 通常被同物理机的其他容器共享,测量噪声大;固定 CPU、避开邻居才能得到稳定数字。

1.1.22 CPI 与 Amdahl:把 perf 数字代进公式

这一节回答:perf stat 给出 cycles、instructions、cache-misses,怎样判断该优化哪一项?优化后能快多少?

CPI 可以按「基础 + 各类停顿」分解:

CPI=CPIbase+∑i(每次指令的事件率i×惩罚i). \text{CPI} = \text{CPI}_{\text{base}} + \sum_i \left(\text{每次指令的事件率}_i \times \text{惩罚}_i\right).

假设 perf stat 给出:cycles 3.2×1093.2\times10^9、instructions 2.56×1092.56\times10^9、branches 4×1084\times10^8、branch-misses 2×1072\times10^7、LLC-load-misses 8×1068\times10^6。先算 IPC =2.56/3.2=0.8=2.56/3.2=0.8,CPI =1.25=1.25。再把停顿拆开(惩罚取 Skylake 级典型值):

项次数每次惩罚(拍)周期占 cyclesCPI 贡献
基础(无停顿)——1.3×1091.3\times10^940.6%0.508
分支误预测2×1072\times10^7150.3×1090.3\times10^99.4%0.117
LLC 缺失8×1068\times10^62001.6×1091.6\times10^950.0%0.625
合计——3.2×1093.2\times10^9100%1.250

三项相加正好回到 1.25,说明这个模型自洽。现在用 Amdahl 判断优化收益:

优化新 cycles加速比Amdahl 解释
LLC 缺失延迟减半2.4×1092.4\times10^91.33p=0.5,s=2⇒1/(0.5+0.25)p=0.5, s=2 \Rightarrow 1/(0.5+0.25)
消除分支误预测2.9×1092.9\times10^91.10p=0.094,s→∞⇒1/(1−0.094)p=0.094, s\to\infty \Rightarrow 1/(1-0.094)
两者都做2.1×1092.1\times10^91.52剩余 0.656,两项收益叠加

结论很直接:先打占 cycles 一半的 LLC 缺失。把分支预测准确率从 95% 提到 99% 只值 10%,而把 LLC 缺失代价减半值 33%。

perf stat -e cycles,instructions,branches,branch-misses,\
cache-references,cache-misses,LLC-loads,LLC-load-misses ./prog

事件名因 PMU 而异,先用 perf list 确认;没有 LLC-load-misses 时用 cache-misses 并注明它对应哪一层。

现象可能瓶颈下一步
IPC < 0.8,LLC 缺失高访存分块、预取、压缩数据结构、大页
branch-misses 占比高控制流PGO/BOLT、无分支写法、排序数据
stalled-cycles-frontend 高前端减小热代码、减少间接调用、LTO
stalled-cycles-backend 高后端拆依赖链、看端口压力
CPI 与 Amdahl 的六个坑
  1. “CPI 一定大于等于 1”——多发射与 SMT 下 CPI 可以小于 1;用 IPC 更直观,但跨微架构比较仍要看具体指令流。
  2. “perf stat 的数字是精确的”——事件多路复用(multiplexing)时计数按比例缩放,看输出里的 % 与 scaled 标记。
  3. “cycles 可以直接跨机器比较”——频率、turbo、降频、SMT 都会影响;比较 IPC 或同一机器上的相对值。
  4. “cache-misses 就是 LLC 缺失”——不同 PMU 上含义不同;显式写 LLC-load-misses 或 L1-dcache-load-misses。
  5. “Amdahl 的 p 是代码占比”——必须是时间占比;按代码行数估 p 会得出错误上限。
  6. “优化一项就一定能加速”——若该项与另一项共享资源(如端口、带宽),收益可能被抵消;改完要重新测。

1.1.23 跨架构 hello:三份 ELF 的同与不同

这一节回答:同一份 C 编译到 x86-64、ARM64、RISC-V,哪些东西变了、哪些没变?怎样用一条命令把差异列成表?

1.1.15 给了交叉编译命令与 QEMU 运行方式。把三份二进制的 ELF 事实并排看:

维度x86-64ARM64RISC-V 64
e_machine62183243
动态链接器/lib64/ld-linux-x86-64.so.2/lib/ld-linux-aarch64.so.1/lib/ld-linux-riscv64-lp64d.so.1
系统调用指令syscallsvc #0ecall
write 编号16464
exit_group 编号2319494
参数寄存器rdi,rsi,rdx,r10,r8,r9x0–x5a0–a5
典型页大小4 KiB4 KiB(部分 16 KiB)4 KiB
动态 hello 体积~16 KiB~16 KiB~16 KiB

一条脚本把上表的大部分字段打出来:

for f in hello-x86_64 hello-arm64 hello-riscv64; do
  echo "== $f"
  llvm-readelf -h "$f" | awk -F: '/Machine|Entry point/{gsub(/^ +/,"",$2); print $1": "$2}'
  llvm-readelf -l "$f" | awk '/interpreter/{print "interp: "$NF}'
  llvm-readelf -d "$f" | awk '/NEEDED/{print "needed: "$NF}'
done

用 llvm-readelf 而不是 GNU readelf,是因为后者可能只编入了本机架构支持;llvm-readelf 能解析三种目标文件。

去掉 libc 之后,hello 可以小到 4–6 KiB:

cc -nostdlib -static hello-x86_64.S -o hello-x86_64-static
aarch64-linux-gnu-gcc -nostdlib -static hello-arm64.S -o hello-arm64-static
riscv64-linux-gnu-gcc -nostdlib -static hello-riscv64.S -o hello-riscv64-static
ls -l hello-*-static
qemu-aarch64 -strace ./hello-arm64-static

典型体积是 4–6 KiB;文件本身不必是页的整数倍,但每个 PT_LOAD 段加载时按页对齐。-strace 会打印 write(1,0x...,6) = 6 与 exit(0),这就是「ISA 变、ABI 变、系统调用号变,但抽象不变」的直接证据。

跨架构构建的六个坑
  1. “clang --target=... hello.c -o hello 一定能链接”——没有 sysroot 时只能 -S/-c;链接会报找不到 crt1.o/-lc。1.1.15 的命令需要 sysroot 或改用交叉 gcc。
  2. “GNU readelf 什么架构都认”——发行版可能裁掉非本机目标;用 llvm-readelf 或交叉 binutils。
  3. “QEMU 用户态要加 -L 才能跑”——-L 是给动态链接器找 sysroot 的;静态二进制不需要,加了反而可能加载错库。
  4. “LP64 下 char 都有符号”——x86-64 上 char 默认有符号,ARM64 上默认无符号;跨架构序列化要显式用 signed char/uint8_t。
  5. “系统调用号可以硬编码”——write 在 x86-64 是 1、ARM64/RISC-V 是 64;用 SYS_write 或 libc。
  6. “静态 hello 是裸二进制”——它仍是 ELF,有程序头与入口 _start;体积最小也在 KiB 级,不是 13 字节。

1.1.24 本章出口检查

检查项达标线没达标去哪
说出编译四个阶段的输入输出能写出 -E/-S/-c 与链接命令1.1.2
区分节与段能解释 readelf -S 与 -l 的差别1.1.3
解释 fork 后为什么有两份返回值能说出父子进程各自的执行路径1.1.4
画出进程地址空间布局标出 text/data/heap/mmap/stack 的权限1.1.5
用数量级比较寄存器、DRAM、SSD、网络误差不超过一个数量级1.1.6
用 Amdahl 定律算一次加速比会算上限,能判断瓶颈1.1.9
读重定位表能算 PC32/PLT32 的字段值1.1.18
解释 lazy binding能指出 PLT/GOT 的两次绑定1.1.19
读 auxv能说出 AT_PHDR/AT_RANDOM 的用途1.1.20
即时自测 · 1.1

Q1 gcc hello.c -o hello 之后,hello 里是否包含 printf 的机器码?为什么?

Q2 fork() 返回 0 的进程和返回正数的进程,谁的地址空间是「新」的?COW 在什么时候真正复制内存?

Q3 一个程序 80% 的时间在等网络,20% 在算。把计算部分并行到 8 核,整体最多快多少?若把网络等待改成异步,理论上限是多少?

Q4 strace ./hello 看到的第一个 write 是 printf 直接发出的吗?中间还经过了什么?

答案

A1 通常不包含。printf 的实现在动态库 libc.so.6 里,可执行文件只保留一个 PLT 桩和符号引用,运行时由动态链接器解析。静态链接(-static)时才会把实现复制进来。

A2 子进程的地址空间是「新」的(逻辑上独立),但 fork 后父子共享物理页,页表项被标记为只读;任一方写入触发写保护缺页,内核才复制那一页。COW 的粒度是页(4 KiB),不是整个地址空间。

A3 串行比例 80%,计算部分加速 8 倍:S=1/(0.8+0.2/8)=1/0.825=1.21S = 1/(0.8 + 0.2/8) = 1/0.825 = 1.21。若网络等待完全消除(相当于那 80% 被加速到无穷),上限是 1/0.2=51/0.2 = 5。这说明异步 I/O 的收益远大于多核计算——但前提是等待真的能被重叠。

A4 不是。printf 先把格式化后的字节写入 stdio 缓冲区;stdout 连终端时行缓冲,遇到 \n 调用 write(1, ...);若重定向到文件,可能攒满 4 KiB 才调用。strace 看到的是 write 系统调用,不是 printf 函数调用。

母本对应

母本章节本书落点程度
C(CSAPP)第 1 章 计算机系统漫游(1.1–1.9)1.1.1–1.1.9完整:编译四阶段、ELF/段、进程、存储层次、OS 抽象、Amdahl
C(CSAPP)第 1 章 1.10 小结1.1.10改写为工具链证据链,不摘录
C(CSAPP)第 7 章 链接(7.1–7.13)1.1.17–1.1.19、1.1.23扩展:重定位、PLT/GOT、LD_DEBUG、三架构 ELF
C(CSAPP)第 8 章 异常控制流(8.2–8.4)1.1.20扩展:_start、auxv
C(CSAPP)第 6 章 6.1–6.2 存储技术与局部性1.1.21扩展:Little 定律、指针追逐
C(CSAPP)第 1 章 1.9、第 5 章 5.7(Amdahl/现代处理器)1.1.22扩展:CPI 分解与 perf
O(OSTEP)第 2 章 操作系统介绍(2.1–2.5)1.1.7背景:虚拟化 CPU/内存、并发、持久性四主题
O(OSTEP)第 4 章 抽象:进程1.1.4背景:进程状态与上下文切换
L(李治军)第 1 章 系统启动(1.1–1.5)1.1.4、1.1.10背景:启动与执行的第一手观察,详见 1.13
R(奔跑吧)第 1 章 处理器体系结构1.1.5背景:指令周期与流水线,详见 1.4
K(Kerrisk)第 2 章 基本概念(2.1–2.7)1.1.4、1.1.7精选:内核/shell/进程/文件 I/O 模型
K(Kerrisk)第 3 章 系统编程概念(3.1–3.4)1.1.7、1.1.10精选:系统调用、错误处理
W(谢希仁)第 1 章 概述(1.1–1.7)1.1.8背景:分层、封装、时延,详见 1.11

下一站:hello 里的字符在内存中到底怎么排?0x0a 和整数 10 是什么关系?进入 1.2 信息的表示与运算,从位、字节和补码开始。