深入理解CC++的编译与链接技术9:动态库细节

深入理解CC++的编译与链接技术9:动态库细节(完结)

前言

下面,我们准备来聊一下动态库的细节问题。这个问题一般而言,工程开发不太可能会涉及到,但是知道动态库的工作原理总比不知道好。所以,笔者这里专门的结合《C/C++高级编译技术》,重新聊一聊动态库的一些细节

8.1 解析内存地址的必要性

先不着急前进,补充几个汇编。

很显然,我们知道现代计算机的基本模型都是图灵机,我们知道操作数在哪,取出来做运算再放回去。

X86为例子,我们要知道内存操作数的地址,这样我们才能在内存和CPU之间来回传递数据。

复制代码
mov eax, ds:0xBAD10000 ; 将地址0xBAD10000装载到eax中
add eax, 0x1 ; 装载值自增
mov ds:0xBAD10000, eax; 写回操作

非常好,知道这个事情之后,我们要指出,函数调用的本质也是找到代码段的函数地址------比如说,咱们要调用一个平凡的add函数,就要告诉我们的call指令add哈桑农户在哪(也就说,我们要提供add函数入口点的代码段地址)

复制代码
add <0x11451400>:
	... ; Add Procedure
	
main:
	... ; Main Procedure
	call 11451400 ; add absolute

当然,有的时候我们也会call相对地址,这种情况下会方便一点。

引用解析中的常见问题

我们来看看最简单的情况吧!假如说,可执行文件是加载单个动态链接库后才能进一步工作的。这些事情是显而易见的:

  • 客户二进制文件提供进程内存映射中地址范围固定且可以预先确定的部分
  • 动态加载完成之后,才属于进程中的有效的部分
  • 当可执行文件调用了一个或者几个由动态库对外提供的功能实现的时候(比如说,动态库的接口),这个时候才去自然的建立联系

上述基本情况,我们可以知道一个事情:动态库最核心的问题是:库代码在运行时的位置是不确定的 。无论是 Windows 的 DLL,Linux 的 .so,还是 macOS 的 dylib,它们都有一个共同点:动态库无法在编译阶段确定最终的加载地址。

为什么呢?主要是这几个原因:

(1)多个动态库可能发生地址冲突

假设两个 .so 都想映射到虚拟内存中的 0x400000 区域,这将导致冲突。

为了避免冲突,操作系统的加载器必须重新选择一个合适的基址。

(2)ASLR(Address Space Layout Randomization)

现代操作系统会为安全性启用地址随机化,动态库每次加载地址都不同。

这意味着:编译器和链接器不能假设动态库会在固定地址运行。

(3)同一个动态库在不同进程中加载位置不同

进程的地址空间彼此独立,库在每个进程的加载位置可以完全不同。

地址要做转换是解决方案

Case: 我们就是要用导出的二进制符号

比如说,我们就是要用那些导出的符号,比如说库提供的------create_windowinit_alldeinit_all等等接口。这就是使用导出的二进制符号,这个时候,客户程序显然要立马知道加载成功的地址在哪里,而不是动态库原始的符号地址(他们从0开始偏移呢!),所以,在过去直接由链接器完成所有符号解析的工作显然就显得不可能了。符号地址的确定必须交由加载器一起确定才是。

Case:自己调用自己的私有符号

不管如何,有些私有的符号是无法被客户程序找到的,但是有一个更加严峻的问题------如果这些符号是被导出的符号在调用,这下又该怎么办呢?

链接器 - 装载器协作------旧技术

现在我们来仔细聊聊链接器 - 装载器协作的事情,了解了前面描述的所有约束后,可以根据以下的规则来建立链接器和装载器之间的协作:

  • 链接器识别自身符号解析的局限性。
  • 链接器精确统计失效的符号引用,准备引用修复提示,并将提示嵌入二进制文件中。
  • 装载器准确遵循链接器的重定位提示,并且在完成地址转换后根据这些提示进行修复。

链接器识别自身符号解析的局限性

在创建一个动态库时,链接器除了要明确地分清不同部分代码之间的关系,也需要足够准确地识别出将代码段加载到不同的地址范围中时会失效的符号引用。

首先,与可执行文件不同,动态库内存映射的地址范围是从零开始的。链接器处理可执行文件时,大多情况下不会将地址范围的起点设置成零。其次,在加载阶段前,如果链接器发现某些符号的地址无法解析时就会停止解析,取而代之会使用临时值填充未解析符号(通常会使用明显错误的值,比如0之类的数值)。但是这并不意味着链接器会完全放弃符号解析任务。相反,他只会放弃处理那些真的搞不定的符号。

下一步:链接器精确统计失效符号引用,准备修复提示

