编译器编译源代码后生成的文件叫做目标文件,从结构上讲,它是一种编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构式稍有不同。
现在PC平台流行的可执行文件格式主要是Windows下的PE和Linux的ELF,它们都是COFF格式的变种。目标文件就是源代码编译后但未进行连接的中间文件(Windows的.obj和Linux下的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件。
不光可执行文件(Windows的.exe和Linux下的ELF可执行文件)按照可执行文件格式存储。动态链接库(Windows的.dll和Linux的.so)及静态链接库(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。它们在Windows下都按照PE-COFF格式存储,Linux下按照ELF格式存储。静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,你可以简单地把它理解为一个包含有很多目标文件的文件包。ELF文件标准里面把系统中采用EFL格式的文件归为如表1所列举的4类。
表1
ELF文件类型 |
说明 |
实例 |
可重定位文件 |
这类文件包含了代码和数据,可以被用来连接成可执行文件或共享目标文件,静态链接库也可以归为这一类 |
Linux的.o或Windows的.obj |
可执行文件 |
这类文件包含了可以直接指向的程序,它的代表就是ELF可执行文件,它们一般都没有扩展名 |
比如/bin/bash文件或Windows的.exe |
共享目标文件 |
这种文件包含了代码和数据,可以在以下两种情况下使用,一种是连接器可以使用这种文件跟其他的可重定位文件和共享目标文件,产生新的目标文件。第二种是动态连接器可以将几个这种共享模板文件与可执行文件结合,作为进程映像的一部分运行 |
Linux的.so,如/lib/x86_64-linux-gn/libm.so.6或Windows的DLL |
核心转储文静 |
当进程意外终止时,系统可以将该进程的地址空间的内容及终止的一些其他信息转储到核心转储文件 |
Linux下的coredump |
file a.o
a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped(可重定位文件)
file /bin/bash
/bin/bash: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24(可执行文件)
file /lib/libavcodec.so.59.33.100
/lib/libavcodec.so.59.33.100: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked(共享目标文件)
目标文件中的内容至少有编译后的机器指令代码,数据。除了这些内容以外,目标文件还包括了链接时所需要的一些信息,比如符号表,调试信息,字符串等。一般目标文件将这些信息按不同的属性,以"节"的形式存储,有时候也叫"段",在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的区别是在ELF的链接试图和装载视图。
程序源代码编译后的机器指令经常被放在代码段里,代码段常见的名字有".code"或".text";全局变量和局部静态变量数据经常放在数据段,数据段一般叫做".data"。下图2是一个简单程序被编译成目标文件后的结构。
图2
假设图2的可执行文件(目标文件)的格式是ELF,从图2可以看到,ELF文件的开头是一个"文件头",它描述了整个文件的文件属性,包括文件是否可执行,是静态链接还是动态链接以及入口地址(如果是可执行文件),目标硬件,目标操作系统等信息,文件头还包括一个段表,段表其实是一个描述文件中各个段的数组。
对照图2来看,一般C语言的编译后可执行语句都编译成机器代码,保存在.text段;已初始化的全局变量和局部静态变量都保存在.data段;未初始化的全部变量和局部静态变量一般放在一个叫做".bss"的段里。程序运行的时候他们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,记为.bss段。所以.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
为什么要把程序的指令和数据的存放分开?混杂地方在一个段里面不是更加简单?其实数据和指令分段的好处有很多。主要有如下几个方面。
一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样就可以防止程序的指令被有意或者无意的改写。
另外一方面是对于现代的CPU来说,它们有着极为强大的缓存体系。由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
第三个原因,其实也是最重要的原因,就是当系统中运行着多个程序的副本时,他们的指令都是一样的,所以内存中只需要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读数据也一样,比如很多程序里面带有的图标,图片等资源属于可以共享。当然每个副本进程的数据区域是不一样的,它们是进程私有的。
根据上图的代码清单,我们使用gcc -c a.c 可以得到一个a.o文件,在使用objdump -h a.o、
从上面的结果来看,a.o的段除了最基本的代码段,数据段和BSS段,还有3个段分别是只读数据段(.rodata),注释信息段(。comment)和堆栈提示段(.note.GNU-stack)。我们先来看看几个重要的段属性,其中最容易理解的是段的长度(Size)和段所在的位置(File off),每个段的第2行中的"CONTENTS",“ALLOC”等表示段的各种属性。如图3所示是这些段在ELF文件所示。
图3
了解了这几个段在a.o中基本分布,接着将逐个来看这几个段,看看他们包含了什么内容。
有一个专门的命令叫做"size",它可以用来砍ELF文件的代码段,数据段和BSS段的长度(dec表示3个段的和的十进制,hex表示长度和的十六进制)。
使用objdump -s -d a.o 可以看到下面的信息。
图4
图5
图4的"Contexts of section text"就是代码段数据的十六进制方式打印出来的内容,总共0x49个字节,跟前面图3看到的".text"段长度符合,最左边一列是偏移量,中间4列是十六进制内容,最右边一列是.text段的ASCII码形式。图5是反汇编结果,可以看到,.text段里面所包含的正是a.c里面的两个函数func和main的指令。
.data段保存的是那些已经初始化了的全局变量和局部静态变量。图2一共有两个这样的变量,分别是int global_var = 84和static int static_var = 85。这两个变量每个4字节,一共刚好8个字节,所以“.data”这个段的大小是8个字节,对应图4中的"Contents of section.data"中的0x54和0x55。
a.c里面我们在调用printf的时候,用到了一个字符串常量"i:%d\n",它是一种只读数据,所以被放到了"。在rodata"段中,我们可以在图4的Contents of section .rodata中的693a25640a00就是"i:%d\n"的十六进制表示。
链接过程的本质就是要把多个不同的目标文件之间相互"粘"到一起,或者说像玩具积木一样,可以拼装形成一个整体。为了使不同的目标文件之间能够相互粘合,这些目标文件之间必须相互拼合,实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B要用到了目标文件A中的函数"func",那么我们就称目标文件A定义了函数"func",称目标文件B引用了目标文件A中的函数"func"。这两个概念也同样适用于变量。每个变量或者函数都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号。函数名或者变量名就是符号名。
我们可以将符合看作是链接中的粘合剂,整个链接过载正是基于符号才能正确完成。链接过程中很关键的一部分就是符号的管理,每个目标文件都会由一个相应的符号表,这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值,对于变量和函数来说,符号值就是它们的地址。除了变量和函数之外,还存在其他几种不常用到的符号。我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:
定义在本目标文件的全局符号,可以被其他目标文件引用。比如a.c里面的"func",“main”和"global_var"。
在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号,也就是我们前面所讲到的符号引用。
段名,这种符号往往由编译器产生,它的值就是该段的起始地址。比如a.c里面的".text",".data"等。
局部符号,这类符号只在编译单元内部可见。比如a.o里面的"staitc_var"和"static_var2"。调试器可以使用这些符号来分析程序或者崩溃时的核心转储文静。这些局部符号对于链接过程没有作用,练级去往往忽略他们。
使用nm命令看下a.o的符号结果,如图6所示。
图6