链接与库

最近复习了csapp第7章:链接的相关内容(主要是印象太浅了),加上最近在编译和链接库的时候遇到了一些问题,故还是记录一下。
这章涉及到的关键概念不少, 包括但不限于:

  • Symbol
  • ELF(相信做过6.828的不会陌生)
  • Symbol resolution
  • Relocation
  • static linking
  • dynamic linking
  • shared library interpositioning

链接

csapp给出的定义是:

聚集和组合各个代码和数据到一个文件,该文件可以装载(loaded)到内存中并执行的过程
而这个过程可以发生在:

  • compile time(static linking)
  • load time/run time(dynamic linking)

说到底之所以需要linking,是因为它使得开发软件可以将每个源文件分开,从而避免了将所有东西塞到同一个文件,导致可维护性极低(说白了就是XX)(我不知道其他语言如何,至少对于c/cpp是如此的)。即所谓的分离式编译(separate compilation)。

我不是很想谈及学这玩意有什么用,如果你对静态库或动态库感兴趣,或者你是个库作者/用户,我觉得是相当有必要的,特别是后者,至少我的动机是如此。

编译器驱动

所谓的编译器驱动(compiler driver)也就是一个编译系统,它会调动对应的预处理器,编译器,汇编器,链接器,相当于前端吧。
一些典型的例子就是gcc(g++),clang(clang++),MSVC,这些都不是单纯的编译器,而是这些构筑组件的集合,隐蔽了细节罢了(额,你知道封装吧)。

我们先来说明下静态链接,这个方法主要使用于*.o的集合或者静态库(archive),大致流程如下:
image.png
接下来先要讲述的就是静态链接,而后介绍动态链接及动态库的各种用途。

静态链接

该过程在不同的操作系统是由不同的程序管理,在Linux,基本是由ld执行这个过程。静态链接要做的事上面的图已经表达了,就是将各个*.o集合在一起然后输出成一个可执行文件。*.o文件是可重定位目标文件(relocatable object file),之所以叫这个名字是因为静态链接的第二阶段。

静态链接分为两个大阶段:

  • 符号决议(Symbol resolution)
  • 重定位(Relocation)

符号(symbol)对应的就是函数和变量(包括static和global),而符号决议的目的就是将每个对于符号的引用与对应的符号关联起来,比如我有两个源文件,最终它们编译成两个.o文件,其中一个文件调用的全局函数定义在另一个文件中,因此需要将这个引用和定义在另一个文件中函数定义关联在一起。

而重定位,你可以认为是改写地址的过程,因为编译器和汇编器产生的代码节(code section)和数据节(data section)都是从0开始的,也就是未定位状态。链接器通过将每个符号的定义与内存地址关联起来从而改写每个对符号的引用都指向正确的内存地址,这个过程就是重定位(relocate)。链接器本身是做不到这点的,它主要是借助于汇编器生成的一些特殊的节,重定位项(relocation entries)。

ELF

上面所谈及的节就是ELF中的一序列的bytes(毕竟从二进制角度看,哪里都是连续的bytes)。
如果不是很了解这个东西,可以去看下程序员的自我修养这本书还有就是6.828提供的ELF资料。不过这里不需要了解那么细,你只要知道它有这些东西,以及在这里哪些是你需要了解的。

目标文件有三种:

  • 可重定位目标文件(relocatable object)
  • 可执行目标文件(executable object)
  • 共享目标文件(shared object)

