程序员的自我修养:静态链接

3 目标文件里有什么

3.1 目标文件格式

现在PC平台流行的可执行文件格式主要是Windows的PE(Portable Executable)和Linux的ELF(Executable Linkable Format)格式。它们都是COFF(Common file format)格式的变种。

其实可执行文件与动态库/静态库文件大致结构是类似的,仅仅是因为不同目的而具有不同的段数据而已。目标文件大致可分为以下几种:

ELF文件类型 说明 例子
可从定位文件(Relocatable File) 这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类 Linux的.o / Windows的.obj
可执行文件(Executable File) 这类文件包含了代码和数据,可以直接执行,一般都没有扩展名 Linux的.out / Windows的.exe
共享目标文件(Shared Object File) 这种文件包含了代码和数据,可以跟其他的可重定位文件和共享目标文件静态链接。也可以通过动态链接器将几个这种共享目标文件与可执行文件结合,作为进程映像的部分来运行 Linux的.so / Windows的.dll
核心转储文件(Core Dump File) 当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 Linux的core dump

3.2 目标文件是什么样的

目标文件里面的内容有数据和机器码。这些内容被按照不同的属性分成了多个Section,也叫做“段”。如专门放代码的.text/.code段,专门放数据的.data段······

很多段的名字都是以一个 .符号(句点)开始,这表示是系统保留的段,对于用户自己设立的段可以不以点号开始。

总的来说程序员代码被编译后主要分成了两种段:指令和数据。指令和数据起始是分开存放的,这样做的原因主要是处于以下几点原因:

  • 系统可能会同时运行程序的多个副本,此时,程序的代码部分都是完全一样的,仅仅只有数据不同。指令部分抽离出来形成单独的段,可以让程序的副本共享同一个指令段,节省内存
  • 现代的CPU有着强大的指令缓存体系,指令放到独立的段,有助于CPU预加载指令,来提高执行性能
  • 指令放到单独的段可以精细控制内存访问属性,防止代码指令被修改

3.3 目标文件详解

在Linux下,可以用objdump来把目标文件的段信息打印出来

1
$ objdump -s -d object_file.o  #-s表示16进制打印,-d表示把所有包含指令的段反汇编

.data 段保存了初始化了的全局静态变量和局部静态变量

.rodata 段保存了制度数据,如const修饰的变量,和字符串变量

.bss 段存放的是未初始化的全局变量和局部静态变量

还有一些其他的常见的段:

段名 功能
.rodata1 只读数据,和rodata类似
.comment 存放编译器版本信息
.debug 调试信息
.dynamic 动态链接库中才会用到,用来存放动态链接数据
.hash 加速符号查找的hash算法用到的段
.line 存储行号,用来调试
.note 额外的编译器信息,如公司名字等
.strtab 字符串表,用于存储ELF文件中用到的各种字符串
.symtab 符号表
.shstrtab 段名表
.plt / .got 动态链接的跳转和全局入口表
.init / .fini C++全局构造和析构代码段

3.4 ELF文件结构描述

ELF目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性,如ELF文件版本、目标机器型号、程序入口地址等。

紧接着是ELF文件各个段。其中最重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、长度、在文件中的偏移、读写权限及段的其他属性。

还有就是一些ELF中辅助的结构,比如字符串表、符号表等:

3.4-1

3.4.1 文件头

可以用readelf命令来详细查看elf文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ readelf -h object_file.o #-h 表示header

# ELF 头:
# Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
# 类别: ELF32
# 数据: 2 补码,小端序 (little endian)
# Version: 1 (current)
# OS/ABI: UNIX - System V
# ABI 版本: 0
# 类型: EXEC (可执行文件)
# 系统架构: Intel 80386
# 版本: 0x1
# 入口点地址: 0x8049000
# 程序头起点: 52 (bytes into file)
# Start of section headers: 8228 (bytes into file)
# Size of section headers: 40 (bytes)
# Number of section headers: 4
# Section header string table index: 3
# 标志: 0x0
# Size of this header: 52 (bytes)
# Start of program headers: 0
# Size of program headers: 32 (bytes)
# Number of program headers: 3

