最近的lab里面有ELF文件相关的,所以成这个几乎,学点ELF的东西。
ELF,是一种文件格式。暂时,只看可执行文件的ELF文件格式。
首先,给出文件的格式的布局图:
光看这个很难理解,所以写一个小的程序,用readelf来结合的看。
程序比较简单:
#include <stdio.h> #include <stdlib.h> int data[100] ={0}; int bss[100]; int main() { int i=0; for(i=0; i<100; i++) bss[i] = i; printf("the bss[3]= %d\n", bss[3]); return 1; }
首先,通过readelf -h 命令,来看elf头:
首先,第一个magic,魔数,这个主要是程序用来确认读入的是否是elf文件头,其中,第一个7f是默认的,后面的45,4c,46就是ascii码里面的elf相对于的码值,后面的01,没有实际意义。每次程序在读取elf头文件的时候,都会确认魔数是否正确,以防读入的不是elf文件。
接下来的class,Data,Version,OS/ABI, ABI Version type, machine version 都是一些关于机器,系统还有文件版本的一些信息,不是这次的主要内容,看看就好。
接下来的Entry point address 0x8048330 表示程序的入口地址,即程序载入完成后,第一条指令从这个地方开始,从指令上来说,就是在整个程序建立了进程,将相应的虚拟地址映射载入内存后,做完了所有的准备工作之后,将要开始执行程序了,此时,将eip 置为0x8048330这个值。刚开始学C的时候,很多人都认为,之所以要有main函数,是因为他是程序的入口,程序执行的第一条指令就是main函数。如果是这样,那在0x8048330这个位置的函数就应该是main函数了。
通过程序来看,用objdump将程序反汇编:
可以看到,程序的08048330的部分是一个叫-start的一个函数,并不是我们想当然的main。为什么?来看看<_start>函数的主要内容,扫一眼,就发现,这个函数的主要内容是在要存相关的寄存器,后面跳转到一个叫<[email protected]>函数下面去了,也就是说,在程序真正的执行main操作之前,还进行了其他的函数操作,也就是main并不是真正的第一个执行的函数。那在main之前,到底那些函数都干了什么呢?
其实很简单。main函数在开始的时候,里面的变量,直接开始的时候就在栈里,然后一开始就可以直接使用malloc和new等来申请堆空间,那栈和堆的刚开始的设置地址是什么?在main里好像没有设置吧?还有比如stdin,stdout等都没有打开,所以,main前面的_start等函数做的就是这种工作,初始化堆栈信息,并且打开标准输入输出等文件。
简单来说,就是一下内容:
//***********************************************
__start:
init stack;
init heap;
open stdin;
open stdout;
open stderr;
:
push argv;
push argc;
call _main; (调用 main)
:
destory heap;
close stdin;
close stdout;
close stderr;
:
call __exit;
**********************************************/
所以,main只是整个程序的中间函数,并不是程序最开始执行的函数!!!
接下来,Start of program headers: 52 (bytes into file)
Start of section headers: 5120 (bytes into file)
这就是程序表头和Section 表头的地址。这个地址,表示程序头和Section 表头的首地址距离ELF文件头地址的偏移量。
举个例子,程序总是从磁盘读入内存的。假设程序在磁盘的位置是0x1000,那程序头和Section 表头在磁盘的位置就是0x1052和0x6210,,注意,这个是磁盘的地址。
什么是程序表头和Section 表头。通俗的来讲,程序表是一张表,里面有程序需要从磁盘载入内存的所有内容的相关信息。而系统把这些需要载入内存的内容分成了一个一个的块,这些块需要一张表来管理并且记录他们的信息。而这个表就是程序表。相应的,Section 表就是记录每个Section 的相应信息的一张表。程序表和Section 表,在程序中的表示方法,就是两个结构体的数组,所以程序表头和Section 表头就是两个结构体数组的首地址。
下面的flag,应该是标志位什么的,暂时没搞明白。
在下面: Size of this header: 52 (bytes)
这个就表示,这张elf头文件大小事52字节,参考前面的Start of program headers: 52 (bytes into file),程序头的内容在磁盘中也是从elf首字节下面的低52个字节里面,这就表示在磁盘中,elf头表之后,马上就是程序头的内容,和上面的elf的布局图相符合。
Size of program headers: 32 (bytes)
这个表示每个程序头表中,每一项的大小。前面说过,程序头表就是一个结构体的数组,那这个32byte就代表这个结构体的大小。
Number of program headers: 9
这个代表程序头表中,程序头的个数。和上面的程序头表的每一项大小相乘,就是整个程序头表的大小。
Size of section headers: 40 (bytes)
Number of section headers: 36
这两个数据,就是Section 表的数据,和程序头表的数据时一样的。
在磁盘中,这张elf头文件的形式是一个struct, 整个的内容和下面的代码相似:
struct Elf { uint32_t e_magic; // must equal ELF_MAGIC uint16_t e_type; uint16_t e_machine; uint32_t e_version; uint32_t e_entry; uint32_t e_phoff; uint32_t e_shoff; uint32_t e_flags; uint16_t e_ehsize; uint16_t e_phentsize; uint16_t e_phnum; uint16_t e_shentsize; uint16_t e_shnum; uint16_t e_shstrndx; };
这个和上面的readelf得到的结果是一一对应的。
接下来,看program header
这个就是程序头表里面的内容,这些程序头记录着程序需要拷贝到内存上的所有内容,需要把这些内容拷贝到内存上,才能实现程序的运行。这个程序的程序头总共有9项。每一项都有相应的属性,一项一项来看。
可以看到,第一项的type是PHDR,他表示我们要保存这项内容是程序头表。其中,offset表示这项内容保存的起始地址与程序头地址的偏移。即系统要通过这个偏移地址,从磁盘中读取相应的程序头的内容到内存中。有了从磁盘读入的地方,那肯定要有放到内存中的地方。VirtAddr就表示这部分内容需要要放到虚拟内存中的起始地址,后面的PhysAddr就是为了兼容采用实地址模式的系统。后面的FileSize和memSize分别表示程序头在文件中和在内存中的大小。(两个大小可以不一样,但一定是filesize<=memsize).后面的flg就是表示这个程序头的标志位,其中R表示可读,W表示可写,E表示可执行。最后的Align就表示这段程序头的对齐方式。其中0x4就表示4字节对齐,0x1000就表示4K对齐。
下面来验证一下:第一段程序头的type指出了这段程序头里的内容是表示程序头表的。他的起始文件位置是偏离程序起始文件位置0x34个字节的地方。通过计算可以知道,0x34=52,和前面的elf头文件中程序头表的起始位置吻合。后面的filesize是0x120=288byte, 通过elf知道,程序头表里总共有9项,每一项程序头占了32个字节,这样整个程序头的大小就是32*9=288.这个也吻合。接下来通过gdb查看程序在0x8048034的内容。
由于每一项程序头是的大小事32byte=0x20byte,所以上面两行代表一个程序头,可以看到和readelf给出的内容是完全吻合的,也就是在这块地址空间中,存的是相应的程序头表。
接下去的8项程序头,都是一样的,只是type不一样,每种type所代表的含义如下:
PHDR保存程序头表。
INTERP指定在程序已经从可执行映射到内存之后,必须调用解释器。在这里解释器并不意味着二进制文件的内存必须由另一个程序解释。它指的是这样的一个程序:通过链接其他库,来满足未解决的引用。
LOAD表示一个从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串),程序的目标代码等等。
DYNAMIC段保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息。
NOTE保存了专有信息。
仔细观察会发现,在两个属性为load的程序头中,包含了其他7个程序头中的所有段内容。这点,从载入的虚拟内存上的地址范围也可以发现这个问题。所以,在程序载入时,应该只需要载入type为load的两个程序头就可以了,而其他的程序头只是为了方便查找相应的内容的。
在上面的这章程序表的下面,还有每项程序头所包含的段的信息:
可以再里面看到比较熟悉的.text, .data 和.bss段。这就表示,所有程序头其实是程序里面section的一部分。整个程序按照一定的方法,被划分了若干个section,而程序头就是在所有section中,需要被载入到内存的那部分section。从真个程序文件的架构也可以看到,在那里面根本就没有程序头的部分。
接下来:section header
由于可执行文件的段比较多,所以就不全部截出来了。只给出部分的段:
这里主要看text,data,bss三个段。
其中,text表示的是程序的代码段。可以看到,他在内存中的地址是0x8048330,就是整个程序的入口地址。
后面的data和bss段。首先看.bss段,可以看到,bss段下面的comment段在文件中的偏移地址是一样的。这也就是大家常说的,.bss段在文件中是不占大小的。这是因为,bss段代表的是未初始化的全局变量。在C里面,未初始化的全局变量会被初始化为0,因此就不用在文件中给bss分配空间,因为只要变量属于bss段,那他就是0.而bss段中的变量,在内存中的总大小,就可以通过section段表来记录。也就是上面的bss段的size部分。
可以通过上面的程序头部分进行验证。通过上面的程序头中,.data和bss段都是出于程序头的03项,
单独把第三项拉出来:
可以看到,在这一项的程序头中,filesize金额memsize是不一样的,两者相间,0x45c-0x100=0x35c。比.bss段的大小多了c字节大小,这个多出来的c字节的大小不知道怎么回事。我猜想应该和字节对齐有关系。因为这段程序头在内存中的起始位置是0x0849f14, 加上内存中的大小,就是0x084a370。如果没有这个多出来的c字节,那可能下面的内容要实现字节对齐就比较麻烦,所以编译器认为的加了一个c字节上去。
那bss段的变量是怎么被初始化为0的?
在程序被载入内存的时候,只需要从文件拷贝filesize大小的内容进入内存,然后剩下的部分,将其全部以0填充就可以了。
从上面的程序头中包含的段映射可以看到,bss段是被放在他所在的程序头的最下面的,甚至是整个程序在内存空间中最下面的位置。所以通过上面的方法,就可以将bss段所属的变量全部清0,就完成了初始化为0的操作。而变量可以通过符号表来解决,其所在的位置。
符号表(只列出data和bss的信息):
在程序中,两个全局变量:
<span style="white-space:pre"> </span>int data[100] ={0}; <span style="white-space:pre"> </span>int bss[100];
在符号表里面,value应该就是变量的首地址,后面的size就是变量所占的内存大小。由于两个变量都是int型的大小为100的数组,所以大小为400。后面的NDX指出了符号在哪个段,从段表中找段25,可以看到,段25就是bss段。可能是由于我data[0]的值也是0,这样就导致了data和bss两个值都是0,所以编译器优化,就把两个变量都放到了bss段。
重新编一个简单的程序:
#include <stdio.h> #include <stdlib.h> int d=10; int b; int main() { int i=0; printf("the out is %d\n", b+d+i); return 1; }
符号表和段表为:
可以看到,b,d所对应的的段序号正是data和bss段,而d的地址为0x0804a014, data的起始地址为0x0804a00c,size为c,所以d就是存储在data段的后4个字中的大小。而b变量也正好存储在bss段的后4个字节中。
从后来改过的程序可以看到,程序是通过符号表来进行初始化全局变量的的过程和上面的分析是符合的。
总结:
整个程序,在文件中,是通过elf头文件来管理的,elf中记录了程序中所有内容的信息。整个程序,首先分成了n个段,这些段的信息都存储在程序的段表里面。而程序头就是在所有的段中,需要载入内存的段。程序头由程序头表来记录其信息,包括其在文件中的位置和载入的内存的位置,还有其在文件中的大小和在内存中的大小等关键信息。所以在程序运行的时候,首先就是要找到程序头表的位置,把程序头表中所表示的段,根据程序头中的信息,载入到内存中,然后再运行程序。
通过这个elf的学习,搞明白了几个以前一直比较模糊的问题:
bss段为什么在文件中是0字节的
在程序中的全局变量是如何初始化的。
还有以前一直没弄明白bss,data段和内存管理中的分段的那个段到底是不是一个东西,现在对这些都比较明白了,学学这个还是有好处的,可以让一些问题更加清晰。
版权声明:本文为博主原创文章,未经博主允许不得转载。