编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
        • 1. 案例引入
          • 1.1 链接顺序灾——libB 在 libA 前面→undefined reference
          • 1.2 强弱符号重叠
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 输入与输出
          • 2.2 为何这么切
        • 3. 符号解析
          • 3.1 符号表的结构
          • 3.2 未定义符号的搜索路径
          • 3.2.5 链接器内部——符号表的数据结构
          • 3.3 增量提取
          • 3.4 为什么链接顺序至关重要
        • 4. 重定位
          • 4.1 重定位表
          • 4.2 两种最常见重定位类型
          • 4.3 数据段的重定位
          • 4.4 汇编层视角
        • 5. 强符号与弱符号
          • 5.1 强弱符号规则——初始化 vs 未初始化 vs _attribute_((weak))
          • 5.2 弱符号的典型用例
          • 5.3 COMDAT与模板机制
        • 6. 静态库 .a——归档文件的内在机制
          • 6.1 .a 文件的结构
          • 6.2 对 .a 的处理方式
          • 6.3 循环依赖的解决方案
        • 7. --gc-sections——垃圾回收未使用的段
          • 7.1 段级可达性分析
          • 7.2 前置条件
          • 7.3 实际收益
        • 8. 常见陷阱与反模式
          • 8.1 静态库顺序陷阱
          • 8.2 同名的全局变量
          • 8.3 inline 函数地址不唯一
          • 8.4 静态初始化顺序
        • 9. 综合案例串讲
          • 9.1 案例真相揭晓
          • 9.2 一次完整的链接过程
          • 9.3 设计哲学回扣
          • 9.4 速查表合集
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-06
目录

链接器工作原理

# 50.链接器工作原理

# 目录介绍

  • 1. 案例引入
    • 1.1 链接顺序灾
    • 1.2 强弱符号重叠
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 输入与输出
    • 2.2 为何这么切
  • 3. 符号解析
    • 3.1 符号表的结构
    • 3.2 未定义符号的搜索路径
    • 3.3 增量提取
    • 3.4 为什么链接顺序至关重要
  • 4. 重定位
    • 4.1 重定位表
    • 4.2 两种最常见重定位
    • 4.3 数据段的重定位
    • 4.4 汇编层视角
  • 5. 强符号与弱符号
    • 5.1 强弱符号规则
    • 5.2 弱符号的典型用例
    • 5.3 COMDAT 与模板
  • 6. 静态库 .a
    • 6.1 .a 文件的结构
    • 6.2 对 .a 的处理方式
    • 6.3 循环依赖的解决方案
  • 7. --gc-sections
    • 7.1 段级可达性分析
    • 7.2 前置条件
    • 7.3 实际收益
  • 8. 常见陷阱与反模式
    • 8.1 静态库顺序陷阱
    • 8.2 同名的全局变量
    • 8.3 inline 函数地址不唯一
    • 8.4 静态初始化顺序
  • 9. 综合案例串讲
    • 9.1 案例真相揭晓
    • 9.2 一次完整的链接过程
    • 9.3 设计哲学回扣
    • 9.4 速查表合集

# 1. 案例引入

# 1.1 链接顺序灾——libB 在 libA 前面→undefined reference

某金融系统的模块编译单独通过——但最终链接时报了一堆 undefined reference:

$ g++ main.o -lA -lB -lC -o app
/usr/bin/ld: main.o: undefined reference to `ModuleA::init()'
/usr/bin/ld: libB.a(logger.o): undefined reference to `ModuleA::flush()'
1
2
3

排查——nm 确认 ModuleA::init 在 libA.a 中有定义:

$ nm libA.a | grep init
0000000000000000 T _ZN7ModuleA4initEv    # ✅ 这里有定义!
1
2

根因:链接器从左到右扫描命令行。扫描到 libB.a 时:

  1. libB.a(logger.o) 引用了 ModuleA::flush() → 添加到「未解析列表」
  2. 此时 libA.a 还没被扫描——ModuleA::flush 不在当前已知符号中
  3. 链接器向前找——没有更多的库了 → undefined reference

