编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
        • 1. 案例引入
          • 1.1 两个 .so 暴露了同一个符号——意外绑定到了错误的实现
          • 1.2 dlopen 加载后符号找不到——visibility=hidden 拦截了符号导出
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 静态库 .a vs 动态库 .so——编译期嵌入 vs 运行时加载
          • 2.2 为何这么切
        • 3. 动态库的基础机制——PLT 与 GOT
          • 3.1 为什么动态库调用需要 PLT/GOT——位置的独立性与延迟绑定
          • 3.2 PLT 的三条指令——jump、push、jump 的跳板逻辑
          • 3.3 GOT 的双重身份——初始指向 PLT stub、重定位后指向真实函数
          • 3.4 延迟绑定(lazy binding)——第一次调用才解析的按需机制
          • 3.5 完整调用路径——从 call 指令到动态库函数的全流程
        • 4. 符号可见性——动态库的导出与隐藏
          • 4.1 default / hidden / protected / internal 四种可见性
          • 4.2 -fvisibility=hidden 全局开关——把默认可见性从 default 改成 hidden
          • 4.3 _attribute_((visibility("default"))) 的精准导出
          • 4.4 可见性与符号表的对应——.dynsym 中只保留导出的符号
        • 5. dlopen 与 dlsym——运行时的动态加载
          • 5.1 dlopen 的 RTLDLAZY vs RTLDNOW——延迟 vs 立即符号解析
          • 5.2 dlsym 的符号查找——返回 void* 的函数指针转化
          • 5.3 插件架构——dlopen + 工厂函数的经典模式
        • 6. 符号污染——默认可见性的隐形成本
          • 6.1 内部工具函数暴露为外部符号——增大了 .dynsym 的体积
          • 6.2 两个 .so 导出了同名的内部符号——符号冲突的静默陷阱
          • 6.3 符号可见性治理——visibility=hidden + API 标注的最佳实践
        • 7. 符号版本管理——ABI 演进的控制阀
          • 7.1 .symver 伪指令——同一个函数的新旧版本符号共存
          • 7.2 版本脚本——version script 的全局符号版本控制
          • 7.3 不兼容的 ABI 变更——不改变符号名、改变符号版本
        • 8. 常见陷阱与反模式
          • 8.1 visibility=hidden 后忘记标注 API——dlsym 找不到符号
          • 8.2 静态库和动态库混用——同一个符号出现两次
          • 8.3 LD_PRELOAD 的符号劫持——第三方 .so 覆盖了系统的函数
          • 8.4 RPATH 与 RUNPATH——动态库的搜索路径安全
        • 9. 综合案例串讲
          • 9.1 案例真相揭晓
          • 9.2 一次动态库调用的完整旅程——从 call 到 PLT 到 GOT 到 .so
          • 9.3 设计哲学回扣
          • 9.4 速查表合集
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

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

动态库与符号可见性

# 52.动态库与符号可见性

