通常,编程语言分为编译型和解释型两大类,这里主要讨论编译型的语言,而且讨论的系统环境主要是linux。以c语言为例,一个c源程序(本质是文本文件)是怎么从.c
以及.h
文件到最终的可执行文件(elf
格式)的呢?这个几乎是常识了,主要经过预处理、编译、汇编、链接这几个步骤,如图所示:
接着再介绍一些编译过程中各个步骤的具体工作:
1、预处理 |
---|
递归的将头文件插入到其被include的地方 |
递归的展开宏定义 |
处理所有条件预编译指令,如#if 、#ifdef 等 |
删除注释 |
添加行号和文件名标识,以便后续产生调试用的行号等信息 |
注:
#pragma
是编译指令,会被预处理器保留,由编译阶段处理。
2、编译 |
---|
对预处理后得到的.i 文件进行词法分析、语法分析、语义分析、优化等操作,生成汇编文件 |
注:
编译本身也是一门学问,详见编译原理相关资料。
3、汇编 |
---|
将编译得到的.s (汇编)文件中的汇编指令转换为相应的机器指令(01序列),并进一步生成可重定位目标文件(.o 文件) |
注:
汇编语句本质上是机器指令的符号表示,和机器指令有着一一对应的关系,它们都属于机器级指令,构成的程序称为机器级代码。使用符号表示的目的在于便于记忆,提高程序的可读性。
4、链接 |
---|
将多个可重定位目标文件(.o 文件)合并以生成可执行目标文件。 |
注:
本文重点介绍链接以及相关内容,详情见下文。
链接器是怎么来的,或者说程序为什么需要链接,有一个非常直观的理由。随着软件越来越复杂,大型的软件项目的源程序规模庞大,需要组织在一系列的源文件中,且这些源文件往往由不同的工程师合作完成。但是最终发布的软件产品只有一个,也就是说多个源文件最终需要组合成一个可执行程序。因此,理所当然的需要将编译得到的多个目标文件进行链接这一关键操作。
那么链接器到底做了什么呢,只是把各个目标文件简单的篡在一起么?当然不是!我们知道,在汇编语言里,有符号的概念,一个符号
对应着一个地址
。在编译型的高级语言中,其变量名
实质上就对应着汇编中的符号。那么符号对应的那个地址到底是什么呢,是0x12345678还是0x87654321?这个地址当然不是随意确定的。关于如何确定符号对应的具体地址,答案是在链接之前无法确定,符号对应的最终地址需要由链接器来确定,这也是链接器的关键作用。
为什么说在链接之前无法确定符号对应的地址呢?先看一幅图:
各个目标文件之间有着复杂的符号引用关系,对于引用到其他文件的符号,当前文件是无法知道相应符号对应的地址的。不止如此,就是本文件的符号对应的地址也无法确定,因为当前文件无法确定有多少其他目标文件会参与链接;无法确定这些参与链接的目标文件中程序有多少、数据有多少;无法确定在链接时自己的程序和数据会被摆到哪里。这一系列的无法确定导致在链接发生之前,符号对应的绝对地址是无法确定的。不过,一段汇编程序内部的符号的相对地址是可以确定的,比如一条指令引用到一个局部的符号,此时可以不用记录绝对地址,而记录标号距离当前pc寄存器
值的偏移,这样就与绝对地址无关了。存在这样一些汇编指令,虽然引用了标号,但是没有涉及绝对地址,那么这些指令组成的代码被加载到任何内存地址处都能正确执行,这样的代码也因此被称为位置无关码
。
由于链接器是负责链接工作的,因此它自然很清楚有哪些文件参与链接,这些文件中数据有多少、程序有多少。最终每个文件的程序段摆在哪里,数据段摆在哪里,也是链接器决定并实施的。所以,只有链接器可以确定符号对应的地址,这也是链接器的职责。概括的说,链接器的操作步骤如下:
值得一提的是,汇编器生成的目标文件因为要参与链接,为了方便链接器,目标文件应该按照某种统一的格式组织起来(ELF格式),以便清晰的告诉链接器目标文件中程序有哪些、数据有哪些、符号有哪些等等。这些内容以及更多链接的细节会在后文介绍。
总结一下链接带来的好处:
在介绍目标文件的详细情况之前,在简单介绍一些术语,并概括性的介绍ELF格式文件的两种视图——链接视图、执行视图,为更详细的介绍做铺垫。
这里给出几种常见的目标文件格式:
目标文件格式 | 解释 |
---|---|
COM格式(*.com) | 仅包含代码和数据,被加载到内存的固定位置(不采用虚拟地址),用于DOS。 |
COFF格式 | 包含代码、数据、重定位信息、调试信息、符号表等信息,由一组严格定义的数据结构序列组成,用于System V UNIX的早期版本。 |
PE格式 | COFF的变种,PE的含义是可移植(Portable),可执行(Executable),用于Windows。 |
ELF格式 | COFF的变种,ELF的含义是可执行(Executable),可链接(Linkable),格式(Format),用于Linux等类UNIX操作系统。 |
这里主要关注用于Linux的ELF格式
的目标文件。因此下文出现的目标文件,默认指ELF格式的目标文件。目标文件可分为四类,共有两种视图。先说四类目标文件:
目标文件类别 | 解释 |
---|---|
可重定位目标文件(.o) | 由汇编操作得到,包含有代码、数据、重定位信息等,下文简称可重定位文件 |
可执行目标文件(a.out) | 由一个或多个可重定位文件经过链接操作得到,包含代码、数据、链接地址等信息,下文简称可执行文件 |
静态库文件(.a) | 是可重定位文件的归档,也即多个可重定位文件打个包(严格说不是ELF格式,但因为与可重定位文件关系密切,也列在了此处 ) |
动态库文件(.so) | 也称共享库文件,是一种特殊的可执行文件,能在装入内存或运行时自动被链接 |
注:
linux中实质上不存在文件的后缀(扩展名),只是为了方便人去查看,所以使用了.o
、.out
等后缀,这些后缀有着约定俗成的含义,比如.a
用于表示这是一个静态库文件,但这只是对人而言。对操作系统来说,是不管后缀的。
可见,目标文件虽然有四类,但只有两种视图:链接视图(用于链接)、执行视图(用于操作系统加载执行)。显然,链接视图说的是可重定位文件,执行视图说的是可执行文件。下面进一步介绍两种目标文件的组成,即两种视图的样貌。先给一幅轮廓图:
再解释两种文件的组成:
节(section)
,同一个节内的数据有着相同的特征,比如源文件中显式初始化为非0的全局变量或静态局部变量会被集中到一个名为.data
的节中。除了源文件中程序、数据会被集中到一个个的节中,汇编器还会创建一些节来描述符号信息(符号名、位置、大小)、重定位信息(指出哪些符号引用处需要重定位)。每个节点的基本信息由文件中的节头表
记录。此外,可重定位文件中的代码和数据地址都从0开始,只有经过链接后,才拥有确定的位于虚拟内存空间的链接地址。段(segment)
,一个段由多个节组成,同一个段中的多个节有着相同的性质,比如一个名为.text
的存放程序的节,和一个.init
的存放程序的节,可以合并为一个段——代码段
。文件中有一个段头表(也称程序头表)
,用于记录每个段的基本信息,以及告诉系统如何创建进程映像。比如说,记录着这个段由哪些节组成、段的位置和大小、执行时这个段需要被加载到虚拟内存空间的何处(链接地址)等。本节的最后,给出一幅图,以展示从可重定位文件,到链接为可执行文件,再到被操作系统加载执行的全过程:
可重定位文件主要由ELF头
、各种节
、节头表
等三部分组成,如下图所示(仅列出了常见的节):
注1:
虽然上图中各个节以及节头表按照一定的顺序排列,但实际上除了ELF头部要放在文件开头之外,其他部分并无顺序的规定。
注2:
在c语言中,未初始化的全局变量或静态局部变量默认初始化为0,再加上,显式初始化为0的全局变量或静态局部变量,这两部分数据共同构成了.bss节
。由于已经可以确定这些变量的初始值为0,因此没有必要在可重定位文件/可执行文件中为.bss节分配空间,只需要记住这个节的大小(节头表中有记录)以及节中的符号信息(符号表中有记录)等。这样做的好处是显然的:节省磁盘空间。不过,在可执行文件被加载执行时,需要根据文件中记录的.bss节为相关变量分配内存,然后将分配出来的内存清0(.bss节中变量初始值为0)。
ELF头为与ELF文件的开始,包含整个文件的结构信息,是访问ELF文件时所不可或缺的。具体结构根据32位操作系统和64位操作系统分为32位版本、64位版本。在linux系统中执行man elf
即可看到ELF头结构的定义:
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
ElfN_Addr e_entry;
ElfN_Off e_phoff;
ElfN_Off e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum;
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
} ElfN_Ehdr;
其中ELFN
的N
为32时表示32位的版本,为64时表示64位的版本。接下来解释一下各个字段的含义:
linux提供了访问ELF格式的文件的命令:readelf
,通过不同的选项可以访问到ELF格式文件的不同组成部分,比如使用readelf -h pathname
可以访问到ELF头,如下图:
对于可重定位文件中的每一个节,都有同一类型的一个结构来记录该节的一些信息,对于32位系统来说,上述结构的类型是Elf32_Shdr
。对于多个节来说,就会有多个Elf32_Shdr,这些结构像数组一样排列起来,就形成了节头表。
具体的,Elf32_Shdr的定义如下:
typedef struct {
uint32_t sh_name;
uint32_t sh_type;
uint32_t sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
uint32_t sh_size;
uint32_t sh_link;
uint32_t sh_info;
uint32_t sh_addralign;
uint32_t sh_entsize;
} Elf32_Shdr;
64位系统的Elf64_Shdr
,和Elf32_Shdr有着同样的字段,不过部分字段的长度不同,32位是4字节,64位是8字节,因此不再赘述。接下来解释一下各个字段的含义:
若想知道可重定位文件中的节头表的信息,可以使用readelf -S pathname
来获取。如下图所示:
当我们得到ELF头以及节头表的信息之后,就可以获知整个可重定位文件的节区分布。过程大概是这样,先从ELF头部得到节头表的位置以及.shatrtab节的位置,节头表中记录个各个节的偏移、大小等信息,而.shatrtab节记录着各个节区的名称字符串,这样一来,我们就能知道一个节区在哪里、有多大、叫什么,也就能获知所有节区在文件中的分布。如下图所示:
图中,一些节区因为对齐约束,导致ELF文件中存在着一些间隙。
可执行文件的格式和可重定位文件非常相似,但也有一些不同。先看一副可执行文件的结构示意图(仅列出了常见的节):
接下来概括一下可执行文件格式和可重定位文件格式的不同之处:
ELF头中的e_entry字段
该字段给出执行程序时第一条指令的(链接)地址,而在可重定位文件中,此字段为0。
程序头表
可执行文件多一个程序头表,也称段头表。这个段头表是一个结构数组,数组中的每一项都用来记录相应段的一些信息。
.init节
可执行文件多了一个.init节
,用于存放_init函数
。当可执行文件被加载执行时,该函数在main函数之前运行,用来做一些初始化工作。
.rel.data、.rel.text
可执行文件少了两个节:.rel.data、.rel.text。这两个节在可重定位文件中用于存放重定位相关的信息,而可执行文件已经进行过重定位了,自然不再需要这两个节。
程序头表,或称段头表,在前面已经多次提到,这里具体解释一下它的格式。首先,程序头表是一个结构数组,该数组的每一项都是一个类型为ElfN_Phdr
的结构,其中N根据系统位数分别有32、64,Elf64_Phdr
与Elf32_Phdr
的不同之处在于字段在结构体中的排列顺序以及部分字段的大小。每个结构描述了一个段或者系统准备程序执行所必需的其它信息。一个段包含有一个或者多个节。以Elf32_Phdr
来说明:
typedef struct {
uint32_t p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
uint32_t p_filesz;
uint32_t p_memsz;
uint32_t p_flags;
uint32_t p_align;
} Elf32_Phdr;
各个字段的含义如下:
注:
p_filesz
表示的是段在可执行文件的大小,p_memsz
表示的是该段被加载到内存时(如果需要被加载的话)需要占用的内存大小,两者通常相等,但也有不等的时候,比如.bss
节和.data
节组成数据段,那么.bss节是不占用可执行文件的空间的,但被加载到内存时就需要分配空间。对于p_memsz > p_filesz
的情况,p_memsz - p_filesz
的部分需要分配内存且清0。而p_memsz < p_filesz
的情况是不允许的。
Elf32_Phdr中与构建进程的内存映像密切相关的字段有p_offset
、p_vaddr
、p_filesz
、p_memsz
,它们之间的关系可以用下图表示:
同样的,readelf
也提供了专门查看程序头表的选项,执行readelf -l pathname
即可查看ELF文件的程序头表。
经过上文的介绍,我们已经了解了链接的原材料(可重定位文件)和链接的结果(可执行文件)。但还有很多非常关键的内容没有介绍,主要是:可重定位文件如何一步步的被加工成静态库文件、动态库文件、可执行文件,也即链接器加工可重定位文件时的更多细节。在这之前,还需要介绍一下有关符号的内容以作为铺垫。
符号的名称由连续的字符组成,且对应着一个地址,以及从这个地址开始的一段内存空间,这段内存空间的大小则视符号的语义而定。在汇编语言中,可以非常简单的定义一个符号:
.global _start
_start:
......(汇编语句)
其中_start
就是一个符号,它对应一个内存地址,这个地址就是其后紧随的汇编语句的链接地址(或者说被加载内存时所在的虚拟地址)。.global
意味着将_start
声明为一个全局可见的符号,即该符号能够被其它源文件引用。
再c语言中符号通常对应着一个函数或变量(全局变量以及静态局部变量)。比如:
int a;
int fuc(void)
{
return 0;
}
其中定义了符号a
和符号func
。符号a对应着一个整形变量,换句话说,符号a对应着一个地址(整形变量的地址),以及从这个地址开始的4字节内存空间(整形变量通常占4个字节的内存);符号func对应着一个函数,换句话说,符号func对应着一个地址(函数的地址),以及一段内存空间(函数所占的内存空间,大小视函数而定)。
可以说符号标识了一个变量或函数,但符号本身并不属于一个可执行程序的代码或数据,当程序被加载执行时,是不需要把符号信息也加载到内存的。毕竟程序执行过程中访问一个变量时,不可能根据变量名去找这个变量对应的地址再去该地址处访问数据,这样效率也太低了,程序在内存中执行时,已经没有变量名信息了。那么程序是如何做到正确的访问变量或函数呢?简单的说,符号在源文件中的出现可以分为两种情况,一种是定义(含声明),一种是引用。引用一个符号时才需要去相应的地址处访问数据,而每一次引用都会在程序编译时被记录下来,在链接时,确定了符号的地址后,链接器就会根据记录的引用信息去每一个引用发生的地方写入确定下来的地址(重定位),这样地址也就有了。如此,程序运行的过程中也就不再需要符号信息了。
何谓符号的定义和引用呢?这个问题猜也猜到了,定义变量或函数时是定义,访问变量或函数时是引用:
注:
局部变量(非静态)位于栈上,对局部变量的访问通常按照距当前栈顶的偏移来寻址,栈一直在变化,天知道局部变量在内存中的地址到底是什么。局部变量的实现机制不同于全局变量和静态局部变量,因此局部变量与符号无关。
源文件中定义的符号的作用范围不尽相同,比如静态局部变量只能作用于局部,static
修饰的全局变量或函数只能在本文件内使用,而普通的全局变量和函数是全局可见的,其它源文件只需声明即可使用。同样的,本文件也可以声明定义自其它文件的变量或函数,进而引用之。因此一个源文件中定义(或声明)的符号可以分为三类:全局符号
、局部符号
、外部符号
。举例如下:
后面会看到,汇编时,不同类型的符号的记录信息存在一些区别。
对于全局符号(包括声明的外部符号)来说,因为其全局可见的属性,如果发生了这样的情况:a.c定义了全局变量test,b.c也定义了全局变量test,这时编译器会怎么处理对test的引用呢(怎么确定引用的到底是哪个test),还是会直接报链接错误?要想搞清楚这个问题,就不得不说一说全局符号(包括声明的外部符号)的强、弱属性。
先说哪些符号属于强符号,哪些符号属于弱符号:
举例说明:
链接器在执行符号解析动作时,会确保符号的唯一性,即一个符号仅对应一块内存。倘若无法确保这一点,编译器就会报错。这里给出链接器对符号的解析规则:
gcc –fno-common
进行链接时,会告诉链接器在遇到多个弱定义的全局符号时输出一条警告信息。使用全局变量时,如果不能充分注意强弱符号的解析规则,那么容易写出一些存在问题的程序。这里给出几个例子:
从上述例子可以看出,多重定义全局变量可能会造成一些意想不到的错误,特别是多重不同类型的弱定义。这些错误往往在运行时才会暴露出来,而链接器不会报错。倘若在一个大型软件项目中,这种隐晦的错误通常会使得软件在远离错误的引发处出错,非常难调试。因此在项目中应该尽量避免使用全局变量,尤其是在多人合作完成一个软件项目时。
如果因为项目的需要不得不使用全局变量时,需要对全局变量的使用进行严格的控制,遵循以下规则:
汇编器在生成可重定位文件时,会将源文件中的符号信息记录到可重定位文件的符号表,也就是.symtab节
。本节主要介绍记录的符号信息具体包括哪些内容,以及这些信息是如何存放在.symtab节。
首先,对于每一个符号,都有一个同类型的结构来存放其信息;然后,这些结构按照数组的方式排列起来,就构成了符号表。所述结构也会根据系统位数分为32位版本和64位版本,这里以32位版本为例,结构的定义为:
typedef struct {
uint32_t st_name;
Elf32_Addr st_value;
uint32_t st_size;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
} Elf32_Sym;
各个字段的含义如下:
字段 | 含义 |
---|---|
st_name | 符号名,本质是整数,表示符号名字符串的起始字符在.strtab节中的偏移 |
st_value | 虚拟地址(可执行文件);对应函数/变量所在节中的偏移(可重定位文件) |
st_size | 符号对应的函数/变量的大小,没有大小或大小未知时该字段为0 |
st_info | 指出符号的类型(函数、数据等)以及符号的绑定属性(全局、局部等) |
st_other | 指出符号的可见性(详见man手册) |
st_shndx | 对应函数/变量所在节在节头表中的索引,或其他情况 |
注:
st_shndx字段的其他情况具体指:ABS表示不该被重定位;UND表示未定义;COM表示未初始化数据(符号对应大小在.bss节),因为.bss节不占用ELF格式文件的空间,所以此时st_value不再表示节内偏移,而是表示对齐要求。
使用命令readelf -s pathname
可以查看ELF格式文件的符号表,符号表信息通常组织如下:
上文概括地介绍过链接操作,这里在了解了可重定位文件、可执行文件以及符号表等知识后,进一步对链接操作进行介绍:
我们可以把软件开发中常用的一些函数写好,制作成库文件,这样以后用到时直接包含相应的头文件即可使用。静态库就是一种形式的库,在linux中静态库文件通常以.a
作为后缀,当然实际上这个后缀是没有意义的,只是方便告诉用户这是一个静态库。静态库实质上是一系列可重定位文件的集合,也就是把一些.o
文件打个包,就可以得到静态库。
c语言中常用的静态库有libc.a
(c标准库)以及libm.a
(c数学库)。libc.a中包含有I/O、存储分配、字符串处理、时间和日期、随机数生成、定点整数算术运算等函数,libm.a中包含一些数学运算的函数,如sin, cos, tan, log, exp, sqrt等。
除了使用c语言的标准库和数学库,我们也可以制作自己的静态库。静态库的制作不是使用链接器,而是使用归档器ar
,如下所示:
ar命令常用的选项有:
选项 | 含义 |
---|---|
r | 将.o文件插入静态库中,如果已有同名文件存在,则删除已存在的加入新的 |
c | 表示创建静态库,如果静态库不存在总会创建,但不加选项c会引发一条警告 |
s | 添加一个索引到静态库,如果已存在则更新之(不怎么理解…) |
x | 从静态库中提取成员(一个.o文件),不指定成员则库中所有成员都会被提取 |
在我们自己制作静态库时,最好避免以下两种极端做法:
当我们需要使用静态库中的函数时,需要这么做:在编码的时候包含相应的头文件(或者知道原型的情况下自己直接声明);在链接的时候采用静态链接(-static
)并指定相应的库文件。如此,链接器就会从静态库包含的一系列.o文件中,找出被引用的符号(函数/变量)所在的那个.o文件,然后将其与我们自己编写的.o文件链接起来,进而得到可执行文件。
举个例子:
值得一提的是,上例中链接时,我们指出自己制作的的静态库,但是却没有指出libc.a,然而我们确实是调用了libc.a中的printf函数。这是因为linux中有一个名为D_LIBRARY_PATH
的环境变量,在链接时如果出现没有找到的符号,gcc就会去这个环境变量指定的目录(通常是/lib、/usr/lib)下寻找-l
选项指定的库文件。比如-lm
就会指定libm.a
。上例中没有-lc
(指定libc.a),这是因为-lc是gcc的默认选项,其实加上也不会有问题。
仍以4.2节中的例子介绍静态链接时符号解析的过程。链接器首先会创建3个集合:
集合 | 作用 |
---|---|
E | 保存将被合并以组成可执行文件的所有可重定位文件的集合 |
U | 保存当前所有被引用且未被解析(未找到定义)的符号的集合 |
D | 保存当前所有定义的符号的集合 |
接着按以下过程进行符号解析:
就本例而言,最终解析的结果为:E
中有main.o、myproc1.o、printf.o及其调用的模块;D
中有main、myproc1、printf及其引用的符号。
现在我们已经知道,静态链接时要指定待链接的可重定位文件以及静态库文件,否则链接器找不到一些未定义的符号,也就无法完成链接。这里还有一个问题值得说明,那就是静态链接时可重定位文件以及静态库在链接命令中出现的顺序。这个顺序不是随意的,仍以上例说明,加入执行如下链接命令:
gcc –static –o myproc ./mylib.a main.o
由于mylib.a出现在main.o前面,因此首先扫描mylib.a,又因为mylib.a是静态库,应根据当前U中的符号(未解析符号)来遍历,以取出链接所需的目标文件,而开始时U为空,故其中两个.o模块都不能被加入E中所以被丢弃。然后,链接器扫描main.o,将myfunc1加入U,但直到最后myfunc1都不能被解析(找不到定义),因为定义该符号的模块myproc1.o之前已经遍历过了,不会再去遍历。
之所以会出现上述情况,根本原因在于链接算法:
解决这个问题的办法是根据待链接的可重定位文件和静态库之间的依赖关系,将依赖的放在命令行的前面,被依赖的放在后面。再举一些例子以作说明:
上文已经概括性的介绍过重定位具体的三个步骤,这里仍旧会按照上述的三个步骤进行介绍,只不过会提及更多细节。在开始介绍之前,先说一下记录重定位信息的.rel.text节
和.rel.data节
。这两个节之前只简单的提到过,只说过它们记录的是用于重定位的信息,那么到底怎么个记录法呢?
汇编器遇到每一个符号引用时,都会生成一个重定位条目,位于.data节
的符号引用对应的条目记录在.rel.data节;位于.text节
的符号引用对应的条目记录在.rel_text节。换句话说,.rel.text节和.rel.data节就是由一个个记录着引用信息的重定位条目按数组的格式排列成的。两个节中的重定位条目有着相同的格式,以32位系统为例:
typedef struct {
Elf32_Addr r_offset;
uint32_t r_info;
} Elf32_Rel;
为了表意更清楚,将上述结构重新表达如下:
typedef struct {
int offset;
int symbol:24,
type:8;
} Elf32_Rel;
接下来解释该结构各个字段的含义:
字段 | 含义 |
---|---|
offset | .data节和.text节中需要重定位的地方在节内的偏移 |
symbol | 引用的符号在符号表中的索引,即该符号的链接地址需要填入重定位处 |
type | 重定位的类型 |
注:
重定位的类型通常有两类,以x86为例,一类是绝对地址重定位(R_386_32),一类是PC相对地址重定位(R_386_PC32)。绝对地址指的是最终的链接地址,相对地址指的是需要重定位的符号引用对应的链接地址距离当前PC指针的偏移,也就是说写入需要重定位的地方的是这个偏移地址。
linux提供了命令readelf -r pathname
用于查看可重定位文件中的重定位条目,输出信息格式如下:
Offset Info Type ...
00000000003a 000500000002 R_X86_64_PC32 ...
000000000044 000b00000004 R_X86_64_PLT32 ...
...
接下来,按照重定位的三个步骤展开介绍重定位的一些细节:
① 合并相同的节
将符号解析后集合E的所有目标模块中相同的节合并成新节,比如,所有.text节合并作为可执行文件中的一个.text节,所有;所有.data节合并作为可执行文件中的一个.data节。如下图所示:
② 对定义的符号进行重定位(确定符号的链接地址)
链接器在合并同类的节时,前后排列情况它自己是清楚的,这个时候只要再知道数据和代码被加载到内存时的起始虚拟地址,就可以确定每个符号的链接地址(在虚拟地址空间中的地址)。为函数确定首地址后,就可以进一步确定每条指令的地址。总之一切地址就可以知道了。那么加载时的起始虚拟地址可以知道吗?答案是肯定的,这个地址通常是固定的(可能不同平台会有差异,但都是固定的):
此外,在符号的地址确定后,可执行文件中的符号表里,每个条目的st_value
成员存放的就是相应符号的链接地址,而不再是直接的节内偏移。
③ 对符号引用进行重定位(将上一步确定的地址填入符号引用处)
合并同类节之后,.data节、.text节、符号表等都被统合了,因此重定位条目中的一些字段也将被维护,以记录统合后的相关信息。具体的,offset
字段记录需要重定位的地方在统合后的数据段、代码段中的偏移,symbol
字段记录引用的符号在统合后的符号表中的索引。
至此,完成重定位所需的一切信息都已经具备了,接下来要做的非常简单:根据symbol
字段找到符号表中相应的表项,然后获取相应符号的链接地址(将该表项的st_value
字段值),之后把这个地址填写到offset
指示的偏移处。
上述解释可能还是有些抽象,这里再举两个例子辅助说明:
例1:数据段,绝对地址重定位
例2:代码段,PC相对地址重定位
注:
1、例子中使用的是x86指令集,指令集长度可变,32位地址能够放入指令中。但是,比如arm指令集是32位的定长指令集,地址无法放入一条指令中。因此通常使用文字池的方式存放32位地址。相应的,重定位操作在细节上会有不同(offset字段不会指向指令中了,而是指向文字池)。
2、采用PC相对地址重定位还是绝对地址重定位,通常由编译器根据指令的情况来选择,比如call指令采用的是相对跳转的方式,那么自然就应该使用相对地址重定位。
通常我们在shell中直接输入可执行文件(程序)的pathname,然后回车,即可加载并执行一个程序(如果程序需要参数,那么还需要输入参数),比如执行一个位于当前目录下,文件名为myproc
的程序:
# ./myproc
那么这一切的背后究竟发生了什么呢?shell会先创建一个子进程,然后在子进程中加载要执行的程序,具体如下:
上图中所提的加载器非常关键,加载器(loader)根据可执行文件的程序(段)头表中的信息,将可执行文件的代码和数据从磁盘“拷贝”到存储器中(实际上不会马上拷贝,暂时先建立内存映射关系)。加载后,将PC设定指向入口点(即符号_start处,先做一些初始化工作),最终执行main函数:
静态库存在着一些缺点,比如静态库中的一个函数,在静态库中存在一份,链接后在引用它的程序中也存在一份,加载后在内存中也存在一份。如果一个静态库中的函数经常被各种程序引用,那么就会造成磁盘和内存中同时存在多份的情况,导致空间的极大浪费。
而动态库(也称共享库)就是为了解决这个问题设计的。所谓共享,反应在两个方面:
引用了共享库的可执行程序在加载或者运行时,加载器会使用动态链接器将可执行程序引用的共享库中相应的目标模块加载到内存(如果当前物理内存中尚不存在该目标模块的话),并完成与该可执行程序的链接,相对于静态链接,这种链接称为动态链接。共享库可以被加载到任意的内存地址处,或者说存在于物理内存的唯一一份共享库,可以被不同进程映射到不同的虚拟地址处,这都不会影响共享库程序的运行。本节就将介绍共享库的相关机制,从库的制作开始。
动态库的制作使用的是gcc
命令,需要加两个特殊的选项:
其中-fpic
选项指示编译器生成与位置无关的代码,这部分内容下文会做更多介绍;-shared
选项指示链接器(ld)创建一个共享的目标文件(动态库文件)。
假如我们编写的程序需要引用我们创建的mylib.so,那么需要在链接时指出mylib.so的位置(pathname)。同静态链接类似,c库也有动态版本,如libc.so
、libm.so
,引用到这些库也需要通过-l
选项指出。当然,因为-lc
是默认选项的缘故,引用libc.so无需显式指出,不过不作为默认选项的一些库就需要指出了:
从上图可以看出,即便是采用动态链接的方式链接程序,静态链接器(ld)也发挥了作用。创建可执行文件时,静态链接器复制了一些重定位和符号表信息到可执行文件,为后续动态链接提供必要的信息,但动态库文件中的代码和数据没有被复制到可执行文件中。
加载myproc时,加载器发现在其程序头表中有.interp
段,其中包含了动态链接器(ld-linux.so
)路径名,因而加载器根据指定路径加载并启动动态链接器运行。动态链接器完成相应的重定位工作后,再把控制权交给myproc。
-fpic
选项指示编译器生成与位置无关的代码,简称位置无关码(Position Independent Code, PIC)。所谓位置无关码,指的是加载到任何内存处都可以正常运行的代码。在上文的1.3节中已经提到过位置无关码,不过上文所说的位置无关码主要是由不使用绝对地址的指令构成的代码,这个限制未免有些大(好处是无需动态链接器的介入),无法让编译器只使用这样的指令完成编译。实际上,抛开这项限制我们仍然可以构建位置无关码,但这需要动态链接器做一些重定位的工作。
到底使用了怎样的机制来构建位置无关码呢?我们按照这么几种情况分别介绍:
编译器在生成PIC时,在数据段的开始处创建了一个表,称为全局偏移表(GOT
)。不妨把这个GOT看成是一个指针数组,每一个表项存放的都是一个地址。为了说清楚问题,不妨设当前模块会引用一个外部定义的整形变量b:
static int a;
extern int b;
void bar()
{
a=1;
b=2;
}
那么GOT中就会有一项存放变量b在内存中的真实地址(实际被加载到的虚拟内存处),当然在尚未进行动态链接时这个地址是不知道的,而动态链接器的一项工作就是要修改相应的表项为b的地址。而在代码段中访问变量b时,由于代码段、数据段、GOT都是在一起的,因此代码段中访问b的代码距离GOT中b对应的表项的相对偏移是确定的(无论被加载到哪里),因此访问时先根据偏移找到相应的GOT表项,进而得到变量的真实地址,再进行访问。可以看到,这一层间接的引入确实实现了PIC,但也加大了访问开销。
为了更清楚的阐述,再看一幅图:
上图描述了两个进程同时将两个共享库映射到不同的虚拟地址空间的情况,保持了代码段的共享,同时各自有各自的数据部分,再虚拟空间中保持同一个共享库的代码段和数据段连续,从而保证了offset
不会发生变化。接着,使用之前所说的GOT,便可以访问到外部变量b。不难看出,共享库既能够共享,同时也是PIC,因此能够实现非常灵活的共享。
直到这里,我们还没有讨论关于动态链接器工作的一些细节。比如,动态链接器怎么将GOT的表项和引用的符号对应起来。实际上,类似于静态链接,动态链接也会生成重定位表,每个表的条目会记录引用的符号以及相应GOT表项的位置。以图中例子说明,加载共享模块1时,发现有对外部变量b的引用,并在共享模块2中找到了b的定义,加载并确定b的地址后,利用相应的重定位表条目找到相应的GOT表项,并将b的地址填入其中。所述的重定位表的节名称为.rel.dyn
,可以认为这个节就相当于静态链接中的.rel.data
。相应的,还有.rel.plt
相当于静态链接中的.rel.text
。
实现对外部函数的位置无关引用也可以采取和上一小节类似的方法,即在GOT表项中存储外部函数的地址。但实际上GCC并没有这么做,而是采用了一种名为延迟绑定(lazy binding)的技术。考虑到,一个应用程序的一次运行中,很多外部函数的引用不会真的跳转,因此没必要一开始就把重定位全部做完。延迟绑定能够实现将过程地址的绑定推迟到第一次调用该过程时。
在介绍这项技术的实现细节之前,先介绍一下有关GOT以及过程链接表(PLT
)的一些基本事实。GOT上文就已经简单说过,这里做一些补充。GOT的前三项是固定的,表项及各自的含义如下:
GOT表项 | 含义 |
---|---|
GOT[0] | .dynamic 节地址,该节中包含动态链接器所需的基本信息,如符号表位置、重定位表位置等 |
GOT[1] | link_map 的地址,这是一个链表,每个表项都记录着一个已加载共享库中的函数的地址 |
GOT[2] | _dl_runtime_resolve 的地址,这是一个位于ld-linux.so 模块的函数,功能是解析第一次被调用的函数的地址,并将地址填入相应的GOT表项 |
PLT位于代码段的开始,可以看成一个数组,每个元素16个字节,其实每个元素是几条汇编指令。除PLT[0]比较特殊外,PLT的其余表项对应着一个函数引用,也就是说调用函数func时,只要跳转到func在PLT的对应表项,接着该表项中的指令会负责最终跳转到func对应的GOT表项记录的地址,也就是func的地址。或许有人会问,为什么不直接跳转到相应GOT表项记录的地址,而要加一层间接呢?因为PLT表项的指令会参与延迟绑定,毕竟开始的时候(还未完成延迟绑定),相应的GOT表项可没有记录func的地址。下面给出PLT表项的含义:
PLT表项 | 含义 |
---|---|
PLT[0] | 负责跳转到_dl_runtime_resolve |
PLT[1] | 负责跳转到可执行程序的初始化函数,该函数负责初始化执行环境,调用main函数以及处理main函数的返回值 |
PLT[x] | x >= 2,负责跳转到被某个用户函数调用的共享库函数 |
铺垫完毕,下面介绍延迟绑定的一些细节,仍以一幅图辅助说明:
在第一次调用外部函数func
时,有以下几个步骤:
步骤 | 作用 |
---|---|
① | 通过相对偏移(加载到哪里都不会改变)跳转到func对应的PLT表项(本例中为PLT[2]) |
② | 跳转到func对应的GOT表项所记录的地址处,初始时,相应GOT表项记录的就是该跳转指令的下一条指令 |
③ | 将func函数的ID(对func的引用对应的重定位表项在重定位表.rel.plt中的索引)推入栈,为调用_dl_runtime_resolve做准备(传参),然后跳转PLT[0] |
④ | 将&GOT[1]推入栈(传参),然后跳转_dl_runtime_resolve |
⑤ | _dl_runtime_resolve根据参数来确定func的运行时位置,然后用这个地址重写func对应的GOT表项(本例中为GOT[3]) |
⑥ | 调用func函数 |
⑦ | func执行完后返回,返回到call func的下一条指令处继续执行 |
注:
本文不会介绍_dl_runtime_resolve的细节(事实上,目前我也不知道这个函数的细节),仅仅说一下我对该函数如何完成重定位的一些猜想:根据上述内容,我们会传给该函数两个参数,一个是func函数的ID,一个是&GOT[1]。根据&GOT[1]可以确定&GOT[0],进而找到符号表、重定位表;而根据func的ID,可以找到具体的重定位表的表项,从而找到重定位的具体位置,以及func在符号表中的表项,接下来遍历link_map,找到func的运行时地址,然后填写到之前确定的重定位的具体位置,从而完成重定位。
至此,首次的func调用完成,并且func对应的GOT表项中已经保存了func的运行时地址(延迟绑定工作完成)。可以看到,为了实现延迟绑定,首次调用的开销是比较大的。幸运的是,下一次再调用func时只需要两条指令即可调用到,如下图所示:
至此,对延迟绑定的介绍完成!
对内部符号的位置无关引用比处理外部引用要容易一些,比如可以使用4.3.3节中介绍的PC相对地址重定位的方式实现,当然也可以采用5.3.1节和5.3.2节介绍的方式,也就是和处理外部符号的引用的方式一样,进而实现对内部符号的位置无关引用。原理前面都介绍过了,因而此除不再赘述。
5.2节中已经提过,采用动态链接的方式链接生成可执行文件时,会先使用静态链接器(ld)复制一些重定位和符号表信息到可执行文件,为后续动态链接提供必要的信息,但动态库文件中的代码和数据没有被复制到可执行文件中。
非常关键的是动态链接生成的可执行文件中会含有一个名为.interp
的段,该段保存的是动态链接器(ld-linux.so
)的路径名,如下(执行readelf -l a.out):
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000001f8 0x00000000000001f8 R 0x8
INTERP 0x0000000000000238 0x0000000000000238 0x0000000000000238
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
......
因此,在shell环境下运行./a.out
执行程序时,操作系统的加载器就可以根据.interp
段中的路径名加载并启动动态链接器。动态链接器则会完成相应的重定位工作(重定位对变量的引用,而对函数的引用采用延迟绑定技术实现重定位),然后执行初始化函数,最终调用到main函数,执行用户代码。
linux也支持运行时链接,可通过动态链接器接口函数在运行时进行动态链接。包括linux在内的类UNIX系统中的动态链接器接口函数有dlopen
、dlsym
、dlerror
、dlclose
等,这些函数的声明位于头文件dlfcn.h
。一个具体的运行时链接的例子如下:
接#include <stdio.h> #include <dlfcn.h>
int main()
{
void *handle;
void (*myfunc1)();
char *error;
/* 动态装入包含函数myfunc1()的共享库文件 */
handle = dlopen("./mylib.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
/* 获得一个指向函数myfunc1()的指针myfunc1*/
myfunc1 = dlsym(handle, "myfunc1");
if ((error = dlerror()) != NULL) {
fprintf(stderr, "%s\n", error);
exit(1);
}
/* 现在可以像调用其他函数一样调用函数myfunc1() */
myfunc1();
/* 关闭(卸载)共享库文件 */
if (dlclose(handle) < 0) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
return 0;
}
本文介绍了一些有关程序链接的知识,主要包括目标文件、静态链接、动态链接等。主要是对南大袁春风老师的计算机系统基础课程的笔记,当然也有查阅的一些其它资料。链接的细节非常多,本文也没能阐明所有的细节,需要获知更多细节,还得查阅更多相关资料。由于本人水平有限,如果记录有出错的地方,望不吝指正,不胜感激^_^!
[1] 南大袁春风教授的计算机系统基础课程
[2] CSAPP