编译器编译源代码后生成的文件叫做目标文件,从结构上讲它是已经编译后的可执行文件格式,只是还没有经过链接,其中可能有些符号或有些地址还没有调整。它本身就是按照可执行文件格式存储的,跟真正的可执行文件在结构上稍有不同。
现在 PC 平台流行的可执行文件格式主要是 Windows 下的 PE( Portable Executable,可移植可执行 ) 和 Linux 的 ELF( Executable and Linkable Format,可执行链接格式 ),它们都是 COFF (Common Object Fifle Format,一般目标文件格式 )格式的变种。从广义上看,目标文件和可执行文件的格式其实几乎是一样的,在 Linux 下,我们可以将他们统称为 ELF 文件;在 Windows 下可以统称为 PE-COFF 文件格式。
在 Linux 下使用 file 命令查看相应的文件格式:
目标文件是什么样的呢? 下面我们通过一个具体的例子来看一下。如不加说明,以下分析都是基于 32 位 x86 平台,如何在 64 位系统下编译 32 位可执行程序,需要安装 32 位版本的 glibc库,然后在编译时加上选项 “-m32”,如下所示:
源代码如下:
下面我们用 GCC 编译这个文件(只编译不链接),然后用 objdump 查看 ELF 文件的基本信息,如下:
我们可以看到目标文件是由很多的“节”(Section)组成的,有 .text节、.data节、.bss节、.rodata节、.comment—注释信息节和 .note.GNU-stack—堆栈提示节等。目标文件将所有信息按不同的属性以“节”(Section)的形式存储,有时候也叫“段”(Segment),很难在中文的翻译上加以区分,其实它们两个还是有区别的。
在汇编源码中,通常用语法关键字 section 或 segment 来表示一段区域,在程序中“逻辑地”划分一段区域,这个区域就是节。此时所说的 section 和 segment 都是汇编语法中的关键字,它们在语法中都表示“节”,不是段,只是不同编译器的关键字不同而已。经过汇编生成目标文件之后,由这些 section 和 segment 修饰的程序区域便成为了“节”(section)。
但是操作系统加载程序时,不关心节的数量和大小,只关心节的属性,因为程序是要加载到内存中才能运行,而内存的访问会涉及到全局描述符表中段描述符的访问权限等属性,操作系统通过设置 GDT 全局描述符表来构建段描述符,在段描述符中指定段的位置、大小和属性。操作系统加载程序时不需要对逐个节进行加载,只要给出相同权限的节的集合就行了,比如把所有只读可执行的节归并到一块,所有可读可写的节归并到一块,这样操作系统就能为它们分配不同的段选择子,从而指向不同段描述符,实现不同的访问权限。
汇编器只生成了目标文件,尚未链接,因此将“节”合并的工作是由链接器来完成的,链接器将目标文件中属性相同的节合并成一个大的 section 集合,这个集合称为 segment,也就是段,就是平时说的可执行程序内存空间中的代码段和数据段。
总结下就是“节”出现于目标文件中,段诞生于可执行文件中,某个节(section)属于某个段(segment),段是由节组成的。给加载器用的也是段。为了不纠结到底应该用哪个,所以后面直接用英文来表示。
上面插播了一个基本常识,下面继续挖掘SimpleSection.o。
上面用参数 “-h” 打印出各个 section 的基本信息,容易理解的是 section 的长度(Size)和其所在的位置(File Offset),第二行的“CONTENTS”、”ALLOC”等表示其各种属性,”CONTENTS”表示该 section 在文件中存在。它们在 ELF 文件中的结构如下所示:
接下来逐个来看这几个 section。
objdump 的 “-s”参数可以将所有 section 的内容以十六进制的形式打印出来,”-d” 参数将所有包含指令的 section 反汇编。
“Content of section .text” 就是 .text 的数据以十六进制方式打印出来的内容,最左边一列是偏移量,中间4列是十六进制内容,最右边是ASCII形式。对照反汇编结果,能看出 .text 里包含的就是 和的指令,.text 的第一个字节 “0x55”就是“func1()”函数的第一条指令,最后一个字节“0xc3”正是main()函数的最后一条指令。
.data 保存的是已经初始化了的全局变量和局部静态变量。样例代码定义了两个这样的变量,每个占 4 个字节,所以 .data 的大小为8个字节。
.rodata 存放的是只读数据,一般是只读变量(如const修饰的变量)和字符串常量。样例中调用 printf 时,用到了一个字符串常量“%d\n”,它是只读的,被放到了 .rodata 中,这个section 的4个字节刚好是这个字符串常量的 ASCII字节序,最后以 \0 结尾。
.bss 存放的是未初始化的全局变量和局部静态变量。有些编译器会将全局的未初始化变量存放在目标文件的 .bss section,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在 .bss 段分配空间。这跟不同语言与不同的编译器实现有关。
除了上面3个常用的之外,目标文件也有可能包含其他的 section,用来保存和程序相关的其他信息。图片来自《程序员的自我修养》。
“.”作为前缀表示是系统保留的,当然可以使用一些非系统保留的名字作为 section 名。但是自定义的名字不能使用 “.” 作为前缀,否则容易跟系统保留名冲突。
程序中最重要的部分就是段(segment)和节(section),它们是真正的程序体,程序中有很多段,也有很多节,段是由节来组成的,多个节经过链接之后就被合并成一个段了,前面解释过segment 和 section 的区别。
段和节的信息是用 header 来描述的,即文件头,程序头是 program header,节头是 section header。程序中段的大小和数量是不固定的,节的大小和数量也不固定,因此需要为它们专门找个数据结构来描述它们,这个结构就是程序头表(program header table)和节头表(section header table)。这两个表相当于数组,数组元素分别是程序头 program header 和节头 section header。一个元素代表一个段或者一个节的头描述信息。对于程序头表,它本质上就是用来描述段(segment)的。
由于程序中段和节的数量不固定,所以程序头表和节头表的大小自然也不固定了,而且各表在程序文件中的存储顺序也要有个先后,所以这两个表在文件中的位置也不会固定。因此,必须要在一个固定的位置,用一个固定大小的数据结构来描述程序头表和节头表的大小及位置信息,这个数据结构便是 ELF header,即 ELF 文件头,它位于文件最开始的部分。ELF 文件格式的思想就是 “头中嵌头”,是种层次化结构的格式。它除了描述这两个表的信息之外,还有整个文件的基本属性,用 readelf 命令来查看下 ELF文件头。
ELF 文件格式分为文件头和文件体两部分,文件头稍显复杂,类似层次结构,先用 ELF header 从“全局上”给出程序文件的组织结构,概要出程序中其他头表的位置大小等信息,如程序头表的大小及位置、节头表的大小及位置,然后各个段和节的位置、大小等信息再分别从 “具体的” 程序头表和节头表中予以说明。
ELF 格式的作用体现在两方面,一是链接阶段可重定位文件格式,而是运行阶段可执行文件格式,所以它们在文件中的组织布局也有两个方面,即ELF文件链接视图和执行视图:
ELF 文件头结构及相关常数被定义在 /usr/include/elf.h
里,ELF 文件有 32 位版本和 64 位版本,文件头结构也有这两种版本,分别叫 “Elf32_Ehdr” 和 “Elf64_Ehdr”。
它使用了 typedef 定义了一套自己的变量体系,如图:
对比 ELF 文件头结构的成员与前面 readelf 输出的 ELF 文件头, 可以看到除了 e_indent 这个成员对应了输出结果中的“Class”、“Data”、”Version”、”OS/ABI”和”ABI Version”这 5 个参数,剩下的参数都一一对应。
ELF 文件头结构成员就不一一列出来解释了,大家可以对照着英文注释和 readelf 的输出内容还了解一下,这里重点来看下特殊的 e_ident 数组,其功能如下:
我们是研究目标文件里有什么,如我们的样例 SimpleSection.o,所以只关注链接视图,把程序头先忽略掉,重点看 section header 结构,其实我们也可以看到样例中程序头表的偏移和条目大小数量(e_phoff、e_phentsize 和 e_phnum )都是 0 。
section header table 就是保存这些 section 的基本属性的结构,是目标文件中除了文件头之外最重要的结构,它描述了文件中各个 section 的信息。它的位置由 ELF 文件头的”e_shoff“成员决定。
前面我们通过 objdump -h 来查看 SimpleSection.o 中包含的 section,结果只是把 ELF 文件中关键的 section 显示了出来,而忽略了其他的辅助性的 section。我们可以用 readelf 来查看 SimpleSection 中的 section,它显示出的才是真的:
接下来介绍下节头表中的条目的数据结构,是用来描述各个节的信息用的,结构名字叫“struct Elf32_Shdr”,被定义在 ”/usr/include/elf.h” 中,它的结构如下:
结构体的每一个成员对应于上面输出结果中从第二列开始的每一列。我们重点看 节的类型(sh_type)。
程序的源代码经过编译之后,按照代码和数据分别存放到响应的 section 中,编译器(汇编器)还会将一些辅助性的信息,诸如符号、重定位信息等也按照表的方式存放到目标文件中,而通常情况下,一个表往往就是一个 section 。
我们注意到 SimpleSection.o 中有一个叫做”.rel.text“的节,它的类型(sh_type)为”SHT_REL”,也就是说它是一个重定位表。我们知道链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,即 .text 和 .data 中那些对绝对地址的引用的位置。这些重定位的信息都记录在 ELF 文件的重定位表里面。
对于每个需要重定位的 section,都会有一个相应的重定位表。比如 SimpleSection.o 中的 ”.rel.text“ 就是针对 ”.text“ 的重定位表,这个样例的 .text 中至少有一个绝对地址的引用,就是对 “printf” 函数的调用。类似的还有 .rel.eh_frame 节,.data 节中没有对绝对地址的引用,只包含了几个常量,所以没有针对 .data 的重定位表。
关于重定位表的内部结构,后面说链接的时候再详细分析。
请参考《程序员的自我修养》。
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。将函数和变量统称为符号(symbol),函数名或变量名就是符号名。
每一个目标文件都有一个相应的符号表(symbol table),这个表里面记录了目标文件中所定义和引用到的符号的信息。每个定义的符号有一个对应的值,叫做符号值。
符号的类型:
定义在本目标文件的全局符号,可以被其他目标文件引用。非静态的函数和不带 static 属性的全局变量,如SimpleSection.o 中的 func1、main、global_init_var。
在本目标文件中引用的全局符号,却没有定义在本目标文件,一般叫外部符号。如 printf。
节的name。往往由编译器产生,它的值就是该节的起始地址。如 .data .text。
局部符号,编译单元定义和引用的本地符号,只在编译单元内部可见 。如 static_var static_var2。
行号信息,可选
最值得关注的是全局符号,因为链接的过程只关心全局符号的相互“粘合”,局部符号、行号、节名对于其他目标文件都是”不可见“的。
ELF 符号表往往是文件中的一个 section,名字一般叫“.symtab”,是一个 Elf32_Sym(对应于一个符号)的数组。.symtab 符号表不包含非静态局部变量的条目,这些符号在运行时在栈中被管理,链接器不管它们。
Elf32_Sym 的结构定义如下:
st_name:符号名,该符号名在字符串表中的下标。
st_size:符号大小,通常这个值是数据类型的大小,如果为0,表示该符号大小为0或未知。
st_shndx:符号所在节,如果符号定义在本目标文件中,那么这个成员表示符号所在的section 在 section header table 中的下标。还有几个特殊常量:
我们用 readelf 工具来查看 SimpleSection.o 中的符号表,大家可以比照着 struct Elf32_Sym 中的成员解释一下这些符号。
最后一列为空的,它们的符号名没有显示,其实它们的符号名就是 section 的名字,如 .text。我们可以换个命令查看一下:objdump -t
使用 ld 作为链接器产生可执行文件时,它会在 ld 链接器的链接脚本中定义很多特殊的符号,我们可以直接声明并且引用它们,无需定义。
主要是为了解决多模块的符号名冲突问题。符号修饰和函数签名与C++关系密切。函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。编译器在将 C++ 源代码编译成目标文件时,会将函数和变量名进行修饰,形成符号名,也就是说,C++ 源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。C++ 编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名后的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。
我们可以写个简单的 C++ 代码,然后 看一下生成的目标文件的符号表,如下:
有兴趣的可以参考GCC的名称修饰标准,我们平时程序开发中很少手工分析名称修饰问题,所以无须很详细的了解这个过程。有个叫”c++filt“的工具可以用来解析被修饰过的名称,如下:
不同的编译器的名称修饰方法可能不同,所以不同的编译器对于同一个函数签名可能对应不同的修饰后名称,所以不同编译器编译产生的目标文件无法正常相互链接。
签名和名称修饰机制不光被使用到函数上,C++中的全局变量和静态变量也要同样的机制。全局变量和函数一样,是一个全局可见的名称,不过变量的类型没有被加入到修饰后的名称中。
有了这些目标文件,而且我们大概了解了它里面有什么之后,接下来的问题就是如何将它们组合起来,形成一个可以使用的程序或一个更大的模块。