# 目录介绍

  • 1. 案例引入
    • 1.1 两个 .so 暴露了同一个符号——意外绑定到了错误的实现
    • 1.2 dlopen 加载后符号找不到——visibility=hidden 拦截了符号导出
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 静态库 .a vs 动态库 .so——编译期嵌入 vs 运行时加载
    • 2.2 为何这么切
  • 3. 动态库的基础机制——PLT 与 GOT
    • 3.1 为什么动态库调用需要 PLT/GOT——位置的独立性与延迟绑定
    • 3.2 PLT 的三条指令——jump、push、jump 的跳板逻辑
    • 3.3 GOT 的双重身份——初始指向 PLT stub、重定位后指向真实函数
    • 3.4 延迟绑定(lazy binding)——第一次调用才解析的按需机制
    • 3.5 完整调用路径——从 call 指令到动态库函数的全流程
  • 4. 符号可见性——动态库的导出与隐藏
    • 4.1 default / hidden / protected / internal 四种可见性
    • 4.2 -fvisibility=hidden 全局开关——把默认可见性从 default 改成 hidden
    • 4.3 attribute((visibility("default"))) 的精准导出
    • 4.4 可见性与符号表的对应——.dynsym 中只保留导出的符号
  • 5. dlopen 与 dlsym——运行时的动态加载
    • 5.1 dlopen 的 RTLD_LAZY vs RTLD_NOW——延迟 vs 立即符号解析
    • 5.2 dlsym 的符号查找——返回 void* 的函数指针转化
    • 5.3 插件架构——dlopen + 工厂函数的经典模式
  • 6. 符号污染——默认可见性的隐形成本
    • 6.1 内部工具函数暴露为外部符号——增大了 .dynsym 的体积
    • 6.2 两个 .so 导出了同名的内部符号——符号冲突的静默陷阱
    • 6.3 符号可见性治理——visibility=hidden + API 标注的最佳实践
  • 7. 符号版本管理——ABI 演进的控制阀
    • 7.1 .symver 伪指令——同一个函数的新旧版本符号共存
    • 7.2 版本脚本——version script 的全局符号版本控制
    • 7.3 不兼容的 ABI 变更——不改变符号名、改变符号版本
  • 8. 常见陷阱与反模式
    • 8.1 visibility=hidden 后忘记标注 API——dlsym 找不到符号
    • 8.2 静态库和动态库混用——同一个符号出现两次
    • 8.3 LD_PRELOAD 的符号劫持——第三方 .so 覆盖了系统的函数
    • 8.4 RPATH 与 RUNPATH——动态库的搜索路径安全
  • 9. 综合案例串讲
    • 9.1 案例真相揭晓
    • 9.2 一次动态库调用的完整旅程——从 call 到 PLT 到 GOT 到 .so
    • 9.3 设计哲学回扣
    • 9.4 速查表合集

# 1. 案例引入

# 1.1 两个 .so 暴露了同一个符号——意外绑定到了错误的实现

某多媒体处理框架有两个插件——libcodec_v1.so 和 libcodec_v2.so——各自独立开发。但主程序加载两个插件后——解码视频时渲染花屏:

// libcodec_v1.so 的内部工具函数——忘记了 static
int init_buffer(Buffer* buf) { /* v1 的实现 */ }

// libcodec_v2.so 的内部工具函数——同样的函数名
int init_buffer(Buffer* buf) { /* v2 的实现——签名相同、实现不同 */ }

// 主程序调用 codec_v2 的解码入口:
// codec_v2_init() → 调用 init_buffer()
//   → 动态链接器解析符号 init_buffer
//   → 第一个被加载的是 libcodec_v1.so → bind 到了 v1 的 init_buffer!
//   → v2 的代码收到了 v1 的缓冲区初始化 → 花屏
1
2
3
4
5
6
7
8
9
10
11

根因:两个 .so 都导出了 init_buffer 符号(默认可见性=default)。动态链接器在全局符号表中查找 init_buffer 时——先到先得——v1 的 init_buffer 遮蔽了 v2 的。v2 的代码调用 init_buffer 时——实际执行的是 v1 的实现。

# 1.2 dlopen 加载后符号找不到——visibility=hidden 拦截了符号导出

同一个框架在升级 libcodec_v2.so 后——插件加载器通过 dlsym 查找工厂函数——返回 nullptr:

// libcodec_v2.so
extern "C" {
    __attribute__((visibility("hidden")))  // ❌ hidden——外部看不到!
    Codec* create_codec() { return new CodecV2; }
}

// 主程序
void* handle = dlopen("libcodec_v2.so", RTLD_LAZY);
auto factory = (Codec*(*)()) dlsym(handle, "create_codec");
// factory == nullptr ← hidden 标记阻止了符号被导出到 .dynsym
1
2
3
4
5
6
7
8
9
10

根因:-fvisibility=hidden 在 CMakeLists.txt 中全局开启——所有未标注的符号都不被导出。create_codec 函数忘了标注 visibility("default")——链接器把它标记为隐藏符号——不出现在动态符号表 .dynsym 中。dlsym 只能查找 .dynsym 中的符号。

# 1.3 七个待解疑问

① 动态库和静态库的根本区别?PLT/GOT 是什么、为什么需要它们?              → 第 2 / 第 3 章
② PLT 的 jump→push→jump 三条指令在做什么?GOT 为什么有两个状态?            → 第 3 章
③ 延迟绑定(lazy binding)是怎么工作的?第一次调用和后续调用有什么不同?      → 第 3.4 章
④ visibility=hidden 和 visibility=default 有什么区别?影响哪些层面?         → 第 4 章
⑤ dlopen/dlsym 怎么在运行时加载一个 .so 并获取其中的函数指针?                → 第 5 章
⑥ 符号污染是什么、怎么治理?为什么 -fvisibility=hidden 是动态库的最佳实践?   → 第 6 章
⑦ 符号版本管理怎么做?ABI 升级后怎么保证旧的二进制还能用?                      → 第 7 章
1
2
3
4
5
6
7

