在上一篇中你知道Hello World背后发生了什么吗, 我们从向屏幕打印一个hello,World入手,看了一下整个的执行过程,发现一个简单的hello world,背后竟然藏着这么多的细节,以后,我们要将它的细节一步步呈现出来。
我们在上一篇的最后中说到,要深入讲一下链接,链接是非常重要,它是我们的程序和操作系统关联起来的步骤,对于了解底层的运行原理也是必须的一步,说到链接,从字面上看上去是不是很像用个链条把一些东西串起来,既然想要链接,我们必须有材料,才能够进行链接,这里的材料就是目标文件,什么是目标文件呢?也就是源代码经过编译之后,进行链接之前的的中间代码,了解目标文件中的内容,是我们了解链接的一个非常重要的基础,废话不多说,下面来看看目标文件长什么样子。
不得不说的ELF
先问大伙一个问题:linux上的程序在windows下可以运行吗?
这个问题其实在上一篇说到编译过程的生成中间代码的过程时,提到了要针对目标机器来生成目标代码,也就是最终生成的可执行文件是和机器相关的,linux和windows是两种不同的操作系统,需要的可执行文件的格式也不同,所以最后的结论就是:因为linux和windows的可执行文件的格式不同,所以linux上的程序不能在windows上运行。linux上的可执行文件格式是ELF, windows上的可执行文件格式是PE,我们以Linux上的ELF来重点分析一下
下面来看ELF格式的分类:
从上面我们可以看到,我们的目标文件也就是.o文件,也是采用的是ELF文件格式,那么ELF文件格式里面究竟是什么样子呢? 你是不是特别好奇~,来看看这段代码的目标文件内容吧
int printf(const char* format, ...);
int global_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);
}
通过gcc -c hello.c来生成这段代码的一个目标代码
可以通过一些命令来查看这个目标代码里面的东西
比如可以通过file hello.o查看我们的文件的格式是可重定位文件?还是可执行文件或者共享目标文件。可以自己去尝试一下,下面看看上述目标文件的全貌吧
上述就是一个elf文件的全貌了(这个图肝了一天, emmm…), 图中0x开头的表示16进制,在文件中的偏移地址,没有以0x开头的表示十进制的大小,单位是字节,ELF Header的大小就是52个字节,其他的十进制数字同理。
总的来看一个ELF格式的文件包含的内容分为两部分:header + 段表,这里段表指的是上述440字节部分, header下面都是段表以及段表对应描述的段,段表和段的关系就是:段表是一个数组,数组里面每个元素对应一个段,所以段表的长度是多少,就代表有多少个段,拿上述文件来举例,段表共有11个元素,那么一共有11个段,每个元素占40个字节,所以整个段表占用440个字节。
ELF Header
ELF Header定义了一些硬件平台相关的属性和段表相关的属性,我们说一些重要的
魔数
几乎所有的可执行文件格式的最开始的几个字节都是魔数,魔数的作用就是用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确就会拒绝加载。
最开始的4个字节被称为魔数,所有ELF文件都必须相同,分别是0x7F、0X45、0x4c、0x46,其中0x7F对应的ASCLL字符里面的DEL控制符,最后3个字节是ELF这三个字符的ASCLL码
Class
0x01表示机器是32位,0x02表示机器是64位
数据存储方式
数据存储方式指的是ELF的字节序,也就是大端序还是小端序,0x01表示小端,0x02表示大端
在这里解释一下什么是字节序
内存是以字节为单位读写的,如果定义一个int类型的数,要占用4个字节,那么多个字节表示一个数,在内存中应该怎么存放呢,比如0x1234,在内存中怎么存放呢?数值中的高位12是放在内存的低地址处还是高地址处呢?于是就产生 了两种排列顺序:
小端:数值的低字节放在内存的低地址处,数值的高字节放在内存的高地址处
大端:数值的低字节放在内存的高地址处,数值的高字节放在内存的低地址处
ELF文件的版本号
一般是1
段表和段
在header的e_shoff字段中存储的是段表的偏移地址,通过这个字段我们就可以找到段表了
.text
我们的程序经过编译之后可分为两个部分,一个是指令,另一个是数据,拿上述程序举例:
我们的指令就是存储在.text段,
.data
这个段存放的就是我们的数据,但是并不是上面的所有数据都存在这个段中,这个段里面存放的是已经初始化的全局变量和静态局部变量,也就是说存放的是global_init_var 和 static_var,那么没有初始化的全局变量和静态局部变量在哪里存放呢?
.bss
未初始化的全局变量和局部静态变量存放在.bss段中,未初始化的全局变量和局部变量初始值都是0,也可以存放在data段,但是因为都是0,所以在data段分配空间并且存放数据0没有必要,但程序运行的时候确实要占用内存空间,所以就有了bss段,bss段只是为未初始化的全局变量和局部静态变量预留位置,并没有内容,所有未初始化的全局变量和局部静态变量大小总和由可执行文件来记录,由于bss段并没有内容,所以在文件中并不占据空间,我们上面在说到,段表有多少个元素就有多少个段,但是有些段在图中并没有显示,那就说明这些段并不占据空间。
有些编译器将全局未初始化变量存放在目标文件.bss段,有些不存放,预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间
有个问题:为什么要把程序中的指令和数据分开存放?好处有很多
- 程序被加载到内存之后,数据和指令分别映射到两个区域,数据是对于进程来说可读可写,指令对于进程只读,可以把这两个区域权限设置成可读和可读可写,防止程序的指令被改写
- 现代cpu缓存一般都被设计成数据缓存和指令缓存分离,锁以分开存放对cpu缓存命令率提高有好处
- 可以节省内存,指令是只读的,所以当程序中运行多个该程序的副本的时候,内存中只需保存一份该程序的指令的部分
.rodata
只读数据段,存放的是程序中的只读变量和字符串常量,比如const修饰的变量,上述中的"%d",就被存放在.rodata段中,有时候编译器会把字符串常量放在.data段
可以通过以下命令,来查看主要段的内容
$ objdump -x -s -d hello.o
.comment
存放的是编译器的版本信息
.rel.text
它是一个重定位表,链接器在处理目标文件的时候,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些绝对地址的引用的位置,这些重定位信息都记录在重定位表中,对于每个需要重定位的代码或数据段,都会有一个相应的重定位表,比如.rel.text就是对.text段的重定位表,因为.text段中至少有一个绝对地址的引用,就是对printf函数的调用,.data段没有绝对地址引用,只包含了几个常量,所以没有.rel.data重定位表。
.strtab
字符串表,用来保存普通的字符串,比如符号的名字,符号名字就是说的函数和变量的名字
.shstrtab
段表字符串表,保存段表中用到的字符串,最常见的就是段名
在ELF Header的最后一个字段,它的值就是8,表示段表字符串表在段表中的下标是8
从上可以看出,只要分析ELF 表头,就可以得到段表和段字符串表的位置,从而解析整个ELF文件
符号
我们前面说到,在链接中,将函数和变量统称为符号,函数名或变量名就是符号名,每个定义的符号有一个对应的值,这个值就是符号值,对于函数和变量来说,符号值就是他们的地址
符号表中的符号的分类:
- 定义在本目标文件中的全局符号,可以被其他目标文件引用,比如: func1, main, global_init_var
- 在本目标文件中引用的全局符号,却没有定义在本目标文件中,这叫做外部符号,比如printf
- 段名,由编译器产生,它的值就是段的其实地址,比如.text, .data
- 局部符号,比如static_var, static_var2, 局部符号对于链接过程没有作用,链接器往往也忽略它们
- 行号信息,可选的,是目标文件指令和源代码中的代码行的对用关系
对于我们来说,最值的关注的就是全局符号,上面第一类和第二类
这次对ELF的面貌有了个认识,相信这一篇对于我们后续理解程序的加载会特别有帮助,下一篇看看是如何加载的。
有什么问题可以关注我的公众号,一起交流,一起进步:)