读书笔记(二十九) 《装载、链接与库》#2 静态链接过程

已发布在微信公众号上,点击跳转

背景:

看此书的起源是我在了解Linux注入技术的时候翻阅到的,由于注入技术需要用到很多ELF格式的内容,很多网络上的技术文章都指向了同一本书。也刚好周围的同事有此书,便翻阅了一下,这一番翻阅打开了我对程序世界的又一扇大门。

很快我就自己买了此书并阅读完成,整本书给我很大的震撼,让我对程序从编译到链接到装载有了更深刻的认识。

我把我的整个学习过程以及对书本的理解,用自己的语言和自己画的图表达出来,让读者能够更容易接受到我所学的知识。

目标:

了解编译过程

了解动态库和静态库的装载细节

了解可执行程序装载和执行过程

了解可执行文件和动态库的数据格式

疑问:

c/c++编译器是如何将cpp编译为可执行文件的?

多个c/c++文件是如何编译成一个可执行文件的?

操作系统内存是如何初始化和管理的?

动态库和静态库的链接和装载过程是怎样的?

操作系统的用户态和内核态是如何运作的?

正文:

前文《链接、装载与库-回顾基础》说了计算机设备的架构,硬盘的存储方式,内存虚拟空间的意义,多线程调度和安全的本质,以及代码编译的整体流程。

我们平常用C++代码编译出来的可执行文件、静态库、动态库,看上去它们的不一样的东西,其实它们的数据格式是相同的。

现代流行的操作系统分Windows和Linux两种,因此我们主要介绍这两种平台下的文件格式。在Windows下的可执行文件格式称为PE(Portable Executable),在Linux下则称为ELF(Executable Linkable Format),其实它们都是COFF(Common file format)格式的变种。

不光是可执行文件,动态链接库(DLL,Dynamic Linking Library)和静态链接库(Static Linking Library)都是按照可执行文件格式存储的。

静态链接库稍有不同,它把很多目标文件捆绑在一起形成一个文件,可以简单把它理解为一个包含很多目标文件的文件包。

单个目标文件简单的结构介绍

int global_init_var = 84; // 已初始化全局变量
int global_uninit_var; // 未初始化全局变量

//全局函数
void func1( int i)
{
    printf("%d\n",i);
}

//入口函数
int main(void)
{
    static int static_var = 85; // 已初始化静态变量
    static int static_var2; // 未初始化静态变量

    int a = 1; // 已初始化局部变量
    int b; // 未初始化局部变量
    func1(static_var + static_var2 + a + b); // 调用全局函数

    return 0;
}

我们先以目标文件为例,来举一个简单的文件ELF结构。目标文件是最常见的编译单位,它将指令代码、数据以Section的形式存储在文件中。

上图中我们简单说明了程序、已初始化全局数据、未初始化全局数据在目标文件中的存放位置。 其中.data段保存的是已初始化了的全局静态变量和局部静态变量,这些数据是可写可读的,而只读数据则存放在.rodata中,例如const修饰的变量和字符串常量。 用.rodata单独保存只读数据能够更加好的保护数据,任何修改.rodata中的数据都会作为非法操作处理。

类似的Section段有很多,我们列举一些重要的来说:

.bss:包含程序运行时未初始化的数据(全局变量和静态变量)。当程序运行时,这些数据初始化为0。
.data和.data1,包含初始化的全局变量和静态变量。
.dynamic,包含了动态链接的信息,包括链接器地址、需要的动态库、段地址信息,类型为SHT_DYNAMIC。
.dynstr,包含了动态链接用的字符串,通常是和符号表中的符号关联的字符串,类型为SHT_STRTAB。
.dynsym,包含动态链接函数符号表和地址,没有地址的则为0,标志SHF_ALLOC,类型为SHT_DYNSYM。
.fini,正常结束时要执行的析构程序,类型为SHT_PROGBITS。
.got,全局偏移表(global offset table),类型为SHT_PROGBITS。
.hash,包含符号hash表,用于快速查找函数名的。标志SHF_ALLOC,类型为SHT_HASH。
.init,程序运行或加载时初始化程序。类型为SHT_PROGBITS。
.interp,该节内容是一个字符串,指定了程序链接器的路径名。如果文件中有一个可加载的segment包含该节,属性就包含SHF_ALLOC,否则不包含。类型为SHT_PROGBITS。
.plt 过程链接表(Procedure Linkage Table),类型为SHT_PROGBITS。
.rodata和.rodata1, 包含只读数据,组成不可写的段。标志SHF_ALLOC,类型为SHT_PROGBITS。
.shstrtab,包含section的名字,真正的字符串存储在.shstrtab中,其他都是索引。类型为SHT_STRTAB。
.strtab,包含字符串,通常是符号表中符号对应的变量名字和函数名。类型为SHT_STRTAB。
.symtab,Symbol Table,符号表。包含了所有符号信息,包括变量、函数、定位、重定位符号定义和引用时需要的信息。符号表是一个数组,Index 0 第一个入口,它的含义是undefined symbol index, STN_UNDEF。
.rela.dyn,包含了除PLT以外的 RELA 类型的动态库重定向信息。
.rela.plt,包含了PLT中的 RELA 类型的动态库重定向信息
.rela.text 代码重定位表
.rela.data 数据重定位表
.line,调试时的行号表,即源代码行号与编译后指令的对应表