# 2. 架构概览

# 2.1 静态库 .a vs 动态库 .so——编译期嵌入 vs 运行时加载

静态库 .a:
  编译期:链接器从 .a 中提取需要的 .o → 嵌入最终二进制
  运行期:无额外加载——代码已在可执行文件中
  优点:无运行时依赖、符号解析在编译期完成
  缺点:每个二进制复制一份代码、更新需重新编译、二进制体积大

动态库 .so:
  编译期:链接器只在二进制中记录「需要哪个 .so 的哪个符号」
  运行期:动态链接器 (ld.so) 加载 .so → 解析符号 → 重定位
  优点:多进程共享同一份代码、更新无需重新编译、二进制小
  缺点:运行时依赖、符号解析有开销、版本兼容性需要管理
1
2
3
4
5
6
7
8
9
10
11

# 2.2 为何这么切

动态库 = 一个进程加载多个 .so → 所有这些 .so 共享同一个全局符号空间

问题:如果每个 .so 的内部函数都导出为全局符号:
  → 不同 .so 的内部函数可能同名 → 冲突
  → 加载速度慢(动态链接器需要处理更多符号)
  → ABI 被泄露(用户可能依赖本不该暴露的内部函数)

解决方案:visibility=hidden
  → 内部函数用 hidden 标记——不出现在 .dynsym 中
  → 动态链接器不处理 hidden 符号——减少加载时间
  → 只暴露 API 函数(visibility=default)——隔离内部实现
1
2
3
4
5
6
7
8
9
10
11

# 3. 动态库的基础机制——PLT 与 GOT

# 3.1 为什么动态库调用需要 PLT/GOT——位置的独立性与延迟绑定

静态链接:
  call func → 链接器知道 func 的地址 → 直接填 call 指令的操作数
  最终二进制: call 0x401234 → 一次跳转到固定地址

动态链接:
  call func → 链接器不知道 func 的地址(在另一个 .so 中——运行时才确定)
  → 编译时生成 call func@PLT → 跳到一个「跳板」→ 跳板通过 GOT 找真实地址

为什么需要两层跳转(PLT + GOT):
  ① 代码段(.text)在加载后不能修改——不能把解析后的地址写回 call 指令
  ② 数据段(.data / .got)可以修改——把解析后的地址写入 GOT
  ③ PLT 是固定的跳板——GOT 是可变的数据槽
  → PLT 中的指令不变、GOT 中的地址被运行时填充
1
2
3
4
5
6
7
8
9
10
11
12
13

# 3.2 PLT 的三条指令——jump、push、jump 的跳板逻辑

; PLT 条目 for func:
func@PLT:
    jmp    *GOT[n]          ; ① 跳转到 GOT[n] 中存的地址
    push   $reloc_index      ; ② 如果 GOT[n] 还没解析——推送重定位索引
    jmp    PLT[0]            ; ③ 跳转到 PLT[0](调用 _dl_runtime_resolve)
1
2
3
4
5

两条路径——已解析 vs 未解析:

已解析(第 N 次调用):
  ① jmp *GOT[n] → GOT[n] 已包含 func 的真实地址 → 直接调 func ✅
  只执行第一条 jmp 指令——1 次间接跳转 ~3ns

未解析(第一次调用):
  ① jmp *GOT[n] → GOT[n] 还指向 PLT stub(下一条指令)→ 不跳
  ② push reloc_index → 把「我是第几个 PLT 条目」推入栈
  ③ jmp PLT[0] → 调用 _dl_runtime_resolve
     → 动态链接器查找符号 func、填充 GOT[n]、调用 func
  后续调用:GOT[n] 已有地址 → 走快速路径
1
2
3
4
5
6
7
8
9
10

# 3.3 GOT 的双重身份——初始指向 PLT stub、重定位后指向真实函数

GOT (Global Offset Table) 在 ELF 中的布局:

