编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
        • 目录
        • 1. 案例引入
          • 1.1 一行日志引发的血案
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 链接器位置
          • 2.2 链接器看到的世界
        • 3. ELF 目标文件结构
          • 3.1 节头表全景
          • 3.2 符号表 .symtab
          • 3.3 重定位表 .rel.text 与 .rel.data
          • 3.4 段 vs 节的差异
        • 4. 符号解析算法
          • 4.1 单遍左到右扫描
          • 4.2 静态库 .a 的按需提取
          • 4.3 链接顺序的依赖陷阱
          • 4.4 --start-group 拯救循环依赖
        • 5. 重定位填地址全流程
          • 5.1 重定位的核心问题
          • 5.2 RX8664_PC32 RIP相对寻址
          • 5.3 RX8664_32 绝对寻址
          • 5.4 减四原因
        • 6. 强弱符号仲裁机制
          • 6.1 强弱符号
          • 6.2 仲裁四矩阵与静默覆盖
          • 6.3 COMMON 符号的历史包袱
          • 6.4 防御策略
        • 7. 链接器瘦身技巧
          • 7.1 -ffunction-sections 与 -fdata-sections
          • 7.2 --gc-sections 垃圾回收
          • 7.3 --icf 合并相同函数
          • 7.4 LTO 链接时优化的红利
        • 8. 综合案例串讲
          • 8.1 案例真相揭晓
          • 8.2 符号编译链接
          • 8.3 面试高频问题清单
          • 8.4 链接器速查卡
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

链接器符号与重定位

# 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 一行日志引发的血案
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 链接器位置
    • 2.2 链接器看到的世界
  • 3. ELF 目标文件结构
    • 3.1 节头表全景
    • 3.2 符号表 .symtab
    • 3.3 重定位表 .rel.text 与 .rel.data
    • 3.4 段 vs 节的差异
  • 4. 符号解析算法
    • 4.1 单遍左到右扫描
    • 4.2 静态库 .a 的按需提取
    • 4.3 链接顺序的依赖陷阱
    • 4.4 --start-group 拯救循环依赖
  • 5. 重定位填地址全流程
    • 5.1 重定位的核心问题
    • 5.2 R_X86_64_PC32 RIP相对寻址
    • 5.3 R_X86_64_32 绝对寻址
    • 5.4 减四原因
  • 6. 强弱符号仲裁机制
    • 6.1 强弱符号
    • 6.2 仲裁四矩阵与静默覆盖
    • 6.3 COMMON 符号的历史包袱
    • 6.4 防御策略
  • 7. 链接器瘦身技巧
    • 7.1 -ffunction-sections 与 -fdata-sections
    • 7.2 --gc-sections 垃圾回收
    • 7.3 --icf 合并相同函数
    • 7.4 LTO 链接时优化的红利
  • 8. 综合案例串讲
    • 8.1 案例真相揭晓
    • 8.2 符号编译链接
    • 8.3 面试高频问题清单
    • 8.4 链接器速查卡

# 1. 案例引入

# 1.1 一行日志引发的血案

某游戏服务端上线了一个"无足轻重"的新功能——把玩家的登录时间写进一个全局的 login_timestamp 变量。代码看着极简单:

// login_tracker.c —— 新加的登录时间跟踪模块
int login_timestamp;    // ← 全局变量,未初始化

void set_login_time(int ts) {
    login_timestamp = ts;
}
1
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;
}
1
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!
1
2
3
4
5
6

更恐怖的是——如果用 gdb 打断点:

(gdb) print &login_timestamp
$1 = (int *) 0x404030   # 两个文件中的 login_timestamp 是同一个地址!
(gdb) print login_timestamp
$2 = 0                  # 未初始化的那个值"赢了"
1
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(未初始化全局)
1
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:

  1. main.o: D(强符号,值 = -1)
  2. login_tracker.o: C(COMMON,4字节,0-initialized)
  3. 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 章
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个事故是一个**链接器"静默仲裁"**的典型案例——两个文件定义了同名全局变量,链接器没有报错,选择了"错"的版本,然后运行时数据静默出错。没有 segfault,没有警告,只有输出的数字不对。

本篇路线:

架构总图 (第 2 章)
   ↓
ELF 目标文件结构 (第 3 章) ─→ 解开".o 文件里到底存了什么"
   ↓
符号解析算法 (第 4 章) ─→ 解开"链接器怎么找到符号的"
   ↓
重定位填地址 (第 5 章) ─→ 解开"call 指令的地址怎么补全"
   ↓
强弱符号 (第 6 章) ─→ 解开"同名全局变量谁赢谁输" ← 本篇核心案例
   ↓
链接器瘦身 (第 7 章) ─→ 解开"怎么让 binary 变小"
   ↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
1
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 (动态库)                        └────────────┘
1
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 替换成"当前指令到目标符号的偏移"      │
│                                                            │
└────────────────────────────────────────────────────────────┘
1
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 → 空节,但链接后会有空间
1
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 段,已初始化全局变量
1
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           │
└─────────────────────────────────────────────────────────┘
1
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;
1
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                 # 另一种风格的符号表
1
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
1
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"
1
2

一个段包含多个节:

