大部分内容来自《高级C/C++编译技术》。

概念明确

  • gcc - GNU Compiler Collection,指的是GNU编译套件,除了提供了编译器之外,也同时提供了一系列相关工具和库,这些工具和库中有的是编译器工作所必须的,有的是提供给开发者对编译好的程序进行分析使用的。
    • https://gcc.gnu.org/onlinedocs/
    • 其他编译时常见的工具,一般不直接在命令行中使用这些工具,而是写编译配置文件,如Autotools,或者CMake,这些配置工具中,一般要遵循GNU Autotools的惯例,使用环境变量中指定的编译工具,这样能够保持相当程度的灵活性,允许用户替换编译器、链接器,方便做交叉编译:
      • cpp: C Pre-Processor,C预处理器
      • cc: C编译器,默认是gcc
      • cxx: C++编译器,默认是g++
      • as: 汇编器,默认是gas
      • ld: 链接器
      • ar: 静态库打包工具
      • ranlib: 为静态库生成符号表的工具
      • nm: 查看目标文件中的符号的工具
  • 静态库
    • 静态库是目标文件的集合,静态库不可直接运行,需要经过链接,产生可执行的二进制文件后才可以运行;
    • ar 是gnu编译套件中用于打包静态库的工具;
  • 动态库
    • 动态库是经过编译和链接的,大体符合二进制目标文件规范的二进制文件,除了部分符号地址由于必须要在运行时才能确定外,其他方面都和一个标准的可执行二进制文件一样,动态库可以参与链接,这样源码中可以直接使用动态库导出的符号,也可以不参与链接,这样必须在运行时查询动态库的符号并使用;
    • 要链接产生动态库时,需要为连接器指定—shared选项,此外编译时也需要为编译器指定-fPIC选项;
  • PIC
    • PIC是指Position Independent Code,静态库和动态库都可以使用PIC标记编译,PIC是编译时的选项,而不是链接时的选项;
  • 链接器
    • 链接器是链接过程中负责解析符号引用的,负责将符号的正确地址嵌入汇编指令中,链接器产生的二进制文件是符合平台二进制文件规范的可执行文件或动态库文件;
  • 装载器
    • 装载器是在程序运行时负责加载动态库的,动态库中的所有汇编指令都已经由链接器处理过一遍,所有地址都已经由链接器进行过嵌入了;
  • 引用解析
    • 程序编译时需要解析所有符号,使用静态库编译时,需要确保所有符号都有唯一定义,且不存在未解析的引用;使用动态库时,规则有所放松,参考下面关于“动态库的装载”的讨论。

动态库的装载

地址转换

动态库是在程序运行时由装载器加载到程序进程的内存空间中的,无法事先确定动态库的符号地址,将动态库加载进内存空间的操作称作地址转换,绝对地址会因地址转换而失效。
不过,通过在汇编指令中使用相对地址,可以很大程度上缓解这个问题,使用相对地址的指令不需要进行地址转换。只有那些需要在动态库中对外可见的符号无法使用相对地址。由于这些对外可见的符号和引用这些符号的指令之间的相对位置是不能事先确定的,在链接器对动态库进行链接时,会将动态库符号的绝对地址嵌入应用程序汇编指令中(但由于这时候是无法确定绝对地址的,一般会由链接器确定一个占位符作为地址)。
显然,装载器最终在运行时会将动态库装载到什么内存地址是无法在链接时确定的,因此上面嵌入的绝对地址是无法正常使用的。

解决思路

自然而然的方案是让装载器完成动态库符号的引用解析工作,装载器需要和链接器协作:链接器在链接时标记所有无法通过相对地址正确引用的符号,生成为装载器提供的提示信息并将其嵌入到二进制文件中(重定位节);装载器完成将动态库装载到内存空间中,在地址转换后,再检查所有提示信息,对符号引用地址进行修复。
二进制格式规范为支持动态库,而提供了详细的链接器和装载器之间的通信语法规则,比如ELF规定了几个专用于支持动态库的节:.rel.dyn、rel.plt、got、got.plt等,还提供了readelf、objdump之类的工具显示重定位提示的内容。
针对该思路的具体实现是装载时重定位(LTR)和位置无关代码(PIC)。

装载时重定位