GOT[0]: _DYNAMIC 段的地址(动态链接器内部使用)
GOT[1]: link_map 指针(动态链接器的数据结构)
GOT[2]: _dl_runtime_resolve 的地址(延迟绑定解析器)

GOT[3..N]: 每个动态库函数的槽位
  初始值(加载时):指向 PLT stub 的第二条指令(push reloc_index)
  解析后(第一次调用后):动态链接器填充为 func 的真实地址

静态链接:没有 PLT/GOT——call 直接填目标地址
动态链接:每个外部函数多了一层间接跳转——但只付一次解析成本
1
2
3
4
5
6
7
8
9
10
11
12

# 3.4 延迟绑定(lazy binding)——第一次调用才解析的按需机制

延迟绑定 = LD_BIND_NOW=0(默认)

加载时:不解析任何动态库符号——GOT 槽位都指向 PLT stub
运行时:每个函数第一次被调用时才解析——只解析实际被调用的符号

立即绑定 = LD_BIND_NOW=1

加载时:解析所有动态库符号——加载速度慢——运行时无额外开销
适用于:安全敏感场景——防止 PLT 被篡改(GOT overwrite 攻击)

LD_DEBUG=bindings ./app   # 查看每个符号的绑定时间
1
2
3
4
5
6
7
8
9
10
11

# 3.5 完整调用路径——从 call 指令到动态库函数的全流程

源码:
  int r = strlen("hello");  // strlen 在 libc.so.6 中

编译后的汇编:
  call strlen@PLT           // 调用 PLT 条目——不是直接调 strlen

═══════ 第一次调用 ═══════

① call strlen@PLT
   → 跳转到 .plt 段中的 strlen@PLT

② strlen@PLT:
   jmp *GOT[strlen]  → GOT[strlen] 初始指向 PLT stub(下一条指令)
   push $3            → strlen 是第 3 个需要解析的符号
   jmp PLT[0]         → 跳转到 PLT 解析器

③ PLT[0]:
   push GOT[1]        → 推入 link_map 指针
   jmp *GOT[2]        → 调用 _dl_runtime_resolve(link_map, reloc_index)

④ _dl_runtime_resolve:
   ① 根据 reloc_index 找到对应的 Elf64_Rela 条目
   ② 根据符号名 "strlen" 在 libc.so.6 的 .dynsym 中查找
   ③ 找到 strlen 的地址 = 0x7f...(libc.so.6 在内存中的加载基址 + 偏移)
   ④ 将 0x7f... 写入 GOT[strlen]  ← 下次调用直达
   ⑤ 跳转到 0x7f...(strlen 的第一条指令)

═══════ 第二次调用 ═══════

  call strlen@PLT
  → jmp *GOT[strlen]  → 0x7f...(strlen) → 直接执行  共 2 条指令 ~3ns
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

# 4. 符号可见性——动态库的导出与隐藏

# 4.1 default / hidden / protected / internal 四种可见性

可见性 符号表 跨 .so 可见 典型用途
default .dynsym 导出 ✅ 是 API 函数
hidden 不出现在 .dynsym ❌ 否 内部实现
protected .dynsym 导出 只在当前 .so 内优先 防止被 LD_PRELOAD 覆盖
internal 不出现在 .dynsym ❌ 否(比 hidden 更强——禁止跨段访问) 极致隐藏
// 使用 __attribute__ 控制单个符号
__attribute__((visibility("default"))) void api_init();     // 导出
__attribute__((visibility("hidden")))  void internal_work(); // 隐藏

// 或用于整个类
struct __attribute__((visibility("hidden"))) InternalHelper { ... };
1
2
3
4
5
6

# 4.2 -fvisibility=hidden 全局开关——把默认可见性从 default 改成 hidden

# CMakeLists.txt
set(CMAKE_CXX_VISIBILITY_PRESET hidden)   # 全局隐藏——默认不导出
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)   # inline 函数也隐藏

# 编译命令等价于
g++ -fvisibility=hidden -fvisibility-inlines-hidden ...
1
2
3
4
5
6

效果:所有未标注 visibility("default") 的符号都被标记为 hidden——不出现在 .dynsym 中——不被其他 .so 或 dlsym 看到。

# 4.3 attribute((visibility("default"))) 的精准导出

