【程序员自我修养】第二章:编译和链接——gcc 背后的 4 把刀
第二章:编译和链接
你有没有好奇过:你按下回车执行
gcc hello.c,背后到底跑了多少个程序?答案是 至少 4 个:预处理器、编译器、汇编器、链接器。它们接力赛跑,把 5 行 C 代码变成一个能在屏幕上打印字符的可执行文件。
这一章,我们就来把这 4 把刀拆开看个清清楚楚。
2.0 前言:被隐藏的过程
很多教程教 C 语言,开篇就是 gcc hello.c -o hello,然后告诉你”这就是编译”。这等于把一辆汽车的制造过程说成”工厂造了一辆车”——掩盖了所有有趣的细节。
实际上,gcc hello.c 内部至少跑了 4 个独立程序:
| 阶段 | 程序 | 输入 | 输出 | 工作 |
|---|---|---|---|---|
| 预编译 | cpp / cc1 -E | .c 源文件 | .i 预处理文件 | 宏展开、头文件包含、条件编译 |
| 编译 | cc1 | .i 预处理文件 | .s 汇编文件 | 词法/语法/语义分析 → 汇编代码 |
| 汇编 | as | .s 汇编文件 | .o 目标文件 | 汇编指令翻译、生成机器码 |
| 链接 | ld | 多个 .o | 可执行文件 | 符号决议、重定位、合并段 |
这一章我们要亲手把这 4 个产物全部生成一遍,并用 readelf、objdump 工具逐字节地拆开看。
读完后,你能:
- 解释
gcc调用的每一个子工具是干什么的 - 区分预编译、编译、汇编、链接四个阶段
- 用
gcc -E / -S / -c拿到每个阶段的中间产物 - 用
readelf读懂 ELF 文件的每个段 - 解释为什么多文件项目必须链接,链接器做了什么
2.1 被隐藏了的过程
2.1.1 一个反常识的事实
反常识 1:gcc 不是编译器,而是”编译驱动器”(compiler driver)。
gcc 本质上是一个协调者,它根据参数决定调用哪些子工具。如果你看了 GCC 源码(gcc/gcc.c),会发现 gcc 内部维护了一张超过 100 行的工具调用表——specs。
1 | # 看看 gcc 内部到底调用了什么 |
输出类似:
1 | cc1 -E -quiet hello.c -o /tmp/ccXXXXXX.i |
你看到了 3 个真实工具:cc1(编译器)、as(汇编器)、ld(链接器)。gcc 只是个壳。
2.1.2 完整的 4 步流程图
graph LR
A["📄 hello.c\nC 源文件"] -->|"预编译\ncpp/cc1 -E"| B["📄 hello.i\n预处理文件"]
B -->|"编译\ncc1"| C["📄 hello.s\n汇编文件"]
C -->|"汇编\nas"| D["📄 hello.o\n目标文件"]
D -->|"链接\nld + crt*.o + libc"| E["🚀 hello\n可执行文件"]
A -.->|".c"| A
E -.->|".out"| E
style A fill:#C7CEEA,stroke:#9FA8DA,stroke-width:2px,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,stroke-width:2px,color:#333
style C fill:#FFDAB9,stroke:#FFAB76,stroke-width:2px,color:#333
style D fill:#FFF9C4,stroke:#F9A825,stroke-width:2px,color:#333
style E fill:#B5EAD7,stroke:#80CBC4,stroke-width:2px,color:#333关键洞察:每个阶段产物的”性格”完全不同。
.c是给人看的.i是给编译器看的预处理结果(已经不能直接编译回源码).s是给汇编器看的汇编助记符.o是给链接器看的 ELF 目标文件- 可执行文件是给操作系统加载器看的
2.1.3 用 -E / -S / -c 拆解每一步
GCC 提供 4 个神奇的参数,让我们能手动拆解这 4 步:
1 | # 准备一个简单的 hello.c |
看到没?一个
gcc命令 = 4 个阶段。你可以用参数拆开,也可以用gcc hello.c一次性跑完。
2.1.4 4 个阶段的”性格”对比
| 阶段 | 输入 | 输出 | 工具 | 主要工作 | 是否可逆 |
|---|---|---|---|---|---|
| 预编译 | .c | .i | cpp | 文本替换 | ❌ 宏展开后无法还原 |
| 编译 | .i | .s | cc1 | 高级语言 → 汇编 | ❌ 优化后无法还原 |
| 汇编 | .s | .o | as | 汇编 → 机器码 | ✅ 几乎可逆(objdump -d) |
| 链接 | 多个 .o | 可执行文件 | ld | 合并、填地址 | ❌ 静态库符号已混合 |
生活类比:预编译 = 把食材洗切配(菜谱还在),编译 = 按菜谱做菜(变成菜品),汇编 = 摆盘装盘(标准化),链接 = 配上米饭端上桌(最终产品)。
2.2 预编译(Preprocessing)
2.2.1 预编译做了什么
预编译是 4 个阶段里最简单的一步——它本质上是纯文本操作,不解析 C 语法。
预编译器(cpp)的工作清单:
| # | 工作 | 示例 |
|---|---|---|
| 1 | 展开 #include | #include <stdio.h> → 800+ 行 stdio.h 内容插入 |
| 2 | 展开宏定义 | #define PI 3.14 → 代码中所有 PI 替换为 3.14 |
| 3 | 处理条件编译 | #if / #ifdef / #else / #endif 决定保留哪部分 |
| 4 | 删除注释 | // xxx 和 /* xxx */ 全部删除 |
| 5 | 添加行标记 | 插入 # 1 "hello.c" 2 这样的标记用于调试 |
| 6 | 处理 #pragma | 传给编译器(如 #pragma once) |
| 7 | 处理 #error / #warning | 按需打印错误或警告 |
| 8 | 展开 # 字符串化、## 连接 | 高级宏技巧 |
2.2.2 亲手验证:宏展开
1 | #include <stdio.h> |
预编译后:
1 | gcc -E demo_macro.c -o demo_macro.i |
1 | # 1 "demo_macro.c" |
注意行标记:
# 1 "demo_macro.c"这种行号标记是给编译器报错误时用的——编译器在编译.i时如果出错,会通过这个标记把行号映射回.c。
2.2.3 条件编译:平台兼容的基石
条件编译是预编译器最强大的特性,没有之一。它让同一份代码能在 Linux/Windows/macOS 上编译出不同产物。
1 | #include <stdio.h> |
1 | gcc -E platform.c | grep -v '^#' | grep -v '^$' | head -20 |
关键洞察:预编译后的
.i文件完全没有#if这种语句了。#if是”给预编译器看的话”,不是”给 C 编译器看的”。
2.2.4 一个真实坑:宏的副作用
1 | #include <stdio.h> |
1 | 输出(如果 i=5): |
教训:宏不是函数。它只是文本替换。在生产代码里,优先用
static inline函数或 C++ 模板代替宏。
2.2.5 # 和 ## 高级用法
1 | #include <stdio.h> |
这种技巧在生成代码(如 Linux 内核的 container_of、标准库的 offsetof)里非常常见。
2.2.6 常用预编译参数
| 参数 | 作用 |
|---|---|
-E | 只预编译,输出到 stdout(需要 -o) |
-D MACRO | 定义宏,等价于源码里写 #define MACRO 1 |
-D MACRO=value | 定义宏并指定值 |
-U MACRO | 取消定义宏 |
-I dir | 添加头文件搜索路径 |
-nostdinc | 不搜索系统头文件路径 |
-include file | 强制包含某个头文件 |
-M | 输出依赖关系(Makefile 用) |
-MM | 同 -M,但忽略系统头文件 |
1 | # 实战:查看所有宏定义 |
输出会列出编译器内置的所有宏(__GNUC__、__linux__ 等),这就是为什么代码里能直接用这些宏。
2.3 编译(Compilation)
2.3.1 编译做了什么
编译是把”人话”翻译成”机器话”的核心阶段。它要处理:
- 词法分析(Lexical Analysis):字符流 → token
- 语法分析(Syntax Analysis):token → 抽象语法树(AST, Abstract Syntax Tree)
- 语义分析(Semantic Analysis):检查类型、作用域,做隐式转换
- 中间代码生成(IR Generation):生成与平台无关的中间表示
- 优化(Optimization):常量折叠、死代码删除、循环优化
- 目标代码生成(Code Generation):把 IR 转成目标平台汇编
2.3.2 编译的完整流程图
graph TB
A["📄 源文件\n.i 预处理文件"] --> B["🔍 词法分析\n字符流 → Token"]
B --> C["🌳 语法分析\nToken → AST"]
C --> D["🧠 语义分析\n类型检查、作用域"]
D --> E["⚙️ 中间代码生成\n生成 IR"]
E --> F["🚀 优化\n常量折叠、死代码删除"]
F --> G["💻 目标代码生成\nIR → 汇编"]
G --> H["📄 汇编文件\n.s"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#FFDAB9,stroke:#FFAB76,color:#333
style E fill:#FFF9C4,stroke:#F9A825,color:#333
style F fill:#FFF9C4,stroke:#F9A825,color:#333
style G fill:#B5EAD7,stroke:#80CBC4,color:#333
style H fill:#B5EAD7,stroke:#80CBC4,color:#3332.3.3 词法分析:把字符切成 token
词法分析是把源代码字符流切成一个个有意义的最小单元——token。
1 | int main(void) { |
词法分析后产生的 token 序列(伪代码):
1 | KEYWORD(int) |
GCC 用 flex 工具生成词法分析器(gcc/lex.c),输入正则表达式,输出 token。
2.3.4 语法分析:token 组成 AST
语法分析把 token 序列按语法规则组织成抽象语法树(AST)。
比如 x = 42 + 3.14 的 AST 是:
graph TB
Assign["= 赋值"] --> Id["x 标识符"]
Assign --> Add["+ 加法"]
Add --> Num1["42 整数"]
Add --> Num2["3.14 浮点"]
style Assign fill:#FFDAB9,stroke:#FFAB76,color:#333
style Id fill:#C7CEEA,stroke:#9FA8DA,color:#333
style Add fill:#E8D5F5,stroke:#CE93D8,color:#333
style Num1 fill:#FFF9C4,stroke:#F9A825,color:#333
style Num2 fill:#FFF9C4,stroke:#F9A825,color:#333GCC 用 bison 生成语法分析器(gcc/parse.c),输入 BNF 文法,输出 AST。
观察 AST 工具:
- GCC:
gcc -fdump-tree-original-raw hello.c(输出原始 AST 文本)- Clang:
clang -Xclang -ast-dump -fsyntax-only hello.c(输出可视化 AST)
2.3.5 语义分析:类型检查的”裁判”
语义分析是编译器最”聪明”的阶段。它检查:
- 变量是否声明过
- 类型是否匹配(
int + double自动转double) - 函数调用参数数量和类型
- 运算符是否对类型合法(不能用
%对浮点数) - 控制流是否合法(
break在循环外?) - 隐式转换是否合理
1 | #include <stdio.h> |
1 | gcc -fdump-tree-gimple semantic.c |
2.3.6 中间代码:与平台无关的”通用语”
中间代码(IR, Intermediate Representation) 是编译器的”通用语”。GCC 用 GIMPLE 和 RTL 两层 IR:
- GIMPLE:高级 IR,接近源码结构
- RTL(Register Transfer Language):低级 IR,接近汇编
1 | GIMPLE 例子(a = b + c * d): |
LLVM 用 LLVM IR:
1 | define i32 @main() { |
设计哲学:把”前端”(C/C++)和”后端”(x86/ARM/RISC-V)解耦。一个前端可以对接 N 个后端,添加新语言或新 CPU 都只需要实现一半。
2.3.7 优化:编译器比你聪明(有时候)
优化是编译中最复杂的部分。GCC 有 100+ 种优化 pass。
常用优化级别:
| 级别 | 作用 | 性能 | 编译时间 | 调试 |
|---|---|---|---|---|
-O0 | 不优化 | 基准 | 最快 | 容易 |
-O1 | 基础优化 | +30% | 较慢 | 还行 |
-O2 | 推荐优化 | +50% | 慢 | 较难 |
-O3 | 激进优化(向量化) | +70% | 很慢 | 难 |
-Os | 优化大小 | 视情况 | 慢 | 较难 |
-Oz | 更小 | 视情况 | 慢 | 较难 |
看一个优化的真实案例:
1 | int compute(int x) { |
1 | # O0:保留全部中间变量 |
反常识 2:编译器比人聪明,但没你想的那么聪明。
编译器擅长局部和中等范围的优化(如循环展开、寄存器分配)。但跨函数的全局优化需要 LTO(Link Time Optimization)才能做。
2.3.8 目标代码生成:IR → 汇编
最后,编译器把优化后的 IR 翻译成目标平台的汇编代码。这步涉及:
- 指令选择(Instruction Selection):IR 操作 → 目标 CPU 指令
- 寄存器分配(Register Allocation):把无限虚拟寄存器映射到有限的物理寄存器
- 指令调度(Instruction Scheduling):重排指令顺序以利用 CPU 流水线
- 栈帧布局(Stack Frame Layout):分配局部变量在栈上的位置
1 | .file "hello.c" |
观察细节:
.rodata段:只读数据,”Hello, World!” 在这.LC0:标签,本地常量@PLT:Procedure Linkage Table,动态链接时会用leaq .LC0(%rip), %rdi:把字符串地址放到第一个参数寄存器call puts@PLT:调用 puts 函数
2.3.9 编译器实例:GCC vs Clang vs MSVC
主流编译器对比表:
| 编译器 | 前端 | 后端 | 中间表示 | 平台 | 特点 |
|---|---|---|---|---|---|
| GCC | 自研 | 自研 | GIMPLE + RTL | 跨平台 | 老牌,生态最广 |
| Clang/LLVM | Clang | LLVM | LLVM IR | 跨平台 | 错误信息友好,模块化 |
| MSVC | 自研 | 自研 | MSIL | Windows | Windows 标配 |
| Intel ICC | 自研 | 自研 | 私有 | x86 专属 | 极致 x86 优化 |
| TCC | 自研 | 自研 | 无 IR | 跨平台 | 极小,可编译时执行 |
GCC 内部结构:
graph TB
subgraph "前端"
C["C 前端\ncc1"]
CPP["C++ 前端\ncc1plus"]
end
subgraph "中端"
GIMPLE["GIMPLE\n高级 IR"]
PASSES["优化 Pass\n100+ 种"]
end
subgraph "后端"
RTL["RTL\n低级 IR"]
X86["x86 后端"]
ARM["ARM 后端"]
RISCV["RISC-V 后端"]
end
C --> GIMPLE --> PASSES --> RTL --> X86
CPP --> GIMPLE
RTL --> ARM
RTL --> RISCV
style C fill:#C7CEEA,stroke:#9FA8DA,color:#333
style CPP fill:#C7CEEA,stroke:#9FA8DA,color:#333
style GIMPLE fill:#E8D5F5,stroke:#CE93D8,color:#333
style PASSES fill:#FFDAB9,stroke:#FFAB76,color:#333
style RTL fill:#FFF9C4,stroke:#F9A825,color:#333
style X86 fill:#B5EAD7,stroke:#80CBC4,color:#333
style ARM fill:#B5EAD7,stroke:#80CBC4,color:#333
style RISCV fill:#B5EAD7,stroke:#80CBC4,color:#333Clang/LLVM 内部结构:
graph TB
subgraph "前端"
C["Clang C"]
CPP["Clang C++"]
OBJC["Clang ObjC"]
end
subgraph "中端"
LLVMIR["LLVM IR\n统一表示"]
OPTS["优化 Pass\n50+ 种"]
end
subgraph "后端"
X86["x86 后端"]
ARM["ARM 后端"]
NVPTX["NVPTX (GPU)"]
WEB["WebAssembly"]
end
C --> LLVMIR --> OPTS --> X86
CPP --> LLVMIR
OBJC --> LLVMIR
OPTS --> ARM
OPTS --> NVPTX
OPTS --> WEB
style C fill:#C7CEEA,stroke:#9FA8DA,color:#333
style CPP fill:#C7CEEA,stroke:#9FA8DA,color:#333
style OBJC fill:#C7CEEA,stroke:#9FA8DA,color:#333
style LLVMIR fill:#E8D5F5,stroke:#CE93D8,color:#333
style OPTS fill:#FFDAB9,stroke:#FFAB76,color:#333
style X86 fill:#B5EAD7,stroke:#80CBC4,color:#333
style ARM fill:#B5EAD7,stroke:#80CBC4,color:#333
style NVPTX fill:#FFB3C6,stroke:#F48FB1,color:#333
style WEB fill:#FFB3C6,stroke:#F48FB1,color:#333关键区别:LLVM IR 是全平台统一的 IR,模块化做得比 GCC 更好。这让 Rust、Swift 等新语言复用 LLVM 后端成为可能。
2.4 汇编(Assembly)
2.4.1 汇编做了什么
汇编阶段相对简单——它做一对一或一对多的翻译:
- 指令翻译:汇编助记符 → 机器码(
mov→0x89 0xc5) - 段表生成:把汇编文件里的
.text / .data / .bss等段标记记录到 ELF 段表 - 符号表生成:记录所有标签(函数名、变量名)和它们的偏移
- 重定位信息生成:标记需要链接器修正的地址(如
call puts中的puts地址)
2.4.2 汇编器实例:as 和 nasm
| 汇编器 | 语法 | 平台 | 来源 |
|---|---|---|---|
GNU as | AT&T / Intel | 跨平台(ELF) | GNU Binutils |
nasm | Intel | x86 | 第三方 |
clang -S | Intel | 跨平台 | LLVM |
| MASM | Intel | Windows | 微软 |
2.4.3 亲手验证:汇编前后对比
1 | int add(int a, int b) { |
1 | # 生成汇编 |
1 | .file "asm_demo.c" |
汇编后:
1 | gcc -c asm_demo.c -o asm_demo.o |
1 | asm_demo.o: file format elf64-x86-64 |
关键洞察:反汇编出来的字节和汇编源代码一一对应。
push %rbp=0x55,ret=0xc3。汇编本质上是机器码的”人话版”。
2.4.4 段(Section)的概念
汇编阶段开始,代码就有了段的概念。常见段:
| 段名 | 内容 | 权限 | 示例 |
|---|---|---|---|
.text | 代码 | 只读、可执行 | 函数体 |
.data | 已初始化全局变量 | 读/写 | int g = 42; |
.bss | 未初始化全局变量 | 读/写 | int g;(占空间不占磁盘) |
.rodata | 只读数据 | 只读 | "Hello"、常量 |
.symtab | 符号表 | - | 函数和变量名 |
.strtab | 字符串表 | - | 符号名本身 |
.rel.text | 代码重定位表 | - | 引用外部符号的位置 |
.debug_* | 调试信息 | - | DWARF 格式 |
段 vs 节:英文都是 section / segment,但语义不同。
- 段(Section):汇编器视角,相同属性的代码/数据分组
- 节(Segment):链接器/加载器视角,运行时权限和内存布局
- 一个段可以属于多个节(下一章会详细讲)
2.4.5 用 readelf 看目标文件
1 | readelf -h asm_demo.o # Header |
输出示例(readelf -S):
1 | There are 11 section headers, starting at offset 0x1d0: |
观察:
.text大小 0x14 = 20 字节(汇编代码)AX标志 = Alloc + eXecute(运行时分配内存并可执行).bss是NOBITS,占空间但不占磁盘(节省空间).rela.text是重定位表(本例是空,但概念重要)
2.4.6 目标文件 vs 可执行文件
| 特性 | 目标文件 .o | 可执行文件 a.out |
|---|---|---|
| 文件类型 | ET_REL(可重定位) | ET_EXEC(可执行) |
| 入口地址 | 没有 | 有(_start) |
| 地址 | 相对地址(0 起步) | 绝对地址(或 PIC) |
| 段大小 | 真实大小 | 合并后大小 |
| 符号 | 可能有未定义符号 | 全部已定义 |
| 启动器 | 需要链接 | 直接可运行 |
| 大小 | 较小 | 通常较大 |
2.5 链接(Linking)
2.5.1 为什么需要链接
反常识 3:单文件程序不需要链接也能跑(但很罕见)。
实际项目几乎都是多文件:
1 | project/ |
每个 .c 文件独立编译成 .o,但跨文件的函数调用、变量引用汇编器没法处理。这时候**链接器(linker)**出场了。
2.5.2 链接器做了 3 件事
| # | 工作 | 解释 |
|---|---|---|
| 1 | 空间与地址分配 | 扫描所有 .o,合并同名段(如所有 .text 合一起),分配虚拟地址 |
| 2 | 符号决议(Symbol Resolution) | 把每个符号引用绑定到唯一符号定义。处理多定义、未定义 |
| 3 | 重定位(Relocation) | 把 .o 里的相对地址改成可执行文件里的绝对地址 |
2.5.3 链接流程图
graph TB
A["📄 main.o\n.text main"] --> D["🔗 链接器 ld"]
B["📄 utils.o\n.text add"] --> D
C["📄 math.o\n.text mul"] --> D
E["📄 crt1.o\n启动代码"] --> D
F["📄 crti.o\n初始化"] --> D
G["📄 crtn.o\n收尾"] --> D
H["📚 libc.so\nC 标准库"] --> D
D --> I["🚀 a.out\n可执行文件"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#C7CEEA,stroke:#9FA8DA,color:#333
style C fill:#C7CEEA,stroke:#9FA8DA,color:#333
style E fill:#E8D5F5,stroke:#CE93D8,color:#333
style F fill:#E8D5F5,stroke:#CE93D8,color:#333
style G fill:#E8D5F5,stroke:#CE93D8,color:#333
style H fill:#FFDAB9,stroke:#FFAB76,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style I fill:#B5EAD7,stroke:#80CBC4,color:#3332.5.4 步骤 1:空间与地址分配
链接器先扫描所有输入的 .o 文件,收集:
- 段信息(
.text、.data、.bss等) - 段大小
- 段属性(是否需要分配内存、是否可执行)
然后合并同名段:
1 | 输入: |
虚拟地址分配(静态链接):
1 | 0x400000: .text (80 字节) |
关键洞察:链接器并不知道每个段里有什么,它只关心大小。内容排序由程序员用链接脚本控制(
ld的.ld文件、Keil 的.sct文件)。
2.5.5 步骤 2:符号决议(最复杂的一步)
符号决议是链接中最容易出错的部分。它要回答:
“对每个
call puts或mov %rip, g_var这种引用,puts 和 g_var 在哪定义?”
2.5.5.1 符号的强弱
C 语言里符号有 3 种类型:
| 符号类型 | 说明 | 链接器处理 |
|---|---|---|
| 强符号(Strong) | 函数和已初始化全局变量 | 必须唯一 |
| 弱符号(Weak) | 未初始化全局变量、__attribute__((weak)) 标注 | 多个允许 |
| 未定义符号(Undefined) | 引用了但没在当前 .o 定义 | 必须由其他 .o 提供 |
2.5.5.2 强符号冲突规则
链接器对多个 .o 中同名强符号的规则:
| 情况 | 行为 |
|---|---|
| 多个强符号同名 | ❌ 链接错误:multiple definition |
| 1 个强 + 多个弱 | ✅ 选强符号 |
| 多个弱符号同名 | ✅ 选最大的(或第一个) |
| 引用未定义符号 | ❌ 链接错误:undefined reference |
2.5.5.3 一个真实的链接错误
1 | // a.c |
1 | // b.c |
1 | gcc -c a.c b.c |
修复方法:
- 方法 1:把其中一个改名为
static(文件作用域) - 方法 2:其中一个加
__attribute__((weak)) - 方法 3:用
extern声明 + 单一定义(标准做法)
1 | // 修复后 |
2.5.6 步骤 3:重定位
重定位是链接的最后一步。.o 文件里的地址是相对的、临时的(通常从 0 开始),链接器要:
- 找到每个
.o里引用的外部符号 - 算出该符号在最终可执行文件中的绝对地址
- 把
.o里的占位地址回填为绝对地址
2.5.6.1 重定位表示例
1 | # 准备两个 .c 文件 |
输出:
1 | Relocation section '.rela.text' at offset 0x1c0 contains 1 entry: |
解读:
Offset 0x15:main.o文件内 0x15 字节处需要重定位(是call puts指令的目标地址)Type R_X86_64_PC32:x86-64 的 PC 相对寻址(PC = 4 字节)Sym. Value 0:puts 的占位值(链接前)puts - 4:最终地址 = puts 的真实地址 - 4
2.5.6.2 链接后:地址已修正
1 | gcc main.o add.o -o main |
1 | 0000000000001065 <main>: |
关键变化:
add函数的地址变成 0x103a(@plt是过程链接表)%rip相对的偏移0xffd0已被链接器算好- 重定位完成!
2.5.7 链接器实例:ld / lld / mold
主流链接器对比:
| 链接器 | 来源 | 速度 | 特点 |
|---|---|---|---|
GNU ld | GNU Binutils | 基准 | 老牌,兼容性最好 |
lld | LLVM | 10x faster | 跨平台,模块化 |
mold | Rui Ueyama | 50x faster | 现代设计,惊人速度 |
gold | GNU | 2-3x faster | 已被 lld/mold 取代 |
MSVC link.exe | 微软 | 一般 | Windows 标配 |
速度实测(编译 Chromium):
1 | ld: 1m 30s |
mold 作者 Rui Ueyama(也是 lld 作者)的设计哲学:链接应该是 I/O 密集型而非 CPU 密集型。mold 用多线程、内存映射、
O(1)数据结构,把链接从”古董工艺”变成了”现代工程”。
2.5.8 静态库 vs 动态库
链接有两种方式:
| 类型 | 链接时机 | 产物大小 | 部署 | 启动速度 | 更新 |
|---|---|---|---|---|---|
静态库 .a | 编译时 | 包含在可执行文件 | 单文件即可 | 快 | 需重新编译 |
动态库 .so | 运行时 | 可执行文件不含 | 需带上 .so | 稍慢(动态加载) | 单独替换 .so |
静态库本质:
1 | libfoo.a = ar 打包的多个 .o |
链接器只挑出被引用的 .o 加入可执行文件(不像一些新手想象的那样”全打包”)。
2.6 现代工具链全景
2.6.1 三大编译器工具链对比
| 平台 | 编译器 | 汇编器 | 链接器 | 目标文件 | 调试格式 |
|---|---|---|---|---|---|
| Linux | GCC / Clang | GNU as | ld / lld / mold | ELF | DWARF |
| macOS | Apple Clang | Apple as | ld64 / lld | Mach-O | DWARF |
| Windows | MSVC / Clang | MSVC cl | link.exe / lld-link | COFF/PE | PDB |
2.6.2 一张图看懂工具链
graph LR
subgraph "源代码层"
SRC["📄 .c / .cpp"]
end
subgraph "预处理层"
CPP["🔧 预处理器\ncpp"]
end
subgraph "编译层"
CC["🔧 编译器\ncc1 / clang"]
end
subgraph "汇编层"
AS["🔧 汇编器\nas / nasm"]
end
subgraph "链接层"
LD["🔧 链接器\nld / lld / mold"]
end
subgraph "运行时"
OS["🖥️ 操作系统\nELF Loader"]
end
SRC --> CPP --> CC --> AS --> LD --> OS
style SRC fill:#C7CEEA,stroke:#9FA8DA,color:#333
style CPP fill:#E8D5F5,stroke:#CE93D8,color:#333
style CC fill:#FFDAB9,stroke:#FFAB76,color:#333
style AS fill:#FFF9C4,stroke:#F9A825,color:#333
style LD fill:#FFB3C6,stroke:#F48FB1,color:#333
style OS fill:#B5EAD7,stroke:#80CBC4,color:#3332.6.3 必装的 7 个工具
| 工具 | 作用 | 常用命令 |
|---|---|---|
gcc / clang | 编译驱动 | gcc -O2 -c foo.c |
as | 汇编器 | as -o foo.o foo.s |
ld / lld | 链接器 | ld -o foo foo.o |
objdump | 反汇编、查看段 | objdump -d foo.o |
readelf | 查看 ELF 元信息 | readelf -a foo.o |
nm | 查看符号 | nm foo.o |
strings | 提取字符串 | strings foo.o |
gdb | 调试器 | gdb ./foo |
strace | 追踪系统调用 | strace ./foo |
ltrace | 追踪库调用 | ltrace ./foo |
valgrind | 内存检测 | valgrind ./foo |
addr2line | 地址转源码行 | addr2line -e foo 0x401234 |
2.7 实战:亲手拆解一个多文件项目
2.7.1 项目结构
1 | mkdir -p /tmp/compilelab && cd /tmp/compilelab |
2.7.2 编译流程逐阶段展示
1 | # 阶段 1:预编译 main.c |
2.7.3 查看每个 .o 的符号
1 | # 看每个 .o 定义了什么符号(全局可见的 T = 强符号) |
输出示例:
1 | main.o: |
关键洞察:
T= 全局函数已定义(Text)U= 未定义(Undefined),需要其他.o提供main.o引用了square / cube / validate_input,但自己没定义——链接器要去math_ops.o / utils.o找
2.7.4 查看可执行文件的最终段
1 | readelf -S calculator | head -20 |
1 | Section Headers: |
段数量变化:从 11 个段(单个
.o)变成了 25+ 个段(可执行文件)!多出来的.interp / .dynsym / .plt / .got等都是动态链接和程序启动需要的。这正是链接器的功劳。
2.7.5 用 objdump 反汇编看地址修正
1 | objdump -d calculator | grep -A 15 '<main>:' |
1 | 00000000000007a9 <main>: |
观察
square的调用:
- 在
main.o里:call square的相对地址是 0x00(占位)- 链接后:
call 82c(square真实地址 0x82c)- 重定位起作用了!
2.8 高级话题
2.8.1 链接脚本:自定义内存布局
链接脚本(Linker Script) 是控制链接器行为的脚本。嵌入式开发必学。
1 | SECTIONS |
1 | # 用链接脚本 |
2.8.2 LTO:链接时优化
Link Time Optimization(LTO) 让优化跨越 .o 边界。
1 | # 普通编译:每个 .o 单独优化,看不到全局 |
LTO 的关键:
graph LR
A[".o (含机器码)"] -->|无 LTO| D["链接器\n只做链接"]
B[".o (含 GIMPLE)"] -->|LTO| E["链接器\n重新优化 GIMPLE"]
E --> F["生成机器码"]
F --> G["最终链接"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style E fill:#FFDAB9,stroke:#FFAB76,color:#333
style F fill:#B5EAD7,stroke:#80CBC4,color:#333
style G fill:#B5EAD7,stroke:#80CBC4,color:#3332.8.3 PIE:地址空间布局随机化
PIE(Position Independent Executable) 是现代 Linux 的默认选项,让可执行文件地址随机化,提高安全性。
1 | # 查看是否 PIE |
2.8.4 -Wl,--as-needed:加速链接和减小产物
1 | # 默认:链接所有 -l 指定的库 |
2.8.5 -fno-plt:更快的函数调用
1 | # 默认:每个外部函数调用都过 PLT(多一次跳转) |
2.8.6 -fvisibility=hidden:减少符号导出
1 | # 默认:所有全局符号都导出(占用动态符号表) |
2.9 常见错误与调试
2.9.1 链接错误的 5 种类型
| 错误类型 | 报错信息 | 原因 | 修复 |
|---|---|---|---|
| 未定义符号 | undefined reference to 'foo' | 用了函数但没链接对应的 .o 或 .a | 加上 -lfoo 或补上 .o |
| 多重定义 | multiple definition of 'foo' | 同一符号在多个 .o 强定义 | 用 static 或 extern |
| 找不到库 | cannot find -lfoo | 库路径不对 | -L/path/to/lib |
| 找不到头 | fatal error: foo.h: No such file | 头文件路径不对 | -I/path/to/include |
| 架构不匹配 | Relocation truncated | 32 位代码链接 64 位库 | 统一架构(-m32 或 -m64) |
2.9.2 调试链接错误的 4 个命令
1 | # 1. 看符号在哪个 .o 里 |
2.9.3 链接顺序的坑
1 | # 错误:库在前面,链接器可能丢符号 |
原因:链接器从左到右扫描。遇到
.o记下未定义符号;遇到.a试图解析已记录未定义;遇到.o检查是否引入新未定义。库的顺序很重要。
2.10 性能对比:现代工具链实测
2.10.1 不同优化级别性能
| 优化级别 | 编译时间 | 运行时性能 | 二进制大小 | 适用场景 |
|---|---|---|---|---|
-O0 | 1x | 1x | 1x | 开发调试 |
-O1 | 1.5x | 1.3x | 0.95x | 快速构建 |
-O2 | 2x | 1.5x | 0.9x | 生产推荐 |
-O3 | 3x | 1.7x | 1.1x | 计算密集 |
-Os | 2x | 1.2x | 0.7x | 嵌入式 |
-Oz | 2.5x | 1.1x | 0.6x | 极小化 |
2.10.2 链接器速度对比(Chromium)
| 链接器 | 链接时间 | 内存占用 | 兼容性 |
|---|---|---|---|
ld | 90s | 8 GB | 100% |
gold | 40s | 4 GB | 95% |
lld | 18s | 3 GB | 99% |
mold | 8s | 2 GB | 95% |
2.10.3 一键使用 mold
1 | # Ubuntu 安装 |
2.10.4 Clang vs GCC 编译速度
| 编译器 | 编译时间 | 错误信息 | 内存占用 |
|---|---|---|---|
| GCC | 基准 | 较好 | 中 |
| Clang | 1.3x | 优秀 | 较低 |
| Clang -O0 | 0.9x | 优秀 | 低 |
Clang 的杀手锏:错误信息清晰、支持模块化、AST 可被工具复用(这就是 clangd、clang-tidy、clang-format 的基础)。
2.11 一张总表:4 个阶段对比
| 阶段 | 输入 | 输出 | 工具 | 主要操作 | 数据结构 | 可逆性 |
|---|---|---|---|---|---|---|
| 预编译 | .c | .i | cpp / cc1 -E | 文本替换 | 字符串流 | ❌ |
| 编译 | .i | .s | cc1 | 词法/语法/语义/优化 | AST → IR | ❌ |
| 汇编 | .s | .o | as | 助记符 → 机器码 | ELF 段 | ✅(基本) |
| 链接 | .o + .a + .so | a.out | ld | 合并段 + 决议 + 重定位 | ELF 文件 | ❌ |
graph LR
A[".c"] -->|"预编译\ncpp"| B[".i"]
B -->|"编译\ncc1"| C[".s"]
C -->|"汇编\nas"| D[".o"]
D -->|"链接\nld"| E["a.out"]
D --> F[".a\n静态库"]
F -->|"链接"| E
G[".so\n动态库"] -->|"运行时\nld.so"| E
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style C fill:#FFDAB9,stroke:#FFAB76,color:#333
style D fill:#FFF9C4,stroke:#F9A825,color:#333
style E fill:#B5EAD7,stroke:#80CBC4,color:#333
style F fill:#FFB3C6,stroke:#F48FB1,color:#333
style G fill:#FFB3C6,stroke:#F48FB1,color:#3332.12 思考题与动手实验
思考题
为什么 GCC 要分
cc1和cpp两个程序?而不是合在一起?
提示:考虑复用——cc1plus(C++ 编译器)也要用cpp吗?预编译后的
.i文件能不能编译回.c?为什么?
提示:宏展开是单向的,注释也删了。强符号冲突的链接错误为什么是链接器报,而不是编译器报?
提示:每个.c单独编译时看不到其他.c的符号。为什么动态库的函数调用比静态库慢?
提示:第一次调用要解析 GOT 里的真实地址。mold 比 ld 快 10 倍的秘诀是什么?
提示:数据结构(hash 表 vs 红黑树)+ 多线程 + mmap。
动手实验
实验 1:观察优化效果
1 | # 写一个计算密集函数 |
实验 2:故意制造链接错误
1 | cat > use_func.c << 'EOF' |
实验 3:比较 mold 和 ld
1 | # 克隆一个 C++ 大项目(如 Redis) |
实验 4:观察 LTO 效果
1 | # 跨文件内联 |
实验 5:探索 DWARF 调试信息
1 | gcc -g hello.c -o hello |
2.13 总结:4 把刀的使命
| 阶段 | 一句话总结 | 关键工具 |
|---|---|---|
| 预编译 | 把 # 开头的话翻译成 C 代码 | gcc -E |
| 编译 | 把人话变成机器话(高级语言 → 汇编) | gcc -S |
| 汇编 | 把汇编助记符变成机器码 | gcc -c |
| 链接 | 把多个 .o 缝合成一个可执行文件 | ld / lld / mold |
人生哲学:理解这 4 把刀,你就能看懂 90% 的编译/链接报错。理解”为什么错”,比”看错误信息”重要 10 倍。
参考资料
- 俞甲子、石凡、潘爱民. 程序员的自我修养:链接、装载与库. 电子工业出版社, 2009.(第 2 章)
- Randal E. Bryant, David O’Hallaron. Computer Systems: A Programmer’s Perspective, 3rd Edition.(第 1、5 章)
- GCC Internals Manual
- System V ABI
- LLVM Language Reference
- Rui Ueyama: mold: A Modern Linker
- Intel® 64 and IA-32 Architectures Software Developer’s Manual
📚 程序员的自我修养 系列导航
本文是《程序员的自我修养》系列第 2/15 篇。
| 方向 | 章节 |
|---|---|
| 上一篇 ◀ | 第一章:温故而知新 |
| 下一篇 ▶ | 第三章:目标文件里有什么 |