在讲解elf文件格式之前,我们来回顾一下,一个用c语言编写的高级语言程序是从编写到打包、再到编译执行的基本过程,我们知道在cpu上执行的是低级别的机器语言,从高级语言到低级别的机器语言肯定是要经过翻译过程,这个过程大体的过程如下图所示:
在unix系统中,从源文件到可执行目标文件是由编译驱动程序完成的,如大名鼎鼎的gcc,翻译过程包括图中的是个阶段;
ø 预处理阶段
预处理器(cpp)根据以字符#开头的命令修给原始的c程序,结果得到另一个c程序,通常以.i作为文件扩展名。主要是进行文本替换、宏展开、删除注释这类简单工作。
对应的命令:linux> gcc -e hello.c hello.i
ø 编译阶段
编译器将文本文件hello.i翻译成hello.s,包含相应的汇编语言程序
对应的命令:linux> gcc -s hello.c hello.s
ø 汇编阶段
将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o中(把汇编语言翻译成机器语言的过程)。
把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
对应的命令:linux> gcc -c hello.c hello.o
ø 链接阶段
此时hello程序调用了printf函数。 printf函数存在于一个名为printf.o的单独的预编译目标文件中。 链接器(ld)就负责处理把这个文件并入到hello.o程序中,结果得到hello文件,一个可执行文件。最后可执行文件加载到储存器后由系统负责执行, 函数库一般分为静态库和动态库两种。静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了。其后缀名一般为.a。动态库与之相反,在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以节省系统的开销。动态库一般后缀名为.so,gcc在编译时默认使用动态库。
由上面的过程,我们可以看出在经过汇编器和连接器作用后都会输出一个目标文件,那这两个目标文件有什么样的区别呢?说到这里我们先引入目标文件的形式
2.1 三种目标文件形式
(1)可重定位目标文件:包含二进制代码和数据,其形式可以和其他目标文件进行合并,创建一个可执行目标文件
(2)可执行目标文件:包含二进制代码和数据,可直接被加载器加载执行
(3)共享目标文件:可被动态的加载和链接(本文暂时不讨论)
由此我们可知由汇编器生成的就是可重定位目标文件,经过链接器作用后才生成可执行目标文件,链接器的作用就是以一组可重定位目标文件作为输入,生成可加载和运行的可执行目标文件,具体需要完成以下两个工作:
ø 符号解析:符号解析的目的是将目标文件中每个符号(静态变量、函数、全局变量)和其定义进行关联
ø 重定位:将每个符号的定义与具体在虚拟内存中的位置进行关联
最终生成可执行目标文件
说到这里好像还是没有说清楚这两种目标文件有什么区别,我们还是先把这个问题放一下,相信你看完下一节,应该会有答案,下面我们开始引入目标文件elf文件。
目标文件再不同的系统或平台上具有不同的命名格式,在unix和x86-64 linux上称为elf(executable and linkable format, elf)。
elf文件格式提供了两种不同的视角,在汇编器和链接器看来,elf文件是由section header table描述的一系列section的集合,而执行一个elf文件时,在加载器(loader)看来它是由program header table描述的一系列segment的集合
左边是从汇编器和链接器的视角来看这个文件,开头的elf header描述了体系结构和操作系统等基本信息,并指出section header table和program header table在文件中的什么位置,program header table在汇编和链接过程中没有用到,所以是可有可无的,section header table中保存了所有section的描述信息。右边是从加载器的视角来看这个文件,开头是elf header,program header table中保存了所有segment的描述信息,section header table在加载过程中没有用到,所以是可有可无的。注意section header table和program header table并不是一定要位于文件开头和结尾的,其位置由elf header指出,上图这么画只是为了清晰。
我们在汇编程序中用.section
声明的section会成为目标文件中的section,此外汇编器还会自动添加一些section(比如符号表)。segment是指在程序运行时加载到内存的具有相同属性的区域,由一个或多个section组成,比如有两个section都要求加载到内存后可读可写,就属于同一个segment。有些section只对汇编器和链接器有意义,在运行时用不到,也不需要加载到内存,那么就不属于任何segment。
目标文件需要链接器做进一步处理,所以一定有section header table;可执行文件需要加载运行,所以一定有program header table;而共享库既要加载运行,又要在加载时做动态链接,所以既有section header table又有program header table。
下面用readelf
工具读出目标文件max.o
的elf header和section header table,然后我们逐段分析。
接下来我们来看section header table格式
从section header中读出各section的描述信息,其中.text
和.data
是我们在汇编程序中声明的section,而其它section是汇编器自动添加的。addr
是这些段加载到内存中的地址(我们讲过程序中的地址都是虚拟地址),加载地址要在链接时填写,现在空缺,所以是全0。off
和size
两列指出了各section的文件地址,比如.data
从文件地址0x60开始,一共0x38个字节,回去翻一下程序,.data
中定义了14个4字节的整数,一共是56个字节,也就是0x38个。根据以上信息可以描绘出整个目标文件的布局。
起始文件地址 |
section或header |
0 |
elf header |
0x34 |
.text |
0x60 |
.data |
0x98 |
.bss(此段为空) |
0x98 |
.shstrtab |
0xc8 |
section header table |
0x208 |
.symtab |
0x288 |
.strtab |
0x2b0 |
.rel.text |
这个文件不大,我们直接用hexdump或者使用010 editor
工具把目标文件的字节全部打印出来看。
3.1 .shstrtab
和.strtab
.shstrtab
和.strtab
这两个section中存放的都是ascii码:
可见.shstrtab
中保存着各section的名字,.strtab
中保存着程序中用到的符号的名字。每个名字都是以'\0'结尾的字符串。
我们知道,c语言的全局变量如果在代码中没有初始化,就会在程序加载时用0初始化。这种数据属于.bss
段,在加载时它和.data
段一样都是可读可写的数据,但是在elf文件中.data
段需要占用一部分空间保存初始值,而.bss
段则不需要。也就是说,.bss
段在文件中只占一个section header而没有对应的section,程序加载时.bss
段占多大内存空间在section header中描述。在我们这个例子中没有用到.bss
段,以后我们会看到这样的例子。
3.2.rel.text和
.symtab
我们继续分析readelf
输出的最后一部分,是从.rel.text
和.symtab
这两个section中读出的信息。
.rel.text
告诉链接器指令中的哪些地方需要重定位,我们在下一节讨论。
.symtab
是符号表。ndx
列是每个符号所在的section编号,例如data_items
在第3个section里(也就是.data
),各section的编号见section header table。value
列是每个符号所代表的地址,在目标文件中,符号地址都是相对于该符号所在section的相对地址,比如data_items
位于.data
段的开头,所以地址是0,_start
位于.text
段的开头,所以地址也是0,但是start_loop
和loop_exit
相对于.text
段的地址就不是0了。从bind
这一列可以看出_start
这个符号是global
的,而其它符号是local
的,global
符号是在汇编程序中用.globl
指示声明过的符号。
3.3 .text节
通过使用objdump
工具可以把程序中的机器指令进行反汇编(disassemble),得到其汇编代码
先看可执行文件header的变化
在看section header的变化
.text
和.data
的加载地址分别改成了0x08048074和0x0804 90a0。.bss
段没有用到,所以被删掉了。.rel.text
段就是用于链接过程的,链接完了就没用了,所以也删掉了。
在看多出来的两个program header
多出来的program header table描述了两个segment的信息。.text
段和前面的elfheader、program header table一起组成一个segment(filesiz
指出总长度是0x9e),.data
段组成另一个segment(总长度是0x38)。virtaddr
列指出第一个segment加载到虚拟地址0x0804 8000(注意在x86平台上后面的physaddr
列是没有意义的),第二个segment加载到地址0x0804 90a0。flg
列指出第一个segment的访问权限是可读可执行,第二个segment的访问权限是可读可写。最后一列align
的值0x1000(4k)是x86平台的内存页面大小。在加载时要求文件中的一页对应内存中的一页,对应关系如下图所示。
这个可执行文件很小,总共也不超过一页大小,但是两个segment必须加载到内存中两个不同的页面,因为mmu的权限保护机制是以页为单位的,一个页面只能设置一种权限。此外还规定每个segment在文件页面内偏移多少加载到内存页面仍然偏移多少,比如第二个segment在文件中的偏移是0xa0,在内存页面0x0804 9000中的偏移仍然是0xa0,所以是从0x0804 90a0开始,这样规定是为了简化链接器和加载器的实现。从上图也可以看出.text
段的加载地址应该是0x0804 8074
,也正是_start
符号的地址和程序的入口地址。
原来目标文件符号表中的value
都是相对地址,现在都改成绝对地址了。此外还多了三个符号__bss_start
、_edata
和_end
,这些是在链接过程中添进去的,加载器可以利用这些信息把.bss
段初始化为0。
再看一下反汇编的结果:
到此为止elf文件的问题已介基本介绍,关于共享目标文件的格式和加载过程将在后续补上。