// API 头文件——借助宏统一标注
#if defined(_WIN32)
  #define API_EXPORT __declspec(dllexport)
  #define API_IMPORT __declspec(dllimport)
#else
  #define API_EXPORT __attribute__((visibility("default")))
  #define API_IMPORT
#endif

// 库的编译时——用 API_EXPORT 标注要导出的函数
class API_EXPORT Codec {
public:
    virtual void encode(const uint8_t* in, uint8_t* out) = 0;
};

// 库的使用方——不需要标注
class Codec { ... };  // 声明时不需要 visibility 属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 4.4 可见性与符号表的对应——.dynsym 中只保留导出的符号

# 没有 visibility=hidden——所有符号在 .dynsym 中
$ nm -D libcodec.so | wc -l
2847    # 2847 个导出符号——包括内部辅助函数

# 有 visibility=hidden——只导出 API 标注的函数
$ nm -D libcodec.so | wc -l
42      # 只有 42 个 API 函数被导出

# 对比动态符号表大小
$ readelf -s libcodec.so | grep '.dynsym'
# 无 hidden: .dynsym 占用 ~40KB
# 有 hidden: .dynsym 占用 ~2KB
1
2
3
4
5
6
7
8
9
10
11
12

# 5. dlopen 与 dlsym——运行时的动态加载

# 5.1 dlopen 的 RTLD_LAZY vs RTLD_NOW——延迟 vs 立即符号解析

#include <dlfcn.h>

// RTLD_LAZY:延迟解析——只有实际被调用时才解析(类似延迟绑定)
void* handle = dlopen("libplugin.so", RTLD_LAZY);

// RTLD_NOW:立即解析——dlopen 返回前解析所有符号
void* handle = dlopen("libplugin.so", RTLD_NOW);

// RTLD_NOW | RTLD_GLOBAL:立即解析 + 符号对其他 .so 可见
void* handle = dlopen("libplugin.so", RTLD_NOW | RTLD_GLOBAL);

