动态链接库处理全局变量
参考资料
#当我们指定了动态库之后,我们的程序首先不是执行 main 函数,而是先加载动态库。
对,如果是程序启动时依赖的动态库,一般确实不是一上来就执行 main(),而是先经历动态链接器加载和初始化动态库的过程,然后才进入 main()。
大致顺序可以理解成:
execve("./a.out")
↓
Linux 内核读取 ELF
↓
加载程序本身
↓
加载动态链接器 ld-linux.so
↓
动态链接器加载程序依赖的 .so
↓
符号解析 / 重定位
↓
执行一些初始化代码、全局对象构造函数
↓
程序入口 _start
↓
libc 初始化
↓
main()
例如:
// libfoo.so
#include <cstdio>
class A {
public:
A() {
printf("A constructor\n");
}
};
A a;
主程序:
#include <cstdio>
int main() {
printf("main\n");
}
如果主程序链接了 libfoo.so,你可能看到:
A constructor
main
也就是说,动态库里的全局对象构造函数可以在 main() 之前执行。
不过有个细节:严格来说,“先加载动态库,再执行 _start”这种描述有点简化。
对于动态链接 ELF,Linux 内核发现 ELF 里面指定了类似:
/lib64/ld-linux-x86-64.so.2
这样的 ELF interpreter 后,内核会把控制权交给动态链接器。动态链接器先处理:
libc.so
libstdc++.so
libfoo.so
...
包括:
- 找到依赖库
mmap映射动态库- 处理重定位
- 解析符号
- 处理 TLS
- 执行初始化函数等
然后再进入正常的 C/C++ 启动流程,最终调用:
main(argc, argv);
所以 main() 实际上并不是 ELF 文件真正意义上的“第一条用户态指令”。
真正的入口通常是类似:
_start
然后大概:
_start
↓
__libc_start_main(...)
↓
各种初始化
↓
main()
↓
exit()
C++ 里尤其明显,因为:
Foo g_foo;
这样的全局对象必须在:
main()
之前完成构造。
还有一种情况要区分:如果你不是在链接阶段指定动态库,而是在运行过程中:
void* h = dlopen("libfoo.so", RTLD_NOW);
那就完全不同了。
此时程序已经执行到 main() 里面了:
main()
↓
执行一些代码
↓
dlopen("libfoo.so")
↓
加载 libfoo.so
↓
重定位
↓
执行 libfoo.so 的 constructor
↓
dlopen 返回
↓
继续执行 main()
例如:
int main() {
printf("before dlopen\n");
dlopen("./libfoo.so", RTLD_NOW);
printf("after dlopen\n");
}
如果 libfoo.so 中有全局对象:
A a;
输出可能是:
before dlopen
A constructor
after dlopen
所以可以记成:
| 动态库使用方式 | 什么时候加载 |
|---|---|
链接时依赖,例如 -lfoo |
main() 之前 |
ELF 的 DT_NEEDED |
main() 之前 |
dlopen() |
执行到 dlopen() 时 |
另外一个很实用的观察方式是:
ldd ./a.out
可以看到程序启动时依赖哪些动态库。
而:
readelf -d ./a.out
可以看到:
NEEDED Shared library: [libfoo.so]
NEEDED Shared library: [libc.so.6]
这些 NEEDED 动态库就是动态链接器在程序启动过程中需要处理的依赖。
最核心的一点是:
main()不是程序真正的起点,而只是 C/C++ 运行时初始化完成后调用的一个函数。
所以很多事情都可能发生在 main() 之前,例如动态库加载、重定位、C++ 全局对象构造、__attribute__((constructor)) 函数执行等。
#静态链接在链接期完成符号合并;动态链接把这部分工作推迟到加载、甚至第一次调用时
对,这句话整体是对的,但如果讲得更严谨,建议把“符号合并”改成 符号解析 + 重定位。
静态链接:在链接期完成大部分符号解析和地址重定位,把所需代码复制进最终可执行文件。
动态链接:把部分符号解析和重定位推迟到程序加载时,甚至某个函数第一次被调用时。
具体可以这样理解。
静态链接
比如:
// main.cpp
extern void foo();
int main() {
foo();
}
foo() 在:
libfoo.a
里面。
链接:
g++ main.o -L. -lfoo -o app
链接器会在链接阶段:
main.o
│
│ 发现未定义符号 foo
↓
libfoo.a
│
│ 找到 foo 的实现
↓
把相关目标文件拷贝进 app
↓
解析符号
↓
进行重定位
↓
生成最终 ELF
最后:
app
├── main()
├── foo()
└── ...
因此运行时通常不再需要 libfoo.a。
可以粗略理解为:
编译期 链接期 运行期
main.cpp
↓
main.o ──────┐
├── ld ───→ app ─────────→ 运行
libfoo.a ────┘
↑
主要链接工作
已经完成
动态链接
如果 foo() 在:
libfoo.so
里面:
g++ main.o -L. -lfoo -o app
链接器并不会把 foo() 的机器代码整体复制进 app。
而是留下类似这样的信息:
我依赖 libfoo.so
我需要符号 foo
ELF 中会出现:
DT_NEEDED: libfoo.so
运行时:
app
↓
动态链接器 ld-linux
↓
加载 libfoo.so
↓
解析需要的动态符号
↓
进行动态重定位
↓
运行
所以动态链接相当于把一部分工作:
链接期
推迟到了:
加载期
“甚至第一次调用时”是什么意思?
这说的是 lazy binding(延迟绑定)。
尤其是外部函数调用,例如:
printf("hello");
程序启动时,不一定立刻把:
printf
的最终地址解析出来。
可能先经过:
main
↓
PLT
↓
动态链接器
↓
找到 libc.so.6 中 printf 的真实地址
↓
把地址写入 GOT
↓
调用 printf
第一次:
printf()
↓
PLT
↓
GOT 尚未解析
↓
动态链接器
↓
查找 printf
↓
更新 GOT
↓
真正 printf
第二次再调用:
printf()
↓
PLT
↓
GOT 已经保存真实地址
↓
直接跳到 printf
因此:
第一次调用成本稍高,后续调用就快了。
这就是所谓的:
lazy symbol binding
但不是所有动态链接工作都能拖到第一次调用
这是非常重要的区别。
例如全局变量:
extern int global_count;
如果程序需要访问它:
global_count++;
这类数据符号相关的重定位,通常需要在程序启动时处理。
而:
foo();
printf();
malloc();
这种外部函数调用,才常常可以通过:
PLT + GOT
进行 lazy binding。
所以动态链接可以进一步分成:
动态链接
├── 加载时绑定
│ 程序启动时完成
│
└── 延迟绑定
第一次调用函数时完成
一个更准确的总结
你原来的这句话:
静态链接在链接期完成符号合并;动态链接把这部分工作推迟到加载、甚至第一次调用时
可以稍微改成:
静态链接在链接期完成符号解析和重定位,并将所需代码合入最终可执行文件;动态链接则把部分符号解析和重定位推迟到程序加载时,部分函数符号甚至可以延迟到第一次调用时解析。
再压缩成一句:
静态链接:
link time 解决
动态链接:
load time / run time 解决
而 PLT/GOT 正是理解“为什么动态链接可以推迟到第一次函数调用”的核心机制。
#动态库里哪些会共享?
假设 libfoo.so 同时被进程 A、B 加载:
| 内容 | 多进程之间 | 说明 |
|---|---|---|
.text 代码段 |
✅ 物理页通常共享 | 只读 + 可执行,同一个 .so 文件可映射到多个进程 |
.rodata 常量 |
✅ 通常共享 | 如字符串常量、只读常量 |
.data 已初始化全局变量 |
❌ 不共享 | 每个进程自己的虚拟地址空间 |
.bss 未初始化全局变量 |
❌ 不共享 | 每个进程各一份 |
static 全局/局部变量 |
❌ 不共享 | 本质也是可写数据 |
heap(malloc/new) |
❌ 不共享 | 每个进程独立堆 |
| stack | ❌ 不共享 | 每个线程自己的栈 |
thread_local / TLS |
❌ | 每个线程一份 |
| GOT 等动态链接数据 | ❌ 通常不共享 | 涉及运行期重定位 |
例如动态库:
// libfoo.so
int g_count = 10;
static int s_count = 20;
const char* hello = "hello";
void add() {
++g_count;
}
两个进程:
进程 A 进程 B
┌────────────────┐ ┌────────────────┐
│ libfoo .text │───────────│ libfoo .text │
│ add() │ 物理页共享 │ add() │
├────────────────┤ ├────────────────┤
│ g_count = 10 │ │ g_count = 10 │
│ s_count = 20 │ │ s_count = 20 │
└────────────────┘ └────────────────┘
独立 独立
A 调用:
add(); // A 的 g_count = 11
不会让 B 的 g_count 变成 11。
#为什么 .data 不共享?
Linux 加载 .so 本质上大量使用 mmap。
对于代码段,可以理解为:
libfoo.so 文件
│
├── .text physical page
│ ↑
│ ├── Process A
│ └── Process B
因为代码不会被修改,可以安全共享。
但全局变量必须允许修改,所以动态库的数据段通常使用类似 MAP_PRIVATE / Copy-on-Write 的语义。
初始时某些页面甚至可能共用物理页:
A ─┐
├── 同一个物理页
B ─┘
一旦 A 写:
g_count++;
内核进行 COW:
A ───── 新物理页:g_count = 11
B ───── 原物理页:g_count = 10
所以从程序语义上看,全局变量始终是进程私有的。
#全局变量如何处理?
- 加载时页表把数据页标出只读,多进程的页表项开始都指向同一物理页,此时引用计数 > 1
- 进程写全局变量,触发写保护缺页
- 内核发现这是 MAP_PRIVATE 且该页是共享的:分配新页,拷贝旧页内容,把当前进程的页表项指向新页,放开写权限,引用计数减一,别的进程继续看旧页
- 之后这个进程再写同一页,已经是私有页,不再拷贝,没写过的页继续共享
#同一个进程内部又不一样
这个区别非常重要。
假设一个进程里:
main
├── libA.so
└── libB.so
↓
都依赖 libfoo.so
正常情况下动态链接器只加载一次 libfoo.so:
┌── libA.so
│
main ── libfoo.so
│
└── libB.so
因此:
// libfoo.so
int g_count = 0;
在同一个进程中,libA.so 和 libB.so 访问的通常是同一个 g_count。
所以:
不同进程:
Process A::g_count != Process B::g_count
同一进程:
libA -> libfoo::g_count <- libB
↑
同一份
这就是为什么动态库里的全局变量经常表现得像“进程级单例”。
#static 能不能解决全局变量问题?
要看你想解决什么。
static int count = 0;
static 不会让它跨进程共享。
它主要改变符号可见性/链接属性。
比如:
// foo.cpp
static int count = 0;
这个 count 只在当前编译单元内可见。
但它仍然属于:
.data / .bss
所以还是:
进程 A:count A
进程 B:count B
各自一份。
函数内部 static 也一样:
int foo() {
static int count = 0;
return ++count;
}
它的生命周期是整个进程,但仍然:
每个进程一份。
#const 呢?
例如:
const int table[] = {1, 2, 3};
如果编译器能把它放进只读段:
.rodata
那么不同进程可以共享对应物理页。
但是:
const char* p = "hello";
这里要区分:
p
本身是一个可修改的指针变量,因此通常在 .data:
Process A: p ──┐
Process B: p ──┼──> "hello"
│
.rodata 可以共享
也就是说:
"hello"可以共享;p本身通常不共享。
如果写:
const char* const p = "hello";
编译器/链接器才更有可能把整个东西放到只读区域,但还要考虑重定位等具体实现。
#thread_local 更特殊
thread_local int count = 0;
关系是:
Process A
├── Thread 1 : count = 0
├── Thread 2 : count = 0
└── Thread 3 : count = 0
Process B
├── Thread 1 : count = 0
└── Thread 2 : count = 0
即:
每个进程的每个线程都有自己的实例。
普通全局变量:
一个进程一份
TLS:
一个线程一份
#真想让动态库全局变量跨进程共享怎么办?
不能靠普通:
int global = 0;
需要显式的 IPC / 共享内存,例如:
shm_open()
mmap(..., MAP_SHARED, ...)
或者:
System V shared memory
memfd
共享 mmap 文件
比如概念上:
struct SharedState {
pthread_mutex_t mutex;
int count;
};
SharedState* state =
static_cast<SharedState*>(
mmap(nullptr,
sizeof(SharedState),
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd,
0));
此时:
Process A ─┐
├── shared memory ── count
Process B ─┘
才是真正跨进程共享。
不过这时需要解决进程间同步,比如原子变量、文件锁、信号量,或者设置了:
PTHREAD_PROCESS_SHARED
的 pthread_mutex_t。
#dlopen() 时也是这个规律
同一进程:
void* h1 = dlopen("./libfoo.so", RTLD_NOW);
void* h2 = dlopen("./libfoo.so", RTLD_NOW);
通常动态链接器发现是同一个 DSO 后,会增加引用计数,而不是重新创建第二套全局变量:
h1 ─┐
├── 一个 libfoo.so 实例
h2 ─┘
↓
g_count
但这里有高级例外,比如 dlmopen() 创建新的 link-map namespace,就可能让同一 .so 在同一个进程中存在多份实例,于是全局状态也可能有多份。
#动态库的代价
程序启动时,动态链接器大致要做这些事:
- 找库:根据
DT_NEEDED、RPATH/RUNPATH、LD_LIBRARY_PATH、系统库路径等找到.so。 - 映射库:通过
mmap把共享库映射进进程地址空间。 - 做重定位:因为 ASLR、PIC 等原因,要修正 GOT、数据引用、部分地址相关项。
- 做符号解析:确定某个
foo最终对应哪个动态库里的实现。 - 做动态绑定:函数符号可以立即绑定,也可以通过 PLT/GOT 延迟到第一次调用时绑定。
所以启动路径可以粗略画成:
execve
↓
加载动态链接器
↓
查找依赖 .so
↓
mmap 映射
↓
符号解析
↓
重定位
↓
执行初始化函数 / 全局构造
↓
main()
其中你说的“动态绑定”还可以分两种:
立即绑定
程序启动时把函数地址都解析完
↓
启动稍慢
后续调用更直接
延迟绑定
第一次调用某函数时才解析
↓
启动更快
第一次调用稍慢
例如:
printf("A");
printf("B");
在 lazy binding 下可能是:
第一次 printf
↓
PLT
↓
动态链接器查找 printf
↓
把真实地址写入 GOT
↓
调用 libc::printf
第二次 printf
↓
PLT
↓
GOT 已有地址
↓
直接调用
不过动态库并不只是“慢”。它用这些运行时代价换来了很重要的收益:多个进程可以共享代码物理页、可执行文件更小、库可以独立升级、插件化和按需加载更方便。
所以可以把笔记概括成:
动态库的代价:把静态链接期的一部分工作搬到了运行时,因此程序启动时需要查找和映射共享库、完成动态符号解析和重定位;若采用延迟绑定,部分函数符号解析还会发生在第一次调用时。
还有一个容易混淆的点:真正比较重的通常不是“调用动态库函数”本身,而是加载和首次解析阶段。 一旦 GOT/PLT 已经绑定好,后续普通动态函数调用的额外开销通常很小。

浙公网安备 33010602011771号