从上而输出的结果可以看到,ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、AB版本、ELF重定位类型、硬件平台、硬件平台版本入口地址、程序头入口和长度、段表的位置和长度及段的数量等。

这里面非常重要的几个字段是:

  • ELF Magic Number。这个常数用来识别这个文件属于ELF格式。很多二进制文件开头都有Magic Number,ELF格式也例外

  • 类型(Type)。表示该ELF是可执行文件还是静态库/动态库或者是其他类型

  • 入口点地址(Entry point address)。操作系统加载完该程序后,会从这个地址开始执行代码。只有可执行文件这个字段是有效的

  • 段表信息

    • 段表的偏移地址(Start of section headers)。表示段表在ELF文件的第N个字节处开始
    • 段表的描述符大小(Size of section headers)。表示一个段表的描述结构的大小
    • 段表的描述符数量(Number of section headers)。表示有多少个段表
    • 段名的下标(Section header string table index)。

    一个段从哪儿开始,每个段长度多少,有多少个段。有了这些信息就能找到每个段的数据。

  • Segment表信息

    • Segment的偏移地址(Start of program headers)。表示Segment表在ELF文件的第N个字节处开始
    • Segment的描述符大小(Size of program headers)。表示一个Segment的描述结构的大小
    • Segment的描述符数量(Number of program headers)。表示有多少个Segment表

    在ELF文件中,一般会有很多段,在把段加载到内存中时,会分配单独的内存页来控制段的内存权限。不过很多段都很小,这样单独分配一个内存页太浪费空间。所以处于节省内存的考虑,就在ELF文件中新增了Segment的概念,即把内存属性相似的段合在一起,形成Segment。

    这样本来以前可能有几十个段,现在合并内存权限相似的同类项,最后只形成几个Segment。这样系统在加载时只用进行有限的几种内存权限控制就行了。

    不过Segment和并不是彼此替代关系。所有的Segment和所有的段表示的内容必然是一样的,只是两者侧重点不同,段是为了方便编译和执行而出现的;而Segment则是为了方便系统加载程序而出现。

3.4.2 段表

之前用的readelf命令也可以用来来查看段表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ readelf -S object_file.o #-S 表示Section

# There are 15 section headers, starting at offset 0x308:
#
# 节头:
# [Nr] Name Type Addr Off Size ES Flg Lk Inf Al
# [ 0] NULL 00000000 000000 000000 00 0 0 0
# [ 1] .group GROUP 00000000 000034 000008 04 12 12 4
# [ 2] .text PROGBITS 00000000 00003c 000051 00 AX 0 0 1
# [ 3] .rel.text REL 00000000 000244 000030 08 I 12 2 4
# [ 4] .data PROGBITS 00000000 00008d 000000 00 WA 0 0 1
# [ 5] .bss NOBITS 00000000 00008d 000000 00 WA 0 0 1
# [ 6] .rodata PROGBITS 00000000 00008d 00001a 00 A 0 0 1
# [ 7] .text.__x86.get_p PROGBITS 00000000 0000a7 000004 00 AXG 0 0 1
# [ 8] .comment PROGBITS 00000000 0000ab 000012 01 MS 0 0 1
# [ 9] .note.GNU-stack PROGBITS 00000000 0000bd 000000 00 0 0 1
# [10] .eh_frame PROGBITS 00000000 0000c0 000050 00 A 0 0 4
# [11] .rel.eh_frame REL 00000000 000274 000010 08 I 12 10 4
# [12] .symtab SYMTAB 00000000 000110 0000f0 10 13 11 4
# [13] .strtab STRTAB 00000000 000200 000042 00 0 0 1
# [14] .shstrtab STRTAB 00000000 000284 000082 00 0 0 1
# Key to Flags:
# W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
# L (link order), O (extra OS processing required), G (group), T (TLS),
# C (compressed), x (unknown), o (OS specific), E (exclude),
# p (processor specific)

readelf输出的结果就是ELF的文件段表的内容。段表的是一个“Elf32_Shdr”结构为元素的数组。“Elf_Shdr”又被称为段描述符。“Elf32_Shdr”结构被定义在“/usr/include/elf.h”,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32 Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;