修复——把被依赖的库放在后面:

$ g++ main.o -lB -lA -lC -o app   # ✅ libA 在 libB 之后——链接器找到符号
1

# 1.2 强弱符号重叠

// config.cpp
int max_connections = 100;    // 强符号——已初始化

// defaults.cpp
int max_connections;           // 弱符号——未初始化(C 语言中的「暂定定义」)
// 链接器:选择 config.cpp 的强符号 max_connections = 100 ✅

// 但如果是两个已初始化的强符号:
// config.cpp
int max_connections = 100;

// main.cpp
int max_connections = 200;     // ❌ 链接错误——重复符号
1
2
3
4
5
6
7
8
9
10
11
12
13

更隐蔽的陷阱——两个 .o 中的弱符号类型不同但大小相同:

// a.cpp
int counter = 0;    // 4 字节

// b.cpp  
float counter;       // 也是 4 字节——弱符号
// 链接器选择 a.cpp 的强符号——b.cpp 中所有对 counter 的 float 操作
// 实际上读的是 int 的内存——数据错误但无链接错误!
1
2
3
4
5
6
7

# 1.3 七个待解疑问

① 链接器的输入是什么?.o 文件里有什么信息让链接器干活?                 → 第 2 / 第 3 章
② 链接器怎么找「未定义符号」的定义?什么时候报告 undefined reference?    → 第 3 章
③ 重定位是什么?call 指令怎么变成「跳到具体地址」的?                    → 第 4 章
④ 强符号和弱符号的区别?链接器在两个同名符号间怎么选?                     → 第 5 章
⑤ 静态库 .a 和一堆 .o 有什么区别?链接器怎么从 .a 里「取出」需要的?       → 第 6 章
⑥ --gc-sections 怎么工作?为什么有时要加 -ffunction-sections?            → 第 7 章
⑦ 链接顺序为什么重要?循环依赖怎么办?                                     → 第 8 章
1
2
3
4
5
6
7

# 2. 架构概览

# 2.1 输入与输出

输入:
  main.o      (来自 main.cpp 编译)
  libfoo.a    (libfoo 的静态库)
  libbar.so   (libbar 的动态库)
  crt1.o      (C 运行时的启动代码——_start → __libc_start_main → main)
  ...

        │
        ▼
┌──────────────────────────────────────────────┐
│              链接器 (ld / gold / lld)         │
│                                              │
│  阶段 1:符号解析 (Symbol Resolution)          │
│    - 收集所有 .o 和 .a 中的符号定义            │
│    - 为所有未定义符号查找定义                   │
│    - 报告 undefined reference 错误            │
│                                              │
│  阶段 2:段合并 (Section Merging)              │
│    - 把所有 .o 的 .text 合并为一个 .text       │
│    - 把所有 .o 的 .data 合并为一个 .data       │
│    - 重定位——修正所有地址引用                   │
│                                              │
│  阶段 3:输出布局 (Layout)                     │
│    - 确定各段在最终文件中的偏移                 │
│    - 写入 ELF 头、程序头、段表                  │
│                                              │
│  阶段 4:动态链接信息 (Dynamic Linking)        │
│    - 写入 PLT/GOT 条目(用于 .so 的函数调用)   │
│    - 写入 .dynamic 段和动态符号表               │
└──────────────────────┬───────────────────────┘
                       │
                       ▼
输出:app (可执行文件 ELF)
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

# 2.2 为何这么切

符号解析(第一阶段):回答「这个符号在哪个 .o / .a 里?」
  需要扫描所有输入文件——收集定义、匹配引用
  输出:符号→地址的映射表

重定位(第二阶段):回答「这个 call 指令调用地址应该填什么?」
  第一阶段完成后——每个符号都有地址了
  扫描所有重定位表——把占位符 (0x00000000) 替换为真实地址

两者分开——因为第二阶段只能在「所有符号都已知」之后进行。
1
2
3
4
5
6
7
8
9

