《高级C/C++编译技术》02 - 定位库文件、设计动态链接库
定位库文件
构建过程中库文件定位规则
Linux
静态库命名:lib<name>.a
动态库命名:lib<name>.so.<lib version info>
动态库版本号:<主版本号 M>.<次版本号 m>.<补丁版本号 p>
soname:lib<name>.so.<major version> 例如库文件 libz.so.1.2.3 的soname就是 libz.so.1
soname 通常由链接器嵌入二进制库文件的专有ELF字段中如: gcc -shared <objs> -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0.0
对于库文件 libz.so.1.2.3 库名称或库链接器名称为 z
在构建过程中使用 -L 指定搜索的目录路径 -l 指定库文件名,例如:gcc main.o -L../sharedLib -lworkingdemo -o demo
和 gcc -Wall -fPIC main.cpp -Wl,-L../sharedLib -Wl,-lworkingdemo -o demo
注意 -l 参数接收的一定是库名称,不能带有路径。对于静态库没有关系,对于动态库:-L参数只在链接阶段使用,-l 的参数被嵌入到二进制库文件中,并在运行时起到重要作用。
如果给-l参数传递一个绝对路径,那么无论在那台机器上库文件只有在这个绝对路径下才能被正确找到;如果是相对路径,那么库文件也必须位于相应的相对路径下;如果是库文件名,那么库文件只要能被搜索到就能正常运行。
Windows
在链接器输入中指定DLL导入库(.lib 文件)
// 指定lib文件的代码
#pragma comment(lib, "<import library name, full path, relative path>")
可以通过预处理指定要链接哪些库
运行过程中库文件定位规则
Linux
优先级从高到低
- 预加载库
通过设置 LD_PRELOAD 环境变量,export LD_PRELOAD=/dir/libz.so:$LD_PRELOAD
通过 /etc/ld.so.preload 文件。文件中包含的 ELF 共享库文件在程序启动前加载,文件列表使用空格分割。
该方案不符合标准的设计规范,适合设计压力测试、诊断、对原始代码的紧急补丁等。用于替换原有动态库
elf格式使用 rpath(DT_RPATH) 字段存储与二进制文件相关的搜索路径细节,但其中的相对路径是相对于加载器的。后来又添加了 runpath(DT_RUNPATH) ,二者都可用。runpath为空时rpath为最高优先级,runpath不为空时rpath被忽略。使用方法gcc -Wl,-R/dir/projects -lmylib
或export LD_RUN_PATH=/dir/projects:$LD_RUN_PATH
使用chrpath修改 rpath,使用 patchelf --set-rpath 修改runpath - 环境变量 LD_LIBRARY_PATH
只该用于实验目的,产品不应该使用。 - ldconfig 缓存
ldconfig 工具通常是标准包安装的最后一步
ldconfig将指定目录插入到动态库搜索列表,该文件维护在 /etc/ld.so.conf 中
同时扫描新加入的目录路径,将发现的库文件名加入到库文件名列表中,该列表维护在 /etc/ld.so.cache 文件中 - 默认库文件路径 /lib 和 /usr/lib
在构建可执行文件时使用
-z nodeflib
链接器选项,那么搜索库时操作系统默认库中库会被忽略
优先级方案小结:
指定了 RUNPATH (即 DT_RUNPATH 非空)
- LD_LIBRARY_PATH
- runpath
- ld.so.cache
- /lib /usr/lib
没有指定 RUNPATH
- 被加载库的 RPATH,然后时二进制文件的 RPATH,直到可执行文件或动态库将这些库全加载完毕
- LD_LIBRARY_PATH
- ld.so.cache
- /lib /usr/lib
Windows
- 应用程序同一目录下
- 系统动态链接库目录之一 (C:\Windows\System 或 C:\Windows\System32)
设计动态链接库
在Linux创建动态库
gcc -fPIC first.c second.c
gcc -shared first.o second.o libmylib.so
编译器 -fPIC 选项
-fPIC 代表什么?
PIC 是位置无关代码(Position-independent Code)的缩写。
在PIC出现前只有第一次加载动态库的进程可以使用它。当其他进程加载同一个动态库时需要再将完整副本加载到自身内存空间中以外。在加载前,装载器要修改动态库 .text 段,使得在加载该库中动态库的所有符号都是有意义的。这可以满足需求但是动态库代码的修改是不可逆的,其他进程不能复用。这叫“加载时重定位”
一定要使用 -fPIC 创建动态库么?
在 x86 中,不用此选项会默认使用加载时重定位方式。
在 x64 中,忽略此选项导致链接错误(后续讲解原因)。要使用 -fPIC 链接或向编译器传递 -mcmodel=large
只有编译动态链接库时才用 -fPIC 么?在编译静态链接库时能否使用?
x86中会对静态库产生细微影响,但微乎其微。
x64中:
- 静态库链接到可执行文件中,是否指定此选项没影响
- 静态库链接到动态库中,必须使用 -fPIC 选项编译(或指定编译器选项 -mcmodel=large),否则报错
(视乎因为32寄存器的汇编器无法访问64为平台地址偏移的范围。不确定原因)
设计动态库:基础篇
动态库通过程序二进制接口(ABI)提供接口。由于C++缺乏严格标准化的影响,在设计动态库ABI时要考虑更多问题。
1. C++ 使用了更加复杂的链接符号命名规则
C++ 面向对象带来的问题:
- C++中方法比函数更多,类还可能从属于命名空间,遇到模板时更加复杂
为了唯一标识函数,链接器在为函数入口建立符号时要包含函数从属信息 - C++的重载机制使得建立函数入口点符号时必须包含输入参数的信息
为了解决问题问题产生了“名称修饰”技术。这是将函数名、函数从属信息、函数参数列表组合生成符号。新的问题是名称修饰惯例没有统一标准,由编译器自己指定。除ABI外,还有大量因素在命名机制中起作用(异常栈处理、虚函数表布局、结构和栈帧填充等)。
C++ 默认为C风格函数进行重命名,使用 extern "C"
使其避免重命名。
2. 静态初始化顺序问题
C语言中链接器将.data节中变量初始化,C 语言中初始化顺序不重要。C++中数据类型通常是对象,对象初始化是在运行时通过对象构造函数完成的。为了初始化cpp对象,链接器要做更多工作。
为了帮链接器完成任务,编译器将特定文件要用的所有构造器的列表嵌入目标文件中,并将相关信息存放在特定目标文件中。在链接时,链接器检查所有目标文件,并将其中构造函数列表合并为完整的列表,已被运行时执行。链接器根据继承链观察执行构造函数的顺序。
有时由于链接器的逻辑限制,程序在加载时引起严重崩溃,而且是在任何调试器能够捕捉到之前。发生这类情况因为对象依赖于其他需要在其之前初始化的对象。这类问题被称为“静态初始化顺序问题”。
《Effective C++》47条:确保非局部静态对象在使用前被初始化
问题描述:
非局部静态对象:
- 定义在全局或命名空间中
- 定义在类中的静态变量
- 定义在文件作用域中的静态变量
它们在程序启动之前就完成了对这类对象的初始化。对每个对象,链接器维护了用于创建对象的构造函数列表,并根据继承链顺序依次执行了这些构造函数。这也是链接器唯一可识别并实现的对象初始化顺序方案。
假设有以下两个静态对象:
- A对象用于初始化网络组件,查询可用网络列表、初始化套接字等
- B 对象调用通过网络发送消息
正确顺序是先初始化A再初始化B。但是没有任何规则指定静态对象的初始化顺序。链接器遵循的初始化顺序是由不相关的运行时状态产生的随机数决定的。这使得程序在进程加载期间会偶尔崩溃,且难以追踪问题。
解决方案:
链接器没指定变量初始化顺序,但函数体内声明的静态变量的初始化顺序是确定的。即,可以将对象声明为函数(或类成员函数)内部的静态变量,这样在调用该函数第一次遇到变量定义时才会初始化这些变量。
对象实例不应自由存放在数据内存中,相反,应该:
- 被声明成函数内静态变量
- 只能通过函数来访问定义在文件作用域中的静态变量
总而言之,使用以下两种解决方法:
- 为 _init() 函数提供自定义实现,这个函数在动态库加载时被立即调用的标准函数,可在其中通过类静态成员函数初始化对象,已通过构造函数强制初始化。_fini() 时在动态库被卸载时立即调用的标准函数
- 调用一个自定义函数访问特定对象,而不是直接访问。该函数将会包含一个C++类的一个静态实例,并返回引用。在第一次访问前,程序自动构造这个静态变量,可确保第一次实际调用前完成对象初始化。GNU编译器和C++11标准均保证这种方法线程安全
3. 模板
不同的模板特殊化后会产生不同的机器码。当编译器遇到模板时要将其具体化为某种形式的机器码。但只有在其他源代码文件完成检查并推断出其代码中应如何特殊化该模板时,才能执行该任务。虽然在独立应用程序中相对简单,但当要使用动态库导出模板时,模板特殊化就要更完善的方案了。
两种通用解决方案:
- 编译器保证所有模板特殊化代码,并为每个特殊化版本创建一个弱符号,如果最终的构建结果中没有使用,链接器可丢弃该符号。
- 链接器在连接结束之前都不包含模板特殊化的机器代码实现。当其余所有连接任务都完成后,链接器会检查代码,确定到底需要哪些特殊化版本,并调用C++编译器创建所需模板特殊化代码并插入文件。
设计应用程序二进制接口
为了适应不同平台、编译器,建议遵循以下规则:
1. 用一组 C 风格函数实现动态库 ABI
使用 extern "C"
。 原因:
- 避免各种 C++ 与链接器交互的问题
- 提升跨平台可移植性
- 提升不同编译器产生的二进制文件间可交互性
2. 提供完整ABI声明的头文件
3. 使用被广泛支持的标准 C 关键字
4. 使用类工厂机制(C++)或模块(C)
extern "C" open();
{
return new MyClass();
}
extern "C" opt(MyClass *p, int arg);
{
p->opt(arg);
}
extern "C" close(MyClass *p);
{
delete p;
}
5. 只对外提供必要符号
6. 利用命名空间解决符号名称冲突问题
控制动态库符号可见性
Win默认链接器符号外部不可见,Linux中所有动态库的链接器符号都是外部可见的。实践中,最终只有包含应用程序二进制接口的链接器符号是外部可见的,其余符号是隐藏且外部不可见的。
1. 导出 Linux 动态库符号
gcc 提供了多种机制设置链接器符号可见性
- 影响所有代码
-fvisibility=hidden
所有符号对外不可见 - 只印象单个符号
_attribute__ ((visibility("<default | hidden>")))
作为函数前的修饰 - 影响单个或一组符号
常用于头文件
#pragma visibility push(hidden) // 其中声明对外不可见 void fun(void); #pragma visibility pop
- 在指定版本信息时也可控制符号可见性
2. 导出 windows 动态链接库符号
使用 __desclspec(dllexport) ,在客户端使用时则使用 __desclspec(dllimport)
或使用模块定义文件 .def
完成链接需要满足的条件
Windows中只有完成所有动态链接库符号的解析的情况下链接器才会完成整个链接过程。直到完成最后一个符号的解析之前来链接器都会搜索完整的依赖库列表。
Linux 中即使链接器没有完成所有符号的解析工作,动态库的链接过程也会完成。链接器假设在链接阶段缺失的符号在进程内存映射中能够找到,可能运行时加载某些动态库后就能找到。那些需要却没有在动态库中提供的服啊后被标记为 undefined("U")
gcc 使用 gcc -fPIC <source file> -l <libs> -WL,--no-undefined 可使其像Windows一样严格。
动态链接模式
加载时动态链接
常用于程序从启动到结束都需要某个库的情况。构建过程要准备以下内容:
编译阶段
- 动态库的导出头文件,其中定义 ABI 接口
链接阶段:
- 项目所需动态库列表
- 客户端二进制文件所需的动态库二进制文件路径,用于建立需要对外提供的动态库的符号列表
运行时动态链接
让开发人员有选择地加载必要的动态库。
构建时要提供以下内容:
编译阶段:
- 动态库的导出头文件,定义了ABI接口
链接阶段:
- 至少提供选哟加载的动态库的文件名
设计动态库:进阶篇
动态链接一个重要概念就是内存映射。不同进程共享动态库代码段不共享数据段,进程获取库文件的数据段副本进行使用。
引用解析中常见问题
加载动态库时涉及地址转换。链接器和装载器执行的地址转换操作本质上不同。
链接器执行地址转换时,在合并过程中收集的目标文件均不包含已解析引用。这使得链接器在寻址适合存放目标文件的地址范围时有很大自由。完成目标文件的布局后,链接器解析所有引用,将地址嵌入汇编代码。
装载器得到的输入是动态库二进制文件,这些文件完成了完整的构建过程,完成了引用解析。装载器若执行地址转换会使链接器嵌入的地址毫无意义。
注意:只有那些需要对外可见的符号才会受到地址转换的影响,对外不可见的只有附近的指令访问这些符号,所以使用相对偏移地址实现。
地址转换引发的问题
1. 客户二进制程序需要知道动态库符号地址
动态库加载地址是随机的
2. 被装载的库不需要知道自身的符号地址
假设动态库的接口 Reinitialize() 接口调用了另一个接口 Initialize()。二者都是对外暴露的。链接器实现跳转或调用指令必须使用绝对地址跳转。Initizlize 函数的入口点肯定是动态库导出符号,引用该函数的跳转指令无法使用相对跳转。
链接器-装载器协作
在动态链接中,链接器无法像构建单个可执行文件那样解决所有问题。即使链接器在构建阶段正常完成引用解析,但加载时的地址转换会使嵌入汇编调用指令中的绝对地址无效。必须有工具在加载完成后修复对指令的破坏。
总体策略
- 链接器识别自身符号解析的局限性
要足够准确识别将代码段加载到不同地址范围时会失效的符号引用
首先,动态库内存映射的地址返回是从0开始的,链接器处理可执行文件时,大多数情况下不会将地址范围起点设置为0
其次,加载时,如果链接器发现某些符号的地址无法解析时就停止解析,取而代之会使用临时值填充未解析符号 - 链接器精确统计失效符号引用,准备修复提示
链接器会为装载器预留一些提示,这些提示为装载器指出如何修复动态加载中由地址转换引发的错误,二进制格式规范支持一些新的节用于预留这类提示的空间,还有一些简单语法指出装载器要执行的动作。这些节称为“重定位”节,其中有 .rel.dyn
这些提示制定了:
* 装载器在完成整个进程的最终内存映射布局后要修补的地址
* 装载器为了正确修补未解析引用需要执行的正确动作 - 装载器准备遵循链接器重定位提示
装载器读取动态库并将数据放到进程内存映射中,存放在最初可执行代码附近。最后,定位 .rel.dyn 节,读取链接器预留下的提示,并根据提示对原来的动态库代码修改。完成修后,就可以准备使用内存映射启动进程了
具体技术
那些需要修补代码节的二进制文件通常携带 .rel.dyn 节。
加载一个动态库双方都要面对的问题:
- 客户二进制程序需要知道动态库符号地址
- 被装载的库不需要知道自身的符号地址
图中是一个可执行文件装载一个动态库。当动态库也要加载动态库时就形成了一条动态加载链,任何动态加载中间的动态库都具有双重角色。情景1和情景2可能发生在同一个二进制文件上。
链接器重定位提示概述
链接器主要将重定位提示存储在 .rel.dyn 节中,少量在 rel.plt got got.plt 等中。可使用 readelf objdump 显示重定位提示内容。
对于各个字段:
链接器-装载器协作实现技术
装载时重定位 LTR
缺陷:
- 该技术使用变量或函数地址的值修改动态库代码,这仅对第一个加载动态库的程序有意义,其他程序加载动态库时无法使用这个已被加载的动态库,要再加载一个副本。
- 这种方法需要的代码修改量与引用数量成正比。有多少次引用就有多少次修改和副本。
位置无关代码 PIC
关于位置无关代码:https://www.cnblogs.com/Linux-tech/p/13873886.html
通过引入间接的额外步骤来避免对动态库代码段指令的不必要直接修改。类似“用指向指针的指针代替指针”。
例如:引用外部符号的指令获取符号地址的时候要两个步骤。1. 使用mov访问一个地址,该地址存放了实际的符号地址。2. 将该地址处的数据(即所需符号的地址)加载到有效的CPU寄存器中。这样我们就可以将该寄存器作为后续指令的操作数了。
这种技术使用“全局偏移表”(Global Offset Table, GOT),该表放在 .got 节中。对每个需要解析的符号,链接器都会在 got 中固定位置存放该符号信息。代码节和GOT距离与特定符号信息在GOT中的偏移是固定的(在链接时已知)。对编译器使用一个代码指令引用固定位置的数据也是完全可行的。这种方法实现代码不需要依赖于实际符号地址,故多进程共享共享库时,不需要修改代码。装载器根据特定进程内存布局对数据进行调整,不需要对代码段进行修改,而是修补 .got 节。
1. 延迟绑定
只有在程序启动后,且程序执行流遇到指令引用了地址保存在 .got 节和 .got.plt 节中的符号时,装载器才会设置其中数据。这让进程加载更快,应用启动的也更快,但当第一次引用符号时会多消耗一些时间。客户程序对动态库符号的实际引用越少,能得到的性能优化就越多。
2. 动态链接递归链的规则和限制
程序可能加载多个动态库,动态库可能也加载多个动态库。这就形成一个树结构。
3. 实现技术选择限制
链接器-装载器协作时有两种方法:LTR、PIC。以下是一些限制:
- 可执行文件直接加载库时首选PIC
- LTR和PIC都可能被交替使用
- 单个动态库严格使用同一种链接器-加载器协作方法解析情景1和情景2