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 只认识一种东西——按特定格式编码的指令与数据。从文本到那种格式,需要一整套翻译流程。
把 C 源文件翻译成可执行目标文件的程序集合,通常由预处理器(cpp)、编译器(cc1)、汇编器(as)和链接器(ld)四部分组成。GCC 驱动程序 gcc 按文件后缀和选项依次调用它们,本身不做翻译。
1.1.2 四个阶段:预处理、编译、汇编、链接
gcc hello.c -o hello 一条命令背后是四个阶段。用 gcc -v 可以看到它实际调用的子命令;用 -E、-S、-c 可以让流程停在任意一步。
| 阶段 | 输入 | 输出 | 谁来做 | 主要工作 |
|---|---|---|---|---|
| 预处理 | .c + 头文件 | .i | cc1 -E | 展开 #include、#define,处理条件编译,删注释 |
| 编译 | .i | .s | cc1 | 词法/语法/语义分析,生成中间表示,优化,生成汇编 |
| 汇编 | .s | .o | as | 把助记符编码成机器指令,生成可重定位目标文件 |
| 链接 | .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 做的是三件事:
fork():复制出一个子进程,子进程几乎拥有父进程的一份副本;execve("./hello", argv, envp):在子进程里装入hello,替换掉原来的地址空间;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 / execve | char *const argv[] | 否 | execve 显式传 |
execlp / execvp | 列表 / 数组 | 是 | 继承 |
execvpe | 数组 | 是 | 显式传 |
execlp("ls", "ls", "-l", (char *)NULL) 会沿 PATH 找 ls;execl("/bin/ls", ...) 不会。argv[0] 是程序自己看到的名字,可以与路径不同——这既是 busybox 用一份二进制模拟几百个命令的手法,也是恶意程序伪装进程名的入口。execve 是唯一的系统调用,其余都是 glibc 的包装。
vfork() 不复制页表,父进程阻塞到子进程 exec 或 _exit,子进程甚至共享父进程的地址空间。它曾用于「fork 后立刻 exec」的场景,但限制多、容易出错。现代代码优先用 posix_spawn():glibc 在可用时用 clone(CLONE_VM|CLONE_VFORK) 实现,语义比裸 vfork 安全,适合需要频繁创建子进程的服务。
- 「
exec创建新进程」——不创建。execve替换当前进程的地址空间、寄存器、信号处理方式,pid 不变。创建进程的是fork/clone。 - 「
fork立刻复制整个地址空间」——现代 Linux 用写时复制(copy-on-write, COW),父子共享物理页,只有写时才复制。fork的成本主要是页表复制,不是内存复制。 - 「程序退出码就是
main的返回值」——main的返回值经exit截断为 8 位(status & 0xff)。返回 256 和返回 0 在 shell 里看到的一样。 - 「
./hello是 shell 读文件并执行」——shell 不解析 ELF,它只调用execve;解析和执行都是内核与动态链接器的事。
进程状态与上下文切换
hello 不会从第一行一直执行到退出。它要和终端、磁盘、其他进程竞争 CPU,内核必须能随时暂停它、稍后从同一点恢复。Linux 用一组状态描述这件事,最常遇到的是:
| 状态 | ps 显示 | 含义 | 谁把它唤醒 |
|---|---|---|---|
TASK_RUNNING | R | 正在 CPU 上,或在运行队列里等 CPU | 调度器 |
TASK_INTERRUPTIBLE | S | 可中断睡眠,等事件或信号 | 事件、信号 |
TASK_UNINTERRUPTIBLE | D | 不可中断睡眠,通常等块设备 I/O | I/O 完成 |
__TASK_STOPPED | T | 被 SIGSTOP/调试器暂停 | SIGCONT |
EXIT_ZOMBIE | Z | 已退出,等父进程 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)。读汇编不是为了手写汇编,而是为了验证编译器是否按你的意图生成代码。
进程是程序的一次执行实例。内核为它维护:独立的虚拟地址空间、一组寄存器上下文(尤其 %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。数量级差距是存储层次存在的理由:把最近用过的数据放在更近、更小、更快的介质上。
读图时抓住两条线:纵向是容量与延迟的数量级,横向是局部性。时间局部性让「刚读过的变量」留在 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 访问相对于一次加法,相当于一分半钟;一次网络往返相当于几天。 任何「多访问一次内存/网络无所谓」的假设,都经不起这张表的检验。
缓存无法预知你下一次访问哪个地址,它只能利用「过去访问过的、附近的地址很可能再被访问」这一统计规律。所以缓存对顺序访问友好,对随机访问差;对紧凑数据友好,对指针追逐差。优化缓存命中率,本质是把访问模式改造成缓存能猜中的形状。
- 「缓存越大程序越快」——只在工作集大于当前缓存、且访问模式有局部性时成立。一个纯随机访问的大哈希表,扩大 L3 也可能毫无收益;反过来,把热数据压进 L2 往往比换更大 L3 更有效。
- 「SSD 没有寻道,所以随机读和顺序读一样快」——SSD 没有机械寻道,但仍有页/块粒度、FTL 映射、读放大和并发队列限制。4 KiB 随机读的 IOPS 远高于 HDD,但顺序大块读的吞吐通常仍是随机读的数倍。
- 「CPU 频率高就一定快」——访存密集型程序的瓶颈在内存延迟/带宽,不在主频;提高频率甚至可能让功耗上升、频率回落。判断瓶颈要看
perf stat的 IPC 与 stall 原因,而不是只看 GHz。
1.1.7 操作系统的三个抽象
操作系统把硬件包装成三个抽象,程序只跟抽象打交道:
| 抽象 | 内核对象 | 解决什么问题 | 典型接口 |
|---|---|---|---|
| 进程 | task_struct | 让每个程序以为自己独占 CPU | fork、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 → 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 定律:优化哪一部分
假设一个程序里比例 的部分能被加速 倍,其余 不变,则总加速比为
令 ,上限是 。这个上限比公式本身更有用:它告诉你「不管投入多少并行资源,最多能快多少」。
具体数字:若 ,用 4 核并行,;即使核数无限,。若 ,4 核得到 ,上限 20。串行部分决定了天花板,这就是为什么「加机器」经常不解决问题。
| 串行比例 | 4 核加速比 | 16 核加速比 | 无限核上限 |
|---|---|---|---|
| 40% | 1.82 | 2.29 | 2.50 |
| 10% | 3.08 | 6.40 | 10.0 |
| 5% | 3.48 | 9.14 | 20.0 |
| 1% | 3.88 | 13.9 | 100 |
最后一行说明:想让 16 核跑出 13.9 倍,串行部分必须压到 1% 以下。这也是并发编程里「锁的临界区要短」「I/O 要异步」的量化依据。
某服务单请求耗时 100 ms,其中 JSON 序列化 60 ms、数据库 30 ms、其余 10 ms。把序列化加速 3 倍,总时间变为 ms,加速比 。把数据库加速 3 倍,总时间 ms,加速比 。若两部分都加速 3 倍,总时间 ms,加速比 。先测量,再决定动哪一段——这就是 perf 存在的意义。
并发(concurrency)与并行(parallelism)不是同义词:并发是「多个任务在时间上重叠」,单核分时也能并发;并行是「多个任务在同一时刻真正同时执行」,需要多核或 SIMD。Amdahl 定律约束的是并行带来的收益;并发更多解决的是「等待 I/O 时别闲着」,它的收益模型是 Little 定律与吞吐量,不是单请求延迟。
并行的三个层次
同一段代码可以从三个层次榨取并行度,收益与成本依次上升:
| 层次 | 手段 | 典型加速 | 主要障碍 |
|---|---|---|---|
| 指令级(ILP) | 流水线、多发射、乱序执行 | 2–6 倍 | 数据依赖、分支、寄存器压力 |
| 数据级(DLP) | SSE/AVX、GPU | 4–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,说「它启动慢,而且偶尔输出丢失」,可以按下面的顺序取证。
- 看类型与依赖:
file prog、ldd prog。确认架构、是否动态链接、依赖哪些库;ldd对不可信二进制不要直接跑(它会触发加载器),可用objdump -p prog | grep NEEDED。 - 看节与段:
readelf -S prog、readelf -l prog。确认.text大小、是否有大.bss、LOAD段的权限是否异常(可写又可执行就是危险信号)。 - 看入口与启动路径:
readelf -h prog的Entry point,objdump -d --start-address=...。动态链接的程序入口在ld.so,真正的main由__libc_start_main调用。 - 看运行时系统调用:
strace -f -T -tt -o trace.log ./prog。按耗时排序找慢调用;strace -c先看总量。注意strace会放大开销,别用它测绝对性能。 - 看 CPU 与缓存:
perf stat -e cycles,instructions,cache-references,cache-misses,branch-misses ./prog;perf record -g ./prog后perf report找热点函数。 - 看内存与缺页:
/usr/bin/time -v ./prog的Maximum resident set size、minor/major page faults;/proc/<pid>/smaps看具体映射。
一个具体的「输出丢失」案例:程序用 printf 打日志,重定向到文件后最后几行不见了,终端上却正常。证据链是:
| 现象 | 工具 | 证据 | 结论 |
|---|---|---|---|
| 终端正常、文件丢尾 | strace -e write | 文件模式下 write 次数少、最后没有 flush | stdio 全缓冲 |
| 进程异常退出 | 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) = ?
读这条时间线要抓五个动作:
execve之前:shell 已经fork出子进程,子进程调用execve;内核验证 ELF、建立新地址空间、把argc/argv/envp放到新栈顶。- 动态链接器:内核先跳到
ld-linux-x86-64.so.2的入口;它读/etc/ld.so.cache、加载libc.so.6、做重定位、填 GOT,然后把控制权交给_start。 _start→__libc_start_main→main:_start从栈上取argc/argv,把main、init、fini传给 libc;libc 初始化 TLS、stdio、atexit链,再调用main。printf不是一次write:它先把 13 字节写进 stdio 缓冲区;遇到换行且 stdout 是终端(行缓冲)才write(1, ...)。如果重定向到文件,缓冲区是 4 KiB/8 KiB,可能要等exit或缓冲区满才写。exit_group:return 0从main返回,__libc_start_main调exit(0),运行atexit/fclose,最后exit_group结束所有线程。_exit不会做这些。
./hello不是「运行一个文件」:是 shellfork+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'
ldd会执行动态链接器,不要对不可信二进制用;readelf -d更安全。RPATH优先于LD_LIBRARY_PATH,RUNPATH相反;新代码用RUNPATH(-Wl,-rpath默认)。LD_PRELOAD只能替换动态符号;静态链接或内联后的调用替换不了。-static不是「更安全」:libc 的 NSS、locale、DNS 仍可能需要动态模块;静态二进制也更大、更新更麻烦。- 符号版本:
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/-r | ELF 头、节、段、重定位、符号 |
为什么 main 不是入口? | readelf -h 的 Entry、gdb _start | 入口是 _start,libc 再调 main |
printf 何时真正写? | strace -e write | 行缓冲/全缓冲决定,未必立即 |
| 动态库怎么找到? | ldd、LD_DEBUG=libs、readelf -d | RPATH → LD_LIBRARY_PATH → RUNPATH → ld.so.cache → 默认目录 |
| 为什么静态链接仍依赖 NSS? | ldd、strace openat | 名字服务/ locale 可能 dlopen |
- 把
main当入口,忽略_start/__libc_start_main。 - 以为
printf一定立刻write,用_exit导致缓冲区丢失。 - 把
.bss当文件占用,惊讶于大数组不增大二进制。 - 用
ldd检查不可信二进制(它会执行 ld.so)。 - 在旧系统运行新 glibc 编译的二进制,遇到
GLIBC_2.xx not found。 - 以为
-static完全无动态依赖(NSS/locale 仍可能)。 - 用
LD_PRELOAD替换内联/静态链接的函数,失败。 - 把
argc/argv的栈布局记成「argv 在 argc 前面」。 - 忽略
AT_RANDOM/ASLR,对栈地址做硬编码。 - 用
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.c | 6 行 / 约 90 B | 源码 | 你 |
hello.i | 700–1000 行 / 18–28 KiB | stdio.h 及其传递包含的声明 | cc1 -E |
hello.s | 30–60 行 / 1–2 KiB | 优化后的汇编 | cc1 |
hello.o | 1.3–2 KiB / 5–8 个节 | 指令、只读数据、符号表、重定位 | as |
hello(动态) | 15–17 KiB | ELF 头、代码、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-* 选项,而不是盲目换机器。
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 行数翻倍。
- “
-###已经编译了”——它只打印命令并退出;真正执行的是-v或不带-###的命令。 - “
-save-temps不会影响构建”——它在当前目录写hello.i/.s/.o;并行构建同一目录会互相覆盖,改用-save-temps=obj把中间文件放到输出目录。 - “
cc就是 gcc”——cc可能是 clang;clang 的-###输出结构与 GCC 不同,脚本不要解析它,解析-###只在人工排障时可靠。 - “
-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,链接器计算:
A = -4 不是笔误:x86 的 RIP 相对位移是「目标 − 下一条指令地址」,而 P 指向字段起点,字段起点比下一条指令早 4 字节,所以用 -4 补齐。call puts@plt 位于 0x1158,长度 5,下一条在 0x115d;设 puts@plt 在 0x1050,则
机器码是 e8 f3 fe ff ff。反汇编显示 call 1050 <puts@plt>,与计算一致。
不同架构的重定位类型与范围:
| 架构 | 类型 | 用途 | 计算/范围 |
|---|---|---|---|
| x86-64 | R_X86_64_PC32 | RIP 相对 32 位 | ,±2 GiB |
| x86-64 | R_X86_64_PLT32 | 调用 PLT 桩 | 指向 PLT,±2 GiB |
| x86-64 | R_X86_64_GOTPCREL | 取 GOT 项地址 | -fPIC 后常见 |
| ARM64 | R_AARCH64_ADR_PREL_PG_HI21 | ADRP 的页基址 | 页对齐,±4 GiB |
| ARM64 | R_AARCH64_CALL26 | BL 调用 | ±128 MiB |
| RISC-V | R_RISCV_CALL_PLT | AUIPC+JALR 对 | ±2 GiB |
| RISC-V | R_RISCV_BRANCH | 条件分支 | ±4 KiB |
超出范围时链接器报 relocation truncated to fit。这不是罕见错误:内核模块、巨大静态二进制、把代码链接到高地址都会触发。
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)。「链接后没有重定位」是错觉——静态重定位被链接器消化了,动态重定位还在。
- “Addend 的 -4 是编译器 bug”——它是 RIP 相对寻址的定义决定的;不同指令长度对应不同的
P。 - “可执行文件没有重定位”——PIE/动态链接的可执行文件仍有
.rela.dyn;readelf -r看得到。 - “
-fPIC只是编译选项,不影响链接”——它改变代码生成(GOT/PLT),也改变重定位类型与数量。 - “REL 与 RELA 可以混用”——x86-64 用 RELA、i386 用 REL;手写链接器/加载器时必须按架构处理加数。
- “重定位溢出只会发生在大程序”——把
.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):
| 顺序 | 来源 | 何时生效 | 备注 |
|---|---|---|---|
| 1 | DT_RPATH | 没有 DT_RUNPATH 时 | 已废弃 |
| 2 | LD_LIBRARY_PATH | 非 secure-execution | 开发调试常用 |
| 3 | DT_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,而不是「找不到符号」——两者根因不同。
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 能看到它。
- “
LD_LIBRARY_PATH优先于 RUNPATH”——顺序是 RPATH →LD_LIBRARY_PATH→ RUNPATH;LD_LIBRARY_PATH确实压过 RUNPATH,别记反。 - “setuid 程序也认
LD_PRELOAD”——secure-execution 模式忽略LD_PRELOAD/LD_LIBRARY_PATH;但 RPATH/RUNPATH 仍可能被利用,提权程序要避免从可写目录加载库。 - “
LD_PRELOAD能替换任何函数”——只能替换动态符号;静态链接、内联、-Bsymbolic绑定、版本不匹配都可能让它失效。 - “
dlopen的符号全局可见”——默认RTLD_LOCAL;要让后续库看到,得用RTLD_GLOBAL,但会引入符号冲突。 - “
-z now只有好处”——它把解析成本移到启动,并让 GOT 只读;对启动时间敏感的程序要实测。 - “
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_RANDOM | 16 字节随机数地址 | 栈保护 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 再判断,否则会读到上一次调用的残留值。
- “
argc至少是 1”——execve可以传空argv,此时argc=0、argv[0]=NULL;无条件strlen(argv[0])会崩。 - “
envp = argv + argc”——中间还有一个NULL终止符,正确写法是argv + argc + 1。 - “
AT_RANDOM是字符串”——它是 16 字节二进制,按%s打印会越界。 - “手写
_start可以直接ret”——入口没有返回地址;必须exit/exit_group,否则跳到未定义地址。 - “栈对齐无所谓”——x86-64 在
call前必须 16 字节对齐;main里的movaps会对未对齐地址触发SIGSEGV。 - “
getauxval返回 0 就是值 0”——0 也可能是合法值;用errno区分「不存在」与「值为 0」。
1.1.21 存储层次的并发度:Little 定律与指针追逐
这一节回答:既然 DRAM 要 80 ns,处理器靠什么把带宽跑满?为什么「多开几个线程」有时没用,有时又必须开?
1.1.6 给了延迟表,但延迟只说明「一次访问等多久」,不说明「同时能等多少次」。把系统看成排队网络,Little 定律给出:
要让 25 GB/s 的 DRAM 跑满,在 80 ns 延迟下必须有 字节在途,也就是约 31 条 64 B 的 cache line。CPU 的 miss 状态寄存器(MSHR)与乱序窗口就是用来维持这个并发度的;一旦超发,多出来的请求只会排队。
| 场景 | 延迟 | 吞吐/带宽 | 在途量 |
|---|---|---|---|
| L1 命中 | 1.3 ns(4 拍 @3 GHz) | ~200 GB/s | ~260 B(4 条 line) |
| DRAM | 80 ns | 25 GB/s | ~2000 B(31 条 line) |
| NVMe 随机读 | 20 µs | 500k IOPS | 10 个请求 |
| 1 GbE | 0.5 ms RTT | 125 MB/s | ~62.5 KB |
| 10 GbE | 0.5 ms RTT | 1.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 MiB | 2–4 | L2/L3 |
| 8 MiB | 10–20 | L3 |
| 64 MiB | 80–100 | DRAM |
| 1 GiB | 100–150 | DRAM + TLB miss |
如果 64 MiB 的结果明显高于 100 ns,先查 NUMA 绑定与是否被其他进程干扰;如果 1 GiB 的结果与 64 MiB 相同,说明大页或 TLB 覆盖很好。不要用顺序遍历测延迟——预取器会把 DRAM 延迟藏起来。
- “
volatile能绕过 cache”——volatile只阻止编译器优化,不改变 cache 行为;要测内存延迟用依赖链,不是加volatile。 - “顺序访问和随机访问差不了多少”——顺序访问有预取与行填充,随机访问每次都可能 miss;差距是数量级。
- “大页一定更快”——大页减少 TLB miss,但会增加内部碎片与分配延迟;小工作集用大页可能更慢。
- “
clock_gettime没有开销”——vDSO 快路径也要几十纳秒;测纳秒级事件要循环多次摊薄,或用rdtscp并序列化。 - “容器里的 cache 是独占的”——L3 通常被同物理机的其他容器共享,测量噪声大;固定 CPU、避开邻居才能得到稳定数字。
1.1.22 CPI 与 Amdahl:把 perf 数字代进公式
这一节回答:perf stat 给出 cycles、instructions、cache-misses,怎样判断该优化哪一项?优化后能快多少?
CPI 可以按「基础 + 各类停顿」分解:
假设 perf stat 给出:cycles 、instructions 、branches 、branch-misses 、LLC-load-misses 。先算 IPC ,CPI 。再把停顿拆开(惩罚取 Skylake 级典型值):
| 项 | 次数 | 每次惩罚(拍) | 周期 | 占 cycles | CPI 贡献 |
|---|---|---|---|---|---|
| 基础(无停顿) | — | — | 40.6% | 0.508 | |
| 分支误预测 | 15 | 9.4% | 0.117 | ||
| LLC 缺失 | 200 | 50.0% | 0.625 | ||
| 合计 | — | — | 100% | 1.250 |
三项相加正好回到 1.25,说明这个模型自洽。现在用 Amdahl 判断优化收益:
| 优化 | 新 cycles | 加速比 | Amdahl 解释 |
|---|---|---|---|
| LLC 缺失延迟减半 | 1.33 | ||
| 消除分支误预测 | 1.10 | ||
| 两者都做 | 1.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 一定大于等于 1”——多发射与 SMT 下 CPI 可以小于 1;用 IPC 更直观,但跨微架构比较仍要看具体指令流。
- “
perf stat的数字是精确的”——事件多路复用(multiplexing)时计数按比例缩放,看输出里的%与scaled标记。 - “cycles 可以直接跨机器比较”——频率、turbo、降频、SMT 都会影响;比较 IPC 或同一机器上的相对值。
- “
cache-misses就是 LLC 缺失”——不同 PMU 上含义不同;显式写LLC-load-misses或L1-dcache-load-misses。 - “Amdahl 的 p 是代码占比”——必须是时间占比;按代码行数估 p 会得出错误上限。
- “优化一项就一定能加速”——若该项与另一项共享资源(如端口、带宽),收益可能被抵消;改完要重新测。
1.1.23 跨架构 hello:三份 ELF 的同与不同
这一节回答:同一份 C 编译到 x86-64、ARM64、RISC-V,哪些东西变了、哪些没变?怎样用一条命令把差异列成表?
1.1.15 给了交叉编译命令与 QEMU 运行方式。把三份二进制的 ELF 事实并排看:
| 维度 | x86-64 | ARM64 | RISC-V 64 |
|---|---|---|---|
e_machine | 62 | 183 | 243 |
| 动态链接器 | /lib64/ld-linux-x86-64.so.2 | /lib/ld-linux-aarch64.so.1 | /lib/ld-linux-riscv64-lp64d.so.1 |
| 系统调用指令 | syscall | svc #0 | ecall |
write 编号 | 1 | 64 | 64 |
exit_group 编号 | 231 | 94 | 94 |
| 参数寄存器 | rdi,rsi,rdx,r10,r8,r9 | x0–x5 | a0–a5 |
| 典型页大小 | 4 KiB | 4 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 变、系统调用号变,但抽象不变」的直接证据。
- “
clang --target=... hello.c -o hello一定能链接”——没有 sysroot 时只能-S/-c;链接会报找不到crt1.o/-lc。1.1.15 的命令需要 sysroot 或改用交叉 gcc。 - “GNU
readelf什么架构都认”——发行版可能裁掉非本机目标;用llvm-readelf或交叉 binutils。 - “QEMU 用户态要加
-L才能跑”——-L是给动态链接器找 sysroot 的;静态二进制不需要,加了反而可能加载错库。 - “LP64 下
char都有符号”——x86-64 上char默认有符号,ARM64 上默认无符号;跨架构序列化要显式用signed char/uint8_t。 - “系统调用号可以硬编码”——
write在 x86-64 是 1、ARM64/RISC-V 是 64;用SYS_write或 libc。 - “静态 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 |
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 倍:。若网络等待完全消除(相当于那 80% 被加速到无穷),上限是 。这说明异步 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 信息的表示与运算,从位、字节和补码开始。