# 3. 符号解析

# 3.1 符号表的结构

$ nm main.o
0000000000000000 T main                    # T = .text 中的全局符号
0000000000000000 D global_counter          # D = .data 中的全局符号
                 U _ZN6Logger3logEi        # U = 未定义符号(需要链接器找)
0000000000000000 t static_helper           # t = .text 中的本地符号(static)
0000000000000000 W _Z4maxIiET_S0_S0_       # W = 弱符号(模板实例化)
1
2
3
4
5
6

符号类型标记速查:

字母 含义 链接器行为
T 代码段中的强符号 唯一定义——重复报错
t 代码段中的本地符号 不跨 .o 可见
D 数据段中的强符号 唯一定义
B BSS 段中的未初始化全局变量 不会出现在文件中——运行时加载器分配
U 未定义引用 必须被解析——否则 undefined reference
W 弱符号 可被同名的 T 或 D 覆盖
w 未初始化的弱符号 同上

# 3.2 未定义符号的搜索路径

链接器在命令行输入中扫描——顺序从左到右:

① 先处理所有显式给出的 .o 文件(从左到右)
   → 收集所有定义的符号(T/D/B/W/v)
   → 记录所有未定义的符号(U)

② 然后处理 -l 选项指定的库(从左到右)
   对每个 .a 库:
     → 检查库中是否有「当前未解析的 U 符号」
     → 如果有——从库中提取「包含该符号的 .o 文件」
     → 加入该 .o 的符号定义——可能引入新的 U 符号
     → 继续下一个 .o——不回头

③ 如果扫描完仍有 U 符号——报 undefined reference
1
2
3
4
5
6
7
8
9
10
11
12
13
14

关键: 链接器是单遍的、从左到右的。如果一个库的前面需要后面库中的符号——链接器看不到——必须调整顺序。

# 3.2.5 链接器内部——符号表的数据结构

链接器内部维护三个核心表:

① 全局符号表 (symbol hash table):
   key = 符号名 (mangled name)
   value = {地址, 类型 (T/D/U/W), 所在的 .o 文件}
   → 用于查找、检查重复定义
   → 哈希冲突通过链地址法解决

② 未解析列表 (undefined list):
   当前所有 U 符号的列表
   → 每扫描一个 .o 或库 → 检查这个列表中的条目能否被满足

③ 库符号索引 (archive symbol index):
   从 libfoo.a 的 __.SYMDEF 读到的映射
   → {符号名 → 所在 .o 文件名}

符号表的典型大小:
  中等 C++ 项目 (200 个 .cpp):~20000 个已定义符号 + ~15000 个未定义引用
  链接器用哈希表实现——O(1) 平均查找
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 3.3 增量提取

libfoo.a 的内部结构:
  libfoo.a = foo1.o + foo2.o + foo3.o (通过 ar 打包)
  附加:符号索引 (__.SYMDEF)——记录「哪个符号在哪个 .o 中」

链接器处理 libfoo.a 的流程:
  ① 读取索引 → 建立「符号→.o 文件」的映射
  ② 遍历当前未解析符号列表 → 查找索引
  ③ 如果 foo1.o 提供了某个未解析符号 → 从 .a 中提取 foo1.o
     → 将 foo1.o 的代码和数据合并到最终二进制
     → 同时——foo1.o 可能引入新的未解析符号(foo1.o 自己的 U 符号)
  ④ 继续检查——新引入的 U 符号是否在库的其他 .o 中
  ⑤ 库扫描完——不再回头
1
2
3
4
5
6
7
8
9
10
11
12

不是整个 .a 被链接——只提取需要的 .o。 这是静态链接的核心优势——未使用的 .o 不进入最终二进制。

# 3.4 为什么链接顺序至关重要

案例 1.1 的根因在这里完整展开:

命令行:g++ main.o -lB -lA

链接器视角:

① 扫描 main.o:
   定义: main
   未解析: ModuleB::process

