动态链接库处理全局变量

参考资料

https://www.bilibili.com/video/BV1MJYR69EiD/?spm_id_from=333.1365.list.card_archive.click&vd_source=38033fe3a1f136728a1d6f8acf710b51

#当我们指定了动态库之后,我们的程序首先不是执行 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. 加载时页表把数据页标出只读,多进程的页表项开始都指向同一物理页,此时引用计数 > 1
  2. 进程写全局变量,触发写保护缺页
  3. 内核发现这是 MAP_PRIVATE 且该页是共享的:分配新页,拷贝旧页内容,把当前进程的页表项指向新页,放开写权限,引用计数减一,别的进程继续看旧页
  4. 之后这个进程再写同一页,已经是私有页,不再拷贝,没写过的页继续共享

#同一个进程内部又不一样

这个区别非常重要。

假设一个进程里:

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 在同一个进程中存在多份实例,于是全局状态也可能有多份。

#动态库的代价

程序启动时,动态链接器大致要做这些事:

  1. 找库:根据 DT_NEEDED、RPATH/RUNPATH、LD_LIBRARY_PATH、系统库路径等找到 .so。
  2. 映射库:通过 mmap 把共享库映射进进程地址空间。
  3. 做重定位:因为 ASLR、PIC 等原因,要修正 GOT、数据引用、部分地址相关项。
  4. 做符号解析:确定某个 foo 最终对应哪个动态库里的实现。
  5. 做动态绑定:函数符号可以立即绑定,也可以通过 PLT/GOT 延迟到第一次调用时绑定。

所以启动路径可以粗略画成:

execve
  ↓
加载动态链接器
  ↓
查找依赖 .so
  ↓
mmap 映射
  ↓
符号解析
  ↓
重定位
  ↓
执行初始化函数 / 全局构造
  ↓
main()

其中你说的“动态绑定”还可以分两种:

立即绑定
程序启动时把函数地址都解析完
    ↓
启动稍慢
后续调用更直接

延迟绑定
第一次调用某函数时才解析
    ↓
启动更快
第一次调用稍慢

例如:

printf("A");
printf("B");

在 lazy binding 下可能是:

第一次 printf
    ↓
PLT
    ↓
动态链接器查找 printf
    ↓
把真实地址写入 GOT
    ↓
调用 libc::printf

第二次 printf
    ↓
PLT
    ↓
GOT 已有地址
    ↓
直接调用

不过动态库并不只是“慢”。它用这些运行时代价换来了很重要的收益:多个进程可以共享代码物理页、可执行文件更小、库可以独立升级、插件化和按需加载更方便。

所以可以把笔记概括成:

动态库的代价:把静态链接期的一部分工作搬到了运行时,因此程序启动时需要查找和映射共享库、完成动态符号解析和重定位;若采用延迟绑定,部分函数符号解析还会发生在第一次调用时。

还有一个容易混淆的点:真正比较重的通常不是“调用动态库函数”本身,而是加载和首次解析阶段。 一旦 GOT/PLT 已经绑定好,后续普通动态函数调用的额外开销通常很小。

posted @ 2026-10-05 17:03  光風霽月  阅读(4)  评论(0)    收藏  举报