目录
0.引言
编译器编译源代码后生成的文件叫做目标文件,那么目标文件里面到底存放的是什么呢﹖
或者我们的源代码在经过编译以后是怎么存储的?我们将在这一节剥开目标文件的层层外壳,
去探索它最本质的内容。
目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,
其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,
只是跟真正的可执行文件在结构上稍有不同。
可执行文件格式涵益了程序的编译、链接、装载和执行的各个方面。了解它的结构并深
入剖析它对于认识系统、了解背后的机理大有好处。
1.目标文件的格式
1.1 目标文件的格式及ELF文件格式的文件的分类
现在PC平台流行的可执行文件格式(Executable)主要是Windows下的PE(PortableExecutable)
和Linux的ELF(Executable Linkable Format),它们都是COFF(Common fileformat)格式的变
种。目标文件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和Linux下的.o),
它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用种格式存储。
从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件
与可执行文件看成是一种类型的文件,在Windows下,我们可以统称它们为PE-COFF文件格式。
在Linux下,我们可以将它们统称为ELF文件。其他不太常见的可执行文件格式还有Intel/Microsoft
的OMF( Object Module Format)、Unix a.out格式和MS-DOS.COM格式等。
不光是可执行文件(Windows 的.exe和 Linux下的ELF可执行文件)按照可执行文件格式存储。
动态链接库(DLL,Dynamic Linking Library)(Windows 的.dll和 Linux 的.so)及静态链接库
(Static Linking Library)( Windows 的.lib和Linux的.a)文件都按照可执行文件格式存储。它
们在 Windows下都按照PE-COFF格式存储,Linux下按照ELF格式存储。静态链接库稍有不同,它是把
很多目标文件捆绑在一起形成一个文件,再加上一些索引,你可以简单地把它理解为一个包含有很多目
标文件的文件包。ELF文件标准里面把系统中采用ELF格式的文件归为如表3-1所列举的4类。
我们可以在Linux 下使用file命令来查看相应的文件格式,上面几种文件在file命令下会显示
出相应的类型:
1.2 目标文件与可执行文件格式的小历史
目标文件与可执行文件格式跟操作系统和编译器密切相关,所以不同的系统平台下会有不同的格式,
但这些格式又大同小异,目标文件格式与可执行文件格式的历史几乎是操作系统的发展史。
COFF 是由Unix System V Release 3首先提出并且使用的格式规范,后来微软公司基于COFF格式,
制定了PE格式标准,并将其用于当时的 Windows NT系统。System Release 4在 COFF的基础上引入了
ELF格式,目前流行的 Linux系统也以ELF作为基本可执行文件格式。这也就是为什么目前PE和ELF如此
相似的主要原因,因为它们都是源于同一种可执行文件格式COFF。
Unix最早的可执行文件格式为a.out格式,它的设计非常地简单,以至于后来共享库这个概念出现
的时候,a.out格式就变得捉襟见肘了。于是人们设计了COFF格式来解决这些问题,这个设计非常通用,
以至于COFF的继承者到目前还在被广泛地使用。
COFF的主要贡献是在目标文件里面引入了“段”的机制,不同的目标文件可以拥有不同数量及不同类
型的“段”。另外,它还定义了调试数据格式。
下文的剖析我们以ELF结构为主。然后会专门分析PE-COFF文件结构,并对比其与ELF的异同。
2.目标文件是什么样的
2.1 程序与目标文件简介
我们大概能猜到,目标文件中的内容至少有编译后的机器指令代码、数据。没错,除了这些
内容以外,目标文件中还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。
一般目标文件将这些信息按不同的属性,以“节”( Section)的形式存储,有时候也叫“段”
(Segment),在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的
区别是在ELF的链接视图和装载视图的时候,后面会专门提到。在本书中,默认情况下统一将
它们称为“段”。
程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字
有“.code”或“.text";全局变量和局部静态变量数据经常放在数据段(Data Section),数据
段的一般名字都叫“.data”。让我们来看一个简单的程序被编译成目标文件后的结构,如图3-1
所示。
假设图3-1的可执行文件(目标文件)的格式是ELF,从图中可以看到:
ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、
是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,
文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表
描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。
文件头后面就是各个段的内容,比如代码段保存的就是程序的指令,数据段保存的就是程序的静
态变量等。
对照图3-1来看,一般C语言的编译后执行语句都编译成机器代码,保存在.text段;已初
始化的全局变量和局部静态变量都保存在. data段;未初始化的全局变量和局部静态变量一般放
在一个叫.“bss”的段里。我们知道未初始化的全局变量和局部静态变量默认值都为0,本来它们
也可以被放在.data段的,但是因为它们都是0,所以为它们在.data段分配空间并且存放数据О
是没有必要的。程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初
始化的全局变量和局部静态变量的大小总和,记为.bss段。所以.bss段只是为未初始化的全局变
量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。
2.2 BSS历史
BSS ( Block Started by Symbol)这个词最初是UA-SAP汇编器(United AircraftSymbolic
Assembly Program)中的一个伪指令,用于为符号预留一块内存空间。该汇编器由美国联合航空公司
于20世纪50年代中期为IBM 704大型机所开发。后来BSS这个词被作为关键字引入到了IBM 709和
7090/94机型上的标准汇编器FAP(Fortran Assembly Program),用于定义符号并且为该符号预
留给定数量的未初始化空间。
Unix FAQ section 1.3 (http://www.faqs.org/faqs/unix-faq/faq/part1/section-3.html)
里面有Unix和C语言之父Dennis Rithcie对BSS这个词由来的解释。
2.3 程序源代码为什么要分成程序指令和程序数据?
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,
而数据段和.bss段属于程序数据。
很多人可能会有疑问:为什么要那么麻烦,把程序的指令和数据的存放分开?混杂地放在一个段里
面不是更加简单?其实数据和指令分段的好处有很多。主要有如下几个方面。
(1)当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,
而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样
可以防止程序的指令被有意或无意地改写。
(2)对于现代的CPU来说,它们有着极为强大的缓存(Cache)体系。由于缓存在现代的计算机中地位非
常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代
CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命
中率提高有好处。
(3)第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程序的副本时,它们的指令都是
一样的,所以内存中只须要保存一份改程序的指令部分。对于指令这种只读的区域来说是这样,对于其
他的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每
个副本进程的数据区域是不一样的,它们是进程私有的。不要小看这个共享指令的概念,它在现代的操
作系统里面占据了极为重要的地位,特别是在有动态链接的系统中,可以节省大量的内存。比如我们常
用的 Windows Internet Explorer 7.0运行起来以后,它的总虚存空间为112844 KB,它的私有部分
数据为15 944 KB,即有96 90O KB的空间是共享部分。如果系统中运行了数百个进程,可以想象共享的
方法来节省大量空间。关于内存共享的更为深入的内容我们将在装载这一章探讨。
3.挖掘SimpleSection.o
前面对于目标文件只是作了概念上的阐述,如果不彻底深入目标文件的具体细节,相信这样的分析也
只是泛泛而谈,没有真正深入理解的效果。就像知道TCP/IP协议是基于包的结构,但是从来却没有
看到过包的结构是怎样的,包的头部有哪些内容?目标地址和源地址是怎么存放的?如果不了解这些,
那么对于TCP/P的了解是粗略的,不够细致的。很多问题其实在表面上看似很简单,其实深入内部会
发现很多鲜为人知的秘密,或者发现以前自己认为理所当然的东西居然是错误的,或者是有偏差的。
对于系统软件也是如此,不了解ELF文件的结构细节就像学习了TCP/IP网络没有了解IP包头的结构一
样。本节后面的内容就是以ELF目标文件格式作为例子,彻底深入剖析目标文件,争取不放过任何一个
字节。真正了不起的程序员对自己的程序的每一个字节都了如指掌。
我们就以前面提到过的SimpleSection.c编译出来的目标文件作为分析对象,这个程序是经过精心
挑选的,具有一定的代表性而又不至于过于繁琐和复杂。在接下来所进行的一系列编译、链接和相关的
实验过程中,我们将会用到第1章所提到过的工具套件,比如GCC编译器、binutils等工.具,如果你忘
了这些工具怎么使用,那么在阅读过程中可以再回去参考本书第Ⅰ部分的内容。图3-1中的程序代码如清
单3-1所示。
清单3-1
/*
* SimpleSection.c
*
* Linux:
* gcc -c SimpleSection. c
*
* Windows:
* cl simplesection.c /c /Za
*
*/
#include <stdio.h>
int globa1_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 0;
}
如不加说明,则以下所分析的都是32位Intel x86平台下的ELF文件格式。
我们使用GCC来编译这个文件(参数-c表示只编译不链接):
$ gcc -c -m32 SimpleSection.c
$ gcc -c SimpleSection.c
我们得到了一个1104字节(该文件大小可能会因为编译器版本以及机器平台不同而变化)的SimpleSection.o目标文件。我们可以使用binutils的工具objdump来查看object内部的结构,
这个工具在第1部分已经介绍过了,它可以用来查看各种目标文件的结构和内容。运行以下命令:
$objdump -h SimpleSection.o
[muten@master C]$ objdump -h SimpleSection.o
SimpleSection.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000054 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 00000000 00000000 00000088 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 00000000 00000000 00000090 2**2
ALLOC
3 .rodata 00000004 00000000 00000000 00000090 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002e 00000000 00000000 00000094 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 00000000 00000000 000000c2 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 00000000 00000000 000000c4 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
$ objdump -h SimpleSection.o
SimpleSection.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000005b 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 00000000 00000000 00000090 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 00000000 00000000 00000098 2**2
ALLOC
3 .rodata 00000004 00000000 00000000 00000098 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002e 00000000 00000000 0000009c 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 00000000 00000000 000000c6 2**0
CONTENTS, READONLY
GCC和binutils可被移植到各种平台.上,所以它们支持多种目标文件格式。比如Windows
下的GCC和 binutils支持PE文件格式、Linux版本支持ELF格式。Linux还有一个很不错的工具
叫readelf,它是专门针对ELF文件格式的解析器,很多时候它对ELF文件的分析可以跟objdump
相互对照,所以我们下面会经常用到这个工具。
参数“-h”就是把ELF文件的各个段的基本信息打印出来。我们也可以使用“objdump-x”把更
多的信息打印出来,但是“-x”输出的这些信息又多又复杂,对于不熟悉ELF和objdump 的读者来
说可能会很陌生。我们还是先把ELF段的结构分析清楚。从上面的结果来看,SimpleSection.o
的段的数量比我们想象中的要多,除了最基本的代码段、数据段和BSS段以外,还有3个段分别是
只读数据段(.rodata)、注释信息段(.comment)和堆栈提示段(.note.GNU-stack),这3个额外
的段的意义我们暂且不去细究。
先来看看几个重要的段的属性,其中最容易理解的是段的长度(Size)和段所在的位置
(File Offset),每个段的第⒉行中的“CONTENTS"、“ALLOC”等表示段的各种属性,“CONTENTS”
表示该段在文件中存在。我们可以看到BSS段没有“CONTENTS",表示它实际上在ELF文件中不存在内
容。".note.GNU-stack”段虽然有“CONTENTS",但它的长度为0,这是个很古怪的段,我们暂且忽
略它,认为它在ELF文件中也不存在。那么ELF文件中实际存在的也就是".text"、“.data”、".rodata”
和“.comment”这4个段了,它们的长度和在文件中的偏移位置我们已经用粗体表示出来了。它们在ELF
中的结构如图3-3所示。
了解了这几个段在SimpleSection.o的基本分布,接着将逐个来看这几个段,看看它们
包含了什么内容。
有一个专门的命令叫做“size”,它可以用来查看ELF文件的代码段、数据段和BSS段的
长度(dec表示3个段长度的和的十进制,hex表示长度和的十六进制):
size simp1esection.o
text data bss dec hex fi1ename
95 8 4 107 6b SimpleSection.o
为什么这里的text是95呢?bjdump -h SimpleSection.o中得到的.text的长度是是91(0x5b),
这两者个text的长度有什么关系呢?
3.1 代码段
挖掘各个段的内容,我们还是离不开objdump这个利器。objdump的“-s”参数可以将所有段的内容
以十六进制的方式打印出来,“-d”参数可以将所有包含指令的段反汇编。我们将objdump输出中关
于代码段的内容提取出来,分析一下关于代码段的内容(省略号表示略去无关内容):
“Contents of section .text”就是.text的数据以十六进制方式打印出来的内容,总共Ox5b
字节,跟前面我们了解到的“.text”段长度相符合,最左面一列是偏移量,中间4列是十六进制内
容,最右面一列是.text 段的ASCII码形式。对照下面的反汇编结果,可以很明显地看到,.text
段里所包含的正是SimpleSection.c里两个函数func1()和 main()的指令。.text 段的第一个字
节“Ox55”就是“func1()”函数的第一条“push %ebp”指令,而最后一个字节Oxc3正是main()函数
的最后一条指令“ret”。
3.2 数据段和只读数据段
.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。前面的SimpleSection.c
代码里面一共有两个这样的变量,分别是global_init_varabal 与static_var。这两个变量每个4
个字节,一共刚好8个字节,所以“.data”这个段的大小为8个字节。
SimpleSection.c里面我们在调用“printf”的时候,用到了一个字符串常量“%d\n",它是一种
只读数据,所以它被放到了“.rodata”段,我们可以从输出结果看到“.rodata”这个段的4个字节刚好
是这个字符串常量的ASCII字节序,最后以\0结尾。
".rodata”段存放的是只读数据,一般是程序里面的只读变量(如 const修饰的变量)和字符串
常量。单独设立“.rodata”段有很多好处,不光是在语义上支持了C++的const关键字,而且操作系
统在加载的时候可以将“.rodata”段的属性映射成只读,这样对于这个段的任何修改操作都会作为非
法操作处理,保证了程序的安全性。另外在某些嵌入式平台下,有些存储区域是采用只读存储器的,
如ROM,这样将“.rodata”段放在该存储区域中就可以保证程序访问存储器的正确性。
另外值得一提的是,有时候编译器会把字符串常量放到“.data”段,而不会单独放在“.rodata”段。
有兴趣的读者可以试着把SimpleSection.c的文件名改成SimpleSection.cpp,然后用各种MSVC编译器
编译一下看看字符串常量的存放情况。
$objdump -x -s -d simplesection.o
我们看到“.data”段里的前4个字节,从低到高分别为0x54、Ox00、Ox00、Ox00。这
个值刚好是 global_init_varabal,即十进制的84。global_init_varabal是个4
字节长度的 int类型,为什么存放的次序为0x54、Ox00、Ox00、Ox00而不是Ox0O、
0x00、Ox00、0x54?这涉及CPU的字节序(Byte Order)的问题,也就是所谓的大端
(Big-endian)和小端(Little-endian)的问题。关于字节序的问题本书的附录有
详细的介绍。而最后4个字节刚好是static_init_var的值,即85。
3.3 BSS段
.bss段存放的是未初始化的全局变量和局部静态变量,如上述代码中 global_uninit_var
和static_var2就是被存放在.bss段,其实更准确的说法是.bss 段为它们预留了空间。但是我们
可以看到该段的大小只有4个字节,这与global_uninit_var和static_var2的大小的8个字节不符。
其实我们可以通过符号表(Symbol Table)(后面章节介绍符号表)看到,只有static_var2
被存放在.了.bss段,而global_uninit_var却没有被存放在任何段,只是一个未定义的“COMMON符号”。
这其实是跟不同的语言与不同的编译器实现有关,有些编译器会将全局的未初始化变量存放在目标文件
.bss段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在
.bss段分配空间。我们将在“弱符号与强符号”和“COMMON块”这两个章节深入分析这个问题。原则上讲,
我们可以简单地把它当作全局未初始化变量存放在.bss段。值得提的是编译单元内部可见的静态变量
(比如给global_uninit_var 加上 static修饰)的确是存放在.bss段的,这一点很容易理解。
$objdump -x -s -d SimpleSection.o
Quiz变量存放位置
现在让我们来做一个小的测试,请看以下代码:
static int x1 = 0;
static int x2 = 1;
x1和x2会被放在什么段中呢?
x1会被放在.bss 中,x2会被放在.data中。为什么一个在.bss段,一个在.data段?因为xl为0,
可以认为是未初始化的,因为未初始化的都是0,所以被优化掉了可以放在.bss,这样可以节省
磁盘空间,因为.bss不占磁盘空间。另外一个变量x2初始化值为1,是初始化的,所以放在.data
段中。
注意:
这种类似的编译器的优化会对我们分析系统软件背后的机制带来很多障碍,使得很多问题不
能一目了然,本书将尽量避开这些优化过程,还原机制和原理本身。
3.4 其他段
除了.text、.data、.bss这3个最常用的段之外,ELF文件也有可能包含其他的段,用来保存
与程序相关的其他信息。表3-2中列举了ELF的一些常见的段。
这些段的名字都是由“.”作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些
非系统保留的名字作为段名。比如我们可以在ELF文件中插入一个“music”的段,里面存放了一首MP3
音乐,当ELF文件运行起来以后可以读取这个段播放这首MP3.但是应用程序自定义的段名不能使用“.”
作为前缀,否则容易跟系统保留段名冲突。一个ELF文件也可以拥有几个相同段名的段,比如一个ELF
文件中可能有两个或两个以上叫做“.text”的段。还有一些保留的段名是因为ELF文件历史遗留问题
造成的,以前用过的一些名字:
如sdata、.tdesc、.sbss、.lit4、.lit8、.reginfo、.gptab、.liblist、.conflict。
可以不用理会这些段,它们已经被遗弃了。
Q:如果我们要将一个二进制文件,比如图片、MP3音乐、词典一类的东西作为目标文件中的一个段,
该怎么做?
A:可以使用objcopy 工具,比如我们有一个图片文件“image.jpg”,大小为0x82100字节。
$ objcopy -I binary -o elf32-i386 -B i386 image.jpg image.o
$ objdump -ht image.o
符号“_binary_image_jpg._start”、“_binary_image_jpg_end”和“_binary_image_jpg_size"
分别表示该图片文件在内存中的起始地址、结束地址和大小,我们可以在程序里面直接声明并使用它
们。
3.5 自定义段
正常情况下,GCC 编译出来的目标文件中,代码会被放到“.text”段,全局变量和静态变量
会被放到“.data”和“.bss”段,正如我们前面所分析的。但是有时候你可能希望变量或某些部分
代码能够放到你所指定的段中去,以实现某些特定的功能。比如为了满足某些硬件的内存和IO的
地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异
常等。GCC提供了一个扩展机制,使得程序员可以指定变量所处的段:
_attribute__((section ("FOO"))) int global = 42;
_attribute__((section ("BAR"))) void foo ()
{
}
我们在全局变量或函数之前加上“_attribute__((section("name")))”属性就可以把相应
的变量或函数放到以“name”作为段名的段中。
4.ELF文件结构描述
我们已经通过SimpleSection.o的结构大致了解了ELF文件的轮廓,接着就来看看ELF
文件的结构格式。图3-4描述的是ELF目标文件的总体结构,我们省去了ELF一些繁琐的结构,
把最重要的结构提取出来,形成了如图3-4所示的ELF文件基本结构图,随着我们讨论的展开,
ELF文件结构会在这个基本结构之上慢慢变得复杂起来。
ELF目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性,
比如ELF文件版本、目标机器型号、程序入口地址等。紧接着是ELF文件各个段。其中ELF文件中与
段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,
比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。接着将详细分析ELF
文件头、段表等ELF关键的结构。另外还会介绍一些ELF中辅助的结构,比如字符串表、符号表等,
这些结构我们在本节只是简单介绍一下,到相关章节中再详细展开。
4.1 文件头
我们可以用readelf命令来详细查看ELF文件,代码如清单3-2所示。
清单3-2查看ELF文件头
==================================================================
readelf -h SimpleSection.o
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 280 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 40 (bytes)
Number of section headers: 11
Section header string table index: 8
==================================================================
从上面输出的结果可以看到,ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、
版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、
段表的位置和长度及段的数量等。这些数值中有关描述ELF目标平台的部分,与我们常见的32位0Intel的
硬件平台基本上一样。
ELF文件头结构及相关常数被定义在“/usrlinclude/elf.h”里,因为ELF文件在各种平台下都通用,
ELF文件有32位版本和64位版本。它的文件头结构也有这两种版本,分别叫做“Elf32_Ehdr”
和“EIf64_Ehdr”。32位版本与64位版本的ELF文件的文件头内容是一样的,只不过有些成员的大小
不―样。为了对每个成员的大小做出明确的规定以便于在不同的编译环境下都拥有相同的字段长度,
“elf.h”使用typedef定义了一套自己的变量体系,如表3-3所示。
我们这里以32位版本的文件头结构“Elf32_Ehdr”作为例子来描述,它的定义如下:
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
让我们拿ELF文件头结构跟前面readelf输出的ELF文件头信息相比照,可以看到输出的信息与
ELF文件头中的结构很多都一一对应。有点例外的是“Elf32_Ehdr”中的e_ident这个成员对应了
readelf输出结果中的“Class”,"Data”,"Version","OS/ABI”和"ABl Version"这5个参数。
剩下的参数与“Elf32_Ehdr”中的成员都一一对应。我们在表3-4中简单地列举一下,让大家有个
初步的印象,详细的定义可以在ELF标准文档里面找到。表3-4是ELF文件头中各个成员的含义与
readelf输出结果的对照表。
这些字段的相关常量都定义在“elf.h”里面,我们在表3-5中会列举一些常见的常量,
完整的常量定义请参考“elf.h”。
ELF魔数﹐我们可以从前面readelf的输出看到,最前面的“Magic”的16个字节刚好对
应“EIf32_Ehdr”的e_ident这个成员。这16个字节被ELF标准规定用来标识ELF文件的平台
属性,比如这个ELF字长(32位/64位)、字节序、ELF文件版本,如图3-5所示。
最开始的4个字节是所有ELF文件都必须相同的标识码,分别为Ox7F、Ox45、Ox4c、Ox46,
第一个字节对应ASCII字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCII码。这
4个字节又被称为ELF文件的魔数,几乎所有的可执行文件格式的最开始的几个字节都是魔数。
比如a.out格式最开始两个字节为Ox01、Ox07;PE/COFF文件最开始两个个字节为Ox4d、Ox5a,
即 ASCII字符MZ。这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数
是否正确,如果不正确会拒绝加载。
接下来的一个字节是用来标识ELF的文件类的,Ox01表示是32位的,Ox02表示是64位的:
第6个字是字节序,规定该ELF文件是大端的还是小端的(见附录:字节序)。第7个字节规定ELF
文件的主版本号,一般是1,因为ELF标准自1.2版以后就再也没有更新了。后面的9个字节ELF标
准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志。
各种魔数的由来
a.out格式的魔数为Ox01、Ox07,为什么会规定这个魔数呢?
UNIX早年是在PDP小型机上诞生的,当时的系统在加载一个可执行文件后直接从文件的第一个字节
开始执行,人们一般在文件的最开始放置一条跳转(jump)指令,这条指令负责跳过接下来的7个机
器字的文件头到可执行文件的真正入口。而Ox01 0x07这两个字节刚好是当时PDP-11的机器的跳转
7个机器字的指令。为了跟以前的系统保持兼容性,这条跳转指令被当作魔数一直被保留到了几十年
后的今天。
计算机系统中有很多怪异的设计背后有着很有趣的历史和传统,了解它们的由来可以让我们了解到
很多很有意思的事情。这让我想起了经济学里面所谓的“路径依赖",其中一个很有意思的叫“马屁
股决定航天飞机”的故事在网上流传很广泛,有兴趣的话你可以在google 以“马屁股”和“航天飞机”
作为关键字搜索一下。
ELF文件标准历史
20世纪90年代,一些厂商联合成立了一个委员会,起草并发布了一个ELF文件格式标准供公开使用,
并且希望所有人能够遵循这项标准并且从中获益。1993年,委员会发布了ELF文件标准。当时参与
该委员会的有来自于编译器的厂商,如 Watcom 和Borland;来自CPU的厂商如IBM和Intel;来自操
作系统的厂商如IBM和 Microsoft。1995年,委员会发布了ELF 1.2标准,自此委员会完成了自己
的使命,不久就解散了。所以ELF文件格式标准的最新版本为1.2。
文件类型
e_type成员表示ELF文件类型,即前面提到过的3种ELF文件类型,每个文件类型对应一个常量。
系统通过这个常量来判断ELF 的真正文件类型,而不是通过文件的扩展名。相关常量以“ET”开头,
如表3-5所示。
表3-5
常量 值 含义
ET_REL 1 可重定位文件,一般为.o文件
ET_EXEC 2 可执行文件
ET_DYN 3 共享目标文件,一般为.so文件
机器类型
ELF文件格式被设计成可以在多个平台下使用。这并不表示同一个ELF文件可以在不同的平台
下使用(就像java的字节码文件那样),而是表示不同平台下的ELF文件都遵循同一套ELF标准。
e_machine 成员就表示该ELF文件的平台属性,比如3表示该ELF文件只能在Intel x86机器下
使用,这也是我们最常见的情况。相关的常量以“EM_”开头,如表3-6所示。
表3-6
常量 值 含义
EM_M32 1 AT&TWE 32100
EM_SPARC 2 SPARC
EM_386 3 Intel x86
EM_68K 4 Motorola 68000
EM_88K 5 Motorola 88000
EM_860 6 Intel 80860
4.2 段表
我们知道ELF文件中有很多各种各样的段,这个段表(Section Header Table)就是保存这些段的
基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如
每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。也就是说,ELF文件的段结构就
是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。段表在ELF文件
中的位置由ELF文件头的“e_shoff”成员决定,比如SimpleSection.o中,段表位于偏移Ox118(10进制
对应280)。
前文中我们使用了“objudmp -h”来查看ELF文件中包含的段,结果是SimpleSection里面看到了总
共有6个段,分别是“.code”、“".data"、“".bss”、“".rodata”、".comment”和".note.GNU-stack”。
实际上的情况却有所不同,“objdump -h”命令只是把ELF文件中关键的段显示了出来,而省略了其他的辅
助性的段,比如:符号表、字符串表、段名字符串表、重定位表等。我们可以使用readelf工具来查看ELF
文件的段,它显示出来的结果才是真正的段表结构:
在我的Fedora上面输出的情况:
[muten@localhost Compiler]$ readelf -S SimpleSection.o
There are 11 section headers, starting at offset 0x114:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000052 00 AX 0 0 4
[ 2] .rel.text REL 00000000 000424 000028 08 9 1 4
[ 3] .data PROGBITS 00000000 000088 000008 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000090 000004 00 WA 0 0 4
[ 5] .rodata PROGBITS 00000000 000090 000004 00 A 0 0 1
[ 6] .comment PROGBITS 00000000 000094 00002e 01 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 00000000 0000c2 000000 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 0000c2 000051 00 0 0 1
[ 9] .symtab SYMTAB 00000000 0002cc 0000f0 10 10 10 4
[10] .strtab STRTAB 00000000 0003bc 000066 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
readelf输出的结果就是ELF文件段表的内容,那么就让我们对照这个输出来看看段表的结构。段表
的结构比较简单,它是一个以“E1f32_Shdr”结构体为元素的数组。数组元素的个数等于段的个数,每个
“EIf32_Shdr”结构体对应一个段。“EIf32_Shdr”又被称为段描述符(Section Descriptor)。对于
SimpleSection.o来说,段表就是有11个元素的数组。ELF段表的这个数组的第一个元素是无效的段描
述符,它的类型为“NULL",除此之外每个段描述符都对应一个段。也就是说SimpleSection.o共有10个
有效的段。
数组的存放方式
ELF文件里面很多地方采用了这种与段表类似的数组方式保存。一般定义一个固定长度的结构,然后依
次存放。这样我们就可以使用下标来引用某个结构。
清单3-3 Elf32_Shdr段描述符结构
typedef struct
{
Elf32_Word sh_name; /* Section name (string tbl index) */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section virtual addr at execution */
Elf32_Off sh_offset; /* Section file offset */
Elf32_Word sh_size; /* Section size in bytes */
Elf32_Word sh_link; /* Link to another section */
Elf32_Word sh_info; /* Additional section information */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Entry size if section holds table */
} Elf32_Shdr;
Elf32_Shdr 的各个成员的含义如表3-7所示。
表3-7 | |
sh_name | Section name 段名,段名是个字符串,它位于一个叫做 “.shstrtab”的字符串表。sh_name是段名字符串在 “.shstrtab”中的偏移 |
sh_type | Section type 段的类型,见后文 |
sh_flags | Section type 段的标志位,见后文 |
sh_addr | Section Address 段虚拟地址, 如果该段可以被加载, 则sh_addr为该段被加载后在进程地址空间中的虚拟地址; 否则sh _addr 为0 |
sh_offset | Section offset,如果该段存在于文件中,则表示该段在文件中的偏移;否则无意义。比如sh_offset对于BSS段来说就没有意义 |
sh_size | Section Size段的长度 |
sh_link | Section Link and Section Information段链接信息详见后文 “段的链接信息” |
sh_info | |
sh_addralign | Section Address Alignment段地址对齐 有些段对段地址对齐有要求,比如我们假设有个段刚开始的位置包含了一个double变量,因为Intel x86系统要求浮点数的存储地址必须是本身的整数倍,也就是说保存double变量的地址必须是8字节的整数倍。这样对一个段来说,它的sh_addr必须是8的整数倍。 由于地址对齐的数量都是2的指数倍,sh_addralign表示是地址对齐数量中的指数,即sh_addrlign =3表示对齐为2的3次方倍,即8倍,依此类推。所以一个段的地址 sh_addr必须满足下面的条件,即sh__addr% (2**sh _addralign)= 0。**表示指数运算。 如果sh_addralign为0或1,则表示该段没有对齐要求. |
sh_entsize | Section Entry Size项的长度 有些段包含了一些固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的。对于这种段,sh_entsize表示每个项的大小。如果为0,则表示该段不包含固定大小的项 |
让我们对照Elf32_Shdr和“readelf -S”的输出结果,可以很明显看到,结构体的每一个成员对
应于输出结果中从第二列“Name”开始的每一列。于是SimpleSection 的段表的位置如图3-6所示。
到了这一步,我们才彻彻底底把SimpleSection 的所有段的位置和长度给分析清楚了。在图3-6中,
SectionTable长度为Ox1b8,也就是440个字节,它包含了11个段描述符,每个段描述符为40个字节,
这个长度刚好等于sizeof(Elf32_Shdr),符合段描述符的结构体长度;整个文件最后一个段“.rel.text”
结束后,长度为Ox450,即 1104字节,即刚好是SimpleSection.o的文件长度。中间Section Table和
“.rel.text”都因为对齐的原因,与前面的段之间分别有一个字节和两个字节的间隔。
段的类型(sh_type)
正如前面所说的,段的名字只是在链接和编译过程中有意义,但它不能真正地表示段的类型。
我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是
段的类型(sh_type)和段的标志位(sh_flags)。段的类型相关常量以SHT_开头,列举如表
3-8所示。
表3-8 | ||
常量 | 值 | 含义 |
SHT_NULL | 0 | 无效段 |
SHT_PROGBITS | 1 | 程序段。代码段、数据段都是这种类型的 |
SHT_SYMTAB | 2 | 表示该段的内容为符号表 |
SHT_STRTAB | 3 | 表示该段的内容为字符串表 |
SHT_RELA | 4 | 重定位表。该段包含了重定位信息,具体参考“静态地址决议和重定位”这一节 |
SHT_HASH | 5 | 符号表的哈希表。见“符号表”这一节 |
SHT_DYNAMIC | 6 | 动态链接信息具体见“动态链接”一章 |
SHT_NOTE | 7 | 提示性信息 |
SHT_NOBITS | 8 | 表示该段在文件中没内容,比如.bss段 |
SHT_REL | 9 | 该段包含了重定位信息,具体参考“静态地址决议和重定位”这一节 |
SHT_SHLIB | 10 | 保留 |
SHT_DNYSYM | 11 | 动态链接的符号表。具体见“动态链接”一章 |
段的标志位(sh_flag)段的标志位表示该段在进程虚拟地址空间中的属性,比如是否可写,是否
可执行等。相关常量以SHF_开头,如表3-9所示。
表3-9 段的标志位的常量及含义 | ||
常量 | 值 | 含义 |
SHT_WRITE | 1 | 表示该段在进程空间中可写 |
SHT_ALLOC | 2 | 表示该段在进程空间中须要分配空间。有些包含指示或控制信息的段 不须要在进程空间中被分配空间,它们一般不会有这个标志。像代码段、数据段和.bss段都会有这个标志位 |
SHF_EXECINSTR | 4 | 表示该段在进程空间中可以被执行,一般指代码段 |
对于系统保留段,表3-10列举了它们的属性。
表3-10 系统保留段的属性 | ||
Name | sh_type | sh_flag |
.bss | SHT_NOBFTS | SHF_ALLOC + SHF_WRITE |
.comment | SHT_PROGBITS | none |
.data | SHT_PROGBITS | SHF_ALLOC + SHF_WRITE |
.data1 | SHT_PROGBITS | SHF_ALLOC + SHF_WRITE |
.debug | SHT_PROGBITS | none |
.dynamic | SHT_DYNAMIC | SHF_ALLOC + SHF_WRITE,在有些系统下.dynamic 段可能是只读的,所以没有SHF_WRITE标志位 |
.hash | SHT_HASH | SHF_ALLOC |
.line | SHT_PROGBITS | none |
.note | SHT_NOTE | none |
.rodata | SHT_PROGBITS | SHF_ALLOC |
.rodata1 | SHT_PROGBITS | SHF_ALLOC |
.shstrtab | SHT_STRTAB | none |
.strtab | SHT_STRTAB | 如果该ELF文件中有可装载的段须要用到该字符串表,那么该字符串表也将被装载到进程空间,则有 SHF_ALLOC标志位 |
.symtab | SHT_SYMTAB | 同字符串表 |
.txt | SHT_PROGBITS | SHF_ALLOC+ SHF_EXECINSTR |
段的链接信息(sh_link、sh_info)如果段的类型是与链接相关的(不论是动态链接或静态链接),
比如重定位表、符号表等,那么 sh_Jlink 和 sh_info这两个成员所包含的意义如表3-11所示。
对于其他类型的段,这两个成员没有意义。
表3-11 段的连接信息 | ||
sh_type | sh_link | sh_info |
SHT_DYNAMIC | 该段所使用的字符串表在段表中的下标 | 0 |
SHT_HASH | 该段所使用的符号表在段表中的下标 | 0 |
SHT_REL | 该段所使用的相应符号表在段表中 的下标 | 该重定位表所作用的段在段表中的下标 |
SHT_RELA | ||
SHT_SYMTAB | 操作系统相关的 | 操作系统相关的 |
SHT_DYNSYM | ||
other | SHN_UNDEF | 0 |
4.3 重定位表
我们注意到,SimpleSection.o中有一个叫做“.rel.text”的段,它的类型( sh_type)为
“SHT_REL”,也就是说它是一个重定位表(Relocation Table)。正如我们最开始所说的,链接器
在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的
引用的位置。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个须要重定位的代码段或
数据段,都会有一个相应的重定位表。比如 SimpleSection.o 中的“.rel.text”就是针对“.text”
段的重定位表,因为“.text”段中至少有一个绝对地址的引用,那就是对“printf”函数的调用; 而
“.data”段则没有对绝对地址的引用,它只包含了几个常量,所以SimpleSection.o中没有针对“.data”
段的重定位表“.rel.data”。
一个重定位表同时也是ELF的一个段,那么这个段的类型(sh_type)就是“SHT_REL"类型的,它
的“sh_link”表示符号表的下标,它的“* sh_info"表示它作用于哪个段。比如".rel.text"作用
于“.text”段,而“.text”段的下标为“1”,那么“.rel.text”的“sh_info”为“1”。
关于重定位表的内部结构我们在这里先不展开了,在下一章分析静态链接过程的时候,我们还会
详细地分析重定位表的结构。
4.4 字符串表
ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固
定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在
表中的偏移来引用字符串。比如表3-12这个字符串表。
偏移与它们对应的字符串如表3-13所示。
通过这种方法,在ELF文件中引用字符串只须给出一个数字下标即可,不用考虑字符串长度的问题。
一般字符串表在ELF文件中也以段的形式保存,常见的段名为“.strtab”或".shstrtab”。这两个字符串
表分别为字符串表(String Table)和段表字符串表(SectionHeader String Table)。顾名思义,
字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见
的就是段名(sh_name)。
接着我们再回头看这个ELF文件头中的“e_shstrndx”的含义,我们在前面提到过,“e_shstrndx”
是Elf32_Ehdr的最后一个成员,它是“Section header string table index”的缩写。我们知道段
表字符串表本身也是ELF文件中的一个普通的段,知道它的名字往往叫做“.shstrtab”。那么这个
“e_shstrndx”就表示“.shstrtab”在段表中的下标,即段表字符串表在段表中的下标。前面的
SimpleSection.o中, "e_shstrndx”的值为8,我们再对照“readelf -S”的输出结果,可以看到
“.shstrtab”这个段刚好位于段表中的下标为8的位置上。由此,我们可以得出结论,只要分析ELF
文件头,就可以得到段表和段表字符串表的位置,从而解析整个ELF文件。
表3-12 | ||||||||||
偏移 | +0 | +1 | +2 | +3 | +4 | +5 | +6 | +7 | +8 | +9 |
+0 | \0 | h | e | l | l | o | w | o | r | l |
+10 | d | \0 | M | y | v | a | r | i | a | b |
+20 | l | e | \0 |
表3-13 | |
偏移 | 字符串 |
0 | 空字符串 |
1 | hello |
6 | world |
12 | Myvariable |