链接器符号与重定位
# 14.链接器符号与重定位
.o文件的 ELF 结构.symtab/.rel.text、链接器单遍左到右符号解析算法、静态库.a按需增量提取与__SYMDEF索引、重定位类型R_X86_64_PC32/R_X86_64_32填地址全过程、强弱符号仲裁四矩阵与静默覆盖事故、COMMON符号的历史包袱、-ffunction-sections -Wl,--gc-sections瘦身
# 目录
# 1. 案例引入
# 1.1 一行日志引发的血案
某游戏服务端上线了一个"无足轻重"的新功能——把玩家的登录时间写进一个全局的 login_timestamp 变量。代码看着极简单:
// login_tracker.c —— 新加的登录时间跟踪模块
int login_timestamp; // ← 全局变量,未初始化
void set_login_time(int ts) {
login_timestamp = ts;
}
2
3
4
5
6
// main.c —— 主程序(存在了 3 年)
#include <stdio.h>
int login_timestamp = -1; // ← 已初始化!表示"尚未登录"
int main() {
printf("login_timestamp = %d\n", login_timestamp); // 期望: -1
return 0;
}
2
3
4
5
6
7
8
9
现象:
- 链接没问题,编译零警告零错误
- 运行时
printf输出:login_timestamp = 0 - 但
main.c明明初始化了login_timestamp = -1!
诡异之处:
login_tracker.c里的login_timestamp没初始化main.c里的login_timestamp初始化了-1- 两个全局变量同名但不同文件——链接器怎么处理?
$ gcc -c main.c -o main.o
$ gcc -c login_tracker.c -o login_tracker.o
$ gcc main.o login_tracker.o -o server
$ ./server
login_timestamp = 0 # 💀 不是 -1!
2
3
4
5
6
更恐怖的是——如果用 gdb 打断点:
(gdb) print &login_timestamp
$1 = (int *) 0x404030 # 两个文件中的 login_timestamp 是同一个地址!
(gdb) print login_timestamp
$2 = 0 # 未初始化的那个值"赢了"
2
3
4
那个 = -1 去哪了?为什么链接器选择了未初始化的版本而不是已初始化的版本?
# 1.2 顺藤摸到根因
追查:
- 假设 1:是不是链接器报错了?——
gcc -Wall没有任何警告。用ld的--warn-common也没输出(说明链接器认为这是"合法"行为)。 - 假设 2:是不是不同 .o 里的全局变量默认会合并?—— 对,C 语言的全局符号默认有外部链接(external linkage)。两个
.o文件定义同名的全局变量,链接器只会保留一个。 - 假设 3:那选哪个?—— 这就是强弱符号仲裁:
int x = 42;是强符号,int x;(未初始化)是弱符号。规则是:强符号优先。 - 假设 4:那把
main.c的= -1放最前面,应该是强符号赢——但实际不是!
用 nm 查看两个 .o 文件的符号表:
$ nm main.o | grep login_timestamp
0000000000000000 D login_timestamp # 'D' = .data 段(已初始化)
$ nm login_tracker.o | grep login_timestamp
0000000000000004 C login_timestamp # 'C' = COMMON(未初始化全局)
2
3
4
5
凶手找到了:login_tracker.o 里的 int login_timestamp;(未初始化)在符号表中被标记为 COMMON,而 main.o 里的 int login_timestamp = -1; 被标记为 .data。
COMMON 符号的特别规则:链接器看到同一个符号既有 COMMON 版本又有非 COMMON 版本时,取非 COMMON 版本的大小和段。但在本例中,COMMON 的大小是 4,.data 的大小也是 4——规则没问题,问题出在初始化值上。
等等——如果 .data 版本(= -1)赢了,为什么输出是 0?
继续深挖——login_tracker.o 在 login_tracker.c 中还引入了一个静态库 libutils.a,而这个 .a 文件里恰好也有一个全局 int login_timestamp; 的 COMMON 定义。静态库在链接时按需提取 .o,而这个 .o 里刚好还有一个 init_timestamp() 函数被 login_tracker.c 间接调用了——于是 libutils.a 的那个 login_timestamp 也被拉进来了。
最终的符号仲裁:三个 login_timestamp:
main.o: D(强符号,值 = -1)login_tracker.o: C(COMMON,4字节,0-initialized)libutils.a/settings.o: C(COMMON,4字节,0-initialized)
链接器选择 .data 的强符号(大小一致),但 COMMON 本身的"0 初始化"规则触发了 .bss 段的零填充,把 .data 段的 -1 在链接时覆盖成了 0!
这个 bug 藏着至少 8 个原理点:
① .symtab 符号表里 D/C/B/T/U 各代表什么意思? → 第 3.2 节
② .rel.text 重定位表记录了什么信息? → 第 3.3 节
③ 链接器怎么从一堆 .o 和 .a 中解析符号? → 第 4 章
④ 静态库 .a 的按需提取规则是什么?链接顺序为什么关键? → 第 4.2/4.3 节
⑤ PC32 和 32 重定位类型有什么区别?地址怎么填? → 第 5 章
⑥ 强符号和弱符号的仲裁规则是什么?COMMON 为什么坑? → 第 6 章
⑦ 怎么让链接器丢弃未引用的函数和数据? → 第 7 章
⑧ nm / objdump / readelf 这些工具怎么读符号表? → 第 8 章
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个事故是一个**链接器"静默仲裁"**的典型案例——两个文件定义了同名全局变量,链接器没有报错,选择了"错"的版本,然后运行时数据静默出错。没有 segfault,没有警告,只有输出的数字不对。
本篇路线:
架构总图 (第 2 章)
↓
ELF 目标文件结构 (第 3 章) ─→ 解开".o 文件里到底存了什么"
↓
符号解析算法 (第 4 章) ─→ 解开"链接器怎么找到符号的"
↓
重定位填地址 (第 5 章) ─→ 解开"call 指令的地址怎么补全"
↓
强弱符号 (第 6 章) ─→ 解开"同名全局变量谁赢谁输" ← 本篇核心案例
↓
链接器瘦身 (第 7 章) ─→ 解开"怎么让 binary 变小"
↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:链接是编译流水线的最后一步,也是把"多个 .o 拼成一个完整程序"的关键。理解链接器的符号解析和重定位机制,才能回答那些诡异的链接错误——"multiple definition"到底发生了什么、静态库顺序为什么重要、强弱符号怎么静默覆盖。
# 2. 架构概览
# 2.1 链接器位置
回顾第 13 章的编译全流程,链接是最后一环:
main.c ──► gcc -c ──► main.o ──┐
│
foo.c ──► gcc -c ──► foo.o ──┼──► ld ──► a.out (可执行文件)
│ │
bar.c ──► gcc -c ─► bar.o ────┘ ┌─────┴──────┐
│ libc.so │
libfoo.a (静态库, foo.o 的归档) │ libm.so │
libbar.so (动态库) └────────────┘
2
3
4
5
6
7
8
链接器 (ld) 的两大核心任务:
┌────────────────────────────────────────────────────────────┐
│ 链接器 (ld) 核心任务 │
│ │
│ 任务 1: 符号解析 (Symbol Resolution) │
│ - 每个 .o 文件里存有"未定义的符号"(U)和"已定义的符号"(D/T/B) │
│ - 链接器遍历所有 .o / .a / .so,把每个 U 匹配到一个 D/T/B │
│ - 找不到 → "undefined reference to" │
│ - 找到多个 → 强弱符号仲裁 (第 6 章) │
│ │
│ 任务 2: 重定位 (Relocation) │
│ - .o 里的 call 指令地址还是 0x00 00 00 00 │
│ - 链接器把所有 .o 合并成一个文件,算出每个段的真实地址 │
│ - 然后把 0x00 00 00 00 替换成"当前指令到目标符号的偏移" │
│ │
└────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2 链接器看到的世界
链接器不关心 C 语法——它看到的是 .o 文件里的节 (sections) 和符号 (symbols):
$ gcc -c main.c -o main.o
$ objdump -h main.o # 看有哪些节
main.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000002a 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 0000006a 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 0000006a 2**0
ALLOC
3 .rodata 0000000d 0000000000000000 0000000000000000 0000006a 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002c 0000000000000000 0000000000000000 00000077 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000a3 2**0
CONTENTS, READONLY
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000a8 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
# .text: 有 RELOC 标记 → 这个节里有需要重定位的指令
# .data: size=0, ALLOC+LOAD → 空节,但链接后会有空间
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
链接器看到的符号表(用 nm 精简版):
$ nm main.o
U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main # T = .text 段,全局函数
U printf # U = 未定义,等链接器去找
U puts # U = 未定义(printf 被优化成 puts)
0000000000000000 D login_timestamp # D = .data 段,已初始化全局变量
2
3
4
5
6
链接器只需要三样信息:这个符号在哪(值)、在哪段(类型)、是本地的还是外部引用的(绑定)。
# 3. ELF 目标文件结构
# 3.1 节头表全景
一个 .o 文件是 ELF 格式,核心是一个节头表 (Section Header Table)——每个条目描述文件中的一个节:
┌─────────────────────────────────────────────────────────┐
│ ELF Header │
│ 魔数: 0x7f E L F │
│ 类型: ET_REL (可重定位文件, .o) │
│ 机器: EM_X86_64 (x86-64) │
│ e_shoff: 节头表在文件中的偏移 │
│ e_shnum: 节的数量 │
│ e_shstrndx: 节名字字符串表 (shstrtab) 的索引 │
├─────────────────────────────────────────────────────────┤
│ │
│ .text 机器指令 (AX, ALLOC+EXECUTE) │
│ .data 已初始化全局/static 变量 (WA) │
│ .bss 未初始化变量 (WA, NOBITS) │
│ .rodata 只读数据 (A) │
│ .symtab 符号表 │
│ .strtab 字符串表 (符号名存储处) │
│ .rel.text .text 段的重定位条目 │
│ .rel.data .data 段的重定位条目 │
│ .comment 编译器版本注释 │
│ .note 额外的注释信息 │
│ .shstrtab 节名字符串表 │
│ │
├─────────────────────────────────────────────────────────┤
│ Section Header Table (节头表) │
│ 每个条目: sh_name, sh_type, sh_flags, sh_addr, │
│ sh_offset, sh_size, sh_link, sh_info │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关键节说明:
| 节名 | 类型 | 存什么 | 链接后? |
|---|---|---|---|
.text | PROGBITS | 函数的机器码 | 存留在可执行文件 |
.data | PROGBITS | 已初始化全局变量 | 存留 |
.bss | NOBITS | 未初始化变量的"元数据" | 存留(但不占文件空间) |
.rodata | PROGBITS | 只读常量/字符串 | 存留 |
.symtab | SYMTAB | 符号表 | 可 strip 掉 |
.strtab | STRTAB | 符号名字符串 | 可 strip 掉 |
.rel.text | RELA | .text 的重定位条目 | 不存留(链接后不需要) |
# 3.2 符号表 .symtab
疑惑:nm main.o 输出的那些 T/D/B/C/U 是什么?
论证:符号表 (Elf64_Sym 结构体数组) 每个条目记录:
typedef struct {
uint32_t st_name; // 符号名在 .strtab 中的偏移
uint8_t st_info; // 高4位: bind (LOCAL/GLOBAL/WEAK)
// 低4位: type (NOTYPE/OBJECT/FUNC/SECTION)
uint8_t st_other; // 可见性 (DEFAULT/HIDDEN/PROTECTED)
uint16_t st_shndx; // 符号所在的节索引 (ABS/UNDEF/COMMON 有特殊值)
uint64_t st_value; // 符号在该节中的偏移(或绝对地址)
uint64_t st_size; // 符号的大小
} Elf64_Sym;
2
3
4
5
6
7
8
9
nm 输出的字母含义速查:
| nm 字母 | st_info 组合 | 含义 | 例 |
|---|---|---|---|
T | GLOBAL + FUNC + .text | 全局函数 | main, foo |
t | LOCAL + FUNC + .text | 本地函数 (static) | static_func |
D | GLOBAL + OBJECT + .data | 已初始化全局变量 | int x = 5; |
d | LOCAL + OBJECT + .data | 本地已初始化变量 | static int x = 5; |
B | GLOBAL + OBJECT + .bss | 未初始化全局 (C中 =0) | int x = 0; |
b | LOCAL + OBJECT + .bss | 本地未初始化变量 | static int x; |
C | GLOBAL + OBJECT + COMMON | COMMON (未初始化全局,无 extern) | int x; (不在函数内) |
U | — + — + UNDEF | 未定义,等链接器解析 | printf, 外部变量引用 |
R | GLOBAL + OBJECT + .rodata | 只读数据 | const int x = 5; |
r | LOCAL + OBJECT + .rodata | 本地只读数据 | static const int x = 5; |
W | WEAK + FUNC + .text | 弱符号函数 | __attribute__((weak)) |
V | WEAK + OBJECT | 弱符号变量 |
实战查看:
$ gcc -c test.c -o test.o
$ nm test.o # 只看全局符号
$ nm -a test.o # 看所有符号(含本地)
$ nm -S test.o # 带大小信息
$ nm --format=sysv test.o # 按节分组显示
$ readelf -s test.o # 最完整的信息
$ objdump -t test.o # 另一种风格的符号表
2
3
4
5
6
7
# 3.3 重定位表 .rel.text 与 .rel.data
.o 文件里的 call 指令还不知道目标地址——重定位表记录了"哪些位置需要修补":
$ gcc -c hello.c -o hello.o
$ objdump -r hello.o # 看重定位表
hello.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000005 R_X86_64_PC32 .rodata-0x0000000000000004
000000000000000c R_X86_64_PLT32 puts-0x0000000000000004
2
3
4
5
6
7
8
9
重定位条目的含义:
| 字段 | 值 | 含义 |
|---|---|---|
| OFFSET | 0x5 | 需要修补的位置在 .text 的第 5 字节 |
| TYPE | R_X86_64_PC32 | 填入方式:PC 相对偏移 (32位) |
| VALUE | .rodata-0x4 | 目标符号 + 加数 (addend) |
重定位类型速查:
| 重定位类型 | 公式 (S=符号地址, A=加数, P=被修补位置) | 使用场景 |
|---|---|---|
R_X86_64_PC32 | S + A - P | call 指令的 PC 相对偏移 |
R_X86_64_PLT32 | L + A - P (L=PLT入口) | 动态库函数调用 |
R_X86_64_32 | S + A | 绝对地址 (只能低 4 GB) |
R_X86_64_64 | S + A | 64 bit 绝对地址 |
R_X86_64_GOTPCREL | G + A - P (G=GOT入口) | 访问全局变量的 RIP-relative |
重定位表的本质是一个"待办清单"——告诉链接器:"这里有个洞,请填入符号 X 的地址(以某种方式计算)"。
# 3.4 段 vs 节的差异
疑惑:readelf -S 输出的是"节",readelf -l 输出的是"段",它们是什么关系?
论证:
- 节 (Section):链接器视角——给 ld 看的。以语义分组(
.text= 代码,.data= 数据...) - 段 (Segment):加载器视角——给内核 execve 看的。以权限分组(r-x、rw-...)
.o 文件: 只有节头表 (Section Header Table),没有程序头表 (Program Header Table)
可执行文件: 两者都有 —— 程序头表告诉内核"怎么 mmap"
2
一个段包含多个节:
LOAD segment (r-x): LOAD segment (rw-):
├─ .text ├─ .data
├─ .rodata ├─ .bss
├─ .plt └─ .got
└─ ... (其他只读+可执行)
2
3
4
5
关键命令对比:
readelf -S file.o # 看节(链接器视角)
readelf -l a.out # 看段(加载器视角)
objdump -h file.o # 简洁版节头
2
3
# 4. 符号解析算法
# 4.1 单遍左到右扫描
疑惑:链接器怎么找到一个 U 符号的"家"?
论证:传统 Unix 链接器 (ld) 使用单遍左到右扫描算法:
链接器维护三组集合:
E 集合:已合并的目标文件集合(包含所有 .text/.data/.bss)
U 集合:未解析的符号集合
D 集合:已定义的符号集合
扫描过程(命令行从左到右):
┌──────────────────────────────────────────────────────────┐
│ for each 输入文件 (从左到右): │
│ if 文件是 .o (目标文件): │
│ 把文件的段合并到 E │
│ 把文件定义的符号加入 D │
│ 把文件引用的 U 符号加入 U(如果能用 D 解析,就从 U 移除)│
│ │
│ if 文件是 .a (静态库): │
│ 遍历归档中的 .o 成员 │
│ 如果某个 .o 能解析 U 中的至少一个符号: │
│ → 提取这个 .o,当作普通 .o 处理(合并 E,更新 U/D) │
│ 如果某个 .o 不能解析任何 U 符号: │
│ → 跳过(不提取) │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
为什么是"单遍":
# 如果 libfoo.a 依赖 libbar.a 的符号:
gcc main.o libfoo.a libbar.a # ✅ 正确:libfoo.a 先扫描,
# 遇到 U 符号 → 往下找 libbar.a
gcc main.o libbar.a libfoo.a # ❌ 错误:libbar.a 先扫描,
# 没有 U 符号等它解析 → 跳过全部 .o
# 到 libfoo.a 时,它的 U 符号没人解析了
# → undefined reference!
2
3
4
5
6
7
8
# 4.2 静态库 .a 的按需提取
静态库 .a 本质是 .o 文件的归档(ar 格式):
$ ar -t libc.a | head -20
init-first.o
libc-start.o
sysdep.o
version.o
printf.o
puts.o
...
$ ar -x libc.a printf.o # 提取单个 .o
$ nm printf.o | head -5
0000000000000000 T printf
U stdout
U vfprintf
2
3
4
5
6
7
8
9
10
11
12
13
14
__SYMDEF (ranlib 索引) 是 .a 文件里加速符号查找的索引表:
$ nm -s libfoo.a # 看 .a 的符号索引
Archive index:
foo in foo.o
bar in bar.o
foo.o:
0000000000000000 T foo
U bar
bar.o:
0000000000000000 T bar
2
3
4
5
6
7
8
9
10
11
12
没有索引的 .a 文件,链接器需要逐一遍历所有 .o 成员——非常慢。ranlib 命令可以生成/更新这个索引。
按需提取的关键规则:
.a中的.o只在有 U 符号需要它解析时才被提取- 提取是递归的——提取的
.o可能引入新的 U 符号,需要后续文件来解析 - 已经扫过的位置不会回头——这就是链接顺序问题的根源
# 4.3 链接顺序的依赖陷阱
# 陷阱演示
$ cat foo.c
void foo() { bar(); } # foo 调用 bar
$ cat bar.c
void bar() { }
$ cat main.c
int main() { foo(); }
$ gcc -c main.c foo.c bar.c
$ ar rcs libfoo.a foo.o # 静态库,只含 foo.o
$ ar rcs libbar.a bar.o # 静态库,只含 bar.o
$ gcc main.o libfoo.a libbar.a
# ✅ OK: main.o 引入 U:foo → 扫描 libfoo.a → 提取 foo.o → 引入 U:bar
# → 扫描 libbar.a → 提取 bar.o → 解析 U:bar
$ gcc main.o libbar.a libfoo.a
# ❌ FAIL:
# main.o 引入 U:foo
# → 扫描 libbar.a: U 中没有 bar(因为 foo 还没被提取,bar 还没被引用!)
# → 跳过全部 bar.o
# → 扫描 libfoo.a: U:foo → 提取 foo.o → 引入 U:bar
# → 后面没文件了!
# → undefined reference to `bar'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
直观总结:被依赖的库放右边,依赖别人的放左边:
$ gcc main.o -lfoo -lbar # main → foo → bar
# ↑ ↑
# 依赖者 被依赖者
2
3
# 4.4 --start-group 拯救循环依赖
当两个库相互依赖(foo 调用 bar,bar 也调用 foo):
$ gcc main.o -Wl,--start-group libfoo.a libbar.a -Wl,--end-group
# `--start-group ... --end-group` 让链接器反复扫描组内的库,
# 直到没有新的符号被解析为止。
# 代价:链接变慢(可能多遍扫描)
# 收益:不需要关心组内的链接顺序
2
3
4
5
6
但这只是"治标"——更好的做法是消除循环依赖(重构代码,把共享部分提取到第三个库)。
# 5. 重定位填地址全流程
# 5.1 重定位的核心问题
疑惑:编译器在生成 .o 时不知道最终地址,那 call printf 这条指令里填了什么?
论证:
// hello.c
extern void printf(const char*, ...);
int main() {
printf("hello\n");
return 0;
}
2
3
4
5
6
$ gcc -c hello.c -o hello.o
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # ← 第 7 字节:地址是 0!
b: e8 00 00 00 00 call c <main+0xc> # ← 第 12 字节:地址是 0!
10: b8 00 00 00 00 mov $0x0,%eax
15: 5d pop %rbp
16: c3 ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lea 0x0(%rip), %rdi的00 00 00 00——这里应该填"hello\n"字符串的地址call 0xc的00 00 00 00——这里应该填printf的地址(或 PLT 桩的地址)
重定位表告诉链接器"怎么填":
$ objdump -r hello.o
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000007 R_X86_64_PC32 .rodata-0x0000000000000004
000000000000000c R_X86_64_PLT32 printf-0x0000000000000004
2
3
4
5
6
- 第 7 字节 → 填入
.rodata地址 − 当前指令地址 − 4 - 第 12 字节 → 填入
printf的 PLT 地址 − 当前指令地址 − 4
# 5.2 R_X86_64_PC32 RIP相对寻址
x86-64 的 call 指令使用 RIP-相对寻址——不是填绝对地址,而是填"当前指令到下一条指令的偏移":
call printf
↓ 机器码
e8 XX XX XX XX (XX = 目标 - 下一条指令地址)
例如:
0x400100: e8 00 00 00 00 # 占位符
下一条指令在 0x400105
printf 的地址 = 0x400200
偏移 = 0x400200 - 0x400105 = 0xFB (251)
最终机器码:
0x400100: e8 FB 00 00 00 # call 0x400200
2
3
4
5
6
7
8
9
10
11
12
13
公式:*(int32_t*)(S + A) = (S + A - P),其中:
S= 符号的最终地址(链接后确定)A= 加数 (addend),存于重定位表或指令中P= 被修补位置的地址(链接后确定)
为什么 x86-64 用 PC 相对而不是绝对地址:
- 位置无关代码 (PIC/PIE):如果填绝对地址,代码必须加载到固定地址;PC 相对让代码可以放在任何位置
- 指令更短:
e8 xx xx xx xx(5 字节)vsff 15 xx xx xx xx间接调用(6 字节)
# 5.3 R_X86_64_32 绝对寻址
访问全局变量时常用绝对寻址(但受限于低 4 GB 地址空间):
int global_var = 42;
int get_global() { return global_var; }
2
$ gcc -c -mcmodel=small get.c -o get.o && objdump -r get.o
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000003 R_X86_64_PC32 global_var-0x0000000000000004
# 现代 GCC 默认用 PIC,所以全局变量也是 RIP-relative
# 如果要绝对寻址(-mcmodel=large 或 -fno-pic):
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000003 R_X86_64_32 global_var
2
3
4
5
6
7
8
9
10
11
R_X86_64_32 的限制:填的是 32 位绝对地址 → 目标符号的最终地址必须在低 4 GB 内。对于 PIE(位置无关可执行文件),加载基址随机化可能超过 4 GB → 链接器报 "relocation truncated to fit"。
解决方案:
- 默认用 PIC (Position Independent Code)
- 或
-mcmodel=large(但代码更大更慢)
# 5.4 减四原因
注意 objdump -r 输出中的 -0x4:
R_X86_64_PC32 .rodata-0x0000000000000004
这个 -4 是因为 RIP-relative 的基准是下一条指令。在 .o 文件中,VMA 从 0 开始计数——call 指令占 5 字节,所以从当前指令到 .rodata 的偏移 = .rodata 的最终地址 − (当前地址 + 5)。链接器先把 .o 的地址当作 0,后面合并时统一加上真实偏移。
.o 中的 call 指令 (offset 0xb):
call printf → e8 00 00 00 00 (占位符)
下一条指令在 offset 0x10 (= 0xb + 5)
printf 在 .o 中 offset 未知 → 填 0
加数 A = -4 (因为 RIP 基准点 = 当前地址 + 指令长度 = 0xb + 5)
重定位时:*(0xb) = printf最终地址 - (0xb + 5) + (-4)
= printf最终地址 - 0xb - 5 - 4
= printf最终地址 - 0xb - 9
... 实操中用更简洁的公式直接算
2
3
4
5
6
7
8
9
10
# 6. 强弱符号仲裁机制
# 6.1 强弱符号
C 语言的全局符号分为强 (strong) 和弱 (weak) 两类:
// ===== 强符号 =====
int x = 5; // 已初始化全局变量 → 强符号 (D)
void foo() { } // 已定义的函数 → 强符号 (T)
int x __attribute__((weak)) = 5; // 显式弱符号
// ===== 弱符号 =====
int x; // 未初始化全局变量 → COMMON (C)
// ⚠️ 这里 C 类型被归类为弱符号!
// ===== 不产生符号 =====
extern int x; // 只是声明,不定义符号
2
3
4
5
6
7
8
9
10
11
判定规则:
- 已初始化全局变量 / 函数体有定义的 → 强符号
- 未初始化全局变量(非
static、非extern)→ COMMON(弱) static全局 → 本地符号,不存在链接冲突__attribute__((weak))→ 显式弱符号
# 6.2 仲裁四矩阵与静默覆盖
当链接器发现多个 .o 文件有同名符号时,按以下规则仲裁:
| 强符号 | COMMON (弱) | 显式 WEAK | |
|---|---|---|---|
| 强符号 | ❌ 错误 (multiple definition) | ✅ 选强符号 | ✅ 选强符号 |
| COMMON (弱) | ✅ 选强符号 | ✅ 选最大的 COMMON | ✅ 选 COMMON |
| 显式 WEAK | ✅ 选强符号 | ✅ 选 COMMON | ✅ 选第一个 WEAK |
第 1 章案例的仲裁过程:
main.o: int login_timestamp = -1; → D (强符号, .data, 4字节, 值=-1)
login_tracker.o: int login_timestamp; → C (COMMON, 4字节)
libutils.a/settings.o: int login_timestamp; → C (COMMON, 4字节)
仲裁: D vs C vs C → 选 D (main.o 的版本)
结果: login_timestamp 在 .data 段,4字节,初始值 -1
2
3
4
5
6
那为什么输出 0?因为 settings.o 中还引入了一个初始化器函数 .init_array 段,该函数在 main() 之前运行,把 login_timestamp 清零了。这个 .init_array 被引入是因为 settings.o 中的另一个函数(init_settings)在与 login_tracker 无关的代码路径中被引用了。
这是链接器"按需提取"的副产品——提取 settings.o 意味着它的所有段(包括 .init_array)都被合并进最终可执行文件。
# 6.3 COMMON 符号的历史包袱
疑惑:为什么 C 语言未初始化的全局变量不直接放 .bss,而是先标记为 COMMON?
论证(历史原因):
在 K&R C 时代(1970s),int x; 被当作**"试探性定义 (tentative definition)"**——可能有多个 .c 文件都写了 int x;,链接器应该把它们合并成一个。如果在 .o 中直接放进 .bss,多个 .o 的 .bss 节合并时会产生大小冲突。
COMMON 机制解决了这个问题:多个 COMMON 符号不报错,取最大的那个。而且,如果其他 .o 有同一个符号的强符号定义(int x = 5;),COMMON 会被自动压制。
COMMON 的坑:
// a.c
int x; // COMMON, 4 字节
// b.c
double x; // COMMON, 8 字节
// 链接后: x 是 8 字节的 double,但 a.c 仍然把它当 int 用!
// a.c 的 printf("%d", x) 读到的是 double 的低 4 字节 → 垃圾值
2
3
4
5
6
7
8
现代 GCC 行为:GCC 10+ 默认加上 -fno-common——未初始化全局变量不再标记为 COMMON,而是直接放 .bss。这样多个 .o 定义同名未初始化变量就会报 multiple definition 错误,把隐式错误变成显式错误。
$ gcc -fno-common -c a.c -o a.o # GCC 10+ 默认行为
$ gcc -fcommon -c a.c -o a.o # 回退到老行为(COMMON 模式)
2
# 6.4 防御策略
| 手段 | 效果 | 代价 |
|---|---|---|
gcc -fno-common (GCC 10+ 默认) | 把 COMMON 变成 .bss,重复定义报错 | 旧的 K&R 代码可能编不过 |
gcc -Wl,--warn-common | 链接时对 COMMON 合并发出警告 | 只有警告,不阻止 |
static 修饰全局变量 | 将符号本地化,不同文件的同名符号互不影响 | 不能跨文件共享 |
extern 声明 + 唯一定义 | 消除"试探性定义"歧义 | 需要额外的 .c 文件 |
最佳实践:
- 全局变量只在一个 .c 中定义(
int x = 0;),其他 .c 用extern int x; - 不需要跨文件的变量加
static - 编译加
-fno-common(GCC 10+ 已是默认)
# 7. 链接器瘦身技巧
# 7.1 -ffunction-sections 与 -fdata-sections
疑惑:编译一个 .c 文件,生成的 .o 里所有函数都在同一个 .text 节里——链接时能只保留用到的函数吗?
论证:默认情况下,链接器以节为最小单位操作——要么整个 .text 都被链接进去,要么整个都被丢弃。如果你想丢弃个别函数,需要把它们各自放到独立的节里:
# 默认:所有函数都在 .text 里
gcc -c big.c -o big.o
objdump -h big.o | grep -E '\.text|\.data'
.text : 0x4000 ← 所有函数的代码都在这一节
# -ffunction-sections: 每个函数一个节
gcc -ffunction-sections -c big.c -o big.o
objdump -h big.o | grep -E '\.text|\.data'
.text.foo : 0x40
.text.bar : 0x80
.text.baz : 0x30
# ... 每个函数一个节
# -fdata-sections: 每个全局变量一个节
gcc -fdata-sections -c big.c -o big.o
objdump -h big.o | grep -E '\.data|\.rodata'
.data.global_x : 0x04
.rodata.str_1 : 0x10
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 7.2 --gc-sections 垃圾回收
有了独立节后,链接器可以做"节级垃圾回收":
$ gcc -ffunction-sections -fdata-sections -Wl,--gc-sections \
-Wl,--print-gc-sections \
main.o big.o -o app
# --print-gc-sections 输出被丢弃的节:
/usr/bin/ld: removing unused section '.text.unused_func' in file 'big.o'
/usr/bin/ld: removing unused section '.data.unused_var' in file 'big.o'
2
3
4
5
6
7
垃圾回收算法(标记-清除):
1. 标记根:入口点 (_start, main) 的节、.init_array/.fini_array
2. 遍历重定位引用:如果一个被标记的节引用了另一个节的符号,
就把被引用的节也标记
3. 丢弃未被标记的所有节
2
3
4
保护特定符号不被回收:
__attribute__((used, retain)) void critical_func() { }
// 'used' 告诉编译器别优化掉
// 'retain' 告诉链接器别 gc 掉
2
3
或者在链接脚本中:
KEEP(*(.init_array))
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*)))
2
# 7.3 --icf 合并相同函数
ICF (Identical Code Folding):如果两个函数编译后的机器码完全相同,合并成一个:
int add_one(int x) { return x + 1; }
int inc_one(int x) { return x + 1; } // ← 汇编完全一样
2
$ gcc -ffunction-sections -Wl,--icf=all -Wl,--print-icf-sections \
prog.c -o prog
ld: ICF folding '.text.add_one' and '.text.inc_one'
# 链接器发现两个函数体相同 → 合并为一个,节省空间
2
3
4
5
--icf 选项:
| 值 | 含义 |
|---|---|
--icf=none | 不做合并 |
--icf=safe | 只合并确定安全的(不含可重定位引用) |
--icf=all | 合并所有相同的(小心:可能破坏函数指针比较) |
风险:合并后两个函数的地址相同——如果你的代码依赖 &add_one != &inc_one,ICF 会破坏这个假设。GCC 用 -fno-icf 对特定函数关闭。
# 7.4 LTO 链接时优化的红利
LTO (Link-Time Optimization) 把优化从"编译单元"扩展到"整个程序":
$ gcc -flto -O2 -c foo.c bar.c main.c
$ gcc -flto -O2 foo.o bar.o main.o -o app
# .o 文件实际上包含 GIMPLE 中间表示(而不是纯机器码)
# 链接器调用 GCC 在链接阶段重新做全程序的优化
2
3
4
LTO 能做什么传统的编译做不了的事:
- 跨文件内联:
foo()调用bar(),即使它们在不同 .c 中,LTO 也能内联 - 跨文件死代码消除:如果整个程序都不调用
bar(),那bar的 .o 中的静态函数也能被消除 - 跨文件常量传播:
const int x = 5;在文件 A,f(x)在文件 B → 可以直接替换为f(5) - 全局变量的 SSO (Scalar Replacement of Aggregates):把全局结构体的字段拆分到寄存器
LTO 的代价:
- 链接时间显著变长(几秒 → 几十秒甚至几分钟)
.o文件变大(包含 GIMPLE IR + 机器码)- 调试更困难(符号在链接阶段被改写)
生产建议:开发时不用 LTO,发布前用 LTO(或 ThinLTO)做 Release build。
# 8. 综合案例串讲
# 8.1 案例真相揭晓
回到第 1 章 login_timestamp 覆写问题,八个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
① nm 输出的 D/C/B/T/U 什么意思? | 第 3.2 节:D=.data 已初始化,C=COMMON 试探性定义,B=.bss,T=.text 函数,U=未定义 |
② .rel.text 存了什么? | 第 3.3 节:每个需要修补的指令位置 + 重定位类型 + 目标符号 |
| ③ 链接器怎么解析符号? | 第 4.1:单遍左到右扫描,.o 直接合并,.a 按需提取 |
| ④ .a 按需提取规则 / 链接顺序为什么关键? | 第 4.2/4.3:只提取能解析当前 U 符号的 .o,不回头 |
| ⑤ PC32 和 32 重定位差别? | 第 5.2/5.3:PC32=RIP-relative(可重定位),32=绝对地址(受限于低 4GB) |
| ⑥ 强弱符号仲裁规则 / COMMON 为什么坑? | 第 6 章:多 COMMON 取最大 → 类型不匹配 + 静默覆盖 |
| ⑦ 怎么让链接器丢掉没用到的函数? | 第 7 章:-ffunction-sections -Wl,--gc-sections |
| ⑧ nm/objdump/readelf 怎么读符号? | 第 8.4 节速查卡 |
第 1 章案例的完整根因链条:
main.o: D login_timestamp = -1 (.data)
login_tracker.o: C login_timestamp (COMMON, 4字节)
libutils.a/settings.o → 被另一个函数的引用触发提取
→ C login_timestamp (COMMON, 4字节)
→ .init_array 段: init_timestamp() 在 main 之前执行
→ login_timestamp = 0 (清零)
链接器仲裁: D (强符号) 胜出,符号在 .data 段
运行时: .init_array 执行 → login_timestamp 被清零 → main 看到 0
2
3
4
5
6
7
8
9
修复方案(从弱到强):
方案 A:统一初始化(最简)
// main.c 中显式初始化,其他文件用 extern
// main.c:
int login_timestamp = -1;
// login_tracker.c:
extern int login_timestamp; // 不定义,只声明
2
3
4
5
6
方案 B:加 static 本地化
// login_tracker.c:
static int login_timestamp; // ← 加了 static,不和 main.c 的冲突
2
代价:不能跨文件共享。
方案 C:-fno-common 强制报错 (GCC 10+ 默认)
$ gcc -fno-common main.o login_tracker.o
ld: multiple definition of `login_timestamp' # ← 显式报错
2
# 8.2 符号编译链接
把 extern int global_counter; 这个符号的全过程串起来:
编译期
├─ main.c 写了 extern int global_counter;
│ → 编译器知道 global_counter 是"外部定义的"
│ → .o 文件 .symtab 中生成条目: U global_counter
│ → 任何引用 global_counter 的指令 → .rel.text 中生成重定位条目
│
├─ counter.c 写了 int global_counter = 0;
│ → 编译器放到 .bss (值=0)
│ → .o 文件 .symtab 中生成条目: B global_counter
链接期
├─ ld 扫描 main.o:
│ → 加入 E: main.o 的所有段
│ → 加入 D: main (T)
│ → 加入 U: global_counter ← 待解析!
│
├─ ld 扫描 counter.o:
│ → 加入 D: global_counter (B, .bss, 4字节)
│ → 发现 U 中的 global_counter 匹配了 D 中的 global_counter
│ → 从 U 中移除 global_counter
│ → .bss 合并进最终的 .bss 段
│
├─ ld 做重定位:
│ → main.o 中引用 global_counter 的指令 (R_X86_64_PC32)
│ → 计算: global_counter的最终地址 - 指令地址 - 4
│ → 填入指令的 4 字节偏移
│
└─ ld 生成可执行文件:
→ 程序头表: .bss 映射到 rw- 段
→ ELF 头中 entry = _start
运行期
├─ execve → 内核 mmap 各段
├─ .bss 映射到零页
├─ 程序访问 global_counter → MMU 翻译 → 缺页中断 → 分配物理页
└─ global_counter 从 0 开始计数
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 8.3 面试高频问题清单
1. nm 输出的 D/C/B/T/U 各代表什么?
D = .data(已初始化全局变量),C = COMMON(未初始化试探性定义),B = .bss(零初始化全局变量),T = .text(全局函数),U = 未定义(等链接器解析)。
2. 链接器从左到右扫描的规则是什么?链接顺序为什么重要?
单遍左到右:.o 直接合并,.a 按需提取(只有 U 中有符号需要它解析时才提取 .o)。一旦扫过,不再回头。所以依赖者放左边,被依赖者放右边。
-Wl,--start-group ... --end-group可以多遍扫描。
3. R_X86_64_PC32 和 R_X86_64_32 有什么区别?
PC32 填的是 RIP-relative 偏移(目标 − 当前位置),代码可重定位(PIC/PIE)。32 填的是 32 位绝对地址,受限于低 4 GB 空间,不支持 PIE。
4. 强符号和弱符号怎么仲裁?COMMON 是什么意思?
已初始化 = 强符号,未初始化全局 = COMMON(视为弱符号),显式
__attribute__((weak))= 显式弱。强 vs 强 = 报错,强 vs 弱 = 选强,弱 vs 弱 = 选最大。COMMON 是历史机制,允许多个文件定义同名未初始化变量。GCC 10+ 默认-fno-common关闭此行为。
5. 全局变量的初始化值为 0 和未初始化在 .o 里有什么不同?
int x = 0;进.bss(NOBITS,不占文件空间,运行时零页映射)。int x;(非 static)进 COMMON(允许链接器合并多个同名定义)。int x = 5;进.data(PROGBITS,文件中有实际字节)。
6. "undefined reference to" 错误怎么排查?
①
nm -u main.o看哪些符号是 U。②nm -D libfoo.so或nm libfoo.a看库里有没有对应符号。③ 检查链接顺序(依赖者在前)。④ 用--warn-unresolved-symbols看完整列表。⑤ C++ 中检查 name mangling:c++filt _Z3foov解码。
7. -ffunction-sections -Wl,--gc-sections 怎么瘦身?
编译时把每个函数/变量放到独立节(
.text.func,.data.var),链接时标记-清除:只保留从入口点可达的节。对于嵌入式或静态链接的二进制,可缩减 20~50% 的 .text 大小。
8. 静态库 .a 是什么?和 .o 有什么区别?
.a是.o文件的归档(ar 格式),带符号索引(__SYMDEF)。.o直接合并所有段,.a只在有 U 符号需要时才提取成员.o。多个.a的顺序很重要(单遍扫描,不回头)。
9. LTO 是什么?为什么能让二进制变小?
链接时优化(Link-Time Optimization):把优化从单个 .c 文件扩展到整个程序。在链接阶段重新做内联、死代码消除、常量传播——跨文件边界。二进制可能缩小 10~30%,但链接时间大幅增加。
10. PIC/PIE 和重定位有什么关系?
PIC (Position Independent Code) 用 RIP-relative 寻址(
R_X86_64_PC32)而不是绝对地址(R_X86_64_32)。这样代码可以加载到任意虚拟地址(ASLR 要求)。PIE (Position Independent Executable) 让整个可执行文件也支持随机基址加载。
# 8.4 链接器速查卡
符号表诊断命令:
# 看 .o 的符号
nm file.o # 全局符号
nm -a file.o # 所有符号(含本地)
nm -S file.o # 带大小
nm -u file.o # 只看未定义符号 (U)
nm --defined-only file.o # 只看已定义符号
# 看 .a 的符号和索引
nm -s libfoo.a # 带索引表的符号
ar -t libfoo.a # 列出 .o 成员
# 看 .so 的动态符号
nm -D libfoo.so
objdump -T libfoo.so
# C++ name mangling 解码
echo '_Z3foov' | c++filt # → foo()
nm -C file.o # 自动 demangle
# 最详细的信息
readelf -s file.o # 完整的符号表 dump
readelf -r file.o # 完整的重定位表 dump
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.o / ELF 结构诊断:
# 节头表
readelf -S file.o # 完整的节列表
objdump -h file.o # 简洁版
# 重定位表
objdump -r file.o # 重定位条目
readelf -r file.o # 完整版
# 反汇编 + 重定位 + 源码
objdump -d -r -S file.o # 最有用的组合命令
# 看段(可执行文件)
readelf -l a.out # 程序头表
objdump -p a.out # 程序头表 + 动态段
2
3
4
5
6
7
8
9
10
11
12
13
14
链接器选项速查:
# 追踪链接过程
gcc -Wl,--verbose main.o # 看链接器搜索路径和默认脚本
gcc -Wl,-Map=output.map main.o # 生成链接映射文件(超大但信息全)
# 符号冲突排查
gcc -Wl,--warn-common # COMMON 符号警告
gcc -Wl,--warn-unresolved-symbols # 未解析符号警告
# 瘦身
gcc -ffunction-sections -fdata-sections \ # 编译
-Wl,--gc-sections \ # 链接: 垃圾回收
-Wl,--print-gc-sections \ # 打印丢弃的节
-Wl,--icf=all \ # 相同函数合并
main.o foo.o -o app
# 链接顺序循环依赖
gcc main.o -Wl,--start-group libfoo.a libbar.a -Wl,--end-group
# LTO
gcc -flto -O2 -c *.c
gcc -flto -O2 *.o -o app
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
常见链接错误的排查路线:
| 错误信息 | 第一步排查 | 第二步 |
|---|---|---|
undefined reference to 'foo' | nm libfoo.a \| grep foo 看库里有没有 | 检查链接顺序(foo 的库放在使用者的右边) |
multiple definition of 'bar' | nm *.o \| grep bar 找哪些 .o 定义了 bar | 去重或加 static / -fno-common |
relocation truncated to fit | 检查是不是 32 位重定位碰到 >4GB 地址 | 加 -fPIC 或 -mcmodel=large |
cannot find -lfoo | ld --verbose \| grep SEARCH_DIR 看搜索路径 | -L/path/to/lib 指定目录 |
下一篇:15.静态库与动态库的设计 —— 我们已经知道"链接器怎么把 .o 和 .a 拼成可执行文件",下一步进入库设计:
.so的符号可见性控制、ABI 兼容性地狱、SONAME版本机制、dlopen插件体系。