装载时重定位是上述解决思路的最初实现,也是历史上动态库的最初实现方案。但是如今看来这种技术虽然实现起来比较简单,但相比PIC存在无法弥补的缺陷:

  • 会使用符号绝对地址对动态库指令进行修改,导致动态库指令(text段)无法在多个程序间共享;
  • 需要在装载时对每一个动态库符号的引用进行修改,对于需要加载大量动态库的程序而言装载时间会比较显著,产生启动延迟;
  • 需要将.text段以可写的方式装载,是潜在的安全问题;

位置无关代码

PIC引入了额外的GOT节来解决LTR中的问题,但其缺陷就是设计上和实现上都相当的复杂,因此在LTR出现之后,过了很久各个操作系统和编译器才开始对PIC提供稳定的支持,不过PIC如今已经几乎成为了动态库的代称,在提供了PIC支持的平台上,已经没有什么理由再去使用基于LTR的动态库了。
GOT的具体设计和实现比较复杂,撇开一些优化逻辑不谈,大体来说,就是程序在编译时,会预先确定存在哪些无法通过相对地址寻址的动态库符号引用,为所有这样的符号引用建立一个GOT表,并将指令地址进行替换,原本应该是符号绝对地址的被替换为指向GOT表中符号对应项的地址,由于GOT表是二进制文件的一部分,因此它是可以通过相对地址寻址的。在装载时,由装载器完成动态库的地址转换后,会回过头来对所有GOT表进行处理,将动态库中导出的对应符号的绝对地址填写到GOT表中。这样,不需要修改动态库的代码部分,只有GOT表需要被加载器在运行时修改,代码段不需要写权限,而且能够在不同进程中复用,相比LTR,PIC就是利用GOT这一层抽象,分隔了二进制文件中的变化与不变,从而提升了动态库的复用性。从某种意义上说,这也算得上是令动态库具备更好的高内聚、低耦合特性。

重复符号

在Linux中,默认情况下,链接器认为所有符号都是全局可见的,链接时会将符号引用解析为该符号的定义,因此不同的库中不可以定义同名的符号,不然就会链接失败。
在C中,可以使用static标记符号,表示符号仅在当前文件中可见,这样链接器仅在处理当前文件时会将符号引用解析为static标记的定义,static标记的符号定义不参与链接。
在使用动态库时,如果采用运行时动态加载动态库,也就是事先不对动态库进行链接,而是使用dlopen加载动态库,就不会有重复符号的问题;不过通常我们希望在程序中静态的使用动态库提供的API,因此需要对动态库进行过静态链接,此时需要面对重复符号的问题。

目标文件/静态库链接

如果参与链接的都是目标文件,那么不可以出现重复符号,否则会链接失败。C语言的static关键字修饰的局部符号可以重复。
如果参与链接的有静态库,那么只有第一个静态库中的符号会被链接,后面静态库中的相同符号会被忽略。
这种链接方式可以被称作对重复符号的零容忍策略,和动态库的重复符号处理方式形成对比。

动态库链接

链接器在有动态库作为输入时,会采取较粗略的方式处理符号名称冲突问题,允许符号冲突。这不符合大多数人对于链接的预期,需要深入分析以正确处理这种情况。
动态库重复符号解析有两个基本准则:

  • 链接器在进行动态库链接时,会参考符号定义优先级:
    • 客户程序(包括静态库和目标文件)中的符号优先级最高
    • 动态库导出的符号其次
    • 静态符号(区别于局部符号,静态符号是没被导出的动态库符号)最低。
  • 链接时指定的动态库链接顺序中,先指定的库中的符号具有更高的优先级。

在多个动态库导出了重复符号时,动态库的链接顺序会决定程序究竟会使用该符号的哪个定义,从而影响程序的行为,对于编译脚本的修改不会导致编译失败,而是会影响程序的行为,这是非常令人沮丧的事情。因此,应该尽可能的避免动态库中出现重复符号,每个动态库都应该定义自己的命名空间(C++ namespace)或者符号前缀(C、OC)。

动态库链接静态库的单例问题

如果多个动态库链接了同一个静态库(很可能发生,比如一个日志静态库),并且静态库提供了单例时,就会出现多个动态库中存在多份日志单例对象的情况,这是因为静态库的符号不属于动态库导出符号的一部分,因此每个动态库中都会存在一份静态库符号。
为了避免这种问题,单例类不应该被放在静态库中被动态库链接,如果要在动态库中使用单例,那么提供单例的库也应该是动态库。

强符号和弱符号

https://leondong1993.github.io/2017/04/strong-weak-symbol/