if (!handle) {
    fprintf(stderr, "dlopen failed: %s\n", dlerror());
    exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 5.2 dlsym 的符号查找——返回 void* 的函数指针转化

// 通过符号名查找——必须是 extern "C" 的 C 链接符号
// (C++ mangled 符号——名字不好预测)
auto factory = (Codec* (*)()) dlsym(handle, "create_codec");
if (!factory) {
    fprintf(stderr, "dlsym failed: %s\n", dlerror());
    dlclose(handle);
    return;
}

Codec* codec = factory();   // 调用动态库中的工厂函数
codec->encode(in, out);

dlclose(handle);             // 卸载动态库
1
2
3
4
5
6
7
8
9
10
11
12
13

为什么 dlsym 推荐用 extern "C":C++ 的 name mangling 让符号名不可预测——不同编译器、不同版本的 mangling 不同。extern "C" 关闭 mangling——符号名就是函数名本身。

# 5.3 插件架构——dlopen + 工厂函数的经典模式

// plugin_api.h —— 插件必须实现的接口
struct Plugin {
    virtual const char* name() = 0;
    virtual int execute(const char* input, char* output) = 0;
    virtual ~Plugin() = default;
};

// 每个插件的 .so 必须导出这个工厂函数
extern "C" API_EXPORT Plugin* create_plugin();

// 插件加载器
Plugin* load_plugin(const char* path) {
    void* handle = dlopen(path, RTLD_NOW);
    if (!handle) return nullptr;

    auto factory = (Plugin*(*)()) dlsym(handle, "create_plugin");
    if (!factory) { dlclose(handle); return nullptr; }

    return factory();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 6. 符号污染——默认可见性的隐形成本

# 6.1 内部工具函数暴露为外部符号——增大了 .dynsym 的体积

案例 1.1 的根因——没有 static 或 visibility=hidden:

# 无 visibility 控制——所有函数都被导出
$ nm -D libfoo.so | grep ' T '
0000000000001230 T _Z12parse_headerPKc     # 内部工具函数——不该导出
0000000000001450 T _Z11encode_dataPKhm     # 同上
0000000000001870 T foo_api_init             # API 函数——应该导出

# 后果:动态链接器加载时处理 3000 个符号——其中 50 个是真正需要的 API
1
2
3
4
5
6
7

# 6.2 两个 .so 导出了同名的内部符号——符号冲突的静默陷阱

案例 1.1 的完整推演——两个 .so 的符号冲突:

libv1.so 导出: init_buffer (T)
libv2.so 导出: init_buffer (T)

动态链接器加载顺序:
  ① 先加载 libv1.so → init_buffer 绑定到 v1 的实现
  ② 再加载 libv2.so → 链接器发现 init_buffer 已被占用
     → 根据 Unix 动态链接规则:先到者胜出
     → libv2.so 中的 init_buffer 调用被重定位到 libv1.so 的实现
     → 没有任何报错、没有任何警告
1
2
3
4
5
6
7
8
9

和静态库的对比:静态库中两个同名强符号→链接器直接报 multiple definition 错误。动态库中→静默绑定第一个——更难排查。

# 6.3 符号可见性治理——visibility=hidden + API 标注的最佳实践

# CMakeLists.txt —— 全局最佳实践
set(CMAKE_CXX_VISIBILITY_PRESET hidden)    # ① 默认隐藏
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)    # ② inline 函数也隐藏
set(CMAKE_C_VISIBILITY_PRESET hidden)      # ③ C 代码也隐藏(如果有)
1
2
3
4
// API 导出宏
#if defined(MYLIB_BUILD)                    // 编译库本身
  #define MYLIB_API __attribute__((visibility("default")))
#else                                       // 使用库
  #define MYLIB_API
#endif

// 只标注真正需要暴露的接口
MYLIB_API int  mylib_init();
MYLIB_API void mylib_process(const char* data);

// 内部函数——不加 MYLIB_API → 自动 hidden
void internal_parse();   // hidden ✅
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7. 符号版本管理——ABI 演进的控制阀

# 7.1 .symver 伪指令——同一个函数的新旧版本符号共存

// 新版本——改变了函数签名(增加了参数)
int process_v2(const char* data, int flags) {
    return process(data, strlen(data), flags);
}

// 旧版本——保持旧签名兼容
int process(const char* data, int len) {
    // 旧实现
}

// 符号版本声明——同时导出新旧两个版本
__asm__(".symver process_v2, process@@LIB_2.0");   // 新版本(默认绑定)
__asm__(".symver process,    process@LIB_1.0");     // 旧版本(兼容)
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7.2 版本脚本——version script 的全局符号版本控制

# libcodec.ver —— 版本脚本
LIBCODEC_1.0 {
    global:
        codec_init;
        codec_encode;
    local:
        *;              # 其他符号——全部隐藏
};

LIBCODEC_2.0 {
    global:
        codec_encode_v2;
} LIBCODEC_1.0;         # 继承 1.0 的所有符号
1
2
3
4
5
6
7
8
9
10
11
12
13
# 链接时指定版本脚本
g++ -shared -o libcodec.so ... -Wl,--version-script=libcodec.ver
1
2

# 7.3 不兼容的 ABI 变更——不改变符号名、改变符号版本

原则:函数名不变——但语义改变了 → 改变符号版本标签

旧版本(compat):
  double compute(int x) → 返回原始值
  → 符号标签: compute@@LIB_1.0

新版本(default):
  double compute(int x) → 返回归一化后的值(语义改变——可能破坏旧代码)
  → 符号标签: compute@@LIB_2.0

旧二进制(链接到 LIB_1.0)→ 自动绑定到 compute@LIB_1.0(旧语义)
新二进制(链接到 LIB_2.0)→ 自动绑定到 compute@LIB_2.0(新语义)
1
2
3
4
5
6
7
8
9
10
11
12

# 8. 常见陷阱与反模式

# 8.1 visibility=hidden 后忘记标注 API——dlsym 找不到符号

案例 1.2 的完整修复:

// ❌ 忘记标注
extern "C" Codec* create_codec() { ... }    // hidden——dlsym 找不到

// ✅ 加上 API 导出宏
extern "C" API_EXPORT Codec* create_codec() { ... }
1
2
3
4
5

# 8.2 静态库和动态库混用——同一个符号出现两次

libcommon.a (静态) → 内嵌了 json_parse 的代码
libnet.so (动态)   → 也导出了 json_parse 符号

最终二进制:
  → 静态链接的 json_parse 在可执行文件中
  → 动态链接的 json_parse 在 libnet.so 中
  → 两个 json_parse 在同一个进程的符号空间!
  → 动态链接器可能选择错误的版本
1
2
3
4
5
6
7
8

# 8.3 LD_PRELOAD 的符号劫持——第三方 .so 覆盖了系统的函数

# 恶意 .so 覆盖了系统的 malloc
LD_PRELOAD=./malicious.so ./app

# malicious.so 中:
void* malloc(size_t s) {
    // 记录调用 → 调真正的 malloc → 返回
}
# 进程中的所有 malloc 调用都被劫持——在真正调用之前执行了恶意代码
1
2
3
4
5
6
7
8

防御:关键函数用 protected 可见性——告诉动态链接器「优先用当前 .so 的定义」——即使有 LD_PRELOAD。

# 8.4 RPATH 与 RUNPATH——动态库的搜索路径安全

# 查看二进制中嵌入的动态库搜索路径
$ readelf -d app | grep -E 'RPATH|RUNPATH'
0x000000000000001d (RUNPATH)  Library runpath: [/opt/mylib/lib]

# 编译时指定 RPATH
$ g++ -Wl,-rpath,'$ORIGIN/../lib' main.o -lfoo -o app
# $ORIGIN = 可执行文件所在的目录 → 搜索相对路径
1
2
3
4
5
6
7

# 9. 综合案例串讲

# 9.1 案例真相揭晓

# 疑问 答案
① PLT/GOT 是什么? 第 3 章:PLT 是跳板、GOT 是数据槽——实现位置无关的延迟绑定调用
② PLT 三条指令? 第 3.2:jmp *GOT → push index → jmp resolver——未解析/已解析两条路径
③ 延迟绑定原理? 第 3.4:第一次调解析并写 GOT、后续调用直达——只付一次解析成本
④ visibility 四种? 第 4.1:default(导出)/hidden(隐藏)/protected(防 LD_PRELOAD)/internal(极致)
⑤ dlopen/dlsym 用法? 第 5 章:RTLD_LAZY/NOW + extern "C" 符号名 + 工厂函数插件模式
⑥ 符号污染治理? 第 6 章:-fvisibility=hidden 全局开关 + API 宏精准导出——缩小 .dynsym
⑦ 符号版本管理? 第 7 章:.symver 伪指令 + version script——ABI 演进不破坏旧二进制

案例①修复——符号污染:

  1. 全部 .so 加 -fvisibility=hidden
  2. 只标注 API 函数为 visibility("default")
  3. 内部工具函数加 static 或放匿名命名空间

案例②修复——dlsym 找不到符号:标注 API_EXPORT 宏,确保工厂函数出现在 .dynsym 中。

# 9.2 一次动态库调用的完整旅程——从 call 到 PLT 到 GOT 到 .so

源码:
  int r = strlen("hello");

═══════ 编译期 ═══════

汇编(main.o):
  lea    rdi, [rip + .LC0]    ; "hello"
  call   strlen@PLT           ; 不直接调 strlen——调 PLT 跳板

可执行文件(app):
  .plt 段:strlen@PLT(三条指令)
  .got 段:GOT[strlen] = PLT stub 地址(初始值)
  .dynsym: strlen (U ——未定义引用)
  .dynamic: NEEDED libc.so.6

═══════ 加载期 ═══════

动态链接器 (ld.so):
  ① 读 NEEDED → 加载 libc.so.6 到内存
  ② 不解析 strlen(延迟绑定——默认)
  ③ GOT[strlen] 保持指向 PLT stub

═══════ 第一次调用 strlen ═══════

  call strlen@PLT:
    → jmp *GOT[strlen]  (GOT 指向 PLT stub——不跳)
    → push index         (我是第 N 个需要解析的符号)
    → jmp resolver       (调 _dl_runtime_resolve)
    → 动态链接器在 libc.so.6 中查找符号 strlen
    → 找到地址 0x7f123400 → 写进 GOT[strlen]
    → 跳转到 0x7f123400   (执行真正的 strlen)

═══════ 第二次调用 strlen ═══════

  call strlen@PLT:
    → jmp *GOT[strlen]  (GOT 已经是 0x7f123400)
    → 直接跳转到 strlen  共 1 次间接跳转 ~3ns

═══════ 性能全景 ═══════

  第一次调用:~2μs(_dl_runtime_resolve 的符号查找时间)
  后续调用:~3ns(一次间接跳转——和直接调差不多)
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
42

# 9.3 设计哲学回扣

哲学 1:PLT/GOT 是「编译期不知道地址」和「运行时可能需要修改地址」这两个约束的最优解

编译时不知道 strlen 的地址——因为在 libc.so.6 中——编译期 libc 的内部布局不可知。运行时可能修改地址——延迟绑定需要在第一次调用后更新 GOT。PLT/GOT 的双层间接引用把「不变」和「可变」在内存中分开——.text(PLT 不变)、.data/.got(可变)——满足了代码段只读的安全要求。

哲学 2:符号可见性是动态库的防火墙——和类的 public/private 一样——只是作用在「符号」层

visibility=default = public——其他 .so 和 dlsym 可见。visibility=hidden = private——只在当前 .so 内可见。这和类的封装哲学一致——暴露最小的 API 面、隐藏最大的实现面。 减小 API 面 = 减小 ABi 变更影响范围 = 减小符号冲突概率 = 减小加载时间。

哲学 3:延迟绑定是「按需付费」的最优策略——和 CPU 的惰性执行如出一辙

程序可能加载 50 个 .so、导出 5000 个符号——但一次运行只调用其中的 200 个。延迟绑定把这 200 个的解析成本分散到每次调用——而不是在加载时一次性付 5000 个的代价。这和 CPU 的惰性执行(懒加载、懒初始化、懒求值)共享同一哲学——只在需要时才付代价。

哲学 4:符号版本管理是「ABI 兼容性」的类型系统——把版本嵌入符号名

process@LIB_1.0 和 process@LIB_2.0 是两个不同的符号名——链接器把它们视为不同的符号。旧二进制永远绑定到旧符号——不会受新版本影响。这和版本化 API 的 URL(/v1/users vs /v2/users)共享同一设计模式——在标识符中嵌入版本信息——让「演进」和「兼容」可以共存。

# 9.4 速查表合集

PLT/GOT 路径速查:

调用次数 PLT 行为 GOT 状态 耗时
第一次 jmp *GOT → push → jmp resolver → 解析 初始=PLT stub → 解析后=func 地址 ~2μs
第 N 次 jmp *GOT → 直达 func func 的真实地址 ~3ns

可见性速查:

可见性 .dynsym 跨 .so LD_PRELOAD 可覆盖
default ✅ ✅ ✅
hidden ❌ ❌ —
protected ✅ ✅(优先本 .so) ❌
internal ❌ ❌ —

dlopen 标志速查:

标志 含义 场景
RTLD_LAZY 延迟解析(默认) 一般插件
RTLD_NOW 立即解析 安全敏感
RTLD_GLOBAL 符号对其他 .so 可见 被依赖的插件
RTLD_LOCAL 符号只在当前 .so 内 隔离插件(默认)

最佳实践清单:

# CMakeLists.txt —— 动态库符号可见性的最佳实践
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)