如果我们想自定义些Section,可以用__attribute__((section(“name”)))把相应的变量或函数放到“name”作为段名的段中,例如:

__attribute__((section("GlobalTest"))) int global = 42;

__attribute__((section("FuncTest"))) void foo()
{
}

那么ELF结构在文件中是以怎样的形式存在的呢?

下面是ELF文件的大致布局

(来源ELF描述文档 http://www.skyfree.org/linux/references/ELF_Format.pdf)

左边是ELF的链接视图,可以理解为是目标代码文件的内容布局。右边是ELF的执行视图,可以理解为可执行文件的内容布局。 目标代码文件(编译过程中的.o文件)的内容是由Section组成的,而可执行文件(二进制可执行文件或库文件)的内容是由Segment段组成的。

ELF header的定义可以在 /usr/include/elf.h 中找到。Elf32_Ehdr是32位 ELF header的结构体。Elf64_Ehdr是64位ELF header的结构体。 (Linker and Libraries Guide:https://docs.oracle.com/cd/E19120-01/open.solaris/819-0690/chapter6-43405/index.html)

其中e_ident的前4个字节是魔数,魔数是固定的,它用来确认文件的类型,操作系统在加载可执行文件时会确认魔数是否正确,如果不正确则说明文件不是ELF格式,则拒绝加载。

ELF的整体结构

现在我们知道了,ELF Header描述了Program header table 和 Section header table,Program header table 又描述了Segment摘要,Section header table 又描述了Section摘要。

那么 Program header table 和 Section header table 究竟是怎么去描述Segment和Section摘要的呢?

前面我们说ELF Header里已经有了Program header table 和 Section header table的位置的偏移量,有了它们每个table的大小,以及有多少个table,我们就能知道Program header table 和 Section header table的起始地址、每个结构体的位置、以及它们的范围。

也就是说,每个Program header table 和 Section header table是一个由固定大小结构体组成的数组,于是可以根据ELF Header来确定它们的数据内容和范围。

我们来看下它们的结构体,Program header结构:

Section header结构:

这里特别从重要的几个Section中挑出几个特别重要的字段来重点说明下。

重定位表

任何需要重定位的Section都需要一个重定位表,例如

.text需要.rel.text来辅助重定位 .data需要.rel.data来辅助重定位 .rela.dyn,用于动态重定位变量的表 .rela.plt,则用于动态重定位函数的表

重定位分为两种

  1. 一种是静态编译时重定位,在编译时重定位
  2. 另一种是动态加载时重定位,在执行时重定位

重定位我们放后面详细介绍

字符串表

字符串主要用于匹配函数和变量名,但不同用途的字符串也会分开存放。

.shstrtab,包含section名字的字符串存储在.shstrtab中 .strtab,包含符号表中符号对应的变量名字和函数名的字符串 .symtab,Symbol Table符号表,包含了所有符号信息,包括变量、函数、定位、重定位符号定义和引用时需要的信息,这里所有的字符串都只有索引指向.strtab中的内容。 .dynstr,包含了动态链接用的字符串,通常是和符号表中的符号关联的字符串 .hash,包含符号hash表,用于快速查找函数名和变量名

字符串表中包含了存储字符串的表、存储索引的表、以及hash表,它们的用途分别是:

  1. 存储字符串的表目的是存储真实字符串数据
  2. 其他表有需要字符串的都用索引表示以节省内存
  3. hash表则主要用于快速查找和比较字符串

符号表

符号表主要用于记录函数名、变量名和地址,例如:

.symtab,Symbol Table符号表,包含了所有符号信息,包括变量、函数、定位、重定位符号定义和引用时需要的信息 .dynsym,包含动态链接函数符号表和地址,没有地址的则为0

这两者的区别是,symtab记录了所有符号,包括内部的和外部的函数和变量,而.dynsym则只包含了对外动态加载时需要提供的函数和变量,可以.dynsym是.symtab的阉割版。

symtab的数据结构为

typedef struct{
  Elf32_Word st_name; // 符号名在符号表中的索引
  Elf32_Addr st_value; // 符号相对应的值,是地址或者其他值
  Elf32_Word st_size; // 符号大小,包含数据的符号,该值为占用大小
  unsigned char st_info; // 符号类型和绑定信息
  unsigned char st_other; // 无用,值为0
  Elf32_Half st_shndx; // 符号所在段的索引
} Elf32_Sym;

函数签名规则

前面说函数名就是符号名,会存储在符号表中,但C++中函数名常常会有相同的时候,因此编译器在编译时会对函数名进行处理,以区别不同范围内的函数。

我们称这种改变符号名的机制为函数签名(Function Signature)。 签名后的函数名,包含了函数名、它的参数类型、它所在的类和命名空间以及其他信息。

签名规则每种编译器都不一样,GCC编译器的规则为,所有符号以“_Z”开头,对于嵌套的名字后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以“E”结尾,“E”后面是参数列表,对于int类型来说就是字母“i”,float类型来说就是字母“f”,以此类推。

来举几个例子:

* int func(int)  签名后--> _Z4funci --> _Z开头,4个字符的函数名func,参数是i整型
* float func(float) 签名后--> _Z4funcf --> _Z开头,4个字符的函数名func,参数是f浮点型
* int C::func(int) 签名后--> _ZN1C4funcEi --> _Z开头,嵌套1个字符C,接4个字符的函数名func,E结尾,参数是i整型
* int C::C2::func(int) 签名后--> _ZN1C2C24funcEi --> _Z开头,嵌套1个字符C,再嵌套2个字符C2,接4个字符的函数名func,E结尾,参数是i整型
* int N::func(int) 签名后--> _ZN1N4funcEi --> _Z开头,嵌套1个字符N,接4个字符函数名func,E结尾,参数是i整型
* int N::C::func(int) 签名后--> _ZN1N1C4funcEi --> _Z开图,嵌套1个字符N,再嵌套1个字符C,接4个字符func,E结尾,参数是i整型

由于不同编译器的签名规则不同,所以当我们要使用C语言写的外部函数时,就需要告诉编译器使用C语言的命名规则

extern "C"{
  int func(int);
  int var;
}

如果我们在C++中没有使用extern “C”申明会如何? 如果我们没有正确告知编译器外部引用函数的编译器规则,则在链接器链接时就会因为符号不匹配而无法链接,因此对于C++引用C函数来说,必须使用extern “C”来声明编译器规则。

强符号弱符号,强引用弱引用

通常我们定义的变量和函数都是强符号和强引用,意思是当符号冲突时会直接覆盖原来的符号,而弱符号和弱引用则是当符号冲突时不覆盖原来的。 在编写通用库、共享库程序时,弱符号和弱引用,可以起到替换和被替换的作用。 例如库中定义的弱符号被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的库函数;或者扩展某个功能模块时,弱引用被新模块接口覆盖,从而使得程序可以在正常使用的情况下扩展功能。 attribute((week)) week_var = 2; attribute((weekref)) void func();

Linux提供了几个工具来帮助我们了解ELF格式内容

  1. readelf,可以帮助我们读取ELF文件中Section的内容
  2. objdump,可以帮助我们翻译二进制指令和数据
  3. c++filt,可以帮助我们解析被修饰过的名称
  4. 编译时通过加 -g 参数,可以让编译器在产生目标文件时加上调试信息
  5. strip,可以通过这个命令把ELF中的调试信息都去除

静态链接

静态链接的过程其实就是把目标文件中的数据归档到一个文件中,其归档步骤有两步。

第一步,空间与地址分配

扫描所有输入的目标文件,获取它们的各Section的数据,合并各段数据并建立映射关系。 同时把所有目标文件中的符号和符号定义都收集起来放到全局符号表中。

第二步,符号解析与重定位

根据第一步收集到的信息,包括Section和符号表信息,进行符号解析与重定位,调整代码中的地址。

在重定位时,由于各个符号的在Section内的位置是相对固定的,所以只要计算出Section的偏移量就可以知道合并后的符号实际位置。

在第一步完成空间分配后,就可以确定所有符号的虚拟地址了,但这时目标文件内含有其他目标文件函数,则需要另外通过符号表来重定位。

由于在完成空间分配后,全局符号表内的符号都确定了地址,因此在重定位时可以使用全局符号表中的地址替换未定位地址。

每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。链接器在重定位的过程中,每个重定位的入口都是一个符号的引用,当需要对某个符号的引用进行重定位时,就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

注意:在未确定外部符号地址时,编译器把这部分地址暂时用“0x00000000”和“0xFFFFFFFC”来代替,在链接时再由链接器将计算好的地址填充进来。

重定位表

我们前面说过每个需要重定位的Section都有一个重定位表,例如“.text”有“.rel.text”,“.data”有“.rel.data”。

每个重定位表里的数据为:

typedef struct{
  Elf32_Addr r_offset; // 相对于当前Section的相对位置
  Elf32_Word r_info; // 低8位表示重定位入口类型,高24位表示重定位入口符号的索引
}

简单来说就是,重定位表数据中有一个需要重定位的位置和一个重定位的符号名字符串索引,有了这两个数据,链接器就能在链接时从全局符号表中找到合并后的真实虚拟地址。

在符号地址修正时,也分绝对寻址修正和相对寻址修正,它们的区别是绝对寻址修正后的地址为该符号的实际虚拟地址,相对寻址修正后的地址为符号距离被修正位置的地址差,因此它们分别服务的是不同类型的指令。

全局构造和析构表

.init段中保存的初始化代码。当一个程序开始运行时在main函数调用之前Glibc的初始化程序会执行这个段中的代码。 .fini段中保存的析构代码。当一个进程的main函数正常退出时Glibc会安排执行这个段中的代码。 这两个段有特别的目的,.init在main函数前执行,.fini则在main函数后执行,利用这两个特性C++的全局构造和析构函数就由此实现。

将不同编译器的编译结果链接

那么有没有可能将不同编译器的编译结果链接成一个可执行文件呢?其实可以的,但非常困难。

目标文件必须满足以下条件:

  1. 目标文件的数据格式兼容
  2. 符号签名规则兼容
  3. 变量的内存分布方式兼容
  4. 函数调用方式兼容
  5. 堆栈分布方式兼容
  6. 寄存器使用阅读兼容

我们把这种链接时的兼容性相关内容称为ABI(Application Binary Interface)。与API不同的是,ABI是指二进制层面的接口兼容性更加严格,而API则只是源代码级别上的的接口。 其实不同编译器链接成可执行文件的最大问题就是,各种硬件平台、编程语言、编译器、链接器和操作系统之间的ABI互不兼容。由于ABI的不兼容,导致各个目标文件之间无法互相链接。

静态库与链接

其实静态库可以简单看成一组目标文件的集合,在制作静态库时只是将所有目标文件打包压缩后归档为一个文件而已。

Linux下可以使用“ar”这个工具命令来归档目标文件,“ar”压缩程序将所有目标文件压缩到一起并对其进行编号和索引,以便于查找和检索,就形成了xxx.a静态库文件。Visual C++也提供了类似ar的工具,叫lib.exe,这个程序可以用来创建、提取、查看.lib文件中的内容。

在静态库链接时,Linux下的链接器ld会自动寻找需要的静态库,并从中解压出来目标文件,再最终将这些目标文件链接在一起成为可执行文件。

从这个规则上看来,我们在编写静态库的时候,最好是每个函数独立存放在一个目标文件中,因此这样可以尽量减少空间浪费,因为没有被用到的目标文件就不会链接到最终的输出文件中。

中间件BFD库

我们说各种操作系统平台都遵循类似ELF格式的文件格式,虽然是相似的ELF格式但在不同软硬件平台都有着不同的变种。 种种差异导致编译器和链接器很难处理不同平台之间的目标文件,因此最好有一种统一的接口来处理不同格式的文件。 BFD库(Binary File Dexcriptor library)就是为了这个目的存在的,它的目标就是用一种统一接口来处理不同的目标文件格式。

现在GCC编译器、链接器ld、调试器gdb、binutils等工具都是通过BFD库来处理文件,而不直接操作目标文件。 这样做的好处是将编译器和链接器与具体的目标文件格式隔离开来,一旦我们需要支持一种新的目标文件格式,只需要在BFD库里添加一种格式就可以了,而不需要再去修改编译器和链接器。

已发布在微信公众号上,点击跳转

· 读书笔记, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    读书笔记(二十九) 《装载、链接与库》#2 静态链接过程

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)