我们可以完全知道哪些已解析的引用会因装载器地址转换而失效。只要汇编指令需要绝对地址,指令中的引用都会失效。在完成动态库构建的链接阶段时,链接器可以标识出那些出现绝对地址的地方,并通过某些方法让装载器知道这些信息。为了提供链接器-装载器协作支持,链接器会为装载器预留一些提示,这些提示为装载器指出了如何修复动态加载中由于地址转换引发的错误,二进制格式规范支持一些新的节,专门用于为这类提示预留空间。此外还设计了特定的简单语法以便于链接器准确指出装载器需要执行的动作。

这些节在二进制文件中称为"重定位节",其中.rel.dyn节是最古老的重定位节。通常来说,链接器将重定位提示写入二进制文件中,以便于装载器读取这些提示。这些提示指定了装载器在完成整个进程的最终内存映射布局后需要修补的地址和装载器为了正确修补未解析引用需要执行的正确动作。

装载器准确遵循链接器重定位提示

最后一个阶段属于装载器。装载器读取由链接器创建的动态库,读取动态库中的装载器段(每个段都保存了多个链接器节),并将所有数据放置到进程内存映射中,存放在最初的可执行文件代码附近。

最后,装载器定位.rel.dyn节,读取链接器预留下的提示,并根据这些提示对原来的动态库代码进行修补。完成修补后,就可以准备使用内存映射启动进程了。相比于处理基本任务,在处理动态库加载时,我们需要为装载器提供更多的信息。

现代链接器-装载器协作的实现技术:PLT/GOT

GOT / PLT 的内部机制

GOT(Global Offset Table)用于让代码不依赖固定地址,而从表中取最终地址。当然,这显然要求我们要给咱们的代码施加以-fPIC进行编译(现在是否理解了为什么动态库的Step1是使用PIC位置无关了吧!)

现在,我们调用就变成了类似call [GOT + foo],为此当 foo 的地址被确定后,GOT 中的 foo 项被写成实际地址。这样我们就直接更新了

PLT 结合 GOT 实现延迟绑定:

  • 第一次调用函数 → PLT 跳到解析函数 → 更新 GOT→ 直接跳到正确地址 (不再解析)

PLT 的好处:

  • 加快程序启动速度
  • 仅在需要时解析符号

延迟绑定(Lazy Binding)流程详解

简单的说,延迟绑定指的是直到最后采取真正设置GOT表的地址,在那之前会轮询的解析所有的确定符号。

  1. call foo → 跳转到 PLT[foo]
  2. PLT[foo] 调用解析器 _dl_runtime_resolve
  3. 解析器在所有动态库中查找符号 foo
  4. 更新 GOT[foo] = foo 的真实地址
  5. 返回 foo
  6. 之后的调用直接跳 GO[foo]

动态链接时的重复符号 (Duplicate Symbols in Dynamic Linking)

在静态链接中,如果出现两个同名的全局符号,链接器通常会直接报错(Multiple Definition Error)。但在动态链接的世界里,规则却完全不同。这就是为什么值得单独聊聊

重复的符号定义 (Duplicate Symbol Definitions)

在大型项目中,我们经常会链接多个第三方库。假设你的程序链接了 libA.solibB.so,巧合的是,这两个库的开发者都定义了一个全局函数 void init() 或者一个全局变量 int g_config。

当你的主程序启动并加载这两个库时,内存中就会存在两个名为 init 的符号。

为什么会发生?
  1. 常见的命名 :使用了过于通用的名称(如 utils, log, init)且没有使用 static 限制作用域。
  2. 钻石依赖 (Diamond Dependency):项目依赖库 A 和库 B,而 A 和 B 内部都静态链接了同一个基础库 C(比如老版本的 OpenSSL)。这就导致 C 的符号在 A 和 B 中各有一份副本。
  3. 头文件实现 :在头文件中定义了全局变量或非内联函数,并被多个 .c/.cpp 文件包含。

重复符号的默认处理

Linux 下的动态链接器(ld-linux)采用了一套特定的规则来处理这种冲突,这通常被称为符号介入 (Symbol Interposition)

规则:先入为主 (First Match Wins)

默认情况下,动态链接器使用广度优先搜索 (BFS) 的顺序来查找符号。它会按照全局符号表(Global Symbol Table)中的顺序,绑定它找到的第一个 匹配符号,并忽略后续所有的同名符号。

加载顺序决定一切

这意味着,链接顺序(Link Order)加载顺序(Load Order) 决定了程序到底调用了谁的代码。

假设 app 依赖 libAlibB,且两者都有 func()

  • 如果链接指令是 gcc main.c -lA -lB:主程序调用 func() 时,通常会链接到 libA 的版本。
  • 危险的情况 :如果 libB 内部的代码调用了 func(),按照 ELF 的全局符号绑定规则,libB 也会调用 libAfunc()!这被称为"符号劫持"。libB 以为自己在调用自己的代码,实际上却跑到了 libA 里,这会导致逻辑错误甚至崩溃。