Elf32_Shdr的各个成员含义如下:

字段 含义
sh_name 段名是个字符串,它位于一个叫做“.shstrtab”的字符串表。sh_name是段名字符串在“.shstrtab”中的偏移
sh_type 类型
sh_flags 段标志
sh_addr 如果该段可以被加载,则sh_addr为该段被加载后在进程地址空间中的虚拟地址,否则该值为0
sh_offset 如果该段存在于文件中,则表示该段在文件中的偏移;否则无意义。比如sh_offset对于BSS段来说就没有意义
sh_size 段的长度
sh_link / sh_info 段的链接信息
sh_addralign(段地址对齐) 有些段对段地址对齐有要求,比如我们假设有个段刚开始的位置包含了一个double变量,因为x86系统要求浮点数的存储地址必须是本身的整数倍,也就是说保存 double变量的地址必须是8字节的整数倍。这样对一个段来说,它的 sh_addr必须是8的整数倍

由于地址对齐的数量都是2的指数倍,sh_addralign表示是地址对齐数量中的指数,即 sh_addrlign=3表示对齐为2的3次方倍,即8倍,依此类推所以一个段的地址 sh_addr必须满足下面的条件,$sh_addr\% \left( 2^{sh_addrlign}\right) =0 $

如果 sh_addralign为0或1,则表示该段没有对齐要求
sh_entsize(项的长度) 有些段包含了一些固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的。对于这种段,sh_entsize表示每个项的大小。如果为0,则表示该段不包含固定大小的项
段的类型(sh_type)

段的名字只是在链接和编译过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为“text”,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。该字段的取值如下:

常量 含义
SHT_NULL 0 无效段
SHT_PROGBITS 1 代码段、数据段都是该类型
SHT_SYMTAB 2 符号表
SHT_STRTAB 3 字符串表
SHT_RELA 4 重定位表
SHT_HASH 5 符号表的哈希表
SHT_DYNAMIC 6 动态链接信息
SHT_NOTE 7 提示性信息
SHT_NOBITS 8 表示该段在文件中没有内容
SHT_REL 9 该段包含了重定位信息,用与静态链接
SHT_SHLIB 10 保留
SHT_DNYSYM 11 动态链接的符号表
段的标志位(sh_flag)

段的标志位表示该段在进程虚拟地址在地址空间中的属性,比如是否可写,是否可执行等。该字段的取值如下:

常量 含义
SHF_WRITE 1 表示该段在进程空间中可写
SHF_ALLOC 2 表示该段在进程空间中须要分配空间。有些包含指示或控制信息的段不须要在进程空间中被分配空间,它们一般不会有这个标志。像代码段、数据段和bss段都会有这个标志位
SHF_EXECINSTR 4 表示该段在进程空间中可执行,一般代码段是这个属性

常见的段的标志位如下:

段名 sh_type sh_flag
.bss SHT_NOBITS SHF_ALLOC + SHF_WRITE
.comment SHT_PROGBITS none
.data SHT_PROGBITS SHF_ALLOC + SHF_WRITE
.debug SHT_PROGBITS none
.dynamic SHT_DYNAMIC SHF_ALLOC + SHF_WRITE
有些系统里.dynamic是只读的只有SHF_ALLOC
.hash SHT_HASH SHF_ALLOC
.line SHT_PROGBITS none
.note SHT_NOTE none
.rodata SHT_PROGBITS SHF_ALLOC
.shstrtab SHT_STRTAB none
.strtab SHT_PROGBITS 如果ELF文件中有可装载的段须要用到该字符串表,那么该字符串表也将被装载到进程空间,则有SHF_ALLOC标志位
.symtab SHT_STRTAB 同字符串表
.text SHT_PROGBITS SHF_ALLOC + SHF_EXECINSTR
段的链接信息(sh_link、sh_info)

如果段是和链接相关的,如重定位表、符号表等,那么这些段的含义如下:

sh_type sh_link sh_info
SHT_DYNAMIC 该段所使用的字符串表在段表中的下标 0
SHT_HASH 该段所使用的符号表在段表中的下标 0
SHT_REL 该段所使用的相应符号表在段表中的下标 该重定位表所作用的段在段表中的下标
SHT_RELA 该段所使用的相应符号表在段表中的下标 该重定位表所作用的段在段表中的下标
SHT_SYMTAB 操作系统相关 操作系统相关
SHT_DYNSYM 操作系统相关 操作系统相关
other SHN_UNDEF 0

3.4.3 重定位表

链接器在处理目标文件时,须要对目标文件的代码段和数据段中那些对绝对地址的引用的位置进行重定位。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表。比如之前的示例中,”rel.text“就是针对“text”段的重定位表。而”data“段则没有对绝对地址的引用,所以没有针对”data“段的重定位表”rel. data“。

3.4.4 字符串表

ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。这样就诞生了字符串表这个结构。

最常见的字符串表就是”.strtab“和”.shstrtab“。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。字符串表用来保存普通的字符串,如符号的名字;段表字符串表用来保存段表中用到的字符串,如段名。

3.5 链接的接口——符号

在开发中,每个模块都会使用其他模块的功能,或者提供功能给其他模块引用,上述方法能成功工作是因为模块的函数或者变量都有自己独特的名字,避免了引用时发生混淆。在链接中这些函数和变量统称为符号,它们的名字就是符号名。

整个链接过程就是基于符号才能够正确完成。所以链接中很关键的一环就是符号管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有个对应的值,叫做符号值( Symbol Value),对于变量和函数来说,符号值就是它们的地址。除了函数和变量之外,还存在其他几种不常用到的符号。我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:

  • 定义在本目标文件的全局符号。可以被其他目标文件引用
  • 在本目标文件中引用却在其他文件中定义的全局符号。这一般叫外部符号
  • 局部符号。如本地的局部变量,这类符号仅仅在编译单元内部可见,因为链接器仅仅关注模块间的关系,所以这些符号其实对链接器来说没有什么作用。不过调试器可以使用这些符号来分析程序或崩溃时的核心转储文件
  • 段名 、行号。这些符号用来提供一些辅助功能

对于链接器来说,最重要的还是前面两种符号。

3.5.1 ELF 符号表结构

ELF文件的符号表往往是文件的”symtab“段。它的结构很简单,是一个Elf32_Sym结构(32位ELF文件)的数组,每个Elf32_Sym结构对应一个符号。这个数组的第一个元素,也就是下标0的元素为无效的”未定义“符号。Elf32_Sym的结构定义如下:

1
2
3
4
5
6
7
8
typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;

Elf32_Sym的各个成员含义如下:

字段 含义
st_name 符号名,这个字段的值是该符号在字符串表中的下标
st_value 符号相对应的值。这个值跟符号有关,可能是一个绝对值,也可能是一个地址等,不同的符号,它所对应的值含义不同
st_size 符号大小。对于包含数据的符号,这个值是该数据类型的大小。如 double 类型的符号就是 8 个字节
st_info 符号类型和绑定信息
st_other 目前未用
st_shndx 符号所在的段
符号值(st_value)

每个符号都有一个对应的值,如果符号是一个函数或者变量的定义,那么符号的值就是这个函数或者变量的地址,更准确地说应该分成下面几种情况区别对待:

  • 在目标文件中,如果符号不是”COMMON“类型的。st_value表示该符号在段中的偏移。即符号所对应的函数或者变量位于 st_shndx 指定的段,偏移 st_value 的位置。这也是目标文件中定义全局变量的符号最常见的情况
  • 如果符号是”COMMON“类型的。st_value表示该符号的对齐属性
  • 在可执行文件中,st_value表示符号的虚拟地址
符号类型和绑定信息(st_info)

该字段低 4 位表示符号的类型:

宏定义名 说明
STB_LOCAL 0 局部符号,对目标文件外的编译单位不可见
STB_GLOBAL 1 全局符号,对外部可见
STB_WEAK 2 弱符号

高 28 位表示符号的绑定信息:

宏定义名 说明
STB_NOTYPE 0 位置类型符号
STB_OBJECT 1 数据对象,如变量、数组
STB_FUNC 2 函数或者可执行代码
STB_SECTION 3 该符号是一个段
STB_FILE 4 一般是该目标文件所对应的源文件名
符号所在段(st_shndx)