# API 导出宏
#if defined(MYLIB_BUILD)
  #define API __attribute__((visibility("default")))
#else
  #define API
#endif

# 插件接口——永远用 extern "C"
extern "C" API Plugin* create_plugin();
1
2
3
4
5
6
7
8
9
10
11
12
13

本篇小结:动态库通过 PLT/GOT 实现了位置无关的延迟绑定——第一次调用解析符号、后续调用直达。符号可见性是动态库的防火墙——-fvisibility=hidden + API 标注把导出符号从数千个缩减到数十个——减小了加载时间、消除了符号冲突、降低了 ABI 泄漏风险。dlopen/dlsym 提供了运行时的动态加载——插件架构的基石。符号版本管理让同一个 .so 的多个 ABI 版本共存——旧二进制绑定旧符号、新二进制绑定新符号。

下一篇:动态库的符号控制说清了。下一篇进入 [53.C++ ABI兼容性](53.C++ ABI兼容性.md)——Itanium ABI、std::string ABI dual(_GLIBCXX_USE_CXX11_ABI)、跨编译器边界、版本治理——把动态库的 ABI 管控推向完整。

上次更新: 2026/06/10, 11:13:41
ODR规则与陷阱
C++ ABI兼容性

← ODR规则与陷阱 C++ ABI兼容性→

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