应用场景: LD_PRELOAD 环境变量正是利用了这一机制。通过预加载一个包含 malloc 实现的库,我们可以覆盖 libc 的标准 malloc,从而实现内存泄漏检测工具(如 Valgrind 或 jemalloc)。


在动态库链接过程中处理重复符号 (Handling Duplicates)

既然默认行为如此危险,我们如何在开发动态库时保护自己的符号不被劫持,或者不劫持别人?

1. 链接器参数:-Bsymbolic

在编译动态库时,可以使用链接器参数 -Wl,-Bsymbolic

  • 作用: 强制动态库优先在自身内部解析全局符号引用。
  • 效果: 如果 libB 编译时加了这个参数,那么 libB 内部调用 func() 时,一定会调用 libB 自己的版本,而不会被 libA 或主程序覆盖。
2. 符号可见性 (Symbol Visibility)

这是现代 C++ 开发的最佳实践。通过 GCC/Clang 的 -fvisibility=hidden 参数,将所有符号默认隐藏,只导出需要的接口。

  • 代码示例:

    C 复制代码
    // 只有标记了 DEFAULT 的符号才会被导出到动态符号表
    __attribute__((visibility("default"))) void public_api();
    
    // 即使是全局函数,在外部看来也是不可见的,避免冲突
    void internal_helper(); 
3. dlopen 的作用域控制

如果使用 dlopen 手动加载库,可以指定 RTLD_LOCAL 标志(这是默认值)。这使得被加载库的符号不会进入全局符号表,从而避免影响其他库。


说几个经典的

自定义内存分配器

许多高性能服务(如 Redis, MySQL)会链接 jemalloctcmalloc

  • 现象: 这些库定义了与 Glibc 相同的 malloc, free, realloc 符号。
  • 机制: 由于它们被显式链接或预加载,它们的符号在全局表中排在 Glibc 之前。
  • 结果: 整个进程(包括依赖 Glibc 的其他第三方库)的所有内存申请都会自动转发给 jemalloc。这是一个良性的、有意为之的符号冲突。
C++ STL 版本冲突

这是一个恶性的案例。

  • 场景: 主程序使用 GCC 4.8 编译,依赖 libStdOld.so;插件使用 GCC 9.0 编译,依赖 libStdNew.so
  • 问题: std::stringstd::vector 的内部实现在不同版本中可能不同,但它们的符号名称(Mangled Name)可能通过部分兼容性保持一致,或者发生冲突。
  • 后果: 当对象在跨库传递时,由于内存布局不同但符号相同,程序可能出现未定义行为(Undefined Behavior),通常表现为莫名其妙的 Segfault。

链接并不提供任何类型的命名空间继承 (Tip: No Namespace Inheritance)

这个事情要重复下!很多人认为:"我在 C++ 代码里把函数放在 namespace MyLib { ... } 里,或者我把代码编译成了 libMyLib.so,那么这个库就像一个独立的容器,里面的变量名 count 不会和外面冲突。"

但是实际上链接器(Linker)是"符号类型盲(Type-blind)"和"结构盲"的。我们都知道C++ 命名空间只是语法糖: 编译器通过名字修饰(Name Mangling)MyLib::foo() 变成了字符串 _ZN5MyLib3fooEv。对于链接器来说,这只是一个长字符串。如果两个库碰巧生成了相同的修饰名(Mangled Name),冲突依然会发生。而动态库不是命名空间: 动态库只是文件组织形式。一旦被加载到进程内存,所有导出符号(Exported Symbols)都会进入一个平铺的、扁平的全局符号池(Global Symbol Table)。libA.so 里的全局变量 g_contextlibB.so 里的 g_context 在链接器眼中就是同一个东西,除非你使用了 Visibility 隐藏或 Local 绑定。

相关推荐
222you4 小时前
SpringIOC的注解开发
java·开发语言
William_cl4 小时前
【CSDN 专栏】C# ASP.NET Razor 视图引擎实战:.cshtml 从入门到避坑(图解 + 案例)
开发语言·c#·asp.net
席之郎小果冻4 小时前
【03】【创建型】【聊一聊,单例模式】
开发语言·javascript·单例模式
god004 小时前
Selenium等待判断元素页面加载完成
java·开发语言
isyoungboy4 小时前
c++使用win新api替代DirectShow驱动uvc摄像头,可改c#驱动
开发语言·c++·c#
Dxy12393102165 小时前
python如何去掉字符串中最后一个字符
开发语言·python
世转神风-5 小时前
qt-windows用户点击.exe,报错:缺少libgcc_s_seh-1.dll
c++·qt
码界奇点5 小时前
Java Web学习 第15篇jQuery万字长文详解从入门到实战解锁前端交互新境界
java·前端·学习·jquery
慕容青峰5 小时前
【牛客周赛 107】E 题【小苯的刷怪笼】题解
c++·算法·sublime text