动态库与符号可见性
# 52.动态库与符号可见性
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 动态库的基础机制——PLT 与 GOT
- 4. 符号可见性——动态库的导出与隐藏
- 5. dlopen 与 dlsym——运行时的动态加载
- 6. 符号污染——默认可见性的隐形成本
- 7. 符号版本管理——ABI 演进的控制阀
- 8. 常见陷阱与反模式
- 9. 综合案例串讲
# 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 的缓冲区初始化 → 花屏
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
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 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 静态库 .a vs 动态库 .so——编译期嵌入 vs 运行时加载
静态库 .a:
编译期:链接器从 .a 中提取需要的 .o → 嵌入最终二进制
运行期:无额外加载——代码已在可执行文件中
优点:无运行时依赖、符号解析在编译期完成
缺点:每个二进制复制一份代码、更新需重新编译、二进制体积大
动态库 .so:
编译期:链接器只在二进制中记录「需要哪个 .so 的哪个符号」
运行期:动态链接器 (ld.so) 加载 .so → 解析符号 → 重定位
优点:多进程共享同一份代码、更新无需重新编译、二进制小
缺点:运行时依赖、符号解析有开销、版本兼容性需要管理
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)——隔离内部实现
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 中的地址被运行时填充
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)
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] 已有地址 → 走快速路径
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 直接填目标地址
动态链接:每个外部函数多了一层间接跳转——但只付一次解析成本
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 # 查看每个符号的绑定时间
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
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 { ... };
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 ...
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 属性
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
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);
}
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); // 卸载动态库
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();
}
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
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 的实现
→ 没有任何报错、没有任何警告
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 代码也隐藏(如果有)
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 ✅
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"); // 旧版本(兼容)
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 的所有符号
2
3
4
5
6
7
8
9
10
11
12
13
# 链接时指定版本脚本
g++ -shared -o libcodec.so ... -Wl,--version-script=libcodec.ver
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(新语义)
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() { ... }
2
3
4
5
# 8.2 静态库和动态库混用——同一个符号出现两次
libcommon.a (静态) → 内嵌了 json_parse 的代码
libnet.so (动态) → 也导出了 json_parse 符号
最终二进制:
→ 静态链接的 json_parse 在可执行文件中
→ 动态链接的 json_parse 在 libnet.so 中
→ 两个 json_parse 在同一个进程的符号空间!
→ 动态链接器可能选择错误的版本
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 调用都被劫持——在真正调用之前执行了恶意代码
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 = 可执行文件所在的目录 → 搜索相对路径
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 演进不破坏旧二进制 |
案例①修复——符号污染:
- 全部 .so 加
-fvisibility=hidden - 只标注 API 函数为
visibility("default") - 内部工具函数加
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(一次间接跳转——和直接调差不多)
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();
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::stringABI dual(_GLIBCXX_USE_CXX11_ABI)、跨编译器边界、版本治理——把动态库的 ABI 管控推向完整。