ELF分类
ELF是一种文件格式,可以使用file
命令查看具体的文件格式类型,它主要划分为4大类
ELF文件类型 | 说明 | 实例 |
---|---|---|
可重定位文件 | 包含了代码和数据,可以被用来链接成为可执行文件或共享目标文件 | Linux的目标文件(.o) |
可执行文件 | 包含了可以直接执行的程序 | Linux的/bin/bash文件 |
共享目标文件 | 包含了代码和数据,可以通过动态链接器将几个共享目标文件与可执行文件结合从而作为进程映像的一部分 | Linux的动态库(.so) |
核心转储文件 | 当进程意外终止时,系统可以将进程的地址空间的内容以及终止时的一些其他信息转储其中 | Linux下的core dump |
可以通过objdump
,readelf
等命令查看ELF文件的结构以及其中的具体内容
ELF文件结构
ELF文件的开头是一个文件头,它描述了整个文件的文件属性:文件是否可执行、是静态链接还是动态链接、入口地址(如果是可执行文件)、段表位置和长度、目标硬件、目标操作系统等信息。
文件头后面就是各个段的内容了,一般编译后执行语句都编译成机器代码,保存在.text段;已初始化的全局变量和局部静态变量(全局变量与局部静态变量在堆上分配空间因此地址不会发生变化)保存在.data段;未初始化的全局变量和局部静态变量保存在.data段
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据,之所以要分为两种段而不是混杂地放在一个段中主要有三个原因:
- 数据可以被读写而指令是只读的,划分为两种段可以方便地在装载时映射到两个虚拟内存区域并且将各自的权限分别设置为读写和只读从而防止程序的指令被有意或无意地改写
- CPU的L1缓存一般被设置为指令缓存和数据缓存两块,划分为两种段可以提高缓存命中率
- 当系统中运行着多个程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份该程序的指令部分,这个特性在动态链接中被广泛地用到
除了.text,.data,.bss外还有一些其他的段,包括:.rodata,.comment,.debug等,它们都是以“.”作为前缀的,应用程序可以使用一些非系统保留的名字作为段名,比如我们可以通过objcopy
命令在ELF文件中插入一个“music”的段,里面存放了一首MP3音乐,当ELF文件运行起来以后可以直接读取这个段播放MP3
在各个段之后就是段表了,它描述了文件中各个段(.text .data .bss等)的段名、段长度、在文件的偏移位置、读写权限以及段的属性等
最后是ELF中辅助的结构,包括:字符串表、符号表等,这些辅助结构也可以看作是一类段
段表
段表中描述了ELF中各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性,每个段的描述信息实际用Elf32_Shdr的描述符结构记录,数据结构如下:
typedef struct
{
Elf32_Word sh_name; // 段名,段名本质上是个字符串,但是由于字符串长度不一定,因此将具体的字符串存放在一个叫做.shstrtab的字符串表中,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
重定位表
链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些对全局变量或者全局函数的引用的位置,对于每个存在需要重定位的代码段或者数据段,都会有一个相应的重定位表,比如.rel.text就是针对.text段的重定位表,.rel.data就是针对.data段的重定位表
重定位表其实是由多个重定位项组成,重定位表中的每个项的定义如下:
typedef struct {
Elf32_Addr r_offset; //重定位入口的偏移,也即所需要修正的位置的第一个字节相对于段起始的偏移
Elf32_Word r_info; // 重定位入口的类型和符号
} Elf32_Rel
重定位表的具体结构与使用将在后面的博客中给出
字符串表
ELF文件中用到了很多字符串,因为字符串的长度往往是不定长的,所以用固定的结构来表示它会比较困难,一种常见的也是ELF的做法就是把字符串集中起来存放到一个表中,然后使用字符串在表中的偏移来引用字符串
符号
链接过程的本质就是要把多个目标文件之间相互“黏”到一起,使得目标文件之间互相引用的函数以及变量可以找到真正的地址。在链接中,我们把函数和变量统称为符号,其中函数名或变量名就是符号名,而它们对应的地址就是符号值
符号类型主要需要关注的有两种:
- 定义在本目标文件的全局符号,可以被其他目标文件引用
- 在本目标文件中引用的全局符号,却没有定义在本目标文件中,一般叫做外部符号
其他的符号类型,包括:局部符号、段名、行号等,但是它们对于其他目标文件是不可见的,因此在链接过程中无关紧要,本博文也就不提及了
符号的结构定义如下:
typedef struct {
Elf32_Word st_name; // 符号名,其实也是在字符串表中的下标
Elf32_Addr st_value; // 符号值
Elf32_Word st_size; // 符号大小
char st_info; // 符号类型和绑定信息
char st_other; // 无用
Elf32_Half st_shndx; // 符号所在段
} Elf32_Sym;
特殊符号
当使用ld作为链接器来链接生成可执行文件时,它会为我们定义很多特殊的符号,虽然这些符号并没有在我们的程序中定义,但是我们可以直接声明并且引用它们,它们会被链接器最终解析成正确的值
几个很具有代表性的特殊符号如下:
- __executable_start:程序起始地址
- __etext: 代码段结束地址
- __end: 程序结束地址
弱符号与强符号
我们经常在编程中碰到一种情况叫做符号重复定义,也即多个目标文件中含有相同名字的全局符号定义,那么它们在链接时就会出现上述错误
其实符号划分为弱符号与强符号两种类型,强符号之间会发生符号重复定义错误,而弱强、弱弱之间则不会发生重复定义问题
编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号,我们也可以通过GCC的__attribute__((weak))来定义任何一个强符号为弱符号
另外在GCC中,我们可以通过使用__attribute__((weakref))这个扩展关键字来声明对一个外部函数的引用为弱引用,比如:
__attribute__ ((weakref)) void foo();
int main()
{
if (foo) foo();
}
这里也有一个小trick,通过对某些扩展功能模块的引用定义为弱引用,结合if
判断,可以做到类似Spring Boot中条件注入的功能,从而在必要的时候可以去掉对应的功能模块,程序也可以正常链接与运行,只是缺少了响应的功能,这使得程序的功能更加容易裁剪
参考
- 《程序员的自我修养——链接、装载与库》