操作系统之动态链接库
静态库
静态库也称为归档文件, 它是UNIX系统提供的第一种库。静态库带来以下好处:
- 可以将一组经常被用到的目标文件组织进单个库文件, 这样可以使用它来构建多个可执行程序的时候无需重新编译原来的源代码文件。
- 链接命令变得更简单了,在链接命令行中只需指定静态库名称即可, 而无需一个个列出目标文件了。链接器知道如何搜索静态库并将可执行程序需要的对象抽取出来。
创建和维护静态库
静态库的名称形式为libname.a
。
使用ar
命令能够创建和维护静态库, 其通用形式如下所示。
ar options archive object-file...
options可选项:
- r(替换): 将一个目标文件插入到归档文件中并取代同名的目标文件。如:
ar r libdemo.a mod1.o mod2.o mod3.o
- t(目标表):显示归档中的目标表。即显示归档文件中所有目标文件。添加
v(verbose)
修饰后可显示权限、大小、修改时间等属性。 - d(删除):从归档文件中删除一个模块, 如下面例子所示:
ar d libdemo.a mod3.o
使用静态库
将程序与静态库链接起来存在两种方式。
- 在链接命令中指定静态库名称
cc -g -c prog.c
cc -g -o prog prog.o libdemo.a - 将静态库放在链接器能搜索到的一个目录中(如/usr/lib), 然后使用
-l
选项指定库名称(即库文件名取出来lib前缀和.a后缀)。如果不是放在链接库默认的搜索目录中, 也可使用-L
指定链接器搜索目录。
虽然静态库可以包含很多目标模块, 但链接器只会包含那些程序所需的模块。
共享库
上述的静态库存在哪些缺陷呢?链接静态库的每个可执行文件都存在目标模块的副本, 存在冗余, 这种冗余存在如下缺点:
- 存储同一个目标模块的多个副本会浪费磁盘空间
- 由于每个程序都存在相同的目标模块副本, 在其同时运行时,将耗费系统整体虚拟内存
- 如果对静态库中某个目标模块进行更新,所有依赖这个目标模块的程序则需重新链接以合并这个变更。
共享库就是设计用来解决这些缺点的。 共享库的关键思想是目标模块的单个副本由所依赖的程序所共有。 目标模块不会被复制到链接过的可执行文件中。当共享库第一次被加载到内存后, 将会被所有依赖它的进程共有。
虽然共享库的代码是由多个进程共享的,但其中的变量却是每个使用库的进程所独有,即各自有副本。
共享库具备下列优势:
- 整个程序大小变小了。 第一个加载共享库的程序启动时会花费更多时间, 用于查找共享库并加载到内存中。
- 修改目标模块时, 无需重新链接程序就能看到变更。
相对应的缺点如下:
- 共享库在编译时必须使用为止独立的代码, 这在大多数架构上都会带来性能开销, 因为它需要使用额外的寄存器。
- 在运行时必须要执行符号重定位。在符号重定位期间, 需要将共享库中的每个符号(变量或函数)的引用修改成符号在虚拟内存中的实际运行时位置。所以会带来一些性能消耗。
创建共享库
gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
gcc -g -shared -o libdemo.so mod1.o mod2.o mod3.o
以上用编译和构建两个步骤创建动态库。 根据惯例, 共享库的前缀为lib, 后缀为.so(shared object)
位置独立的代码
gcc -fPIC
选项指定编译器应该生成位置独立的代码, 这会改变编译器生成执行特定操作的代码的方式, 包括访问全局变量、静态和外部变量,访问字符串常量,以及获取函数的地址。这些变更使得代码可以在运行时被放置在任意一个虚拟地址处。这对共享库是必需的, 因为在链接时无法知道共享库代码位于内存的何处。
为了确定目标文件在编译时是否使用了-fPIC
选项, 可使用如下命令来检查符号表中是否存在GLOBAL_OFFSET_TABLE
nm mod1.o | grep _GLOBAL_OFFSET_TABLE_
readelf -s mod1.o | grep _GLOBAL_OFFSET_TABLE_
而检查共享库则使用如下命令:
objdump --all-headers libdemo.so | grep TEXTREL
readelf -d libdemo.so | grep TEXTREL
如果以上命令存在任何输出, 则未指定-fPIC
编译选项
控制函数的可见性
设计良好的共享库应该只公开那些构成其声明的应用程序二进制接口的符号(函数和变量)。理由如下:
- 避免依赖库的调用者, 依赖了非标准接口
- 共享库导出的符号可能会覆盖其他共享库导出的符号
- 导出非必需的符号会增加在运行时虚假在动态符号表的大小
下列技术可控制符号的导出:
- 使用
static
关键字,使得符号私有于一个源代码模块, 从而使得它无法被其他目标文件绑定 - 利用GCC的特性声明:
__attribute__((visibility("hidden")))
- 版本脚本, 利用版本脚本可精细控制符号的可见性。其编译选项为
-Wl,--version-script,scriptfile.map
,其中的scriptfile.map即为版本脚本文件。
如一个共享库默认导出符号: vis_common()、vis_f1()、vis_f2(), 但要求是不需要导出vis_common(), 其脚本如下:
其中VER_1是一种版本标签。global标记出了以分号分隔的对库之外的程序可见的符号列表,local标记出了以分号分隔的对库之外的程序隐藏的符号列表。 以上用*表示除了global中列出的符号,其他都隐藏不可见。VER_1{ global: vis_f1; vis_f2; local: *; }
如果结合
可实现符号版本化,可易于接口升级。__asm__(".symver xyz_old, xyz@VER_1") __asm__(".symver xyz_new, xyz@VER_2")
- 动态加载库中提到的引用主程序函数
static 关键词将一个符号的可见性限制在单个源代码文件中, 而hidden特性使得符号对构成共享库的所有源代码文件可见,而对库之外的文件不可见。
初始化函数与终止函数
可以定义一个或多个在共享库被加载和卸载时自动执行的函数, 这样在使用共享库时就能完成一些初始化和终止工作了。无论是自动被加载还是使用dlopen()接口显式加载,初始化函数和终止函数都会被执行。
使用gcc的constructor
和destructor
特性可定义初始化和终止函数。如:
void __attribute__((constructor)) some_name_load(void){
/*Initialization code*/
}
void __attribute__((destructor)) some_name_unload(void){
/*Finalization code*/
}
在共享库被加载时将会执行constructor修饰的函数, 相反,在卸载时会调用destructor修饰的函数。
当以上特性应用于可执行执行时, 具有相同的特性, 即在主程序初始化和销毁时被调用。
共享库的soname
soname即共享库的别名, 其存储在ELF文件的DT_SONAME标签中,如果共享库拥有一个soname,那么在静态链接阶段会将soname嵌入到可执行文件中,动态链接阶段也会使用soname来搜索库, 而不会使用真实名称。
soname的目的是为了提供一层间接, 使可执行程序能够在运行时使用与链接时的库不同的共享库。
使用soname的第一步是在创建共享库时指定soname:
gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
gcc -g -shared -Wl,-soname,libtest.so -o libdemo.so mod1.o mod2.o mod3.o
上述例子通过编译选项-Wl,-soname
指示了将libdemo.so
取别名为libtest.so
。
通过如下命令可查看共享库的soname:
objdump -p libdemo.so | grep SONAME
readelf -d libdemo.so | grep SONAME
注意: 当给共享库取别名后,应建立别名共享库的软链接, 指向真实生成的共享库。如以上例子中应建立软链接libdemo.so,指向libtest.so
共享库版本和命名规则
明确版本的概念:
- 次要版本
版本不同但相互兼容的版本称为共享库的次要版本 - 主要版本
与上一个版本完全不兼容的版本即为主要版本
关于真实名称、soname以及链接器名称
- 真实名称的格式为:
libname.so.major-id.minor-id
- 共享库的soname包括相应的真实名称中的主要版本标识符, 但不包含次要版本标识符。因此其形式为:
libname.so.major-id
- 所谓链接器名称,即将可执行文件与共享库链接起来时会用到的名称。该名称不包含任何版本信息, 形如:
libname.so
名称 | 格式 | 描述 |
---|---|---|
真实名称 | libname.so.maj.min | 即真实的库文件 |
soname | libname.so.maj | 为一个软链接,库的每个主要版本都存在一个soname: 在链接时嵌入到可执行文件中,在运行时找出其指向的真实名称的库文件 |
链接器名称 | libname.so | 为一个软链接,指向soname |
比如:
gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
gcc -g -shared -Wl,-soname,libdemo.so.1 -o libdemo.so.1.0.1 mod1.o mod2.o mod3.o
以上创建了一个真实名称为libdemo.so.1.0.1的共享库, 其soname为libdemo.so.1
接着为soname和链接器名称创建符号链接:
ln -s libdemo.so.1.0.1 libdemo.so.1
ln -s libdemo.so.1 libdemo.so
然后就可以通过链接器名称来构建可执行程序了:
gcc -g -Wall -o prog prog.c -L. -ldemo
使用共享库
由于可执行程序不再包含目标文件副本,所以需要某种机制找出所需运行时共享库。这是通过在链接阶段将共享库的名称嵌入可执行文件中来完成的。
在ELF中, 库依赖性是记录在可执行文件的DT_NEEDED标签中
如下命令可查看:
objdump --all-headers libdemo.so | grep NEEDED
readelf -d libdemo.so | grep NEEDED
如下命令可将共享库名称嵌入到可执行文件中:
gcc -g -Wall -o prog prog.c libdemo.so
此时执行程序
./prog
会收到错误./prog: error while loading shared libraries: libdemo.so: cannot open shared object file: no such file or directory
解决这个问题需要做第二件事: 动态链接,即在运行时解析内嵌的库名。这个任务由动态链接器(运行时链接器)来完成。动态链接器本身也是一个共享库, 其名称为/lib/ld-linux.so.2
, 所有使用共享库的ELF可执行文件都会用到这个共享库。
动态链接器会检查程序所需共享库清单并使用预先定义好的规则在文件系统上找出相关库文件。如默认会在/lib
和/usr/lib
等标准目录中查找所需动态库。
通知动态链接器一个共享库位于非标准目录中的一种方法是将该目录添加到LD_LIBRARY_PATH
环境变量中以分号间隔。
以上错误是因为libdemo.so文件在当前目录中,通过如下命令即可避免报错:
LD_LIBRARY_PATH=. ./prog
动态链接与静态链接
- 静态链接发生在编译阶段, 即编译时,链接器ld将一个或多个编译过的目标文件组合成一个可执行文件。
- 而动态链接发生在运行时,在运行时, 通过运行时链接器(/lib/ld-linux.so.2)找到所需库文件。
只有动态库才有动态链接, 而静态库不存在动态链接。
动态加载库
动态链接器会加载程序的动态以来列表中的所有共享库, 但有时候可延迟加载库。 动态链接器通过一组API来实现。 这组API通常被称为dlopen API。
dlopen API能在程序运行时打开一个共享库,根据名称在库中搜索一个函数,然后调用这个函数。这个过程通常称为动态加载。其核心api如下:
- dlopen()打开一个共享库, 返回其句柄。同一个库被打开多次,但加载进内存只会执行一次, 其他都是引用计数+1。当加载的库依赖其他库, 其依赖树会同时被加载。
- dlsym()搜索一个符号, 返回其地址。dlsym可接受一个伪句柄, 如RTLD_DEFAULT或RTLD_NEXT。
- dlclose()关闭打开的句柄。该调用会将引用计数-1,当引用计数为0时,dlclose()才会从内存中删除这个库。当进程终止时会隐式地对所有库执行dlclose()。
- dlerror()返回一个错误消息字符串。
- dladdr()获取与加载的符号相关的信息, 非标准接口需要使用宏_GNU_SOURCE
若想调用以上API, 必须依赖libdl库, 即在编译时需添加选项
-ldl
假设使用dlopen()动态加载了一个共享库, 然后dlsym()获取到符号x(), 而在x()中调用了函数y(),通常情况下会在当前共享库或依赖的共享库中查找该符号, 如何让其调用主程序中的函数y()?
要达到上述要求, 需要在编译主程序时, 利用编译选项-Wl,--export-dynamic
或-export-dynamic
,以允许动态加载的库访问主程序中的全局符号。
预加载共享库:LD_PRELOAD
有时候可以选择覆盖一些正常情况下被动态链接器按照多个同名符号优先级问题找出的函数, 可定义环境变量LD_PRELOAD
,该环境变量指示了多个由空格或冒号间隔的共享库。这些库会被优先加载。因为被优先加载, 在找某个函数名时,会优先找预加载的共享库, 因此会覆盖后续的同名函数。
利用/etc/ld.so.preload
文件也可完成同样的效果。若两种方式都指定, LD_PRELOAD优先级更高。
许多监控软件或是分析软件, 利用了此特性用于分析程序。可监控C接口调用。
监控动态链接器:LD_DEBUG
有时候需要监控动态链接器的操作来弄清楚它在搜索哪些库、哪些符号, 可通过LD_DEBUG
环境变量来完成。如:
LD_DEBUG=libs date
可以看到搜索依赖共享库的过程
共享库常用工具
ldd
ldd命令显示了一个程序运行所需的共享库。形如:
ldd prog
objdump
命令形如:
objdump -x <filename> | grep NEEDED
readelf
命令形如:
readelf -d <filename> | grep NEEDED
nm
nm可以查看动态库是否定义了某个函数接口:
nm -A libdemo.so 2> /dev/null | grep somefunction
ldconfig
ldconfig 解决了共享库两个潜在问题:
- 共享库位于各种目录中, 动态链接器搜索加载某个库将会比较慢
- 当安装了新版本的库或删除了旧版本的库, soname符号链接就不是最新的
ldconfig 主要用于解决以上两个问题, 针对第一个问题, ldconfig通过搜索标准库目录(/usr/lib、/lib、/usr/local/lib及x64对应目录)和/etc/ld.so.conf
中列出的目录创建缓存文件/etc/ld.so.cache
, 通过执行ldconfig -p
可查看缓存的内容。动态链接器在运行时解析库名称会使用这个缓存文件。
针对第二个问题,ldconfig会检查每个库的主要版本的最新次要版本以找出嵌入的soname, 然后再同一目录为每个soname创建(或更新)对应的符号链接。
为了避免生成缓存, 可附带-n
选项,而为了阻止soname符号链接创建,可指定-X
每当安装了一个新的库,更新或删除了一个既有库, 以及/etc/ld.so.conf中的目录列表被修改之后, 都应该运行ldconfig。
其他方式定位共享库
- 在静态编译阶段可以在可执行文件中插入一个在运行时搜索共享库的目录列表。需利用编译选项
-Wl,-rpath,path1;path2;path3
, 另一种替代方案即将多个目录放置到环境变量LD_RUN_PATH
环境变量中。
如何查看elf文件中的rpath
, 通常使用命令objdump -p prog | grep PATH
或readelf -d prog | grep PATH
- 在rpath中使用
$ORIGIN
, 如gcc -Wl,-rpath, '$ORIGIN'/lib
。这样可执行文件会在其lib目录中找依赖的共享库, 而不论可执行文件在哪个目录
共享库搜索
共享库搜索优先级如下:
- 在可执行文件中读取DT_RPATH条目, 搜索其指定的目录而排除DT_RUNPATH条目指定的目录(因DT_RUNPATH优先级低于LD_LIBRARY_PATH)。
- 搜索LD_LIBRARY_PATH指定的目录
- 搜索可执行文件中DT_RUNPATH条目包含的目录
- 搜索
/etc/ld.so.cache
指定的目录 - 搜索标准目录,如/lib、/lib64、/usr/lib等
多个同名符号优先级问题
- 如果主程序与动态库中都存在同名符号func,那么会优先调用主程序的func
- 而主程序依赖的多个共享库中存在同名接口, 则以以上搜索顺序找到的第一个共享库中的符号为准