同一份源码,编成静态库(.a)和动态库(.so),链接方式完全不同——一个把代码复制进可执行文件、一个在运行时才加载,体积和依赖各有取舍。这篇用 nm/readelf/ldd/size 的真实输出把这套「链接与加载」机制讲透,包括动态库为什么必须 -fPIC、函数调用怎么走 PLT/GOT 延迟绑定、LD_PRELOAD 怎么劫持符号,以及 -l 链接顺序为什么讲究。
1. 引子:静态 vs 动态,size 一眼看穿
先看两个要被编进库的函数。它们本身普通得很,但「怎么打包进可执行文件」决定了最终的体积和依赖:
#include <iostream>
// 这两个函数之后会被分别编成静态库 .a 和动态库 .so
int square(int x) { return x * x; }
int cube(int x) { return x * x * x; }
int main() {
std::cout << "square(6) = " << square(6) << '\n';
std::cout << "cube(3) = " << cube(3) << '\n';
}
square(6) = 36
cube(3) = 27
把同样的函数编成静态库和动态库,再各自链接出可执行文件,用 size 看体积(godbolt gcc 13.2 实测):
$ size static_demo # 静态链接:square/cube 被复制进可执行文件
text data bss dec hex filename
1268 624 8 1900 76c static_demo
$ size dyn_demo # 动态链接:可执行文件里只有一小段调用桩
text data bss dec hex filename
1356 656 8 2020 7e4 dyn_demo
有意思的是,square/cube 小到各只有一条乘法指令,静态链接的 text 段(1268)反而比动态链接(1356)还小。动态链接的可执行文件要额外装 PLT 桩、GOT、动态段这些「调用基础设施」。「动态一定更省体积」只在库很大(比如整个标准库)时才成立;对几个小函数,静态链接可能更省。这个实测也提醒我们:体积结论要看库的大小,别记死结论。
| 维度 | 静态库 .a | 动态库 .so |
|---|---|---|
| 代码位置 | 链接时复制进可执行文件 | 运行时由动态链接器加载 |
| 可执行文件体积 | 大库时大、小函数时可能更小 | 大库时小、小函数时要装 PLT/GOT |
| 运行时依赖 | 无(自包含) | 依赖 .so 存在且版本匹配 |
| 库更新 | 需要重新链接 | 换 .so 即可(ABI 兼容前提下) |
| 多进程共享内存 | 不共享 | 代码段可被多个进程共享 |
官方文档:CMake
add_library说明STATIC与SHARED两种库目标。
2. 静态库:链接时复制,nm 看符号
静态库本质是 .o 目标文件的打包(ar 归档)。链接时,链接器只把被引用的目标文件复制进可执行文件,没被引用的不复制。
$ nm -C libmystatic.a
0000000000000000 T square(int)
0000000000000000 T cube(int)
square 和 cube 都是全局符号 T,链接时按需复制。
3. 动态库:运行时加载,ldd 看依赖
动态链接的可执行文件启动时,动态链接器(ld.so)按 DT_NEEDED 记录去找 .so。ldd 列出完整依赖链,readelf -d 直接看 ELF 动态段里的 NEEDED:
$ ldd dyn_demo
linux-vdso.so.1 (~~)
libmydyn.so => /app/build/libmydyn.so (~~)
libstdc++.so.6 => /opt/compiler-explorer/gcc-13.2.0/lib64/libstdc++.so.6 (~~)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (~~)
libgcc_s.so.1 => /opt/compiler-explorer/gcc-13.2.0/lib64/libgcc_s.so.1 (~~)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (~~)
/lib64/ld-linux-x86-64.so.2 (~~)
$ readelf -d libmydyn.so | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libstdc++.so.6]
0x0000000000000001 (NEEDED) Shared library: [libm.so.6]
0x0000000000000001 (NEEDED) Shared library: [libgcc_s.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
ldd 里的 libmydyn.so => /app/build/libmydyn.so 证明:可执行文件里没有 square/cube 的代码,只有一个「去找 libmydyn.so」的记录;真正解析函数地址发生在程序启动时。
官方文档:GNU
ldd手册 与readelf手册 说明如何查看动态依赖。
4. 为什么动态库必须 -fPIC
动态库会被加载到任意内存地址(每次加载基址都可能不同),所以库里的代码不能假设固定地址——必须编译成「位置无关代码」(Position-Independent Code,PIC)。-fPIC 让代码通过 GOT(全局偏移表)间接访问全局变量和函数,而不是硬编码地址。下面的 calc.o 里访问了全局变量 global_counter,不加 -fPIC 去编 .so 会直接报错(真实报错):
$ g++ -shared -o libbad.so calc.o # calc.o 没有 -fPIC
/usr/bin/ld: calc.o: relocation R_X86_64_PC32 against symbol `global_counter'
can not be used when making a shared object; recompile with -fPIC
collect2: error: ld returned 1 exit status
官方文档:GCC
-fPIC选项 说明位置无关代码的生成方式。
5. PLT/GOT:函数调用为什么能「延迟绑定」
动态链接的可执行文件调用外部函数时,编译期不知道函数真实地址,于是走「PLT + GOT」两级跳:
调用 square() 的流程(第一次调用后)
main 里 call square@plt
└─> PLT 条目:jmp *GOT[square] (GOT 里已存真实地址)
└─> 真正的 square() 代码
第一次调用前:GOT[square] 指向 PLT 的下一条指令,
触发动态链接器去 .so 里解析真实地址,写回 GOT —— 这叫「延迟绑定」(lazy binding)。
好处:不用的符号不解析,启动更快;解析一次后后续调用直接跳真实地址,几乎零开销。
6. LD_PRELOAD:劫持符号
LD_PRELOAD 环境变量指定的 .so 会最先被加载,其中定义的符号优先于其他库。于是可以在不重新编译程序的情况下「劫持」某个函数。先看一个正常返回 1 的函数(这里为演示直接定义在 main 里,真实场景它来自某个 .so):
#include <iostream>
// 真实场景里 get_value 来自某个动态库;这里定义在 main 里便于单文件运行
int get_value() { return 1; }
int main() {
std::cout << "get_value() = " << get_value() << '\n';
}
get_value() = 1
劫持库定义一个同名函数,返回值不同:
// fake.cpp —— 编成 libfake.so,LD_PRELOAD 加载后优先匹配
extern "C" int get_value() { return 999; }
在真实多文件工程里(get_value 来自 libgreet.so),LD_PRELOAD 劫持效果如下(godbolt 容器实测):
$ ./demo # 正常:来自 libgreet.so 的版本
get_value() = 1
$ LD_PRELOAD=./libfake.so ./demo # 劫持:优先匹配 libfake.so 的版本
get_value() = 999
LD_PRELOAD 常用于调试、mock、注入监控代码;但生产环境滥用会引入安全和稳定性风险,只应在可控环境使用。
7. 链接顺序:-l 顺序为什么重要
静态库链接是单遍扫描:链接器从左到右处理 -l 库,只有「当前未解析符号」才会去后面的库里找。所以被依赖的库要放在依赖它的库后面。liba 调用了 libb 的函数,正确顺序是 -la -lb,反过来报 undefined reference:
$ g++ main.o -lb -la # 错误:处理 libb 时还没有需要它的符号
/usr/bin/ld: liba.a(a.o): undefined reference to `b_func'
$ g++ main.o -la -lb # 正确
(链接成功)
| 顺序 | 结果 |
|---|---|
-la -lb(被依赖的 b 在后) | 成功 |
-lb -la(b 在前) | undefined reference to b_func |
官方文档:GNU
ld手册 中-l选项说明库的搜索与扫描顺序。
8. 完整示例:一个库函数 + 静态/动态链接对比
把「库函数」这个核心概念落成一个能跑的完整程序,然后用 size/nm/ldd 看静态链接与动态链接的差异。
#include <iostream>
// 这就是「库」要提供的能力:两个纯函数
int square(int x) { return x * x; }
int cube(int x) { return x * x * x; }
int main() {
// 用确定的结果自检,证明链接正确
std::cout << "square(6)=" << square(6) << '\n';
std::cout << "cube(3)=" << cube(3) << '\n';
std::cout << (square(6) == 36 && cube(3) == 27 ? "自检通过\n" : "自检失败\n");
}
square(6)=36
cube(3)=27
自检通过
在真实多文件工程里(calc.cpp 编成 .a 和 .so 各一份,main.cpp 分别链接),体积、符号、依赖对比如下(godbolt 实测):
$ size static_demo # 静态链接:代码复制进可执行文件
text data bss dec hex filename
1268 624 8 1900 76c static_demo
$ size dyn_demo # 动态链接:可执行文件里只有调用桩
text data bss dec hex filename
1356 656 8 2020 7e4 dyn_demo
$ nm -C libmystatic.a # 静态库符号:全局 T,链接时复制
0000000000000000 T square(int)
0000000000000000 T cube(int)
$ ldd dyn_demo # 动态链接的可执行文件依赖 .so
libmydyn.so => /app/build/libmydyn.so (~~)
libstdc++.so.6 => ~~ (~~)
libc.so.6 => ~~ (~~)
9. 延伸阅读
- GNU
ldd手册 —— 查看动态依赖的标准工具。 - GCC
-fPIC文档 —— 位置无关代码的生成与适用。 - GNU
ld手册 ——-l、--as-needed、静态库扫描顺序。 - CMake
add_library——STATIC/SHARED/OBJECT库目标。 - cppreference:语言链接 ——
extern "C"让符号稳定,正是LD_PRELOAD能按名劫持的符号基础。 - Compiler Explorer(godbolt) —— 自己写个跨库调用,看
call foo@plt与 GOT 重定位的汇编。 - C++ Core Guidelines —— 本知识库代码示例遵循的工程基调。
本知识库内的相关篇目:
- 《C++ ABI 与跨库兼容:为什么换编译器要重编、符号可见性》 —— C++ ABI 与跨库兼容,同体系里最该一起看的另一篇。
- 《C++ 名字修饰(name mangling)与 extern “C”:C/C++ 互操作》 —— 用 nm/nm -C 真实符号表讲清名字修饰
- 《异常的实现与代价:零成本异常、栈展开与 -fno-exceptions 什么时候用》 —— C++ 异常常被说「零成本」,但它的代价藏在抛出路径上。
10. 一句话总结
静态库链接时按需复制代码(自包含、体积大、无运行时依赖),动态库运行时加载(体积小、可共享、但依赖版本匹配);动态库必须 -fPIC 以支持任意地址加载,跨库函数调用走 PLT/GOT 延迟绑定,LD_PRELOAD 利用加载顺序劫持符号,而静态链接的单遍扫描决定了 -l 顺序(被依赖的库放后面)。
2637



被折叠的 条评论
为什么被折叠?