如果符号定义在本目标文件中,那这个成员表示符号所在的段在段表中的下标;如果不在本目标文件中,或者一些特殊符号 st_shndx 的值有些特殊:

宏定义名 说明
SHN_ABS 0xFFF1 表示该符号包含一个绝对值。如文件名的符号就是此类型
SHN_COMMON 0xFFF2 表示该符号是一个”COMMON“块类型的符号。一般来说未初始化的全局符号定义就是该类型
SHN_UNDEF 0 表示该符号未定义。这个符号表示该符号在本目标文件被引用,但是定义在其他目标文件中

3.5.2 特殊符号

在用 ld作为链接器来链接产生可执行文件时,它会生成一些特殊的符号,这些符号并没有在源代码中定义,但是可以直接引用。一些典型的特殊符号如下:

  • __FILE__ 编译单元对应的文件名
  • __FUNCTION__ 符号所的函数

  • __executable_start 程序起始地址

  • _end 程序结束地址

3.5.3 符号修饰与函数签名

在 C 语言之前已具备很多用汇编实现的程序库,这些库的符号名和对应的函数/变量名是一样的。为了避免名字冲突,Unix C 的编译器规定,C 语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线”_“,这个办法在当时解决了大部分的符号名冲突的问题。

到后来 C++在设计时就考虑到了这一点,引入了命名空间特性,才彻底解决符号名冲突的问题。一组用来确定函数唯一性的关键信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息称之为函数签名( Function Signature)。函数签名用于识别不同的函数,C++使用特定的名称修饰方法,使得每个函数签名对应唯一一个修饰后的名称。

生成函数签名以及根据函数签名生成对应的名称的方法可能每个编译器的实现都不一样,导致不同厂家的编译器产生的库不能相互链接,这也是 C++二进制难以兼容的原因之一。

3.5.4 extern “C”

有时C++要和 C 兼容,C++有一个用来声明或定义一个 C 的符号的extern "C"关键字

1
2
3
4
5
6
extern "c" {
int var; //多行形式
int func(int);
}

extern "c" int var; //单行形式

C++编译器会将在extern "C"的大括号内部的代码当做 C 语言代码来处理,此时 C++的名称修饰机制就不会起作用。

3.5.5 弱符号与强符号

强弱符号是用来处理同一个符号在不同模块被重复定义的而出现的。对于 C / C++ 编译器来说,函数和以及初始化的全局变量是强符号,未初始化的全局变量是弱符号。不过强弱符号也可以用特定的修饰关键字单独指定。链接器会按照下列规则处理与选择被多次定义的全局符号:

  • 规则1:不允许强符号被多次定义;如果有多次重复定义,则链接器会报错
  • 规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号
  • 规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的个。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)
强引用和弱引用

目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用。

与之相对应还有一种弱引用,在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。

这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。

3.5.6 调试信息

目标文件里面还有可能保存的是调试信息。几乎所有现代的编译器都支持源代码级别的调试,比如我们可以在函数里面设置断点,可以监视变量变化,可以单步行进等,前提是编译器必须提前将源代码与目标代码之间的关系保存下来,比如目标代码中的地址对应源代码中的哪行、函数和变量的类型、结构体的定义、字符串保存到目标文件里面。

现在的ELF文件采用一个叫DWARF(Debug With Arbitrary Record Format)的标准的调试信息格式来保存调试信息。值得注意的是,调试信息在目标文件中一般占据很大的空间,有时候甚至比代码和数据本身大好几倍,所以当代码完成开发时,往往要用 release 模式编译,把调试信息去掉。

4 静态链接

4.1 空间与地址分配

如两个静态文件 A 和 B 链接成 AB,链接器会采取”合并同类项“的方式把两个文件相同的段合并起来,如 A 的代码段和 B 的代码段合在一起,形成一个新的代码段。最后文件 AB 的大小是根据 A 和 B 的所有段的大小来确定的。

合并的过程一般是两步:

  1. 扫描全部输入文件,获得它们各个段的长度,并把所有符号以及符号引用收集起来,形成全局符号表
  2. 用第 1 步获得到的信息,进行符号解析和重定位,调整文件中的各种引用地址

