前言
在编写代码时我们经常会用到第三方提供的函数接口,这些函数一般是以库的形式提供的,常见的库有两种形式,静态库和动态库。
静态库与动态库
在介绍库之前,先简单介绍一下目标文件。目标文件常常按照特定格式来组织,在linux下,它是ELF格式(Executable Linkable Format,可执行可链接格式),而在windows下是PE(Portable Executable,可移植可执行)。
而通常目标文件有三种形式:
- 可执行目标文件。即我们通常所认识的,可直接运行的二进制文件。
- 可重定位目标文件。包含了二进制的代码和数据,可以与其他可重定位目标文件合并,并创建一个可执行目标文件-一般为.o文件。
- 共享目标文件。它是一种在加载或者运行时进行链接的特殊可重定位目标文件-一般为.so文件。
静态库
将上述提到的可重定位目标文件打包成一个单独的文件(一般为.a文件),这个.a文件就成为静态库。其实静态库就是.o文件的集合,使用ar打包命令来生成静态库。eg. ar rcs mylib.a 1.o 2.o 3.o
链接过程
ld最基本的链接单位是.o文件,ld链接器主要解析object文件内的以下内容:
- 输出符号OS: 可供外界使用的符号
- 未定义符号US: 需要从外部提供的符号
链接器在链接时,会维护以下三个集合:
- U: 当前所有未定义的引用符号的集合
- D: 当前所有已知定义的符号集合
- E:组成可执行文件目标文件集合
链接过程就是ld链接器按照命令顺序读取.o和.a文件。
链接.o文件有以下几个解析规则:
- 将该.o文件加入目标文件集合E中
- 首先将obj文件的所有输出符号加入集合D,如果obj文件内的输出符号和集合D中的符号冲突,ld就会报多重定义错误。
- 若集合U中的某一符号可以obj的输出符号匹配上,则将该符号从集合U中去除
- 用集合D中的已定义符号去匹配obj文件内的未定义符号,最后将obj内的无法和集合D中匹配上的未定义符号加入未定义符号集合U中。
若遇到.a文件,ld会扫描.a文件内部包含的所有.o并按照以下规则进行解析:
- ld会解析当前.o的输出符号是否可以减少集合U中的未定义符号,若无法提供集合U中的符号,该.o文件就会丢弃,不会加入链接
- 若.o文件的输出符号可以提供集合U中的未定义符号,那么链接器就会将.o文件加入链接,和上述.o文件的解析过程一致
- 如果.a文件中的某个.o加入链接,而这个.o文件引入了新的未定义符号,那么ld会从头扫描一遍.a里的.o文件,试图找出新的未定义符号所在的.o文件并将该.o文件加入链接,这个过程会一直重复直到没有引入新的未定义符号
根据以上规则我们可以知道,如果链接时只包含.o文件时,链接结果不会受.o文件排列顺序影响,并且同一个静态库.a文件内的.o文件不受链接顺序的影响,但是只要链接跨越静态库边界,链接顺序就会是一个问题。下面我们通过一个例子来解释上面的规则,并阐明链接顺序对链接结果的影响。
callee.c
void callee(void)
{
printf("callee");
}
caller.c
void caller(void)
{
callee();
}
main.c
int main(void)
{
caller();
return 0;
}
我们callee.c,caller.c,main.c三个文件,如果我们只通过.o文件进行链接,那么顺序不会造成影响,以下链接命令均可以正常执行
1. gcc -g -o app main.o caller.o callee .o
2. gcc -g -o app caller.o callee.o main.o
3. gcc -g -o app callee.o caller.o main.o
但是如果我们将caller.c 和 callee.c打包成静态库,那么链接顺序就会是个问题
#ok
gcc -static -o app main.o -lcaller -lcallee
# fail1: undefined reference to 'callee'
gcc -static -o app main.o -lcallee -lcaller
# fail2: undefined reference to 'caller'
gcc -static -o app -lcaller -lcallee main.o
fail1: main.o引入了caller未知符号,解析callee.a时,未定义符号集合U中只有caller,calee.a无法提供,所以calee.a被略过,caller.a可以提供,所以caller.o被加入链接,但是caller.o引入的未知符号callee却再也无法获得,因为链接器默认不会再去从头查找该符号,最终报错无法找到callee符号
fail2:刚开始,未定义符号集合U中为空,所以callee.a和caller.a都被略过,导致最后main所需的caller符号无法找到,报错。
我们一般在链接的时候都应遵循的法则是:
如果一个静态库A需要依赖静态库B,在链接命令中A应该要放在B之前
如果A和B互相依赖呢?即A中调用了B的函数,B中调用了A的函数,那么就形成了循环依赖
改变预设行为的参数
如果链接器ld的预设行为没有办法搞定编译,那么可以改变一些ld配置参数来达到目的
-start-group 和 -end-group
通过该ld选项可以指定多个静态库为同一群组,ld在遇到未定义符号时,ld会将扫描范围扩大至同一群组里的所有object文件。可以认为把多个静态库当做一个大的静态库来做链接,当这些静态库里出现未定义符号,将从头在这个”静态库组“里重新搜索一遍以期望找到该未定义符号。
由于扫描的范围变大,并且object数目变大,在比较极端的情况下会使链接速度明显变慢
--whole-archive 和 --no-whole-archive
该ld选项可以强制将包含在--whole-archive 和 --no-whole-archive中间的静态库的所有object文件全部链接进来,不管静态库中的个别object文件是否实际被使用到。该选项的缺点是会把一些无用的object文件链接进程序,可能导致最后的可执行程序变得很大。
特性:
- 由于每个使用静态库的应用程序都需要拷贝所用函数的代码,所以静态链接的生成的可执行文件会比较大,多个程序运行时占用内存空间比较大(每个程序在内存中都有一份重复的静态库代码)
- 由于运行的时候不用从外部动态加载额外的库了,速度会比共享库快一些
- 更换一个静态库或者修改一个静态库后,需要重新编译应用程序
动态库
动态库使用了PIC技术使代码和数据的引用与地址无关,也称“位置无关代码”,程序可以被加载到地址空间的任意位置,这就可以使得动态库具备动态加载的功能。它并不在链接时将需要的二进制代码都“拷贝”到可执行文件中,而是仅仅“拷贝”一些重定位和符号表信息,这些信息可以在程序运行时完成真正的链接过程。
特性:
- 应用程序在运行的时候加载共享库
- 减少了依赖共享库的模块的大小,因为它们不必把共享库提供的功能的实现代码静态编译到自己的模块代码中。
- 运行多个程序时占用内存空间比也比静态库方式链接少(因为内存中只有一份共享库代码的拷贝)
- 由于有一个动态加载的过程所以速度稍慢
- 更换动态库不需要重新编译程序,只需要更换相应的库即可
动态库的使用
编译时使用-shared -fPIC参数产生动态库,eg. gcc -shared -fPIC -o libtest.so test.c
使用时加上-ltest
将libtest.so链接到可执行文件中
共享库的命名
- 每个动态库都有一个以"lib"为前缀且以".so.x"为结尾的被称为soname的特定名称,其中x为主版本号,soname命名格式通常为libxxx.so.x
- 每个动态库还有一个包含了真正的库代码的文件名,通常被称为库的realname,与soname相比,它增加了副版本号(minor number)和发行版本号(release number),命名格式通常为libxxx.so.x.y.z,其中so后缀中的x为主版本号,y为副版本号,z为发行版本号。
- 链接或启动依赖了共享库的应用模块时,链接器(linker)或loader只认不带任何版本号的共享库名,可以把供linker/loader用的库名称作"linker name"。也即,某个依赖了zlib库的模块在链接或启动时,linker或loader只会查找名为libz.so的共享库,查找不到就会报错,gcc的-L选项应该指定linker name所在的目录。有的linker name是库文件的一个符号链接,有的linker name是一段链接脚本。
上面提到的realname/soname/linker name这3个命名约定是linux系统管理共享库的关键,具体而言:
- 当库开发者创建共享库时,通常以realname为该库命名
- 该共享库的某个版本被安装时,安装脚本通常会下载对应版本命名为realname的库文件,然后调用linux系统内置的ldconfig工具为名为realname的库文件生成名为soname的软链且把该软链关系更新至/etc/ld.so.cache中
- 安装脚本创建一个不带版本号的库名(即共享库的linker name),它是一个指向该库soname的symbolic link
- 更新新版共享库时,安装脚本重复上述第2步
当然,我们完全可以手动完成上述步骤中的两次软链设定。还以我的linux系统机器上zlib共享库为例,它有一个供linker在链接时查找用的名为libz.so的库名,该库名是一个指向libz.so.1的软链,而libz.so.1是一个指向libz.so.1.2.8的软链。
两层软链的部署约定为同一系统下同一个共享库不同版本间的共存或共享库升级提供了方便:依赖了某共享库的上层模块无需关心当前系统下该共享库的最新版本是多少,只要最新版本已成功安装且soname指向了最新版本的realname,则上层模块下次启动时会由loader自动加载最新版本的共享库。
共享库搜索路径
共享库的搜索路径由动态链接器决定,从ld.so(8)的Man Page可以查到共享库路径的搜索顺序:
- 首先在环境变量LD_LIBRARY_PATH所记录的路径中查找。
- 然后从缓存文件/etc/ld.so.cache中查找。这个缓存文件由ldconfig命令读取配置文件/etc/ld.so.conf之后生成,稍后详细解释。
- 如果上述步骤都找不到,则到默认的系统路径中查找,先是/usr/lib然后是/lib。
所以如果在运行时报找不到共享库的错误我们可以通过以下几种方法来解决:
- 通过环境变量LD_LIBRARY_PATH把当前目录添加到共享库的搜索路径
LD_LIBRARY_PATH=. ./main
这种方法只适合在开发中临时用一下,通常LD_LIBRARY_PATH是不推荐使用的,尽量不要设置这个环境变量 - 把libxxx.so所在目录的绝对路径(比如/home/somedir)添加到/etc/ld.so.conf中(该文件中每个路径占一行),然后运行ldconfig。ldconfig命令除了处理/etc/ld.so.conf中配置的目录之外,还处理一些默认目录,如/lib、/usr/lib等,处理之后生成/etc/ld.so.cache缓存文件,动态链接器就从这个缓存中搜索共享库
- 把libxxx.so拷到/usr/lib或/lib目录,这样可以确保动态链接器能找到这个共享库
- 其实还有第四种方法,在编译可执行文件main的时候就把libstack.so的路径写死在可执行文件中:
$ gcc main.c -g -L. -lstack -Istack -o main -Wl,-rpath,/home/somedir
,当然rpath这种办法也是不推荐的,把共享库的路径定死了,失去了灵活
动态链接
动态链接可以按以下两种方式进行:
-
在第一次加载并运行时(load-time linking)
Linux通常由动态链接器(ld-linux.so)自动处理;
标准C库(libc.so)通常是按照这种方式被动态链接的。 -
在程序已经开始运行后进行(run-time linking)
在Linux中,通过调用dlopen()等接口来实现;
上面提到位置无关代码(PIC,全称:Position-Independent Code),这是动态链接中一个重要的概念:PIC 在代码中的跳转和分支指令不使用绝对地址。PIC 在 ELF 可执行映像的数据段.data段中建立一个存放所有全局变量指针(指针数组)的全局偏移量表 GOT。
动态链接的符号引用有如下4种情况:
- 模块内的过程调用、跳转,采用PC相对偏移寻址;
- 模块内的数据访问,如模块内的全局变量和静态变量
- 模块外的过程调用、跳转 【要生成PIC代码来解决】
- 模块外的数据访问,如外部变量的访问【要生成PIC代码来解决】
对于本模块内的静态变量和静态函数,用 GOT 表的首地址作为一个基准,用相对于该基准的偏移量来引用,因为不论程序被加载到何种地址空间,模块内的静态变量和静态函数与 GOT 的距离是固定的,并且在链接阶段就可知晓其距离的大小。这样,PIC 使用 GOT 来引用变量和函数的绝对地址,把位置独立的引用重定向到绝对位置。下图可以更加直观的展现这种通过固定偏移来重定位地址的过程
对于模块外部引用的全局变量和全局函数,用 GOT 表的表项内容作为地址来间接寻址
- 模块外数据的引用-利用GOT来完成重定位
- 模块间的函数调用或跳转 —— 利用GOT完成重定位
- 模块间的函数调用或跳转 —— 利用GOT/PLT完成重定位
PLT表
过程链接表PLT位于.text段,每个动态链接的可执行程序和共享库都有一个PLT,PLT表的每一项存放的是一小段跳转代码,对应于本运行模块要引用的一个全局函数,eg.调用printf函数,对应的在PLT表中有一项printf@plt。程序对某个函数的访问都被调整为对 PLT 入口的访问。PLT 的第 1 个入口 PLT0 是一段访问动态链接器的特殊代码,程序对 PLT 表的第 1 次访问都会跳转到了 PLT0。下图为动态链接的简要示意图
延迟重定位
当需要对一个函数进行调用时,他的汇编代码call首先会掉用PLT表,然后PLT再通过调用GOT与动态库实现重定位连接,这样函数调用动态库时便类似于间接 jmp+地址。
但是如果当一个文件中存在大量的函数时,如果在程序运行前就重定位好所有的函数调用的话虽然会减轻函数调用的时间,但是会大大增加程序的启动时间,是整个程序变得很慢。因此Linux便产生了延迟重定位:也就是当你调用函数的时候函数才开始执行重定位和地址解析工作。
因此便形成了以下代码来实现延迟定位:
GOT表
全局偏移表(Global Offset Table,GOT)能够把位置无关的地址定位到绝对地址,GOT表里存放的是函数的绝对地址。
动态链接过程
程序运行时,首先将解释器程序即动态链接器ld.so
映射到一个合适的地址,然后启动ld.so
。ld.so
先完成自己的初始化工作,再从可执行文件的动态库依赖表中指定的路径名查找所需要的库,将其加载映射到内存。动态库的加载映射过程主要分 3 步:
- 动态链接器调用 __mmap 函数对动态库的所有PT_LOAD 可加载段进行整体映射
- 共享文件映射完毕,动态链接器处理共享库的PT_DYNAMIC 动态段,将各项动态链接信息主要是哈希表、符号表、字符串表、重定位表、PLT 重定位项表等地址填写到 link_map(Linux用一个全局的库映射信息结构 struct link_map链表来管理和控制所有动态库的加载,结构 struct link_map 描述共享目标文件的加载映射信息) 的 l_info 数组结构中。l_info 是 link_map 最重要的字段之一,几乎所有与动态链接管理相关的内容都与 l_info数组有关。动态链接器还要加载处理当前共享库的所有依赖库。
- 设置动态库的第1 个和第 2 个 GOT 表项:
Elf32_Addr *got =(Elf32_Addr *) lmap->l_info[DT_PLTGOT].d_un.d_ptr;
got[0] = got;
got[1]=lmap;
got[2]=&_dl_runtime_resolve;
下图展示了动态延迟重定位执行过程
在调用共享库的函数时,都会先跳转到PLT表中对应的函数跳转项,对应上图的标号1,如果是第一次调用该函数,则会跳转至PLT[0],最后跳入 GOT[2]存储的地址执行符号解析函数。待完成符号解析后,将符号的实际地址存入相应的 GOT 项,这样以后调用函数时可直接跳到实际的函数地址,不必再执行符号解析函数(这个过程其实就是延迟重定位)。如果已经调用过该函数,那么可以通过PLT表项直接跳转到该函数的绝对地址,即直接调用该函数。
参考文章:
程序的链接(五):共享库和动态链接
共享库
细说linux系统下共享库的命名规范和使用方法
linux elf格式 全局指针表got call跳转表plt 简介
全局偏移表(GOT)和过程链接表(PLT)