首先我们知道程序经过编译器例如gcc,在打下gcc 1.c的时候经历了什么,分为如下四步
预处理
主要处理那些以’#‘开始的指令
1、将所有#define展开
2、处理所有预编译条件指令。#ifdef等
3、处理’#include’预编译指令,将被包含的文件插入到该预编译指令的位置。这个过程是递归的,可能会包含重复的头文件。(所有需要#ifndef #define)
4、删除所有注释。保留所有#pragma编译器指令。
编译
编译就是对文件进行一系列的词法分析,语法分析,语义分析及优化后生成的相应汇编代码。
汇编
汇编器是将汇编代码转换为机器可执行的指令,每一个汇编语句几乎都对应了一条机器指令。
汇编后生成的文件叫做目标文件。
链接
链接的主要内容就是把各个模块之前相互引用的部分都处理好,使得各个模块之间能够正确地衔接。
链接过程主要包括了地址和空间分配(address and storage allocation)、符号决议(symbol resolution)和重定位(relocation)等步骤。
当目标文件当前不知道其内部变量(设为var)var的地址时,编译器先将其地址置为0,等到链接器链接后,确定了var的地址后,我们再将var的地址进行修正,这一部就叫做重定位。
在本篇博客我们主要讲解汇编之后生成的目标文件(ELF)。
目标文件的格式
在linux下,可执行文件的格式是ELF(Executable Linkable Format),目标文件就是源代码经过编译后但未进行链接的那些中间文件,它一般跟可执行文件采用一种格式存储。动态链接库文件按照可执行文件格式存储。静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,可以将静态链接库理解为包含了许多个目标文件的文件包。
既然段表决定了所有的段的属性,那么ELF文件中的段究竟是个什么东西呢?其实段只是对ELF文件内的不同类型的数据的一种分类。例如,我们把所有的代码(指令)放到一个段中,并且给这个段起名.text;把所有的已经初始化的数据放在.data段;把所有的未初始化的数据放在.bss段;把所有的只读数据放在.rodata段,等等。
至于为什么要把数据(指令在ELF文件中也算是一种数据,它是ELF文件的数据之一)分为不同的类型,除了方便进行区分之外,还有以下几个原因
- 便于给段设置读写权限,有些段只需要设置只读权限即可
- 方便CPU缓存的生效
- 有利于节省内存,例如程序有多个副本情况下,此时只需要一份代码段即可
总体来说,程序源代码被编译后主要分成两种段:程序段和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。 - 当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意的改写。
- 另外一方面是对于现代的cpu来说,它有着极为强大的缓存(cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代cpu的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对cpu的缓存命中率提高有好处。
- 第三个原因也是最重要的原因,当系统中运行这多个该程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其它的只读数据也一样,比如很多程序里面带有的图标,图片、文本等资源也是属于共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有地。
#include<stdio.h>
int compare(int a, int b)
{
return a > b;
}
int c = 3;//.data段
int d; //.bss段“COMMON块”见下文解析.bss段
//将e变量指定到.data段中,见下文自定义段
__attribute__((section(".data"))) int e;
int main()
{
char *str = "hello word"; //字符串常量,.rodata只读段
static int h; //.bss段,未初始化静态局部变量
printf("e = %d\n", e);
printf("hello word!\n");
return 0;
}
其中有代码段.text,数据段.data和.bss段,还有三个段分别为只读数据段.rodata,注释信息段.comment,堆栈提示段.note.GNU-stack。
其中size是段的长度和段所在的位置(File off,段当前的偏移量),每个段第二行的CONTENTS,ALLOC等表示段的各种属性,CONTENTS表示该段在文件中存在,我们可以看到BSS段没有CONTENTS,它实际上在ELF文件中不存在内容。.eh_frame表示异常处理帧信息段
解析.bss段
首先,根据我们之前了解到的未初始化的全局变量位于.bss段,会将其值初始化为0。
因为目标文件要考虑链接的问题,所以将全局未初始化变量放入"COMMON块",将静态变量放入.bss段。这么做的目的是为了让全局未初始化变量对外部可见。
如上图所示,看上文的代码,d为未初始化的全局变量,可以看到d就在“COMMON块”中。
- __bss段在可执行文件中没有任何内容,这个其实不难理解。存放在__bss段的是未初始化的全局变量和局部静态变量,既然没有初始化,可执行文件中也就不需要专门去记录变量的值了(也没有值拿来记录),唯一需要的就是给这些变量一个确定的内存地址(像__data段中的变量一样)。这样其实有两种方法:其一,像__data段那样在相应位置写一些初始值进去占位,可执行文件装载时直接映射就好了,和__data段一模一样;其二,不给__bss段在可执行文件中占位,在装载时根据__bss段信息直接在内存中开辟相应区域,即将占位从可执行文件推迟到装载时。 **编译器就是选择的方法二,将占位从可执行文件推迟到装载时,**这样做的好处就是减小了可执行文件的体积,比如一个长度为10000的未初始化int数组,采用方法二不会占用任何可执行文件空间,而采用方法一则会将可执行文件增大至少40KB。
编译单元内部可见的静态变量的确是存放在.bss段
结论:
bss段确实不占用可执行文件空间,但文件装载后在内存中还是会占用相应大小的空间的,这种处理方法就是为了减少可执行文件大小,避免不必要的开销。
但严格地说,bss段也还是占用一些可执行文件空间的,比如在段表中有bss段的描述,在符号表中有bss段内相关变量的描述,但这里就不是同一个概念了。
自定义段
我们希望将有些数据放到我们指定的段中,可以用
attribute( (section(“name”)) ),就可以将相应的变量或函数放入以"name"为段名的段中。
//将e变量指定到.data段中
__attribute__((section(".data"))) int e;
ELF文件头
上表可以看到ELF文件头的具体内容,我们怎么分析文件头而找到段表结构呢?
看上表中最后一行,字符串表索引节头,通过它我们可以找到段表中的.shstrtab段,而该段中又保存了段表中其它段的名字。所以只有分析ELF文件头,就可以得到段表和段字符串表的位置,从而解析整个ELF文件。
段表
ELF文件的段结构就是有段表决定的,编译器,链接器和装载器都是依靠段表来定位和访问各个段的属性。
段类型
- PROGBITS值(1)表示该段为程序段或代码段或数据段。
- SYMTAB值(2)表示该段的内容为符号表
- STRTAB值(3)表示该段的内容为字符串表
- RELA值(4)表示重定位表。该段包含了重定位信息。
- HASH值(5)符号表的哈希表
- DYNAMIC值(6)动态链接信息
- NOTE值(7)提示信息
- NOBITS值(8)表示该段在文件中没内容,如.bss段。
- REL值(9)该段包含了 重定位信息
- SHLIB值(10)保留
- DNYSYM值(11)动态链接的符号表
段的标志位 - SHF_WRITE表示该段在进程空间中可写
- SHF_ALLOC表示该段在进程空间中需要分配空间。有些包含指示或控制信息的段不需要在进程空间中被分配空间,它们一般不会有这个标志。像代码段、数据段和.bss段都会有这个标志位。
- SHF_EXECINSTR表示该段在空间中可以被执行,一般指代码段。
.strtab表示字符串表,.shtrtab表示段表字符串表。字符串表用来保存普通的字符串,比如符号的名字;段字符串表用来保存段表中用到的字符串,最常见的就是段名。
链接的接口——符号
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,既对函数和变量的地址的引用。
下图为符号表
Num表示符号表的数组下标。
value表示符号值。
size为符号大小。
Bind符号绑定信息
- LOCAL 局部符号,对于目标文件的外部不可见
- GLOBAL全局符号,外部可见
- WEAK 弱引用
type符号类型 - NOTYPE未知类型
- OBJECT该符号是个数据对象,比如变量、数组等
- FUNC该符号是个函数或者其它可执行代码
- SECTION该符号表示一个段,这种符号必须是LOCAL的
- FILE 该符号表示文件名,一般都是目标文件的源文件名。如上表中的2.c
Ndx即st_shndx,表示该符号所属的段,上图中用下标的值表示还有如下所示的三个表示法。
- ABS 表示符号包含了一个绝对的值。比如表示文件名的符号就属于这种类型
- COMMON上表中为COM,表示该符号是一个"COMMON块"类型的符号。一般来说,未初始化的全局符号定义就是这种类型的。
- UNDEF上表中为UND,表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其它目标文件中。
Name即符号名称。
弱符号与强符号
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过GCC的__attribute__((weak))将一个强符号变为弱符号。强符号和弱符号都是针对定义来说的,不是针对符号的引用。
extern int ext;
int weak; //弱符号
int strong = 1; //强符号
__attribute__((weak)) weak2 = 2;//弱符号
int main()
{
return 0;
}
针对强弱符号的概念,链接器就会按如下规则处理与选择多次定义的全局符号:
- 规则1:不允许强符号被多次定义(即不同的目标文件不能有同名的强符号);如果有多个强符号的定义,则编译器报符号重复定义错误。
- 规则2:如果一个符号在某个目标文件中是强符号,在其它文件中是弱符号,那么选择强符号。
- 规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。
弱引用与强引用 目前我们看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们必须被正确决议,如果没有找到该符号的定义,链接器就会报符号为定义错误,这种被称为强引用。与之相对应的还有弱引用,在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。