LOAD segment (r-x):           LOAD segment (rw-):
  ├─ .text                      ├─ .data
  ├─ .rodata                    ├─ .bss
  ├─ .plt                       └─ .got
  └─ ... (其他只读+可执行)
1
2
3
4
5

关键命令对比:

readelf -S file.o         # 看节(链接器视角)
readelf -l a.out           # 看段(加载器视角)
objdump -h file.o          # 简洁版节头
1
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 符号:                     │
  │            → 跳过(不提取)                                 │
  └──────────────────────────────────────────────────────────┘
1
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!
1
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
1
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
1
2
3
4
5
6
7
8
9
10
11
12

没有索引的 .a 文件,链接器需要逐一遍历所有 .o 成员——非常慢。ranlib 命令可以生成/更新这个索引。

按需提取的关键规则:

  1. .a 中的 .o 只在有 U 符号需要它解析时才被提取
  2. 提取是递归的——提取的 .o 可能引入新的 U 符号,需要后续文件来解析
  3. 已经扫过的位置不会回头——这就是链接顺序问题的根源

# 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'
1
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
#                   ↑     ↑
#                 依赖者 被依赖者
1
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` 让链接器反复扫描组内的库,
# 直到没有新的符号被解析为止。

# 代价:链接变慢(可能多遍扫描)
# 收益:不需要关心组内的链接顺序
1
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;
}
1
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
1
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
1
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
1
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 相对而不是绝对地址:

  1. 位置无关代码 (PIC/PIE):如果填绝对地址,代码必须加载到固定地址;PC 相对让代码可以放在任何位置
  2. 指令更短:e8 xx xx xx xx(5 字节)vs ff 15 xx xx xx xx 间接调用(6 字节)

# 5.3 R_X86_64_32 绝对寻址

访问全局变量时常用绝对寻址(但受限于低 4 GB 地址空间):

int global_var = 42;
int get_global() { return global_var; }
1
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
1
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
1

这个 -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
  ... 实操中用更简洁的公式直接算
1
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;                 // 只是声明,不定义符号
1
2
3
4
5
6
7
8
9
10
11

判定规则:

  1. 已初始化全局变量 / 函数体有定义的 → 强符号
  2. 未初始化全局变量(非 static、非 extern)→ COMMON(弱)
  3. static 全局 → 本地符号,不存在链接冲突
  4. __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
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 字节 → 垃圾值
1
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 模式)
1
2

# 6.4 防御策略

手段 效果 代价
gcc -fno-common (GCC 10+ 默认) 把 COMMON 变成 .bss,重复定义报错 旧的 K&R 代码可能编不过
gcc -Wl,--warn-common 链接时对 COMMON 合并发出警告 只有警告,不阻止
static 修饰全局变量 将符号本地化,不同文件的同名符号互不影响 不能跨文件共享
extern 声明 + 唯一定义 消除"试探性定义"歧义 需要额外的 .c 文件

最佳实践:

  1. 全局变量只在一个 .c 中定义(int x = 0;),其他 .c 用 extern int x;
  2. 不需要跨文件的变量加 static
  3. 编译加 -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
1
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'
1
2
3
4
5
6
7

垃圾回收算法(标记-清除):

1. 标记根:入口点 (_start, main) 的节、.init_array/.fini_array
2. 遍历重定位引用:如果一个被标记的节引用了另一个节的符号,
   就把被引用的节也标记
3. 丢弃未被标记的所有节
1
2
3
4

保护特定符号不被回收:

__attribute__((used, retain)) void critical_func() { }
// 'used' 告诉编译器别优化掉
// 'retain' 告诉链接器别 gc 掉
1
2
3

或者在链接脚本中:

KEEP(*(.init_array))
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*)))
1
2

# 7.3 --icf 合并相同函数

ICF (Identical Code Folding):如果两个函数编译后的机器码完全相同,合并成一个:

int add_one(int x) { return x + 1; }
int inc_one(int x) { return x + 1; }     // ← 汇编完全一样
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'
# 链接器发现两个函数体相同 → 合并为一个,节省空间
1
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 在链接阶段重新做全程序的优化
1
2
3
4

LTO 能做什么传统的编译做不了的事:

  1. 跨文件内联:foo() 调用 bar(),即使它们在不同 .c 中,LTO 也能内联
  2. 跨文件死代码消除:如果整个程序都不调用 bar(),那 bar 的 .o 中的静态函数也能被消除
  3. 跨文件常量传播:const int x = 5; 在文件 A,f(x) 在文件 B → 可以直接替换为 f(5)
  4. 全局变量的 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
1
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;    // 不定义,只声明
1
2
3
4
5
6

方案 B:加 static 本地化

// login_tracker.c:
static int login_timestamp;    // ← 加了 static,不和 main.c 的冲突
1
2

代价:不能跨文件共享。

方案 C:-fno-common 强制报错 (GCC 10+ 默认)

$ gcc -fno-common main.o login_tracker.o
ld: multiple definition of `login_timestamp'  # ← 显式报错
1
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 开始计数
1
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
1
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                # 程序头表 + 动态段
1
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
1
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 插件体系。

上次更新: 2026/06/11, 09:01:44
编译到汇编全流程
静态库与动态库对比

← 编译到汇编全流程 静态库与动态库对比→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式