② 扫描 -lB (libB.a):
   检查索引→ ModuleB::process 在 b.o → 提取 b.o
   b.o 中引入新的未解析符号: ModuleA::init, ModuleA::flush
   检查索引→ libB.a 中没有 ModuleA 相关符号 → 跳过

③ 扫描 -lA (libA.a):
   检查索引→ ModuleA::init, ModuleA::flush 都在 a.o → 提取 a.o
   ✅ 所有未解析符号找到

但如果是:
  g++ main.o -lA -lB  (A 在前,B 在后)

① 扫描 main.o → U: ModuleB::process
② 扫描 -lA → libA.a 提供 ModuleA::init, ModuleA::flush
   但 main.o 不需要这些——libA 没有被提取!
③ 扫描 -lB → libB 提供 ModuleB::process → 提取 b.o
   b.o 需要 ModuleA::init → 但 -lA 已经扫过了——不回头!
   → undefined reference: ModuleA::init ❌
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

# 4. 重定位

# 4.1 重定位表

每个 .o 文件中都有一个重定位表(.rela.text / .rela.data)

重定位条目的结构:
  offset:  在段中的偏移 (需要修改哪个字节)
  type:    重定位类型 (怎么算地址)
  symbol:  引用哪个符号
  addend:  加数 (在基址上加多少)

objdump -r 查看:
$ objdump -r main.o
0000000000000022 R_X86_64_PC32  _ZN6Logger3logEi-0x00000004
  ↑ offset                    ↑ type         ↑ symbol       ↑ addend
1
2
3
4
5
6
7
8
9
10
11
12

# 4.2 两种最常见重定位类型

R_X86_64_PC32 (PC 相对寻址——32 位偏移):
  用于:同一 .o 内的函数调用、静态链接的外部函数调用
  计算:target_addr + addend - (current_addr + offset)
  限制:偏移必须在 ±2GB 内

R_X86_64_PLT32 (PLT 相对寻址——32 位偏移):
  用于:动态库中的函数调用(通过 PLT 跳板)
  和 PC32 相同——但链接器知道目标在 PLT 中
  
R_X86_64_64 (64 位绝对地址):
  用于:64 位全局变量的地址引用
  计算:target_addr + addend
  链接器在 .got / .data 段中写入完整 64 位地址

真实重定位过程:
  .o 中 call 指令的机器码:e8 00 00 00 00
  后 4 字节是 0——占位符
  链接器计算 PC32 偏移 → 0x00001234
  修改后:e8 34 12 00 00
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 4.3 数据段的重定位

extern int global_counter;          // 在另一个 .o 中定义
int* ptr = &global_counter;         // 需要链接器填地址
1
2
; ptr 在 .data 段中:
; 未重定位:.quad 0x0000000000000000    ← 占位
; 重定位条目:R_X86_64_64 global_counter
; 链接后:.quad 0x0000000000404020       ← global_counter 的真实地址
1
2
3
4

和函数调用的关键差异:

  • 函数调用用 PC32(相对偏移)——省空间、支持位置无关代码
  • 全局变量地址用 64(绝对地址)——因为是数据段不是代码段,不能相对寻址

# 4.4 汇编层视角

# 编译但不链接
$ g++ -c main.cpp -o main.o

# 查看汇编 + 重定位
$ objdump -dr main.o

0000000000000000 <main>:
   0:   push   %rbp
   1:   mov    %rsp,%rbp
   4:   call   9 <main+0x9>
        5: R_X86_64_PLT32  _ZN6Logger3logEi-0x4    ← 重定位条目
   # 汇编中是 call 9(占位符=0)
   # 重定位表说:在偏移 5 处填 PLT32(_ZN6Logger3logEi)
1
2
3
4
5
6
7
8
9
10
11
12
13

# 5. 强符号与弱符号

# 5.1 强弱符号规则——初始化 vs 未初始化 vs attribute((weak))

强符号(编译器默认):
  int x = 42;             // 已初始化的全局变量
  void func() { ... }     // 函数定义

