编译器编译源代码后生成的文件叫做目标文件,那么目标文件里面到底存放的是什么呢?或者我们的源代码在经过编译以后是怎么存储的?
目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同。
可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面。
3.1 目标文件格式
现在PC平台流行的可执行文件格式主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它们都COFF(Common File Format)格式的变种。目标文件就是源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。
不光是可执行文件(Windows的.exe和Linux下的ELF可执行文件)按照可执行文件格式存储。动态链接库(Dynamic Linking Library)(Windows的.dll和Linux的.so)和静态链接库(Static Linking Library)(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。它们在Window是下偶读按照PE-COFF格式存储,Linux下按照ELF格式存储。
ELF文件标准里面把系统中采用ELF格式的文件归为如下表所列举的4类:
我们可以在Linux下使用file命令来查看相应的文件格式。
3.2 目标文件是怎么样的
目标文件中的内容至少有编译后的机器指令代码、数据。没错,除了这些内容外,目标文件还包括链接时所需要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以“段(Segment)”的形式存储,在一般情况下,它们都表示一个一定长度的区域,基本上不加区别,唯一的区别是在ELF的链接视图和装载视图的时候,后面会提到。
程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有“.code”或“.text”;全局变量和局部静态变量数据经常放在数据段(Data Section),数据段的一般名字都叫".data"。让我们来看一个简单的程序被编译成目标文件后的结构,如下图所示。
假设上图的可执行文件(目标文件)的格式是ELF,从图中可以看出,ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是冬天链接以及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置以及段的属性等。从段表中可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保存的就是程序的指令等等。
对照上图来看,一般C语言的编译后执行语句都编译成机器代码,保存在.text段;已初始化的全局变量和局部静态变量都保存在.data段;未初始化的全局变量和局部静态变量一般放在一个叫.bss的段中,我们知道未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以放在.data段的,但是因为它们都是0,所以为它们在.data段分配空间并且存放数据0是没有必要的。程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,即为.bss段。所以.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并木有内容,所以它在文件中也不占据空间。
总体来说,程序源代码经过编译之后主要分为两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
为啥把程序指令和数据分开来放?主要有如下几个方面原因:
(1)一方面是程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意的改写。
(2)另一方面是对于现代的CPU来说,它们有着极为强大的缓存体系。程序必须尽量提高缓存的命中率,指令区和数据区的分离有利于提高程序的局部性。
(3)第三个原因,其实也是很重要的是原因,就是当系统中运行着多个许多个该程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读数据也是这么。当然,每个副本进程的数据区域是不一样的,它们是进程私有的。
3.3 挖掘SimpleSection.o
源代码清单如下所示。
- int printf(const char *format, ....);
- 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 a;
注意:如不加说明,则以下所分析的都是32位Intel X86平台下的ELF文件格式。
我们使用GCC来编译这个文件(参数-c表示只编译不链接):
我们得到一个1104字节(该文件大小可能会因编译器的版本以及机器平台不同而变化)的SimpleSection.o目标文件。可以用objdump来查看object内部的结构,运行以下命令:
从上面的结果可以看出,SimpleSection.o的段的数量比我们想象的要多,除了最基本的代码段、数据段和BSS段外,还有3个段分别为只读数据段(.rodata)、注释信息段(.comment)和堆栈提示段(.note.GNU-stack),这3个额外的段的意义暂不去追究。
先来看看几个重要的段的属性,其中最容易理解的是段的长度(Size)和段所在的位置(File Offset),每个段的第2行的“CONTENTS”、“ALLOC“等表示段的各种属性,”CONTENTS”表示该段在文件中存在。可以看到BSS段没有“CONTENTS”,表示它实际上在ELF文件中不存在内容。“.note.GNU-stack”段虽然有“CONTENTS”,但它的长度为0,这是个很古怪的段,暂且忽略它,认为它在ELF文件中不存在。那么ELF文件中实际存在的也就是.text、.data、.rodata和.comment这4个段了,它们的长度和在文件中的偏移文章已经用表示出来了,如下图所示。
3.3.1 代码段
objdump的-s参数可以将所有段的内容以十六进制的方式打印出来,-d参数可以将所有包含指令的段反汇编。我们将objdump输出中关于代码daunting的内容提取出来,分析下关于代码段的内容(省略号表示无关内容):
“Contents of section.text”就是.text的数据以十六进制方式打印出来的内容,总共0x5b字节,最左边的偏移量,中间4列是十六进制内容,最右边一列是.text段的ASCII码形式。对照下面的反汇编结果,可以很明显的看到,.text段里所包含的正是SimpleSection.c里两个函数func1()和main()的指令。.text段的第一个字节“0x55”就是“func1()”函数的第一条"push %ebp"指令,而最后一个字节0xc3整数main()函数的最后一条指令“ret”。
3.3.2 数据段和只读数据段
.data段保存的是那些已经初始化了的全局变量和局部静态变量。前面的SimpleSection.c代码里面一共有两个这样的变量,分别为global_init_var和global_uninit_var。这两个变量每个4个字节,一共刚好8个字节,所以.data这个段的大小为8个字节。
SimpleSection.c里面我们在调用printf的时候,用到了一个字符串常量“%d\n”,它是一种只读数据,所以它被放到了“.rodata”段,可以从输出结果看到该段的这4个字节刚好是这个字符串常量的ASCII字节序,最后以‘\0‘结尾。
.rodata段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。单独设立.rodata段有很多好处,不光是在语义上支持了C++的const关键字,而且操作系统在加载的时候可以将.rodata段的属性映射成只读,这样对于这个段的任何修改操作都会作为非常操作处理,保证了程序的安全性。
另外值得一提的是,有时候编译器会把字符串常量放到.data段,而不会单独放在.rodata段。有兴趣的读者可以试着把SimpleSection.c的文件名改为SimpleSection.cpp,然后用各种MSVC编译器编译下看看字符串常量的存放情况。
我们看到.data段里的前4个字节,从低位到高位分别为0x54、0x00、0x00、0x00。这个值刚好是global_init_var,即十进制的84。global_init_var是个4个字节长度的int类型,为什么存放的次序为0x54、0x00、0x00、0x00而不是0x00、0x00、0x00、0x54?这涉及CPU的字节序的问题,也就是所谓的大端和小端的问题。
3.3.3 BSS段
.bss段存放的是未初始化的全局变量和局部变量,如上面所说的global_uninit_var和static_var2就是被存放在.bss段,其实更准确的说法是.bss段为它们预留了空间。但是我们可以看到该段的大小只有4个字节,这与global_uninit_var和static_var2的大小为8个字节不符。
其实,我们可以通过符号表看到,只有static_var2被存放在了.bss段,而global_uninit_var却没有被存放在任何段,只是一个未定义的“COMMON符合”。这其实是跟不同的语言与不同的编译器实现有关,有写编译器会将全局的未初始化的变量存放在目标文件的.bss段,有些则不存放,只是预留一个未定义的全局变量符合,等到最终链接成可执行文件的时候再在.bss段分配空间。
原则上讲,我们可以简单的把它当做全局未初始化变量存放在.bss段。值得一提的是编译单元内部可见的静态变量(比如给global_uninit_var加上static修饰的确存放在.bss段的),这一点很容易理解。
Quiz变量存放位置
现在让我们来做一个小的测试,请看以下代码:
- static int x1 = 0;
- static int x2 = 1;
x1和x2会被放在什么段中呢?
x1会被放在.bss段,x2会被放在.data中。为什么一个放在了.bss段,一个在.data段中?因为x1为0,可以认为是未初始化的,因为未初始化的都是0,所以被优化了放在了.bss,这样可以节省磁盘空间,因为.bss不占磁盘空间。另外一个变量x2初始化值为1,是初始化的,所以放在了.data段中。
注意:这种类似的编译器的优化会对我们分析系统软件背后的机制带来很多的障碍,使得很多问题不能一目了然。
3.3.4 其他段
除了.text、.data、.bss这3个最常用的段之外,ELF文件也有可能包含其他的段,用来保存与程序相关的其他信息。下图列举了ELF的一些常见的段。
这些段的名字都是由‘.‘作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名。
自定义段
正常情况下,GCC编译出来的目标文件只能够,代码会被放到“.text”段,全局变量和静态变量会被放到".data"和".bss"段,正如我们前面所分析的。但是有些时间你可能希望变量或某些部分代码能够放到你指定的段中取,以实现某些特定的功能。比如为了满足某些硬件的内存和I/O的地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等。GCC提供了一个扩展机制,使得程序员可以指定变量所处的段:
我们在全局变量或函数之前加上"__attribute__( (section("name") ) )"属性就可以把相应的变量或函数放到以“name”作为段名的段中。
引自:《程序员自我修养》