正确而高效的使用动态库是一个很复杂的话题,这需要开发者编译和链接有相当深入的理解. 本文主要关注"正确使用",这已经足够复杂.
这里主要是描述linux/gnu体系下编译器/链接器的行为(可能不适合其他系统,甚至不适合老版本的linux工具链), 这些行为的设计一般都有历史因素及兼容性的考量,所以某些部分可能看起来不够优美.
编译,链接,静态库与动态库
预备知识
编译:
-
将单个编译单元(translation unit),如
foo.cc
转变为对应的obj
的过程- 每个编译单元的编译都是相对独立的,这也是并行编译的基础.
-
每个
obj
都有一个静态符号表.symtab
,用于标明符号和(地址,符号名)的映射关系,这个仅供静态链接器使用. -
强引用/弱引用:
- 强引用: 必须在静态链接阶段resolve的引用,找不到定义将报错"Undefined reference"
- 弱引用: 在静态链接阶段不解决的外部引用,必须放在运行期进行resolve.
- 可以通过
__attribute__((weakref)) void foo()
强制使用弱引用. - 使用弱引用可以帮我们在运行期判断函数是否存在,从而辅助我们选择函数分支,例如,可以通过
if(foo)
判断是否有foo
-
强定义/弱定义
- 强定义: 有初始化语句的全局定义称为强定义(由于函数定义都是初始化了的,所以都是强定义)
- 弱定义: 没有初始化语句的全局定义称为弱定义
- 可以使用编译期修饰符将对象强制为
__attribute__((weak)) int foo=2
- 静态链接器优先使用强定义,动态链接器忽略所有弱定义.
Relocation
-
由于编译过程是相对独立的,所以对于各个单元,会有很多全局量(全局变量和函数)的地址是编译期未知的,引用这些符号时,相应的地址会先用全0的placehoder占位.然后在链接期修正.
- 每个待修正位置都会产生一条relocation记录,链接器只需要顺序处理所有reloation记录即可.
- 理论上说,relocation可以被静态链接器
ld
处理,也可以被动态链接器ld.so
处理; 但是实践中,有一类relocation只可以在静态链接期relocation. 主要是因为动态链接器一般被禁止修改.text
代码段 - resolve主要是指为
UND
的符号找到定义,relocation
则是根据当前的context对程序进行patch. UND
符号被reslove后,都将直接用于relocation.- 当前单元给出的定义可能不会被用于relocation(会在运行期被interpose).
-
relocation的方式总是和编译器生成的指令相关.
- 例如,编译器生成了
jmp address_of_foo
或jmp PC + offset_of_foo
,那么就需要对代码段进行relocation.这类relocation一般无法在运行期执行. - 例如,编译器生成了,
jmp GOT[foo_id]
或jmp PC + GOT[foo_offset_id]
,这里GOT[]
是一个数组,foo_id
(foo_offset_id
)是常数.这种场合需要两步relocation,首先是确定GOT表自身的位置,然后是确定GOT
表的内容(即把foo的地址填入GOT表);第一步的relocation只能由静态链接器完成,这通常也会涉及到修改代码段,第二步则既可以在静态链接期完成,也可以在动态链接期完成.
- 例如,编译器生成了
-
名词定义:
- 静态relocation: 特指在静态链接期执行的relocation
- 动态relocation: 特指在动态链接期执行的relocation
静态链接
定义:将若干obj文件生成一个ELF文件,并执行静态relocation的过程
-
一般是使用
ld
程序,它被称为Link editor
,常常用linker
特指 -
"静态链接期"被划分到"编译期",静态链接器
ld
仍然可以访问编译期的绝大多数信息,如果有编译系统/编译器的配合,那
么静态链接器几乎可以访问到所有信息. -
静态链接还可以有其他
DSO
(dynamic shared object,即各种.so
文件)输入作为被链接项目,这些DSO
主要影响了链接器的决策,并不直接为链接产物提供代码/数据.这里记做o-DSOs
(other DSOs) -
ld
的产物有很多类型,最常用的两种是executable
和DSO
- 静态库并不是ld的产物,而是archive的产物,静态库
.a
文件会在将来被静态链接器ld
使用 - 静态库
.a
的内包含的.obj
可能会被链接器丢弃,从而导致一些链接顺序的问题.相比之下.so
和.o
都没有链接顺序的问题.
- 静态库并不是ld的产物,而是archive的产物,静态库
-
ld
的产物会包含一个dynsym
动态符号表,供动态链接器ld.so
使用- 如果链接产物是
DSO
,那么在不做任何控制的情况下,DSO内定义/引用的所有符号都会导出到dynsym - 如果链接产物是
executable
,那么在不做任何控制的情况下,executable内定义/引用的符号,只有出现在o-DSOs
的dynsym表中时,才会被导出到dynsym.
- 如果链接产物是
-
dynsym
的核心意义: 符号可能是由o-DSOs
定义的,或者可能被o-DSOs
引用.- 这意味着,符号的真实地址只能在运行期由动态链接器获得. 对静态链接器而言,这些符号的真实地址可能位于任何静态链接期未知的地址.
- 导出到
dynsym
的符号relocation只能在运行期执行,如果编译器生成的代码只能用静态relocation处理,静态链接器会报错.
-
重要:
dynsym
将影响ld和编译器的优化开关.- 如果符号
foo
没有出现在dynsym
中,则它们一定不会被位于o-DSOs
的代码使用,编译器/链接器可以完全掌握foo
的相关信息. - 反之,由于符号
foo
可能会被位于o-DSOs
的代码使用,这些代码的信息是编译器/链接器不能感知的,编译器/链接器就必须按最差的场景做假设. - 例如,如果编译器已知某个全局量
const int a = 10;
不会被导出到dymsym中,则编译器可以大胆的不为其分配存储空间,用10
替代所有a
- 如果符号
-
默认情况下,编译器总是假定所有符号都不会导出到
dynsym
,以生成更优质的代码- 这极有可能会导致生成的部分代码必须在静态链接期被relocation.
- 编译选项中,只有
fpic
/-fPIC
用于通知编译器所有符号都可能被导出到dynsym
,-shared
参数并没有任何作用.
-
如果生成的产物是
executable
,那么静态链接器会相当严格.DSO
则松弛的多.executable
:所有UND的符号都必须被reslove- 如果符号不被导出到dynsym,那么reslove的同时,就会直接执行静态relocation
- 如果符号被导出dynsym,那么链接器只会记录一些简单的信息(如静态链接期resolve符号时使用的VERSION),而不执行静态relocation.
DSO
: 默认不会有任何检查. 可以通过-Wl,-z,defs
强制开启检查(推荐使用)
-
在生成executable时一定要注意:exectuble内定义的符号可能不会被导出到dynsym中. 这将导致行为不符合预期.
- 可以使用
-fPIC,-Wl,-export-dynamic
,让可执行文件中的所有符号都导出到dynsym中.注意,-fPIC
是必须的,因为编译器优化可能生成不符合动态链接规则的代码.
- 可以使用
动态链接
定义:将若干ELF文件装载到内存中,并执行relocation的过程
- 动态链接的工作相对来说简单清晰的多,从启动程序起看:
- 在exec(some_exec)之后,kernel会先将整个虚拟内存空间用some_exec替代.
- kernel将
ld.so
装入内存,并把控制权转移到ld.so
ld.so
是一个没有任何依赖的pie
(position indepenedent executable)可执行程序,只不过名字里带了个.so
以便和静态链接器相区别,可以被装载到任何位置执行ld.so
- 递归的分析出
some_exec
的所有依赖,并装入这些依赖项到内存. - 所有依赖装入完毕后,开始执行resolve及动态relocation,修复所有DSO内对符号的引用.
- relocation完成后,按照依赖之间的拓扑逆序,逐个执行各个DSO的
__init
函数. - 控制权自然而然的转移到
some_exec
的__init
,并开始后续的执行
- "动态链接期"一般被划分到"运行期",动态链接器已经无法获得"编译期"的信息了.
细节问题
动态链接器如何确定可执行文件的所有依赖?
- 首先,每个DSO文件都有自己的SONAME,可以通过链接器选项在生成DSO时为其指定
-Wl,-soname,xxxxx
- SONAME直接保存在ELF文件内的一条记录中.
- 在静态链接生成target时,所有输入DSO文件,以及通过
-l
链入动态库的SONAME,都会被保存到输出的target文件中- 可以用
objdump -x target | grep NEEDED
观察 - 这些NEEDED的项目称为直接依赖. NEEDED项目所引入的依赖称为间接依赖.
--as-needed
选项可以让没有被使用的so
不出现在NEEDED中.
- 可以用
- 动态链接器可以根据
NEEDED
项目定位需要载入的文件,递归的扫描完所有NEEDED项目后,就能获得可执行文件的所有依赖.ldd
的工作原理就是这样,递归的查找所有NEEDED
entry
文件定位的规则
- 静态链接期
- DSO可以按src的形式作为输入,即
/path/to/lib.so
可以直接出现在g++的输入中. - DSO可以按
-lxyz
的规则输入,静态链接器默认会在$LIBRARY_PATH:/lib:/urs/lib
下查找名为libxyz.so
(也可能为libxyz.a)的文件. - 额外的搜索路径通过
-L
参数指定 - 如果是生成可执行文件,间接依赖要么需要通过显式的变为直接依赖,要么需要通过
-rpath-link
指定间接依赖的目录,以满足静态链接期UND符号resolve的全面性检查.
- DSO可以按src的形式作为输入,即
- 动态链接期
DT_NEEDED
记录的SONAME将直接用于查询,例如,如果有一项名为my_soname
,默认会在rpath:$LD_LIBRARY_PATH:run_path:/lib:/usr/lib
下查找名为my_soname
的文件.- 当然
my_soname
可以是一个符号链接,以指向真实的文件,ldd显示的是最终使用的文件路径 rpath
和run_path
是记录在可执行文件内部的,这两个entry的值相同,可以通过链接期的选项设定.- 对于支持RUNPATH的系统,RUNPATH的优先级低于LD_LIBRARY_PATH.
- RPATH的优先级则总是高于LD_LIBRARY_PATH,也就是说,设置RPATH后, LD_LIBRARY_PATH将无法动态替换.
- rpath和runpath使用一些特殊的路径,例如
$ORIGIN
代表了可执行文件的绝对路径.- 注意,对rpath/run_path,空字符串和
.
效果都是CWD
,这可能会导致一些问题
- 注意,对rpath/run_path,空字符串和
- 对于segtid/setuid的程序,
ld.so
不会使用LD_LIBRARY_PATH
及$ORIGIN
,只会使用绝对路径,主要是为了安全问题考虑.- 如果一个setgid的程序按root启动,又可能加载用户定义的库,就很危险了
- 换言之,如果你的程序需要通过root权限调用,那么一定要注意安全问题.
- ldd的分析会忽略setuid/setgid,对于这类程序,会打印出错误的结果
动态relocation细节
主要关注Symbol lookup.
- 首先是lookup scope的确定
- 存在一个
global_scope
链表,它的初始值是[exec_file]
ld.so
按广度优先的策略分析exec_file
的所有依赖,并逐步append
到global_scope
的链表中.- 可以通过
LD_DEBUG=files
选项,根据needed
来判断加入表的顺序 - 加入表的顺序和DSO被载入内存的顺序是一致的,载入完成后,
global_scope
自然就更新完成了 $LD_PRELOAD
相当于是为可执行文件插入了一项DT_NEEDED
,并且保证是第一个被载入的DSO.
- 存在一个
- 所有文件载入完成,开始动态relocation:顺着
global_scope
顺序查找每个DSO文件的dynsym
,第一个找到且匹配的定义就会被使用. - 动态relocatoin完成后,可执行文件及所有的DSO就都被修复了,此时可以开始执行初始化.
- 形象的说: 只有当所有依赖都被初始化之后,自身才能被初始化.实践上,逆着
global_scope
进行初始化即可.
- 形象的说: 只有当所有依赖都被初始化之后,自身才能被初始化.实践上,逆着
- 注意: 同一个DSO文件只会被载入/relocation一次.
dlopen
- dlopen(x)做的事和程序启动时类似
- 会分析
x
的dependency,形成与x绑定的一个local_scope
- 如果有新的DSO被载入,那么需要对新载入的DSO进行relocation,此时会按
global scope
->local_scope
的顺序尝试对新DSO进行relocation - 所有新载入的DSO
__init
会被执行.
- 会分析
dlsym
很简单,就是在handle.local_scope
中顺序查找定义,第一个查找到的匹配定义将被返回.global_scope
总是会被跳过- 注意: dlsym只能搜索定义的符号,静态链接期为
UND
的符号无法被dlsym查找(尽管这些符号已经被reslove)
- dlopen各个选项的影响:
- RTLD_LAZY,RTLD_NOW: 没有任何功能性影响
- BIND_NOW/RTLD_NOW可以用于代码调试,例如,若某个程序会造成coredump,如果在启动时设置了BIND_NOW,就能保证所有plt表项内都填充了正确的值,可以更容易的进行代码跳转.
- LAZY bind仅影响函数,变量都是在DSO加载后自动relocation的
- RTLD_LOCAL(默认): local_scope 不会append 到global_scope后面
- RTLD_GLOBAL(慎重的使用):local_scope会append到global_scope后面,后续的其他dlopen可能会受到影响.
- RTLD_DEEPBIND(绝对不要使用): 在对新载入的DSO进行relocation时,会先尝试在local_scope查找符号,而不是先尝试global_scope
- dlsym的特殊Handle:
- RTLD_DEFAULT:直接在global_scope内查找定义
- RTLD_NEXT:在global_scope内查找第二个定义.
- 没有在global_scope查找第三个定义的方法.
- 没有在local_scope查找第二个定义的方法
fPIC
- fPIC的核心是生成位置无关代码,这会非常微弱的降低性能
- fPIC的主要原理是引入一层间接性,以避免对代码段进行动态relocation
- fPIC生成的代码既可以被静态链接器relocation,也可以被动态链接器relocation. 因此,并不影响生成的obj被链入可执行文件.
- fPIC的实现方式因平台而异,但目的都是避免对代码段进行动态relocation.
- windows下就必须对代码段进行reloation,不能使用fPIC的策略
- fPIC对性能的影响是间接的: 该选项会导致编译器假定所有符号都会被导出到
dynsym
中,进而导致一些编译优化会被关闭 - fpic和fPIC没有本质的区别
- 专业的说,如果链接器/编译器没有报错,总是使用fpic,它性能更好.
- 或者偷懒的说,总是使用fPIC,因为它总是可以工作.
- 小技巧:由于
-fPIC
编译的文件都是不需要重定位的,所以可以通过readelf -d foo.so | grep TEXTREL
,看是否存在重定位表,来判断其类型
控制DSO的dynsym符号导出
- 控制dynsym的符号导出有很多优势:
- 限制ABI,避免内部ABI暴露出去
- 改善代码生成/链接的质量
- 改善动态链接速度,暴露出的ABI越少,动态relocation执行的就越快.
- 使用语言自带的
static
:static
的量本身就不会出现在symtable中
- 使用gcc 的attribute visibility(hidden)
- 它用于通知编译器,该符号一定不会被导出到dynsym中.
- 由于符号仍然会出现在symtab中,这些符号可以跨文件被使用.相关的引用总是会被静态relocation处理.
- bind type 和 dynsym 的关系
- 对于
symtab
而言,bind type 有 LOCAL GLOBAL WEAK - 对于
.dynsym
而言,bind type 有 GLOABL WEAK UNIQUE, 没有LOCAL.. 因为LOCAL binding的就不会被导出到dynsym中 - 这里的WEAK意味着可以被其他的覆盖,只有当所有symbol都是WEAK时,才在WEAK中选择.
UNIQUE
的bind会使用一个全局的UNIQUE
池(类似于global_scope),被绑定过的对象会加入UNIQUE
池中,供其他地方绑定.也就是说,UNIQUE型的对象,总是会保证整个进程都使用同一个实例.
- 对于
- 注意: static变量的导出规则是有区别的:
- 类的static 型成员会导出到dynsym中,使用GLOBAL bind type
- 函数的static变量也不会导出到dynsym中
- 模板函数的static变量会导出到dynsym中,且使用UNIQUE 的bindtype
Versioning (Version script)
- 源代码中:
__asm__(".symver original_foo,foo@VERNAME");
语法用于为original_foo
创建一个带有版本号的别名- 可以有一个唯一的
@@
可以替代@
,例如foo@@VERNAME
,它是一个特殊的名字,它用于通知链接器,默认使用该符号进行链接. - 当某个
UND
符号在静态链接期被resovled到带有VERSION的定义时,会把这个信息记录到dynsym中,动态链接时优先按VERSION的定义进行relocation. - 只有当引用处和定义处都有VERSION信息时,VERSION信息才会用于符号的resovle
- 可以有一个唯一的
- Version script 语法简介
VERS_1.1 { global: foo1; local: old*; original*; new*; *; };
VERS_1.2 {
foo1;
foo2;
} VERS_1.1;
* VER_1.1
,VERS_1.2
:版本代号
* global
,local
: 用于限定符号是否会导出到dynsym
* 对VER_1.1
而言,foo1@VERS_1.1
会导出到dynsym
,满足old*@VERS_1.1
表达式的符号不会被导出到dynsym
* 单独的*
是一个特殊的通配符,它表示**任何**没有被version-script直接使用到的函数,因此*
只应该出现在一个VERSION
的global/local内
* VERS_1.2
大括号之后的VERS_1.1
表示前向兼容性节点,如果两个VERSION中有同名的函数,那么这些函数是前向兼容的.
* 也就是说,如果动态链接时,没有找到foo1@VERS_1.1
的定义,却找到了foo1@VERS_1.2
,那么是OK的,因为VERS_1.2
已经表明了自己对foo1
的前向兼容. 反之则不一定.
* version script可以包含一个匿名VERSION,此时不允许有其他VERSION节点,这种场景下,version script仅用于限制符号是否导出到dynsym.
## Interpose
* C环境下对函数调用的拦截(打桩,插装)
* 编译期拦截:主要是用#define old myold
这样的宏实现
* 链接期拦截:主要是利用GCC的--warp,foo
链接选项.
* 所有对foo
的调用都被链接到__warp_foo
* 那么如何使用原始版本呢? 使用__real_foo
的调用将链接到原始版本
* 运行期拦截:通过LD_PRELOAD来进行
## Good Practice
* 不要使用__init
/_fini
做DSO的初始化,因为这将覆盖libc的行为,可能导致全局对象的初始化顺序失控.(例如,部分用attribute constructor 添加的函数将不会被执行)
* 不使用-Bsymbolic
/RTLD_GLOBAL
等改变 global symbol lookup顺序的工具. 这可能会引入巨大的Debug难题.
* 动态库项目的合理组织
* 区分公开头文件和内部头文件;所有不开放的symbol都设为hidden,不在ABI层面暴露出去
* impl技巧总应该和visibility搭配使用,同时在API和ABI两个层面控制访问权限.
* 尽可能的使用static
和visibility(hidden)
* class要么整体是hidden的,要么完全default,不要限定单独的成员.
* Version Script只用于支持Version,不用于控制dynsym的导出,因为编译期的hidden可以保证更优质的代码生成.
* inline
函数最好始终是static alwaysinline
的
* 模板函数一般在导出到dynsym后都是WEAK bindtype,所以最好也不要在动态库中暴露出模板