三种目标文件都是遵守ELF的,通过ELF中对应的标志位可以识别它们。
下面是可重定位目标文件的ELF结构:
image.png

  • .text: 就是程序文本翻译成的机器码
  • .rodata:只读数据,比如字符串字面值,还有switch语句的jump table都存放在这里(如果了解虚拟内存,应该知道对应的page是可以设置权限位的)
  • .data:主要存放已初始化的全局变量已初始化的静态变量
  • .bss:主要存放未初始化的静态变量未初始化的全局变量。在Linux(等平台?),这个不是很准确,具体来说,未初始化的全局变量会放到COMMON这个伪节(pseudosection),而初始化为0的全局变量和静态变量以及未初始化的静态变量才会放到.bss。你可能疑问初始化为0的全局变量和静态变量会什么要放到这?那是因为因为本质上它们就是0,而所有放在.bss都是零初始化的,没有必要放到.data,且.bss最终在可执行目标文件中与.data组合成一个(segment)的时候,他只占8字节,即一个占位符,这是节省空间的一种手法。
  • .symtab:存放函数和变量符号的元数据,该符号表相对于编译器的符号表区别在于没有局部变量的项,因为编译器已经生成了对应的汇编代码,对于链接器而言,局部变量它根本不需要关心,因此,局部变量也不会存在于.data.bss,由runtime stack管理。
  • .rel.text: 在.text中需要修改地址的位置的列表,一般这些位置都是调用了不属于该模块的全局函数或是引用了不属于该模块的全局变量。
  • .rel.data:在该模块被引用或定义的全局变量的重定位信息。

这两个部分对于可执行目标文件都是无必要的,除非用户显式指定包含它们。

  • .debug.line都是debug用的元数据,这里就不细说了
  • .strtab.symtab.debug的符号表以及各个节头(section header)的节名称的字符串表。

其中你需要重点理解的是.symtab.rel.text.rel.data

Symbol

符号大体上来说有两类:

  • 全局符号(Global Symbol):在一个模块有定义,可以被其他模块引用。在C程序中对应的是non-static函数和全局变量。特别的,如果一个全局符号在该模块被引用但是定义在其他模块,这样的符号是external的,这个在C语言中,可以通过extern关键字声明这样的引用(更常用的手法是包含这些全局符号声明的头文件,因为默认就是extern的)
  • 局部符号(Local Symbol):这类符号局限于单个模块,不能被其他的模块引用。在C程序中对应的是static函数和static变量,基本的使用方式就是作为公开API的实现细节。

可重定位目标文件的符号表是汇编器用.s(编译器产生的汇编文件)中的符号构建的。
其符号表项结构如下所示:

1
2
3
4
5
6
7
8
9
typedef struct {
int name; /** String table offset */
char tyte: 4, /** Function or data(4 bits) */
binding: 4; /** Local or global(4 bits) */
char reserved; /** Unused */
short section; /** Section header index */
long value; /** Section offset or absolute address */
long size; /** Object size in bytes */
} Elf64_Symbol;

  • name实际上不是一个字符串,而是它在.strtab中的偏移量。
  • type指明了是函数还是数据而binding则指明了是全局符号还是局部符号
  • value对于可重定位目标文件而言是相对于section的偏移量,在可执行目标文件中则是绝对地址(运行时地址)

Pseudosetions

伪节本身在section header table中没有对应的项,因此section字段不是一个有效的索引。
有三个伪节,它们仅存在于可重定位目标文件中:

  • ABS:不应被重定位的符号
  • UNDEF:未定义的符号,其定义应定义在其他模块,不然最后你必然会得到undefined reference to xxx的错误信息
  • COMMON:前面提过,不赘述

Symbol Resolution

这里讨论符号决议的一些细节。

  • 对于局部符号,编译器会保证在同一个模块中它只定义一次且拥有唯一的名字,因此对它的引用很容易决议。
  • 而对于全局符号,更为麻烦的在于定义可能出现在其他模块,因此编译器遇到这样的符号,会假设它定义在其他的模块中,留给链接器去处理。
    对于全局符号还需要注意的一点就是可能多个模块都定义了它,一般有两种方法处理这种情况:
    • 报错,必须一个才能通过
    • 选择一个,忽视其他的

为此,编译器在导出符号给汇编器的时候,它区分了强弱符号:

  • 强符号(strong):函数/初始化的全局变量
  • 弱符号(weak):未初始化的全局变量

规则是这样的:

  • 不允许多个强符号
  • 强符号和弱符号同时存在的时候选择强符号
  • 多个弱符号则随意选择一个

从这里也可以得出为什么要将不同的全局变量塞进.bss.COMMON

  • .bss:强符号
  • .COMMON:弱符号