Shared Library

正确而高效的使用动态库是一个很复杂的话题,这需要开发者编译和链接有相当深入的理解. 本文主要关注"正确使用",这已经足够复杂.

这里主要是描述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_foojmp 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的产物有很多类型,最常用的两种是executableDSO

    • 静态库并不是ld的产物,而是archive的产物,静态库.a文件会在将来被静态链接器ld使用
    • 静态库.a的内包含的.obj可能会被链接器丢弃,从而导致一些链接顺序的问题.相比之下.so.o都没有链接顺序的问题.
  • 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的全面性检查.
  • 动态链接期
    • DT_NEEDED记录的SONAME将直接用于查询,例如,如果有一项名为my_soname,默认会在rpath:$LD_LIBRARY_PATH:run_path:/lib:/usr/lib下查找名为my_soname的文件.
    • 当然my_soname可以是一个符号链接,以指向真实的文件,ldd显示的是最终使用的文件路径
    • rpathrun_path是记录在可执行文件内部的,这两个entry的值相同,可以通过链接期的选项设定.
    • 对于支持RUNPATH的系统,RUNPATH的优先级低于LD_LIBRARY_PATH.
    • RPATH的优先级则总是高于LD_LIBRARY_PATH,也就是说,设置RPATH后, LD_LIBRARY_PATH将无法动态替换.
  • rpath和runpath使用一些特殊的路径,例如$ORIGIN代表了可执行文件的绝对路径.
    • 注意,对rpath/run_path,空字符串和.效果都是CWD,这可能会导致一些问题
  • 对于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的所有依赖,并逐步appendglobal_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两个层面控制访问权限.
  * 尽可能的使用staticvisibility(hidden)
  * class要么整体是hidden的,要么完全default,不要限定单独的成员.
  * Version Script只用于支持Version,不用于控制dynsym的导出,因为编译期的hidden可以保证更优质的代码生成.
* inline函数最好始终是static alwaysinline的
* 模板函数一般在导出到dynsym后都是WEAK bindtype,所以最好也不要在动态库中暴露出模板