弱符号:
  int x;                  // C 中的「暂定定义」(tentative definition)
  __attribute__((weak)) void func() { ... }
  template &lt;typename T> void max(T a, T b) { ... }  // 模板实例化

链接器仲裁——同名符号:
  ① 一个强 + 零/多弱 → 选强 ✅
  ② 零强 + 一弱 → 选弱 ✅
  ③ 零强 + 多弱 → 任选其一 ✅(任意——但内容应该相同)
  ④ 多强 → ❌ 链接错误——重复符号
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 5.2 弱符号的典型用例

// 库的默认实现——弱符号
__attribute__((weak)) void error_handler(const char* msg) {
    fprintf(stderr, "Error: %s\n", msg);
    exit(1);
}

// 用户可以在自己的 .c/.cpp 中提供强符号版本——覆盖默认
void error_handler(const char* msg) {
    // 用户自定义的错误处理——发送到远程日志服务
    send_to_remote_logger(msg);
    exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12

链接器看到两个 error_handler 符号——一个强的(用户版)、一个弱的(库版)→ 选强。

# 5.3 COMDAT与模板机制

第 49 篇已提到——这里从链接器视角展开:

模板函数在每个使用它的 TU 中实例化一次:
  a.o: _Z4maxIiET_S0_S0_ (W - 弱符号)
  b.o: _Z4maxIiET_S0_S0_ (W - 弱符号)
  c.o: _Z4maxIiET_S0_S0_ (W - 弱符号)

链接器处理:
  ① 三个 .o 都定义了同名的弱符号
  ② 弱符号互不冲突——链接器任选一个(通常选第一个扫描到的)
  ③ 丢弃其他两个副本——只保留一份代码 + COMDAT 段标记
  ④ 最终二进制中:一份 max&lt;int> 的代码

GCC 的实现:把每个 inline/template 函数放在独立的 .gnu.linkonce 段
  链接器看到同名的 .gnu.linkonce 段 → 只保留第一份
1
2
3
4
5
6
7
8
9
10
11
12
13

# 6. 静态库 .a——归档文件的内在机制

# 6.1 .a 文件的结构

# 创建静态库
$ ar rcs libfoo.a foo1.o foo2.o foo3.o

# 查看内容
$ ar t libfoo.a
foo1.o
foo2.o
foo3.o
__.SYMDEF     # ← 符号索引——加速链接器查找

# libfoo.a 的结构:
┌─────────────────────────────────────┐
│  ar magic ("!<arch>\n")              │
├─────────────────────────────────────┤
│  __.SYMDEF                         │  ← 映射:符号名 → 所在 .o 文件
│    符号1 → foo1.o                   │
│    符号2 → foo1.o                   │
│    符号3 → foo2.o                   │
├─────────────────────────────────────┤
│  foo1.o(完整 ELF .o 文件)         │
├─────────────────────────────────────┤
│  foo2.o                            │
├─────────────────────────────────────┤
│  foo3.o                            │
└─────────────────────────────────────┘
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

ranlib(ar s 中的 s)生成 __.SYMDEF 索引——没有这个索引链接器需要线性扫描库中所有 .o 文件。

# 6.2 对 .a 的处理方式

已在 3.3 和 3.4 详细展开。核心规则重复:

  1. 单遍扫描——处理过的 .a 不回头
  2. 按需提取——只取需要的 .o
  3. 提取后可能引入新未解析符号——在同一个库内继续找——但库处理完后放弃

# 6.3 循环依赖的解决方案

libA 依赖 libB 的符号、libB 依赖 libA 的符号——循环依赖

传统方案——命令行重复:
  g++ main.o -lA -lB -lA -lB
  → libA 被扫描两遍——第二遍能找到 libB 需要的但之前遗漏的符号

GNU ld 方案——组循环扫描:
  g++ main.o -Wl,--start-group -lA -lB -Wl,--end-group
  → --start-group 和 --end-group 之间的库被反复扫描
  → 直到不再有新符号被解析——或达到最大迭代次数

gold / lld 更智能——自动处理循环依赖——不需要 --start-group
1
2
3
4
5
6
7
8
9
10
11
12

# 7. --gc-sections——垃圾回收未使用的段

# 7.1 段级可达性分析

原理:
  ① 编译时:-ffunction-sections -fdata-sections
     → 每个函数 → 独立的 .text.&lt;funcname> 段
     → 每个变量 → 独立的 .data.&lt;varname> 段

  ② 链接时:--gc-sections
     → 从入口点 (_start) 开始标记所有引用的段
     → 递归遍历——标记传递闭包中所有可达的段
     → 丢弃不可达的段——省空间 + 省加载时间

  这和 GC 的「标记-清除」算法是同构的——只是作用在「段」层而非「对象」层。
1
2
3
4
5
6
7
8
9
10
11

# 7.2 前置条件

# 没有 -ffunction-sections——所有函数在同一个 .text 段
$ g++ -c widget.cpp -o widget.o
$ objdump -h widget.o | grep .text
  .text         000001a0  全在一个段——gc-sections 无法分开

# 有 -ffunction-sections——每个函数独立一段
$ g++ -c -ffunction-sections widget.cpp -o widget.o
$ objdump -h widget.o | grep .text
  .text._ZN6WidgetC1Ei     00000020  Widget::Widget(int)
  .text._ZN6WidgetD1Ev     00000018  Widget::~Widget()
  .text._ZN6Widget7processEv 00000030  Widget::process()
  .text._ZN6Widget5resetEv 00000028  Widget::reset()
  # 每个函数独立——链接器可以分别保留/丢弃
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7.3 实际收益

# 不使用 --gc-sections
$ g++ main.o -lfoo -o app
$ size app
   text    data     bss     dec     hex
 245678    8456    1234  255368   3e588

# 使用 --gc-sections
$ g++ main.o -lfoo -o app -Wl,--gc-sections -ffunction-sections -fdata-sections
$ size app
   text    data     bss     dec     hex
 182340    7200    1234  190774   2e936
# text 段减小 ~26%——主要来自未调用的模板实例化和小工具函数
1
2
3
4
5
6
7
8
9
10
11
12

--gc-sections 的边界情况:

  1. 构造函数注册表:如果用了 __attribute__((constructor))——链接器可能把「只被构造函数引用的函数」误判为不可达→丢弃
  2. 虚函数表:如果整个类都没有被实例化——但 vtable 被保留(因为 .rodata 的引用链)→浪费
  3. 显式模板实例化:template class Foo<int>; 强制生成所有成员函数——即使没有调用——gc-sections 不能丢弃(因为是显式请求的强符号)

和动态库的对比:

  • 静态链接:gc-sections 只保留需要的段——效果好
  • 动态库(.so):整个 .so 被加载——即使 --gc-sections 在编译 .so 时工作——运行时整个 .so 仍在内存中 → 动态库的「未使用代码消除」需要更激进的手段——如把库拆成多个小的 .so

# 8. 常见陷阱与反模式

# 8.1 静态库顺序陷阱

案例 1.1 的完整解决方案:

# ❌ 被依赖方在前
g++ main.o -lA -lB    # libB 需要 libA 的符号——但 libA 被扫时 libB 还没被扫

# ✅ 依赖方在前
g++ main.o -lB -lA    # libB 的未解析符号在后续扫描 libA 时被满足

# ✅ 组循环(GNU ld)
g++ main.o -Wl,--start-group -lA -lB -Wl,--end-group

# ✅ CMake 自动处理——不需要手动排顺序
target_link_libraries(myapp libA libB)  # CMake 自动生成正确的顺序
1
2
3
4
5
6
7
8
9
10
11

# 8.2 同名的全局变量

案例 1.2 的变种——static 在 TU 内隔离 vs 全局变量的跨 TU 冲突:

// a.cpp
int counter = 100;          // 强符号

// b.cpp
int counter;                // C 弱符号——链接器选择 a.cpp 的 100
                            // b.cpp 以为 counter 是 0——实际是 100!
// 解决方法:避免使用 C 风格的「暂定定义」
// b.cpp:int counter = 0;   // 强符号→链接器报重复定义错误——及早发现
1
2
3
4
5
6
7
8

# 8.3 inline 函数地址不唯一

// common.h
inline int id() { return 42; }

// a.cpp
auto ptr_a = &id;   // ptr_a = 0x401000

// b.cpp
auto ptr_b = &id;   // ptr_b = 0x402000  ← 不同的地址!

// 虽然链接器通过 COMDAT 只保留了一份函数体——
// 但取函数地址的操作在编译期就决定了——每个 .cpp 编译时指向不同的偏移
1
2
3
4
5
6
7
8
9
10
11

C++17 inline 变量保证了跨 TU 唯一地址——函数不是变量——在 inline 函数中 &function 仍不保证唯一。

# 8.4 静态初始化顺序

// a.cpp
std::string global_name = "hello";   // 静态初始化——生成 __cxx_global_var_init

// b.cpp
extern std::string global_name;
int name_len = global_name.size();   // ⚠️ 如果 b.cpp 的初始化先于 a.cpp → UB
// 链接器把 __cxx_global_var_init 放在 .init_array 段——但不保证顺序!
1
2
3
4
5
6
7

# 9. 综合案例串讲

# 9.1 案例真相揭晓

# 疑问 答案
① 链接器输入? 第 3.1:每个 .o 有符号表(T/D/U/W)和重定位表
② 怎么找符号? 第 3.2-3.4:单遍从左到右——.o 全部收纳、.a 按需提取——不回头
③ 重定位做什么? 第 4 章:把 call 指令的占位符 0x00000000 替换为计算好的 PC32/PLT32 偏移
④ 强弱符号怎么选? 第 5.1:遇到强 + 弱→选强、多强→报错、多弱→选其一
⑤ .a 和 .o 的区别? 第 6 章:.a = 归档的 .o + 符号索引——链接器只提取需要的 .o
⑥ gc-sections 怎么工作? 第 7 章:把每个函数/变量独立分段——从入口点标记可达段——丢弃不可达
⑦ 链接顺序重要? 第 8.1:单遍扫描——依赖方必须在前、被依赖方在后

案例①修复——链接顺序:g++ main.o -lB -lA ——依赖 libA 的 libB 放在前面。

案例②修复——强弱符号:用 -Wl,--warn-common 在链接时打印强弱符号冲突——把未初始化的全局变量改成显式初始化。

# 9.2 一次完整的链接过程

三个 .o 文件:main.o, logger.o, widget.o

═══════ 阶段 1:符号解析 ═══════

① 扫描 main.o:
   符号表:U _Z5startv, T main
   已知定义:main
   未解析:_Z5startv

② 扫描 widget.o:
   符号表:T _ZN6WidgetC1Ei, U _ZN6Logger3logEi
   已知定义:main, Widget::Widget(int)
   未解析:_Z5startv, Logger::log(int)

③ 扫描 logger.o:
   符号表:T _ZN6Logger3logEi, T _Z5startv
   已知定义:+ Logger::log(int), + start()
   未解析:(清空——全部找到了!)

═══════ 阶段 2:段合并 + 排序 ═══════

  所有 .text 段合并 → 一个 .text 段 (0x401000 起)
  所有 .data 段合并 → 一个 .data 段 (0x404000 起)
  所有 .rodata 段合并 → 一个 .rodata 段
  → 确定每个符号的最终地址

═══════ 阶段 3:重定位 ═══════

  main.o 中的 call _Z5startv:
    重定位前:e8 00 00 00 00
    重定位后:e8 xx xx xx xx (start 的真实 PC32 偏移)

  widget.o 中的 call _ZN6Logger3logEi:
    同上——替换占位符为真实偏移

═══════ 阶段 4:输出 ═══════

  写入 ELF 头 (file format + entry point)
  写入段表 (各段在文件中的位置 + 大小)
  写入符号表 (.symtab)
  写入最终二进制 → app
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
37
38
39
40
41

# 9.3 设计哲学回扣

哲学 1:链接器是「补洞工」——不是编译器、不是魔法师

编译器的每个 .o 文件里满是可以优化的代码片段和未填充的地址洞。链接器的工作不是生成新代码——是填补这些洞(重定位)和合并代码片段(段合并)。它的强大来自它的简单——一个单遍的、确定性的缝合机。

哲学 2:链接顺序的敏感性不是 bug——是单遍扫描的效率代价

如果链接器是双遍的——可以解决所有顺序问题。但单遍 = 不回头意味着不需要缓存所有符号、不需要磁盘 IO 回溯。1970 年代的链接器设计在内存不足的约束下做出了这个决定——今天的链接器(gold/lld)虽然内存充裕,但仍沿用了这个模型以保证兼容性和可预测性。

哲学 3:强弱符号不是魔法——是链接器在「定义冲突」时的自动裁决规则

C 语言的「暂定定义」(int x; 不初始化 = 弱符号)是为了让多个 .c 文件可以声明同一个全局变量而设计的。C++ 放弃了暂定定义——但 inline、template 继承了弱符号的语义。弱符号的本质:让「多份代码」在链接器中变成「一份代码」——不报错、静默削重。

哲学 4:静态库是最简单的代码分发机制——一个 .a 就是一个「按需加载的 .o 超市」

链接器走进 .a 这个超市——拿着「未解析符号」的购物清单——只拿需要的那几件 .o——然后继续赶路。这个模型在 40 年来没有本质变化——因为它简单、可预测、不需要额外的运行时支持。

# 9.4 速查表合集

nm 符号字母速查:

字母 含义 冲突行为
T 代码段强符号 重复→错误
D 数据段强符号 重复→错误
B BSS 未初始化全局 弱→被强覆盖
U 未定义引用 必须被解析
W 弱符号(模板/内联) 多弱→选其一
t / d 本地符号 (static) 不跨 .o 可见

链接顺序规则:

正确顺序:依赖方 → 被依赖方
  命令行中——需要符号的 .o/.a 放在提供符号的 .o/.a 之前

循环依赖:--start-group ... --end-group
  两组之间的库被反复扫描——直到不再有新符号被解析
1
2
3
4
5

重定位类型速查:

类型 适用场景 公式
R_X86_64_PC32 同一 .o 内函数调用、静态链接外部函数 target + addend - PC
R_X86_64_PLT32 动态库函数调用(通过 PLT) 同上
R_X86_64_64 64 位全局变量地址 target + addend

--gc-sections 配置:

# 编译时——每个函数/变量独立分段
CFLAGS += -ffunction-sections -fdata-sections

# 链接时——丢弃不可达的段
LDFLAGS += -Wl,--gc-sections
1
2
3
4
5

本篇小结:链接器把一堆 .o 文件缝合成一个可执行文件——符号解析(找定义)+ 重定位(填充地址)+ 段合并(布局)。静态库 .a 是按需提取的 .o 超市——链接器被单遍扫描的约束决定了链接顺序至关重要。强弱符号是模板和内联函数的基石——让「多份代码」在链接器中自动归为一份。--gc-sections 把死代码消除从函数级推到段级——模板密集型项目可省 10-30% 的 .text 段。

下一篇:链接器把符号和段缝合好了——但 ODR 规则可能在缝合过程中埋下定时炸弹。下一篇进入 51.ODR规则与陷阱——一次定义规则、inline 变量 C++17、模板与 ODR、跨 TU 的 static 与匿名命名空间——链接器缝合时的 UB 典型场景。

上次更新: 2026/06/10, 11:13:41
编译期符号生成
ODR规则与陷阱

← 编译期符号生成 ODR规则与陷阱→

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