4.2 符号解析与重定位

4.2.1 重定位

符号在段内的位置是相对不变的,而且这个相对位置在编译时已经确定,所以只要段的起始地址确定,符号地址也随之确定。所以在多个段合并后,每个段的起始地址也确定下来,此时段内的符号地址能具体地计算出来。

完成空间和地址的分配后,链接器就进入了符号解析和重定位的步骤,这也是静态链接的核心内容。

4.2.2 重定位表

重定位的工作中最重要的一项数据结构就是重定位表,它用来保存如何修改相应的段里的内容的信息。每个要被重定位的地方叫一个重定位入口(Relocation Entry),重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置。

对于 32 位的 x86 系列处理器来说,重定位表的结构是一个 Elf32_Rel 结构的数组:

1
2
3
4
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel

具体的字段解释如下:

字段 说明
r_offset 对于可重定位文件来说,这个值是该重定位入口所要修正的位置的第一个字节相对于段起始的偏移;
对于可执行文件或共享对象文件来说,这个值是该重定位入口所要修正的位置的第一个字节的虚拟地址
r_info 这个成员的低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表中的下标。对于可执行文件和共享目标文件来说,它们的重定位入口是动态链接类型的

4.2.3 符号解析

平时在编写程序的时候常遇到的问题就是,链接时符号未定义。导致这个问题的原因很多,最常见的一般都是链接时缺少了某个库,或者输入目标文件路径不正确或符号的声明与定义不一样。

通过前面指令重定位的介绍,现在可以更加深层次地理解为什么缺少符号的定义会导致链接错误。其实重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

在链接器扫描完所有的输入目标文件之后,理论上所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。

4.2.4 指令修正方式

32 位的 x86 处理器支持的寻址方式特别多,很多是从 16 位开始一直兼容上来的。到现在已经用的不多,现在主要的还是以下两种:

  • 绝对近址32位寻址
  • 相对近址32位寻址

前面说到的 r_info 字段的 低 8 位表示重定位入口类型,具体如下:

宏定义 重定位修正方法
R_386_32 1 绝对寻址修正 S + A
R_386_PC32 2 相对寻址修正 S + A - P

A = 保存在被修正位置的值,这个位置由r_offset字段查询得到

S = 符号的实际地址,由 r_info 的高 24 位指定的符号的实际地址

P = 被修正的位置的第一个字节的虚拟地址

绝对寻址修正

一般进行绝对寻址修正时,就是直接把符号的地址赋值到引用该符号的地方。所以一般引用处的 A 值都是 0

相对寻址修正

相对寻址需要计算符号引用的地方和符号相距的距离,所以上述的公式可以先简化成 S - P,这里 S 就是符号地址,P 就是引用符号的地址。

x86 执行相对近调用指令时,后面参数的偏移量的计算公式是:目的地地址 - 源地址 - call指令长度。是不是看着很熟悉。和上面的 S - P 公式有点像了,这里就知道为什么要加上 A了。A 的值就是call 指令的长度,由编译器在编译时计算得出,在32位x86的CPU上A的值一般为-4

4.4 C++相关问题

  • C++的模板、虚函数表、外部内联函数可能在各种实例中出现,会形成大量冗余代码。所以C++的编译器把每个模板的代码放在一个单独的段里,重复的模板只会产生一个段,通过这样的方法来去除冗余
  • 全局构造与析构代码放在.init和.finit 段,确保能在main之前前执行构造方法,在main之后执行析构方法
  • C++的模板、多继承、命名空间等诸多特性导致符号的生成规则因素过多,ABI的兼容性难度很大

4.6 链接过程控制

  • 用命令行参数
  • 把链接指令放在目标文件里面
  • 用链接脚本控制链接过程

4.7 BFD 库

不同平台的目标文件格式千差万别,导致编译器和链接器很难处理不同平台的目标文件,特别是还具有跨平台特性的编译器来说,更是头疼。

所以业界出现了BFD(Binary File Descriptor library)项目,它能把目标文件抽象成一个统一的模型。